Authentication System — Overview
1 / 6
Frontend (Next.js)
Backend (NestJS)
Database (PostgreSQL)
External API
Error / Rejection
Success
Warning / Pending
🛒 Buyer
Feature 01 — Buyer Signup Updated
Multi-stage buyer registration. Email is the primary unique identifier. Phone uniqueness is enforced as a secondary layer after email OTP verification is confirmed. The phone number check uses 3-case logic: Case A (same email, unverified phone — skip to OTP), Case B (different email owns this phone — field-level rejection), Case C (free phone — hash password & store profile, send OTP). All OTPs expire in exactly 60 seconds. Profile name, phone, and Argon2id password hash are persisted before the SMS OTP is dispatched, enabling frictionless resume on retry.
Next.js NestJS AuthService PostgreSQL Cloudflare Turnstile BullMQ + Resend + Fast2SMS Redis
flowchart TD
    classDef fe fill:#dbeafe,stroke:#3b82f6,color:#1e40af,font-size:12px
    classDef be fill:#dcfce7,stroke:#16a34a,color:#166534,font-size:12px
    classDef db fill:#fef9c3,stroke:#ca8a04,color:#78350f,font-size:12px
    classDef ext fill:#f3e8ff,stroke:#9333ea,color:#4c1d95,font-size:12px
    classDef err fill:#fee2e2,stroke:#dc2626,color:#7f1d1d,font-size:12px
    classDef ok fill:#d1fae5,stroke:#059669,color:#064e3b,font-size:12px
    classDef warn fill:#fff7ed,stroke:#ea580c,color:#7c2d12,font-size:12px

    subgraph STAGE1 [Stage 1 — Email Verification]
        A([Buyer opens signup page]):::fe
        A --> CF{{Cloudflare Turnstile}}:::ext
        CF -->|Invalid| CFE["Bot check failed — stop"]:::err
        CF -->|Valid| EM[/"Enter email address"/]:::fe
        EM --> INIT["POST /api/v1/auth/buyer/signup/initiate
        body: email + turnstileToken"]:::be
        INIT --> RETIREDCHK[("Check retired_credentials
        email permanently retired?")]:::db
        RETIREDCHK -->|Retired| RET410["HTTP 410 Gone
        Credential permanently retired
        Cannot create new account"]:::err
        RETIREDCHK -->|Clear| EMCHK[("Query Users WHERE
        email = input AND isDeleted = false")]:::db
        EMCHK -->|New user| CU["Create User record
        isEmailVerified = false
        isPhoneVerified = false
        role = BUYER · status = PENDING"]:::db
        EMCHK -->|Exists unverified| RESEND_E["Upsert Otp record
        channel=email · purpose=VERIFICATION_OTP
        Reset attempts · rate-limit enforced"]:::be
        EMCHK -->|Email verified phone pending| SKIPPHONE["Return 200 COMPLETE_PHONE
        Skip to profile + phone form"]:::warn
        EMCHK -->|Fully active account| RLOGIN["Return 409 AUTH_EMAIL_EXISTS
        Email already registered — log in"]:::err
        CU --> EMAILOTP["NotificationsService dispatch email otp
        BullMQ notifications queue: send-notification
        Generate 6-digit OTP
        Hash OTP with SHA-256
        Save to Otp · channel=email · purpose=VERIFICATION_OTP · TTL 60 seconds
        Rate limit: 5 resends per hour"]:::be
        RESEND_E --> EMAILOTP
        EMAILOTP --> OTPPAGE[/"Enter 6-digit email OTP
        Resend button disabled for 60s"/]:::fe
        OTPPAGE --> VFYEMAIL["POST /api/v1/auth/buyer/signup/verify-email
        body: email + otp"]:::be
        VFYEMAIL --> OTPLOAD[("Load Otp record
        identity=email · channel=email · purpose=VERIFICATION_OTP
        Compare SHA-256 hash
        Check expiresAt · check attempts")]:::db
        OTPLOAD -->|Expired after 60s| OTPEXP["OTP expired
        Click Resend OTP for a new code"]:::err
        OTPLOAD -->|Attempts exceeded 5| OTPMAX["Too many attempts
        Request new OTP via Resend"]:::err
        OTPLOAD -->|Hash mismatch| OTPWRONG["Incorrect OTP
        Remaining attempts shown"]:::err
        OTPLOAD -->|Valid match| EMVERIFIED["Set isEmailVerified = true
        Delete Otp record"]:::db
    end

    subgraph STAGE2 [Stage 2 — Profile and Phone Submission]
        EMVERIFIED --> PROFILEFORM
        SKIPPHONE --> PROFILEFORM
        PROFILEFORM[/"Enter: Phone Number
        Profile Name · Password · Confirm Password"/]:::fe
        PROFILEFORM --> PHONESUBMIT["POST /api/v1/auth/buyer/signup/complete
        Zod: phone 10-digit starts 6-9
        Zod: password strength
        Zod: confirm password match"]:::be
        PHONESUBMIT --> PHONE3CASE{"Phone 3-Case Check"}:::be

        PHONE3CASE -->|Case A: same email owns phone unverified| CASEA["Return 200 VERIFY_PHONE_EXISTING
        Name and password already stored
        Show OTP screen only — no re-entry"]:::warn

        PHONE3CASE -->|Case B: different email owns phone| CASEB["Return 409 AUTH_PHONE_EXISTS
        Phone already registered
        Highlight phone field only
        All other fields retain values"]:::err

        PHONE3CASE -->|Case C: phone is free| CASEC["Hash password with Argon2id
        cost=4 · parallelism=2 · memory=65536
        Store profileName + phone + passwordHash
        Data saved BEFORE OTP is sent
        Generate 6-digit phone OTP
        Hash with SHA-256 · TTL 60s
        NotificationsService dispatch sms otp
        BullMQ notifications queue -> Fast2SMS
        Return 200 VERIFY_PHONE resendAfter=60"]:::be
    end

    subgraph STAGE3 [Stage 3 — Phone OTP and Account Activation]
        CASEA --> PHONEOTPPAGE
        CASEC --> PHONEOTPPAGE
        PHONEOTPPAGE[/"Enter 6-digit SMS OTP
        Resend OTP button — disabled 60s"/]:::fe
        PHONEOTPPAGE --> VFYPHONE["POST /api/v1/auth/buyer/signup/verify-phone
        body: email + phone + otp"]:::be
        VFYPHONE --> PHOTPLOAD[("Load Otp record
        identity=phone · channel=phone · purpose=VERIFICATION_OTP
        Hash compare SHA-256
        Check 60s expiry · check attempts")]:::db
        PHOTPLOAD -->|Expired| PHOTPEXP["OTP expired
        Click Resend OTP for a new code"]:::err
        PHOTPLOAD -->|Invalid| PHOTPERR["Incorrect OTP
        Resend available after 60s cooldown"]:::err
        PHOTPLOAD -->|Valid| ACTIVATE["Set isPhoneVerified = true
        Set status = ACTIVE
        Delete Otp record
        Issue RS256 JWT — 15min TTL
        Issue Refresh Token — 7d TTL
        SHA-256 hash refresh token stored
        Set httpOnly + Secure + SameSite=Strict"]:::be
        ACTIVATE --> AUDITLOG[("Insert AuditLog
        Event: BUYER_SIGNUP
        IP + userAgent + timestamp")]:::db
        AUDITLOG --> WELCOME["NotificationsService dispatch email welcome
        BullMQ notifications queue: send-notification
        via Resend API"]:::be
        WELCOME --> SUCCESS(["Signup complete
        Redirect to home or redirect_url
        Sync guest cart and wishlist"]):::ok
    end
🔒 Security Properties
Argon2id (cost=4, parallelism=2, memory=65536) · All OTPs hashed SHA-256 before storage — plaintext never persisted · Universal 60-second OTP TTL · Anti-enumeration: same response shape for new and existing emails · Cloudflare Turnstile on form entry · Redis rate-limit: 5 OTP sends/hour/phone · Retired credential check via dedicated table → HTTP 410
⚡ API Reference — Buyer Signup Endpoints
REQUEST — Initiate Signup
{
  "method": "POST",
  "endpoint": "/api/v1/auth/buyer/signup/initiate",
  "headers": { "Content-Type": "application/json" },
  "body": {
    "email": "buyer@gmail.com",
    "turnstileToken": "cf-turnstile-response-token"
  }
}
RESPONSE — New Email
{
  "status": 200,
  "body": {
    "action": "VERIFY_EMAIL",
    "resendAfter": 60
  }
}

// Retired credential:
{
  "status": 410,
  "body": { "code": "CREDENTIAL_RETIRED" }
}
REQUEST — Complete Profile
{
  "method": "POST",
  "endpoint": "/api/v1/auth/buyer/signup/complete",
  "body": {
    "email": "buyer@gmail.com",
    "phone": "9876543210",
    "profileName": "Rahul Kumar",
    "password": "SecurePass@123"
  }
}
RESPONSE — Phone 3-Cases
// Case A — same email, phone unverified:
{ "status": 200, "code": "VERIFY_PHONE_EXISTING" }

// Case B — different email owns phone:
{ "status": 409, "code": "AUTH_PHONE_EXISTS",
  "message": "Phone number already registered." }

// Case C — phone is free:
{ "status": 200, "action": "VERIFY_PHONE",
  "resendAfter": 60 }
📋 Full Flow Description
BUYER → Opens /signup → Solves Cloudflare Turnstile → Enters email → Clicks "Continue" FRONTEND (Zod) → Validate email format IF invalid → show inline error → stop → POST /api/v1/auth/buyer/signup/initiate { email, turnstileToken } BACKEND → Verify Turnstile token — IF invalid → 400 AUTH_TURNSTILE_FAILED → stop → Check retired_credentials table — IF email found → Return 410 Gone → stop → Query User WHERE email = input AND isDeleted = false CASE A — Email does NOT exist (new user): → Create User { email, isEmailVerified: false, isPhoneVerified: false, status: PENDING, role: BUYER } → Generate 6-digit OTP → Hash SHA-256 → Save Otp { identity: email, channel: email, purpose: VERIFICATION_OTP, hash, expiresAt: +60s, attempts: 0 } → NotificationsService dispatch email otp → BullMQ notifications queue → Resend API sends HTML email → Return 200 { action: "VERIFY_EMAIL", resendAfter: 60 } CASE B — Email exists, isEmailVerified = false: → Upsert Otp record (channel=email, purpose=VERIFICATION_OTP), reset attempts → Resend OTP (rate-limit 5/hr) → Return 200 { action: "VERIFY_EMAIL", hint: "OTP re-sent" } CASE C — Email exists, isEmailVerified = true, isPhoneVerified = false: → Return 200 { action: "COMPLETE_PHONE" } → skip email step CASE D — Email exists, fully active: → Return 409 AUTH_EMAIL_EXISTS → redirect to login FRONTEND → /signup/verify-email → 6-digit OTP input → "Resend OTP" button disabled for 60s countdown → Buyer types OTP → POST /api/v1/auth/buyer/signup/verify-email { email, otp } BACKEND → Load Otp record for email (channel=email, purpose=VERIFICATION_OTP) IF expiresAt < NOW() → Return 400 { code: "OTP_EXPIRED", message: "Your OTP has expired. Please click Resend OTP to receive a new code." } IF attempts ≥ 5 → Return 429 AUTH_OTP_LOCKED → Hash submitted OTP → constant-time compare with stored hash IF mismatch → Increment attempts → Return 400 AUTH_OTP_INVALID IF match → Set isEmailVerified = true → Delete Otp record → Return 200 FRONTEND → /signup/complete-profile → Shows 4 fields: Phone Number (10-digit Indian mobile, starts 6–9) Profile Name (min 2, max 50 chars) Password (min 8 chars, 1 uppercase, 1 number) Confirm Password (must match) → All Zod-validated client-side before submission → POST /api/v1/auth/buyer/signup/complete { email, phone, profileName, password } BACKEND — Phone 3-Case Logic CASE A — same email owns this phone AND isPhoneVerified = false: → profileName + password already stored in prior session → Return 200 { code: "VERIFY_PHONE_EXISTING" } → Frontend: show 6-digit OTP screen ONLY — do not show profile form again CASE B — a different email owns this phone (verified OR unverified): → Return 409 { code: "AUTH_PHONE_EXISTS", message: "Phone number already registered. Please enter another phone number." } → Frontend: highlight phone field only — name and password fields retain their values CASE C — phone number is free: → Hash password Argon2id (cost=4, parallelism=2, memory=65536) → Update User { profileName, phone, passwordHash } — DATA STORED BEFORE OTP IS SENT → Check Redis OTP rate-limit: max 5 sends/hr/phone IF exceeded → Return 429 AUTH_OTP_RATE_LIMIT → stop → Generate 6-digit phone OTP → Hash SHA-256 → Save to Otp { identity: phone, channel: phone, purpose: VERIFICATION_OTP, TTL: 60s } → NotificationsService dispatch sms otp → BullMQ notifications queue → Fast2SMS → Return 200 { action: "VERIFY_PHONE", resendAfter: 60 } FRONTEND → /signup/verify-phone → 6-digit OTP input + 60s resend timer → POST /api/v1/auth/buyer/signup/verify-phone { identifier, identifierType, otp } BACKEND → Same OTP verification logic (SHA-256 compare, attempts check, 60s expiry) IF expired → "Your OTP has expired. Please click Resend OTP to receive a new code." IF match: → Set isPhoneVerified = true → status = ACTIVE → Delete Otp record → Create Session → Issue RS256 JWT (15min) + Refresh Token (7d) → Store refresh token as SHA-256 hash in Session table → Set httpOnly + Secure + SameSite=Strict cookies → NotificationsService dispatch email welcome → BullMQ notifications queue → Insert AuditLog { event: BUYER_SIGNUP, ip, userAgent, timestamp } → Return 201 { user: { id, profileName, email, phone, role: BUYER } } ✓ SIGNUP COMPLETE → Frontend stores non-sensitive user fields in Zustand → Socket.IO client connects → joins room user:{userId} → Sync guest cart (POST /api/v1/buyer/cart/sync with guest items) → Sync guest wishlist (POST /api/v1/buyer/wishlist/sync with guest items) → Redirect to /home (or stored redirect_url from sessionStorage)
🛒 Buyer
Feature 02 — Buyer Login Full Rebuild
Complete redesign. A single identifier input field accepts either email or phone. Client-side Zod detection routes the input type. The password screen always presents two links: "Forgot Password" and "Login with OTP". Path A (password) triggers mandatory OTP 2FA after correct credentials. Path B (OTP-only) is passwordless. Path C (forgot password) uses the same unified identifier entry with anti-enumeration (always returns 200). Account lockout: 5 failures → 30-minute Redis lock.
Next.js NestJS AuthService PostgreSQL Redis (lockout) Cloudflare Turnstile Resend + Fast2SMS
flowchart TD
    classDef fe fill:#dbeafe,stroke:#3b82f6,color:#1e40af,font-size:12px
    classDef be fill:#dcfce7,stroke:#16a34a,color:#166534,font-size:12px
    classDef db fill:#fef9c3,stroke:#ca8a04,color:#78350f,font-size:12px
    classDef ext fill:#f3e8ff,stroke:#9333ea,color:#4c1d95,font-size:12px
    classDef err fill:#fee2e2,stroke:#dc2626,color:#7f1d1d,font-size:12px
    classDef ok fill:#d1fae5,stroke:#059669,color:#064e3b,font-size:12px
    classDef warn fill:#fff7ed,stroke:#ea580c,color:#7c2d12,font-size:12px

    A([Buyer Opens /login Page]):::fe
    A --> CF{{Cloudflare Turnstile}}:::ext
    CF -->|Fail| CFE[Bot check failed — stop]:::err
    CF -->|Pass| STEP1[/Step 1: Single input field
Placeholder: Enter email or phone number/]:::fe

    STEP1 --> DETECT{Client-side Zod
Detection}:::fe
    DETECT -->|Contains @ sign| EMAIL_VALID{Valid email format?}:::fe
    DETECT -->|10-digit starts 6-9| PHONE_VALID[Store as phone
type: phone]:::fe
    DETECT -->|Neither matches| INPUT_ERR[Inline error:
Enter a valid email address
or 10-digit Indian mobile number]:::err

    EMAIL_VALID -->|Invalid format| INPUT_ERR
    EMAIL_VALID -->|Valid| EMAIL_STORE[Store as email
type: email]:::fe

    EMAIL_STORE --> PWDSCREEN
    PHONE_VALID --> PWDSCREEN

    PWDSCREEN[/Step 2: Password Screen
Label shows entered identifier
Masked password field with show/hide toggle
Link 1: Forgot Password
Link 2: Login with OTP/]:::fe

    PWDSCREEN --> CHOICE{User action?}:::fe
    CHOICE -->|Enters password and clicks Sign In| PATHA[Path A — Password + Mandatory 2FA]:::fe
    CHOICE -->|Clicks Login with OTP| PATHB[Path B — OTP Only passwordless]:::fe
    CHOICE -->|Clicks Forgot Password| PATHC[Path C — Password Reset]:::fe

    subgraph PA [Path A — Password + Mandatory OTP 2FA]
        PATHA --> PA1[POST /api/v1/auth/buyer/login
body: identifier + identifierType
+ password + turnstileToken]:::be
        PA1 --> PA_LOCK[(Check Redis AUTH:LOCKOUT:identifier)]:::db
        PA_LOCK -->|Locked 30min| PA_LOCKED[Error: Too many attempts
Account locked — try in 30 minutes
Offer Forgot Password]:::err
        PA_LOCK -->|Clear| PA_FIND[(Find User by identifier
Check isDeleted = false
Check status not SUSPENDED)]:::db
        PA_FIND -->|Not found| PA_FAIL[Return 401 generic
Increment fail counter
Never reveal which field wrong]:::err
        PA_FIND -->|Found| PA_PWD[Argon2id password compare]:::be
        PA_PWD -->|Wrong| PA_FAIL2[(Increment Redis AUTH:FAIL
At 5: set LOCKOUT TTL 1800s)]:::db
        PA_FAIL2 --> PA_FAIL
        PA_PWD -->|Correct| PA_CHECKS[(BuyerAuthController post-password checks:
phone login requires verified phone
email must be verified
profile/password/phone must be complete
Reset AUTH:FAIL counter)]:::db
        PA_CHECKS -->|phone identifier unverified| PA_UNVER[400 AUTH_PHONE_UNVERIFIED
Prompt login with email to verify phone]:::err
        PA_CHECKS -->|email not verified| PA_EMAIL_VERIFY[Return signup email OTP action
NotificationsService dispatch email otp]:::warn
        PA_CHECKS -->|profile incomplete| PA_COMPLETE[Return COMPLETE_PROFILE
Frontend resumes signup completion]:::warn
        PA_CHECKS -->|OK| PA_OTP[Generate 6-digit OTP
Hash SHA-256 — TTL 60s
Send to identifier channel]:::be
        PA_OTP -->|email identifier| PA_EMAIL[NotificationsService dispatch email otp
BullMQ notifications queue: send-notification
Return 200 VERIFY_OTP
medium: email
maskedEmail: bu***@gmail.com]:::be
        PA_OTP -->|phone identifier| PA_SMS[NotificationsService dispatch sms otp
BullMQ notifications queue: send-notification
Return 200 VERIFY_OTP
medium: sms
maskedPhone: ******3210]:::be
        PA_EMAIL --> PA_OTP_IN
        PA_SMS --> PA_OTP_IN
        PA_OTP_IN[/Enter 6-digit OTP
60s resend timer
Resend OTP after countdown/]:::fe
        PA_OTP_IN --> PA_VERIFY[POST /api/v1/auth/buyer/login/verify-otp]:::be
        PA_VERIFY --> PA_VCHECK[(Verify OTP hash
Check 60s expiry
Check attempt count)]:::db
        PA_VCHECK -->|Expired| PA_EXP[OTP expired
Click Resend OTP for a new code]:::err
        PA_VCHECK -->|Invalid| PA_INV[OTP invalid — offer resend]:::err
        PA_VCHECK -->|Valid| CREATE_SESSION[Create Session record
Issue RS256 JWT 15min
Issue Refresh Token 7d
Set httpOnly Secure SameSite=Strict cookies]:::be
    end

    subgraph PB [Path B — Login with OTP Passwordless]
        PATHB --> PB1[POST /api/v1/auth/buyer/login/request-otp
body: identifier + identifierType + turnstileToken]:::be
        PB1 --> PB_FIND[(Find User by identifier
Check active status)]:::db
        PB_FIND -->|Not found| PB_SIGNUP[Return 200 REDIRECT_SIGNUP
Unknown identifier — prompt signup]:::warn
        PB_FIND -->|Found| PB_RL[(Redis rate-limit check
5 OTP sends per hour per identifier)]:::db
        PB_RL -->|Exceeded| PB_LIMIT[Return 429 OTP_RATE_LIMIT]:::err
        PB_RL -->|OK| PB_OTP[Generate 6-digit OTP
Hash SHA-256 — TTL 60s
Send to identifier channel]:::be
        PB_OTP --> PB_OTP_IN[/Enter 6-digit OTP
60s resend timer/]:::fe
        PB_OTP_IN --> PB_VFY[POST /api/v1/auth/buyer/login/verify-otp]:::be
        PB_VFY --> PB_CHK[(Verify OTP hash + expiry)]:::db
        PB_CHK -->|Valid| CREATE_SESSION
        PB_CHK -->|Invalid or expired| PB_ERR[OTP error — resend offered]:::err
    end

    subgraph PC [Path C — Forgot Password]
        PATHC --> PC1[/Single input field shown
Enter your email or phone number
Same detection logic as Step 1/]:::fe
        PC1 --> PC2[POST /api/v1/auth/buyer/forgot-password
body: identifier + identifierType]:::be
        PC2 --> PC3[(Look up user account)]:::db
        PC3 -->|Always returns 200 — anti-enumeration| PC4[IF user found:
Generate OTP → Hash SHA-256
TTL 60s — save Otp purpose=PASSWORD_RESET_OTP
NotificationsService dispatch identifier channel otp
BullMQ notifications queue: send-notification]:::be
        PC4 --> PC5[Return 200 regardless of account existence
Frontend: Check your inbox or messages
Do not reveal whether account exists]:::fe
        PC5 --> PC6[/Enter OTP + New Password/]:::fe
        PC6 --> PC7[POST /api/v1/auth/buyer/reset-password
body: identifier + identifierType + otp + password]:::be
        PC7 --> PC8[(Verify OTP — 60s TTL
Hash new password Argon2id
Invalidate ALL active sessions
Delete Otp reset record
Clear lockout keys)]:::db
        PC8 -->|Success| PC9([Password reset — redirect to /login]):::ok
        PC8 -->|Expired OTP| PC_EXP[Error: OTP expired
Request a new reset code]:::err
    end

    CREATE_SESSION --> LOGIN_OK([Login Successful
Sync guest cart + wishlist via current bulk APIs
Redirect to /home or redirect_url]):::ok
🔒 Security Properties
Generic error messages on all failure paths — never reveal which field is wrong · Redis lockout after 5 failures (30-min TTL) · 60-second OTP TTL universal · Path B rate-limited to 5 OTP sends/hour/identifier · Forgot password always returns 200 regardless of account existence (anti-enumeration) · RS256 asymmetric JWT (not HS256) · httpOnly + Secure + SameSite=Strict cookies — never localStorage
⚡ API Reference — Buyer Login Endpoints
REQUEST — Password Login
{
  "method": "POST",
  "endpoint": "/api/v1/auth/buyer/login",
  "body": {
    "identifier": "buyer@gmail.com",
    "identifierType": "email",
    "password": "SecurePass@123",
    "turnstileToken": "cf-token"
  }
}
RESPONSE — OTP Required (2FA)
{
  "status": 200,
  "body": {
    "action": "VERIFY_OTP",
    "medium": "email",
    "maskedEmail": "bu***@gmail.com"
  }
}
REQUEST — OTP-Only Login
{
  "method": "POST",
  "endpoint": "/api/v1/auth/buyer/login/request-otp",
  "body": {
    "identifier": "9876543210",
    "identifierType": "phone",
    "turnstileToken": "cf-token"
  }
}
RESPONSE — Locked / Success
// Locked account:
{ "status": 429,
  "code": "AUTH_ACCOUNT_LOCKED",
  "retryAfter": 1800 }

// Successful login (after OTP):
{ "status": 200,
  "user": { "id": "uuid", "profileName": "Rahul",
    "email": "buyer@gmail.com", "role": "BUYER" } }
📋 Full Flow Description
BUYER → Opens /login → Solves Cloudflare Turnstile STEP 1 — UNIFIED IDENTIFIER INPUT → Single input field: placeholder "Enter email or phone number" → No tabs, no method selection — one unified entry point FRONTEND (Zod client-side detection) IF input contains "@" → validate email format → store { identifier, type: "email" } IF input is 10-digit starting 6–9 → store { identifier, type: "phone" } IF neither → inline error: "Enter a valid email address or 10-digit Indian mobile number." STEP 2 — PASSWORD SCREEN → Label shows the entered identifier (e.g., buyer@gmail.com or 9876543210) → Password field (masked, with show/hide toggle) → Two links always visible: "Forgot Password" | "Login with OTP" ════════════════════════════════════ PATH A — PASSWORD + MANDATORY OTP 2FA → Buyer enters password → Clicks "Sign In" → POST /api/v1/auth/buyer/login { identifier, identifierType, password, turnstileToken } BACKEND → Verify Turnstile → Check Redis AUTH:LOCKOUT:{identifier} — IF locked → 429 "Account locked. Try in 30 minutes." → Find User by identifier (email or phone) IF not found → increment AUTH:FAIL:{identifier} → Return 401 "Incorrect email or password" (generic) → Argon2id compare IF wrong → increment fail counter → at 5: set LOCKOUT TTL=1800s → Return 401 (same generic message) IF correct → BuyerAuthController checks verification state before login OTP: - phone identifier with unverified phone → 400 AUTH_PHONE_UNVERIFIED and stay on login - email not verified → send signup email OTP and return verification action - incomplete profile/phone/password → return COMPLETE_PROFILE → Reset AUTH:FAIL counter → Generate 6-digit OTP → Hash SHA-256 → Otp record purpose=LOGIN_OTP (TTL: 60s) IF email identifier → NotificationsService dispatch email otp → BullMQ notifications queue → Resend API → Return 200 { action: "VERIFY_OTP", medium: "email", maskedEmail: "bu***@gmail.com" } IF phone identifier → NotificationsService dispatch sms otp → BullMQ notifications queue → Fast2SMS → Return 200 { action: "VERIFY_OTP", medium: "sms", maskedPhone: "******3210" } FRONTEND → Show OTP input + 60s resend timer → POST /api/v1/auth/buyer/login/verify-otp { identifier, identifierType, otp } → BACKEND: Verify OTP → Create Session → Issue RS256 tokens → Set cookies → ✓ LOGIN COMPLETE ════════════════════════════════════ PATH B — LOGIN WITH OTP (Passwordless) → Buyer clicks "Login with OTP" on password screen → OTP sent to the initially entered identifier (email or phone — already known) → POST /api/v1/auth/buyer/login/request-otp { identifier, identifierType, turnstileToken } BACKEND → Find User by identifier IF not found → Return 200 { action: "REDIRECT_SIGNUP" } (anti-enumeration — do not say "not found") IF found → Check Redis rate-limit: max 5 OTP sends per hour per identifier IF exceeded → Return 429 AUTH_OTP_RATE_LIMIT → Generate OTP (TTL 60s) → send to identifier channel → OTP verified → Session created → ✓ LOGIN COMPLETE ════════════════════════════════════ PATH C — FORGOT PASSWORD → Buyer clicks "Forgot Password" on password screen → Show single input field: "Enter your email or phone number" → Same Zod identifier detection logic as Step 1 → POST /api/v1/auth/buyer/forgot-password { identifier, identifierType } BACKEND → Look up user account (silently) → ALWAYS return 200 regardless of whether account exists (anti-enumeration protection) IF user found: → Generate OTP → Hash SHA-256 → Otp record purpose=PASSWORD_RESET_OTP (TTL: 60s) → NotificationsService dispatch identifier channel otp through BullMQ notifications queue → Frontend: "Check your inbox or messages." (No account existence hint) → Buyer enters OTP + new password → POST /api/v1/auth/buyer/reset-password { identifier, identifierType, otp, password } BACKEND: Verify OTP → Hash new password (Argon2id) → Update User → Invalidate ALL active Sessions for this user → Delete reset OTP → Return 200 → Frontend: "Password reset. Please log in." → Redirect /login
🏪 Seller
Feature 12 — Seller Signup Updated
Current seller onboarding follows the implemented controller routes: signup initiate → email OTP → phone OTP → authenticated PAN verification → Cashfree bank verification → trademark submission → admin review. Bank verification supports direct bank-account validation through Cashfree BAV or UPI reverse penny drop. If Cashfree returns a verified result, SellerBankAccountDetails.isVerified becomes true and the seller can submit trademark. If the provider is pending, the seller refreshes status and admin can refresh, approve only when Cashfree is valid, or reject with a reason.
Next.js NestJS PostgreSQL Cloudflare Turnstile BullMQ + Resend + Fast2SMS Admin Panel
flowchart TD
    classDef fe fill:#dbeafe,stroke:#3b82f6,color:#1e40af,font-size:12px
    classDef be fill:#dcfce7,stroke:#16a34a,color:#166534,font-size:12px
    classDef db fill:#fef9c3,stroke:#ca8a04,color:#78350f,font-size:12px
    classDef ext fill:#f3e8ff,stroke:#9333ea,color:#4c1d95,font-size:12px
    classDef err fill:#fee2e2,stroke:#dc2626,color:#7f1d1d,font-size:12px
    classDef ok fill:#d1fae5,stroke:#059669,color:#064e3b,font-size:12px
    classDef warn fill:#fff7ed,stroke:#ea580c,color:#7c2d12,font-size:12px

    A([Seller Opens /seller/signup]):::fe
    A --> CF{{Cloudflare Turnstile}}:::ext
    CF -->|Fail| CFE[Bot check failed]:::err

    subgraph S1 [Stage 1 — Profile Details]
        CF -->|Pass| FORM[/Enter: Profile Name
Phone Number
Email Address
Password + Confirm Password
Shop Address/]:::fe
        FORM --> SUBMIT[POST /api/v1/auth/seller/signup/initiate
Zod validation all fields
turnstileToken included]:::be
        SUBMIT --> RETIREDCHK[(Check retired_credentials table
for email and phone)]:::db
        RETIREDCHK -->|Retired| RET410[HTTP 410 Gone
Credential permanently retired]:::err
        RETIREDCHK -->|Clear| DUPCHK[(Check email uniqueness
Check phone uniqueness)]:::db
        DUPCHK -->|Email or phone taken| DUPCHK_ERR[Return 409 — field already registered]:::err
        DUPCHK -->|Both unique| CREATE_USER[Create User: role = SELLER
userProfileStatus = PENDING
Hash password Argon2id now
Create SellerProfile record
Create SellerBankAccount stub]:::db
        CREATE_USER --> EMAIL_OTP_Q[NotificationsService dispatch email otp
BullMQ notifications queue: send-notification
Generate OTP — Hash SHA-256 — TTL 60s]:::be
        EMAIL_OTP_Q --> PHONE_OTP_Q[NotificationsService dispatch sms otp
BullMQ notifications queue: send-notification
Generate phone OTP — Hash SHA-256 — TTL 60s]:::be
    end

    subgraph S2 [Stage 2 — Email OTP Verification]
        PHONE_OTP_Q --> EV_PAGE[/Enter Email OTP
6-digit code — 60s countdown/]:::fe
        EV_PAGE --> VERIFY_E[POST /api/v1/auth/seller/signup/verify-email]:::be
        VERIFY_E --> EV_CHK[(Verify OTP hash
Check 60s expiry
Check attempts)]:::db
        EV_CHK -->|Invalid or expired| EV_ERR[OTP error — offer resend after 60s]:::err
        EV_CHK -->|Valid| EV_OK[Set isEmailVerified = true
accountSetupPhase = EMAIL_VERIFIED
Send phone signup OTP]:::db
    end

    subgraph S3 [Stage 3 — Phone OTP Verification]
        EV_OK --> PV_PAGE[/Enter Phone OTP
6-digit SMS code — 60s countdown/]:::fe
        PV_PAGE --> VERIFY_P[POST /api/v1/auth/seller/signup/verify-phone]:::be
        VERIFY_P --> PV_CHK[(Verify phone OTP hash
Check 60s expiry)]:::db
        PV_CHK -->|Invalid or expired| PV_ERR[OTP error — offer resend]:::err
        PV_CHK -->|Valid| PV_OK[Set isPhoneVerified = true
accountSetupPhase = PHONE_VERIFIED
Issue onboarding access and refresh cookies]:::db
    end

    subgraph S4 [Stage 4 — PAN + Cashfree Bank Verification]
        PV_OK --> PAN_COLLECT[/Enter PAN Card Number
Requires seller JWT issued after phone OTP/]:::fe
        PAN_COLLECT --> KYC_INLINE[POST /api/v1/auth/seller/signup/verify-pan
SellerPanVerificationService verifies PAN
Save kycRegisterName
accountSetupPhase = KYC_VERIFIED]:::be
        KYC_INLINE -->|KYC failed — invalid PAN| KYC_INLINE_FAIL[Return error: Invalid PAN
Seller must retry with correct PAN]:::err
        KYC_INLINE -->|KYC passed| METHOD{Choose verification method}:::fe
        METHOD -->|BANK or BANK_DETAILS| BANK_FORM[/Account holder name
Account number
IFSC code/]:::fe
        METHOD -->|UPI| UPI_START[/Start UPI verification
Cashfree reverse penny drop link/QR/]:::fe
        BANK_FORM --> BANK_SUBMIT[POST /api/v1/auth/seller/signup/bank-details
primaryPaymentMethod = BANK_DETAILS or BANK
Encrypt account number
Call Cashfree verifyBankAccountSync]:::be
        UPI_START --> UPI_SUBMIT[POST /api/v1/auth/seller/signup/bank-details
primaryPaymentMethod = UPI
Create Cashfree RPD request
Return QR and UPI deep links]:::be
        BANK_SUBMIT --> BANK_RESULT{Cashfree BAV status}:::ext
        UPI_SUBMIT --> UPI_WAIT[Seller pays Rs.1 from Cashfree link or QR
Then frontend calls status endpoint]:::fe
        UPI_WAIT --> UPI_STATUS[GET /api/v1/auth/seller/signup/bank-verification/:verificationId/status
or /status/current]:::be
        BANK_RESULT -->|VALID / ACCOUNT_IS_VALID| BANK_VERIFIED[(SellerBankAccountDetails.isVerified = true
bankVerificationStatus = VERIFIED
accountSetupPhase = BANK_VERIFIED)]:::db
        BANK_RESULT -->|Pending / validation in progress| BANK_PENDING[bankVerificationStatus = VALIDATION_INITIATED
Frontend or admin refreshes current status]:::warn
        BANK_RESULT -->|Invalid / duplicate / daily limit| BANK_FAIL[VALIDATION_FAILED or 429/409
Seller retries or contacts admin]:::err
        UPI_STATUS -->|SUCCESS| BANK_VERIFIED
        UPI_STATUS -->|CREATED / PENDING| UPI_PENDING[bankVerificationStatus = REVERSE_PENNY_DROP_PENDING
Refresh status after payment]:::warn
        UPI_STATUS -->|FAILED / EXPIRED| BANK_FAIL
    end

    subgraph S5 [Stage 5 — Brand / Trademark Submission]
        BANK_VERIFIED --> TM_FORM[/Enter Trademark:
Brand Name — required — uniqueness check
Logo/description are seller brand UI concerns
Auth route currently accepts trademark name/]:::fe
        TM_FORM --> TM_SUBMIT[POST /api/v1/auth/seller/signup/trademark
Check retired brand names and TradMark uniqueness]:::be
        TM_SUBMIT --> TM_UNIQ[(Check TradMark table
name case-insensitive)]:::db
        TM_UNIQ -->|Name taken| TM_ERR[Return 409
Trademark name already registered
Try another name]:::err
        TM_UNIQ -->|Unique| TM_CREATE[Create TradMark
tradeMarkStatus = PENDING_APPROVAL
Update User accountSetupPhase = TRADEMARK_SUBMITTED]:::db
        TM_CREATE --> NOTIFY_ADMIN[NotificationsService dispatches
seller-trademark-submitted email
admin-new-trademark email]:::be
    end

    subgraph SIGNUP_COMPLETE [Signup Complete — Always]
        NOTIFY_ADMIN --> SESSION_CREATE[Create Session record
Issue RS256 JWT — 15min
Issue Refresh Token — 7d
Set httpOnly cookies
Log AuditLog: SELLER_SIGNUP]:::be
        SESSION_CREATE --> TRADEMARK_STATUS{seller.trademarkStatus?}:::fe
        TRADEMARK_STATUS -->|TRADEMARK_PENDING| WAIT_PAGE[Redirect to /seller/verification-status
Shows: Progress tracker
Profile DONE Bank status Trademark PENDING
Trademark under review up to 48 hours
Cannot access dashboard yet]:::warn
        TRADEMARK_STATUS -->|ACTIVE — admin approved| DASH([Redirect to /seller/dashboard
Can list products and receive orders
Payouts require verified bank status]):::ok
    end

    subgraph ADMIN_APPROVAL [Admin Approval Flow — Parallel]
        ADMIN1[Admin logs into /admin/trademarks
Views pending submissions
Checks: uniqueness + validity]:::ext
        ADMIN1 -->|APPROVE| APPROVE[PATCH /api/v1/admin/trademarks/:id/approve
Brand.status = ACTIVE
Seller.status = ACTIVE]:::be
        APPROVE --> BANK_READY{Admin service refreshes Cashfree bank status}:::be
        BANK_READY -->|Bank verified| APPROVE_ACTIVE[User status ACTIVE
accountSetupPhase = COMPLETED]:::ok
        BANK_READY -->|Bank not verified| APPROVE_PENDING[Trademark active but user remains PENDING
accountSetupPhase = TRADEMARK_SUBMITTED]:::warn
        APPROVE_ACTIVE --> APPROVE_NOTIFY[NotificationsService dispatches trademark-approved email]:::be
        APPROVE_PENDING --> APPROVE_NOTIFY
        ADMIN1 -->|REJECT| REJECT[PATCH /api/v1/admin/trademarks/:id/reject
body: rejectionReason
Brand.status = REJECTED
Seller.status = TRADEMARK_REJECTED]:::be
        REJECT --> REJECT_NOTIFY[Queue: send-trademark-rejected-email
Seller can resubmit new trademark name]:::be
    end

    subgraph BANK_NOTE [Admin Bank Verification Controls]
        BANK_PENDING --> ADMIN_BANK[GET /api/v1/admin/bank-verifications
GET /api/v1/admin/bank-verifications/:sellerId/status
PATCH approve only if refreshed Cashfree result is valid
PATCH reject requires reason]:::ext
        UPI_PENDING --> ADMIN_BANK
    end
Current Code: PAN First, Then Cashfree Bank Verification
Seller phone OTP issues onboarding cookies. PAN verification then runs through POST /api/v1/auth/seller/signup/verify-pan. Bank verification runs through POST /api/v1/auth/seller/signup/bank-details and supports BANK_DETAILS/BANK for Cashfree bank-account validation or UPI for Cashfree reverse penny drop. Trademark submission is POST /api/v1/auth/seller/signup/trademark. Admin can refresh bank status and can only approve bank verification when Cashfree returns a verified result.
⚡ API Reference — Seller Signup Endpoints
REQUEST — Initiate Seller Signup
{
  "method": "POST",
  "endpoint": "/api/v1/auth/seller/signup/initiate",
  "body": {
    "profileName": "Rahul Manoj Kumar",
    "phone": "9876543210",
    "email": "seller@shop.com",
    "password": "SecurePass@123",
    "shopAddress": "123 Main St, Mumbai, MH 400001",
    "turnstileToken": "cf-token"
  }
}
RESPONSE — Signup Initiated
{
  "status": 201,
  "body": {
    "action": "VERIFY_EMAIL_AND_PHONE",
    "resendAfter": 60
  }
}
REQUEST — Submit Trademark
{
  "method": "POST",
  "endpoint": "/api/v1/auth/seller/signup/trademark",
  "body": {
    "name": "MyBrandName"
  }
}
RESPONSE — Trademark Submitted
{
  "status": 201,
  "body": {
    "message": "Trademark submitted for review.",
    "expectedBy": "2026-04-26T10:00:00Z",
    "seller": {
      "status": "TRADEMARK_PENDING"
    }
  }
}
📋 Full Flow Description
STAGE 1 — PROFILE DETAILS SELLER → Opens /seller/signup → Fills all required fields: Profile Name (Display Name for sellers) Phone Number (10-digit Indian mobile, unique) Email Address (unique) Password + Confirm Password Shop Address (full: city, state, PIN) → Solves Cloudflare Turnstile → Clicks "Continue" FRONTEND (Zod) → Validate all fields inline IF invalid → inline errors → stop → POST /api/v1/auth/seller/signup/initiate { profileName, phone, email, shopAddress, password, turnstileToken } BACKEND → Verify Turnstile → Check retired_credentials for email and phone IF either retired → Return 410 Gone → Check email uniqueness → Check phone uniqueness IF email or phone taken (verified) → Return 409 → Hash password Argon2id immediately → Create User { role: SELLER, userProfileStatus: PENDING } → Create SellerProfile { userId, shopAddress } → Create SellerBankAccount stub { isVerified: false } → NotificationsService dispatch email otp through BullMQ notifications queue (TTL 60s) → NotificationsService dispatch sms otp through BullMQ notifications queue (TTL 60s) → Return 201 { action: "VERIFY_EMAIL_AND_PHONE" } STAGE 2 — EMAIL OTP VERIFICATION → /seller/signup/verify-email → 6-digit OTP input (60s countdown) → POST /api/v1/auth/seller/signup/verify-email { email, otp } BACKEND: hash compare → isEmailVerified = true → accountSetupPhase = EMAIL_VERIFIED → send phone signup OTP STAGE 3 — PHONE OTP VERIFICATION → /seller/signup/verify-phone → 6-digit OTP via SMS (60s countdown) → POST /api/v1/auth/seller/signup/verify-phone { phone, otp } BACKEND: hash compare → isPhoneVerified = true → accountSetupPhase = PHONE_VERIFIED → issue onboarding cookies → Return { action: "VERIFY_PAN" } STAGE 4 — PAN + CASHFREE BANK VERIFICATION → /seller/signup/verify-pan → Seller submits PAN Card number → POST /api/v1/auth/seller/signup/verify-pan { pan } BACKEND: SellerPanVerificationService verifies PAN, saves kycRegisterName, moves the flow to SUBMIT_BANK_DETAILS → /seller/signup/bank-details → Seller fills: BANK_DETAILS/BANK method: accountHolderName, accountNumber, ifscCode UPI method: creates a Cashfree reverse-penny-drop payment view and UPI links → POST /api/v1/auth/seller/signup/bank-details { primaryPaymentMethod, accountHolderName?, accountNumber?, ifscCode? } BACKEND → BANK_DETAILS/BANK: encrypt account number with AES-256-CBC, fingerprint the account, enforce daily attempt limit, call Cashfree bank-account verification. → UPI: create Cashfree reverse penny drop, store verificationId/refId and deep links, wait for seller payment/status refresh. → GET /api/v1/auth/seller/signup/bank-verification/:verificationId/status or /status/current refreshes the saved Cashfree request IF Cashfree returns verified/success → SellerBankAccountDetails.isVerified = true, bankVerificationStatus = VERIFIED, accountSetupPhase = BANK_VERIFIED → proceed to Stage 5 IF Cashfree returns pending → seller or admin refreshes later; admin can approve only after refresh confirms valid IF failed/expired/duplicate/daily-limit → seller retries or contacts admin depending on the error STAGE 5 — BRAND / TRADEMARK SUBMISSION → /seller/signup/trademark → Seller fills: Brand / Trademark Name (required, checked for global uniqueness) → POST /api/v1/auth/seller/signup/trademark { name } BACKEND → Check retired brand names and trademark name uniqueness — case-insensitive — across ALL sellers IF name taken → Return 409 { message: "This brand name is already registered. Try another." } → Create TradMark { name, sellerId, tradeMarkStatus: PENDING_APPROVAL } → Update User accountSetupPhase = TRADEMARK_SUBMITTED → NotificationsService dispatches seller-trademark-submitted email → NotificationsService dispatches admin-new-trademark email "Your trademark has been submitted for review. You will hear from us within 48 hours." → Return 201 { expectedBy: now+48h } STAGE 6 — SESSION CREATION (ALWAYS) → Create Session → Issue RS256 JWT (15min) + Refresh Token (7d) → Set httpOnly cookies → Insert AuditLog { event: SELLER_SIGNUP } FRONTEND ROUTING: IF status = TRADEMARK_PENDING → /seller/verification-status Shows: [Profile ✓] [Bank ✓] [Trademark ⏳] "Your trademark is under review. We will email you the result." IF status = ACTIVE (after admin approves) → /seller/dashboard PAYOUT BEHAVIOUR: → Seller payout requests require SellerProfile.bankVerificationStatus = VERIFIED and a verified SellerBankAccountDetails row → Cashfree beneficiary is created/reused at payout request time → Payout status can be refreshed through /api/v1/seller/payouts/refresh ADMIN APPROVAL FLOW: → Admin → /admin/trademarks → Views pending queue sorted by submittedAt IF APPROVED: → PATCH /api/v1/admin/trademarks/:brandId/approve → TradMark.status = ACTIVE → Admin service refreshes seller bank status through Cashfree → If bank is verified: User.userProfileStatus = ACTIVE and accountSetupPhase = COMPLETED → If bank is not verified: trademark is active but user remains PENDING until bank verification is approved → NotificationsService dispatches trademark-approved email IF REJECTED: → PATCH /api/v1/admin/trademarks/:brandId/reject { rejectionReason } → TradMark.status = REJECTED → User accountSetupPhase = BANK_VERIFIED → NotificationsService dispatches trademark-rejected email → Seller can resubmit a new trademark name (Stage 5 again)
Bank
Current Seller Bank Verification Pipeline Updated
This section now documents the actual code: PAN verification is handled by SellerPanVerificationService, then SellerBankVerificationService starts a Cashfree verification request. Sellers can use direct bank-account verification (BANK_DETAILS/BANK) or Cashfree UPI reverse penny drop (UPI). The service stores Cashfree ids, provider status, encrypted account data, attempt history, and moves the user to BANK_VERIFIED only when Cashfree returns a verified/success result.
PAN Verification NestJS PostgreSQL Cashfree Verification Cashfree Payouts Admin Panel
flowchart TD
    classDef fe fill:#dbeafe,stroke:#3b82f6,color:#1e40af,font-size:12px
    classDef be fill:#dcfce7,stroke:#16a34a,color:#166534,font-size:12px
    classDef db fill:#fef9c3,stroke:#ca8a04,color:#78350f,font-size:12px
    classDef ext fill:#f3e8ff,stroke:#9333ea,color:#4c1d95,font-size:12px
    classDef err fill:#fee2e2,stroke:#dc2626,color:#7f1d1d,font-size:12px
    classDef ok fill:#d1fae5,stroke:#059669,color:#064e3b,font-size:12px
    classDef warn fill:#fff7ed,stroke:#ea580c,color:#7c2d12,font-size:12px
    classDef diamond fill:#fef3c7,stroke:#d97706,color:#78350f,font-size:12px

    START([Seller has verified email and phone
Phone OTP issued onboarding cookies]):::fe

    subgraph PAN [PAN Verification]
        START --> PAN_FORM[/Seller enters PAN/]:::fe
        PAN_FORM --> PAN_API[POST /api/v1/auth/seller/signup/verify-pan
SellerPanVerificationService]:::be
        PAN_API -->|verified| PAN_SAVE[(sellerProfile.kycRegisterName saved
accountSetupPhase = KYC_VERIFIED)]:::db
        PAN_API -->|failed| PAN_FAIL[Return PAN validation error]:::err
    end

    subgraph BANK [Cashfree Bank Verification]
        PAN_SAVE --> METHOD{Seller chooses method}:::fe
        METHOD -->|BANK or BANK_DETAILS| BAV_REQ[POST /api/v1/auth/seller/signup/bank-details
accountHolderName accountNumber ifscCode
Encrypt account number
Hash account fingerprint
Daily limit: 3 attempts per method]:::be
        BAV_REQ --> BAV_PROVIDER[Cashfree verifyBankAccountSync
Saves reference_id user_id
providerStatus providerStatusCode]:::ext
        BAV_PROVIDER -->|VALID or ACCOUNT_IS_VALID| BAV_OK[(isVerified = true
bankRegisterName saved
bankVerificationStatus = VERIFIED
accountSetupPhase = BANK_VERIFIED)]:::ok
        BAV_PROVIDER -->|Pending / validation in progress| BAV_PENDING[(bankVerificationStatus = VALIDATION_INITIATED
seller/admin can refresh status)]:::warn
        BAV_PROVIDER -->|Invalid / provider failed| BAV_FAIL[(bankVerificationStatus = VALIDATION_FAILED
failureReason saved)]:::err

        METHOD -->|UPI| RPD_REQ[POST /api/v1/auth/seller/signup/bank-details
primaryPaymentMethod = UPI
Cashfree createReversePennyDrop]:::be
        RPD_REQ --> RPD_VIEW[/Return verificationId refId
UPI link QR gpay phonepe paytm bhim
valid for 600 seconds/]:::fe
        RPD_VIEW --> RPD_STATUS[GET /api/v1/auth/seller/signup/bank-verification/:verificationId/status
or /status/current]:::be
        RPD_STATUS -->|SUCCESS| RPD_OK[(isVerified = true
upiRegisterName saved
bankVerificationStatus = VERIFIED
accountSetupPhase = BANK_VERIFIED)]:::ok
        RPD_STATUS -->|CREATED or PENDING| RPD_PENDING[(bankVerificationStatus = REVERSE_PENNY_DROP_PENDING
Refresh later)]:::warn
        RPD_STATUS -->|FAILED or EXPIRED| BAV_FAIL
    end

    subgraph ADMIN [Admin Verification Controls]
        BAV_PENDING --> ADMIN_REFRESH[GET /api/v1/admin/bank-verifications/:sellerId/status
Refresh Cashfree result]:::ext
        RPD_PENDING --> ADMIN_REFRESH
        ADMIN_REFRESH -->|verified| ADMIN_APPROVE[PATCH /api/v1/admin/bank-verifications/:sellerId/approve
Only succeeds after Cashfree refresh says verified]:::ok
        ADMIN_REFRESH -->|not verified| ADMIN_REJECT[PATCH /api/v1/admin/bank-verifications/:sellerId/reject
reason required]:::warn
    end

    subgraph NEXT [Next Seller Steps]
        BAV_OK --> TM[POST /api/v1/auth/seller/signup/trademark]:::be
        RPD_OK --> TM
        ADMIN_APPROVE --> TM
        TM --> ADMIN_TM[Admin approves trademark
If bankReady true user becomes ACTIVE
else remains PENDING until bank is verified]:::ext
        ADMIN_TM --> PAYOUT[Seller payouts require
bankVerificationStatus VERIFIED
and verified bank account
Cashfree beneficiary is created during payout request]:::ok
    end
Current Code Fields Written by Bank Verification
SellerProfile: kycRegisterName, bankVerificationStatus, bankRegisterName, upiRegisterName, technicalErrorMessage
SellerBankAccountDetails: encrypted account number, account fingerprint, IFSC, Cashfree refs, provider status, verification response, failure reason, isVerified
User: accountSetupPhase becomes BANK_VERIFIED after a verified bank result, or COMPLETED if an active trademark already exists.
API Reference — Current Bank Verification Endpoints
PAN Verification
{
  "method": "POST",
  "endpoint": "/api/v1/auth/seller/signup/verify-pan",
  "body": { "pan": "ABCDE1234F" }
}

{
  "success": true,
  "name": "RAHUL MANOJ KUMAR",
  "action": "SUBMIT_BANK_DETAILS"
}
Cashfree Bank Account Verification
{
  "method": "POST",
  "endpoint": "/api/v1/auth/seller/signup/bank-details",
  "body": {
    "primaryPaymentMethod": "BANK_DETAILS",
    "accountHolderName": "Rahul Manoj Kumar",
    "accountNumber": "765432123456789",
    "ifscCode": "HDFC0000053"
  }
}

{
  "action": "SUBMIT_TRADEMARK | VERIFY_BANK_PENDING | RETRY_BANK_VERIFICATION",
  "bankVerification": {
    "method": "BANK_DETAILS",
    "status": "valid | pending | failed",
    "isVerified": true,
    "bankVerificationStatus": "VERIFIED"
  }
}
Cashfree UPI Reverse Penny Drop
{
  "method": "POST",
  "endpoint": "/api/v1/auth/seller/signup/bank-details",
  "body": {
    "primaryPaymentMethod": "UPI"
  }
}

{
  "action": "VERIFY_UPI_PAYMENT",
  "bankVerification": {
    "method": "UPI",
    "verificationId": "IEMS-RPD-...",
    "payment": {
      "upi": "upi://pay?...",
      "qrCode": "base64-or-url",
      "validForSeconds": 600
    }
  }
}
Status and Admin Review
GET /api/v1/auth/seller/signup/bank-verification/status/current
GET /api/v1/auth/seller/signup/bank-verification/:verificationId/status
GET /api/v1/admin/bank-verifications
GET /api/v1/admin/bank-verifications/:sellerId/status
PATCH /api/v1/admin/bank-verifications/:sellerId/approve
PATCH /api/v1/admin/bank-verifications/:sellerId/reject

Admin approve refreshes Cashfree first and fails if the result is not verified.
Reject requires a reason and records VALIDATION_FAILED.
Full Flow Description
CURRENT SELLER BANK VERIFICATION 1. Seller completes email OTP and phone OTP. 2. Phone OTP verification issues onboarding cookies, so PAN and bank routes are authenticated seller routes. 3. Seller submits PAN through /api/v1/auth/seller/signup/verify-pan. 4. Seller submits bank verification through /api/v1/auth/seller/signup/bank-details. BANK_DETAILS / BANK METHOD → Backend validates accountHolderName, accountNumber and ifscCode. → Account number is AES-encrypted; account + IFSC fingerprint prevents reuse. → Daily verification limit is 3 attempts per method per IST day. → Cashfree bank-account verification returns reference/user ids and account status. IF status is VALID or ACCOUNT_IS_VALID → bankVerificationStatus = VERIFIED, isVerified = true, bankRegisterName saved. IF status is pending/in progress → bankVerificationStatus = VALIDATION_INITIATED. IF status is invalid/failed → bankVerificationStatus = VALIDATION_FAILED with failureReason. UPI METHOD → Backend creates Cashfree reverse penny drop request and stores verificationId/refId. → Frontend shows UPI link, QR and app links for GPay/PhonePe/Paytm/BHIM when Cashfree returns them. → Seller pays through the link and clicks refresh. → /bank-verification/:verificationId/status or /status/current fetches the Cashfree status. IF status is SUCCESS → bankVerificationStatus = VERIFIED, isVerified = true, upiRegisterName saved. IF status is CREATED/PENDING → bankVerificationStatus = REVERSE_PENNY_DROP_PENDING. IF status is FAILED/EXPIRED → bankVerificationStatus = VALIDATION_FAILED. ADMIN BANK REVIEW → Admin lists sellers through /api/v1/admin/bank-verifications. → Admin refreshes a seller via /api/v1/admin/bank-verifications/:sellerId/status. → Admin approve calls the same refresh and succeeds only when Cashfree says verified. → Admin reject requires a reason and marks the bank verification failed. TRADEMARK AND ACTIVATION → Seller submits trademark after bank verification with /api/v1/auth/seller/signup/trademark. → Admin trademark approval activates the user only if bankReady is true. → If trademark is approved while bank is still pending, trademark becomes ACTIVE but user remains PENDING until bank verification is completed. PAYOUT CONNECTION → Seller payout request requires bankVerificationStatus = VERIFIED and a verified SellerBankAccountDetails row. → Cashfree beneficiary is created/reused at payout-request time. → Payout status is refreshed with /api/v1/seller/payouts/refresh.
🏪 Seller
Seller Login — Full Rebuild Updated
Complete redesign mirroring buyer login, with one critical difference: "Login with OTP" is explicitly NOT available for sellers. Sellers must always authenticate with a password followed by mandatory OTP 2FA. The single identifier input accepts email or phone. Post-login routing is determined by the seller's trademarkStatus: TRADEMARK_PENDING → verification-status page, TRADEMARK_REJECTED → resubmit page, ACTIVE → dashboard.
Next.js NestJS AuthService Redis Cloudflare Turnstile Resend + Fast2SMS
flowchart TD
    classDef fe fill:#dbeafe,stroke:#3b82f6,color:#1e40af,font-size:12px
    classDef be fill:#dcfce7,stroke:#16a34a,color:#166534,font-size:12px
    classDef db fill:#fef9c3,stroke:#ca8a04,color:#78350f,font-size:12px
    classDef ext fill:#f3e8ff,stroke:#9333ea,color:#4c1d95,font-size:12px
    classDef err fill:#fee2e2,stroke:#dc2626,color:#7f1d1d,font-size:12px
    classDef ok fill:#d1fae5,stroke:#059669,color:#064e3b,font-size:12px
    classDef warn fill:#fff7ed,stroke:#ea580c,color:#7c2d12,font-size:12px

    A([Seller Opens /seller/login]):::fe
    A --> CF{{Cloudflare Turnstile}}:::ext
    CF -->|Fail| CFE[Bot check failed — stop]:::err
    CF -->|Pass| STEP1[/Step 1: Single identifier input
Placeholder: Enter email or phone number/]:::fe

    STEP1 --> DETECT{Client-side Zod
Detection}:::fe
    DETECT -->|Contains @ valid email format| EMAIL_STORE[Store as email
identifierType: email]:::fe
    DETECT -->|10-digit starts 6-9| PHONE_STORE[Store as phone
identifierType: phone]:::fe
    DETECT -->|Neither| INPUT_ERR[Inline error:
Enter a valid email address
or 10-digit Indian mobile number]:::err

    EMAIL_STORE --> PWDSCREEN
    PHONE_STORE --> PWDSCREEN

    PWDSCREEN[/Step 2: Password Screen
Label shows entered identifier
Masked password field with show/hide
ONLY ONE link: Forgot Password
Login with OTP is NOT available for sellers/]:::fe

    PWDSCREEN --> CHOICE{User action}:::fe
    CHOICE -->|Enters password and clicks Sign In| LOGIN_REQ[POST /api/v1/auth/seller/login
body: identifier + identifierType
+ password + turnstileToken]:::be
    CHOICE -->|Clicks Forgot Password| FORGOT[Forgot Password flow]:::fe

    LOGIN_REQ --> LOCKCHK[(Check Redis AUTH:LOCKOUT:identifier
role filter = SELLER)]:::db
    LOCKCHK -->|Locked 30min| LOCKED[Return 429
Too many failed attempts
Try again in 30 minutes
Offer Forgot Password]:::err
    LOCKCHK -->|Clear| FIND[(Find User by identifier
WHERE role = SELLER
AND isDeleted = false
AND status != SUSPENDED)]:::db

    FIND -->|Not found| GENERIC_FAIL[Return 401 generic message
Increment AUTH:FAIL counter
Never reveal which field wrong]:::err
    FIND -->|Found| PWD_VFY[Argon2id password compare]:::be
    PWD_VFY -->|Wrong| INCR[(Increment AUTH:FAIL:identifier
At 5: set LOCKOUT TTL 1800s)]:::db
    INCR --> GENERIC_FAIL
    PWD_VFY -->|Correct| OTP_SEND[Generate 6-digit OTP
Hash SHA-256 — TTL 60s
Reset AUTH:FAIL counter]:::be

    OTP_SEND -->|email identifier| EMAIL_OTP[NotificationsService dispatch email otp
BullMQ notifications queue: send-notification
Return 200 VERIFY_OTP
medium: email
maskedEmail shown]:::be
    OTP_SEND -->|phone identifier| SMS_OTP[NotificationsService dispatch sms otp
BullMQ notifications queue: send-notification
Return 200 VERIFY_OTP
medium: sms
maskedPhone shown]:::be

    EMAIL_OTP --> OTP_PAGE
    SMS_OTP --> OTP_PAGE

    OTP_PAGE[/Enter 6-digit OTP
60s resend timer
Cannot skip this step/]:::fe
    OTP_PAGE --> VERIFY[(Verify OTP hash
Check 60s expiry
Check attempt count)]:::db
    VERIFY -->|Expired| OTP_EXP[OTP expired
Click Resend OTP for a new code]:::err
    VERIFY -->|Invalid| OTP_INV[OTP invalid — resend available]:::err
    VERIFY -->|Valid| SESSION[Create Session record
Issue RS256 JWT 15min
Issue Refresh Token 7d
Set httpOnly Secure SameSite=Strict cookies
Log AuditLog: SELLER_LOGIN]:::be

    SESSION --> STATUS_CHK{seller.trademarkStatus}:::fe
    STATUS_CHK -->|TRADEMARK_PENDING| WAIT[Redirect to /seller/verification-status
Trademark review in progress
No dashboard access yet]:::warn
    STATUS_CHK -->|TRADEMARK_REJECTED| RESUBMIT[Redirect to /seller/resubmit-trademark
Show rejection reason from admin]:::err
    STATUS_CHK -->|SUSPENDED| SUSP[Return 403 ACCOUNT_SUSPENDED
Contact support message shown]:::err
    STATUS_CHK -->|ACTIVE| DASH([Redirect to /seller/dashboard
Full dashboard access granted]):::ok

    subgraph FORGOT_SELLER [Forgot Password — Seller]
        FORGOT --> FP1[/Single input field
Enter your email or phone number
Same Zod detection as Step 1/]:::fe
        FP1 --> FP2[POST /api/v1/auth/seller/forgot-password
body: identifier + identifierType]:::be
        FP2 --> FP3[(Look up seller account
WHERE role = SELLER)]:::db
        FP3 -->|Always returns 200 — anti-enumeration| FP4[IF found: Generate OTP
Hash SHA-256 — TTL 60s
NotificationsService dispatch identifier channel otp]:::be
        FP4 --> FP5[Return 200
Frontend: Check your inbox or messages]:::fe
        FP5 --> FP6[/Enter OTP + New Password/]:::fe
        FP6 --> FP7[POST /api/v1/auth/seller/reset-password
body: identifier + identifierType + otp + password
Verify OTP — Hash Argon2id — Invalidate all sessions]:::be
        FP7 --> FP8([Password reset success — redirect to /seller/login]):::ok
    end
🔒 Key Difference from Buyer Login
Sellers do NOT have a "Login with OTP" (passwordless) option. The OTP 2FA step after correct password cannot be skipped or bypassed. This is a security requirement — sellers have financial privileges (product listings, payout requests) that require higher authentication assurance.
⚡ API Reference — Seller Login Endpoints
REQUEST — Seller Login
{
  "method": "POST",
  "endpoint": "/api/v1/auth/seller/login",
  "body": {
    "identifier": "seller@shop.com",
    "identifierType": "email",
    "password": "SecurePass@123",
    "turnstileToken": "cf-token"
  }
}
RESPONSE — OTP Required
{
  "status": 200,
  "body": {
    "action": "VERIFY_OTP",
    "medium": "email",
    "maskedEmail": "se***@shop.com"
  }
}
RESPONSE — Login Success + Routing
// TRADEMARK_PENDING:
{ "status": 200,
  "seller": { "trademarkStatus": "TRADEMARK_PENDING",
    "redirect": "/seller/verification-status" } }

// ACTIVE:
{ "status": 200,
  "seller": { "id": "uuid", "profileName": "Rahul",
    "trademarkStatus": "ACTIVE",
    "redirect": "/seller/dashboard" } }
RESPONSE — Error States
// Account locked:
{ "status": 429, "code": "AUTH_ACCOUNT_LOCKED",
  "retryAfter": 1800 }

// Wrong credentials (generic):
{ "status": 401,
  "message": "Incorrect email or password." }

// Suspended:
{ "status": 403, "code": "ACCOUNT_SUSPENDED" }
📋 Full Flow Description
SELLER → Opens /seller/login → Solves Cloudflare Turnstile STEP 1 — UNIFIED IDENTIFIER INPUT → Single input field: "Enter email or phone number" → Same Zod client-side detection as buyer login: Contains "@" + valid format → email 10-digit starting 6–9 → phone Neither → inline error STEP 2 — PASSWORD SCREEN → Label shows entered identifier → Masked password field (show/hide toggle) → ONE link only: "Forgot Password" → "Login with OTP" is explicitly NOT present for sellers STEP 3 — SUBMIT → POST /api/v1/auth/seller/login { identifier, identifierType, password, turnstileToken } BACKEND → Verify Turnstile → Check Redis AUTH:LOCKOUT:{identifier} IF locked → Return 429 "Too many failed attempts. Try again in 30 minutes." → Find User WHERE role = SELLER AND isDeleted = false IF not found → increment AUTH:FAIL → Return 401 "Incorrect email or password" (generic) → Argon2id compare IF wrong → increment AUTH:FAIL → at 5: set LOCKOUT TTL=1800s → Return 401 (same generic) IF correct: → Reset AUTH:FAIL counter → Generate 6-digit OTP → Hash SHA-256 → TTL 60s IF email → NotificationsService dispatch email otp → "OTP sent to ****@" IF phone → NotificationsService dispatch sms otp → "OTP sent to your phone" STEP 4 — MANDATORY OTP VERIFICATION (Cannot be skipped) → POST /api/v1/auth/seller/login/verify-otp { identifier, identifierType, otp } BACKEND: Verify SHA-256 hash → check 60s TTL → check attempts IF expired → "Your OTP has expired. Please click Resend OTP to receive a new code." IF valid: → Create Session → Issue RS256 JWT (15min) + Refresh Token (7d) → Set httpOnly cookies → Insert AuditLog { event: SELLER_LOGIN, ip, userAgent } → Return 200 { seller: { id, profileName, trademarkStatus, bankVerified } } POST-LOGIN ROUTING: IF trademarkStatus = TRADEMARK_PENDING → redirect /seller/verification-status (no dashboard) IF trademarkStatus = TRADEMARK_REJECTED → redirect /seller/resubmit-trademark (reason shown) IF status = SUSPENDED → Return 403 ACCOUNT_SUSPENDED IF status = ACTIVE → redirect /seller/dashboard FORGOT PASSWORD (SELLER): → Single input field: "Enter your email or phone number" → Same Zod identifier detection → POST /api/v1/auth/seller/forgot-password { identifier, identifierType } → Always return 200 (anti-enumeration) → If found: OTP (TTL 60s) sent to identifier channel → Seller enters OTP + new password → reset → invalidate all sessions → redirect /seller/login
👤 Guest
Feature 04g — Guest Buyer Experience Updated
Guests can browse, search, and interact with the platform without any login. Cart lives in Zustand + sessionStorage; wishlist in Zustand + localStorage. Critical rule: zero POST/PUT/DELETE API calls for guest users. All mutations are local-state only. Upon login or signup, independent per-item API calls sync guest data — no merge API endpoint. Each call is single-purpose and failures do not abort others. The checkout gate preserves redirect_url in sessionStorage.
Next.js Frontend NestJS API PostgreSQL Algolia Zustand + localStorage
flowchart TD
    classDef fe fill:#dbeafe,stroke:#3b82f6,color:#1e40af,font-size:12px
    classDef be fill:#dcfce7,stroke:#16a34a,color:#166534,font-size:12px
    classDef db fill:#fef9c3,stroke:#ca8a04,color:#78350f,font-size:12px
    classDef ext fill:#f3e8ff,stroke:#9333ea,color:#4c1d95,font-size:12px
    classDef err fill:#fee2e2,stroke:#dc2626,color:#7f1d1d,font-size:12px
    classDef ok fill:#d1fae5,stroke:#059669,color:#064e3b,font-size:12px
    classDef warn fill:#fff7ed,stroke:#ea580c,color:#7c2d12,font-size:12px

    A([Guest Arrives — No server-side session
Frontend assigns guest context]):::fe

    subgraph BROWSE [Permitted — No Auth Required]
        A --> B[View home page
Hero banner + categories + new arrivals
GET /api/v1/products public endpoint]:::fe
        B --> C[View product listing pages
All filters active
GET /api/v1/products with query params]:::be
        C --> D[(Query PostgreSQL
status = ACTIVE
isDeleted = false)]:::db
        A --> E[Search via Algolia
300ms debounce
Returns 8 suggestions
title + tags + sku fields only]:::ext
        E --> PDP[View product detail page
GET /api/v1/products/:slug
Images + description + bullets
Variants + seller info + Q&A + reviews]:::be
    end

    subgraph LOCAL_ONLY [Local State Only — Zero API Calls]
        PDP --> ADD_CART[Add to Cart clicked]:::fe
        ADD_CART --> CART_LOCAL[Add to Zustand cart store
Persist to sessionStorage
Zero backend interaction
Cart badge updates live]:::fe

        PDP --> ADD_WISH[Add to Wishlist clicked]:::fe
        ADD_WISH --> WISH_LOCAL[Add to Zustand wishlist store
Persist to localStorage
Zero backend interaction]:::fe

        PDP --> NOTIFY_GUEST[Notify Me clicked
Out-of-stock product]:::fe
        NOTIFY_GUEST --> NOTIFY_MSG[Show message: You are not logged in
Please log in to track this product
Store product slug in sessionStorage
Show Log In button — no API call]:::warn
    end

    subgraph BLOCKED [Blocked for Guests — Require Authentication]
        BLOCK1[Proceed to Checkout — blocked]:::err
        BLOCK2[Place an order — blocked]:::err
        BLOCK3[Submit product review — blocked]:::err
        BLOCK4[Ask or answer Q and A — blocked]:::err
        BLOCK5[Track an order — blocked]:::err
        BLOCK6[View order history — blocked]:::err
        BLOCK7[Initiate return or refund — blocked]:::err
        BLOCK8[Send message to seller — blocked]:::err
    end

    subgraph CHECKOUT_GATE [Checkout Authentication Gate]
        CART_LOCAL --> CHECKOUT_BTN[Guest clicks Proceed to Checkout]:::fe
        CHECKOUT_BTN --> AUTH_CHECK{Is user
authenticated?}:::fe
        AUTH_CHECK -->|NO| GATE_MSG[Do NOT proceed to checkout
Show message: You are not logged in
Please sign in to complete your purchase
Your cart items will be preserved
Store redirect_url = /checkout in sessionStorage]:::warn
        GATE_MSG --> LOGIN_REDIRECT[Redirect to /login or /signup]:::fe
        AUTH_CHECK -->|YES| PROCEED_CHECKOUT[Proceed to checkout flow]:::ok
    end

    subgraph POST_LOGIN_SYNC [Post-Login Sync — Current Bulk Sync APIs]
        LOGIN_REDIRECT --> COMPLETE_AUTH[User completes login or signup]:::be
        COMPLETE_AUTH --> READ_GUEST[Frontend reads:
sessionStorage for cart items
localStorage for wishlist items]:::fe

        READ_GUEST --> CART_SYNC[POST /api/v1/buyer/cart/sync
body: items array with productId + variantId + qty
Backend resolves each item
Existing DB item keeps max guestQty and dbQty
New item inserts row]:::be

        CART_SYNC --> WISH_SYNC[POST /api/v1/buyer/wishlist/sync
body: items array with productId + optional variantId
Existing wishlist item is skipped
New item inserts up to max 100 items]:::be

        WISH_SYNC --> CLEAR_LOCAL[Clear sessionStorage guest cart
Clear localStorage guest wishlist]:::fe
        CLEAR_LOCAL --> HYDRATE[Hydrate Zustand cart from GET /api/v1/buyer/cart
Hydrate Zustand wishlist from GET /api/v1/buyer/wishlist]:::be
        HYDRATE --> REDIRECT_CHECKOUT[Redirect to /checkout
or stored redirect_url from sessionStorage]:::ok
    end
⚡ Critical Design Decision — No Merge API
No /cart/merge or /wishlist/merge endpoint exists. Post-login sync uses the current bulk endpoints from the codebase: POST /api/v1/buyer/cart/sync and POST /api/v1/buyer/wishlist/sync. Normal authenticated item changes still use the single-item cart and wishlist endpoints.
📋 Full Flow Description
GUEST → Lands on /home → No server-side session → Frontend assigns guest context GUEST CREDENTIAL CONTEXT: → Cart: Zustand store + sessionStorage (survives refresh; clears on browser close) → Wishlist: Zustand store + localStorage (persists across sessions) → No server-side session is created CRITICAL RULE — ZERO POST APIs FOR GUEST USERS: → "Add to Cart" → local Zustand state only (sessionStorage) → "Add to Wishlist" → local Zustand state only (localStorage) → "Update Quantity" → local state only → "Remove from Cart" → local state only → "Toggle Wishlist" → local state only → No backend interaction of any kind until user is authenticated PERMITTED WITHOUT LOGIN: → View home page (hero, categories, promotions, new arrivals) → View product listing pages (all filters and sorting) → View product detail pages (images, description, bullet points, variants, seller info, Q&A, reviews, A+ content) → Compare products → Search via Algolia (title / tags / SKU fields, case-insensitive) → Add to cart (local only) → Add to wishlist (local only) → Update cart quantity (local only) → Remove from cart (local only) → Toggle wishlist (local only) BLOCKED WITHOUT LOGIN (Require Authentication): → Proceed to checkout → Checkout gate fires → Place an order → Track an order → Submit a product review → Ask or answer a product question → Click "Notify Me" on out-of-stock products (show login prompt) → Access saved addresses → View order history → Initiate return or refund → Send message to a seller GUEST "NOTIFY ME" BEHAVIOUR: → Guest clicks "Notify Me" on out-of-stock product → Do NOT call any API → Do NOT auto-redirect → Show: "You are not logged in. Please log in to track the product stock." → Show "Log In" button → Store product slug in sessionStorage for return after login → After login: return user to product detail page → user clicks "Notify Me" again → API called CHECKOUT AUTHENTICATION GATE: → Guest clicks "Proceed to Checkout" → IF NOT authenticated → Do NOT proceed to checkout → Show: "You are not logged in. Please sign in to complete your purchase. Your cart items will be preserved." → Store redirect_url = /checkout in sessionStorage → Redirect to /login or /signup POST-LOGIN SYNC — CURRENT BULK SYNC CALLS (NO MERGE API): After successful login or signup: → Frontend reads sessionStorage (cart) and localStorage (wishlist) SYNC CART: → POST /api/v1/buyer/cart/sync { items: [{ productId, variantId?, qty }] } → Backend: if item already in DB cart → keep max(guestQty, dbQty); if new → insert SYNC WISHLIST: → POST /api/v1/buyer/wishlist/sync { items: [{ productId, variantId? }] } → Backend: if already in wishlist → skip (no duplicate); if new → insert (max 100 items) AFTER SYNC: → Clear sessionStorage guest cart → Clear localStorage guest wishlist → Hydrate Zustand cart from GET /api/v1/buyer/cart → Hydrate Zustand wishlist from GET /api/v1/buyer/wishlist → Redirect to /checkout (or stored redirect_url)
🔐 Security
Feature 03 — Account Deletion & Soft Deletion Updated
Current code soft-deletes users by setting isDeleted=true and userProfileStatus=DELETED. Email and phone are retired in retired_credentials; seller brand names are retired too. Deletion requires an authenticated session and email OTP. After OTP verification, AccountDeletionService creates/updates a DeletionRequest as RESOLVED, soft-deletes the account, and revokes all sessions immediately.
Next.js NestJS PostgreSQL RetiredCredentialService TokenService revoke-all
flowchart TD
    classDef fe fill:#dbeafe,stroke:#3b82f6,color:#1e40af,font-size:12px
    classDef be fill:#dcfce7,stroke:#16a34a,color:#166534,font-size:12px
    classDef db fill:#fef9c3,stroke:#ca8a04,color:#78350f,font-size:12px
    classDef ext fill:#f3e8ff,stroke:#9333ea,color:#4c1d95,font-size:12px
    classDef err fill:#fee2e2,stroke:#dc2626,color:#7f1d1d,font-size:12px
    classDef ok fill:#d1fae5,stroke:#059669,color:#064e3b,font-size:12px
    classDef warn fill:#fff7ed,stroke:#ea580c,color:#7c2d12,font-size:12px

    A([User initiates account deletion
from profile settings]):::fe

    subgraph PREFLIGHT [Pre-Deletion Checks — Seller Only]
        A -->|User role = SELLER| S_CHECK[(Check:
seller wallet available balance = 0
Cashfree beneficiary ids if present)]:::db
        S_CHECK -->|Checks fail| S_BLOCK[Deletion blocked
Show which condition is unmet
Cannot delete until all conditions clear]:::err
        S_CHECK -->|All clear| COMMON
    end
    A -->|User role = BUYER| COMMON

    subgraph COMMON [Common Deletion Flow — All Users]
        COMMON[User presented with:
Deletion reason — required — dropdown plus optional text
Privacy Policy warning prominently displayed:
Your email and phone will be permanently retired
They cannot be reused to create a new account]:::fe

        COMMON --> REASON_SUBMIT[POST /api/v1/auth/buyer/delete/request
or POST /api/v1/auth/seller/delete/request]:::be
        REASON_SUBMIT --> OTP_SEND[Generate OTP — Hash SHA-256 — TTL 60s
Send to verified email]:::be
        OTP_SEND --> OTP_PAGE[/Enter 6-digit OTP
to confirm deletion intent/]:::fe
        OTP_PAGE --> OTP_VFY[DELETE /api/v1/auth/buyer/delete/verify
or DELETE /api/v1/auth/seller/delete/verify
body: otp + reason + reasonDetail]:::be
        OTP_VFY --> OTP_CHK[(Verify OTP hash — 60s TTL)]:::db
        OTP_CHK -->|Invalid or expired| OTP_ERR[OTP error — offer resend]:::err
        OTP_CHK -->|Valid| ADMIN_QUEUE[AccountDeletionService.initiateDeletion
DeletionRequest status = RESOLVED
Retire credentials
Soft delete user
Revoke all sessions]:::be
        ADMIN_QUEUE --> USER_CONFIRM[Return 200
Message: account deleted
local auth state must be cleared]:::fe
    end

    subgraph ADMIN_FLOW [Admin Dashboard Visibility]
        ADMIN1[Admin can list deletion records
GET /api/v1/admin/deletion-requests]:::ext
        ADMIN1 --> SOFT_DELETE[Current delete flow already ran:
Set User.isDeleted = true
Invalidate ALL active Sessions immediately
Set status = DELETED]:::be
        SOFT_DELETE --> RETIRE[Insert into retired_credentials:
email: permanently retired
phone: permanently retired]:::db
        RETIRE --> NOTIFY_DELETED[Return deletion success response]:::be
    end

    subgraph SELLER_EXIT [Seller Exit — Additional Steps]
        SOFT_DELETE --> SELLER_FINAL_CHK{Role = SELLER?}:::be
        SELLER_FINAL_CHK -->|Yes| FINAL_SETTLE[Block deletion if wallet.available > 0]:::be
        FINAL_SETTLE --> RAZORPAY_DEACT[Remove stored Cashfree beneficiary ids when provider supports it]:::ext
    end

    subgraph RETIRED_CRED [Retired Credential Enforcement — Always]
        NEW_ATTEMPT[New signup or login attempt
using retired email or phone]:::fe
        NEW_ATTEMPT --> RET_CHECK[(Check retired_credentials table)]:::db
        RET_CHECK -->|Retired found| HTTP410[Return HTTP 410 Gone
Message: This email address was previously associated
with a deleted account on this platform
Per our Privacy Policy deleted credentials are
permanently retired and cannot be reused
Contact support if you believe this is an error]:::err
    end
📋 Privacy Policy — One-Time-Use Credentials Rule
Email addresses and phone numbers are one-time-use identifiers on this platform. Once an account is deleted, the associated email and phone number are permanently retired and enforced at the database level via the retired_credentials table. This policy is disclosed at signup and shown as a warning before deletion is confirmed. HTTP 410 Gone is returned on any reuse attempt — no account can ever be created with a retired credential.
⚡ API Reference — Account Deletion Endpoints
REQUEST — Initiate Deletion
{
  "method": "POST",
  "endpoint": "/api/v1/auth/buyer/delete/request"
  // seller: /api/v1/auth/seller/delete/request
}
RESPONSE — OTP Sent
{
  "status": 200,
  "body": {
    "action": "VERIFY_DELETION_OTP",
    "resendAfter": 60,
    "message": "Enter the OTP to confirm deletion."
  }
}
RESPONSE — Retired Credential (410)
{
  "status": 410,
  "code": "CREDENTIAL_RETIRED",
  "message": "This email address was previously
associated with a deleted account on
this platform. Per our Privacy Policy,
deleted credentials are permanently
retired and cannot be used to create
or access any account. If you believe
this is an error, please contact support."
}
RESPONSE — Deletion Completed
{
  "status": 200,
  "body": {
    "success": true,
    "message": "Account has been successfully deleted and credentials retired."
  }
}
📋 Full Flow Description
SOFT DELETION MODEL: → isDeleted = true — rows are NEVER physically removed from the database → All Prisma/ORM queries enforce: WHERE isDeleted = false at query level → Soft-deleted data is treated as completely non-existent for all operational purposes SELLER PRE-DELETION REQUIREMENT IN CURRENT CODE: → Seller wallet.available must be 0 → Cashfree beneficiary ids are removed when the payout provider supports removal IF wallet balance exists → deletion blocked with clear message ACCOUNT DELETION FLOW — ALL USERS: → User navigates to /settings/delete-account → Presented with: Privacy Policy warning about permanent credential retirement → Required: select deletion reason from dropdown + optional freeform detail → POST /api/v1/auth/buyer/delete/request or POST /api/v1/auth/seller/delete/request BACKEND: Generate OTP (60s) → hash SHA-256 → send deletion OTP to verified email → Return 200 { action: "VERIFY_DELETION_OTP" } → User enters OTP → DELETE /api/v1/auth/buyer/delete/verify or DELETE /api/v1/auth/seller/delete/verify { otp, reason, reasonDetail } BACKEND: Verify OTP hash + 60s TTL IF valid → Upsert DeletionRequest { userId, reason, status: RESOLVED, resolvedAt } → Set User.isDeleted = true → Set userProfileStatus = DELETED → Invalidate ALL active Sessions immediately → Insert retired_credentials { email, phone, retiredAt, reason: ACCOUNT_DELETED } → For sellers, retire owned brand names too → Return 200 "Account has been successfully deleted and credentials retired." RETIRED CREDENTIAL ENFORCEMENT: → Check on every signup initiation and login attempt IF email or phone found in retired_credentials: → Return HTTP 410 Gone → Message: "This email address (or phone number) was previously associated with a deleted account on this platform. Per our Privacy Policy, deleted credentials are permanently retired and cannot be used to create or access any account. If you believe this is an error, please contact support." SELLER DELETION LIMIT IN CURRENT CODE: → Seller deletion is blocked while sellerDigitalWallet.available > 0 → The service removes stored Cashfree beneficiary ids through PayoutProviderService when possible → No settlement bypass or external fund-account deactivation route is implemented in the auth deletion service
Current Implementation
Current Auth Code Sync — Bootstrap Once, 401 Refresh, OTP Routes, Deletion
This panel is aligned with the current codebase. The frontend session bootstrap runs once from AppProviders. Navigation does not call /auth/me or /auth/refresh repeatedly. Axios refreshes only after a protected API call returns 401. Buyer supports password login followed by OTP and passwordless login OTP. Seller login requires password + Turnstile before OTP. Account deletion is OTP-gated and resolved immediately by the auth deletion service.
Next.js AppProviders NestJS Auth Controllers PostgreSQL Sessions Redis OTP + lockout
sequenceDiagram
    autonumber
    participant Browser
    participant App as AppProviders
    participant Auth as Auth API
    participant Axios
    participant API as Protected API

    Browser->>App: Mount frontend app
    App->>App: Skip bootstrap on /auth pages
    App->>Auth: GET /api/v1/auth/me
    alt access cookie valid
      Auth-->>App: user payload
    else access expired
      App->>Auth: POST /api/v1/auth/refresh
      Auth-->>App: rotated HttpOnly cookie pair
      App->>Auth: GET /api/v1/auth/me
      Auth-->>App: user payload
    end
    Browser->>Axios: Navigate and call seller/buyer/admin API
    Axios->>API: Original protected request
    alt API returns 401
      Axios->>Auth: POST /api/v1/auth/refresh
      Auth-->>Axios: rotated cookie pair
      Axios->>API: Retry original request once
    else API succeeds
      API-->>Axios: response data
    end
        
🔌 Current Auth Route Map
COMMON SESSION ROUTES
GET  /api/v1/auth/me
POST /api/v1/auth/refresh
POST /api/v1/auth/logout
POST /api/v1/auth/logout-all
POST /api/v1/auth/change-password

Frontend rule:
AppProviders hydrates once.
Axios refreshes only on protected 401.
BUYER OTP ROUTES
POST /api/v1/auth/buyer/signup/initiate
POST /api/v1/auth/buyer/signup/resend-otp
POST /api/v1/auth/buyer/signup/verify-email
POST /api/v1/auth/buyer/signup/complete
POST /api/v1/auth/buyer/signup/verify-phone
POST /api/v1/auth/buyer/login
POST /api/v1/auth/buyer/login/request-otp
POST /api/v1/auth/buyer/login/resend-otp
POST /api/v1/auth/buyer/login/verify-otp
POST /api/v1/auth/buyer/forgot-password
POST /api/v1/auth/buyer/reset-password
SELLER OTP + ONBOARDING ROUTES
POST /api/v1/auth/seller/signup/initiate
POST /api/v1/auth/seller/signup/verify-email
POST /api/v1/auth/seller/signup/verify-phone
POST /api/v1/auth/seller/signup/verify-pan
POST /api/v1/auth/seller/signup/bank-details
POST /api/v1/auth/seller/signup/trademark
POST /api/v1/auth/seller/login
POST /api/v1/auth/seller/login/resend-otp
POST /api/v1/auth/seller/login/verify-otp
POST /api/v1/auth/seller/forgot-password
POST /api/v1/auth/seller/reset-password
ACCOUNT DELETION ROUTES
POST   /api/v1/auth/buyer/delete/request
DELETE /api/v1/auth/buyer/delete/verify
POST   /api/v1/auth/seller/delete/request
DELETE /api/v1/auth/seller/delete/verify

Current auth service:
DeletionRequest is recorded as RESOLVED during OTP verification.
Admin deletion routes exist for dashboard visibility/actions, but buyer/seller deletion is resolved immediately after successful OTP verification.
📋 Current Working Rules
AUTH TRAFFIC RULE The frontend must not call /auth/me or /auth/refresh on every page navigation. AppProviders hydrates once. Axios handles the refresh path only when a protected API returns 401. Local development currently uses NEXT_PUBLIC_API_BASE_URL=http://localhost:4000/api/v1 so the browser calls the backend directly instead of proxying through :3000. LOGIN RULE Buyer password login validates identifier + password + Turnstile before OTP, and buyer passwordless login uses /auth/buyer/login/request-otp. Seller login validates identifier + password + Turnstile before OTP. If password or identifier is wrong, the user remains on the password screen. OTP is not sent for failed password login. PASSWORD RULE Forgot password uses role-specific OTP routes. Logged-in buyer and seller profile pages call /api/v1/auth/change-password; successful reset/change revokes all sessions. SELLER ONBOARDING RULE Seller signup flow is: email OTP → phone OTP → issue onboarding cookies → PAN → bank verification → trademark → admin approval/status screen. DELETION RULE Buyer and seller account deletion requires an authenticated session plus email OTP. Current code soft-deletes immediately after OTP verification, records DeletionRequest as RESOLVED, retires credentials, and revokes all sessions.
🔑 Token Lifecycle
Access & Refresh Token Lifecycle New
Current implementation of the authentication token system. The platform uses two tokens issued together at every successful login or signup: a short-lived Access Token (RS256 JWT, 15-minute TTL) for authorising API requests, and a long-lived Refresh Token (UUID v4, 7-day TTL, stored as SHA-256 hash in the Sessions table) for silent renewal. Tokens are delivered via httpOnly + SameSite=Strict cookies, with Secure enabled in production. Refresh tokens rotate on every use. Sessions are revoked by logout, logout-all, account deletion, and suspicious rotated-refresh reuse. The frontend hydrates once and the Axios interceptor silently refreshes only after a protected request returns 401.
Next.js (Axios interceptor) NestJS AuthService Sessions table (PostgreSQL) Sessions table revocation RS256 key pair (private/public)
flowchart TD
    classDef fe fill:#dbeafe,stroke:#3b82f6,color:#1e40af,font-size:12px
    classDef be fill:#dcfce7,stroke:#16a34a,color:#166534,font-size:12px
    classDef db fill:#fef9c3,stroke:#ca8a04,color:#78350f,font-size:12px
    classDef ext fill:#f3e8ff,stroke:#9333ea,color:#4c1d95,font-size:12px
    classDef err fill:#fee2e2,stroke:#dc2626,color:#7f1d1d,font-size:12px
    classDef ok fill:#d1fae5,stroke:#059669,color:#064e3b,font-size:12px
    classDef warn fill:#fff7ed,stroke:#ea580c,color:#7c2d12,font-size:12px

    subgraph ISSUANCE [Token Issuance on Every Successful Login or Signup]
        A([Auth success — login or signup OTP verified]):::ok
        A --> ISSUE["Generate Access Token
RS256 JWT · signed with RSA private key
Claims: sub · role · sessionId · iat · exp
TTL 15 minutes
Set-Cookie: access_token
httpOnly · Secure in production · SameSite=Strict
Path=/ · Max-Age=900"]:::be
        ISSUE --> ISSUE2["Generate Refresh Token
UUID v4 — cryptographically random
TTL 7 days — hash SHA-256 before storing
Set-Cookie: refresh_token
httpOnly · Secure in production · SameSite=Strict
Path=/api/v1/auth/refresh · Max-Age=604800"]:::be
        ISSUE2 --> SESSION_SAVE[("Insert Sessions row
id: UUID primary key
userId: FK to Users
refreshTokenHash: SHA-256 of UUID
expiresAt: now plus 7 days
ipAddress · userAgent
isRevoked: false")]:::db
    end

    subgraph API_USE [Every Authenticated API Request]
        REQ([Frontend makes any API call]):::fe
        REQ --> COOKIE_SENT["Browser auto-attaches access_token cookie
httpOnly — JS cannot read it
XSS cannot steal the token"]:::fe
        COOKIE_SENT --> JWT_VERIFY["NestJS JwtAuthGuard
Verify RS256 signature via RSA public key
Check expiry · check sub · check role
Extract userId · role · sessionId"]:::be
        JWT_VERIFY -->|Valid JWT| API_OK([Request proceeds to handler]):::ok
        JWT_VERIFY -->|JWT expired after 15 min| REFRESH_FLOW["Return 401 AUTH_TOKEN_EXPIRED"]:::err
    end

    subgraph SILENT_REFRESH [Silent Refresh — No User Interaction Required]
        REFRESH_FLOW --> INTERCEPTOR["Axios response interceptor catches 401
Sets isRefreshing flag to true
Queues all parallel in-flight requests
Calls POST /api/v1/auth/refresh automatically"]:::fe
        INTERCEPTOR --> REFRESH_REQ["POST /api/v1/auth/refresh
Browser auto-sends refresh_token cookie
Path-restricted — sent only to this endpoint"]:::be
        REFRESH_REQ --> RT_LOAD[("Sessions table lookup
Match SHA-256 hash of incoming token
Check isRevoked is false
Check expiresAt is after now")]:::db
        RT_LOAD -->|Not found · revoked · or expired| RT_FAIL["Return 401 AUTH_SESSION_EXPIRED
Clear both cookies via Max-Age=0
Frontend redirects user to /login
All queued requests rejected with error"]:::err
        RT_LOAD -->|Valid session found| ROTATION["TOKEN ROTATION — mandatory
Generate NEW access token RS256 · 15 min
Generate NEW refresh token UUID v4 · 7 days
Hash new refresh token with SHA-256
Update Sessions row with new hash and expiry
Old refresh token UUID is now invalid"]:::be
        ROTATION --> RETRY["Set-Cookie both new tokens
Return 200 to interceptor
Interceptor retries original request
All queued requests retried in order
isRefreshing reset to false"]:::ok
    end

    subgraph LOGOUT [Logout Flows]
        LOGOUT_SINGLE["POST /api/v1/auth/logout
Reads sessionId from JWT claims"]:::be
        LOGOUT_SINGLE --> REVOKE_ONE[("UPDATE Sessions
SET isRevoked = true
WHERE id = sessionId")]:::db
        REVOKE_ONE --> CLEAR_ONE["Clear both cookies — Max-Age=0
Return 200 Logged out"]:::ok

        LOGOUT_ALL["POST /api/v1/auth/logout-all
Used for manual all-device sign-out and account-deletion cleanup"]:::be
        LOGOUT_ALL --> REVOKE_ALL[("UPDATE Sessions
SET isRevoked = true
WHERE userId matches
All devices · all sessions invalidated")]:::db
        REVOKE_ALL --> CLEAR_BOTH["Clear both cookies — Max-Age=0
Return 200 with count of sessions revoked"]:::ok
    end

    subgraph TOKEN_INSPECT [Access Token Structure — RS256 JWT]
        JWT_HEADER["Header
alg: RS256
typ: JWT"]:::ext
        JWT_PAYLOAD["Payload claims
sub: user UUID
role: BUYER or SELLER
sessionId: session UUID
iat: issued-at Unix timestamp
exp: iat plus 900 seconds"]:::ext
        JWT_SIG["Signature
RSASSA-PKCS1-v1_5 with SHA-256
Signed by RSA private key 2048-bit
Verified by RSA public key only
No shared secret — microservice-safe"]:::ext
        JWT_HEADER --> JWT_PAYLOAD --> JWT_SIG
    end

    subgraph REVOCATION_EVENTS [Automatic Full Revocation Triggers]
        EV1["Manual Logout All
POST /api/v1/auth/logout-all
All sessions revoked across all devices"]:::warn
        EV2["Account Deletion approved by admin
All Sessions set isRevoked to true
Both cookies cleared immediately"]:::warn
        EV3["Suspended user login
Password validator blocks suspended accounts
Existing sessions should be revoked by admin action when suspension is added"]:::warn
        EV4["Stolen rotated token detected
Old rotated refresh token presented again
Entire session family revoked as precaution"]:::warn
    end
🔑 Token Architecture Summary
Access Token: RS256 JWT · 15 min TTL · httpOnly cookie (Path=/) · Verified with RSA public key — no shared secret · Claims: sub, role, sessionId, iat, exp
Refresh Token: UUID v4 · 7 day TTL · SHA-256 hash stored in Sessions table · httpOnly cookie (Path=/api/v1/auth/refresh — path-restricted) · Rotated on every use
Silent refresh: Axios interceptor catches 401 → auto-calls /api/v1/auth/refresh → gets new token pair → retries original request — zero user interaction
Token rotation: Every refresh generates a new refresh token and invalidates the old one — prevents replay attacks even if a token is stolen
Never in: Response body · localStorage · sessionStorage · JavaScript-accessible cookies
⚡ API Reference — Token Lifecycle Endpoints
RESPONSE — Token Issuance (login / signup)
// Cookies set on every successful auth:
Set-Cookie: access_token=eyJhbGci...
  HttpOnly; Secure in production; SameSite=Strict
  Path=/api; Max-Age=900

Set-Cookie: refresh_token=550e8400-e29b-41d4...
  HttpOnly; Secure in production; SameSite=Strict
  Path=/api/v1/auth/refresh; Max-Age=604800

// JWT claims (decoded — never sent to FE):
{
  "sub": "user-uuid-123",
  "role": "BUYER",
  "sessionId": "session-uuid-456",
  "iat": 1745582400,
  "exp": 1745583300
}
REQUEST / RESPONSE — Silent Token Refresh
// Frontend Axios interceptor fires on 401:
{
  POST /api/v1/auth/refresh
  // No body needed — refresh_token cookie auto-sent
  // Browser only sends it to this exact path
}

// Success (200) — new token pair issued:
Set-Cookie: access_token=eyJhbGci... (new)
Set-Cookie: refresh_token=a8098c1a... (new, old revoked)
{ "status": 200, "message": "Token refreshed" }

// Failure (401) — session expired or revoked:
{ "status": 401,
  "code": "AUTH_SESSION_EXPIRED",
  "message": "Session expired. Please log in again." }
// Both cookies cleared → redirect /login
REQUEST — Logout (single session)
{
  POST /api/v1/auth/logout
  // access_token cookie auto-sent — no body
  // sessionId extracted from JWT claims
}

// Response:
{
  "status": 200,
  "message": "Logged out successfully."
}
// Sessions.isRevoked = true for this session
// Both cookies cleared (Max-Age=0)
REQUEST — Logout All Sessions
{
  POST /api/v1/auth/logout-all
  // User-triggered all-device logout
}

// Response:
{
  "status": 200,
  "sessionsRevoked": 3,
  "message": "All sessions revoked."
}
// All Sessions rows for userId set isRevoked=true
// All devices must re-authenticate
📋 Full Implementation Specification
TOKEN ISSUANCE — Fires on: Signup completion, Login OTP verified, Token refresh success DATABASE SCHEMA — Sessions table: id → UUID v4 — primary key userId → FK → Users.id refreshTokenHash → SHA-256(refreshToken) — plaintext NEVER stored expiresAt → NOW + 7 days createdAt → NOW ipAddress → request IP (INET type in PostgreSQL) userAgent → request User-Agent header (truncated to 512 chars) isRevoked → Boolean default false — index on (userId, isRevoked) ACCESS TOKEN STRUCTURE: → Algorithm: RS256 (RSASSA-PKCS1-v1_5 with SHA-256) → Key pair: 2048-bit RSA — private key signs, public key verifies → Private key stored as environment secret — NEVER committed to git → Public key used by JwtAuthGuard — can be safely distributed → Why RS256 over HS256: no shared secret — microservices can verify without access to private key JWT Header: { "alg": "RS256", "typ": "JWT" } JWT Payload: { sub: "user-uuid-123" // userId — primary user identifier role: "BUYER" | "SELLER" // For route guard enforcement sessionId: "session-uuid-456" // For targeted single-session revocation iat: 1745582400 // Issued at (Unix timestamp) exp: 1745583300 // Expiry = iat + 900 (15 minutes) } NOTE: No sensitive data (email, phone, name) in JWT payload — only IDs and role. REFRESH TOKEN: → UUID v4 generated server-side (crypto.randomUUID()) → Hashed with SHA-256 before storing in Sessions table → Plaintext UUID sent to client via cookie only — never stored anywhere else → Cookie path restricted to /api/v1/auth/refresh — browser only sends it to that exact URL COOKIE ATTRIBUTES (both tokens): → HttpOnly: true — inaccessible to JavaScript — XSS cannot steal tokens → Secure: true in production, false in local development — controlled by NODE_ENV → SameSite: Strict — CSRF protection — not sent on cross-site requests → access_token: Path=/ — sent with API requests automatically → refresh_token: Path=/api/v1/auth/refresh — sent ONLY to the refresh endpoint SILENT REFRESH FLOW (Axios request interceptor pattern): FRONTEND → Every API call automatically includes access_token cookie (browser behaviour) → Backend JwtAuthGuard validates JWT: signature → expiry → userId active IF JWT expired (401 AUTH_TOKEN_EXPIRED): → Axios response interceptor catches the 401 → Check: is already refreshing? → IF YES → queue this request → wait for new token → IF NO: set isRefreshing = true → Call POST /api/v1/auth/refresh (refresh_token cookie auto-sent) BACKEND /auth/refresh: → Read refresh_token cookie → Hash it with SHA-256 → Query Sessions: WHERE refreshTokenHash = hash AND isRevoked = false AND expiresAt > NOW IF not found, revoked, or expired: → Return 401 AUTH_SESSION_EXPIRED → Frontend: clear both cookies → redirect /login → flush all queued requests with error IF valid session: TOKEN ROTATION (mandatory — prevents refresh token replay attacks): → Generate NEW Access Token (RS256, 15 min) → Generate NEW Refresh Token (UUID v4) → Hash new refresh token with SHA-256 → UPDATE Sessions SET refreshTokenHash = new hash, expiresAt = NOW + 7d, isRevoked = false → Old refresh token UUID is now invalidated (its hash no longer matches DB) → Set-Cookie both new tokens with same httpOnly/Secure/SameSite attributes → Return 200 → Axios interceptor: set isRefreshing = false → Retry the original failed request (new access_token cookie now set) → Resolve all queued parallel requests (they retry in order) LOGOUT — SINGLE SESSION: → User clicks Logout → FRONTEND: POST /api/v1/auth/logout (access_token cookie auto-sent) → BACKEND: extract sessionId from JWT claims → UPDATE Sessions SET isRevoked = true WHERE id = sessionId → Set-Cookie: access_token= ; Max-Age=0 (clears cookie) → Set-Cookie: refresh_token= ; Max-Age=0 (clears cookie) → Return 200 LOGOUT — ALL SESSIONS: → POST /api/v1/auth/logout-all → BACKEND: extract userId from JWT → UPDATE Sessions SET isRevoked = true WHERE userId = userId → Clear both cookies on current device → All other devices: next API call returns 401 → refresh attempt fails → redirected to /login AUTOMATIC FULL REVOCATION TRIGGERS: Manual Logout All → POST /api/v1/auth/logout-all revokes every session for the current user Account Deletion (admin approved) → all Sessions.isRevoked = true immediately Account Suspension by admin → PATCH /admin/users/:id/suspend → all sessions revoked Refresh token theft detection → if old (rotated) refresh token is presented → revoke ENTIRE session family (all sessions for that user) as a security measure IMPLEMENTATION NOTES: → RS256 key pair generation: openssl genrsa -out private.pem 2048 → openssl rsa -in private.pem -pubout -out public.pem → Keys injected via environment variables JWT_PRIVATE_KEY_B64 + JWT_PUBLIC_KEY_B64 → NestJS JwtModule: { algorithm: 'RS256', privateKey, publicKey, signOptions: { expiresIn: '15m' } } → NestJS JwtAuthGuard: custom guard extending PassportStrategy('jwt') — attached to all protected routes → Role guard: @Roles('SELLER') decorator → checks JWT claims.role before handler runs → Expired sessions are ignored by SessionService queries; no session cleanup worker exists in the current codebase