Compare commits

..

3 Commits

31 changed files with 387 additions and 1558 deletions
-3
View File
@@ -8,6 +8,3 @@ export/
# OS
.DS_Store
Thumbs.db
# Godot UID files (regenerated from path)
*.uid
+4 -7
View File
@@ -4,13 +4,10 @@ DATABASE_URL=postgresql+asyncpg://agp_user:agp_password@localhost:5432/agp_db
# Redis
REDIS_URL=redis://localhost:6379/0
# JWT / Auth
ALGORITHM=RS256
ACCESS_TOKEN_EXPIRE_MINUTES=60
REFRESH_TOKEN_EXPIRE_DAYS=30
# Directory where the RSA keypair is loaded/generated at runtime.
# Defaults to ~/.agp/keys (outside the repo). Keys are never committed to git.
RSA_KEY_DIR=~/.agp/keys
# JWT
SECRET_KEY=change-me-to-a-secure-random-string
ALGORITHM=HS256
ACCESS_TOKEN_EXPIRE_MINUTES=30
# App
APP_NAME=AGP Backend
+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, report, moderation_action, appeal # noqa: F401
# this is the Alembic Config object, which provides
# access to the values within the .ini file in use.
+2 -3
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"
),
nullable=True,
),
@@ -90,7 +89,7 @@ def upgrade() -> None:
"asset_type",
postgresql.ENUM(
"image", "audio", "video", "document", "model_3d", "other",
name="asset_type", create_type=False,
name="asset_type",
),
nullable=False,
),
@@ -1,36 +0,0 @@
"""add refresh_tokens table
Revision ID: 0003_add_refresh_tokens_table
Revises: 0002_add_date_of_birth_to_users
Create Date: 2025-07-25 00:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "0003_add_refresh_tokens_table"
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 upgrade() -> None:
"""Create the refresh_tokens table."""
op.create_table(
"refresh_tokens",
sa.Column("id", sa.String(36), primary_key=True),
sa.Column("user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
sa.Column("token", sa.String(512), unique=True, index=True, nullable=False),
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("is_active", sa.Boolean(), server_default=sa.text("true"), nullable=False),
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),
)
def downgrade() -> None:
"""Drop the refresh_tokens table."""
op.drop_table("refresh_tokens")
+5 -80
View File
@@ -1,56 +1,10 @@
"""Application configuration via pydantic-settings."""
from functools import lru_cache
from pathlib import Path
from cryptography.hazmat.primitives.asymmetric.rsa import (
RSAPrivateKey,
RSAPublicKey,
generate_private_key,
)
from cryptography.hazmat.primitives.serialization import (
Encoding,
NoEncryption,
PrivateFormat,
PublicFormat,
load_pem_private_key,
load_pem_public_key,
)
from pydantic_settings import BaseSettings, SettingsConfigDict
def _load_or_generate_rsa_keys(key_dir: Path) -> tuple[bytes, bytes]:
"""Load existing RSA keys or generate a new pair.
Keys are stored as PEM files in *key_dir*. The directory is created
if it does not exist. Files are named ``private.pem`` and
``public.pem`` and are **not** committed to git.
"""
private_path = key_dir / "private.pem"
public_path = key_dir / "public.pem"
if private_path.exists() and public_path.exists():
private_pem = private_path.read_bytes()
public_pem = public_path.read_bytes()
else:
# Generate a new 2048-bit RSA key pair
private_key = generate_private_key(public_exponent=65537, key_size=2048)
private_pem = private_key.private_bytes(
Encoding.PEM,
PrivateFormat.PKCS8,
NoEncryption(),
)
public_pem = private_key.public_key().public_bytes(
Encoding.PEM,
PublicFormat.SubjectPublicKeyInfo,
)
key_dir.mkdir(parents=True, exist_ok=True)
private_path.write_bytes(private_pem)
public_path.write_bytes(public_pem)
return private_pem, public_pem
class Settings(BaseSettings):
# App
APP_NAME: str = "AGP Backend"
@@ -64,13 +18,11 @@ class Settings(BaseSettings):
REDIS_URL: str = "redis://localhost:6379/0"
# JWT / Auth
ALGORITHM: str = "RS256"
ACCESS_TOKEN_EXPIRE_MINUTES: int = 60
REFRESH_TOKEN_EXPIRE_DAYS: int = 30
# RS256 key storage. Defaults to a directory outside the repo so the
# generated keypair is never accidentally committed.
RSA_KEY_DIR: str = str(Path.home() / ".agp" / "keys")
# NOTE: production must override SECRET_KEY with a secure random value
# (e.g. `python -c "import secrets; print(secrets.token_urlsafe(32))"`)
SECRET_KEY: str = "change-me-to-a-secure-random-string"
ALGORITHM: str = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES: int = 30
# CORS
CORS_ORIGINS: str = "http://localhost:8080,http://localhost:3000"
@@ -78,7 +30,6 @@ class Settings(BaseSettings):
# File uploads
UPLOAD_DIR: str = "uploads"
MAX_UPLOAD_SIZE_MB: int = 100
GLTF_MAX_FILE_SIZE_MB: int = 200
model_config = SettingsConfigDict(
env_file=".env",
@@ -90,32 +41,6 @@ class Settings(BaseSettings):
def cors_origins_list(self) -> list[str]:
return [origin.strip() for origin in self.CORS_ORIGINS.split(",")]
@property
def rsa_key_dir(self) -> Path:
return Path(self.RSA_KEY_DIR)
@property
def private_pem(self) -> bytes:
"""Return the private key PEM bytes (loads/generates on first call)."""
private_pem, _ = _load_or_generate_rsa_keys(self.rsa_key_dir)
return private_pem
@property
def public_pem(self) -> bytes:
"""Return the public key PEM bytes (loads/generates on first call)."""
_, public_pem = _load_or_generate_rsa_keys(self.rsa_key_dir)
return public_pem
@property
def private_key(self) -> RSAPrivateKey:
"""Return the deserialized RSA private key object."""
return load_pem_private_key(self.private_pem, password=None)
@property
def public_key(self) -> RSAPublicKey:
"""Return the deserialized RSA public key object."""
return load_pem_public_key(self.public_pem)
@lru_cache()
def get_settings() -> Settings:
+1 -1
View File
@@ -27,7 +27,7 @@ async def get_current_user(
)
try:
payload = jwt.decode(
token, settings.public_pem, algorithms=[settings.ALGORITHM]
token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM]
)
user_id: str | None = payload.get("sub")
if user_id is None:
+4 -1
View File
@@ -2,5 +2,8 @@ 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.report import Report
from app.models.moderation_action import ModerationAction
from app.models.appeal import Appeal
__all__ = ["User", "Session", "World", "Asset"]
__all__ = ["User", "Session", "World", "Asset", "Report", "ModerationAction", "Appeal"]
+67
View File
@@ -0,0 +1,67 @@
"""Appeal model for moderation actions."""
from datetime import datetime
from typing import Optional
from sqlalchemy import (
Column,
Enum,
ForeignKey,
Integer,
String,
Text,
DateTime,
CheckConstraint,
)
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from app.database import Base
class Appeal(Base):
__tablename__ = "appeals"
id = Column(Integer, primary_key=True, index=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("pending", "approved", "rejected", name="appeal_status", create_type=False),
nullable=False,
default="pending",
)
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],
back_populates="reviewed_appeals",
)
__table_args__ = (
CheckConstraint(
"status IN ('pending', 'approved', 'rejected')",
name="check_appeal_status",
),
)
+118
View File
@@ -0,0 +1,118 @@
"""Moderation action model."""
from datetime import datetime
from enum import Enum as PyEnum
from typing import Optional
from sqlalchemy import (
Column,
Enum,
ForeignKey,
Integer,
String,
Text,
DateTime,
func,
)
from sqlalchemy.dialects.postgresql import TIMESTAMPTZ
from sqlalchemy.orm import relationship
from app.database import Base
class TargetType(str, PyEnum):
"""Type of entity being reported or moderated."""
USER = "user"
WORLD = "world"
ASSET = "asset"
CHAT_MESSAGE = "chat_message"
class ReasonCategory(str, PyEnum):
"""Category of report reason."""
HARASSMENT = "harassment"
HATE_SPEECH = "hate_speech"
SEXUAL_CONTENT = "sexual_content"
VIOLENCE = "violence"
SPAM = "spam"
SELF_HARM = "self_harm"
OTHER = "other"
class ReportStatus(str, PyEnum):
"""Status of a report."""
OPEN = "open"
IN_REVIEW = "in_review"
RESOLVED = "resolved"
DISMISSED = "dismissed"
class Resolution(str, PyEnum):
"""Resolution outcome of a report."""
ACTION_TAKEN = "action_taken"
NO_ACTION = "no_action"
class ModerationActionType(str, PyEnum):
"""Type of moderation action."""
WARN = "warn"
SUSPEND = "suspend"
BAN = "ban"
RESTRICT = "restrict"
REMOVE = "remove"
RESTORE = "restore"
class ModerationAction(Base):
__tablename__ = "moderation_actions"
id = Column(Integer, primary_key=True, index=True)
moderator_id = Column(
Integer,
ForeignKey("users.id", ondelete="SET NULL"),
nullable=True,
)
target_user_id = Column(
Integer,
ForeignKey("users.id", ondelete="SET NULL"),
nullable=True,
)
target_type = Column(
Enum(TargetType, name="target_type_enum", create_type=False),
nullable=False,
)
target_id = Column(String(64), nullable=False)
action = Column(
Enum(ModerationActionType, name="moderation_action_type_enum", 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(TIMESTAMPTZ, nullable=True)
revoked_at = Column(TIMESTAMPTZ, nullable=True)
created_at = Column(
DateTime(timezone=True),
server_default=func.now(),
)
# Relationships
moderator = relationship(
"User",
foreign_keys=[moderator_id],
back_populates="moderation_actions_given",
)
target_user = relationship(
"User",
foreign_keys=[target_user_id],
back_populates="moderation_actions_received",
)
report = relationship("Report", back_populates="moderation_actions")
appeals = relationship(
"Appeal",
back_populates="moderation_action",
cascade="all, delete-orphan",
)
+146
View File
@@ -0,0 +1,146 @@
"""Report model for user-generated moderation reports."""
from datetime import datetime
from typing import Optional
import sqlalchemy as sa
from sqlalchemy import (
Column,
DateTime,
Enum,
ForeignKey,
Integer,
String,
Text,
)
from sqlalchemy.dialects.postgresql import TIMESTAMPTZ
from sqlalchemy.orm import relationship
from app.database import Base
class ReportStatus:
"""Valid statuses for a Report."""
OPEN = "open"
IN_REVIEW = "in_review"
RESOLVED = "resolved"
DISMISSED = "dismissed"
class Resolution:
"""Possible resolution outcomes for a Report."""
ACTION_TAKEN = "action_taken"
NO_ACTION = "no_action"
class TargetType:
"""Types of entities that can be reported."""
USER = "user"
WORLD = "world"
ASSET = "asset"
CHAT_MESSAGE = "chat_message"
class ReasonCategory:
"""Categories for why something was reported."""
HARASSMENT = "harassment"
HATE_SPEECH = "hate_speech"
SEXUAL_CONTENT = "sexual_content"
VIOLENCE = "violence"
SPAM = "spam"
SELF_HARM = "self_harm"
OTHER = "other"
class Report(Base):
__tablename__ = "reports"
id = Column(Integer, primary_key=True, index=True)
reporter_id = Column(
Integer,
ForeignKey("users.id", ondelete="CASCADE"),
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,
),
nullable=False,
default=ReportStatus.OPEN,
)
resolved_by = Column(
Integer,
ForeignKey("users.id", ondelete="SET NULL"),
nullable=True,
)
resolution = Column(
Enum(
Resolution.ACTION_TAKEN,
Resolution.NO_ACTION,
name="resolution",
create_type=False,
),
nullable=True,
)
created_at = Column(
TIMESTAMPTZ,
nullable=False,
server_default=sa.func.now(),
)
updated_at = Column(
TIMESTAMPTZ,
nullable=False,
server_default=sa.func.now(),
onupdate=sa.func.now(),
)
resolved_at = Column(TIMESTAMPTZ, nullable=True)
# Relationships
reporter = relationship("User", foreign_keys=[reporter_id])
resolver = relationship("User", foreign_keys=[resolved_by])
moderation_actions = relationship(
"ModerationAction",
back_populates="report",
cascade="all, delete-orphan",
)
appeals = relationship(
"Appeal",
back_populates="report",
cascade="all, delete-orphan",
)
-21
View File
@@ -3,7 +3,6 @@ import uuid
from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, Boolean
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from app.database import Base
@@ -19,23 +18,3 @@ class Session(Base):
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
user = relationship("User", back_populates="sessions")
class RefreshToken(Base):
__tablename__ = "refresh_tokens"
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
# RS256 JWTs are ~420+ chars; 512 is sufficient and matches the migration.
token = Column(String(512), unique=True, index=True, nullable=False)
expires_at = Column(DateTime(timezone=True), nullable=False)
is_active = Column(Boolean, nullable=False, default=True, server_default="true")
created_at = Column(DateTime(timezone=True), nullable=False, server_default=func.now())
updated_at = Column(
DateTime(timezone=True),
nullable=False,
server_default=func.now(),
onupdate=func.now(),
)
user = relationship("User", back_populates="refresh_tokens")
+25 -2
View File
@@ -1,13 +1,20 @@
from datetime import date
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
from app.database import Base
class UserRole:
"""Valid user roles."""
USER = "user"
MODERATOR = "moderator"
ADMIN = "admin"
class User(Base):
__tablename__ = "users"
@@ -17,13 +24,29 @@ 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(UserRole.USER, UserRole.MODERATOR, UserRole.ADMIN, name="user_role", create_type=False),
nullable=False,
default=UserRole.USER,
)
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")
refresh_tokens = relationship("RefreshToken", back_populates="user", cascade="all, delete-orphan")
worlds = relationship("World", back_populates="owner")
assets = relationship("Asset", back_populates="owner")
moderation_actions_given = relationship(
"ModerationAction",
foreign_keys="ModerationAction.moderator_id",
back_populates="moderator",
)
moderation_actions_received = relationship(
"ModerationAction",
foreign_keys="ModerationAction.target_user_id",
back_populates="target_user",
)
@property
def is_adult(self) -> bool:
+2 -36
View File
@@ -1,6 +1,5 @@
"""Asset management router."""
import json
import os
from pathlib import Path
from typing import Optional
@@ -14,7 +13,6 @@ from app.database import get_db
from app.dependencies import CurrentUser
from app.models.asset import Asset, AssetType
from app.models.world import World
from app.services import gltf as gltf_service
from pydantic import BaseModel, Field
router = APIRouter()
@@ -66,14 +64,6 @@ def _sanitize_filename(filename: str) -> str:
return safe or "file"
def _detect_asset_type(filename: Optional[str], content: bytes) -> AssetType:
"""Detect the asset type from filename and content."""
if filename and gltf_service.looks_like_gltf(content, filename):
return AssetType.MODEL_3D
# Fallback: let the caller decide based on mime_type or default.
return AssetType.OTHER
# --- Endpoints ---
@router.post("/", response_model=AssetResponse, status_code=201)
@@ -193,16 +183,10 @@ async def upload_asset_file(
# Sanitize filename to prevent path traversal.
safe_filename = _sanitize_filename(file.filename or f"asset_{asset_id}")
# Read the file content.
# Validate file size against configured maximum.
max_bytes = settings.MAX_UPLOAD_SIZE_MB * 1024 * 1024
content = await file.read()
file_size = len(content)
# Determine which size limit to apply.
if gltf_service.looks_like_gltf(content, file.filename):
max_bytes = settings.GLTF_MAX_FILE_SIZE_MB * 1024 * 1024
else:
max_bytes = settings.MAX_UPLOAD_SIZE_MB * 1024 * 1024
if file_size > max_bytes:
raise HTTPException(
status_code=413,
@@ -216,28 +200,10 @@ async def upload_asset_file(
file_path = upload_dir / stored_name
file_path.write_bytes(content)
# Detect asset type from content/filename.
detected_type = _detect_asset_type(file.filename, content)
# Update asset record.
asset.file_path = str(file_path)
asset.mime_type = file.content_type
asset.file_size = file_size
# If this is a glTF file, extract and store metadata.
if detected_type == AssetType.MODEL_3D:
try:
metadata = gltf_service.extract_gltf_metadata(content)
asset.metadata_json = json.dumps(metadata)
# Also set the asset type to model_3d if it was auto-detected.
if asset.asset_type == AssetType.OTHER:
asset.asset_type = AssetType.MODEL_3D
except ValueError as exc:
raise HTTPException(
status_code=400,
detail=f"Invalid glTF file: {exc}",
)
await db.commit()
await db.refresh(asset)
return asset
+10 -71
View File
@@ -1,21 +1,20 @@
"""Authentication router - login, register, token refresh."""
import uuid
from datetime import date, datetime, timedelta, timezone
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.security import OAuth2PasswordRequestForm
from pydantic import BaseModel, EmailStr, field_validator
from jose import jwt
from passlib.context import CryptContext
from pydantic import BaseModel, EmailStr, field_validator
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database import get_db
from app.dependencies import CurrentUser
from app.models.session import RefreshToken
from app.models.user import User
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
@@ -26,9 +25,7 @@ router = APIRouter()
class TokenResponse(BaseModel):
access_token: str
refresh_token: str
token_type: str = "bearer"
access_token_expires_minutes: int
class RegisterRequest(BaseModel):
@@ -62,36 +59,12 @@ class UserResponse(BaseModel):
# --- Helpers ---
def _create_access_token(data: dict) -> str:
"""Create a signed access token using RS256."""
to_encode = data.copy()
expire = datetime.now(timezone.utc) + timedelta(
minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES
)
to_encode.update({"exp": expire})
return jwt.encode(
to_encode,
settings.private_pem,
algorithm=settings.ALGORITHM,
)
def _create_refresh_token(data: dict) -> str:
"""Create a signed refresh token using RS256.
Includes a unique ``jti`` claim so that concurrent calls produce
distinct tokens even when issued within the same second.
"""
to_encode = data.copy()
to_encode["jti"] = str(uuid.uuid4())
expire = datetime.now(timezone.utc) + timedelta(
days=settings.REFRESH_TOKEN_EXPIRE_DAYS
)
to_encode.update({"exp": expire})
return jwt.encode(
to_encode,
settings.private_pem,
algorithm=settings.ALGORITHM,
)
return jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
def verify_password(plain_password: str, hashed_password: str) -> bool:
@@ -124,25 +97,11 @@ def calculate_age(birth_date: date, today: date | None = None) -> int:
return age
def _issue_refresh_token(user_id: int, db: AsyncSession) -> str:
"""Create and persist a refresh token for the given user."""
refresh_token_value = _create_refresh_token({"sub": str(user_id)})
refresh_token_record = RefreshToken(
id=str(uuid.uuid4()),
user_id=user_id,
token=refresh_token_value,
expires_at=datetime.now(timezone.utc)
+ timedelta(days=settings.REFRESH_TOKEN_EXPIRE_DAYS),
)
db.add(refresh_token_record)
return refresh_token_value
# --- 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 access + refresh tokens.
"""Register a new user and return an access token.
Requires date_of_birth and rejects users under 18.
"""
@@ -179,26 +138,16 @@ async def register(req: RegisterRequest, db: AsyncSession = Depends(get_db)):
date_of_birth=dob,
)
db.add(user)
await db.flush() # flush to get user.id before creating refresh token
await db.commit()
await db.refresh(user)
# Create access token
access_token = _create_access_token({"sub": str(user.id)})
# Create and persist refresh token
refresh_token_value = _issue_refresh_token(user.id, db)
await db.commit()
return TokenResponse(
access_token=access_token,
refresh_token=refresh_token_value,
access_token_expires_minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES,
)
return TokenResponse(access_token=access_token)
@router.post("/login", response_model=TokenResponse)
async def login(form: OAuth2PasswordRequestForm = Depends(), db: AsyncSession = Depends(get_db)):
"""Authenticate user and return access + refresh tokens.
"""Authenticate user and return an access token.
Accepts form-encoded credentials (username/password) as expected by
OAuth2PasswordBearer, so the Swagger UI "Authorize" flow works correctly.
@@ -218,18 +167,8 @@ async def login(form: OAuth2PasswordRequestForm = Depends(), db: AsyncSession =
detail="User account is inactive",
)
# Create access token
access_token = _create_access_token({"sub": str(user.id)})
# Create and persist refresh token
refresh_token_value = _issue_refresh_token(user.id, db)
await db.commit()
return TokenResponse(
access_token=access_token,
refresh_token=refresh_token_value,
access_token_expires_minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES,
)
return TokenResponse(access_token=access_token)
@router.get("/me", response_model=UserResponse)
View File
-197
View File
@@ -1,197 +0,0 @@
"""Lightweight glTF / GLB metadata extraction.
This module intentionally avoids heavyweight dependencies such as trimesh or
pygltflib. For the metadata fields the asset upload pipeline needs (mesh and
vertex counts, animation presence, texture references, and the bounding box)
a small amount of glTF-specific parsing is sufficient and keeps the backend
dependency-free.
"""
from __future__ import annotations
import json
import struct
from typing import Any, Optional
# GLB binary header magic bytes ("glTF").
_GLB_MAGIC = b"glTF"
_GLB_MAGIC_INT = 0x46546C67 # b"glTF" interpreted as little-endian uint32
_GLB_JSON_CHUNK_TYPE = 0x4E4F534A # "JSON"
_GLB_BIN_CHUNK_TYPE = 0x004E4942 # "BIN\0"
# Whitespace allowed around a GLB JSON chunk. The spec pads with spaces (0x20),
# but be lenient with null bytes and other whitespace that real exporters emit.
_JSON_CHUNK_TRIM = b" \t\r\n\x00"
def looks_like_gltf(content: bytes, filename: Optional[str] = None) -> bool:
"""Return True when the bytes or filename indicate a glTF/GLB asset."""
if content.startswith(_GLB_MAGIC):
return True
if filename:
lowered = filename.lower()
if lowered.endswith(".gltf") or lowered.endswith(".glb"):
return True
# A bare JSON buffer (e.g. uploaded without an extension) that has the
# glTF asset marker is also a glTF file.
stripped = content.lstrip()
if stripped.startswith(b"{"):
try:
payload = json.loads(stripped.decode("utf-8"))
except (ValueError, UnicodeDecodeError):
return False
return isinstance(payload, dict) and "asset" in payload and "version" in payload.get("asset", {})
return False
def _parse_glb(content: bytes) -> dict[str, Any]:
"""Parse the JSON chunk of a GLB binary container."""
if len(content) < 12:
raise ValueError("GLB file is too short")
magic, _version, _length = struct.unpack_from("<III", content, 0)
if magic != _GLB_MAGIC_INT:
raise ValueError("invalid GLB magic")
offset = 12
json_chunk: Optional[dict[str, Any]] = None
while offset + 8 <= len(content):
chunk_length, chunk_type = struct.unpack_from("<II", content, offset)
offset += 8
chunk_end = offset + chunk_length
if chunk_end > len(content):
raise ValueError("GLB chunk overruns file boundary")
chunk_data = content[offset:chunk_end]
if chunk_type == _GLB_JSON_CHUNK_TYPE:
try:
json_chunk = json.loads(chunk_data.strip(_JSON_CHUNK_TRIM).decode("utf-8"))
except (ValueError, UnicodeDecodeError) as exc:
raise ValueError(f"invalid GLB JSON chunk: {exc}") from exc
offset = chunk_end
if json_chunk is None:
raise ValueError("GLB file does not contain a JSON chunk")
return json_chunk
def _parse_gltf_json(content: bytes) -> dict[str, Any]:
"""Parse a plain .gltf JSON file."""
stripped = content.lstrip()
try:
payload = json.loads(stripped.decode("utf-8"))
except (ValueError, UnicodeDecodeError) as exc:
raise ValueError(f"invalid glTF JSON: {exc}") from exc
if not isinstance(payload, dict):
raise ValueError("glTF JSON root must be an object")
return payload
def _position_accessor_counts(gltf: dict[str, Any]) -> list[int]:
"""Return POSITION accessor counts for every mesh primitive."""
counts: list[int] = []
accessors = gltf.get("accessors", [])
for mesh in gltf.get("meshes", []):
for primitive in mesh.get("primitives", []):
attributes = primitive.get("attributes", {})
position_index = attributes.get("POSITION")
if position_index is None:
continue
try:
accessor = accessors[int(position_index)]
except (IndexError, TypeError, ValueError):
continue
count = accessor.get("count")
if isinstance(count, int):
counts.append(count)
return counts
def _position_bounds(gltf: dict[str, Any]) -> list[list[float]]:
"""Return min/max bounds for every POSITION accessor that declares them."""
bounds: list[list[float]] = []
accessors = gltf.get("accessors", [])
for mesh in gltf.get("meshes", []):
for primitive in mesh.get("primitives", []):
attributes = primitive.get("attributes", {})
position_index = attributes.get("POSITION")
if position_index is None:
continue
try:
accessor = accessors[int(position_index)]
except (IndexError, TypeError, ValueError):
continue
minimum = accessor.get("min")
maximum = accessor.get("max")
if isinstance(minimum, list) and isinstance(maximum, list):
bounds.append([float(x) for x in minimum])
bounds.append([float(x) for x in maximum])
return bounds
def _texture_refs(gltf: dict[str, Any]) -> list[str]:
"""Collect external texture source references from the image array."""
refs: list[str] = []
for index, image in enumerate(gltf.get("images", [])):
uri = image.get("uri")
if isinstance(uri, str) and not uri.startswith("data:"):
refs.append(uri)
continue
name = image.get("name")
if isinstance(name, str) and name:
refs.append(name)
continue
if isinstance(uri, str):
# Embedded data URI; keep a stable, non-huge reference.
refs.append(f"data:{index}")
continue
buffer_view = image.get("bufferView")
if buffer_view is not None:
refs.append(f"bufferView:{buffer_view}")
continue
refs.append(f"image:{index}")
return refs
def extract_gltf_metadata(content: bytes) -> dict[str, Any]:
"""Extract upload-relevant metadata from glTF/GLB bytes.
Raises ValueError for malformed glTF content. The returned dict contains:
mesh_count, vertex_count, has_animation, texture_refs,
bounding_box_min, bounding_box_max.
"""
if content.startswith(_GLB_MAGIC):
gltf = _parse_glb(content)
else:
gltf = _parse_gltf_json(content)
position_counts = _position_accessor_counts(gltf)
bounds = _position_bounds(gltf)
if bounds:
bounding_box_min = [min(axis) for axis in zip(*bounds[::2])]
bounding_box_max = [max(axis) for axis in zip(*bounds[1::2])]
else:
bounding_box_min = [0.0, 0.0, 0.0]
bounding_box_max = [0.0, 0.0, 0.0]
return {
"mesh_count": len(gltf.get("meshes", [])),
"vertex_count": sum(position_counts),
"has_animation": bool(gltf.get("animations")),
"texture_refs": _texture_refs(gltf),
"bounding_box_min": bounding_box_min,
"bounding_box_max": bounding_box_max,
}
+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:
-220
View File
@@ -1,220 +0,0 @@
"""Tests for the lightweight glTF metadata extraction service."""
import json
import struct
import pytest
from app.services.gltf import (
extract_gltf_metadata,
looks_like_gltf,
_parse_glb,
_parse_gltf_json,
)
# ---------------------------------------------------------------------------
# Test data helpers
# ---------------------------------------------------------------------------
def _make_gltf_json(
meshes: int = 1,
vertices_per_mesh: int = 24,
has_animations: bool = False,
images: list[dict] | None = None,
) -> dict:
"""Build a minimal glTF JSON structure."""
accessors = []
meshes_data = []
for i in range(meshes):
acc_index = len(accessors)
accessors.append({
"bufferView": 0,
"componentType": 5126, # FLOAT
"count": vertices_per_mesh,
"type": "VEC3",
"max": [1.0, 1.0, 1.0],
"min": [-1.0, -1.0, -1.0],
})
meshes_data.append({
"primitives": [{
"attributes": {"POSITION": acc_index},
}],
})
gltf = {
"asset": {"version": "2.0"},
"accessors": accessors,
"meshes": meshes_data,
}
if has_animations:
gltf["animations"] = [{"name": "anim1", "channels": [], "samplers": []}]
if images:
gltf["images"] = images
return gltf
def _make_glb(gltf_json: dict) -> bytes:
"""Wrap *gltf_json* in a minimal GLB binary container."""
json_bytes = json.dumps(gltf_json).encode("utf-8")
json_length = len(json_bytes)
# Pad JSON chunk to 4-byte boundary.
json_padding = (4 - json_length % 4) % 4
json_chunk = json_bytes + b"\x00" * json_padding
json_chunk_type = 0x4E4F534A # "JSON"
# No BIN chunk.
bin_length = 0
bin_chunk_type = 0x004E4942
total_length = 12 + 8 + len(json_chunk) + 8 + bin_length
header = struct.pack("<III", 0x46546C67, 2, total_length) # magic, version, length
json_header = struct.pack("<II", len(json_chunk), json_chunk_type)
if bin_length > 0:
bin_header = struct.pack("<II", bin_length, bin_chunk_type)
else:
bin_header = b""
return header + json_header + json_chunk + bin_header
# ---------------------------------------------------------------------------
# looks_like_gltf
# ---------------------------------------------------------------------------
class TestLooksLikeGltf:
def test_glb_magic(self):
assert looks_like_gltf(b"glTF\x00\x00\x00\x00") is True
def test_gltf_extension(self):
assert looks_like_gltf(b'{"asset":{"version":"2.0"}}', "model.gltf") is True
def test_glb_extension(self):
assert looks_like_gltf(b"some binary", "model.glb") is True
def test_json_with_asset_version(self):
payload = json.dumps({"asset": {"version": "2.0"}}).encode()
assert looks_like_gltf(payload) is True
def test_plain_json_no_asset(self):
payload = json.dumps({"foo": "bar"}).encode()
assert looks_like_gltf(payload) is False
def test_non_json_bytes(self):
assert looks_like_gltf(b"\x89PNG\r\n\x1a\n") is False
# ---------------------------------------------------------------------------
# _parse_glb
# ---------------------------------------------------------------------------
class TestParseGlb:
def test_parses_valid_glb(self):
gltf_json = _make_gltf_json()
glb = _make_glb(gltf_json)
result = _parse_glb(glb)
assert result["asset"]["version"] == "2.0"
assert len(result["meshes"]) == 1
def test_rejects_too_short(self):
with pytest.raises(ValueError, match="too short"):
_parse_glb(b"glTF\x00\x00")
def test_rejects_invalid_magic(self):
with pytest.raises(ValueError, match="invalid GLB magic"):
_parse_glb(b"NOTglTF\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00")
# ---------------------------------------------------------------------------
# _parse_gltf_json
# ---------------------------------------------------------------------------
class TestParseGltfJson:
def test_parses_valid_gltf(self):
gltf_json = _make_gltf_json()
result = _parse_gltf_json(json.dumps(gltf_json).encode())
assert result["asset"]["version"] == "2.0"
def test_rejects_non_object(self):
with pytest.raises(ValueError, match="root must be an object"):
_parse_gltf_json(b"[1, 2, 3]")
# ---------------------------------------------------------------------------
# extract_gltf_metadata (GLB path)
# ---------------------------------------------------------------------------
class TestExtractGltfMetadataGlb:
def test_basic_glb(self):
gltf_json = _make_gltf_json(meshes=1, vertices_per_mesh=24)
glb = _make_glb(gltf_json)
meta = extract_gltf_metadata(glb)
assert meta["mesh_count"] == 1
assert meta["vertex_count"] == 24
assert meta["has_animation"] is False
assert meta["bounding_box_min"] == [-1.0, -1.0, -1.0]
assert meta["bounding_box_max"] == [1.0, 1.0, 1.0]
assert meta["texture_refs"] == []
def test_multiple_meshes(self):
gltf_json = _make_gltf_json(meshes=3, vertices_per_mesh=12)
glb = _make_glb(gltf_json)
meta = extract_gltf_metadata(glb)
assert meta["mesh_count"] == 3
assert meta["vertex_count"] == 36
def test_with_animation(self):
gltf_json = _make_gltf_json(has_animations=True)
glb = _make_glb(gltf_json)
meta = extract_gltf_metadata(glb)
assert meta["has_animation"] is True
def test_with_external_texture(self):
gltf_json = _make_gltf_json(images=[{"uri": "textures/rock.png"}])
glb = _make_glb(gltf_json)
meta = extract_gltf_metadata(glb)
assert meta["texture_refs"] == ["textures/rock.png"]
def test_with_named_texture(self):
gltf_json = _make_gltf_json(images=[{"name": "my_texture"}])
glb = _make_glb(gltf_json)
meta = extract_gltf_metadata(glb)
assert meta["texture_refs"] == ["my_texture"]
def test_empty_meshes(self):
gltf_json = _make_gltf_json(meshes=0)
glb = _make_glb(gltf_json)
meta = extract_gltf_metadata(glb)
assert meta["mesh_count"] == 0
assert meta["vertex_count"] == 0
assert meta["bounding_box_min"] == [0.0, 0.0, 0.0]
assert meta["bounding_box_max"] == [0.0, 0.0, 0.0]
# ---------------------------------------------------------------------------
# extract_gltf_metadata (plain .gltf JSON path)
# ---------------------------------------------------------------------------
class TestExtractGltfMetadataGltfJson:
def test_plain_gltf(self):
gltf_json = _make_gltf_json(meshes=2, vertices_per_mesh=8)
raw = json.dumps(gltf_json).encode()
meta = extract_gltf_metadata(raw)
assert meta["mesh_count"] == 2
assert meta["vertex_count"] == 16
assert meta["has_animation"] is False
def test_plain_gltf_with_animation(self):
gltf_json = _make_gltf_json(has_animations=True)
raw = json.dumps(gltf_json).encode()
meta = extract_gltf_metadata(raw)
assert meta["has_animation"] is True
# ---------------------------------------------------------------------------
# Error handling
# ---------------------------------------------------------------------------
class TestExtractGltfMetadataErrors:
def test_invalid_glb(self):
with pytest.raises(ValueError):
extract_gltf_metadata(b"not a glb")
def test_invalid_gltf_json(self):
with pytest.raises(ValueError):
_parse_gltf_json(b"[1, 2, 3]")
-79
View File
@@ -1,79 +0,0 @@
"""Tests for refresh token uniqueness (jti claim) and model column length."""
import uuid
from datetime import datetime, timedelta, timezone
import pytest
from jose import jwt
from app.config import settings
from app.routers.auth import _create_refresh_token
from app.models.session import RefreshToken
class TestRefreshTokenJtiUniqueness:
"""Verify that _create_refresh_token produces unique tokens even when
called with identical data in rapid succession (issue #1 from review)."""
def test_two_calls_produce_different_tokens(self):
"""Two calls with the same sub produce different JWTs because of jti."""
token_a = _create_refresh_token({"sub": "42"})
token_b = _create_refresh_token({"sub": "42"})
assert token_a != token_b
def test_jti_is_valid_uuid(self):
"""The jti claim inside the refresh token is a valid UUID."""
token = _create_refresh_token({"sub": "1"})
payload = jwt.decode(
token,
settings.public_pem,
algorithms=[settings.ALGORITHM],
)
assert "jti" in payload
# Verify it parses as a UUID (raises ValueError if not)
uuid.UUID(payload["jti"])
def test_each_token_has_unique_jti(self):
"""Generate 100 tokens for the same user — all jtis must differ."""
jtis = set()
for _ in range(100):
token = _create_refresh_token({"sub": "999"})
payload = jwt.decode(
token,
settings.public_pem,
algorithms=[settings.ALGORITHM],
)
jtis.add(payload["jti"])
assert len(jtis) == 100
def test_jti_survives_decode(self):
"""The jti claim is present and correct after decode."""
token = _create_refresh_token({"sub": "7"})
payload = jwt.decode(
token,
settings.public_pem,
algorithms=[settings.ALGORITHM],
)
assert payload["sub"] == "7"
assert "jti" in payload
assert "exp" in payload
class TestRefreshTokenModelColumnLength:
"""Verify the model's token column length matches the migration (issue #2)."""
def test_token_column_has_length_512(self):
"""RefreshToken.token column should be String(512), not unbounded."""
from sqlalchemy import inspect
# Get the column definition from the model's table
column = RefreshToken.__table__.columns["token"]
# The type should be String with length 512
assert column.type.length == 512
def test_token_column_type_is_string(self):
"""The token column should be a String type."""
from sqlalchemy import String as SAString
column = RefreshToken.__table__.columns["token"]
assert isinstance(column.type, SAString)
-41
View File
@@ -1,41 +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 + register** UI built & wired (form-encoded + JSON/age gate) | 1 |
| 3 | implementation | **World list** screen consuming `GET /api/worlds` | 1 |
| 4 | testing | End-to-end headless smoke test + manual verify | 1,2,3 |
## 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.
## Notes
- The main-menu auth UI does **not** exist yet (main_menu.gd currently has only Play/Quit
stubs). Task 2 must **build** the login/register UI, not merely wire an existing one.
- The world-list screen also needs to be **built** (a new scene), not just wired.
-50
View File
@@ -1,50 +0,0 @@
# Testing at Package Checkpoints
A Package Checkpoint (PC1, PC2, ...) is a periodic review point — every few
real milestones, the client and server builds get packaged together and
independently reviewed before anyone calls anything actually done. See
`project_agp` in Claude's memory, or ask, for the full definition; this
doc is just the mechanical "how do I run the tests" part of that review.
## Running the suite
```
python scripts/run_checkpoint_tests.py
```
Brings up a throwaway Postgres, runs Alembic migrations against it, runs the
backend pytest suite, starts the backend server, and runs every Godot
headless smoke test (`scripts/smoke_test_*.gd`) against it. Tears the
Postgres container down afterward (`--keep-postgres` to skip that if you
want to poke at the DB after a failure).
If port 5432 is already taken on the machine, pass `--postgres-port` with
something else.
Exit code is 0 only if everything that actually ran passed — suites with
nothing to run yet (no Godot binary found, no smoke test scripts yet) are
reported SKIPPED, not treated as a failure.
## What this does and doesn't prove
Passing this script means: migrations apply cleanly to a genuinely empty
database, the backend's unit-level logic is correct, and whatever Godot
smoke tests exist actually round-trip against a live backend. It does
**not** mean full endpoint/integration coverage exists — see
`test_inventory.md` for the current, honest list of what's tested and what
isn't. A checkpoint review should still involve reading the actual diffs
for anything this script's current coverage doesn't reach, not just running
green and calling it done.
## Growing this over time
As real integration tests get added (endpoint tests hitting the FastAPI app,
more Godot smoke coverage beyond `health_check`), they should land under
`backend/tests/` / `scripts/smoke_test_*.gd` respectively — this script
picks up new files automatically, nothing to register by hand. Update
`test_inventory.md`'s tables when you do, so the inventory stays a source
of truth instead of quietly going stale.
Eventual goal (not implemented yet): gate merges to `main` on this script
passing, the same way a real CI pipeline would. For now it's a manual step
run at checkpoint review time.
-78
View File
@@ -1,78 +0,0 @@
# Test Inventory
What automated coverage actually exists, what it actually verifies, and what
doesn't have coverage yet. Keep this current as tests are added — it's the
thing a Package Checkpoint review (see `docs/testing/README.md`) reads first
to know what "run the tests" actually checked.
Run everything below in one shot with `scripts/run_checkpoint_tests.py`.
Last verified against `main`: 2026-08-19 (commit `1c75367`).
## Backend — `backend/tests/` (pytest)
Run: `cd backend && python -m pytest -v` (needs `DATABASE_URL` pointed at a
reachable Postgres — see the runner script; most of these tests don't
actually touch the DB, but the app's import chain requires a valid URL to
construct).
**Current total: 38 tests, all passing on a clean `main` checkout.**
| File | Tests | What it covers |
|---|---|---|
| `test_auth_age_gate.py` | 10 | `calculate_age()` date-math correctness (exact boundary, leap years, birthday-not-yet-this-year) + `RegisterRequest` date-of-birth validation (parametrized valid/invalid date rejection). Backs the 18+ registration gate (PR #23). |
| `test_gltf_metadata.py` | 21 | glTF/GLB binary + JSON parsing: magic-byte detection, chunk-padding handling, mesh/animation/texture-reference metadata extraction, malformed-input rejection. Backs the asset-import pipeline (PR #27). |
**What these are, honestly:** pure unit tests of standalone functions
(`calculate_age`, the glTF parser, Pydantic validators). None of them spin
up the FastAPI app or hit a real endpoint — they run in ~1s and don't
actually need Postgres reachable at all (the import chain just needs a
syntactically valid `DATABASE_URL`, nothing connects during collection).
**Known coverage gaps** (real gaps, not placeholders — nothing enforces filling
these yet):
- No integration/endpoint tests at all — `/register`, `/login`, `/refresh`,
`/worlds`, `/assets/*` have zero tests that actually call them through
the FastAPI app (e.g. via `httpx.AsyncClient` + `ASGITransport`) against a
real (or test) database. Everything currently shipped works because it
was manually verified at merge time (checkpoint review), not because a
test asserts it.
- No tests for the RS256 auth upgrade (key generation, refresh-token
issuance/persistence) — PR #30, pending review as of this writing,
manually verified (correct key usage, all 38 existing tests still pass)
but not covered by an automated test of its own yet.
- No tests for the moderation schema/migration (in progress as of this
writing) beyond "the migration runs."
## Godot client — `scripts/smoke_test_*.gd`
**Nothing merged into `main` yet as of this writing** — `sandbox_probe/test_script.gd`
is the only Godot script on `main` right now, and it's **not a real
test** (a leftover manual probe, returns a hardcoded string, asserts
nothing). Don't count it toward coverage; delete or repurpose it if anyone
actually needs a sandbox probe again.
`scripts/smoke_test_api.gd` (PC1, PR #29, pending review as of this writing)
will be the first real one once merged — `ApiClient` autoload's
`health_check()` against a **live backend** (set `AGP_API_BASE_URL` to point
at one), verified by hand end-to-end before opening the PR: exercises the
real HTTP request path (`HTTPRequest` node lifecycle, JSON parse, the
result-dictionary contract), not just that the script compiles. Exits 1 on
failure, so it's CI/script-friendly.
Run (once merged): `godot --headless --path . --import` once (builds the
class cache — skip this and every `class_name`-declared class falsely
errors as undeclared), then for each `scripts/smoke_test_*.gd`:
`godot --headless --path . --script res://scripts/<name>.gd`
**Known gap once it lands:** only `health_check()` is exercised end-to-end.
`login`, `register`, `get_worlds`, `get_me` are implemented (see
`scripts/api_client.gd`) but have no smoke coverage yet — each would need a
real user/world fixture in the target backend to test against meaningfully.
## What "full coverage" should eventually mean here
Not defined precisely yet — the honest target as of 2026-08-19 is just
"more than pure-function unit tests." As real integration/endpoint tests get
added, update the tables above rather than letting this doc drift stale;
a wrong inventory is worse than no inventory.
-74
View File
@@ -1,74 +0,0 @@
[preset.0]
name="Linux"
platform="Linux/X11"
runnable=true
advanced_options=false
dedicated_server=false
custom_features=""
export_filter="all_resources"
include_filter=""
exclude_filter=""
export_path="export/linux/agp-game.x86_64"
encryption_include_filters=""
encryption_exclude_filters=""
encrypt_pck=false
encrypt_directory=false
script_export_mode=2
[preset.0.options]
custom_template/debug=""
custom_template/release=""
debug/export_console_wrapper=1
binary_format/embed_pck=false
texture_format/bptc=true
texture_format/s3tc=true
texture_format/etc=false
texture_format/etc2=false
binary_format/architecture="x86_64"
ssh_remote_deploy/enabled=false
[preset.1]
name="Windows Desktop"
platform="Windows Desktop"
runnable=true
advanced_options=false
dedicated_server=false
custom_features=""
export_filter="all_resources"
include_filter=""
exclude_filter=""
export_path="export/windows/agp-game.exe"
encryption_include_filters=""
encryption_exclude_filters=""
encrypt_pck=false
encrypt_directory=false
script_export_mode=2
[preset.1.options]
custom_template/debug=""
custom_template/release=""
debug/export_console_wrapper=1
binary_format/embed_pck=false
texture_format/bptc=true
texture_format/s3tc=true
texture_format/etc=false
texture_format/etc2=false
binary_format/architecture="x86_64"
codesign/enable=false
application/icon=""
application/console_wrapper_icon=""
application/icon_interpolation=4
application/file_version=""
application/product_version=""
application/company_name=""
application/product_name=""
application/file_description=""
application/copyright=""
application/trademarks=""
application/export_angle=0
application/export_d3d12=0
d3d12/agility_sdk_multizip=0
-3
View File
@@ -9,12 +9,9 @@ config_version=5
config/name="AGP-Game"
run/main_scene="res://scenes/main_menu.tscn"
config/features=PackedStringArray("4.6.2")
agp/api_base_url="http://127.0.0.1:8000"
[autoload]
ApiClient="*res://scripts/api_client.gd"
[display]
window/size/viewport_width=1920
-292
View File
@@ -1,292 +0,0 @@
extends Node
## Central HTTP client singleton (autoload name: "ApiClient").
##
## Every network call in the game flows through this class. Callers use the
## high-level helpers ([method login], [method register], [method get_worlds],
## [method health_check], [method get_me]) or the generic [method request]
## primitive. All results conform to the result-dictionary contract below.
##
## [b]Result Dictionary Contract[/b]
## Every public method returns (after `await`) a Dictionary with this shape:
##
## {
## "ok": bool, # true for HTTP 2xx, false otherwise
## "status": int, # HTTP status code (0 on network failure)
## "data": Variant, # parsed JSON body on success (null otherwise)
## "detail": String, # human-readable message
## }
##
## Network failures (timeout / DNS / refused connection) return `status == 0`
## with a `detail` describing the transport failure. Non-2xx responses return
## the real HTTP status and the backend's `detail` field from the JSON error
## body when present. No caller can crash on a failed request.
##
## [b]Token storage[/b]
## JWTs are kept in memory. [method set_token] is the single seam for future
## disk persistence (override it to save to `user://`).
##
## [b]Usage[/b]
## var result: Dictionary = await ApiClient.login("alice", "secret")
## if result.ok:
## print("Logged in!")
## else:
## print("Error: ", result.detail)
# ---------------------------------------------------------------------------
# Token storage
# ---------------------------------------------------------------------------
var _access_token: String = ""
var _token_type: String = "Bearer"
## Full Authorization header value, e.g. "Bearer eyJ...". Empty if no token.
var bearer_token: String:
get:
return _token_type + " " + _access_token if _access_token != "" else ""
## Whether an access token is currently stored.
func has_token() -> bool:
return _access_token != ""
## Set the access token (called after login / register / token refresh).
## This is the persistence seam: override to also write to disk.
func set_token(access_token: String, token_type: String = "Bearer") -> void:
_access_token = access_token
_token_type = token_type
## Clear stored tokens.
func clear_token() -> void:
_access_token = ""
_token_type = "Bearer"
# ---------------------------------------------------------------------------
# Configuration
# ---------------------------------------------------------------------------
var _base_url: String = "http://127.0.0.1:8000"
## Request timeout in seconds. 0 disables the timeout.
var timeout_seconds: float = 10.0
func _ready() -> void:
# Environment variable override (e.g. for headless smoke tests against a
# backend on a non-default port). Falls back to the ProjectSetting, then
# to the documented default.
var env_url := OS.get_environment("AGP_API_BASE_URL")
if env_url != "":
_base_url = env_url.rstrip("/")
else:
_base_url = str(ProjectSettings.get_setting(
"application/agp/api_base_url",
"http://127.0.0.1:8000"
)).rstrip("/")
# ---------------------------------------------------------------------------
# Core request primitive
# ---------------------------------------------------------------------------
## Generic request primitive. Awaitable via `await`.
##
## Uses a dedicated HTTPRequest node per call so concurrent requests can never
## cross-talk, and so the result maps 1:1 to the awaiting caller.
##
## @param method HTTPClient.Method enum value (GET/POST/PUT/PATCH/DELETE).
## @param path URL path, e.g. "/api/health".
## @param body Request body string (JSON or form-encoded).
## @param content_type "application/json" or "application/x-www-form-urlencoded".
## @param require_auth If true, attach the stored bearer token when present.
## @return Result dictionary conforming to the contract.
func request(
method: HTTPClient.Method,
path: String,
body: String = "",
content_type: String = "application/json",
require_auth: bool = false
) -> Dictionary:
var http := HTTPRequest.new()
http.timeout = timeout_seconds
http.max_redirects = 8
http.accept_gzip = true
add_child(http)
var headers := PackedStringArray()
headers.append("Accept: application/json")
if content_type != "":
headers.append("Content-Type: " + content_type)
if require_auth and has_token():
headers.append("Authorization: " + bearer_token)
var url := _base_url + path
var err := http.request(url, headers, method, body)
if err != OK:
http.queue_free()
return _make_result(false, 0, null, "Failed to start request: " + _error_string(err))
# request_completed emits (result, response_code, headers, body).
var args: Array = await http.request_completed
var result_code: int = args[0]
var response_code: int = args[1]
var response_body: PackedByteArray = args[3]
http.queue_free()
# Transport-level failure (timeout / DNS / refused / TLS, etc.).
if result_code != HTTPRequest.RESULT_SUCCESS:
return _make_result(false, 0, null, _transport_error_detail(result_code))
# Server accepted the connection but returned no HTTP status line.
if response_code == 0:
return _make_result(false, 0, null, "No response received from server.")
var json_string := response_body.get_string_from_utf8()
var ok := response_code >= 200 and response_code < 300
if ok:
var data: Variant = _parse_json(json_string)
return _make_result(true, response_code, data, "OK")
# Non-2xx: surface the backend's "detail" message when available.
var detail := "HTTP error %d" % response_code
var parsed: Variant = _parse_json(json_string)
if parsed is Dictionary and (parsed as Dictionary).has("detail"):
detail = str((parsed as Dictionary)["detail"])
return _make_result(false, response_code, null, detail)
# ---------------------------------------------------------------------------
# High-level helpers
# ---------------------------------------------------------------------------
## Log in with username/password (OAuth2PasswordRequestForm).
## On success stores the returned access token.
func login(username: String, password: String) -> Dictionary:
var form := "username=" + username.uri_encode() + "&password=" + password.uri_encode()
var result := await request(
HTTPClient.METHOD_POST,
"/api/auth/login",
form,
"application/x-www-form-urlencoded",
false
)
_store_token_from_result(result)
return result
## Register a new user. On success stores the returned access token.
## @param date_of_birth ISO date string "YYYY-MM-DD".
func register(
username: String,
email: String,
password: String,
date_of_birth: String
) -> Dictionary:
var payload := {
"username": username,
"email": email,
"password": password,
"date_of_birth": date_of_birth,
}
var result := await request(
HTTPClient.METHOD_POST,
"/api/auth/register",
JSON.stringify(payload),
"application/json",
false
)
_store_token_from_result(result)
return result
## List worlds visible to the current user (requires auth).
## @param visibility Optional filter: "public", "private", "friends_only".
func get_worlds(skip: int = 0, limit: int = 50, visibility: String = "") -> Dictionary:
var path := "/api/worlds/?skip=%d&limit=%d" % [skip, limit]
if visibility != "":
path += "&visibility=" + visibility.uri_encode()
return await request(HTTPClient.METHOD_GET, path, "", "application/json", true)
## Ping the backend health endpoint (no auth).
func health_check() -> Dictionary:
return await request(HTTPClient.METHOD_GET, "/api/health", "", "application/json", false)
## Fetch the current user's profile from the stored JWT (requires auth).
func get_me() -> Dictionary:
return await request(HTTPClient.METHOD_GET, "/api/users/me", "", "application/json", true)
# ---------------------------------------------------------------------------
# Internals
# ---------------------------------------------------------------------------
func _store_token_from_result(result: Dictionary) -> void:
if result.ok and result.data is Dictionary:
var token: String = str((result.data as Dictionary).get("access_token", ""))
if token != "":
set_token(token)
func _parse_json(text: String) -> Variant:
if text == "":
return null
var parsed: Variant = JSON.parse_string(text)
if parsed == null:
# null is ambiguous (valid JSON "null" vs parse failure). Fall back to
# the raw string so callers never lose the body entirely.
return text if text != "null" else null
return parsed
func _make_result(ok: bool, status: int, data: Variant, detail: String) -> Dictionary:
return {
"ok": ok,
"status": status,
"data": data,
"detail": detail,
}
func _transport_error_detail(result_code: int) -> String:
match result_code:
HTTPRequest.RESULT_CANT_CONNECT:
return "Could not connect to server. Is it running?"
HTTPRequest.RESULT_CANT_RESOLVE:
return "Could not resolve server hostname."
HTTPRequest.RESULT_CONNECTION_ERROR:
return "Connection error."
HTTPRequest.RESULT_TLS_HANDSHAKE_ERROR:
return "TLS handshake failed."
HTTPRequest.RESULT_NO_RESPONSE:
return "No response from server."
HTTPRequest.RESULT_BODY_SIZE_LIMIT_EXCEEDED:
return "Response body too large."
HTTPRequest.RESULT_BODY_DECOMPRESS_FAILED:
return "Failed to decompress response body."
HTTPRequest.RESULT_REQUEST_FAILED:
return "Request failed."
HTTPRequest.RESULT_TIMEOUT:
return "Request timed out."
_:
return "Network error (code %d)." % result_code
func _error_string(err_code: int) -> String:
match err_code:
OK:
return "OK"
ERR_UNCONFIGURED:
return "HTTPRequest not configured (not in tree)."
ERR_BUSY:
return "HTTPRequest is busy with a previous request."
ERR_INVALID_PARAMETER:
return "Invalid URL format."
ERR_CANT_CONNECT:
return "Cannot connect to host."
_:
return "Unknown error (%d)." % err_code
-1
View File
@@ -1 +0,0 @@
uid://cjd0uhll7iylj
-42
View File
@@ -1,42 +0,0 @@
#!/usr/bin/env bash
# Headless CI export for Linux and Windows.
#
# Requires:
# - Godot 4.6.2 (the exact version this project + export_presets.cfg were
# built against; a different Godot minor version's export templates are
# not guaranteed compatible)
# - The matching export templates installed at
# ~/.local/share/godot/export_templates/4.6.2.stable/
# (download: https://godotengine.org/download/archive/4.6.2-stable/,
# "Export templates" -- NOT the same as the editor download)
# - godot on PATH (or set GODOT_BIN below)
#
# Usage: scripts/build.sh
# Output: export/linux/agp-game.x86_64, export/windows/agp-game.exe
set -euo pipefail
cd "$(dirname "$0")/.."
GODOT_BIN="${GODOT_BIN:-godot}"
if ! command -v "$GODOT_BIN" >/dev/null 2>&1; then
if [ -x "$HOME/godot/godot" ]; then
GODOT_BIN="$HOME/godot/godot"
else
echo "godot binary not found (checked PATH and ~/godot/godot)." >&2
exit 1
fi
fi
echo "Godot version: $("$GODOT_BIN" --version)"
mkdir -p export/linux export/windows
echo "=== Exporting Linux/X11 ==="
"$GODOT_BIN" --headless --path . --export-release "Linux" export/linux/agp-game.x86_64
echo "=== Exporting Windows Desktop ==="
"$GODOT_BIN" --headless --path . --export-release "Windows Desktop" export/windows/agp-game.exe
echo ""
echo "Build artifacts:"
ls -la export/linux export/windows
-1
View File
@@ -1 +0,0 @@
uid://bgnc3iamfgf6k
-170
View File
@@ -1,170 +0,0 @@
#!/usr/bin/env python3
"""Run the full automated test inventory for a Package Checkpoint review.
See docs/testing/test_inventory.md for what each suite actually covers (and
doesn't) -- keep that doc in sync as tests are added, this script just runs
whatever's there.
Usage:
python scripts/run_checkpoint_tests.py [--keep-postgres] [--postgres-port PORT]
Exits 0 if everything that ran passed, 1 otherwise. Suites with nothing to
run (e.g. no scripts/smoke_test_*.gd yet, or no Godot binary found) are
reported as SKIPPED, not counted as a failure.
"""
import argparse
import glob
import os
import shutil
import subprocess
import sys
import time
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
BACKEND = ROOT / "backend"
def run(cmd, cwd=None, env=None, capture=False):
print(f"$ {' '.join(cmd)}" + (f" (cwd={cwd})" if cwd else ""))
return subprocess.run(cmd, cwd=cwd, env=env, capture_output=capture, text=True)
def find_godot():
for candidate in ("godot", str(Path.home() / "godot" / "godot")):
if shutil.which(candidate) or Path(candidate).is_file():
return candidate
return None
def wait_for_http(url: str, timeout_s: int = 20) -> bool:
import urllib.request
import urllib.error
deadline = time.time() + timeout_s
while time.time() < deadline:
try:
with urllib.request.urlopen(url, timeout=2) as resp:
if resp.status == 200:
return True
except (urllib.error.URLError, ConnectionError, OSError):
pass
time.sleep(1)
return False
def main():
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("--keep-postgres", action="store_true",
help="Don't tear down the checkpoint Postgres container afterward")
parser.add_argument("--postgres-port", default=os.environ.get("CHECKPOINT_POSTGRES_PORT", "5432"),
help="Host port for the checkpoint's own Postgres container (default 5432 -- "
"override if that's already in use on this machine)")
parser.add_argument("--backend-port", default=os.environ.get("CHECKPOINT_BACKEND_PORT", "18080"),
help="Port to run the backend server on for the Godot smoke tests")
args = parser.parse_args()
results = {}
# --- Backend: bring up Postgres ---
compose_env = os.environ.copy()
compose_env["COMPOSE_PROJECT_NAME"] = "agp-checkpoint"
compose_env["POSTGRES_HOST_PORT"] = args.postgres_port
compose_env["POSTGRES_PORT"] = args.postgres_port # some branches use this name instead, see tools/definitions.py
print("=== Backend: bringing up Postgres ===")
run(["docker", "compose", "up", "-d", "postgres"], cwd=BACKEND, env=compose_env)
print("Waiting for Postgres to be ready...")
pg_ready = False
for _ in range(20):
r = run(["docker", "compose", "exec", "-T", "postgres", "pg_isready", "-U", "agp_user", "-d", "agp_db"],
cwd=BACKEND, env=compose_env, capture=True)
if r.returncode == 0:
pg_ready = True
break
time.sleep(1)
if not pg_ready:
print("Postgres never became ready -- aborting.")
sys.exit(1)
db_url = f"postgresql+asyncpg://agp_user:agp_password@localhost:{args.postgres_port}/agp_db"
backend_env = os.environ.copy()
backend_env["DATABASE_URL"] = db_url
print("\n=== Backend: alembic upgrade head ===")
r = run([sys.executable, "-m", "alembic", "upgrade", "head"], cwd=BACKEND, env=backend_env)
results["migrations (fresh DB)"] = r.returncode == 0
print("\n=== Backend: pytest ===")
r = run([sys.executable, "-m", "pytest", "-v"], cwd=BACKEND, env=backend_env)
results["backend pytest"] = r.returncode == 0
# --- Godot: headless smoke tests (need a live backend server) ---
godot_bin = find_godot()
smoke_tests = sorted(glob.glob(str(ROOT / "scripts" / "smoke_test_*.gd")))
if not godot_bin:
print("\n=== Godot: binary not found (checked PATH and ~/godot/godot), skipping smoke tests ===")
results["godot smoke tests"] = None
elif not smoke_tests:
print("\n=== Godot: no scripts/smoke_test_*.gd found yet, nothing to run ===")
results["godot smoke tests"] = None
else:
print(f"\n=== Backend: starting server on :{args.backend_port} for Godot smoke tests ===")
server_proc = subprocess.Popen(
[sys.executable, "-m", "uvicorn", "app.main:app", "--host", "127.0.0.1", "--port", args.backend_port],
cwd=BACKEND, env=backend_env,
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
)
try:
base_url = f"http://127.0.0.1:{args.backend_port}"
if not wait_for_http(f"{base_url}/api/health"):
print("Backend server never became healthy -- skipping Godot smoke tests.")
results["godot smoke tests"] = False
else:
godot_env = os.environ.copy()
godot_env["AGP_API_BASE_URL"] = base_url
print("=== Godot: import pass (builds the class cache) ===")
run([godot_bin, "--headless", "--path", str(ROOT), "--import"], env=godot_env)
run([godot_bin, "--headless", "--path", str(ROOT), "--quit"], env=godot_env)
godot_ok = True
for test in smoke_tests:
rel = os.path.relpath(test, ROOT)
print(f"\n--- {rel} ---")
r = run([godot_bin, "--headless", "--path", str(ROOT), "--script", f"res://{rel}"], env=godot_env)
if r.returncode != 0:
godot_ok = False
results["godot smoke tests"] = godot_ok
finally:
server_proc.terminate()
try:
server_proc.wait(timeout=10)
except subprocess.TimeoutExpired:
server_proc.kill()
if not args.keep_postgres:
print("\n=== Backend: tearing down checkpoint Postgres ===")
run(["docker", "compose", "down", "-v", "postgres"], cwd=BACKEND, env=compose_env)
# --- Summary ---
print("\n" + "=" * 50)
print("SUMMARY")
print("=" * 50)
overall_ok = True
for name, ok in results.items():
if ok is None:
print(f" {name}: SKIPPED")
elif ok:
print(f" {name}: PASS")
else:
print(f" {name}: FAIL")
overall_ok = False
print("\nSee docs/testing/test_inventory.md for what each suite actually covers (and doesn't).")
sys.exit(0 if overall_ok else 1)
if __name__ == "__main__":
main()
-46
View File
@@ -1,46 +0,0 @@
extends SceneTree
## Headless smoke test for the ApiClient autoload.
##
## Run with:
## godot --headless --path . --script res://scripts/smoke_test_api.gd
##
## Requires the backend to be running (default base URL
## `application/agp/api_base_url` -> http://127.0.0.1:8000).
##
## Calls the real autoload's `health_check()` (which exercises the generic
## `request()` primitive) and exits 0 on a `status == "ok"` response, 1
## otherwise. This script is intentionally NOT wired into any game scene.
func _initialize() -> void:
_run()
func _run() -> void:
# _initialize() fires before the SceneTree has processed its first frame,
# so a freshly add_child()'d HTTPRequest node isn't reliably considered
# "inside tree" yet (request_raw fails with !is_inside_tree()). One frame
# of delay is enough; this only matters for --script mode, not a real
# running game.
await process_frame
# Godot's --script/SceneTree runner doesn't resolve autoload singletons
# as bare global identifiers at compile time (the node exists at
# runtime -- root.has_node("ApiClient") is true -- but referencing it as
# a plain identifier fails with "Identifier not found: ApiClient" during
# script compilation). get_node sidesteps compile-time resolution
# entirely. This only affects --script mode; a real game scene resolves
# the bare autoload name normally.
var api_client := root.get_node("ApiClient")
print("SMOKE: calling ApiClient.health_check() ...")
var result: Dictionary = await api_client.health_check()
print("SMOKE: result = ", result)
if result.ok \
and result.data is Dictionary \
and str((result.data as Dictionary).get("status", "")) == "ok":
print("SMOKE RESULT: PASS (status=ok)")
quit(0)
else:
printerr("SMOKE RESULT: FAIL")
quit(1)