PC3 Phase 3.2: real age verification (date of birth, not a checkbox)

POST /api/auth/register now requires a real date_of_birth (ISO YYYY-MM-DD).
The server computes age (auth.calculate_age) and rejects under-18s with 403;
a future/nonsensical date is 400. The DOB is stored and surfaced only to
that user (/api/auth/me).

Still self-attested (a user can enter a false DOB) -- but a concrete,
stored, legally-meaningful data point and the seam for a future ID-check
pass. Encryption-at-rest for the column and the Tier-2 hook are not done;
the limitation is stated plainly in docs/architecture/content_moderation.md 5.2.

- auth.py: RegisterRequest.date_of_birth (date); MIN_AGE=18; register()
  computes + enforces; UserResponse.date_of_birth typed `date | None`
  (the old `str | None` would have 500'd /me once a DOB was actually stored).
- api_client.gd / main_menu.gd: the Over18 checkbox is now a
  "Date of birth (YYYY-MM-DD)" field with a client-side sanity check.
- tests: test_auth_age_gate.py rewritten around the endpoint + age math
  (adult/minor/exactly-18/future-dob/stored); ~25 over_18 references across
  6 backend test files + 6 game smoke tests updated. Backend 74 passed;
  game smokes green (auth 6/6, menu_full_flow 12/12, e2e 4/4, etc.);
  fresh-clone boot PASS.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015jtxAiu5dAW4WxCNdGkrkJ
This commit is contained in:
2026-09-05 22:18:38 +00:00
parent 855311e731
commit 7bd8124199
19 changed files with 194 additions and 150 deletions
+4 -3
View File
@@ -178,18 +178,19 @@ func login(username: String, password: String) -> Dictionary:
## Register a new user. On success stores the returned access token.
## @param over_18 Must be true; the backend rejects registration otherwise.
## @param date_of_birth ISO "YYYY-MM-DD". The backend computes age from it
## and rejects under-18s (403).
func register(
username: String,
email: String,
password: String,
over_18: bool
date_of_birth: String
) -> Dictionary:
var payload := {
"username": username,
"email": email,
"password": password,
"over_18": over_18,
"date_of_birth": date_of_birth,
}
var result := await request(
HTTPClient.METHOD_POST,
+25 -11
View File
@@ -35,7 +35,7 @@ var _auth_mode_label: Label
var _username_edit: LineEdit
var _password_edit: LineEdit
var _email_edit: LineEdit
var _over_18_check: CheckBox
var _dob_edit: LineEdit
var _auth_button: Button
var _status_label: Label
var _welcome_label: Label
@@ -59,7 +59,7 @@ func _ready() -> void:
_username_edit = $CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/UsernameEdit
_password_edit = $CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/PasswordEdit
_email_edit = $CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/EmailEdit
_over_18_check = $CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/Over18Check
_dob_edit = $CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/DobEdit
_auth_button = $CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/AuthButton
_status_label = $CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/AuthStatusLabel
_welcome_label = $CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/WelcomeLabel
@@ -115,7 +115,7 @@ func _show_authenticated(username: String) -> void:
_username_edit.visible = false
_password_edit.visible = false
_email_edit.visible = false
_over_18_check.visible = false
_dob_edit.visible = false
_auth_button.visible = false
_auth_mode_label.visible = false
_play_button.disabled = false
@@ -131,8 +131,8 @@ func _reset_to_logged_out() -> void:
_status_label.visible = false
_welcome_label.visible = false
_email_edit.visible = false
_over_18_check.visible = false
_over_18_check.button_pressed = false
_dob_edit.visible = false
_dob_edit.text = ""
_play_button.disabled = true # Gate play behind successful auth.
_customize_button.disabled = true
_update_auth_mode_ui()
@@ -158,6 +158,19 @@ func _on_quit_button_pressed() -> void:
get_tree().quit()
## Loose client-side check -- YYYY-MM-DD, a real calendar date, not in the
## future. The backend does the authoritative age computation.
func _is_valid_dob(dob: String) -> bool:
var m := RegEx.new()
m.compile("^\\d{4}-\\d{2}-\\d{2}$")
if m.search(dob) == null:
return false
var parts := dob.split("-")
var d := {"year": int(parts[0]), "month": int(parts[1]), "day": int(parts[2]), "hour": 0, "minute": 0, "second": 0}
var unix := Time.get_unix_time_from_datetime_dict(d)
return unix > 0 and unix < Time.get_unix_time_from_system()
# ─── Auth panel toggle ────────────────────────────────────────────────────────
func _on_login_button_pressed() -> void:
@@ -184,12 +197,12 @@ func _update_auth_mode_ui() -> void:
_auth_mode_label.text = "Register"
_auth_button.text = "Register"
_email_edit.visible = true
_over_18_check.visible = true
_dob_edit.visible = true
else:
_auth_mode_label.text = "Login"
_auth_button.text = "Login"
_email_edit.visible = false
_over_18_check.visible = false
_dob_edit.visible = false
# ─── Auth submission ──────────────────────────────────────────────────────────
@@ -216,8 +229,9 @@ func _on_auth_button_pressed() -> void:
if email == "":
_show_status("Email is required.")
return
if not _over_18_check.button_pressed:
_show_status("You must confirm you are 18 or older to register.")
var dob := _dob_edit.text.strip_edges()
if not _is_valid_dob(dob):
_show_status("Enter your date of birth as YYYY-MM-DD.")
return
_auth_button.disabled = true
@@ -225,7 +239,7 @@ func _on_auth_button_pressed() -> void:
_is_authenticating = true
_clear_status()
var result := await ApiClient.register(username, email, password, true)
var result := await ApiClient.register(username, email, password, dob)
await _on_auth_complete(result)
else:
_auth_button.disabled = true
@@ -251,7 +265,7 @@ func _on_auth_complete(result: Dictionary) -> void:
_username_edit.visible = false
_password_edit.visible = false
_email_edit.visible = false
_over_18_check.visible = false
_dob_edit.visible = false
_auth_button.visible = false
_auth_mode_label.visible = false
_status_label.visible = false
+1 -1
View File
@@ -30,7 +30,7 @@ func _run() -> void:
var session := root.get_node("Session")
var uname := "mpflow_%s_%d" % [role, int(Time.get_unix_time_from_system() * 1000) % 1000000]
var reg: Dictionary = await api.register(uname, uname + "@e.com", "secret123", true)
var reg: Dictionary = await api.register(uname, uname + "@e.com", "secret123", "1990-01-01")
if not reg.ok:
print("RESULT %s register_failed %s" % [role, reg.detail])
quit(1); return
+11 -9
View File
@@ -10,7 +10,7 @@ extends SceneTree
## Tests:
## 1. Register a unique user → 201 + token stored.
## 2. Register the same user again → 409 "Username already registered".
## 3. Register with over_18=false → 400 "Must be 18 or older".
## 3. Register with an under-18 DOB → 403 "at least 18".
## 4. Login with correct credentials → 200 + token stored.
## 5. Login with wrong password → 401 "Incorrect username or password".
## 6. Login with non-existent user → 401 "Incorrect username or password".
@@ -44,7 +44,7 @@ func _run() -> void:
var email := "smoke_%d@test.example.com" % unique_id
print("\n=== Test 1: Register unique user ===")
var result := await api.register(username, email, TEST_PASSWORD, true)
var result := await api.register(username, email, TEST_PASSWORD, "1990-01-01")
if _check("register returns ok", result.ok) \
and _check("register status is 201", result.status == 201) \
and _check("register stores token", api.has_token()):
@@ -55,7 +55,7 @@ func _run() -> void:
# --- Test 2: Register same user again (duplicate) ---
print("\n=== Test 2: Duplicate username ===")
result = await api.register(username, email, TEST_PASSWORD, true)
result = await api.register(username, email, TEST_PASSWORD, "1990-01-01")
if _check("duplicate register not ok", not result.ok) \
and _check("duplicate status is 409", result.status == 409) \
and _check("duplicate detail mentions username",
@@ -65,14 +65,16 @@ func _run() -> void:
failed += 1
print(" detail: ", result.detail)
# --- Test 3: Register with over_18=false ---
print("\n=== Test 3: over_18=false registration ===")
# --- Test 3: Register with an under-18 date of birth ---
print("\n=== Test 3: under-18 registration ===")
var child_username := "child_%d" % unique_id
var child_email := "child_%d@test.example.com" % unique_id
result = await api.register(child_username, child_email, TEST_PASSWORD, false)
if _check("over_18=false register not ok", not result.ok) \
and _check("over_18=false status is 400", result.status == 400) \
and _check("over_18=false detail mentions age",
var now := Time.get_datetime_dict_from_system()
var minor_dob := "%04d-01-01" % (int(now["year"]) - 14)
result = await api.register(child_username, child_email, TEST_PASSWORD, minor_dob)
if _check("under-18 register not ok", not result.ok) \
and _check("under-18 status is 403", result.status == 403) \
and _check("under-18 detail mentions age",
"18" in result.detail):
passed += 1
else:
+1 -1
View File
@@ -69,7 +69,7 @@ func _run() -> void:
# --- Step 2: Register -----------------------------------------------------
print("\n=== Step 2: Register (POST /api/auth/register) ===")
var reg: Dictionary = await api.register(username, email, TEST_PASSWORD, true)
var reg: Dictionary = await api.register(username, email, TEST_PASSWORD, "1990-01-01")
var reg_token := _extract_token(reg)
var reg_ok: bool = (
reg.ok
+1 -1
View File
@@ -35,7 +35,7 @@ func _run() -> void:
var unique_id: int = int(Time.get_unix_time_from_system() * 1000) + randi() % 100000
var username := "enterworld_%d" % unique_id
var email := "enterworld_%d@test.example.com" % unique_id
var reg_result: Dictionary = await api.register(username, email, "SmokeTest123!", true)
var reg_result: Dictionary = await api.register(username, email, "SmokeTest123!", "1990-01-01")
if not _check("setup: register succeeded", reg_result.ok):
print(" detail: ", reg_result.detail)
_finish()
+1 -1
View File
@@ -49,7 +49,7 @@ func _run() -> void:
var username := USERNAME % unique_id
var email := EMAIL % unique_id
print("\n=== Setup: register ", username, " ===")
var register_result: Dictionary = await api.register(username, email, PASSWORD, true)
var register_result: Dictionary = await api.register(username, email, PASSWORD, "1990-01-01")
if not _check("register succeeds", register_result.ok):
print(" detail: ", register_result.detail)
_finish()
+3 -3
View File
@@ -25,7 +25,7 @@ func _run() -> void:
var username_edit: LineEdit = instance.get_node("CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/UsernameEdit")
var password_edit: LineEdit = instance.get_node("CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/PasswordEdit")
var email_edit: LineEdit = instance.get_node("CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/EmailEdit")
var over_18_check: CheckBox = instance.get_node("CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/Over18Check")
var dob_edit: LineEdit = instance.get_node("CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/DobEdit")
var auth_button: Button = instance.get_node("CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/AuthButton")
var auth_mode_label: Label = instance.get_node("CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/AuthModeLabel")
var welcome_label: Label = instance.get_node("CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/WelcomeLabel")
@@ -46,7 +46,7 @@ func _run() -> void:
username_edit.text = "mainmenu_e2e_%d" % unique_id
password_edit.text = "TestPass123!"
email_edit.text = "mainmenu_e2e_%d@test.example.com" % unique_id
over_18_check.button_pressed = true
dob_edit.text = "1990-01-01"
# Call the submit handler directly (awaited) rather than via
# auth_button.pressed.emit() -- a synthetic .emit() in --script/
@@ -68,7 +68,7 @@ func _run() -> void:
_check("Username edit hidden after auth", not username_edit.visible)
_check("Password edit hidden after auth", not password_edit.visible)
_check("Email edit hidden after auth", not email_edit.visible)
_check("Over18 checkbox hidden after auth", not over_18_check.visible)
_check("DOB field hidden after auth", not dob_edit.visible)
_check("Auth button hidden after auth", not auth_button.visible)
_check("Auth mode label hidden after auth", not auth_mode_label.visible)
_check("Welcome label contains username", welcome_label.text.begins_with("Welcome, mainmenu_e2e_"))
+4 -4
View File
@@ -31,7 +31,7 @@ func _run() -> void:
var auth_panel: PanelContainer = instance.get_node("CenterContainer/VBoxContainer/AuthPanel")
var auth_mode_label: Label = instance.get_node("CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/AuthModeLabel")
var email_edit: LineEdit = instance.get_node("CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/EmailEdit")
var over_18_check: CheckBox = instance.get_node("CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/Over18Check")
var dob_edit: LineEdit = instance.get_node("CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/DobEdit")
# --- Test 1: auth panel starts hidden ---
if _check("auth panel starts hidden", not auth_panel.visible):
@@ -58,7 +58,7 @@ func _run() -> void:
else:
failed += 1
if _check("Over18 checkbox hidden in Login mode", not over_18_check.visible):
if _check("DOB field hidden in Login mode", not dob_edit.visible):
passed += 1
else:
failed += 1
@@ -82,7 +82,7 @@ func _run() -> void:
else:
failed += 1
if _check("Over18 checkbox visible in Register mode", over_18_check.visible):
if _check("DOB field visible in Register mode", dob_edit.visible):
passed += 1
else:
failed += 1
@@ -101,7 +101,7 @@ func _run() -> void:
else:
failed += 1
if _check("Over18 checkbox hidden after switching back to Login", not over_18_check.visible):
if _check("DOB field hidden after switching back to Login", not dob_edit.visible):
passed += 1
else:
failed += 1
+1 -1
View File
@@ -58,7 +58,7 @@ func _run() -> void:
var unique_id: int = int(Time.get_unix_time_from_system() * 1000) + randi() % 100000
var username := "worldlist_smoke_%d" % unique_id
var email := "worldlist_smoke_%d@test.example.com" % unique_id
var reg_result: Dictionary = await api.register(username, email, "SmokeTest123!", true)
var reg_result: Dictionary = await api.register(username, email, "SmokeTest123!", "1990-01-01")
if not _check("setup: register succeeded", reg_result.ok):
print(" detail: ", reg_result.detail)
quit(1)