Skip to content

Flow: dropbet sign-up

Trace ID: 57e0409877e606146ea30c9d96fedf1f · Jaeger: http://localhost:16686/trace/57e0409877e606146ea30c9d96fedf1f · E2E: tests-e2e/tests/dropbet-signup.spec.ts Generated: 2026-04-16 · Author: signin-doc-writer · Services traced: ebit-api (+ Postgres via @prisma/instrumentation, Redis via ioredis). Browser spans present when form is used from the UI; the E2E skips the UI (captcha) and POSTs from page.evaluate so only ebit-api is in the captured trace.

1. User-visible contract

From tests-e2e/tests/dropbet-signup.spec.ts:21-90 and the real UI at ebit-fe/src/components/modals/AuthModal/SignUpForm.tsx:41-270:

  • Entry point: GET / on ebit-fe → click button[name="Login"] → toggle to the "sign-up" tab inside the same AuthModal. Sign-up is not a separate route — it's a modal variant.
  • Inputs: username (3–16 chars, English + digits, no Cyrillic/special/spaces), email (RFC, ≤48 chars, auto-lowercased at input SignUpForm.tsx:189), password (≥7 chars with at least one upper/lower/digit/symbol), optional affiliateCode. FE also sends language: navigator.language and captchaToken.
  • Wire call: POST /auth/sign-up to ebit-api with JSON body plus header x-captcha-token: <recaptcha_response>. The E2E uses the local-env shortcut x-captcha-token: pass (see §6 FM-1).
  • Happy-path response: HttpCode(200) (note: auth.controller.ts:49not 201 unlike /auth/sign-in) with body {accessToken, refreshToken, socketToken} (masked to "cookie" strings when disable_set_cookies_and_mask_tokens is off, which is the default — see session.service.ts:116-131).
  • Post-condition (E2E): access_token cookie set on the browser context within 15 s. UI-path post-condition: modal closes and "check-your-inbox" modal opens (SignUpForm.tsx:121-122) — confirming email verification is a separate flow driven by the link sent during user creation.
  • Asymmetry vs sign-in: sign-up skips the BullMQ updateSessionQueue enqueue (session.service.ts:104 gates it on !isFromRegister, and auth.service.ts:132 passes true). No UserSession Postgres row is written for the freshly-registered user's first session — it materializes on the next sign-in.

2. Sequence diagram

49 spans in 57e0409877.... bcrypt and the three jwt.signAsync calls are inferred from the ~150 ms gap between the two Prisma spans and the Redis writes (no native-code instrumentation).

sequenceDiagram
  participant U as Browser
  participant G as RecaptchaGuard
  participant C as AuthController.signUp
  participant S as AuthService.registerUser
  participant US as UserService.createUser
  participant UR as UserRepository
  participant PG as Postgres
  participant SS as SessionService.createAuthSession
  participant R as Redis (ioredis)
  U->>G: POST /auth/sign-up (+ x-captcha-token)
  G->>R: UNLINK recaptcha:blocked:<ip> (post-solve cleanup)
  G->>C: canActivate=true
  C->>S: registerUser(body, req, res)
  Note over S: validateAndHashPassword (bcrypt, ~30 ms, not traced)
  S->>UR: findFirstUser({OR:[username,email] insensitive})
  UR->>PG: prisma:engine:db_query SELECT user ...
  UR-->>S: null (not taken)
  Note over S: (if affiliateCode) affiliateCodeService.getByCode
  S->>US: createUser(dto + nested kyc/statsUsd/roles/registrationInfo)
  US->>UR: prisma.user.create(...)
  UR->>PG: BEGIN — INSERT user / user_kyc / user_stats_usd / user_role / registration_info — COMMIT
  US->>R: SET user:details:<id> 60
  US->>R: EXPIRE user:details:<id> 60
  Note over US: sendEmailVerificationLink (fire-and-forget, logs token in local)
  S->>SS: createAuthSession(user, meta, undefined, isFromRegister=true, res)
  Note over SS: jwt.signAsync × 3 (access/refresh/socket, not traced)
  SS->>R: SET auth-session:<uid>:<sKey>:<sId>
  SS->>R: EXPIRE auth-session:<uid>:<sKey>:<sId> 604800
  Note over SS: isFromRegister=true ⇒ NO BullMQ updateSessionQueue enqueue
  SS->>U: Set-Cookie access_token/refresh_token/socket_token
  S-->>U: 200 + tokens body
  Note over S: sendUserWelcomeEmail (void, not awaited)

3. Component diagram

Edges are numbered in request-flow order. Section §4 below has the same numbers — each (N) on the diagram has its own §4.N subsection, so you can click straight through.

flowchart TD
    %% Datastores
    pg[("Postgres<br/>User · UserKyc · UserStatsUsd · UserRole · RegistrationInfo · AffiliateCode")]
    rd[("Redis (cache)<br/>recaptcha:blocked · user:details · auth-session")]

    %% Browser SPA
    subgraph web["Browser (ebit-fe)"]
        form["SignUpForm + useSignUpMutation<br/><i>react-hook-form + ReCAPTCHA v2</i>"]
    end

    %% NestJS process
    subgraph api["ebit-api (NestJS)"]
        guard["RecaptchaGuard<br/><i>@ThrottleRecaptcha (always-required)</i>"]
        ctrl["AuthController.signUp<br/><i>POST /auth/sign-up · @HttpCode(200)</i>"]
        svc["AuthService.registerUser<br/><i>auth.service.ts:59</i>"]
        hash["validateAndHashPassword<br/><i>bcrypt.hash, not traced</i>"]
        aff["AffiliateCodeService.getByCode<br/><i>conditional, absent in trace</i>"]
        urepo["UserRepository<br/><i>findFirstUser + createUser</i>"]
        usr["UserService.createUser<br/><i>user.service.ts:301</i>"]
        sess["SessionService.createAuthSession<br/><i>isFromRegister=true ⇒ no BullMQ</i>"]
        ck["setTokensCookie<br/><i>auth/cookies.ts:31</i>"]
        email["EmailService.sendEmail<br/><i>verification + welcome (fire-and-forget)</i>"]
    end

    %% (1)-(3) Browser submit + captcha guard
    form -- "(1) POST /auth/sign-up" --> guard
    guard -- "(2) UNLINK recaptcha:blocked" --> rd
    guard -- "(3) canActivate" --> ctrl

    %% (4)-(5) Controller hands off; password hash
    ctrl -- "(4) registerUser(body, req, res)" --> svc
    svc -- "(5) bcrypt.hash" --> hash

    %% (6)-(8) Dup-check + optional affiliate lookup
    svc -- "(6) findFirstUser (dup-check)" --> urepo
    svc -- "(7) getByCode (if provided)" --> aff
    aff -- "(8) AffiliateCode.findUnique" --> pg

    %% (9)-(11) Create user + 4 nested tables
    svc -- "(9) createUser(dto)" --> usr
    usr -- "(10) prisma.user.create + nested" --> urepo
    urepo -- "(11) SELECT + INSERT user + 4 side-effect tables" --> pg

    %% (12)-(13) Post-create side effects
    urepo -- "(12) SET/EXPIRE user:details:<id>" --> rd
    usr -- "(13) sendEmailVerificationLink (void)" --> email

    %% (14)-(16) Auth session + cookies
    svc -- "(14) createAuthSession (isFromRegister=true)" --> sess
    sess -- "(15) SET/EXPIRE auth-session:<uid>:<sKey>:<sId>" --> rd
    sess -- "(16) Set-Cookie access/refresh/socket" --> ck

    %% (17) Welcome email
    svc -- "(17) sendUserWelcomeEmail (void)" --> email

    %% Style: datastores stand out
    classDef db fill:#1f4e79,stroke:#bbb,color:#fff;
    class pg,rd db;

4. Per-step walkthrough

Section headers below mirror the diagram step numbers in §3 — each §4.N covers (N) on the diagram. Total duration: 196.14 ms. Root span POST /auth/sign-up (@opentelemetry/instrumentation-http@0.56.0, span 31b14b71...) parents everything. The 12-span Express middleware prefix (~2.8 ms — query, expressInit, corsMiddleware, cookieParser, session, initialize/authenticate passport, jsonParser, urlencodedParser, plus two <anonymous> Nest OTel context-propagation spans; registered in libs/shared/src/basic/base.main.ts:57-92) is identical to sign-in and not repeated here. The ebit-api-local trace does not include browser spans — see §7.

4.1 Step (1) — POST /auth/sign-up reaches RecaptchaGuard

SignUpForm.tsx:41-270 submits via useSignUpMutation (@tanstack/react-query) through ApiClientBrowser (axios, withCredentials) — a single browser-side hop to ebit-api carrying the JSON body plus header x-captcha-token: <recaptcha_response> (the E2E uses the local-env shortcut pass, see FM-1). On the api side this lands under the root span POST /auth/sign-up and is wrapped by AuthController.signUp — 191.16 ms (span e826d7f8..., nestjs.type: request_context; tags nestjs.callback = signUp, http.route = /auth/sign-up). Source: apps/api/src/auth/auth.controller.ts:48-58. Decorated @ThrottleRecaptcha() with no options — per recaptcha.decorator.ts:14-16 this means captcha is always required (throttler not consulted). Also @HttpCode(200) (note: not 201 unlike /auth/sign-in).

  • Step (2): Redis unlink — 495 µs (span 492169ab..., db.statement = "unlink [1 other arguments]"). RecaptchaGuard.unblockUser (recaptcha.guard.ts:161-163) deletes the recaptcha:blocked:<key> flag after the captcha token verifies. It runs as part of the guard, i.e. before the controller body — hence the span sits under AuthController.signUp rather than under the handler.
  • Step (3): Guard returns canActivate=true and Nest routes into the signUp handler (span 10cc91ce..., 178.72 ms, nestjs.type: handler).

4.3 Step (4) — AuthController.signUpAuthService.registerUser

Handler delegates directly to AuthService.registerUser(body, req, res) (auth.controller.ts:57). No body validation beyond the controller-level ValidationPipe (UserSignUpDto).

4.4 Step (5) — validateAndHashPassword (bcrypt, not traced)

The 15-ms gap between handler entry and the first Prisma span is validateAndHashPassword (libs/shared/src/common/utils) running bcrypt.hash at cost factor 10 (≈ 30–80 ms on this ARM host, here ~15 ms because sign-up runs bcrypt.hash not bcrypt.compare). Not instrumented — inferred from the gap between handler entry and step (11)'s first Prisma child. See the third bullet in §7 for how to wrap this with tracer.startActiveSpan.

4.5 Step (6) — findFirstUser dup-check

AuthService calls userRepository.findFirstUser (grep findFirstUser in user.repository.ts) matching on {OR: [{username: eq insensitive}, {email: eq insensitive}]} — see auth.service.ts:67-82. Throws AUTH_USERNAME_OR_EMAIL_TAKEN (line 85) if a row comes back. The actual prisma:engine:db_query SELECT (10.26 ms, span a4fe0...) lives on edge (11) from the Postgres side; hash indexes users_email_index (api.prisma:288) and the username unique constraint (api.prisma:202) cover both OR branches, so Postgres runs the union without a sequential scan.

4.6 Steps (7)–(8) — affiliate code resolution (optional, absent in this trace)

auth.service.ts:88-96 calls AffiliateCodeService.getByCode (which issues an AffiliateCode.findUnique against Postgres) only when the body carries affiliateCode. The E2E omits it, so no span appears in 57e0409877.... Drawn on the diagram for completeness; when present, an AffiliateCode.findUnique Prisma span appears between steps (6) and (9) in span order.

4.7 Steps (9)–(11) — createUser + INSERT user (+ kyc / statsUsd / roles / registrationInfo)

  • Step (9): AuthService calls UserService.createUser (user.service.ts:301) with the nested DTO (kyc, statsUsd, roles, registrationInfoauth.service.ts:107-125).
  • Step (10): UserService.createUseruserRepository.createUser (user.repository.ts:297-323), a single prisma.user.create with nested creates.
  • Step (11): prisma:client:operation User.create — 23.87 ms (span 50de00...). The prisma:engine:query subspan wraps five prisma:engine:db_query children (605 µs / 802 µs / 886 µs / 1137 µs / 1636 µs) — one INSERT per table (user, user_kyc LEVEL_0/gender=OTHER, user_stats_usd empty, user_role role=User, registration_info IP/device/browser/OS/country/referrer type=LOCAL) — inside an implicit BEGIN/COMMIT. The edge (11) arrow also covers the SELECT issued by step (6)'s dup-check; the diagram collapses both UserRepository → Postgres operations into one node-pair per the one-edge-per-pair rule.

4.8 Step (12) — user:details:<id> cache write

userRepository.updateUserInCache(user) (user.repository.ts:321) fires Redis set + expire on user:details:<id> — 1.53 ms + 295 µs. 60-second TTL is the hot-path cache read by UserService.findUniqueUser; sign-up populates it preemptively.

user.service.ts:304-315 fires sendEmailVerificationLink as a floating promise — in local env it also debug-logs the token (user.service.ts:772-778). emailService.sendEmail does not emit OTel spans in the version running here (see §7), so the SMTP leg is invisible to Jaeger. Failure mode: see FM-2 (user is committed even if verification email never sends).

4.10 Steps (14)–(15) — SessionService.createAuthSession + auth-session cache write

  • Step (14): AuthService calls sessionService.createAuthSession(user, meta, undefined, isFromRegister=true, res) (session.service.ts:86). Between handler entry into this method and step (15), createAuthSessionTokens (session.service.ts:141-177) signs three JWTs (access/refresh/socket, all with JWT_SECRET, different TTLs and rt flags); not instrumented.
  • Step (15): SessionService.createAuthSessionCache (session.service.ts:179-196) writes {sId, userId, sKey} to Redis — set 480 µs + expire 299 µs, AUTH_SESSION_TTL_SECONDS = 604800 (7 d). sId/sKey are fresh randomUUID()s. Key prefix USER_AUTH_SESSION_CACHE_PATTERN = "auth-session" at apps/api/src/auth/dto/constants.ts:11not user-auth-session as an older revision of the sign-in doc had it. SET and EXPIRE run separately, not SETEX.

Critical asymmetry vs sign-in: session.service.ts:104 guards sessionQueueProducer.updateSession on !isFromRegister, and auth.service.ts:132 passes true. The BullMQ EVALSHA that sign-in emits (docs/flows/dropbet-sign-in.md §4.3.6) is absent here — no UserSession row until the next login. See FM-4.

setTokensCookie (auth/cookies.ts:31-44) writes the three Set-Cookie headers identical to sign-in. The response body returns {accessToken, refreshToken, socketToken} masked to "cookie" strings when disable_set_cookies_and_mask_tokens is off (the default — see session.service.ts:116-131).

4.12 Step (17) — sendUserWelcomeEmail (fire-and-forget)

auth.service.ts:136 fires sendUserWelcomeEmail (user.service.ts:919-931) last, void-returning. Same SMTP-invisibility caveat as step (13) — see §7. Same family of FM-2 risk: response is already a 200 with tokens body before SMTP resolves.

5. Data model

Table / key R/W Fields touched Schema / file
User (Postgres) R (dup-check), then W (insert) id, username (@unique), email (@unique, case-insensitive via mode:'insensitive'), password (bcrypt hash), avatar, emailVerified=false (default), isEmailNotificationsEnabled, affiliateCodeId?, vipLevel=1, exp=0 libs/_prisma/src/schema/api.prisma:198-293@unique on username (:202) and email (:205); hash index users_email_index at :288
UserKyc (Postgres) W level=LEVEL_0, verificationPending=false, gender=OTHER api.prisma:688; writer user.repository.ts:301-307
UserStatsUsd (Postgres) W empty row, 1:1 api.prisma:965; writer user.repository.ts:308-310
UserRole (Postgres) W role = User api.prisma:511; writer auth.service.ts:107-110
RegistrationInfo (Postgres) W ipAddress, device, deviceType, browser, os, userAgent, countryCode, language, referrer, type = LOCAL api.prisma:1526; writer auth.service.ts:112-125
AffiliateCode (Postgres) R (if provided) id, code api.prisma:1107; reader affiliate-code.service.ts via AffiliateCodeService.getByCode
user:details:<id> (Redis) W serialized UserDto, TTL = 60 s user.repository.ts:321 via updateUserInCache
auth-session:<uid>:<sKey>:<sId> (Redis) W {sId, userId, sKey}, TTL = 604800 s (7 d) session.service.ts:179-196; prefix const apps/api/src/auth/dto/constants.ts:11
recaptcha:blocked:<ip> (Redis) W (UNLINK) flag, cleared after solve captcha/google/recaptcha.guard.ts:161-163
BullMQ queue updateSessionQueue NOT WRITTEN on sign-up session.service.ts:104 guard !isFromRegister + auth.service.ts:132

6. Failure modes

  1. Captcha-bypass is a single string constant. recaptcha.service.ts:28 short-circuits on isLocal && token === 'pass'. isLocal is just NODE_ENV === 'local'. A prod container misconfigured with NODE_ENV=local disables reCAPTCHA entirely for the 4-character token pass. Tracked: FM-1.
  2. User is committed even if verification email never sends. user.service.ts:305-315 fires sendEmailVerificationLink as a floating promise with a .catch that only logs. If JWT_VERIFICATION_TOKEN_SECRET is unset or SMTP fails before jwt.sign emits, the row is already in Postgres with emailVerified=false and the user has no link — they can sign in but the verification path is broken until an admin re-triggers it. Same shape applies to sendUserWelcomeEmail at auth.service.ts:136.
  3. Race on duplicate username/email. auth.service.ts:67-86 runs the dup-check as a separate statement from prisma.user.create — no advisory lock. Two concurrent sign-ups on the same email both clear findFirstUser; the losing INSERT fails on the @unique constraint (Prisma P2002). The controller has no targeted catch, so the loser gets a 500 instead of AUTH_USERNAME_OR_EMAIL_TAKEN. Low-probability in practice, but a bot can distinguish "taken" (400) from "race-loser" (500) and fingerprint live users.
  4. First session has no server-side metadata row. auth.service.ts:132 passes isFromRegister=true; session.service.ts:104 skips the BullMQ enqueue. UserSession (api.prisma:480) stays empty until the next sign-in — an admin investigating a just-registered account sees no device/IP session, only the one-shot RegistrationInfo row.
  5. Thin validation on referrer. user-login.dto.ts:116-118 decorates referrer with @IsString @IsOptional only — no @MaxLength, no URL shape check. It lands in RegistrationInfo (Postgres text). A multi-MB payload is accepted and stored, bloating the row and any downstream ETL. Same family as the SF-003 thin-DTO finding on sign-in.

7. Unresolved

  • Browser spans not captured in this trace. The E2E uses page.evaluate(fetch) rather than driving the UI form (captcha would otherwise need a real Google ReCAPTCHA solve). The browser OTel Web SDK does fire its POST http://localhost:4000/auth/sign-up span when the real UI is used — see docs/flows/dropbet-sign-in.md:83-87 for the analogous browser root-span anchor. If browser-side latency needs profiling, reproduce by temporarily replacing the ReCAPTCHA component with a dummy captchaValue='pass' in SignUpForm.tsx:52 and drive the form with Playwright.
  • Welcome-email and verification-email SMTP legs not traced. emailService.sendEmail does not emit OTel spans in the version running here. Both the welcome and verification emails leave the sign-up trace at the fire-and-forget boundary and are invisible to Jaeger. To close this gap, instrument the email SMTP client or the @nestjs-modules/mailer transport.
  • bcrypt.hash is not traced. The ~15 ms gap between handler entry and the first Prisma span is an inference. If bcrypt cost tuning ever becomes a latency question, wrap validateAndHashPassword with tracer.startActiveSpan — same shape as the manual spans added in task #20 on the sign-in path.