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/completeand/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_flagsadmins 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 (
generateReferralCodeinuser/service.go). They share it; their friends type it on signup; their wallet earns 500 coins when the friend places a first trade (seereferrals.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 == ""(seeservice.go:222). - Frontend locks the name field only when BOTH halves came from Google (
Register.tsxsetNameLocked(!!(split.first && split.last))). DisplayNameisstrings.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:
- Frontend (UX) —
RequireAuthinApp.tsxredirects non-admins to/coming-soonwhenflags.coming_soon == true. Fails open if/api/flagserrors (the flag store setscoming_soon ?? falseon failure — by design, so a flag-fetch hiccup doesn't black out the app). - Backend (authority) —
middleware.ComingSoonGate(flagSvc, allowAdmin=true)is registered on the protected route group right afterJWTAuth. 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:
- Tutorial overlay tries to auto-navigate to
/stadium(its first step's route). RequireAuthon/stadiumbounces to/coming-soon.- Tutorial overlay still active → tries to auto-navigate again.
- Flicker between
/stadiumand/coming-soon.
Three guards close this (defense in depth):
Register.tsx: don't start the tutorial whenflags.coming_soonis on.TutorialOverlay.tsx: skip the auto-route effect whenflags.coming_soonis on.App.tsx: skip theTUTORIAL_REFRESH_FLAG → /stadiumredirect whenflags.coming_soonis 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-393—RegisterFromGoogle(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-205—protectedgroup +ComingSoonGatewire-up.ftl-backend/internal/handler/routes.go:503-580—AuthGoogleCompletehandler with typed-error → 409 mapping.ftl-backend/internal/middleware/coming_soon.go—ComingSoonGatemiddleware.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_FLAGguard,RequireAuthredirect to/coming-soon.ftl-frontend/src/stores/flags.ts— flag store with fail-open behavior on /api/flags errors.
Related decisions¶
- ADR-0007 —
ComingSoonGatebackend 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, 409phone_takenerror 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.