Skip to content

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 to if 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:
    // Before:
    setNameLocked(!!(split.first || split.last));
    // After:
    setNameLocked(!!(split.first && split.last));
    
    Effect: the lock fires only when BOTH names came from Google. A user with only givenName populated keeps lastName editable (and may submit it empty if they wish).
  • Submit payload: no change needed. Register.tsx:442-451 already sends lastName: 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:
    // Before:
    if (!ageNum || ageNum < 13 || ageNum > 100) {
    // After:
    if (!ageNum || ageNum < 18) {
    
    Error string: "You must be 18 or older to register."
  • Backend service.go:231:
    // Before:
    if in.Age < 13 || in.Age > 120 {
    // After:
    if in.Age < 18 {
    
    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 since min= 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").
  • 429 on rate-limit (10 req/min per IP) — handled by Fiber's existing rate-limit middleware. Use the same rl(...) helper that other public routes use.
  • Wire in routes.go outside the protected group, e.g. under a new public group defined alongside auth:
    publicGroup := api.Group("/public")
    publicGroup.Get("/referral/validate", rl(10, time.Minute), h.PublicReferralValidate)
    
  • 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). On valid: true, show inline "Valid — invited by <displayName>" below the field. On valid: 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 false result. 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: 16 etc. in service_test.go, register.test.tsx, and Playwright fixtures.
  • /api/public/referral/validate is 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.last intentionally 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.