/api/auth/*. It owns users, sessions, organizations, invitations, passkeys and two-factor. The trainee app does not use it at all. Trainees sign in with their own token, described on Trainee sign-in.
This page covers three things: how /api is mounted, the better-auth endpoints the web app relies on with the rules this project adds to them, and the small auth module in the core API that verifies sessions.
How /api is mounted
src/app.ts mounts honoGateway (src/middleware/hono-gateway.ts) at /api. The gateway rebuilds a web Request from the Express request and hands it to the Hono app exported by @repo/api (backend/packages/api/src/index.ts), then copies the response back.
The Hono app has base path /api and these routes:
Gateway details that affect clients:
- Bodies. The gateway forwards
req.rawBody. Only the JSON parser and theapplication/octet-streamparser capture raw bytes inapp.ts, so a request with another content type reaches better-auth without a body. Send JSON. - Cookies.
Set-Cookieheaders are copied one by one withgetSetCookie(). An impersonation response sets three cookies, and joining them into one header made browsers drop all of them. - CORS. The Hono app allows the origins from
getTrustedOrigins(NEXT_PUBLIC_SAAS_URL)with credentials, methodsGET,POSTandOPTIONS, and headersContent-TypeandAuthorization. - Limits.
/apiis not behind either Express rate limiter. better-auth’s own protections apply. - Envelope. Responses are better-auth’s and oRPC’s own shapes, never the
{ ok, data }envelope.
authClient). Requests and responses follow the better-auth version installed in packages/auth. For exact request and response schemas use the generated reference at /api/docs on a running API, not this page.
Sessions
Configured inbackend/packages/auth/src/auth.ts and config.ts:
- Sessions are stored in the database through the Prisma adapter and carried in a cookie.
expiresInis 30 days (sessionCookieMaxAge = 60 * 60 * 24 * 30).freshAgeis 0, so no action requires a recently created session. baseURLand trusted origins come fromNEXT_PUBLIC_SAAS_URL.- Users have three additional fields:
onboardingComplete,localeandlastActiveOrganizationId. - When a session is created, a database hook sets
activeOrganizationIdto the user’slastActiveOrganizationId, or to the organization returned byclaimStaffSeats. That function links staff who were added in the team directory by email to their account and membership, so a first-time staff member lands inside their studio. - Account linking is enabled for the trusted providers
googleandgithub.
/v1/web/* with a service signature and the user’s ids as headers. See Authentication.
Plugins
Email and password sign-in is enabled with a minimum password length of 8, automatic sign-in after sign-up, and no required email verification. Social providers are Google and GitHub, configured from
GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET, GITHUB_CLIENT_ID and GITHUB_CLIENT_SECRET.
Endpoints the web app relies on
The list below is taken from theauthClient calls in frontend/apps/saas. Paths follow better-auth’s mapping from client method to route. The ones marked “hooked” are referenced by path in this repo’s own hooks and are confirmed from code. The rest are better-auth defaults: confirm them against /api/docs before depending on a detail.
Sign-in and sign-up
Session and account
Organizations
Platform admin
Phone sign-in
Coaches can sign in with a WhatsApp code. The rules are in thehooks.before middleware and lib/phone-otp.ts.
The five phone paths (/sign-in/phone-number, /phone-number/send-otp, /phone-number/verify, /phone-number/request-password-reset, /phone-number/reset-password) share one guard:
phoneNumbermust be present and passisValidLoginPhoneNumber. Otherwise the request fails with 400 and codeINVALID_PHONE_NUMBER.- The number is normalized to international digits before better-auth sees it, so every lane stores and compares one form.
- On
/phone-number/send-otpwithout a session, the number must resolve to a user (resolveUserIdForLoginPhone). Otherwise 400 with codePHONE_NUMBER_NOT_EXIST. With a session the check is skipped, because that is the “change my number” flow.
resolveUserIdForLoginPhone does more than look up the user table. Coaches historically had their phone only on the studio’s Coach row. The function matches the coach row by phone, links it to an account by externalUserId or by the directory email, and provisions an account from the directory entry for a listed staff member who has never signed in.
Code settings, shared with the email lane:
The code is sent with the WhatsApp template
registercode in Hebrew (sendLoginOtpOverWhatsapp).
Email code sign-in
emailOTP runs with disableSignUp: true, so better-auth silently skips the send for an unknown email. That left staff who were only listed in the team directory with a code that never arrived. The before hook fixes it: on /email-otp/send-verification-otp with type sign-in, provisionStaffAccountForEmail creates an account from the directory entry when the email belongs to a listed staff member. The account has no password and an unverified email, and the seat is claimed when the session is created.
Errors
better-auth errors are JSON with acode and a message, with the HTTP status of the error. The codes this project adds:
Every better-auth API error is logged through
onAPIError.
The auth module in the core API
src/modules/auth/ has no routes. It holds session-verifier.ts, which turns a better-auth session token into the principal used by the bearer lane.
Both map the result with
principalFromSession:
userIdisuser.id.studioIdis the session’sactiveOrganizationId.rolecomes from the member role:ownertoOWNER,admintoHEAD_COACH,memberor anything else toSUB_COACH.- A session with no user or no active organization is rejected.
authenticate in src/middleware/auth.ts uses the verifier and answers 401 missing bearer token or invalid session. It guards only the three bearer mounts listed on the API overview.