Skip to content

spec: signup-and-onboarding status: active last-updated: 2026-05-22 owners: [@amalkrsihna] code-refs: - ftl-backend/internal/handler/routes.go - ftl-backend/internal/user/service.go - ftl-backend/internal/middleware/coming_soon.go - ftl-backend/internal/auth/cookies.go - ftl-backend/migrations/000012_add_user_profile_fields.up.sql - ftl-backend/migrations/000030_phone_unique_index.up.sql - ftl-frontend/src/pages/Register.tsx - ftl-frontend/src/components/WelcomeCelebration.tsx - ftl-frontend/src/stores/flags.ts - ftl-frontend/src/App.tsx - ftl-frontend/src/tutorial/TutorialOverlay.tsx decisions: [0007, 0008, 0010]


Signup and Onboarding

TL;DR for an intern

A new player signs in with Google, verifies a 6-digit code emailed to them, fills out a short profile (name, age, phone, Kerala district, optional referral code), and lands in the Stadium. While the platform is in "coming-soon" teaser mode, everything except sign-in is blocked at the API layer — the user sees a "FTL launches soon" page no matter how they got there.

Glossary (defined before first use)

  • Google OAuth / GSI — Google's "Sign in with Google" library. We do the ID-token verification ourselves rather than paying a managed-auth provider.
  • Registration ticket — short-lived JWT we mint after Google's ID token verifies. It represents "we know who this Google account is — they can finish profile setup within the next 5 minutes." Required for /auth/google/complete and /auth/verify-otp.
  • OTP — One-Time Password. A 6-digit code emailed to the user's Google address. Even though Google already verified the address, we re-verify it via OTP so a stolen ID token alone can't complete signup.
  • Coming-soon flag — a boolean in feature_flags admins toggle to lock the app down pre-launch. Backed by a frontend redirect AND a backend 503 (since 2026-05-22, ADR-0007).
  • District — one of 14 Kerala districts. Used for the district leaderboard. Immutable after registration (changing it would corrupt historical rankings).
  • Referral code — an 8-char hex string assigned to every new user (generateReferralCode in user/service.go). They share it; their friends type it on signup; their wallet earns 500 coins when the friend places a first trade (see referrals.md).

The flow

1. /              → Sign in with Google button
                    (consent gate: must accept Terms + Privacy)
2. POST /api/auth/google
   - body: { idToken, nonce }
   - server verifies Google ID token (audience, signature, nonce one-shot)
   - new user → 422 { needsProfile: true, registrationTicket, needsEmailOTP: true }
   - existing user → 200 { user, ... } + access/refresh cookies set
3. POST /api/auth/verify-otp                (only for new users)
   - body: { registrationTicket, otp }
   - server checks regotp:code:<sub> in Redis (60s cooldown, attempts capped)
   - on success: flips regotp:verified:<sub> = true
4. POST /api/auth/google/complete           (new user only)
   - body: { registrationTicket, firstName, lastName?, username, phone, age,
             districtId, referralCode?, inviteId? }
   - server re-verifies the ticket + OTP-verified flag
   - server validates fields (rules below) and creates the user row
   - server seeds wallet (10,000 coins), assigns a referral_code
   - server claims the optional invite/referral (see referrals.md)
   - server issues access + refresh cookies
   - returns { user, isNew: true, registrationResult: {appliedReferral, ...} }
5. Frontend renders /stadium (or /coming-soon if flag is on)
   - On first arrival, optionally starts the onboarding tutorial
     (suppressed when coming-soon is on, ADR-0007)

Validation rules

Authority is the backend. Frontend pre-checks are for UX hints only. All rules live in user.RegisterFromGoogle (internal/user/service.go).

Field Rule Source ADR
firstName required, 1–50 chars
lastName optional, ≤ 50 chars (empty allowed) 0008 (was: required)
username regex ^[a-z][a-z0-9_]{2,19}$, unique
phone regex ^[6-9]\d{9}$ (Indian 10-digit mobile), partial UNIQUE 0010
age integer ≥ 18, no upper bound 0008 (was: 13–120)
districtId one of 14 valid Kerala codes
referralCode optional 4–16 chars; silently ignored if unknown
inviteId optional UUID; linked to the referral row if provided 0009

Phone uniqueness (ADR-0010)

Migration 000030 adds users_phone_unique as a partial UNIQUE on users(phone) WHERE phone <> ''. Legacy demo accounts with phone = '' keep working; real numbers get the constraint. Duplicate INSERTs surface as PG SQLSTATE 23505; user.RegisterFromGoogle maps that constraint-name match to ErrPhoneTaken, the handler returns 409 phone_taken, and the frontend paints an inline error next to the phone field with a "did you mean to sign in?" affordance.

No SMS OTP — the cost (~₹0.15–0.20 per send) and funnel drop-off (10–30%) were judged not worth the marginal abuse-mitigation given the partial UNIQUE already closes the main "one phone, N accounts" path.

Single-name registrations (ADR-0008)

Google profiles that return only givenName (no familyName) used to dead-end: the backend rejected an empty LastName, and the frontend locked the name field after Google filled it. Now:

  • Backend permits LastName == "" (see service.go:222).
  • Frontend locks the name field only when BOTH halves came from Google (Register.tsx setNameLocked(!!(split.first && split.last))).
  • DisplayName is strings.TrimSpace(FirstName + " " + LastName) so a single-name user's display name is just their first name without a trailing space.

Coming-soon gate (ADR-0007)

Two-layer enforcement:

  1. Frontend (UX)RequireAuth in App.tsx redirects non-admins to /coming-soon when flags.coming_soon == true. Fails open if /api/flags errors (the flag store sets coming_soon ?? false on failure — by design, so a flag-fetch hiccup doesn't black out the app).
  2. Backend (authority)middleware.ComingSoonGate(flagSvc, allowAdmin=true) is registered on the protected route group right after JWTAuth. Returns 503 with {"error":"coming_soon","reason":"coming_soon"} for non-admins when the flag is on. Admins bypass so they can flip the flag back off.

The middleware fails OPEN on flag-store errors — same rationale as the FE layer. If Postgres is down, the user has worse problems than the gate.

Tutorial / coming-soon redirect loop

Without the guards from ADR-0007, an admin flipping coming_soon=true mid-tutorial caused this loop:

  1. Tutorial overlay tries to auto-navigate to /stadium (its first step's route).
  2. RequireAuth on /stadium bounces to /coming-soon.
  3. Tutorial overlay still active → tries to auto-navigate again.
  4. Flicker between /stadium and /coming-soon.

Three guards close this (defense in depth):

  • Register.tsx: don't start the tutorial when flags.coming_soon is on.
  • TutorialOverlay.tsx: skip the auto-route effect when flags.coming_soon is on.
  • App.tsx: skip the TUTORIAL_REFRESH_FLAG → /stadium redirect when flags.coming_soon is on.

Error semantics

Status Body Trigger
400 {"error":"<reason>"} Validation failure (firstName empty, age < 18, bad phone format, …).
401 {"error":"invalid or expired registration ticket"} Ticket missing/expired. Frontend restarts the Google flow.
403 {"error":"email_not_verified"} OTP not yet verified for this registration ticket.
409 {"error":"…","reason":"username_taken"} UNIQUE on users.username.
409 {"error":"…","reason":"email_taken"} UNIQUE on users.email OR users.google_id.
409 {"error":"…","reason":"phone_taken"} UNIQUE on users_phone_unique (ADR-0010).
503 {"error":"coming_soon","reason":"coming_soon"} ComingSoonGate blocked the request (non-admin).

The reason field is the source of truth for frontend branching. The error field is display copy only and may change wording without a contract bump.

Code references

  • ftl-backend/internal/user/service.go:213-393RegisterFromGoogle (validation, INSERT, wallet seed, referral code assignment, optional invite claim).
  • ftl-backend/internal/handler/routes.go:174-194 — public route registration including /api/public/referral/validate (ADR-0008, used for debounced FE validation).
  • ftl-backend/internal/handler/routes.go:195-205protected group + ComingSoonGate wire-up.
  • ftl-backend/internal/handler/routes.go:503-580AuthGoogleComplete handler with typed-error → 409 mapping.
  • ftl-backend/internal/middleware/coming_soon.goComingSoonGate middleware.
  • ftl-backend/migrations/000030_phone_unique_index.up.sql — partial UNIQUE migration.
  • ftl-frontend/src/pages/Register.tsx — 3-step form (Google → OTP → profile). nameLock, age, phone 409 inline error, debounced referral validate, tutorial-start guard.
  • ftl-frontend/src/components/WelcomeCelebration.tsx — post-signup celebration screen. No referral-bonus banner under v2 economics.
  • ftl-frontend/src/tutorial/TutorialOverlay.tsx:150-160 — auto-route effect with coming-soon guard.
  • ftl-frontend/src/App.tsx?ref= + ?invite= capture, TUTORIAL_REFRESH_FLAG guard, RequireAuth redirect to /coming-soon.
  • ftl-frontend/src/stores/flags.ts — flag store with fail-open behavior on /api/flags errors.
  • ADR-0007ComingSoonGate backend middleware closes the curl-around-frontend gap.
  • ADR-0008 — single-name registrations, age 18+, public referral validate endpoint.
  • ADR-0010 — partial UNIQUE on users.phone, 409 phone_taken error path, no SMS OTP.

How to verify

# Backend tests (single-name, age, duplicate phone, empty-phone partial-index behavior)
cd ftl-backend
go test ./internal/user/... ./internal/middleware/... -run RegisterFromGoogle\|ComingSoonGate

# Migration round-trip
make migrate-down && make migrate-up

# Public referral validate (no auth)
curl -s "$API_BASE/api/public/referral/validate?code=ABCD1234" | jq

# Coming-soon enforcement with non-admin cookie
COOKIE=$(cat /tmp/non-admin-cookie.txt)
curl -s -o /dev/null -w "%{http_code}\n" -b "$COOKIE" "$API_BASE/api/instruments"
# Expect 503 when admin has flipped coming_soon=true; 200 otherwise.

# Manual UI repro
# 1. Open ftl.jetafutures.com (staging).
# 2. Sign in with a Google account whose name returns only givenName (or use OAuth Playground).
# 3. Confirm the registration form's last-name input is editable.
# 4. Type age=17 → see "You must be 18 or older."
# 5. Type a known referral code → see green "Invited by X" after ~300ms.