Users & Authentication
What is this
FitKit’s identity stack is Clerk-backed: Clerk owns the credential and session, FitKit owns the application-level profile (users table). The two stay in sync via a Svix-verified webhook (apps/api/src/webhooks/clerk-webhook.controller.ts) and a small set of direct Clerk SDK calls. Every authenticated request flows through AuthGuard (apps/api/src/auth/auth.guard.ts), which verifies the bearer JWT with Clerk and attaches { userId, sessionId, user } to the request.
The users row is the single, global identity. Org-scoped attributes live in memberships and member_profiles. The two profile tables are intentionally separate: users holds personal identity (name, phone, DOB, gender, emergency contact, Israeli national ID encrypted at rest), while member_profiles holds gym-specific data (RFID tag, allow_sms, allow_mailing_list, medical_cert, etc.) per (user, org).
Who uses it
| Persona | Why |
|---|---|
| Anyone | Sign-up, sign-in, profile edit. |
| Member | Update own profile, complete required fields, accept legal consents, optionally self-delete (App Store 5.1.1(v)). |
| Owner / Admin / Coach | Same as member for their own user; in addition, can view+update other org members’ profiles via the memberships module. |
| Platform | Clerk webhook syncs Clerk users into the DB on create / update / delete. |
Persona impact
- First-time sign-in — if no DB row exists yet,
findOrCreateFromClerklazily creates it (or links an imported user where the row exists withclerk_id=NULLbut the same email). - Imported member onboarding — Arbox/CSV import creates user rows with
clerk_id=NULL. The first sign-in (via Clerk invitation) attaches the existing row to the Clerk identity. Names imported are not overwritten by Clerk values. - Profile completeness gate —
isProfileComplete(user)checks the 7 required fields; the dashboard nudges members tocomplete-profileif any are missing. - Account deletion —
deleteSelfsoft-deletes the user, cancels active subs (cancelAtPeriodEnd=true), soft-deletes memberships and device tokens, then deletes the Clerk identity. Idempotent. Webhookuser.deletedcalls it back withskipClerk:trueto avoid recursion.
High-level capabilities
- Clerk webhook sync — handles
user.created,user.updated,user.deleted. Verified via Svix;Publicendpoint. - Lazy provisioning —
GET /users/mefalls back tofindOrCreateFromClerkif the AuthGuard didn’t already attach a user row (rare: first request before webhook lands). - Profile read —
GET /users/mereturns user + memberships + pending consents + profile-complete flag + masked national ID. - Profile update —
PATCH /users/me. Names also synced back to Clerk; phone normalized to Israeli format if recognizable; national ID validated (Israeli ID checksum) and encrypted before storage. - Account self-deletion —
DELETE /users/me. Cascades softly across memberships, subs, device tokens, then deletes Clerk identity. - Test bypass — when
TEST_AUTH_BYPASS=trueand non-prod, headersx-test-user-id/x-test-session-id/x-test-roleshort-circuit the JWT verify. Used in CI/Playwright. - Invitation auto-accept — first sign-in triggers
acceptPendingInvitations(clerkId)from the webhook + a fallback onGET /users/meif no active membership exists.
Relationship to other features
- memberships —
membershipsrows linkuserstoorganizationswithrole+status.GET /users/mereturns memberships nested. - organizations — creation calls Clerk’s
createOrganizationand storesclerk_organization_id. - onboarding — first-time owner runs the wizard;
acceptPendingInvitationsconvertspending_invitationmemberships toactive. - Legal / consents —
hasOutstandingConsentsgates UI on the user’s pending legal documents (terms/privacy/waiver). - National ID encryption —
NationalIdEncryptionService(envelope encryption; key from env). Mask format:***1234.
Current status
Shipped. Notable design choices:
- Email and imageUrl are Clerk-authoritative —
syncFromClerkonly writes these. Names are explicitly not overwritten by Clerk onuser.updated(comment: “Names are managed exclusively in FitKit and are never overwritten from Clerk.”). - Phone normalization —
normalizeIsraeliPhonefrom@fitkit/sharedruns on every profile update; falls back to the raw value if the input isn’t recognizable as Israeli. - National ID — validated via
isValidIsraeliId(Teudat Zehut checksum), stored encrypted, only masked on read. - AuthGuard caches the DB user on the request — controllers prefer
@CurrentUser('user')over re-fetching.
Known gaps
findByClerkIdhas an in-memory 30 s TTL cache (USER_CACHE_TTL_MS=30_000). Profile updates invalidate it; webhook updates likewise. Across multiple API pods the cache is per-process. (Comment in code.)- The webhook handler is single-shot — no retries, no idempotency table. Svix delivers at-least-once, the operations are idempotent (
findOrCreate, status updates) but a duplicateuser.deletedwill log a warning rather than fail. syncToClerkonly pushesfirstName/lastName. Profile edits to phone/DOB/etc. live only in FitKit.- No SSO / SAML configured at the FitKit layer; if Clerk-side SSO is enabled it just works.