API
Every route the openGym backend serves — passkey auth, state sync,
push, pairing, admin — as one hand-written
OpenAPI spec. The same file lives
in the repository
at api/openapi.yaml, next to the one server file it documents.
OpenAPI 3.1.0 openGym v1.3.9 69 endpoints AGPL-3.0-or-later
Overview
Passkey (WebAuthn) auth + per-user state storage for openGym. One Node file, no framework, JSON-file storage, HMAC-signed session cookies.
/Same origin as the openGym web app (the normal deployment)https://opengym.example.comYour self-hosted instanceAuthentication
Sign-in is by passkey (WebAuthn, via @simplewebauthn/server). A successful register/verify or login/verify sets the session cookie; every authenticated route then works with that cookie. The paired mobile app has no shared cookie jar, so it redeems a short pairing code (/api/pair/redeem) for the same signed token and sends it as Authorization: Bearer <token> instead.
An instance started with PASSWORD_LOGIN=1 also offers name-and-password sign-in (tag password): opt-in per profile, scrypt-hashed, throttled, with an admin-issued one-time reset code instead of e-mail. A profile may also add an e-mail address to sign in with instead of its name (/api/account/email); no mail is ever sent to it. Its routes set exactly the same cookie. With the flag off (the default) every one of them answers 404.
Throttling
The password routes are counted per client address: at most 60 requests a minute across them, and wrong answers (a password, a reset code, an invite code on password signup, an e-mail address already in use) pause the address after 20 for 30 s, doubling up to 15 min. Wrong passwords also pause the account they were tried against after 5, for 1 min doubling up to 1 h — the same pause whether the account was named by its name or its e-mail — and an identifier that names no account is paused the same way, as typed. A password check counts from the moment it starts, so checks sent at once get no more tries than checks sent one by one. A paused caller gets 429 {"error": …, "code": "locked", "retryAfter": <s>} with a Retry-After header. Passkey sign-up and sign-in and the pairing routes are not throttled. The two routes that redeem a one-time device link (tag passkeys) share the per-address budget, and wrong codes pause the address for link redemption only, after 20, the way wrong reset codes do. Adding or removing a passkey in Settings (POST /api/account/passkeys/options, DELETE /api/account/passkeys) and making a device code (POST /api/account/device-link) spend the per-address budget too, and a current password given there as proof counts like one given at sign-in. The address is the socket's, or with TRUST_PROXY=1 the one a trusted proxy put in CF-Connecting-IP / the last X-Forwarded-For entry / X-Real-IP; IPv6 clients are counted by /64. Counters live in memory and reset on restart.
Sessions
The cookie/bearer value is <payload>.<hmac> where the payload is <uid>:<expiry-ms>:<session-version>, signed with a per-instance secret (HMAC-SHA256). Sessions last SESSION_DAYS (default 90). POST /api/logout/all bumps the user's session version, which invalidates every token ever issued for the account.
CSRF
State-changing browser requests must come from the app's own origin. The server checks Sec-Fetch-Site (falling back to Origin vs the configured ORIGIN); a mismatch is refused with 403 {"error":"cross-origin request refused"} on any non-GET route. Exempt: passkey sign-up and sign-in (register/options, register/verify, login/options, login/verify) and pair/redeem, each of which carries its own one-shot credential in the body, and any request authenticated with a Bearer token. The password routes are not exempt: a hostile page knows a valid name and password of its own, and could otherwise sign a visitor into it (login CSRF). Neither are the device-link routes: a link is redeemed on the app's own origin, the only one a passkey can be created for.
Environment-dependent behavior
INVITE_ONLY=1— registration requires a valid invite code (minted by an admin).ALLOW_GUEST=0— hides the client-side "continue without account" mode; the server merely reports the flag viaGET /api/config(guest mode never talks to this API at all).ADMIN_UIDS=<uid>,<uid>— user ids treated as admins (a"admin": trueflag on the user record indb.jsonworks too). Admins get the/api/admin/*routes.AUDIT_LOG(default on),AUDIT_MAX(default 5000 events),AUDIT_DAYS(default 90),AUDIT_IP(off|net|full, default off) — shape the audit log served byGET /api/admin/audit.PASSWORD_LOGIN=1— adds thepasswordroutes andpassword_login: trueinGET /api/config. Off by default.DEFAULT_LANG(e.g.pt-BR; unset by default) —default_langinGET /api/config, the language of the sign-in screen and of every profile that never picked one.TRUST_PROXY=1— the throttle reads the client address from proxy headers (set by the bundleddocker-compose.yml, where only the web container can reach the API).MEDIA_UPLOADS(default on;0removes themediaroutes and themediablock ofGET /api/config),MEDIA_QUOTA_MB(200 per profile,0= no cap),MEDIA_IMAGE_MAX_MB(2),MEDIA_GIF_MAX_MB(8),MEDIA_VIDEO_MAX_MB(40),MEDIA_VIDEO_MAX_SEC(60),MEDIA_GC_GRACE_DAYS(14),MEDIA_UPLOADS_PER_HOUR(600 per profile),MEDIA_MIN_FREE_MB(512,0= no floor) — photos and videos of custom exercises (tagmedia). Every MB here is 2^20 bytes.
Conventions
- Every response body is JSON with
Cache-Control: no-store. The one exception isGET /api/media/{hash}, which answers with the stored file itself (Cache-Control: private, no-store). - Errors are always
{"error": "<human-readable message>"}. Thepassword, throttle andmediaroutes add a stablecodefor the client to word in its own language. - Unknown method+path pairs return
404 {"error":"not found"}; an unhandled exception returns500 {"error":"server error"}. - Request bodies are parsed as JSON regardless of Content-Type. A body that does not parse, or parses to anything but an object (
null, a string, an array), is400 {"error":"invalid json"}on every route that reads one; an empty body counts as{}. A body over 5 MiB is413 {"error":"body too large"}; the rest of the upload is discarded so the answer reaches the client. - CORS: the request's
Originis reflected inAccess-Control-Allow-OriginwithoutAllow-Credentials— cross-origin callers can only ever authenticate with a Bearer token, never with the cookie.
Meta
Health and public configuration
GET
/api/healthpublic
Health check
Always public. Also reports how many user accounts exist.
Public — no authentication required.
Responses
GET
/api/configpublic
Instance configuration
The flags the login screen needs before anyone is signed in. Both reflect environment variables on the server (INVITE_ONLY, ALLOW_GUEST), and so does password_login, which is there only when PASSWORD_LOGIN=1, and default_lang, which is there only when DEFAULT_LANG is set.
media is there unless the instance runs with MEDIA_UPLOADS=0, for everybody: the caps are not a secret, and its absence is how the app knows this server does not store photos and videos at all.
A caller with a valid session also gets a coach key: the block when the instance has the AI Coach switched on and a provider connected, and null when it has not. A caller with no session gets no key at all — the block names the provider this instance talks to, which is the same fact GET /api/coach/disclosure will not hand out unauthenticated.
The key is always present for a session, null included, so a client that caches this answer can tell "no Coach on this instance" from "you were not signed in when you asked" and knows whether asking again would change it.
Public — no authentication required.
Responses
Auth
Passkey (WebAuthn) registration and login, sessions
GET
/api/me
Who am I?
Resolves the current session to a user. A paired phone (bearer token) whose token is past half of SESSION_DAYS also receives a fresh token to use from now on; it carries the account's current session version, so "sign out everywhere" revokes it like the old one. Cookie sessions never get one.
Requires a session — the cookie set at sign-in, or Authorization: Bearer <token> from mobile-app pairing.
Responses
{"error":"not signed in"}
POST
/api/register/optionspublic
Start passkey registration (WebAuthn ceremony, step 1)
Returns PublicKeyCredentialCreationOptions (from @simplewebauthn/server's generateRegistrationOptions) plus a challenge id cid. The client passes options to navigator.credentials.create() (or @simplewebauthn/browser's startRegistration) and sends the result to /api/register/verify together with the cid. Challenges are one-shot and expire after 5 minutes.
CSRF-exempt (the challenge is the credential). No session required.
Public — no authentication required.
Request body (JSON)
{
"name": "Ada",
"code": "3F9C21A07B54D688"
}
Responses
name missing/empty.{"error":"name required"}
{"error":"a valid invite code is required"}
POST
/api/register/verifypublic
Finish passkey registration (WebAuthn ceremony, step 2)
Verifies the authenticator's attestation response against the stored challenge (verifyRegistrationResponse), creates the user + credential, and signs the caller in (sets the session cookie). On an invite-only instance the invite is re-checked and burned here. CSRF-exempt.
Public — no authentication required.
Request body (JSON)
Responses
Set-Cookie
{"error":"challenge expired — try again"}
{"error":"invite code is no longer valid — ask for a new one"}
{"error":"credential already registered"}
POST
/api/login/optionspublic
Start passkey login (WebAuthn ceremony, step 1)
Returns PublicKeyCredentialRequestOptions (generateAuthenticationOptions with an empty allowCredentials — discoverable credentials / resident keys are required at registration, so the browser offers the user their passkeys itself) plus a one-shot challenge id cid. CSRF-exempt. Takes no request body.
Public — no authentication required.
Responses
POST
/api/login/verifypublic
Finish passkey login (WebAuthn ceremony, step 2)
Verifies the assertion (verifyAuthenticationResponse), updates the signature counter, and signs the caller in (sets the session cookie). CSRF-exempt.
Public — no authentication required.
Request body (JSON)
Responses
Set-Cookie
{"error":"not verified"}
{"error":"this account has been disabled"}
{"error":"unknown passkey — create a profile first"}
{"error":"user missing"}
POST
/api/logoutpublic
Sign out (this device)
Clears the session cookie. Always succeeds — an invalid or missing session is a no-op. The token itself stays cryptographically valid until it expires; use /api/logout/all to revoke tokens.
Public — no authentication required.
Responses
Set-Cookie
POST
/api/logout/all
Sign out everywhere
Bumps the account's session version, which invalidates every cookie and Bearer token ever issued for it — on every device, including a copy someone walked off with. Passkeys are untouched; signing back in works immediately. Also clears the caller's own cookie. Unredeemed pairing codes for the account are voided too.
Requires a session — the cookie set at sign-in, or Authorization: Bearer <token> from mobile-app pairing.
Responses
Set-Cookie
{"error":"not signed in"}
Pairing
Mobile-app pairing (code from a signed-in browser tab)
POST
/api/pair/create
Mint a pairing code for the mobile app
Called from an already signed-in browser tab (Settings → "Pair the mobile app"). Returns an 8-character code (alphabet without 0/O/1/I) the phone redeems within 5 minutes via /api/pair/redeem. One-shot.
Requires a session — the cookie set at sign-in, or Authorization: Bearer <token> from mobile-app pairing.
Responses
{"error":"not signed in"}
POST
/api/pair/redeempublic
Redeem a pairing code for a Bearer token
Called from the mobile app with the code shown in the browser. No session required — the code is the credential (one-shot, 5-minute TTL). Returns the same HMAC-signed session token the cookie would carry; the app sends it as Authorization: Bearer <token> from then on. CSRF-exempt.
Public — no authentication required.
Request body (JSON)
{
"code": "K7WQ2MZP"
}
Responses
{"error":"invalid or expired code"}
Password
Optional name-and-password sign-in (PASSWORD_LOGIN=1). Every route here is a 404 while the flag is off.
POST
/api/login/passwordpublic
Sign in with a profile name or e-mail and a password
Only a profile that has set a password can sign in this way. The identifier is the profile's display name or the e-mail address it added (POST /api/account/email), compared trimmed, NFKC-normalised and case-insensitively. Send it as identifier (what the app sends), name (the original field, still accepted) or email. In identifier and name, a value with an @ is looked up as an e-mail first and then as a name; email is looked up only as an e-mail. A wrong password, an unknown name and a name whose profile has no password all get the same 401 after the same scrypt work, so neither the answer nor its timing says which names exist. So does a password that was right when its check started but was changed, reset or removed before the check finished. Five wrong passwords for an account pause it, whichever identifier they named it by (see Throttling); passkey sign-in is never paused by this. Sets the same session cookie as /api/login/verify. Not CSRF-exempt.
Public — no authentication required.
Request body (JSON)
Responses
Set-Cookie
{"error":"name and password required"}
{"error":"wrong name or password"}
{"error":"this account has been disabled"}
Retry-After seconds.{"error":"too many attempts — try again later"}
{"error":"the server is busy — try again in a moment"}
POST
/api/register/passwordpublic
Create a profile with a name and password
For browsers that cannot make a passkey (plain http on a LAN address, some Firefox setups). Creates the profile and signs it in. The same invite rules as passkey registration apply: on an invite-only instance the code is checked first, checked again after hashing, and burned. The password must be 10–256 characters and not one of the passwords guessing scripts try first; no two profiles with a password may share a name. An optional email is stored as the profile's sign-in e-mail (see POST /api/account/email); one that is not an address is refused with 400 email-invalid, one that is in use with 409 email-taken — asked only after the hash and the second invite check, so without a valid code it is never answered — which counts against the caller's address like a wrong invite code. Not CSRF-exempt.
Public — no authentication required.
Request body (JSON)
{
"name": "Ada",
"password": "correct horse battery staple",
"code": "3F9C21A07B54D688"
}
Responses
Set-Cookie
too-short (under 10 characters), too-long (over 256) or too-common — a field is missing, or email-invalid: the email given is not an e-mail address.{"error":"this password is too easy to guess"} · {"error":"that is not an e-mail address"}
{"error":"a valid invite code is required"}
name-taken (see NameTaken) or email-taken — the e-mail is in use by another profile.{"error":"another profile already uses this e-mail address"}
Retry-After seconds.{"error":"too many attempts — try again later"}
{"error":"the server is busy — try again in a moment"}
POST
/api/login/password-resetpublic
Set a new password with an admin's one-time reset code
Redeems the code from POST /api/admin/user/password-reset (valid 24 h, single use, stored only as a hash; dashes, spaces and case do not matter) and signs in. A new password that breaks the policy is refused without using up the code. Every other session of the account ends. Wrong codes count against the caller's address only (see Throttling), never against the name, so they cannot keep a real code from working. The profile is named by its name or its sign-in e-mail, in identifier, name or email as at sign-in. Not CSRF-exempt.
Public — no authentication required.
Request body (JSON)
Responses
Set-Cookie
reset-invalid (wrong, used or expired code — one answer for all), missing, or a password-policy code.{"error":"that reset code is wrong or has expired"}
{"error":"this account has been disabled"}
{"error":"another profile already signs in with this name"}
Retry-After seconds.{"error":"too many attempts — try again later"}
{"error":"the server is busy — try again in a moment"}
GET
/api/account/password
Whether this profile has a password
Requires a session — the cookie set at sign-in, or Authorization: Bearer <token> from mobile-app pairing.
Responses
{"error":"not signed in"}
POST
/api/account/password
Set or change this profile's password
Proof first: current when the profile has a password, or a passkey assertion made for this request (cid from POST /api/login/options, credential signed by one of this profile's passkeys) — the only way to set a first password, and the way to replace a forgotten one. A session alone is never enough. Wrong current values count toward the same pause as wrong sign-ins. On success the session version is bumped, which ends every other session and pending pairing code of the account; the caller's own continues on a fresh cookie, or a fresh token for a Bearer caller.
Requires a session — the cookie set at sign-in, or Authorization: Bearer <token> from mobile-app pairing.
Request body (JSON)
Responses
Set-Cookie
too-short (under 10 characters), too-long (over 256) or too-common — or a field is missing.{"error":"this password is too easy to guess"}
{"error":"not signed in"}
current-required, current-wrong, passkey-required (no password yet, so a passkey has to confirm), or passkey (the assertion did not verify or belongs to another profile).{"error":"your current password is not right"}
{"error":"another profile already signs in with this name"}
Retry-After seconds.{"error":"too many attempts — try again later"}
{"error":"the server is busy — try again in a moment"}
DELETE
/api/account/password
Remove this profile's password
Refused while the profile has no passkey: the password would be its last way in. Otherwise the body carries the same proof setting one takes — current, the password itself, or a passkey assertion made for this request (cid from POST /api/login/options, credential signed by one of this profile's passkeys). A session alone is never enough: a stolen cookie must not take away the owner's way in where passkeys do not work. The last-way-in refusal comes before the proof is checked, and is checked again after it. Wrong current values count toward the same pause as wrong sign-ins. Existing sessions are left alone (POST /api/logout/all ends them). A profile without a password gets 200 as well, with no proof asked. Audited as auth.password.remove.
Requires a session — the cookie set at sign-in, or Authorization: Bearer <token> from mobile-app pairing.
Request body
Responses
{"error":"not signed in"}
passkey-required (no password that counts — none is set, or PASSWORD_LOGIN is off — so a passkey has to confirm), passkey (the assertion did not verify, belongs to another profile, or was made for another challenge, one handed out for another ceremony or one already used), current-required or current-wrong.{"error":"confirm with your passkey first"}
{"error":"the password is the only way into this profile"}
Retry-After seconds.{"error":"too many attempts — try again later"}
{"error":"the server is busy — try again in a moment"}
POST
/api/account/email
Set, change or remove the e-mail this profile may sign in with
An e-mail address to type at POST /api/login/password instead of the profile name. No mail is ever sent to it — there is no verification and no reset mail; resets stay the admin's one-time code — so it is only an identifier. Stored trimmed, NFKC-normalised and lower-cased; at most 254 characters; unique across every profile, and never another password-holding profile's name. An empty or null email removes it (as DELETE does).
The body carries the same proof as setting a password (see OwnerProof): a session alone is never enough. Saving the address the profile already has answers 200 with no proof asked. An address in use by another profile is refused only after the proof, with 409 email-taken, and counts against the caller's address and account (20 free, then 30 s doubling to 15 min) — so the answer never costs less than a passkey prompt or a password check, and runs out after a few tries. It says an address is in use somewhere on this instance, never by whom. Audited as auth.email.set / auth.email.change with the address masked (a…@e…).
Requires a session — the cookie set at sign-in, or Authorization: Bearer <token> from mobile-app pairing.
Request body (JSON)
{
"email": "ada@example.com",
"current": "correct horse battery staple"
}
Responses
{"error":"that is not an e-mail address"}
{"error":"not signed in"}
passkey-required (no password that counts — none is set, or PASSWORD_LOGIN is off — so a passkey has to confirm), passkey (the assertion did not verify, belongs to another profile, or was made for another challenge, one handed out for another ceremony or one already used), current-required or current-wrong.{"error":"confirm with your passkey first"}
{"error":"another profile already uses this e-mail address"}
Retry-After seconds.{"error":"too many attempts — try again later"}
{"error":"the server is busy — try again in a moment"}
DELETE
/api/account/email
Remove the e-mail this profile may sign in with
The profile then signs in by its name only. Takes the same proof as setting one (OwnerProof); a profile without an e-mail gets 200 with none asked. Audited as auth.email.remove.
Requires a session — the cookie set at sign-in, or Authorization: Bearer <token> from mobile-app pairing.
Request body
Responses
{"error":"not signed in"}
passkey-required (no password that counts — none is set, or PASSWORD_LOGIN is off — so a passkey has to confirm), passkey (the assertion did not verify, belongs to another profile, or was made for another challenge, one handed out for another ceremony or one already used), current-required or current-wrong.{"error":"confirm with your passkey first"}
Retry-After seconds.{"error":"too many attempts — try again later"}
{"error":"the server is busy — try again in a moment"}
Passkeys
More than one passkey on a profile: list, add, rename, remove — and one-time device links, which let another device of the same person add a passkey of its own.
GET
/api/account/passkeys
This profile's passkeys
Every passkey that signs this profile in, without key material. created and lastUsed are null for a passkey made before they were recorded.
Requires a session — the cookie set at sign-in, or Authorization: Bearer <token> from mobile-app pairing.
Responses
{"error":"not signed in"}
DELETE
/api/account/passkeys
Remove one passkey
Refused while it is the profile's last way in: its only passkey, and no password that can sign in (a password counts only while PASSWORD_LOGIN is on). Otherwise the body carries the same proof adding one takes (OwnerProof): a session alone is never enough, since a stolen cookie could otherwise choose which of the owner's passkeys is left. Any of the profile's passkeys may confirm, the one being removed included. The 404 and 409 refusals come before the proof is checked, and the last-way-in rule is checked again after it. Audited as auth.passkey.remove. An unused device code of the profile is dropped too, so one made with the removed passkey cannot add another afterwards.
Sessions are not tied to the passkey that opened them, so a session it already opened carries on; POST /api/logout/all ends those.
Requires a session — the cookie set at sign-in, or Authorization: Bearer <token> from mobile-app pairing.
Query parameters
id from the list (its WebAuthn credential id).
Request body
Responses
{"error":"not signed in"}
passkey-required (no password that counts — none is set, or PASSWORD_LOGIN is off — so a passkey has to confirm), passkey (the assertion did not verify, belongs to another profile, or was made for another challenge, one handed out for another ceremony or one already used), current-required or current-wrong.{"error":"confirm with your passkey first"}
{"error":"passkey not found"}
{"error":"this passkey is the only way into this profile"}
Retry-After seconds.{"error":"too many attempts — try again later"}
{"error":"the server is busy — try again in a moment"}
POST
/api/account/passkeys/options
Add a passkey (step 1): prove it is you, get creation options
A session alone is never enough to add a way in that outlives "sign out everywhere". The body carries the same proof POST /api/account/password takes: a passkey assertion made for this request (cid from POST /api/login/options, credential signed by one of this profile's passkeys), or current, the profile's password — only while PASSWORD_LOGIN is on. With it off, a password kept from before is not checked at all and the answer is passkey-required, so a password-only profile has to move onto a passkey while the flag is still on. Wrong passwords count toward the sign-in pause.
The options use the profile's own user handle and list its passkeys in excludeCredentials, so an authenticator that already holds one refuses. The challenge is good for 5 minutes, for this profile, and only while the session version is unchanged.
Requires a session — the cookie set at sign-in, or Authorization: Bearer <token> from mobile-app pairing.
Request body
Responses
{"error":"not signed in"}
passkey-required (no password that counts — none is set, or PASSWORD_LOGIN is off — so a passkey has to confirm), passkey (the assertion did not verify, belongs to another profile, or was made for another challenge, one handed out for another ceremony or one already used), current-required or current-wrong.{"error":"confirm with your passkey first"}
{"error":"a profile can have at most 20 passkeys"}
Retry-After seconds.{"error":"too many attempts — try again later"}
{"error":"the server is busy — try again in a moment"}
POST
/api/account/passkeys/verify
Add a passkey (step 2): store the new one
Verifies the attestation against the challenge from step 1 and stores the passkey on this profile. Audited as auth.passkey.add, with the proof that allowed it.
Requires a session — the cookie set at sign-in, or Authorization: Bearer <token> from mobile-app pairing.
Request body (JSON)
Responses
{"error":"challenge expired — try again"}
{"error":"not signed in"}
credential-exists (that passkey is already registered) or passkey-limit.{"error":"credential already registered"}
POST
/api/account/passkeys/rename
Name one passkey
An empty name clears it; the app then shows a numbered "Passkey n".
Requires a session — the cookie set at sign-in, or Authorization: Bearer <token> from mobile-app pairing.
Request body (JSON)
Responses
{"error":"not signed in"}
{"error":"passkey not found"}
POST
/api/account/device-link
Make a one-time device link
For adding another device of the same person: returns a code of 12 characters from the pairing alphabet (60 bits), which the app also shows as a QR code of <app>?link=<code>. The other device redeems it with /api/device-link/* by creating a passkey of its own. The code is shown once, stored only as a SHA-256, good for 10 minutes and for one passkey; a newer one replaces it, and signing out everywhere, a new password, an admin reset or a disable drop it. Same proof as adding a passkey. Audited as auth.link.create.
Requires a session — the cookie set at sign-in, or Authorization: Bearer <token> from mobile-app pairing.
Request body
Responses
{"error":"not signed in"}
passkey-required (no password that counts — none is set, or PASSWORD_LOGIN is off — so a passkey has to confirm), passkey (the assertion did not verify, belongs to another profile, or was made for another challenge, one handed out for another ceremony or one already used), current-required or current-wrong.{"error":"confirm with your passkey first"}
{"error":"a profile can have at most 20 passkeys"}
Retry-After seconds.{"error":"too many attempts — try again later"}
{"error":"the server is busy — try again in a moment"}
POST
/api/device-link/optionspublic
Redeem a device link (step 1): check the code, get creation options
Called on the other device, which has no session: the code is the credential. Returns creation options for the profile the code belongs to, and that profile's id and name. Does not use the code up, so a dismissed passkey prompt can be tried again. Wrong codes count toward a per-address pause of link redemption (see Throttling). Not CSRF-exempt.
Public — no authentication required.
Request body (JSON)
{
"code": "K7WQ-2MZP-4HXA"
}
Responses
{"error":"that code is wrong, used or expired"}
{"error":"a profile can have at most 20 passkeys"}
Retry-After seconds.{"error":"too many attempts — try again later"}
POST
/api/device-link/verifypublic
Redeem a device link (step 2): store the passkey and sign this device in
Verifies the attestation against the challenge from step 1 (which must have been made for this very code), stores the passkey on the code's profile, burns the code and sets the session cookie — the passkey just made is what signs the device in. Of two requests racing with one code, one wins. Audited as auth.link.ok, and every refusal as auth.link.fail. Not CSRF-exempt.
Public — no authentication required.
Request body (JSON)
Responses
Set-Cookie
link-invalid (see below), a challenge that expired or was made for another code, or an attestation that did not verify.{"error":"that code is wrong, used or expired"}
credential-exists or passkey-limit.{"error":"credential already registered"}
Retry-After seconds.{"error":"too many attempts — try again later"}
Data
Per-user state sync (the whole app state as one JSON blob)
GET
/api/data
Pull the account's app state
Returns the caller's entire app state as one JSON blob, or {"state": null} if nothing has been synced yet, together with rev, the server's revision of that document (0 when there is none). A client keeps the rev it last read or wrote and sends it back as baseRev on the next push, which lets the server refuse a write over a document the client never saw (see the 409 on PUT).
Requires a session — the cookie set at sign-in, or Authorization: Bearer <token> from mobile-app pairing.
Responses
{"error":"not signed in"}
PUT
/api/data
Push the account's app state
Replaces the stored state wholesale (atomic write; there is no merge on the server). The active field — an in-progress workout — is stripped before saving: a running session belongs to the device running it. Body limit 5 MiB.
With baseRev the write is conditional: it goes through only when baseRev equals the document's current revision, and answers 409 with the current document otherwise — the client merges the two and pushes again with the revision it was given. Without baseRev the write is unconditional (clients from before revisions, and deliberate replaces such as a backup import). The server stamps the new revision into the document as _rev; a _rev sent by the client is ignored.
Every accepted write also starts the grace period of each stored file (tag media) the new state no longer references, and stops it for each one it references again. That bookkeeping never changes the answer.
Requires a session — the cookie set at sign-in, or Authorization: Bearer <token> from mobile-app pairing.
Request body (JSON)
Responses
state missing or not an object, state an array or an object with nothing of the profile in it (_rev/_ts do not count — both would empty the profile), or workouts/routines set to anything other than an array or null. Entries inside those two arrays that are not objects (null, numbers, nested arrays) are dropped before the write rather than refused, so a client with a damaged copy still syncs.{"error":"state required"} · {"error":"invalid state"}
{"error":"not signed in"}
baseRev is not the current revision — another client wrote since this one last read. Carries the current document so the client can merge and retry.
GET
/api/data/rev
Current revision of the caller's profile state
The rev that GET /api/data would return, without the document. Signed-in clients poll this while open and on every return to the foreground and fetch the document only when the number changed.
Requires a session — the cookie set at sign-in, or Authorization: Bearer <token> from mobile-app pairing.
Responses
{"error":"not signed in"}
Push
Web Push (VAPID) subscriptions, rest-timer and test pushes
GET
/api/push/public-keypublic
VAPID public key
The instance's Web-Push application server key (generated once at first boot), for PushManager.subscribe({applicationServerKey}). Public.
Public — no authentication required.
Responses
POST
/api/push/subscribe
Register a Web-Push subscription
Stores the browser's PushSubscription for this account. Only endpoint and the two protocol keys are kept. The endpoint must be a public https:// URL — private/loopback/link-local addresses are refused (here and again at send time, against DNS rebinding). At most 20 subscriptions per user; the oldest are dropped first. Re-subscribing an existing endpoint replaces it and keeps its original created — the client re-sends its subscription on every signed-in boot, so a row the instance lost comes back on its own.
Requires a session — the cookie set at sign-in, or Authorization: Bearer <token> from mobile-app pairing.
Request body (JSON)
Responses
{"error":"invalid subscription"} · {"error":"endpoint must not point at a private address"}
{"error":"not signed in"}
GET
/api/push/status
Does the server hold this subscription?
Whether the caller's account has a stored subscription with exactly this endpoint. The browser's PushManager.getSubscription() says nothing about the server's side — a row pruned after a dead send (404/410/403) leaves the browser subscribed to nowhere — so the client asks here before showing the notifications switch as on, and re-registers when the answer is no.
Requires a session — the cookie set at sign-in, or Authorization: Bearer <token> from mobile-app pairing.
Query parameters
Responses
{"error":"not signed in"}
POST
/api/push/unsubscribe
Remove a Web-Push subscription
Removes the caller's subscription with the given endpoint. Idempotent.
Requires a session — the cookie set at sign-in, or Authorization: Bearer <token> from mobile-app pairing.
Request body (JSON)
Responses
{"error":"not signed in"}
POST
/api/push/test
Send a test notification
Sends a test push to all of the caller's subscriptions (localized with the lang in their synced state). Returns 200 even if delivery to individual endpoints fails — dead endpoints (404/410, and 403 for a subscription made against a VAPID key the instance no longer has) are pruned as a side effect.
Requires a session — the cookie set at sign-in, or Authorization: Bearer <token> from mobile-app pairing.
Responses
{"error":"not signed in"}
POST
/api/push/rest-timer
Schedule a server-side rest-timer push
Schedules one push after seconds (a number or numeric string ≥ 1, clamped to 3600). One pending timer per device — deviceId, the same token the subscription was registered with; a new call from the same device replaces it, and the push goes only to that device's subscriptions. Without a deviceId there is one timer per account and the push goes to every subscription, as before. The client schedules this on rest start/extend and cancels on skip or on-screen completion — the push only actually fires when the tab was backgrounded/suspended and never got to cancel it. Timers live in memory: an API restart drops them.
Requires a session — the cookie set at sign-in, or Authorization: Bearer <token> from mobile-app pairing.
Request body (JSON)
POST /api/push/subscribe. Optional.
{
"seconds": 90,
"deviceId": "3f9c1d2e7b0a4c6d9e8f1a2b3c4d5e6f"
}
Responses
seconds missing, not numeric, or below 1. Nothing is scheduled.{"error":"seconds required"}
{"error":"not signed in"}
POST
/api/push/rest-timer/cancel
Cancel the pending rest-timer push
Cancels the caller's scheduled rest-timer push, if any. With a deviceId only that device's timer; without one, every pending timer of the account. Idempotent.
Requires a session — the cookie set at sign-in, or Authorization: Bearer <token> from mobile-app pairing.
Request body (JSON) (optional)
Responses
{"error":"not signed in"}
Activity
Live-workout presence heartbeat
POST
/api/activity
Live-workout presence heartbeat
The client pings this every ~20 s while a workout is on screen; the admin dashboard reads who is training right now. Purely ephemeral, in-memory, never persisted; entries expire ~70 s after the last ping. {"active": false} drops the caller's entry immediately.
Requires a session — the cookie set at sign-in, or Authorization: Bearer <token> from mobile-app pairing.
Request body (JSON)
{
"active": true,
"name": "Push Day",
"exIdx": 2,
"exTotal": 5,
"setsDone": 7,
"setsTotal": 16,
"startedAt": 1756500000000
}
Responses
{"error":"not signed in"}
Media
The photo, GIF or video of a user's own custom exercise, and the photos and videos attached to a logged workout. Stored per profile (one quota for both) and named by the SHA-256 of its bytes; the state only carries a MediaRef. Every route here is a 404 when the instance runs with MEDIA_UPLOADS=0.
GET
/api/media/{hash}
Download one of your photos or videos
The stored file, whole, from the caller's own folder only: another profile's file answers exactly like one that was never uploaded (404). The body is the file, not JSON, with headers that keep a browser from ever treating it as a page: Content-Type from the sniffed type (never text/* or SVG), X-Content-Type-Options: nosniff, Content-Security-Policy: default-src 'none'; sandbox, Cross-Origin-Resource-Policy: same-origin, Content-Disposition: inline, X-Robots-Tag: noindex and Cache-Control: private, no-store — the app keeps its own verified copy, and an HTTP-cache copy would outlive signing out.
No Range and no ETag: the app downloads a file once, checks it against its hash and keeps it. A paired phone fetches with its bearer token; <img src> cannot carry one. Not throttled.
Requires a session — the cookie set at sign-in, or Authorization: Bearer <token> from mobile-app pairing.
Responses
Content-Length, Content-Disposition
{"error":"not signed in"}
{"error":"no such file"}
PUT
/api/media/{hash}
Upload one photo, GIF or video
The body is the raw file; Content-Type must be one of the seven types below and Content-Length is optional (chunked uploads are capped while they stream). The path names the SHA-256 of the bytes, and the file is stored only if they hash to it. Idempotent: uploading a file the server already has answers 200 with existed: true.
What is stored is decided by the file's first bytes, not by the declared type. The declared type only has to be in the same category — a still (JPEG, PNG, WebP, GIF) or a video (MP4, MOV, WebM): a JPEG declared as a GIF is stored as the JPEG it is, a video declared as an image is refused. The cap of the kind the bytes turned out to be applies (MEDIA_IMAGE_MAX_MB, MEDIA_GIF_MAX_MB, MEDIA_VIDEO_MAX_MB), and an MP4 or MOV must be a well-formed file no longer than MEDIA_VIDEO_MAX_SEC (+1 s), read from its headers. HEIC/AVIF, SVG, HTML and every other type are refused; the app converts HEIC photos before uploading. The server never decodes or alters a file: the app has already re-encoded photos and removed video metadata on the device.
Checked in this order: session (401), the hourly budget and at most two uploads at once per profile (429), the declared type (415), a declared length over the cap (413 before a byte is read), already stored (200), the quota (413 media-quota; files the profile already dropped get one hour instead of the grace to make room), free disk (507), then while and after streaming: size (413), hash (400), sniffed type (415), sniffed cap (413), video checks (415/413). No bytes for 60 s ends the upload with 408. A refused upload is read and discarded up to twice its cap, so the answer reaches the client.
A new file that the profile's state does not reference yet starts its grace period at once, so a state push that never comes does not keep it forever.
Requires a session — the cookie set at sign-in, or Authorization: Bearer <token> from mobile-app pairing.
Responses
hash-mismatch).{"error":"the file does not match its name"}
{"error":"not signed in"}
{"error":"cross-origin request refused"}
timeout). The connection is closed.{"error":"the upload stalled"}
media-too-large — over the cap of its kind; maxMB says the cap. - media-quota — the profile's space is full; usedMB and quotaMB say how full. - media-too-long — a video longer than maxSec seconds.{"error":"that file is too large"} · {"error":"your space for photos and videos is full"} · {"error":"that video is too long"}
media-type — the declared type is not one of the seven, the bytes are none of them, or they are a video declared as an image (or the reverse). media-invalid — an MP4 or MOV whose boxes do not add up.{"error":"that file type is not accepted"} · {"error":"that video could not be read"}
locked — more than MEDIA_UPLOADS_PER_HOUR uploads this hour; busy — two uploads of this profile are already in flight (retryAfter 5).{"error":"too many uploads — try again later"} · {"error":"too many uploads at once — try again in a moment"}
MEDIA_MIN_FREE_MB free (storage-full).{"error":"the server is running out of disk space"}
POST
/api/media/missing
Which of these files the server does not have
The app sends every hash its state references and uploads only what comes back, so the same call moves a guest's files into a new account, a phone's into its pairing, a backup's into the server, and re-sends a file the sweep removed while a device still had it. Answered from the caller's own folder only.
Requires a session — the cookie set at sign-in, or Authorization: Bearer <token> from mobile-app pairing.
Request body (JSON)
Responses
hashes is missing, not a list, longer than 1000, or holds something that is not a lowercase SHA-256 (bad-request).
{"error":"not signed in"}
{"error":"cross-origin request refused"}
POST
/api/media/sweep
Delete every file your current state does not reference
What "Reset everything" does after pushing the empty state: every stored file of the caller that the stored state does not reference goes now, without the grace period. A state that cannot be read removes nothing. At most 10 an hour per profile (429 locked). Recorded in the activity log as media.sweep.
Without this call nothing is lost either: the hourly sweep removes a file once the state has not referenced it for MEDIA_GC_GRACE_DAYS days (14), and only for profiles whose state reads — a missing or unreadable state, or a profile missing from db.json, never deletes anything.
Requires a session — the cookie set at sign-in, or Authorization: Bearer <token> from mobile-app pairing.
Request body (JSON)
{}
Responses
{"error":"not signed in"}
{"error":"cross-origin request refused"}
locked).{"error":"too many uploads — try again later"}
AI Coach
AI Coach — plans, reviews and debriefs. Every route here answers 503 unless the instance has the Coach switched on and a provider connected.
GET
/api/coach/disclosure
What the consent screen must say
The categories of data a Coach job would send, and the provider it would go to, read straight from the module that builds the payload — so the consent screen cannot drift from what actually leaves the box.
Needs a session but not a connected Coach: the screen that collects consent runs before there is anything to consent to.
Requires a session — the cookie set at sign-in, or Authorization: Bearer <token> from mobile-app pairing.
Responses
{"error":"not signed in"}
GET
/api/coach/status
The running job, the waiting proposal and today's budget
What the Coach screen polls. Also the only place a proposal past its 14-day expiry is retired — a phone that stays closed never polls, so nothing else would notice.
Requires a session — the cookie set at sign-in, or Authorization: Bearer <token> from mobile-app pairing.
Responses
{"error":"not signed in"}
{"error":"the Coach is not set up on this instance"}
POST
/api/coach/plan
Ask for a training plan
Queues a create job. With intake it builds a plan from the answers given on the intake screen; with refine it reworks the proposal already waiting, in the words of the person reading it. The answer is 202 and a job id — the result arrives through GET /api/coach/status.
Requires a session — the cookie set at sign-in, or Authorization: Bearer <token> from mobile-app pairing.
Request body (JSON) (optional)
maxMessageLen (1000 unless the admin changed it; 200–4000).
lang is used.
{
"intake": {
"goal": "muscle",
"experience": "novice",
"daysPerWeek": 3,
"equipment": [
"dumbbell",
"barbell"
]
}
}
Responses
GET /api/coach/status, which the client polls.{ job: object }
{"error":"not signed in"}
code: - busy (409) — a job for this profile is already running. - shared (409) — instance mode, and the credential belongs to another profile. - cap (429) — this profile's or the instance's daily limit is spent. - consent (403) — no consent recorded for this profile. - unprivileged (503) — the privilege drop cannot be performed, so no job runs. The message is the one to show; the raw provider detail never reaches the user.{"error":"the Coach is already thinking about your training"}
code: - busy (409) — a job for this profile is already running. - shared (409) — instance mode, and the credential belongs to another profile. - cap (429) — this profile's or the instance's daily limit is spent. - consent (403) — no consent recorded for this profile. - unprivileged (503) — the privilege drop cannot be performed, so no job runs. The message is the one to show; the raw provider detail never reaches the user.{"error":"the Coach is already thinking about your training"}
code: - busy (409) — a job for this profile is already running. - shared (409) — instance mode, and the credential belongs to another profile. - cap (429) — this profile's or the instance's daily limit is spent. - consent (403) — no consent recorded for this profile. - unprivileged (503) — the privilege drop cannot be performed, so no job runs. The message is the one to show; the raw provider detail never reaches the user.{"error":"the Coach is already thinking about your training"}
{"error":"the Coach is not set up on this instance"}
POST
/api/coach/review
Ask for a review of the training since the last one
Queues a review job — the same job the weekly cadence queues on its own. The answer is either a change-set to accept or reject, or "nothing to change" with the reason, both delivered through GET /api/coach/status.
Requires a session — the cookie set at sign-in, or Authorization: Bearer <token> from mobile-app pairing.
Request body (JSON) (optional)
maxMessageLen (1000 unless the admin changed it; 200–4000).
lang is used.
Responses
GET /api/coach/status, which the client polls.{ job: object }
{"error":"not signed in"}
code: - busy (409) — a job for this profile is already running. - shared (409) — instance mode, and the credential belongs to another profile. - cap (429) — this profile's or the instance's daily limit is spent. - consent (403) — no consent recorded for this profile. - unprivileged (503) — the privilege drop cannot be performed, so no job runs. The message is the one to show; the raw provider detail never reaches the user.{"error":"the Coach is already thinking about your training"}
code: - busy (409) — a job for this profile is already running. - shared (409) — instance mode, and the credential belongs to another profile. - cap (429) — this profile's or the instance's daily limit is spent. - consent (403) — no consent recorded for this profile. - unprivileged (503) — the privilege drop cannot be performed, so no job runs. The message is the one to show; the raw provider detail never reaches the user.{"error":"the Coach is already thinking about your training"}
code: - busy (409) — a job for this profile is already running. - shared (409) — instance mode, and the credential belongs to another profile. - cap (429) — this profile's or the instance's daily limit is spent. - consent (403) — no consent recorded for this profile. - unprivileged (503) — the privilege drop cannot be performed, so no job runs. The message is the one to show; the raw provider detail never reaches the user.{"error":"the Coach is already thinking about your training"}
{"error":"the Coach is not set up on this instance"}
POST
/api/coach/debrief
Ask for a read of one session
Queues a debrief job: one workout, read closely. It changes nothing — the card it produces is kept in the profile's log. With no workoutId the most recent session is read.
Requires a session — the cookie set at sign-in, or Authorization: Bearer <token> from mobile-app pairing.
Request body (JSON) (optional)
lang is used.
Responses
GET /api/coach/status, which the client polls.{ job: object }
{"error":"not signed in"}
code: - busy (409) — a job for this profile is already running. - shared (409) — instance mode, and the credential belongs to another profile. - cap (429) — this profile's or the instance's daily limit is spent. - consent (403) — no consent recorded for this profile. - unprivileged (503) — the privilege drop cannot be performed, so no job runs. The message is the one to show; the raw provider detail never reaches the user.{"error":"the Coach is already thinking about your training"}
code: - busy (409) — a job for this profile is already running. - shared (409) — instance mode, and the credential belongs to another profile. - cap (429) — this profile's or the instance's daily limit is spent. - consent (403) — no consent recorded for this profile. - unprivileged (503) — the privilege drop cannot be performed, so no job runs. The message is the one to show; the raw provider detail never reaches the user.{"error":"the Coach is already thinking about your training"}
code: - busy (409) — a job for this profile is already running. - shared (409) — instance mode, and the credential belongs to another profile. - cap (429) — this profile's or the instance's daily limit is spent. - consent (403) — no consent recorded for this profile. - unprivileged (503) — the privilege drop cannot be performed, so no job runs. The message is the one to show; the raw provider detail never reaches the user.{"error":"the Coach is already thinking about your training"}
{"error":"the Coach is not set up on this instance"}
POST
/api/coach/pending/resolve
Apply or discard the waiting proposal
The client applies the accepted changes to its own state and syncs them through PUT /api/data; this only records the decision and clears the proposal. Sending it when nothing is waiting is a no-op, not an error.
Requires a session — the cookie set at sign-in, or Authorization: Bearer <token> from mobile-app pairing.
Request body (JSON) (optional)
Responses
{"error":"not signed in"}
{"error":"the Coach is not set up on this instance"}
GET
/api/coach/cohort
How this profile sits against the others who opted in
Medians only, and only when at least three profiles on the instance share. Nothing at all for a profile that does not share itself, or on an instance where the admin has not switched the comparison on — each of those is a 200 saying which it is.
Requires a session — the cookie set at sign-in, or Authorization: Bearer <token> from mobile-app pairing.
Responses
{"error":"not signed in"}
{"error":"the Coach is not set up on this instance"}
GET
/api/coach/account
Whose provider account this profile would spend
Its own route because both the Coach screen and the admin card have to state it, and neither should be inferring it from settings. In instance mode a personal credential binds to the first profile that spends it; every other profile is refused outright rather than warned, and reason says so.
Requires a session — the cookie set at sign-in, or Authorization: Bearer <token> from mobile-app pairing.
Responses
{"error":"not signed in"}
POST
/api/coach/forget
Drop everything the server holds for this profile
Consent withdrawn, or the Coach switched off for this profile: the job record, the waiting proposal, the history and the sharing flag go at once, without waiting for a sync to carry the news. A job already running is cancelled where the adapter allows it and writes nothing back either way.
Today's job count is the one thing that survives — it is the spending record the daily cap reads, and forgetting must not hand out a fresh cap.
Needs a session only: a profile must be able to be forgotten on an instance whose Coach has since been switched off.
Requires a session — the cookie set at sign-in, or Authorization: Bearer <token> from mobile-app pairing.
Responses
{"error":"not signed in"}
Admin
Admin dashboard — requires an admin session (ADMIN_UIDS or admin:true)
POST
/api/admin/user/password-resetadmin
Issue a one-time password reset code
Returns a code (12 characters from the pairing alphabet, 60 bits) that is shown once, stored only as a SHA-256, valid for 24 h and good for one POST /api/login/password-reset. Issuing it removes the profile's current password and bumps its session version (every session and pending pairing code ends); passkeys keep working. A newer code replaces an older one. Admin accounts are refused — an admin sets their own password in Settings. Audited as admin.password.reset.
Admin only — a signed-in session whose user is an admin (ADMIN_UIDS or admin:true), as the session cookie or a Bearer token.
Request body (JSON)
Responses
{"error":"an admin sets their own password in Settings"}
{"error":"not signed in"}
{"error":"forbidden"}
{"error":"no such user"}
{"error":"another profile already signs in with this name"}
GET
/api/admin/usersadmin
List all users
One row per user with workout counts, last sync, push status and live presence. workouts counts only the entries the drill-down can show. Admin only.
Admin only — a signed-in session whose user is an admin (ADMIN_UIDS or admin:true), as the session cookie or a Bearer token.
Responses
{"error":"not signed in"}
{"error":"forbidden"}
GET
/api/admin/useradmin
One user in detail
Drill-down — full workout history (newest first) and body-weight log for one user. Admin only. Anything in routines, bodyweight or workouts that is not an object is left out, and a list that is not an array reads as empty: PUT /api/data drops those entries now, but a state file written before it did still has them and this route has to answer for it. A workout's media (its photos and videos) is left out: they are the owner's own.
Admin only — a signed-in session whose user is an admin (ADMIN_UIDS or admin:true), as the session cookie or a Bearer token.
Query parameters
Responses
{"error":"not signed in"}
{"error":"forbidden"}
{"error":"no such user"}
POST
/api/admin/user/disableadmin
Disable or re-enable a user
Sets the disabled flag. A disabled account is locked out everywhere at once (every existing session stops resolving) and dropped from live presence. Admins cannot be disabled. Audited.
Admin only — a signed-in session whose user is an admin (ADMIN_UIDS or admin:true), as the session cookie or a Bearer token.
Request body (JSON)
Responses
{"error":"cannot disable an admin"}
{"error":"not signed in"}
{"error":"forbidden"}
{"error":"no such user"}
POST
/api/admin/user/deleteadmin
Delete a user and everything of theirs
Removes the account for good: the user record, their passkeys, their push subscriptions, their live presence, their training history file, their uploaded photos and videos (DATA_DIR/uploads/<uid>/) and any Coach credential stored for them. Not reversible — /api/admin/user/disable is the reversible one. An admin cannot delete their own account, and the last admin cannot be deleted. Audited by name, since the id stops meaning anything.
Admin only — a signed-in session whose user is an admin (ADMIN_UIDS or admin:true), as the session cookie or a Bearer token.
Request body (JSON)
Responses
{"error":"you cannot delete your own account"}
{"error":"not signed in"}
{"error":"forbidden"}
{"error":"no such user"}
GET
/api/admin/invitesadmin
List invite codes
All invite codes, with the redeeming user's name resolved for display.
Admin only — a signed-in session whose user is an admin (ADMIN_UIDS or admin:true), as the session cookie or a Bearer token.
Responses
{"error":"not signed in"}
{"error":"forbidden"}
POST
/api/admin/invites/newadmin
Mint an invite code
Creates a fresh single-use invite code (16 hex chars = 64 bits — the code itself is the thing that isn't worth guessing; rate limiting is the reverse proxy's job). Audited.
Admin only — a signed-in session whose user is an admin (ADMIN_UIDS or admin:true), as the session cookie or a Bearer token.
Request body (JSON) (optional)
Responses
{"error":"not signed in"}
{"error":"forbidden"}
POST
/api/admin/invites/revokeadmin
Revoke an unused invite code
Deletes an unused code. A code that was already redeemed cannot be revoked.
Admin only — a signed-in session whose user is an admin (ADMIN_UIDS or admin:true), as the session cookie or a Bearer token.
Request body (JSON)
Responses
{"error":"already used — cannot revoke"}
{"error":"not signed in"}
{"error":"forbidden"}
{"error":"no such code"}
GET
/api/admin/auditadmin
Read the activity log
The audit log (sign-ins, failed attempts, admin actions), newest first, paged by event id (before cursor — offset paging would repeat rows as new events land). Retention (AUDIT_MAX events / AUDIT_DAYS days) is applied on read as well as on the hourly compaction. When AUDIT_LOG is off the route still answers, with enabled: false and whatever was logged before.
Admin only — a signed-in session whose user is an admin (ADMIN_UIDS or admin:true), as the session cookie or a Bearer token.
Query parameters
nextBefore).
fail keeps only failed events; any other value keeps events whose name starts with <cat>. (in practice auth or admin).
Responses
{"error":"not signed in"}
{"error":"forbidden"}
POST
/api/admin/audit/clearadmin
Clear the activity log
Deletes the log file. The clear is itself the first event of the fresh log, and event ids are never reset — a wipe always leaves a visible id gap.
Admin only — a signed-in session whose user is an admin (ADMIN_UIDS or admin:true), as the session cookie or a Bearer token.
Responses
{"error":"not signed in"}
{"error":"forbidden"}
GET
/api/admin/coachadmin
The Coach card
Everything the admin screen renders in one round trip, including a live check of the configured provider — for a runtime provider "is the runtime there", for an HTTPS one the model list its stored key can see.
Counts and outcomes only: no intake answers, no payloads, no proposals, and never a credential — only whether one is filed and what it is labelled.
Admin only — a signed-in session whose user is an admin (ADMIN_UIDS or admin:true), as the session cookie or a Bearer token.
Responses
{"error":"not signed in"}
{"error":"forbidden"}
POST
/api/admin/coach/configadmin
Change the Coach settings
A patch — every field is optional and only what is sent is changed. Model, endpoint and credential are all keyed by provider, so switching provider never drops any of them.
Admin only — a signed-in session whose user is an admin (ADMIN_UIDS or admin:true), as the session cookie or a Bearer token.
Request body (JSON)
{
"enabled": true,
"provider": "compatible",
"model": "qwen3.8-27b",
"caps": {
"perProfileDaily": 10,
"instanceDaily": 0
}
}
Responses
compatible).{"error":"unknown provider"}
{"error":"not signed in"}
{"error":"forbidden"}
POST
/api/admin/coach/testadmin
Run one round trip against the provider
Checks the runtime, then asks the model for one fixed JSON object and validates the shape that comes back — the whole path a job takes, on nobody's profile.
Always 200: the outcome is in the body, because "the provider refused" is an answer to the question the button asks, not a failure of the request. It brings the two short upstream retry pauses rather than COACH_RETRY_DELAYS_MS, since an admin is watching it through a proxy with a timeout of its own.
Admin only — a signed-in session whose user is an admin (ADMIN_UIDS or admin:true), as the session cookie or a Bearer token.
Responses
{"error":"not signed in"}
{"error":"forbidden"}
POST
/api/admin/coach/modelsadmin
List the models the endpoint serves
So the card can offer a list rather than a text field that goes stale with every model release. HTTPS providers only; a provider that cannot be asked answers ok: false with an empty list rather than an error status.
Admin only — a signed-in session whose user is an admin (ADMIN_UIDS or admin:true), as the session cookie or a Bearer token.
Responses
{"error":"not signed in"}
{"error":"forbidden"}
POST
/api/admin/coach/connectadmin
File the provider credential
The token is accepted once and never read back: it is encrypted with the instance secret and leaves again only as an environment variable on a job's child process. type has to match a variable the provider actually declares, so a key for one provider cannot be filed under another and then silently go nowhere.
A key may be filed for a provider that is not the active one, so the chips can be prepared before switching.
Admin only — a signed-in session whose user is an admin (ADMIN_UIDS or admin:true), as the session cookie or a Bearer token.
Request body (JSON)
{
"type": "apikey",
"token": "sk-…",
"account": "team@example.com"
}
Responses
{"error":"no token supplied"}
{"error":"not signed in"}
{"error":"forbidden"}
POST
/api/admin/coach/disconnectadmin
Remove the provider credential
Admin only — a signed-in session whose user is an admin (ADMIN_UIDS or admin:true), as the session cookie or a Bearer token.
Request body (JSON) (optional)
Responses
{"error":"unknown provider"}
{"error":"not signed in"}
{"error":"forbidden"}
Schemas
The named shapes the endpoints above link to. The State blob
is representative, not enforced — the server stores it opaquely and the app
grows it over time.
Error
bad-credentials, locked, too-short, media-quota, …). Media refusals add the numbers to word it with (maxMB, maxSec, usedMB, quotaMB).
Ok
PasskeyList
PASSWORD_LOGIN is on, so current can prove an addition or a removal.
last-way-in).
OwnerProof
Either a passkey assertion made for this request (cid + credential), or the profile's current password (current) — which counts only while PASSWORD_LOGIN is on.
Each assertion is good for one request: its challenge is spent by it.
SessionUser
The caller's identity, as returned by /api/me and the sign-in routes.
WebAuthnRegistrationOptions
PublicKeyCredentialCreationOptionsJSON — pass to startRegistration() (@simplewebauthn/browser) or decode for navigator.credentials.create().
Resident keys are required; attestation is none.
WebAuthnRegistrationCredential
RegistrationResponseJSON — the value startRegistration() resolves with (the created credential plus the authenticator's attestation response), sent back verbatim.
WebAuthnAuthenticationOptions
PublicKeyCredentialRequestOptionsJSON — pass to startAuthentication().
allowCredentials is empty: the browser offers the user's discoverable passkeys itself.
WebAuthnAuthenticationCredential
AuthenticationResponseJSON — the value startAuthentication() resolves with, sent back verbatim.
State
The whole app state as one blob.
The server treats it as opaque (it only strips active on save and reads a few fields for reminders and the admin dashboard) — the schema below is representative, not enforced (except workouts and routines, which must be arrays (or null) when present), and grows with the app. Concurrent writes are caught by the server's revision (_rev, see PUT /api/data); the client merges the two documents and decides by _ts which side's settings win.
week.
time (HH:MM, in tz) it sends one push if a routine is planned and nothing was logged yet.
CustomExercise
One exercise the user made (representative shape).
The server stores it as sent and reads only media from it, to know which uploaded files are still referenced.
MediaRef
What the state knows of an uploaded file.
The bytes are behind GET /api/media/{hash}; the server keeps a file as long as some customEx[].media.hash, workouts[].media[].hash or .poster.hash of either in the profile's state names it, and for MEDIA_GC_GRACE_DAYS after.
gif means animated; a one-frame GIF is an image.
MediaConfig
The instance's media caps (MEDIA_*), in MB of 2^20 bytes.
Absent when the instance runs with MEDIA_UPLOADS=0.
media list names too, not only a custom exercise's. Absent on an instance from before; the app then offers photos and videos on custom exercises only.
MediaUsage
MediaStored
Workout
One completed workout (representative shape).
unit.
/api/media/{hash}, and the server keeps every file any entry here names (the GC walks this list as it walks each custom exercise's media).
Routine
A workout template (representative shape).
BodyweightEntry
unit.
PushSubscription
The browser's PushSubscription.toJSON() — only these fields are stored.
AdminUserRow
_ts (ms).
Presence
Live-workout snapshot (from /api/activity heartbeats).
AdminUserDetail
Invite
AuditEvent
One line of the audit log.
auth.register.ok|fail|denied, auth.login.ok|fail, auth.logout, auth.logout.all, auth.pair.create|ok|fail, admin.denied, admin.user.disable|enable, admin.invite.create|revoke, admin.audit.clear.
full) or just the network, /24 or /48 (net).
CoachCap
What this profile has spent of its daily allowance.
CoachStatus
CoachProposal
The answer waiting for a decision, from whichever kind of job produced it.
It expires 14 days after it was made; the first status poll after that retires it. changes comes from a review, bundle from a plan, score/highlights from a debrief — one of the three, never more.
create job — routines, week and any custom exercises.
CoachIntake
The intake answers, as the plan screen collects them.
Stored in the profile's own state as well; sent here so a plan can be asked for with answers that have not been synced yet.
muscle, strength, fat-loss.
CoachCohort
Medians across the profiles that opted in, and where this one stands among them.
ok is false whenever there is nothing to show, and the other flags say why: the admin has the comparison switched off (enabled: false), this profile does not share (sharing: false), or there are not yet three who do.
AdminCoach
The whole admin card in one answer.
Never a credential — only whether one is filed.
unreadable is its own state because it has a specific cause and a specific fix — ./data restored without its secret.
ok is false no job runs at all, and the admin needs to hear that here rather than from a user.