Trainees sign in with a phone number and a code sent over WhatsApp. They have no better-auth account, no password and no email. The session is a small HMAC token the API signs itself. Files:
  • 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 a Client row. traineeAccounts(phone):
  1. 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.
  2. Loads candidates with repo.clientsByPhoneVariants(phoneMatchVariants(phone)). That repository match is a substring match.
  3. 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.
The result is every studio this number trains at, newest first. The first one is the account that signs in.

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

  1. Reads the stored code. Missing or different: BAD_REQUEST, invalid or expired code.
  2. Loads the accounts again. None: NOT_FOUND.
  3. Deletes the code, stamps lastCheckInAt and the platform, and creates the token.
  4. Returns { token, client, studio, studios }. studios lists 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.
There is no attempt counter on 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:
  • verifyTraineeToken rejects it when exp is missing or in the past.
  • authenticateTrainee rejects every method other than GET with FORBIDDEN, reason PREVIEW_READ_ONLY.
  • It never writes the trainee’s time zone. The header would be the coach’s device.
  • me does not stamp lastCheckInAt. A preview must not count as the trainee using the app.
A new 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 endsOn and no freeze, so the app stays open.
  • endsOn passed in is coverageEndsOn(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.
The access state is cached per client for 30 seconds in a map capped at 5,000 entries.

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 sends x-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. 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.