Compare commits

..

5 Commits

9 changed files with 538 additions and 204 deletions
+1 -1
View File
@@ -8,7 +8,7 @@ from sqlalchemy.ext.asyncio import create_async_engine
from app.config import settings
from app.database import Base
from app.models import user, session, world, asset # noqa: F401
from app.models import user, session, world, asset, moderation # noqa: F401
# this is the Alembic Config object, which provides
# access to the values within the .ini file in use.
+1 -2
View File
@@ -69,8 +69,7 @@ def upgrade() -> None:
sa.Column(
"visibility",
postgresql.ENUM(
"public", "private", "friends_only", name="world_visibility",
create_type=False,
"public", "private", "friends_only", name="world_visibility", create_type=False
),
nullable=True,
),
+211
View File
@@ -0,0 +1,211 @@
"""add moderation tables and extend users table
Revision ID: 0003_moderation
Revises: 0002_add_date_of_birth_to_users
Create Date: 2025-01-01 00:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision: str = "0003_moderation"
down_revision: Union[str, None] = "0002_add_date_of_birth_to_users"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def _create_enum_if_not_exists(enum_name: str, values: list[str]) -> None:
"""Create a PostgreSQL ENUM type if it does not already exist."""
bind = op.get_bind()
# Check if the type already exists in pg_type
result = bind.execute(
sa.text(
"SELECT 1 FROM pg_type WHERE typname = :name"
).bindparams(name=enum_name)
).fetchone()
if result is None:
values_str = ", ".join(f"'{v}'" for v in values)
op.execute(f"CREATE TYPE {enum_name} AS ENUM ({values_str})")
def upgrade() -> None:
"""Add role, banned_until, suspended_until to users table
and create reports, moderation_actions, appeals tables."""
# --- Create ENUM types if they don't exist ---
_create_enum_if_not_exists("user_role", ["user", "moderator", "admin"])
_create_enum_if_not_exists("report_target_type", ["user", "world", "asset", "chat_message"])
_create_enum_if_not_exists("report_reason_category", [
"harassment", "hate_speech", "sexual_content",
"violence", "spam", "self_harm", "other"
])
_create_enum_if_not_exists("report_status", ["open", "in_review", "resolved", "dismissed"])
_create_enum_if_not_exists("report_resolution", ["warn", "suspend", "ban", "restrict", "remove"])
_create_enum_if_not_exists("moderation_target_type", ["user", "world", "asset", "chat_message"])
_create_enum_if_not_exists("moderation_action_type", ["warn", "suspend", "ban", "restrict", "remove", "restore"])
_create_enum_if_not_exists("appeal_status", ["pending", "approved", "rejected"])
# --- Extend users table ---
# Add role column
op.add_column(
"users",
sa.Column(
"role",
postgresql.ENUM("user", "moderator", "admin", name="user_role", create_type=False),
nullable=False,
server_default="user",
),
)
# Add banned_until column
op.add_column(
"users",
sa.Column("banned_until", sa.DateTime(timezone=True), nullable=True),
)
# Add suspended_until column
op.add_column(
"users",
sa.Column("suspended_until", sa.DateTime(timezone=True), nullable=True),
)
# --- Create reports table ---
op.create_table(
"reports",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("reporter_id", sa.Integer(), sa.ForeignKey("users.id"), nullable=False),
sa.Column(
"target_type",
postgresql.ENUM("user", "world", "asset", "chat_message", name="report_target_type", create_type=False),
nullable=False,
),
sa.Column("target_id", sa.String(64), nullable=False),
sa.Column(
"reason_category",
postgresql.ENUM(
"harassment", "hate_speech", "sexual_content",
"violence", "spam", "self_harm", "other",
name="report_reason_category", create_type=False,
),
nullable=False,
),
sa.Column("details", sa.Text(), nullable=True),
sa.Column(
"status",
postgresql.ENUM("open", "in_review", "resolved", "dismissed", name="report_status", create_type=False),
nullable=False,
server_default="open",
),
sa.Column("resolved_by", sa.Integer(), sa.ForeignKey("users.id"), nullable=True),
sa.Column(
"resolution",
postgresql.ENUM("warn", "suspend", "ban", "restrict", "remove", name="report_resolution", create_type=False),
nullable=True,
),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column("resolved_at", sa.DateTime(timezone=True), nullable=True),
)
# Indexes for reports
op.create_index("ix_reports_reporter_id", "reports", ["reporter_id"])
op.create_index("ix_reports_target", "reports", ["target_type", "target_id"])
op.create_index("ix_reports_status", "reports", ["status"])
# --- Create moderation_actions table ---
op.create_table(
"moderation_actions",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("moderator_id", sa.Integer(), sa.ForeignKey("users.id"), nullable=False),
sa.Column("target_user_id", sa.Integer(), sa.ForeignKey("users.id"), nullable=True),
sa.Column(
"target_type",
postgresql.ENUM("user", "world", "asset", "chat_message", name="moderation_target_type", create_type=False),
nullable=False,
),
sa.Column("target_id", sa.String(64), nullable=False),
sa.Column(
"action",
postgresql.ENUM("warn", "suspend", "ban", "restrict", "remove", "restore", name="moderation_action_type", create_type=False),
nullable=False,
),
sa.Column("reason", sa.Text(), nullable=True),
sa.Column("report_id", sa.Integer(), sa.ForeignKey("reports.id"), nullable=True),
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
)
# Indexes for moderation_actions
op.create_index("ix_moderation_actions_moderator_id", "moderation_actions", ["moderator_id"])
op.create_index("ix_moderation_actions_target_user", "moderation_actions", ["target_user_id"])
op.create_index("ix_moderation_actions_target", "moderation_actions", ["target_type", "target_id"])
op.create_index("ix_moderation_actions_report_id", "moderation_actions", ["report_id"])
# --- Create appeals table ---
op.create_table(
"appeals",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("moderation_action_id", sa.Integer(), sa.ForeignKey("moderation_actions.id"), nullable=False),
sa.Column("user_id", sa.Integer(), sa.ForeignKey("users.id"), nullable=False),
sa.Column("reason", sa.Text(), nullable=False),
sa.Column(
"status",
postgresql.ENUM("pending", "approved", "rejected", name="appeal_status", create_type=False),
nullable=False,
server_default="pending",
),
sa.Column("reviewed_by", sa.Integer(), sa.ForeignKey("users.id"), nullable=True),
sa.Column("reviewed_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
)
# Indexes for appeals
op.create_index("ix_appeals_moderation_action_id", "appeals", ["moderation_action_id"])
op.create_index("ix_appeals_user_id", "appeals", ["user_id"])
op.create_index("ix_appeals_status", "appeals", ["status"])
def downgrade() -> None:
"""Remove moderation tables and revert users table changes."""
# Drop appeals table
op.drop_index("ix_appeals_status", table_name="appeals")
op.drop_index("ix_appeals_user_id", table_name="appeals")
op.drop_index("ix_appeals_moderation_action_id", table_name="appeals")
op.drop_table("appeals")
# Drop moderation_actions table
op.drop_index("ix_moderation_actions_report_id", table_name="moderation_actions")
op.drop_index("ix_moderation_actions_target", table_name="moderation_actions")
op.drop_index("ix_moderation_actions_target_user", table_name="moderation_actions")
op.drop_index("ix_moderation_actions_moderator_id", table_name="moderation_actions")
op.drop_table("moderation_actions")
# Drop reports table
op.drop_index("ix_reports_status", table_name="reports")
op.drop_index("ix_reports_target", table_name="reports")
op.drop_index("ix_reports_reporter_id", table_name="reports")
op.drop_table("reports")
# Remove columns from users table
op.drop_column("users", "suspended_until")
op.drop_column("users", "banned_until")
op.drop_column("users", "role")
# Drop the enum types
op.execute("DROP TYPE IF EXISTS user_role")
op.execute("DROP TYPE IF EXISTS report_target_type")
op.execute("DROP TYPE IF EXISTS report_reason_category")
op.execute("DROP TYPE IF EXISTS report_status")
op.execute("DROP TYPE IF EXISTS report_resolution")
op.execute("DROP TYPE IF EXISTS moderation_target_type")
op.execute("DROP TYPE IF EXISTS moderation_action_type")
op.execute("DROP TYPE IF EXISTS appeal_status")
+2 -1
View File
@@ -2,5 +2,6 @@ from app.models.user import User
from app.models.session import Session
from app.models.world import World
from app.models.asset import Asset
from app.models.moderation import Report, ModerationAction, Appeal
__all__ = ["User", "Session", "World", "Asset"]
__all__ = ["User", "Session", "World", "Asset", "Report", "ModerationAction", "Appeal"]
+286
View File
@@ -0,0 +1,286 @@
"""Moderation-related models: Report, ModerationAction, Appeal."""
from datetime import datetime
from typing import Optional
from sqlalchemy import (
Column,
Enum,
ForeignKey,
Integer,
String,
Text,
DateTime,
Index,
)
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from app.database import Base
# --- Enums ---
class TargetType(str):
USER = "user"
WORLD = "world"
ASSET = "asset"
CHAT_MESSAGE = "chat_message"
class ReasonCategory(str):
HARASSMENT = "harassment"
HATE_SPEECH = "hate_speech"
SEXUAL_CONTENT = "sexual_content"
VIOLENCE = "violence"
SPAM = "spam"
SELF_HARM = "self_harm"
OTHER = "other"
class ReportStatus(str):
OPEN = "open"
IN_REVIEW = "in_review"
RESOLVED = "resolved"
DISMISSED = "dismissed"
class ResolutionAction(str):
WARN = "warn"
SUSPEND = "suspend"
BAN = "ban"
RESTRICT = "restrict"
REMOVE = "remove"
RESTORE = "restore"
class ModerationActionType(str):
WARN = "warn"
SUSPEND = "suspend"
BAN = "ban"
RESTRICT = "restrict"
REMOVE = "remove"
RESTORE = "restore"
class AppealStatus(str):
PENDING = "pending"
APPROVED = "approved"
REJECTED = "rejected"
# --- Models ---
class Report(Base):
"""A moderation report filed by a user or system."""
__tablename__ = "reports"
id = Column(Integer, primary_key=True)
reporter_id = Column(
Integer,
ForeignKey("users.id", ondelete="SET NULL"),
nullable=False,
)
target_type = Column(
Enum(
TargetType.USER,
TargetType.WORLD,
TargetType.ASSET,
TargetType.CHAT_MESSAGE,
name="target_type",
create_type=False,
),
nullable=False,
)
target_id = Column(String(64), nullable=False)
reason_category = Column(
Enum(
ReasonCategory.HARASSMENT,
ReasonCategory.HATE_SPEECH,
ReasonCategory.SEXUAL_CONTENT,
ReasonCategory.VIOLENCE,
ReasonCategory.SPAM,
ReasonCategory.SELF_HARM,
ReasonCategory.OTHER,
name="reason_category",
create_type=False,
),
nullable=False,
)
details = Column(Text, nullable=True)
status = Column(
Enum(
ReportStatus.OPEN,
ReportStatus.IN_REVIEW,
ReportStatus.RESOLVED,
ReportStatus.DISMISSED,
name="report_status",
create_type=False,
),
default=ReportStatus.OPEN,
nullable=False,
)
resolved_by = Column(
Integer,
ForeignKey("users.id", ondelete="SET NULL"),
nullable=True,
)
resolution = Column(
Enum(
ResolutionAction.WARN,
ResolutionAction.SUSPEND,
ResolutionAction.BAN,
ResolutionAction.RESTRICT,
ResolutionAction.REMOVE,
ResolutionAction.RESTORE,
name="resolution_action",
create_type=False,
),
nullable=True,
)
created_at = Column(
DateTime(timezone=True),
server_default=func.now(),
nullable=False,
)
updated_at = Column(
DateTime(timezone=True),
server_default=func.now(),
onupdate=func.now(),
nullable=False,
)
resolved_at = Column(DateTime(timezone=True), nullable=True)
# Relationships
reporter = relationship("User", foreign_keys=[reporter_id])
resolver = relationship("User", foreign_keys=[resolved_by])
actions = relationship(
"ModerationAction",
back_populates="report",
cascade="all, delete-orphan",
)
__table_args__ = (
Index("ix_reports_target", "target_type", "target_id"),
Index("ix_reports_status", "status"),
Index("ix_reports_created_at", "created_at"),
)
class ModerationAction(Base):
"""A moderation action taken by a moderator/admin."""
__tablename__ = "moderation_actions"
id = Column(Integer, primary_key=True)
moderator_id = Column(
Integer,
ForeignKey("users.id", ondelete="SET NULL"),
nullable=False,
)
target_user_id = Column(
Integer,
ForeignKey("users.id", ondelete="SET NULL"),
nullable=True,
)
target_type = Column(
Enum(
TargetType.USER,
TargetType.WORLD,
TargetType.ASSET,
TargetType.CHAT_MESSAGE,
name="mod_target_type",
create_type=False,
),
nullable=False,
)
target_id = Column(String(64), nullable=False)
action = Column(
Enum(
ModerationActionType.WARN,
ModerationActionType.SUSPEND,
ModerationActionType.BAN,
ModerationActionType.RESTRICT,
ModerationActionType.REMOVE,
ModerationActionType.RESTORE,
name="moderation_action_type",
create_type=False,
),
nullable=False,
)
reason = Column(Text, nullable=True)
report_id = Column(
Integer,
ForeignKey("reports.id", ondelete="SET NULL"),
nullable=True,
)
expires_at = Column(DateTime(timezone=True), nullable=True)
created_at = Column(
DateTime(timezone=True),
server_default=func.now(),
nullable=False,
)
# Relationships
moderator = relationship("User", foreign_keys=[moderator_id])
target_user = relationship("User", foreign_keys=[target_user_id])
report = relationship("Report", back_populates="actions")
appeals = relationship("Appeal", back_populates="moderation_action")
__table_args__ = (
Index("ix_moderation_actions_target", "target_type", "target_id"),
Index("ix_moderation_actions_created_at", "created_at"),
)
class Appeal(Base):
"""An appeal filed by a user against a moderation action."""
__tablename__ = "appeals"
id = Column(Integer, primary_key=True)
moderation_action_id = Column(
Integer,
ForeignKey("moderation_actions.id", ondelete="CASCADE"),
nullable=False,
)
user_id = Column(
Integer,
ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
)
reason = Column(Text, nullable=False)
status = Column(
Enum(
AppealStatus.PENDING,
AppealStatus.APPROVED,
AppealStatus.REJECTED,
name="appeal_status",
create_type=False,
),
default=AppealStatus.PENDING,
nullable=False,
)
reviewed_by = Column(
Integer,
ForeignKey("users.id", ondelete="SET NULL"),
nullable=True,
)
reviewed_at = Column(DateTime(timezone=True), nullable=True)
created_at = Column(
DateTime(timezone=True),
server_default=func.now(),
nullable=False,
)
# Relationships
moderation_action = relationship(
"ModerationAction", back_populates="appeals"
)
reviewer = relationship("User", foreign_keys=[reviewed_by])
__table_args__ = (
Index("ix_appeals_status", "status"),
Index("ix_appeals_created_at", "created_at"),
)
+35 -2
View File
@@ -1,7 +1,7 @@
from datetime import date
from datetime import date, datetime
from typing import Optional
from sqlalchemy import Column, Integer, String, Boolean, DateTime, Date
from sqlalchemy import Column, Integer, String, Boolean, DateTime, Date, Enum
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
@@ -17,12 +17,45 @@ class User(Base):
hashed_password = Column(String(255), nullable=False)
date_of_birth = Column(Date, nullable=True)
is_active = Column(Boolean, default=True)
role = Column(
Enum("user", "moderator", "admin", name="user_role", create_type=False),
default="user",
nullable=False,
)
banned_until = Column(DateTime(timezone=True), nullable=True)
suspended_until = Column(DateTime(timezone=True), nullable=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
sessions = relationship("Session", back_populates="user", cascade="all, delete-orphan")
worlds = relationship("World", back_populates="owner")
assets = relationship("Asset", back_populates="owner")
reported_by = relationship(
"Report",
foreign_keys="Report.reporter_id",
back_populates="reporter",
)
resolved_reports = relationship(
"Report",
foreign_keys="Report.resolved_by",
back_populates="resolver",
)
moderation_actions = relationship(
"ModerationAction",
foreign_keys="ModerationAction.moderator_id",
back_populates="moderator",
)
target_moderation_actions = relationship(
"ModerationAction",
foreign_keys="ModerationAction.target_user_id",
back_populates="target_user",
)
appeals = relationship("Appeal", back_populates="user")
reviewed_appeals = relationship(
"Appeal",
foreign_keys="Appeal.reviewed_by",
back_populates="reviewer",
)
@property
def is_adult(self) -> bool:
-159
View File
@@ -1,6 +1,5 @@
"""Asset management router."""
import json
import os
from pathlib import Path
from typing import Optional
@@ -38,7 +37,6 @@ 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
@@ -47,18 +45,6 @@ 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."""
@@ -78,144 +64,6 @@ 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)
@@ -356,13 +204,6 @@ 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
+2 -2
View File
@@ -8,7 +8,7 @@ services:
POSTGRES_PASSWORD: agp_password
POSTGRES_DB: agp_db
ports:
- "${POSTGRES_HOST_PORT:-5432}:5432"
- "5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
@@ -20,7 +20,7 @@ services:
redis:
image: redis:7-alpine
ports:
- "${REDIS_HOST_PORT:-6379}:6379"
- "6379:6379"
volumes:
- redis_data:/data
healthcheck:
-37
View File
@@ -1,37 +0,0 @@
# 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.