Users & Auth — Behavior
State
The users row has no explicit status enum — its lifecycle is conveyed by:
| Field | State |
|---|---|
clerk_id IS NULL | Imported user that has not yet signed in (Arbox / CSV import). |
clerk_id IS NOT NULL | Linked to a Clerk identity. |
deleted_at IS NULL | Active. |
deleted_at IS NOT NULL | Soft-deleted. Clerk identity may or may not exist depending on the path. |
guided_tour_completed_at is a separate completion timestamp that PATCH /users/me still accepts as guidedTourCompletedAt. The web app has no guided tour and neither reads nor sends it.
Invariants
- Email is globally unique —
users.emailUNIQUE. Imported rows can be linked to a fresh Clerk identity on first sign-in if the emails match (findOrCreateFromClerk), except that anunprovenlogin only takes a row holding nothing but join-link self-registrations (see Claiming a gym-created row below). - Clerk ID is globally unique —
users.clerk_idUNIQUE. - A user can have many memberships — one per (user, org).
membershipsUNIQUE on(user_id, organization_id). - Names are Taikan-authoritative —
syncFromClerkonly overwrites email and imageUrl.findOrCreateFromClerkbackfills name from Clerk only if the imported row’s name is null. - National ID is always encrypted at rest — plaintext never persisted. Reads return masked form. Setting it always validates Israeli ID checksum first.
- Account self-delete is idempotent — calling
deleteSelf(clerkId)twice returns{id: clerkId}the second time (no row to soft-delete). - Test bypass requires non-prod AND env flag —
NODE_ENV !== 'production' && TEST_AUTH_BYPASS === 'true'. Both must hold.
Email ownership proof
Clerk publicMetadata is server-only writable and is copied from a Clerk invitation onto the user at sign-up. Taikan derives a binary email ownership from it (no DB column; resolveEmailOwnership in apps/api/src/auth/email-ownership.ts):
| Ownership | When |
|---|---|
proven | emailProvenAt is set, or no joinToken (emailed / Clerk-Dashboard invitation, OAuth, email-code sign-up), or joinInviteDelivery === 'email' (join ticket delivered by email, as every public join request now is). |
unproven | A join-ticket account whose sign-up link was returned to the browser (flag OFF): whoever typed the address could create the account. Also any Clerk read failure (fail closed). |
emailProvenAt / emailProvenBy='admin' / emailProvenByActor (the attesting admin’s Clerk id) are written only by UsersService.markEmailOwnershipProven, called from one place: the platform-admin action POST /admin/users/:id/verify-email-ownership (“Mark email verified”).
Accepting an invitation ticket is deliberately not proof. For an address that already has an account, Clerk’s ticket signs the clicker into that existing account, which may have been pre-registered by someone else who still holds its password; proving it would hand them the invitation.
Consumers: acceptPendingInvitations (holds every non-join_link invitation for an unproven account — see memberships), OwnerInvitationsService.create({ existingAccount: true }) (refuses unproven accounts with OWNER_INVITATION_EMAIL_UNPROVEN), and findOrCreateFromClerk (below).
Claiming a gym-created row
findOrCreateFromClerk links a login to the unclaimed row (clerk_id IS NULL) filed under its email. For an unproven login it first checks the row (holdsOnlyJoinRegistrations, apps/api/src/memberships/join-registration.ts). The link goes ahead only if every live membership on the row is source='join_link'. Anything else means a gym put data there: an import, a desk registration, a staff invitation, a membership at another gym, or no membership at all (a named guardian). For that row the call throws 403 { code: 'EMAIL_OWNERSHIP_REQUIRED' } and links nothing.
Why: a join ticket handed to the browser is minted while the address is empty and stays live for 7 days. If a gym files someone under that address in the meantime, whoever filled in the form would otherwise sign up and take over the row.
- Every caller propagates the 403.
GET /users/mereturns it, and the webRoleRoutershowsemail-ownership-required(“Confirm it’s your email”) with Sign out and no redirects. - The Clerk webhook (
user.created, anduser.updatedviasyncFromClerk) acknowledges it with200instead of failing the delivery. user.createdskipped the sign-up bookkeeping (PostHog identify/alias,user_signed_up, pending invitations). Theuser.updatedthat carries the admin’semailProvenAtstamp links the row and runs it. The join page’s/acceptcall gets the same 403;join-continuehands over to/{lang}and reports no checkout failure.- Release: a platform admin opens the row in Admin → Users. The page resolves the waiting login by email and shows it
Unproven. Mark email verified stamps that login, and its next request links the row.
Ownership trail (platform admin)
GET /admin/users/:id/ownership-trail (platform admin; :id is the DB uuid or a Clerk id) gathers what is recorded about how an account came to hold its address, for telling a squatter from the owner. Admin → Users shows it as the Email ownership trail card. It reads the same login as the detail page: the row’s own, or the login waiting on it.
account: DB and Clerk creation times, last sign-in, ownership, andsignUp.method, read from the login’spublicMetadata.join_link_browsermeans ajoinTokenwith the ticket returned to the browser.join_link_emailmeansjoinInviteDelivery='email'.invitation_or_othermeans nojoinToken.signUp.joinLinkresolves the token to its org and link (join_links.token, then the legacyorganizations.join_token); a rotated token resolves tonull.proofis theemailProvenAtstamp with the verifying admin’s email. A Clerk read failure givesclerkUnavailable: true,signUp: nulland ownershipunproven.sessions: the login’s 10 latest Clerk sessions (IP, city/country, browser/device, impersonating admin). A Clerk failure returns[]withsessionsUnavailable: true, never an error.joinRequests:invitationsrows withsource='join_link'for the address (case-insensitive, any org, latest 20).consents: the row’slegal_consents(latest 20). They carry the submitting browser’s IP and user agent.events:audit_eventsfrom every org that target the user (target_type='user': registration refusals,auth.*) or aremembership.invitation_heldwithmetadata.userId= the user (latest 100). This is a bounded scan, because no index onaudit_eventsleads withtarget_type.adminActions: platformaudit_logsrows for/admin/users/<id>(by either id), the erasure record, and impersonation or sign-in-token grants for the login (latest 50). These rows are coarse: the admin audit interceptor keeps only the method and the first two path segments, so a verify reads like a restore. Useaccount.proofto see who verified.
Golden paths
G1 — Brand-new user sign-up
- User completes Clerk sign-up at
/{lang}/(auth)/sign-up. - Clerk fires
user.createdwebhook toPOST /webhooks/clerk(signed via Svix). - Webhook calls
findOrCreateFromClerk(clerkId)which either creates a newusersrow from Clerk profile data, or links an existing email-matched row withclerk_id=NULL. - Webhook calls
acceptPendingInvitations(clerkId)— any pendinginvitationsrows matching this email get accepted, convertingpending_invitationmemberships toactive. - Web frontend: user lands on
/{lang}→RoleRouterredirects based on memberships. If none: → onboarding whencanCreateOrganization(a claimable owner grant, see/users/mebelow), else →/{lang}/librarywhenownsCourses(a course buyer), otherwise →/{lang}/invitation-required. A new owner arrives through an owner invitation (Clerk invitation withpublicMetadata.ownerInvitationId) issued by a platform admin.
G2 — Returning sign-in
- Clerk session resumes. Frontend hits
GET /users/mewith bearer. AuthGuardverifies token, looks up user byclerk_id, attaches to request.- Controller serves cached
userfrom@CurrentUser('user')— no extra DB hit for the user. - Memberships fetched separately by
MembershipsService.getUserMemberships.
G3 — Profile update
PATCH /users/mewith partial profile.- Phone (if present) normalized to E.164 via
normalizePhone; emergency phone same. - National ID (if present) validated and encrypted via
NationalIdEncryptionService.encrypt. - DB updated, cache invalidated, Clerk synced (
firstName/lastNameonly) in parallel. - Response returns the updated user with
profileCompleterecomputed.
G4 — Imported user first sign-in
- CSV import created
{email, firstName, lastName}withclerk_id=NULL. - Owner sent Clerk invitation (
MembershipsService.sendMemberInvitationorbulkInvite). - Member clicks email link, signs up via Clerk.
- Webhook
user.createdarrives.findOrCreateFromClerksees the email-matched row withclerk_id=NULL, links it (sets clerk_id, imageUrl; backfills name only if currently null). acceptPendingInvitationsflips thepending_invitationmembership toactive.MEMBERSHIP_ACTIVATEDevent emitted; downstream subscribers (forms fan-out, etc.) wake up.
G5 — Account self-deletion
- Member taps “Delete account” in app/web.
DELETE /users/me. deleteSelf(clerkId, {skipClerk:false}):- Transaction:
users.deleted_at=now;memberships.status='cancelled' + deleted_at=now; activesubscriptions.status='cancelled' + cancel_at_period_end=true + cancelled_at=now;device_tokens.deleted_at=now. - Outside transaction:
clerk.users.deleteUser(clerkId). Failure logs error but does not roll back DB.
- Transaction:
account_deletedevent emitted via tracking.
G6 — User deleted via Clerk dashboard
- Admin deletes the user in Clerk console.
- Clerk fires
user.deletedwebhook. - Webhook calls
usersService.deleteSelf(clerkId, {skipClerk:true})— same DB cascade, no Clerk re-delete.
Edge cases & error states
| Scenario | Behavior |
|---|---|
| Bearer missing or malformed | 401 ‘Missing or invalid authorization header’. |
| Bearer signature invalid / expired | 401 ‘Invalid token’. |
GET /users/me for a brand-new user (webhook hasn’t landed) | AuthGuard’s dbUser is null, controller calls findOrCreateFromClerk and lazily creates the row. |
GET /users/me exposes ownsCourses | True when an account with no memberships holds an active course_entitlements row (UsersService.ownsActiveCourse). Not evaluated (always false) for anyone with a membership; a failed lookup is false. Routes a course buyer without a gym to /library instead of /invitation-required; an owner grant still wins (onboarding). |
GET /users/me exposes canCreateOrganization and invitationHeld | canCreateOrganization: OwnerInvitationsService.canCreateOrganization — a pending, unexpired grant bound to the caller’s Clerk id, or an unbound grant matching both the caller’s email and publicMetadata.ownerInvitationId (Clerk is read only when an email-matched grant exists). invitationHeld: only evaluated when the user has a pending_invitation shell; true when a live non-join_link invitation is held for an unproven email. Both default to false on error. |
GET /users/me and no active membership | acceptPendingInvitations(clerkId) runs as a fallback to convert any pending invites that haven’t been processed yet. |
PATCH /users/me with invalid Israeli ID | UsersService.encryptNationalId throws 'Invalid Israeli ID'. Controller surfaces as 500 (TODO: verify whether this is mapped to 400). |
PATCH /users/me with an unparseable phone, or a non-Israeli number without its + country code (415-555-2671) | 400 from UpdateProfileDto’s phone constraint. +1 415 555 2671 is accepted and stored as +14155552671. |
| Webhook with bad Svix signature | 400 ‘Invalid webhook signature’. |
| Webhook missing svix headers | 400 ‘Missing svix headers’. |
Webhook for user.deleted with no DB row | deleteSelf returns {id: clerkId} after a best-effort Clerk delete (which may itself fail and log a warn). |
GET /users/:id with id != current user | 404 ‘User not found’ (intentional — only self-lookup allowed). |
| Imported user signs up under a different email than imported | Email mismatch → no linking; a brand-new row is created. The imported row remains an orphan until manually merged. |
Side effects
| Event | Side effect |
|---|---|
Webhook user.created | findOrCreateFromClerk, tracking.identify, tracking.track('user_signed_up'), acceptPendingInvitations. |
Webhook invitation.accepted | acceptInvitationForEmail(email) → acceptPendingInvitations for an already-linked account; the email-ownership hold still applies. |
Webhook invitation.revoked | Cancels the matching invitation, or revokes the matching owner_invitations grant. |
Webhook user.updated | syncFromClerk (email + imageUrl only). |
Webhook user.deleted | deleteSelf({skipClerk:true}). |
PATCH /users/me | DB update, clerk.users.updateUser (name only), tracking (not explicitly called but flows via identify on next login). |
DELETE /users/me | Soft-delete cascade + Clerk delete + tracking.track('account_deleted'). |
Permissions
| Endpoint | Auth | Notes |
|---|---|---|
GET /users/me | Bearer | Self. |
PATCH /users/me | Bearer | Self. |
DELETE /users/me | Bearer | Self. Idempotent. |
GET /users/:id | Bearer | Self only; 404 otherwise. |
POST /webhooks/clerk | @Public() + Svix signature | Not gated by AuthGuard; validated by Svix. |
POST /invitations/accept-pending | Bearer + Throttle(10/min) | Self. Idempotent. |
Test bypass headers (only with TEST_AUTH_BYPASS=true and NODE_ENV !== 'production'):
x-test-user-id→ request.auth.userId (Clerk-style identifier)x-test-session-id→ request.auth.sessionIdx-test-role→ request.auth.role (informational only)
Managed members
Spec: managed-members/behavior.md §5.2a, data-model.md §3.1.
users.kind:account(default) ormanaged. A managed user is a child with no Clerk identity and, usually, no email; the schema requires a birth date (libs/db/src/lib/schema/users.ts:67,108).users.emailis nullable, unique only where present (users_email_unique_idx,users.ts:97). Migration0168turned old empty-string emails into NULL. Code must treat a missing address as a skipped send, never?? ''.- Teen sign-up block (ungated): a sign-up whose email a managed record already holds is refused with 403
signup_email_held_by_managed(SignupEmailHeldByManagedError,apps/api/src/users/users.service.ts:57, thrown at:139,:251). The Clerk webhook acknowledges with 2xx and audits ids only. The apps say “ask your gym to link your account”. - A managed member never signs in. Claiming a login is not built.