modules/trainee/trainee.token.ts: create and verify the token.modules/trainee/trainee.service.ts:requestOtp,verifyOtp,switchStudio,me.middleware/trainee-auth.ts:authenticateTrainee,requireTraineeAppAccess.modules/trainee/trainee.routes.ts: which guard each route gets.
Login flow
Finding the account
A trainee is aClient row. traineeAccounts(phone):
- Reduces the number to its national core (digits without country code or leading zeros). A core shorter than 6 digits matches nobody. Without that rule a short input would match every client that has no number on file.
- Loads candidates with
repo.clientsByPhoneVariants(phoneMatchVariants(phone)). That repository match is a substring match. - Keeps only candidates whose own national core is exactly equal. Without this step a number would sign in as whoever holds a longer number it sits inside.
request-otp
The SmartSend organization used for the send is
SMARTSEND_OTP_API_KEY, else SMARTSEND_API_KEY, else the studio’s own key from its settings. With none of them, or when the send fails, the answer is SERVICE_UNAVAILABLE with verification code could not be sent right now.
verify-otp
- Reads the stored code. Missing or different:
BAD_REQUEST,invalid or expired code. - Loads the accounts again. None:
NOT_FOUND. - Deletes the code, stamps
lastCheckInAtand the platform, and creates the token. - Returns
{ token, client, studio, studios }.studioslists every membership so the app can ask which studio the trainee meant.
A wrong code is
400, not 401. The trainee app signs the user out on any 401, and a typo must not do that.verify-otp itself. Guessing is bounded by the 300 second code lifetime, the 3 sends per 10 minutes and the per IP /v1 rate limit of 120 requests a minute.
Review login
For app store review,verify-otp accepts one fixed phone and code pair without a Redis lookup when both TRAINEE_REVIEW_PHONE and TRAINEE_REVIEW_CODE are non empty and the request matches both. request-otp is unchanged, so the real owner of that number still gets normal codes. Blank either variable to turn it off. See the warning on Configuration about the defaults.
The token
This is not a JWT and has no header segment. Do not try to decode it with a JWT library.
Consequences of no expiry
A token cannot be revoked by itself, so everything that must end a session is checked on each request instead:
The 30 second caches mean a change takes up to half a minute to reach an active session.
Preview tokens
A coach can open a read only view of the app as a trainee.POST /v1/web/clients/:id/preview-token (web lane) calls createTraineeToken({ clientId, studioId, preview: true }, secret, PREVIEW_TTL_SECONDS) and returns the token with expiresInSeconds. PREVIEW_TTL_SECONDS is 900.
Rules for a preview token:
verifyTraineeTokenrejects it whenexpis missing or in the past.authenticateTraineerejects every method other thanGETwithFORBIDDEN, reasonPREVIEW_READ_ONLY.- It never writes the trainee’s time zone. The header would be the coach’s device.
medoes not stamplastCheckInAt. A preview must not count as the trainee using the app.
GET handler with a side effect has to check principal.preview itself.
Guards per route
The first authenticated group stays reachable for a locked trainee so the app can show who they are, explain why it is locked, let them sign out, switch studio or delete the account.
App access locking
traineeAppLocked decides whether a subscription state closes the app:
- A studio that does not track subscriptions has no
endsOnand no freeze, so the app stays open. endsOnpassed in iscoverageEndsOn(state.endsOn, state.subscriptions): the end of the last queued plan, not only the running one.- The coach can keep access on for a frozen or expired trainee with
Client.appAccessWhileFrozen.
Switching studios
POST /v1/trainee/auth/switch-studio takes the target client id. The current token already proves the phone, so no new OTP is needed. The service loads the current client, lists the accounts for its phone and requires the target to be one of them. It returns a new token for the target with the same response shape as verify-otp.
Time zone header
The app sendsx-timezone with an IANA zone name on every request. authenticateTrainee passes it to rememberTraineeTimezone, which stores it in Client.timezone when it is a valid zone and differs from the stored value. See Time zones and day keys.
Push tokens and logout
POST /v1/trainee/push-token upserts the Expo token with its platform, then triggers notifyPendingForms for that client so a form assigned before the app was installed is announced right away. DELETE /v1/trainee/push-token is called on logout, so a handed over phone stops receiving this account’s pushes. See Notifications and push.
Related lanes that reuse the trainee token
authenticateTrainee(ctx.env.BETTER_AUTH_SECRET, ctx.prisma) is also the guard for /v1/trainee/forms, /uploads, /calendar, /content, /shopping, /foods, /notification-configs and /notification-preferences. Those routers are defined in the modules that own the data.