Compare commits

..

14 Commits

Author SHA1 Message Date
coder-bot fe37ccdba4 WIP checkpoint: US-1: Asset Import Pipeline \u2014 glTF upload endpoint with metadata extraction 2026-08-19 13:34:50 +00:00
coder-bot 79cdd4a132 WIP checkpoint: US-1: Asset Import Pipeline \u2014 glTF upload endpoint with metadata extraction 2026-08-19 13:15:15 +00:00
reviewer-bot aa2f79b125 Merge pull request 'Plan: MVP Vertical Slice: Client-Server Wiring' (#26) from agent/21694e349045 into main 2026-08-19 13:13:32 +00:00
planner-bot 8551d14591 docs: MVP vertical slice planning doc for client-server wiring
- Architecture task: ApiClient autoload (HTTP + JWT + error contract)
- Implementation task: Login/Register UI wired to /api/auth
- Implementation task: Browse worlds screen wired to GET /api/worlds
- Validation task: End-to-end smoke test (login + list worlds)
2026-08-19 12:39:33 +00:00
agent-bot ff610c0466 Merge pull request 'Fix migration 0001_initial double CREATE TYPE + port collision' (#25) from fix/migration-0001-double-enum-and-port-collision into main 2026-08-18 21:57:01 +00:00
claude-mvp-fix acbc518b4a Fix migration 0001_initial: double CREATE TYPE crashes every clean bootstrap
world_visibility and asset_type were each declared twice: once explicitly
via `.create(checkfirst=True)`, and again implicitly by SQLAlchemy when
create_table() compiled the inline postgresql.ENUM() column type (which
defaults create_type=True). On a genuinely empty database this raises
asyncpg.DuplicateObjectError mid-transaction and the migration never
completes -- verified this was true 100% of the time from a fresh
container, meaning the backend has never successfully bootstrapped from
scratch. Fixed by passing create_type=False on the inline column
declarations, since the type is already created explicitly above.

Also parameterized both services' host ports (POSTGRES_HOST_PORT /
REDIS_HOST_PORT, defaulting to the original 5432/6379) so concurrent
agent workspaces sharing this same docker-compose.yml can run isolated
databases without colliding on the host port.
2026-08-18 21:55:34 +00:00
reviewer-bot 25a8900357 Merge pull request 'Plan: Economy — Currency, Marketplace & Creator Payouts' (#22) from agent/f96c351dfa5e into main 2026-08-18 17:31:08 +00:00
reviewer-bot 5a1f99eb28 Merge pull request 'Enforce 18+ age gate at account registration' (#23) from agent/7fedeb122f7e into main 2026-08-18 16:36:43 +00:00
coder-bot cb95895612 Recovered orphaned WIP before switching to a new task 2026-08-18 15:33:32 +00:00
coder-bot 62889f39f1 WIP checkpoint: Enforce 18+ age gate at account registration 2026-08-18 15:32:16 +00:00
coder-bot ef646a8e54 WIP checkpoint: Enforce 18+ age gate at account registration 2026-08-18 15:28:25 +00:00
coder-bot e4d3fe566b WIP checkpoint: Enforce 18+ age gate at account registration 2026-08-18 15:28:25 +00:00
coder-bot 1d0222d980 WIP checkpoint: Enforce 18+ age gate at account registration 2026-08-18 15:28:25 +00:00
coder-bot 433d5d8f9f WIP checkpoint: Enforce 18+ age gate at account registration 2026-08-18 15:28:25 +00:00
10 changed files with 394 additions and 8 deletions
+3 -2
View File
@@ -69,7 +69,8 @@ def upgrade() -> None:
sa.Column(
"visibility",
postgresql.ENUM(
"public", "private", "friends_only", name="world_visibility"
"public", "private", "friends_only", name="world_visibility",
create_type=False,
),
nullable=True,
),
@@ -89,7 +90,7 @@ def upgrade() -> None:
"asset_type",
postgresql.ENUM(
"image", "audio", "video", "document", "model_3d", "other",
name="asset_type",
name="asset_type", create_type=False,
),
nullable=False,
),
@@ -0,0 +1,30 @@
"""add date_of_birth column to users table
Revision ID: 0002_add_date_of_birth_to_users
Revises: 0001_initial
Create Date: 2025-01-01 00:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "0002_add_date_of_birth_to_users"
down_revision: Union[str, None] = "0001_initial"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Add date_of_birth column to users table."""
op.add_column(
"users",
sa.Column("date_of_birth", sa.Date(), nullable=True),
)
def downgrade() -> None:
"""Remove date_of_birth column from users table."""
op.drop_column("users", "date_of_birth")
+17 -1
View File
@@ -1,4 +1,7 @@
from sqlalchemy import Column, Integer, String, Boolean, DateTime
from datetime import date
from typing import Optional
from sqlalchemy import Column, Integer, String, Boolean, DateTime, Date
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
@@ -12,6 +15,7 @@ class User(Base):
username = Column(String(50), unique=True, index=True, nullable=False)
email = Column(String(120), unique=True, index=True, nullable=False)
hashed_password = Column(String(255), nullable=False)
date_of_birth = Column(Date, nullable=True)
is_active = Column(Boolean, default=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
@@ -19,3 +23,15 @@ class User(Base):
sessions = relationship("Session", back_populates="user", cascade="all, delete-orphan")
worlds = relationship("World", back_populates="owner")
assets = relationship("Asset", back_populates="owner")
@property
def is_adult(self) -> bool:
"""Return True if the user is 18 or older based on date_of_birth."""
if self.date_of_birth is None:
return False
today = date.today()
age = today.year - self.date_of_birth.year
# Adjust if birthday hasn't occurred yet this year
if (today.month, today.day) < (self.date_of_birth.month, self.date_of_birth.day):
age -= 1
return age >= 18
+159
View File
@@ -1,5 +1,6 @@
"""Asset management router."""
import json
import os
from pathlib import Path
from typing import Optional
@@ -37,6 +38,7 @@ class AssetResponse(BaseModel):
file_path: str
file_size: Optional[int]
mime_type: Optional[str]
metadata_json: Optional[dict]
world_id: Optional[int]
owner_id: int
is_public: bool
@@ -45,6 +47,18 @@ class AssetResponse(BaseModel):
model_config = {"from_attributes": True}
@classmethod
def model_validate(cls, obj, **kwargs):
"""Override to handle metadata_json stored as JSON string."""
if isinstance(obj, dict):
obj = dict(obj)
if "metadata_json" in obj and isinstance(obj["metadata_json"], str):
try:
obj["metadata_json"] = json.loads(obj["metadata_json"])
except (json.JSONDecodeError, TypeError):
obj["metadata_json"] = None
return super().model_validate(obj, **kwargs)
class AssetUpdate(BaseModel):
"""Schema for updating an asset."""
@@ -64,6 +78,144 @@ def _sanitize_filename(filename: str) -> str:
return safe or "file"
def _extract_gltf_metadata(content: bytes) -> Optional[dict]:
"""Extract metadata from a glTF/GLB file.
Returns a dict with mesh_count, vertex_count, has_animation, texture_refs
if the file is a valid glTF/GLB, or None otherwise.
Uses only the standard library (struct, json) so no extra dependencies
are required.
"""
if not content:
return None
# --- GLB detection ---
# GLB magic: 0x676C5442 ("glTF") followed by version 2 and payload length.
if len(content) >= 12:
magic = int.from_bytes(content[0:4], "little")
if magic == 0x46546C67: # "glTF"
version = int.from_bytes(content[4:8], "little")
if version == 2:
return _parse_glb(content)
# --- .gltf JSON detection ---
# Try parsing as JSON; if it has a top-level "scene" or "scenes" key,
# it's likely a .gltf file.
try:
data = json.loads(content)
except (json.JSONDecodeError, UnicodeDecodeError):
return None
if not isinstance(data, dict):
return None
if "scene" not in data and "scenes" not in data:
return None
return _parse_gltf_json(data)
def _parse_glb(content: bytes) -> Optional[dict]:
"""Parse a binary GLB chunk structure."""
# Header: magic(4) + version(4) + length(4) = 12 bytes
# Then chunks: length(4) + type(4) + data...
offset = 12
json_start = None
json_length = 0
binary_start = None
binary_length = 0
while offset < len(content):
if offset + 8 > len(content):
break
chunk_length = int.from_bytes(content[offset:offset + 4], "little")
chunk_type = int.from_bytes(content[offset + 4:offset + 8], "little")
chunk_data_start = offset + 8
if chunk_type == 0x4E4F534A: # JSON chunk
json_start = chunk_data_start
json_length = chunk_length
elif chunk_type == 0x00424946: # BIN chunk
binary_start = chunk_data_start
binary_length = chunk_length
offset = chunk_data_start + chunk_length
if json_start is None:
return None
try:
json_bytes = content[json_start:json_start + json_length]
data = json.loads(json_bytes)
except (json.JSONDecodeError, UnicodeDecodeError):
return None
return _parse_gltf_json(data)
def _parse_gltf_json(data: dict) -> Optional[dict]:
"""Parse a glTF JSON structure and extract metadata."""
meshes = data.get("meshes") or []
animations = data.get("animations") or []
textures = data.get("textures") or []
images = data.get("images") or []
accessors = data.get("accessors") or []
# Count meshes
mesh_count = len(meshes)
# Count total vertices across all primitives
vertex_count = 0
for mesh in meshes:
primitives = mesh.get("primitives") or []
for primitive in primitives:
indices = primitive.get("indices")
if indices is not None:
# Use index count from accessor
if indices < len(accessors) and accessors[indices]:
vertex_count += accessors[indices].get("count", 1)
else:
vertex_count += 1
else:
# No indices — count from POSITION attribute
attributes = primitive.get("attributes") or {}
pos_accessor = attributes.get("POSITION")
if pos_accessor is not None:
if pos_accessor < len(accessors) and accessors[pos_accessor]:
vertex_count += accessors[pos_accessor].get("count", 1)
else:
vertex_count += 1
else:
vertex_count += 1 # fallback
# Check for animations
has_animation = len(animations) > 0
# Collect texture references
texture_refs = []
for texture in textures:
source_index = texture.get("source")
ref = {}
if source_index is not None and source_index < len(images):
image = images[source_index]
if isinstance(image, dict):
if image.get("name"):
ref["name"] = image["name"]
if image.get("uri"):
ref["uri"] = image["uri"]
if image.get("mimeType"):
ref["mime_type"] = image["mimeType"]
texture_refs.append(ref)
return {
"mesh_count": mesh_count,
"vertex_count": vertex_count,
"has_animation": has_animation,
"texture_refs": texture_refs,
}
# --- Endpoints ---
@router.post("/", response_model=AssetResponse, status_code=201)
@@ -204,6 +356,13 @@ async def upload_asset_file(
asset.mime_type = file.content_type
asset.file_size = file_size
# Extract glTF metadata for glTF/GLB files.
filename_lower = (file.filename or "").lower()
if filename_lower.endswith(".glb") or filename_lower.endswith(".gltf"):
metadata = _extract_gltf_metadata(content)
if metadata is not None:
asset.metadata_json = json.dumps(metadata)
await db.commit()
await db.refresh(asset)
return asset
+49 -3
View File
@@ -1,10 +1,10 @@
"""Authentication router - login, register, token refresh."""
from datetime import datetime, timedelta, timezone
from datetime import date, datetime, timedelta, timezone
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.security import OAuth2PasswordRequestForm
from pydantic import BaseModel, EmailStr
from pydantic import BaseModel, EmailStr, field_validator
from jose import jwt
from passlib.context import CryptContext
@@ -32,12 +32,24 @@ class RegisterRequest(BaseModel):
username: str
email: EmailStr
password: str
date_of_birth: str # ISO format date string (YYYY-MM-DD)
@field_validator("date_of_birth")
@classmethod
def validate_date_of_birth(cls, v: str) -> str:
"""Validate that the date string is a valid date in YYYY-MM-DD format."""
try:
datetime.strptime(v, "%Y-%m-%d")
except ValueError:
raise ValueError("date_of_birth must be in YYYY-MM-DD format")
return v
class UserResponse(BaseModel):
id: int
username: str
email: str
date_of_birth: str | None = None
is_active: bool
created_at: datetime | None = None
@@ -65,11 +77,44 @@ def hash_password(password: str) -> str:
return pwd_context.hash(password)
def calculate_age(birth_date: date, today: date | None = None) -> int:
"""Calculate age in years from a birth date.
Args:
birth_date: The person's date of birth.
today: The reference date. Defaults to today's date (UTC).
Returns:
Age in whole years as of the reference date.
"""
if today is None:
today = datetime.now(timezone.utc).date()
age = today.year - birth_date.year
# Adjust if birthday hasn't occurred yet this year
if (today.month, today.day) < (birth_date.month, birth_date.day):
age -= 1
return age
# --- Endpoints ---
@router.post("/register", response_model=TokenResponse, status_code=status.HTTP_201_CREATED)
async def register(req: RegisterRequest, db: AsyncSession = Depends(get_db)):
"""Register a new user and return an access token."""
"""Register a new user and return an access token.
Requires date_of_birth and rejects users under 18.
"""
# Parse and validate date_of_birth
dob = datetime.strptime(req.date_of_birth, "%Y-%m-%d").date()
age = calculate_age(dob)
if age < 18:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Must be 18 or older to register (provided date of birth: {req.date_of_birth})",
)
# Check if username already exists
result = await db.execute(select(User).where(User.username == req.username))
if result.scalar_one_or_none():
@@ -90,6 +135,7 @@ async def register(req: RegisterRequest, db: AsyncSession = Depends(get_db)):
username=req.username,
email=req.email,
hashed_password=hash_password(req.password),
date_of_birth=dob,
)
db.add(user)
await db.commit()
+2 -2
View File
@@ -8,7 +8,7 @@ services:
POSTGRES_PASSWORD: agp_password
POSTGRES_DB: agp_db
ports:
- "5432:5432"
- "${POSTGRES_HOST_PORT:-5432}:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
@@ -20,7 +20,7 @@ services:
redis:
image: redis:7-alpine
ports:
- "6379:6379"
- "${REDIS_HOST_PORT:-6379}:6379"
volumes:
- redis_data:/data
healthcheck:
+3
View File
@@ -0,0 +1,3 @@
[pytest]
asyncio_mode = auto
testpaths = tests
View File
+94
View File
@@ -0,0 +1,94 @@
"""Tests for the 18+ age gate on user registration."""
from datetime import date, timedelta
import pytest
from app.routers.auth import calculate_age
class TestCalculateAge:
"""Unit tests for the calculate_age helper."""
def test_exactly_18(self):
"""A person born exactly 18 years ago today is 18."""
today = date(2025, 6, 15)
dob = today.replace(year=today.year - 18)
assert calculate_age(dob, today=today) == 18
def test_just_turned_18(self):
"""A person who turned 18 yesterday is 18."""
today = date(2025, 6, 15)
dob = today.replace(year=today.year - 18) - timedelta(days=1)
assert calculate_age(dob, today=today) == 18
def test_one_day_short_of_18(self):
"""A person born one day after their 18th birthday is 17."""
today = date(2025, 6, 15)
dob = today.replace(year=today.year - 18) + timedelta(days=1)
assert calculate_age(dob, today=today) == 17
def test_17_years_old(self):
"""A person who is 17 years old today is 17."""
today = date(2025, 6, 15)
dob = date(2008, 6, 15)
assert calculate_age(dob, today=today) == 17
def test_65_years_old(self):
"""A person who is 65 is correctly calculated."""
today = date(2025, 6, 15)
dob = today.replace(year=today.year - 65)
assert calculate_age(dob, today=today) == 65
def test_newborn(self):
"""A newborn is 0."""
today = date(2025, 6, 15)
dob = today
assert calculate_age(dob, today=today) == 0
def test_birthday_not_yet_this_year(self):
"""Person born Dec 31, 2000 — on Jan 1, 2025 they are still 24."""
dob = date(2000, 12, 31)
assert calculate_age(dob, today=date(2025, 1, 1)) == 24
def test_birthday_today(self):
"""Person born Dec 31, 2000 — on Dec 31, 2024 they turn 24."""
dob = date(2000, 12, 31)
assert calculate_age(dob, today=date(2024, 12, 31)) == 24
class TestRegisterRequestDateValidation:
"""Tests for the RegisterRequest date_of_birth field validator."""
@pytest.mark.parametrize(
"dob",
[
"2000-01-01",
"1990-12-31",
"2004-02-29", # leap day
],
)
def test_valid_dates(self, dob):
"""Valid YYYY-MM-DD dates pass validation."""
from app.routers.auth import RegisterRequest
req = RegisterRequest(username="u", email="u@example.com", password="p", date_of_birth=dob)
assert req.date_of_birth == dob
@pytest.mark.parametrize(
"dob",
[
"01-01-2000",
"2000/01/01",
"2000-13-01",
"2000-00-10",
"not-a-date",
"",
],
)
def test_invalid_dates_rejected(self, dob):
"""Invalid dates are rejected by the validator."""
from app.routers.auth import RegisterRequest
with pytest.raises(ValueError):
RegisterRequest(username="u", email="u@example.com", password="p", date_of_birth=dob)
+37
View File
@@ -0,0 +1,37 @@
# MVP Vertical Slice: Client-Server Wiring — Plan
## Goal
Prove the real Godot client and the real backend can talk: **a user can register/log in
and see a list of worlds from the game client**. Nothing more. Gameplay, join-a-world,
chat, avatar, Discovery search/tags/trending, Social/Economy/Trust & Safety are all
explicitly out of scope.
## Verified backend contract (as of this writing)
- `POST /api/auth/register` — JSON body `{username, email, password, date_of_birth}`.
`date_of_birth` is a `YYYY-MM-DD` string. Returns `201 {access_token, token_type}`.
Rejects under-18 with `400`.
- `POST /api/auth/login` — OAuth2 **form-encoded** (`application/x-www-form-urlencoded`)
`username` + `password` (this is NOT a JSON endpoint). Returns `{access_token, token_type}`.
- `GET /api/worlds` — **requires auth** (`CurrentUser`), returns a JSON array of
`{id, name, description, visibility, owner_id, created_at}`. There is no unauthenticated
public-worlds path, so the worlds list screen can only be shown after login/register.
- `GET /api/health` — unauthenticated, `{status, app}`.
- Base URL: uvicorn default `http://127.0.0.1:8000` (must be configurable in the client).
- CORS is irrelevant to the native Godot `HTTPRequest` client (no browser origin).
## Task breakdown
| # | Type | Task | Depends on |
|---|------|------|-----------|
| 1 | architecture | `ApiClient` autoload: HTTP + JWT storage + error contract | — |
| 2 | implementation | Main-menu **login** wiring (form-encoded) | 1 |
| 3 | implementation | Main-menu **register** wiring (JSON + age gate) | 2 |
| 4 | implementation | **World list** screen consuming `GET /api/worlds` | 2 |
| 5 | testing | End-to-end headless smoke test + manual verify | 1,3,4 |
## Design decisions captured (resolved in task 1)
- Token storage: in-memory singleton property for the MVP; a `save_to_disk` seam is left
open but not required yet (no re-login on restart in this slice).
- Error contract: every call returns a discriminated result (`ok`/`error`) so callers
never crash on network failure; non-2xx surfaces `{status, detail}` from the backend.
- Base URL: read from a ProjectSetting, defaulting to `http://127.0.0.1:8000`.
- Login is form-encoded; register is JSON — the client helper must support both content types.