adr: 0008 title: Registration profile policy (single-name, age 18+, pre-login referral validation) status: accepted implementation-status: implemented date: 2026-05-22 deciders: [@amalkrsihna] affects-specs: [signup-and-onboarding, referrals] affects-code: - ftl-frontend/src/pages/Register.tsx # name lock (~L179-188), age (~L434), submit payload (~L442-451) - ftl-backend/internal/user/service.go # last-name validator (~L222), age range (~L231) - ftl-backend/internal/handler/routes.go # new public route registration - ftl-backend/internal/handler/referral.go # NEW public-validate handler (or inline) supersedes: null superseded-by: null
ADR-0008: Registration profile policy¶
Pending implementation. Bundles three related profile-validation fixes that share a single decision domain (what the registration form requires of a user). Bundled rather than split into three mini-ADRs because the three are tightly coupled at the form layer.
Context¶
The pre-registration review surfaced three related defects in profile validation. All three sit inside the Google-OAuth-then-complete-profile flow that every new user passes through.
1. Single-name Google users are stuck¶
ftl-frontend/src/pages/Register.tsx:179-188 reads res.givenName and res.familyName
from the Google profile response. When both are present, the names prefill and the fields
are locked. When both are empty, the form falls back to splitting res.name via
splitName() and only locks if (split.first || split.last) is truthy. The bug: if Google
returns only givenName (single-name users in many cultures — Indonesian, South Indian,
Brazilian, etc.), split.first is truthy but split.last === "", so nameLocked = true
and the lastName field is non-editable at the empty string. Submit sends lastName: "".
Backend at ftl-backend/internal/user/service.go:222 enforces
if in.LastName == "" || len(in.LastName) > 50 → error "last name must be 1-50 chars".
Registration fails with no path forward.
2. Age range is inconsistent AND too permissive¶
Register.tsx:434: if (!ageNum || ageNum < 13 || ageNum > 100) → "Age must be between
13 and 100". Backend service.go:231: if in.Age < 13 || in.Age > 120 → "age must be
between 13 and 120".
Two problems: (a) the upper-bound mismatch is a real cross-tier inconsistency; and (b) the operator has now decided the minimum should be 18 for liability/legal reasons (real-money-adjacent trading game). Neither tier reflects that yet.
3. Referral code validation happens too late¶
ftl-backend/internal/handler/routes.go:217 registers /referral/validate under the
protected group, requiring a JWT. The form has no way to call it pre-login. The form also
does NOT call it post-login — Register.tsx:442-451 simply includes referralCode in the
registration payload and trusts the backend.
The backend's RegisterFromGoogle handles a bad code gracefully — no error, no wallet
credit, no signal to the user. A typo in a referral code is silently dropped. The user
thinks they're earning the referrer credit; they aren't.
Decision¶
We will allow single-name registrations, set the age minimum to 18 with no upper bound, and add a public referral-code validator the form calls on debounce.
Concrete changes the paired code PR will encode:
Single-name handling¶
- Backend
service.go:222: change the validator toif len(in.LastName) > 50— empty string is allowed. The column already permits empty (TEXT, no NOT NULL). - Frontend
Register.tsx:179-188: tighten the lock condition. The exact replacement:Effect: the lock fires only when BOTH names came from Google. A user with only// Before: setNameLocked(!!(split.first || split.last)); // After: setNameLocked(!!(split.first && split.last));givenNamepopulated keepslastNameeditable (and may submit it empty if they wish). - Submit payload: no change needed.
Register.tsx:442-451already sendslastName: lastName.trim()which becomes""when the field is empty.
Age policy¶
- Both tiers: minimum
18, no maximum (drop the upper bound). - Frontend
Register.tsx:434: Error string:"You must be 18 or older to register." - Backend
service.go:231: Error string:"you must be 18 or older to register"(lowercase per the existing service-layer convention). - The age input on the form should also reject values <18 in real time (HTML
min={18}on the<input type="number">), but the JS guard above remains as the authoritative check sincemin=only constrains spinner buttons, not keyboard input.
Pre-login referral validation¶
- New public route
GET /api/public/referral/validate?code=<code>returns: 200 { "valid": true, "referrerDisplayName": "Asha P." }on a known code.200 { "valid": false }on unknown/expired code (NOT 404 — the form needs to distinguish "I checked, it's invalid" from "the server is down").429on rate-limit (10 req/min per IP) — handled by Fiber's existing rate-limit middleware. Use the samerl(...)helper that other public routes use.- Wire in
routes.gooutside theprotectedgroup, e.g. under a newpublicgroup defined alongsideauth: - The existing
/referral/validate(routes.go:217) stays — it returns the logged-in user's own referral state when called with JWT. The new endpoint is read-only validation of a code, no JWT, never returns sensitive info beyond a display name. - Frontend: in
Register.tsx, add a debounced fetch (300-500ms) that fires when the referral input has 8 characters (the codes are fixed-length). Onvalid: true, show inline"Valid — invited by <displayName>"below the field. Onvalid: false, show"Code not found"in error styling. On network error, fail silent (don't block submission — backend will silently drop the invalid code anyway, which is the existing behavior). - The form does NOT block submission on a
falseresult. Users can submit anyway; the inline message warns them what will happen. (Forcing submission to fail would frustrate users whose referrer's account is in some unexpected state — better to surface the issue and let them proceed.)
Consequences¶
Positive¶
- Single-name users can register. Removes a hard-stop in the funnel that disproportionately affected users from cultures where single-name accounts are common.
- Consistent age policy across tiers. No more "FE allows X but BE rejects X" mismatches.
- 18+ minimum aligns with stated operator intent for trading-adjacent games.
- Referral code typos are caught at the form, not silently swallowed. Users see what's going to happen before they commit.
Negative¶
- Some existing test fixtures may use ages <18 and will need updating. Grep for
age: 13,age: 16etc. inservice_test.go,register.test.tsx, and Playwright fixtures. /api/public/referral/validateis unauthenticated — a small enumeration risk if a user iterates codes. Mitigations: rate-limit at 10/min/IP (already in scope above); return only the referrer's display name, not username/email/anything PII-grade; consider opaque non-sequential codes if not already (they are — codes are randomly generated alphanumerics).
Neutral / new obligations¶
- The age input field's HTML
min={18}is a UX hint, not authoritative. Both the JS guard and the backend guard remain as the real checks. Don't drop either when adding the HTML attribute. - Lock-condition change is subtle. The new condition
split.first && split.lastintentionally diverges from the original||. Worth a comment in the code explaining why: "Lock only when BOTH names came from Google; otherwise let the user fill in the blanks."
Alternatives considered¶
Alternative A: Require lastName always but auto-fill - when Google returns none¶
Backend keeps len(in.LastName) > 0 validator. Frontend, when split.last === "", fills
the field with "-" and locks it.
Rejected. Visually ugly. The auto-- becomes part of the user's profile name. The
display name throughout the app reads "Cher -" or "Asha -" — confusing and disrespectful of
users' real names. Allowing empty lastName respects the cultural reality without compromising
data integrity (the column already allows empty).
Alternative B: Move existing /referral/validate out of protected to make it public¶
Re-wire the existing endpoint under the public group instead of adding a new one.
Rejected. That endpoint serves two purposes today: (1) validate a code, and (2) return the logged-in user's own referral state (referrer info, referral count, milestones earned). Conflating both into a public endpoint either leaks the logged-in-user case to the world or requires conditional response shapes based on auth state — messy. A dedicated public endpoint with a narrow contract is cleaner.
Alternative C: Keep age max 120 to match today's backend¶
Pick the looser of the two existing ranges (13-120) and align the frontend to it. Don't introduce the 18 minimum yet.
Rejected. The operator explicitly raised the minimum to 18; this branch's whole purpose is to fix the pre-registration funnel. Doing only the consistency fix without the policy fix would mean a second ADR shortly. The 120 maximum is arbitrary and pointless — better to drop it entirely.
Alternative D: Validate referral codes server-side in RegisterFromGoogle and return 422 on bad code¶
Block account creation if the code is invalid.
Rejected. Surfaces the same error too late — the user has already gone through Google OAuth and filled the form. A debounced inline check at form fill time gives them the chance to fix the code (or remove it) before committing. Also, blocking registration entirely on a bad referral code is harsh — the code is optional, not required.