Flow: dropbet sign-up¶
Trace ID:
57e0409877e606146ea30c9d96fedf1f· Jaeger: http://localhost:16686/trace/57e0409877e606146ea30c9d96fedf1f · E2E:tests-e2e/tests/dropbet-signup.spec.tsGenerated: 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 frompage.evaluateso 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 → clickbutton[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 inputSignUpForm.tsx:189),password(≥7 chars with at least one upper/lower/digit/symbol), optionalaffiliateCode. FE also sendslanguage: navigator.languageandcaptchaToken. - Wire call:
POST /auth/sign-upto ebit-api with JSON body plus headerx-captcha-token: <recaptcha_response>. The E2E uses the local-env shortcutx-captcha-token: pass(see §6 FM-1). - Happy-path response:
HttpCode(200)(note:auth.controller.ts:49— not 201 unlike/auth/sign-in) with body{accessToken, refreshToken, socketToken}(masked to"cookie"strings whendisable_set_cookies_and_mask_tokensis off, which is the default — seesession.service.ts:116-131). - Post-condition (E2E):
access_tokencookie 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
updateSessionQueueenqueue (session.service.ts:104gates it on!isFromRegister, andauth.service.ts:132passestrue). NoUserSessionPostgres 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).
4.2 Steps (2)–(3) — RecaptchaGuard: UNLINK recaptcha:blocked + canActivate¶
- Step (2): Redis
unlink— 495 µs (span492169ab...,db.statement = "unlink [1 other arguments]").RecaptchaGuard.unblockUser(recaptcha.guard.ts:161-163) deletes therecaptcha: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 underAuthController.signUprather than under the handler. - Step (3): Guard returns
canActivate=trueand Nest routes into thesignUphandler (span10cc91ce..., 178.72 ms,nestjs.type: handler).
4.3 Step (4) — AuthController.signUp → AuthService.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):
AuthServicecallsUserService.createUser(user.service.ts:301) with the nested DTO (kyc,statsUsd,roles,registrationInfo—auth.service.ts:107-125). - Step (10):
UserService.createUser→userRepository.createUser(user.repository.ts:297-323), a singleprisma.user.createwith nested creates. - Step (11):
prisma:client:operation User.create— 23.87 ms (span50de00...). Theprisma:engine:querysubspan wraps fiveprisma:engine:db_querychildren (605 µs / 802 µs / 886 µs / 1137 µs / 1636 µs) — one INSERT per table (user,user_kycLEVEL_0/gender=OTHER,user_stats_usdempty,user_rolerole=User,registration_infoIP/device/browser/OS/country/referrertype=LOCAL) — inside an implicit BEGIN/COMMIT. The edge (11) arrow also covers the SELECT issued by step (6)'s dup-check; the diagram collapses bothUserRepository → Postgresoperations 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.
4.9 Step (13) — sendEmailVerificationLink (fire-and-forget)¶
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):
AuthServicecallssessionService.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 withJWT_SECRET, different TTLs andrtflags); not instrumented. - Step (15):
SessionService.createAuthSessionCache(session.service.ts:179-196) writes{sId, userId, sKey}to Redis —set480 µs +expire299 µs,AUTH_SESSION_TTL_SECONDS= 604800 (7 d).sId/sKeyare freshrandomUUID()s. Key prefixUSER_AUTH_SESSION_CACHE_PATTERN = "auth-session"atapps/api/src/auth/dto/constants.ts:11— notuser-auth-sessionas an older revision of the sign-in doc had it.SETandEXPIRErun separately, notSETEX.
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.
4.11 Step (16) — setTokensCookie writes access/refresh/socket Set-Cookie headers¶
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¶
- Captcha-bypass is a single string constant.
recaptcha.service.ts:28short-circuits onisLocal && token === 'pass'.isLocalis justNODE_ENV === 'local'. A prod container misconfigured withNODE_ENV=localdisables reCAPTCHA entirely for the 4-character tokenpass. Tracked: FM-1. - User is committed even if verification email never sends.
user.service.ts:305-315firessendEmailVerificationLinkas a floating promise with a.catchthat only logs. IfJWT_VERIFICATION_TOKEN_SECRETis unset or SMTP fails beforejwt.signemits, the row is already in Postgres withemailVerified=falseand the user has no link — they can sign in but the verification path is broken until an admin re-triggers it. Same shape applies tosendUserWelcomeEmailatauth.service.ts:136. - Race on duplicate username/email.
auth.service.ts:67-86runs the dup-check as a separate statement fromprisma.user.create— no advisory lock. Two concurrent sign-ups on the same email both clearfindFirstUser; the losingINSERTfails on the@uniqueconstraint (PrismaP2002). The controller has no targeted catch, so the loser gets a500instead ofAUTH_USERNAME_OR_EMAIL_TAKEN. Low-probability in practice, but a bot can distinguish "taken" (400) from "race-loser" (500) and fingerprint live users. - First session has no server-side metadata row.
auth.service.ts:132passesisFromRegister=true;session.service.ts:104skips 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-shotRegistrationInforow. - Thin validation on
referrer.user-login.dto.ts:116-118decoratesreferrerwith@IsString @IsOptionalonly — no@MaxLength, no URL shape check. It lands inRegistrationInfo(Postgrestext). 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 itsPOST http://localhost:4000/auth/sign-upspan when the real UI is used — seedocs/flows/dropbet-sign-in.md:83-87for the analogous browser root-span anchor. If browser-side latency needs profiling, reproduce by temporarily replacing the ReCAPTCHA component with a dummycaptchaValue='pass'inSignUpForm.tsx:52and drive the form with Playwright. - Welcome-email and verification-email SMTP legs not traced.
emailService.sendEmaildoes 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/mailertransport. - 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
validateAndHashPasswordwithtracer.startActiveSpan— same shape as the manual spans added in task #20 on the sign-in path.