Skip to content

Flow: dropbet sign-in

Trace ID: 456b4797b61fd23db1baf685bbdcf378 · Jaeger: http://localhost:16686/trace/456b4797b61fd23db1baf685bbdcf378 · E2E: tests-e2e/tests/dropbet-signin.spec.ts Generated: 2026-04-16 · Author: signin-doc-writer · Services traced: ebit-api (+ Redis via ioredis). Browser and Postgres touched but not traced — see §7.

1. User-visible contract

From tests-e2e/tests/dropbet-signin.spec.ts:9-58:

  • Entry point: GET / on ebit-fe, then click button[name="Login"] to open the auth modal.
  • Inputs: form fields matched by placeholder="Email" and placeholder="Password"; test uses local@example.com / password.
  • Wire call: POST /auth/sign-in on ebit-api with JSON {email,password}; status must be < 300 (actual: 201 Created).
  • Post-condition: cookie named exactly access_token must be set on the browser context within 15 s.
  • Cross-service trace assertion: trace window covers both ebit-fe (pre-click SSR) and ebit-api (the POST). After pw-signin-dev's helper fix in task #15, the captured trace is now root-anchored on POST /auth/sign-in.

2. Sequence diagram

Built from the 21 spans in 456b4797.... bcrypt and JWT work are inferred from duration (no native-code instrumentation) and called out as comments.

sequenceDiagram
  participant U as Browser
  participant MW as Express middleware
  participant H as AuthController.signIn (Nest handler)
  participant R as Redis (ioredis)
  participant BMQ as BullMQ (Redis-backed)
  U->>MW: POST /auth/sign-in (cookies + JSON body)
  MW->>MW: query, cors, expressInit, cookieParser, urlencodedParser
  MW->>MW: authenticate (passport), session, jsonParser, global-prefix
  MW->>H: route matched
  Note over H: findFirstUserWithPassword (Prisma, not traced)
  H->>R: GET lockout:<email>
  Note over H: bcrypt.compare (~70 ms, not traced)
  H->>R: UNLINK user:login-attempts:<email>
  H->>R: UNLINK lockout:<email>
  Note over H: JwtService.signAsync x3 (access/refresh/socket, not traced)
  H->>R: SET user-auth-session:<uid>:<sKey>:<sId>
  H->>R: EXPIRE user-auth-session:<uid>:<sKey>:<sId>
  H->>BMQ: EVALSHA queue.add updateSessionQueue (fire-and-forget)
  H-->>U: 201 + Set-Cookie access_token / refresh_token / socket_token

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 · UserSession")]
    rd[("Redis (cache)<br/>lockout · login-attempts · user-auth-session · BullMQ updateSessionQueue")]

    %% Browser SPA
    subgraph web["Browser (ebit-fe)"]
        ax["SignInForm → useSignInMutation → ApiClientBrowser<br/><i>react-hook-form + axios, withCredentials</i>"]
    end

    %% Primary api process
    subgraph api["ebit-api (NestJS)"]
        ctrl["AuthController.signIn<br/><i>POST /auth/sign-in</i>"]
        svc["AuthService.login<br/><i>admin-origin gate + MFA short-circuit + 3× JwtService.signAsync</i>"]
        usr["UserService.authenticate<br/><i>bcrypt.compare ~77 ms (dominant cost)</i>"]
        sess["SessionService.createAuthSession<br/><i>SET+EXPIRE auth-session, then setTokensCookie</i>"]
        prod["SessionQueueProducer.updateSession<br/><i>fire-and-forget, not awaited</i>"]
        ck["setTokensCookie<br/><i>access/refresh/socket tokens</i>"]
    end

    %% Session worker — same process, separate BullMQ consumer
    subgraph wk["Session worker (same process, separate consumer)"]
        proc["SessionUpdateProcessor<br/><i>@Processor updateSessionQueue</i>"]
    end

    %% (1)-(2) Request enters the handler
    ax -- "(1) POST /auth/sign-in (cookies + traceparent)" --> ctrl
    ctrl -- "(2) login(dto, req, res)" --> svc

    %% (3)-(5) UserService authenticates against Postgres + Redis
    svc -- "(3) authenticate(email, password)" --> usr
    usr -- "(4) findFirstUserWithPassword (case-insensitive)" --> pg
    usr -- "(5) GET lockout + UNLINK attempts/lockout" --> rd

    %% (6)-(7) SessionService creates the auth-session
    svc -- "(6) createAuthSession(user, meta, res)" --> sess
    sess -- "(7) SET + EXPIRE user-auth-session" --> rd

    %% (8)-(9) Async BullMQ enqueue
    sess -- "(8) updateSession (fire-and-forget)" --> prod
    prod -- "(9) EVALSHA bull:updateSessionQueue" --> rd

    %% (10) Cookie response back to the browser
    sess -- "(10) write Set-Cookie → 201" --> ck

    %% (11)-(12) Separate worker trace persists UserSession
    rd -- "(11) job pulled" --> proc
    proc -- "(12) UserSession upsert" --> pg

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

4. Per-step walkthrough

Section headers mirror the diagram step numbers in §3 — each §4.N covers (N) on the diagram. Total request duration 99.975 ms. Root span POST /auth/sign-in (@opentelemetry/instrumentation-http, span 6310aeae) parents everything ebit-api-side; ioredis spans are direct children of the Nest handler span. As of task #27 the trace also extends upward into the browser (reference trace d4342af7440f0d3beff8f3e5e46a6336, 36 spans).

4.1 Step (1) — Browser fetch + Express middleware chain reaches AuthController.signIn

Browser root span (ebit-fe-browser, reference trace d4342af7440f0d3beff8f3e5e46a6336): @opentelemetry/instrumentation-fetch registered in ebit-fe/src/otel-client.ts via <OtelClientInit/> mounted in src/app/[locale]/layout.tsx injects W3C traceparent on the outgoing fetch (propagateTraceHeaderCorsUrls: [/.*/]). Root operation POST, duration ~226 ms (browser-perceived; includes the ~100 ms of ebit-api work plus network + JS scheduling). NestJS's app.enableCors({ origin, credentials: true }) reflects the preflight's Access-Control-Request-Headers, so traceparent/tracestate are auto-allowed without a config change on ebit-api. The exporter posts OTLP/HTTP from the browser to http://localhost:4318/v1/traces (compose env NEXT_PUBLIC_OTEL_EXPORTER_OTLP_ENDPOINT); CORS for that endpoint is configured in observability/otel-collector.yml for origins http://localhost:{3000,3001,3003}. Web Vitals (CLS/LCP/INP/FCP/TTFB) are emitted as zero-duration spans on a separate web-vitals tracer — useful for RUM dashboards but not part of the sign-in causal chain.

Express middleware chain (12 sibling spans, 1.8 ms combined): registered in libs/shared/src/basic/base.main.ts:57-92 (cookieParser, session, passport.initialize, passport.session, global ValidationPipe) plus Nest's defaults (query, expressInit, jsonParser, urlencodedParser). Both middleware - <anonymous> spans (~690 µs + 230 µs) are OTel context-propagation and Nest global-prefix stubs. Span ordering reflects exit time, not registration order.

Outer Nest wrapperAuthController.signIn span c5e8cd2c, nestjs.type: request_context, 96.98 ms. Tags: nestjs.callback = signIn, http.route = /auth/sign-in. Source: auth.controller.ts:62-69. Parents the signIn handler span (c7e4366206, nestjs.type: handler, 95.16 ms).

4.2 Step (2) — signIn handler → AuthService.login

NestJS instrumentation 0.43.0 still emits only request_context + handler per request, but as of trace 4384ab9dc90c8671aa60924192cdd9e3 the handler now parents manual service spans (AuthService.login, UserService.authenticate, bcrypt.compare) wired in auth.service.ts / user.service.ts, plus Prisma spans (prisma:client:operation, prisma:engine:query, prisma:engine:db_query) emitted by @prisma/instrumentation registered in libs/shared/src/basic/pre/pre-otel.main.ts. The AuthService.login manual span runs ~104 ms and parents everything below.

4.3 Step (3) — AuthService.loginUserService.authenticate

AuthService.login (auth.service.ts:134-162) calls userService.authenticate (manual span, ~91 ms); if request.headers.origin === APP_FE_ORIGIN_ADMIN, additionally requires Admin/SuperAdmin (see §6 bullet 4 — admin gate is Origin-header-based). Then handleMfaLogin short-circuits with { requireMfa, token } if user.mfaSecret is set. The seeded local@example.com has no MFA, so it falls through to signIn(user, meta, res)SessionService.createAuthSession (covered in step (6)).

4.4 Step (4) — findFirstUserWithPassword against Postgres

UserService.authenticate (user.service.ts:714-742) calls findFirstUserWithPassword({ email }) (user.repository.ts:384-398, case-insensitive) — now visible as a prisma:client:operation (~10 ms) wrapping prisma:engine:query and a prisma:engine:db_query for the actual SELECT. The lookup hits the @unique constraint on email (hash index users_email_index at api.prisma:288). An unknown email returns USER_INVALID_CREDENTIALS before any Redis or bcrypt work runs — see SF-001.

4.5 Step (5) — Redis lockout GET + post-success UNLINK × 2 (bracketing bcrypt.compare)

Three Redis spans, with the ~77 ms bcrypt.compare manual span sitting between the GET and the two UNLINKs:

  • Redis get — 397 µs (053eb388). GET lockout:<email>. UserService.isUserLockedOut at user.service.ts:912-917. Key: USER_LOCKOUT_KEY_get at user/const.ts:18 — raw email embedded verbatim (see SF-003 for casing-bypass).
  • bcrypt.compare (manual span, ~77 ms). Confirms bcrypt is the dominant cost of the handler. After isUserLockedOut and the user.isBanned guard, the password is verified here.
  • Redis unlink × 2 — 422 µs + 215 µs (f59cc5f5, 398cac32). Post-password-success cleanup at user.service.ts:733-736: cache.del on both the attempts and lockout keys. ExtendedCacheClient (@bebkovan/server-core) maps del to UNLINK.

4.6 Step (6) — AuthService.loginSessionService.createAuthSession (incl. 3× JWT sign)

Before any Redis writes, createAuthSessionTokens (session.service.ts:141-177) signs three JWTs — access, refresh, socket — all with JWT_SECRET, different TTLs, different rt claim. signAsync is not instrumented, so the cost folds into the parent SessionService.createAuthSession span.

4.7 Step (7) — Redis SET + EXPIRE of user-auth-session

SessionService.createAuthSessionCache at session.service.ts:179-196. Writes user-auth-session:<uid>:<sKey>:<sId>{sId, userId, sKey}, TTL = AUTH_SESSION_TTL_SECONDS. sId/sKey are fresh randomUUID()s. The wrapper issues SET + EXPIRE separately rather than SETEX.

  • Redis set — 476 µs (64f73b23).
  • Redis expire — 339 µs (fcca8366).

4.8 Steps (8)–(9) — SessionQueueProducer.updateSession enqueues onto BullMQ

BullMQ, not RabbitMQ. SessionQueueProducer.updateSession (session.queue-producer.ts:13-29) calls queue.add(UPDATE_SESSION_QUEUE, sessionData, …). BullMQ uses Lua for atomic enqueue; ioredis reports EVALSHA. The call is not awaited (session.service.ts:105), so it races with the HTTP response — it completes first in this trace, but not guaranteed.

  • Redis evalsha — 1.58 ms (fcf4ae58). Step (8) is the producer-side enqueue; step (9) is the same EVALSHA viewed from the Redis side (one span in the trace, split here for diagram clarity — same pattern as bet-place §4.6/§4.8).

setTokensCookie (auth/cookies.ts:31-44) writes access_token (httpOnly), refresh_token (httpOnly, path: /auth/refresh), socket_token (httpOnly: false). domain defaults to process.env.BASE_DOMAIN ?? undefined. When disable_set_cookies_and_mask_tokens is off, the JSON body contains masked "cookie" strings — the E2E test ignores the body and asserts on the access_token cookie.

4.10 Steps (11)–(12) — separate session-update worker trace persists UserSession

Processing of the enqueued job is a separate trace rooted on BullMQJob updateSessionQueue (no traceparent is stored in the job payload — same gap as bet-place AF-2 in ../weaknesses-register.md). The job is consumed by session.update.queue-processor.ts:17, which writes the UserSession row (sessionKey PK, sessionId, userId, ip, countryCode, regionCode, lastActivity, userAgent) via session.repository.ts:23.

5. Data model

Table / key R/W Fields touched Schema / file
User (Postgres) R id, email (case-insensitive), password, isBanned, roles, mfaSecret, username libs/_prisma/src/schema/api.prisma:198-293@unique on email; hash index users_email_index at :288
UserSession (Postgres) W (async via BullMQ) sessionKey PK, sessionId, userId, ip, countryCode, regionCode, lastActivity, userAgent api.prisma:480-498; writer session.repository.ts:23
user-auth-session:<uid>:<sKey>:<sId> (Redis) W {sId, userId, sKey}, TTL = AUTH_SESSION_TTL_SECONDS session.service.ts:179-196
lockout:<email> (Redis) R/W flag "1", TTL = USER_LOCKOUT_DURATION_SECONDS (1 h) user.service.ts:913, 936-940; key builder user/const.ts:18
user:login-attempts:<email> (Redis) R/W integer counter user.service.ts:921-931; key builder user/const.ts:19-20
BullMQ queue updateSessionQueue W job payload SessionDto Redis-backed; session.queue-producer.ts:32-43

6. Failure modes

  1. Unknown-email timing oracle. user.service.ts:715-720 returns USER_INVALID_CREDENTIALS before running isUserLockedOut or bcrypt.compare. A known email incurs Redis GET + bcrypt (~80 ms, seen as the dominant slice of the 95 ms handler span in this trace); an unknown one returns after a single Prisma hit. An attacker can enumerate registered emails by timing alone, despite superficially similar error codes — and the error code itself differs (USER_INVALID_CREDENTIALS vs USER_INVALID_PASSWORD). Tracked: SF-001.
  2. Lockout counter reset bug. handleLoginAttempt (user.service.ts:935-941) deletes attemptsKey when the lockout is armed, and both keys share USER_LOCKOUT_DURATION_SECONDS as TTL. When the lockout expires, the counter is gone, so the attacker gets a fresh MAX_LOGIN_ATTEMPTS window on the same email. Tracked: SF-002.
  3. Thin SignInDto constraints. auth/dto/user-login.dto.ts:27-40 decorates email and password with only @IsString() — no @IsEmail, no @MaxLength, no @MinLength. The global ValidationPipe at libs/shared/src/basic/base.main.ts:64 still runs, but only enforces what the DTO declares. (@Evo.ValidateDto(false) at auth.controller.ts:61 affects response serialization only — see libs/shared/src/api/serialization/core/serializer/serializer.ts:116 — it does not disable request validation.) Effect: empty strings, multi-KB payloads, and non-email shapes reach bcrypt and the Redis lockout-key writer unchecked. Tracked: SF-003.
  4. Admin gate is Origin-header-based. auth.service.ts:146-156 admits admins only when request.headers.origin === APP_FE_ORIGIN_ADMIN. Any proxy that strips Origin silently disables the check.
  5. Cookie scope depends on BASE_DOMAIN. cookies.ts:11 uses process.env['BASE_DOMAIN'] ?? undefined. Unset → host-scoped cookie (matches E2E). Setting BASE_DOMAIN=.example.com without TLS (secure=false in local) leaks cookies across subdomains.

7. Unresolved

  • Prisma / Postgres not instrumented. processes lists only http/express/nestjs-core/ioredis on ebit-api. findFirstUserWithPassword (user.repository.ts:384-398) ran — auth succeeded — but no pg.query / prisma:client:* spans. Fix: add @prisma/instrumentation + @opentelemetry/instrumentation-pg.
  • Service-layer methods not traced. @opentelemetry/instrumentation-nestjs-core 0.43.0 traces controller dispatch only — AuthService.login, UserService.authenticate, SessionService.createAuthSession are opaque inside the 95 ms handler span. Wrap hot methods manually with tracer.startActiveSpan if finer granularity is needed.
  • Browser side has no spans. POST /auth/sign-in is a direct browser→:4000 axios call; it never transits Next.js Node. Root-anchoring on the ebit-api HTTP span is expected after task #15's helper fix.

Appendix A — pre-sign-in home-page SSR waterfall (trace 3ab661bc97f3ca84dcda40e3e52a7b5c)

Kept for reference — it is the flow users hit when opening dropbet before clicking "Login", not part of the sign-in contract.

Root span GET /[locale] (ebit-fe next.js, 270.9 ms) renders the App Router page at ebit-fe/src/app/[locale]/page.tsx and fans out six parallel SSR fetch calls via @vercel/otel/fetch:

  • GET /live-bets?count=20&type=BigWins + three count=10 follow-ups (LatestBets/HighRollers/LuckyWins) — all served via LiveBetsController.getMany + Redis LRANGE.
  • GET /casino/games/main, /casino/games/providers, /exchange-rates?fiatCurrency=USD, /currency — cache-aside (GET hit or SET+EXPIRE on miss).

Every sub-request traverses the same middleware chain as §4.1. No auth. Use this trace to document the SSR flow, not sign-in.