Skip to Content
Living documentation — last reviewed 2026-05-28
FeaturesUsers AuthUsers & Auth — Behavior

Users & Auth — Behavior

State

The users row has no explicit status enum — its lifecycle is conveyed by:

FieldState
clerk_id IS NULLImported user that has not yet signed in (Arbox / CSV import).
clerk_id IS NOT NULLLinked to a Clerk identity.
deleted_at IS NULLActive.
deleted_at IS NOT NULLSoft-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.email UNIQUE. Imported rows can be linked to a fresh Clerk identity on first sign-in if the emails match (findOrCreateFromClerk), except that an unproven login 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_id UNIQUE.
  • A user can have many memberships — one per (user, org). memberships UNIQUE on (user_id, organization_id).
  • Names are Taikan-authoritative — syncFromClerk only overwrites email and imageUrl. findOrCreateFromClerk backfills 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):

OwnershipWhen
provenemailProvenAt 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).
unprovenA 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/me returns it, and the web RoleRouter shows email-ownership-required (“Confirm it’s your email”) with Sign out and no redirects.
  • The Clerk webhook (user.created, and user.updated via syncFromClerk) acknowledges it with 200 instead of failing the delivery.
  • user.created skipped the sign-up bookkeeping (PostHog identify/alias, user_signed_up, pending invitations). The user.updated that carries the admin’s emailProvenAt stamp links the row and runs it. The join page’s /accept call gets the same 403; join-continue hands 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, and signUp.method, read from the login’s publicMetadata. join_link_browser means a joinToken with the ticket returned to the browser. join_link_email means joinInviteDelivery='email'. invitation_or_other means no joinToken. signUp.joinLink resolves the token to its org and link (join_links.token, then the legacy organizations.join_token); a rotated token resolves to null. proof is the emailProvenAt stamp with the verifying admin’s email. A Clerk read failure gives clerkUnavailable: true, signUp: null and ownership unproven.
  • sessions: the login’s 10 latest Clerk sessions (IP, city/country, browser/device, impersonating admin). A Clerk failure returns [] with sessionsUnavailable: true, never an error.
  • joinRequests: invitations rows with source='join_link' for the address (case-insensitive, any org, latest 20).
  • consents: the row’s legal_consents (latest 20). They carry the submitting browser’s IP and user agent.
  • events: audit_events from every org that target the user (target_type='user': registration refusals, auth.*) or are membership.invitation_held with metadata.userId = the user (latest 100). This is a bounded scan, because no index on audit_events leads with target_type.
  • adminActions: platform audit_logs rows 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. Use account.proof to see who verified.

Golden paths

G1 — Brand-new user sign-up

  1. User completes Clerk sign-up at /{lang}/(auth)/sign-up.
  2. Clerk fires user.created webhook to POST /webhooks/clerk (signed via Svix).
  3. Webhook calls findOrCreateFromClerk(clerkId) which either creates a new users row from Clerk profile data, or links an existing email-matched row with clerk_id=NULL.
  4. Webhook calls acceptPendingInvitations(clerkId) — any pending invitations rows matching this email get accepted, converting pending_invitation memberships to active.
  5. Web frontend: user lands on /{lang} → RoleRouter redirects based on memberships. If none: → onboarding when canCreateOrganization (a claimable owner grant, see /users/me below), else → /{lang}/library when ownsCourses (a course buyer), otherwise → /{lang}/invitation-required. A new owner arrives through an owner invitation (Clerk invitation with publicMetadata.ownerInvitationId) issued by a platform admin.

G2 — Returning sign-in

  1. Clerk session resumes. Frontend hits GET /users/me with bearer.
  2. AuthGuard verifies token, looks up user by clerk_id, attaches to request.
  3. Controller serves cached user from @CurrentUser('user') — no extra DB hit for the user.
  4. Memberships fetched separately by MembershipsService.getUserMemberships.

G3 — Profile update

  1. PATCH /users/me with partial profile.
  2. Phone (if present) normalized to E.164 via normalizePhone; emergency phone same.
  3. National ID (if present) validated and encrypted via NationalIdEncryptionService.encrypt.
  4. DB updated, cache invalidated, Clerk synced (firstName/lastName only) in parallel.
  5. Response returns the updated user with profileComplete recomputed.

G4 — Imported user first sign-in

  1. CSV import created {email, firstName, lastName} with clerk_id=NULL.
  2. Owner sent Clerk invitation (MembershipsService.sendMemberInvitation or bulkInvite).
  3. Member clicks email link, signs up via Clerk.
  4. Webhook user.created arrives. findOrCreateFromClerk sees the email-matched row with clerk_id=NULL, links it (sets clerk_id, imageUrl; backfills name only if currently null).
  5. acceptPendingInvitations flips the pending_invitation membership to active.
  6. MEMBERSHIP_ACTIVATED event emitted; downstream subscribers (forms fan-out, etc.) wake up.

G5 — Account self-deletion

  1. Member taps “Delete account” in app/web. DELETE /users/me.
  2. deleteSelf(clerkId, {skipClerk:false}):
    • Transaction: users.deleted_at=now; memberships.status='cancelled' + deleted_at=now; active subscriptions.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.
  3. account_deleted event emitted via tracking.

G6 — User deleted via Clerk dashboard

  1. Admin deletes the user in Clerk console.
  2. Clerk fires user.deleted webhook.
  3. Webhook calls usersService.deleteSelf(clerkId, {skipClerk:true}) — same DB cascade, no Clerk re-delete.

Edge cases & error states

ScenarioBehavior
Bearer missing or malformed401 ‘Missing or invalid authorization header’.
Bearer signature invalid / expired401 ‘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 ownsCoursesTrue 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 invitationHeldcanCreateOrganization: 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 membershipacceptPendingInvitations(clerkId) runs as a fallback to convert any pending invites that haven’t been processed yet.
PATCH /users/me with invalid Israeli IDUsersService.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 signature400 ‘Invalid webhook signature’.
Webhook missing svix headers400 ‘Missing svix headers’.
Webhook for user.deleted with no DB rowdeleteSelf returns {id: clerkId} after a best-effort Clerk delete (which may itself fail and log a warn).
GET /users/:id with id != current user404 ‘User not found’ (intentional — only self-lookup allowed).
Imported user signs up under a different email than importedEmail mismatch → no linking; a brand-new row is created. The imported row remains an orphan until manually merged.

Side effects

EventSide effect
Webhook user.createdfindOrCreateFromClerk, tracking.identify, tracking.track('user_signed_up'), acceptPendingInvitations.
Webhook invitation.acceptedacceptInvitationForEmail(email) → acceptPendingInvitations for an already-linked account; the email-ownership hold still applies.
Webhook invitation.revokedCancels the matching invitation, or revokes the matching owner_invitations grant.
Webhook user.updatedsyncFromClerk (email + imageUrl only).
Webhook user.deleteddeleteSelf({skipClerk:true}).
PATCH /users/meDB update, clerk.users.updateUser (name only), tracking (not explicitly called but flows via identify on next login).
DELETE /users/meSoft-delete cascade + Clerk delete + tracking.track('account_deleted').

Permissions

EndpointAuthNotes
GET /users/meBearerSelf.
PATCH /users/meBearerSelf.
DELETE /users/meBearerSelf. Idempotent.
GET /users/:idBearerSelf only; 404 otherwise.
POST /webhooks/clerk@Public() + Svix signatureNot gated by AuthGuard; validated by Svix.
POST /invitations/accept-pendingBearer + 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.sessionId
  • x-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) or managed. 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.email is nullable, unique only where present (users_email_unique_idx, users.ts:97). Migration 0168 turned 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.