From aa8a497abcdf0bb6f2f6bd1862e0706bb7ed2104 Mon Sep 17 00:00:00 2001 From: agent-bot Date: Mon, 17 Aug 2026 15:18:20 +0000 Subject: [PATCH 01/27] chore(backend): add env config template MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Scaffolding only — FastAPI app, SQLAlchemy models, Alembic migrations, and docker-compose from the original task description are still not implemented. Committing what exists so it isn't lost; task ec0074121ca3 reset to pending to finish the rest. --- backend/.env.example | 14 ++++++++++++++ backend/.gitignore | 4 ++++ 2 files changed, 18 insertions(+) create mode 100644 backend/.env.example create mode 100644 backend/.gitignore diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..508ec96 --- /dev/null +++ b/backend/.env.example @@ -0,0 +1,14 @@ +# Database +DATABASE_URL=postgresql://agp_user:agp_password@localhost:5432/agp_db + +# Redis +REDIS_URL=redis://localhost:6379/0 + +# JWT +SECRET_KEY=change-me-to-a-secure-random-string +ALGORITHM=HS256 +ACCESS_TOKEN_EXPIRE_MINUTES=30 + +# App +DEBUG=true +CORS_ORIGINS=http://localhost:8080,http://localhost:3000 diff --git a/backend/.gitignore b/backend/.gitignore new file mode 100644 index 0000000..fc36f7d --- /dev/null +++ b/backend/.gitignore @@ -0,0 +1,4 @@ +.env +__pycache__/ +*.pyc +.venv/ From 49d608d357ca7d841182c85b6e1e594636fb9067 Mon Sep 17 00:00:00 2001 From: agent-bot Date: Mon, 17 Aug 2026 16:52:37 +0000 Subject: [PATCH 02/27] WIP checkpoint: Scaffold FastAPI backend project structure --- backend/requirements.txt | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 backend/requirements.txt diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..413b118 --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,16 @@ +fastapi==0.115.6 +uvicorn[standard]==0.34.0 +sqlalchemy[asyncio]==2.0.36 +alembic==1.14.0 +asyncpg==0.30.0 +psycopg2-binary==2.9.10 +pydantic==2.10.3 +pydantic-settings==2.7.0 +python-jose[cryptography]==3.3.0 +passlib[bcrypt]==1.7.4 +python-multipart==0.0.20 +redis==5.2.1 +httpx==0.28.1 +pytest==8.3.4 +pytest-asyncio==0.24.0 +aiosqlite==0.20.0 From 857cf0840233d0c0a9f1e665e666e27c90933e5b Mon Sep 17 00:00:00 2001 From: agent-bot Date: Mon, 17 Aug 2026 16:54:03 +0000 Subject: [PATCH 03/27] WIP checkpoint: Scaffold FastAPI backend project structure --- backend/requirements.txt | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/backend/requirements.txt b/backend/requirements.txt index 413b118..040cea8 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -1,16 +1,16 @@ fastapi==0.115.6 uvicorn[standard]==0.34.0 -sqlalchemy[asyncio]==2.0.36 +sqlalchemy==2.0.36 alembic==1.14.0 -asyncpg==0.30.0 psycopg2-binary==2.9.10 +redis==5.2.1 pydantic==2.10.3 pydantic-settings==2.7.0 python-jose[cryptography]==3.3.0 passlib[bcrypt]==1.7.4 python-multipart==0.0.20 -redis==5.2.1 +email-validator==2.2.0 httpx==0.28.1 pytest==8.3.4 -pytest-asyncio==0.24.0 -aiosqlite==0.20.0 +pytest-asyncio==0.25.0 +pytest-cov==6.0.0 From 7594039fd07db2bfd050fba3fbc503a20ec2d1a0 Mon Sep 17 00:00:00 2001 From: agent-bot Date: Mon, 17 Aug 2026 16:55:03 +0000 Subject: [PATCH 04/27] WIP checkpoint: Scaffold FastAPI backend project structure --- backend/__init__.py | 0 backend/app/__init__.py | 0 backend/app/config.py | 32 ++++++++++++++++++++++++++++++++ backend/app/database.py | 38 ++++++++++++++++++++++++++++++++++++++ backend/app/main.py | 36 ++++++++++++++++++++++++++++++++++++ 5 files changed, 106 insertions(+) create mode 100644 backend/__init__.py create mode 100644 backend/app/__init__.py create mode 100644 backend/app/config.py create mode 100644 backend/app/database.py create mode 100644 backend/app/main.py diff --git a/backend/__init__.py b/backend/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/config.py b/backend/app/config.py new file mode 100644 index 0000000..35109a7 --- /dev/null +++ b/backend/app/config.py @@ -0,0 +1,32 @@ +from pydantic_settings import BaseSettings +from functools import lru_cache + + +class Settings(BaseSettings): + # Database + DATABASE_URL: str = "postgresql+asyncpg://agp_user:agp_password@localhost:5432/agp_db" + + # Redis + REDIS_URL: str = "redis://localhost:6379/0" + + # JWT + SECRET_KEY: str = "change-me-to-a-secure-random-string" + ALGORITHM: str = "HS256" + ACCESS_TOKEN_EXPIRE_MINUTES: int = 30 + + # App + DEBUG: bool = True + CORS_ORIGINS: str = "http://localhost:8080,http://localhost:3000" + + @property + def cors_origins_list(self) -> list[str]: + return [origin.strip() for origin in self.CORS_ORIGINS.split(",")] + + class Config: + env_file = ".env" + case_sensitive = True + + +@lru_cache() +def get_settings() -> Settings: + return Settings() diff --git a/backend/app/database.py b/backend/app/database.py new file mode 100644 index 0000000..1002c8d --- /dev/null +++ b/backend/app/database.py @@ -0,0 +1,38 @@ +"""Async database engine and session factory.""" + +from sqlalchemy.ext.asyncio import ( + AsyncSession, + async_sessionmaker, + create_async_engine, +) +from sqlalchemy.orm import DeclarativeBase + +from app.config import settings + +engine = create_async_engine( + settings.database_url, + echo=settings.DEBUG, + pool_size=10, + max_overflow=20, +) + +async_session_factory = async_sessionmaker( + engine, + class_=AsyncSession, + expire_on_commit=False, +) + + +class Base(DeclarativeBase): + pass + + +async def get_db() -> AsyncSession: + """Dependency that yields an async database session.""" + async with async_session_factory() as session: + try: + yield session + await session.commit() + except Exception: + await session.rollback() + raise diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 0000000..e3210e0 --- /dev/null +++ b/backend/app/main.py @@ -0,0 +1,36 @@ +"""FastAPI application entry point.""" + +from contextlib import asynccontextmanager + +from fastapi import FastAPI + +from app.config import settings +from app.routers import auth, users, worlds, assets + + +@asynccontextmanager +async def lifespan(app: FastAPI): + """Application lifespan: startup and shutdown events.""" + # Startup: initialize database, connect to Redis, etc. + yield + # Shutdown: close connections, cleanup + + +app = FastAPI( + title=settings.app_name, + version=settings.app_version, + description="Agentic Game Platform - Backend API", + lifespan=lifespan, +) + +# Include routers +app.include_router(auth.router, prefix="/api/auth", tags=["auth"]) +app.include_router(users.router, prefix="/api/users", tags=["users"]) +app.include_router(worlds.router, prefix="/api/worlds", tags=["worlds"]) +app.include_router(assets.router, prefix="/api/assets", tags=["assets"]) + + +@app.get("/api/health") +async def health_check(): + """Simple health check endpoint.""" + return {"status": "ok", "app": settings.app_name} From 3e030f8d45832480aa789a4d09bdee4d5fc75029 Mon Sep 17 00:00:00 2001 From: agent-bot Date: Mon, 17 Aug 2026 16:56:46 +0000 Subject: [PATCH 05/27] WIP checkpoint: Scaffold FastAPI backend project structure --- backend/app/models/__init__.py | 6 ++++++ backend/app/models/asset.py | 36 ++++++++++++++++++++++++++++++++++ backend/app/models/session.py | 18 +++++++++++++++++ backend/app/models/user.py | 15 ++++++++++++++ backend/app/models/world.py | 27 +++++++++++++++++++++++++ 5 files changed, 102 insertions(+) create mode 100644 backend/app/models/__init__.py create mode 100644 backend/app/models/asset.py create mode 100644 backend/app/models/session.py create mode 100644 backend/app/models/user.py create mode 100644 backend/app/models/world.py diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py new file mode 100644 index 0000000..96e2dc3 --- /dev/null +++ b/backend/app/models/__init__.py @@ -0,0 +1,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 + +__all__ = ["User", "Session", "World", "Asset"] diff --git a/backend/app/models/asset.py b/backend/app/models/asset.py new file mode 100644 index 0000000..ba27c53 --- /dev/null +++ b/backend/app/models/asset.py @@ -0,0 +1,36 @@ +from sqlalchemy import Column, Integer, String, ForeignKey, Text, DateTime, Enum as SAEnum +from sqlalchemy.orm import relationship +from sqlalchemy.sql import func +import enum + +from app.database import Base + + +class AssetType(str, enum.Enum): + IMAGE = "image" + AUDIO = "audio" + VIDEO = "video" + DOCUMENT = "document" + MODEL_3D = "model_3d" + OTHER = "other" + + +class Asset(Base): + __tablename__ = "assets" + + id = Column(Integer, primary_key=True, index=True) + name = Column(String(255), nullable=False) + description = Column(Text, nullable=True) + asset_type = Column(SAEnum(AssetType), nullable=False) + file_path = Column(String(512), nullable=False) + file_size = Column(Integer, nullable=True) + mime_type = Column(String(128), nullable=True) + metadata_json = Column(Text, nullable=True) + world_id = Column(Integer, ForeignKey("worlds.id"), nullable=True) + owner_id = Column(Integer, ForeignKey("users.id"), nullable=False) + is_public = Column(Integer, default=0, nullable=False) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) + + owner = relationship("User", back_populates="assets") + world = relationship("World", back_populates="assets") diff --git a/backend/app/models/session.py b/backend/app/models/session.py new file mode 100644 index 0000000..6f94edb --- /dev/null +++ b/backend/app/models/session.py @@ -0,0 +1,18 @@ +from sqlalchemy import Column, String, DateTime, ForeignKey, Boolean +from sqlalchemy.orm import relationship +from sqlalchemy.sql import func +from app.database import Base + + +class Session(Base): + __tablename__ = "sessions" + + id = Column(String, primary_key=True) + user_id = Column(String, ForeignKey("users.id", ondelete="CASCADE"), nullable=False) + token = Column(String, unique=True, index=True, nullable=False) + expires_at = Column(DateTime(timezone=True), nullable=False) + is_active = Column(Boolean, default=True) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) + + user = relationship("User", back_populates="sessions") diff --git a/backend/app/models/user.py b/backend/app/models/user.py new file mode 100644 index 0000000..626a2e0 --- /dev/null +++ b/backend/app/models/user.py @@ -0,0 +1,15 @@ +from sqlalchemy import Column, Integer, String, Boolean, DateTime +from sqlalchemy.sql import func +from app.database import Base + + +class User(Base): + __tablename__ = "users" + + id = Column(Integer, primary_key=True, index=True) + username = Column(String(50), unique=True, index=True, nullable=False) + email = Column(String(120), unique=True, index=True, nullable=False) + hashed_password = Column(String(255), nullable=False) + is_active = Column(Boolean, default=True) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + updated_at = Column(DateTime(timezone=True), onupdate=func.now()) diff --git a/backend/app/models/world.py b/backend/app/models/world.py new file mode 100644 index 0000000..97e37fc --- /dev/null +++ b/backend/app/models/world.py @@ -0,0 +1,27 @@ +from sqlalchemy import Column, Integer, String, Text, DateTime, ForeignKey, Enum +from sqlalchemy.orm import relationship +from sqlalchemy.sql import func +import enum + +from app.database import Base + + +class WorldVisibility(enum.Enum): + PUBLIC = "public" + PRIVATE = "private" + FRIENDS_ONLY = "friends_only" + + +class World(Base): + __tablename__ = "worlds" + + id = Column(Integer, primary_key=True, index=True) + name = Column(String(255), nullable=False) + description = Column(Text, nullable=True) + owner_id = Column(Integer, ForeignKey("users.id"), nullable=False) + visibility = Column(Enum(WorldVisibility), default=WorldVisibility.PUBLIC) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + updated_at = Column(DateTime(timezone=True), onupdate=func.now()) + + owner = relationship("User", back_populates="worlds") + assets = relationship("Asset", back_populates="world", cascade="all, delete-orphan") From fa6e9064dcbcb833f620ddeb3a340718167e0c00 Mon Sep 17 00:00:00 2001 From: agent-bot Date: Mon, 17 Aug 2026 16:58:02 +0000 Subject: [PATCH 06/27] Recovered orphaned WIP before switching to a new task --- backend/app/routers/__init__.py | 0 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 backend/app/routers/__init__.py diff --git a/backend/app/routers/__init__.py b/backend/app/routers/__init__.py new file mode 100644 index 0000000..e69de29 From f659fd4d2a89ddfe792ab2a19a35f41254581f7f Mon Sep 17 00:00:00 2001 From: agent-bot Date: Mon, 17 Aug 2026 17:47:29 +0000 Subject: [PATCH 07/27] Recovered orphaned WIP before switching to a new task --- backend/app/models/user.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/backend/app/models/user.py b/backend/app/models/user.py index 626a2e0..ff48caa 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -1,5 +1,7 @@ from sqlalchemy import Column, Integer, String, Boolean, DateTime +from sqlalchemy.orm import relationship from sqlalchemy.sql import func + from app.database import Base @@ -13,3 +15,7 @@ class User(Base): is_active = Column(Boolean, default=True) created_at = Column(DateTime(timezone=True), server_default=func.now()) updated_at = Column(DateTime(timezone=True), onupdate=func.now()) + + sessions = relationship("Session", back_populates="user", cascade="all, delete-orphan") + worlds = relationship("World", back_populates="owner") + assets = relationship("Asset", back_populates="owner") From 5837fad5df685ef60512f5c423ce6e21ec777dc5 Mon Sep 17 00:00:00 2001 From: architect-bot Date: Mon, 17 Aug 2026 18:02:48 +0000 Subject: [PATCH 08/27] WIP checkpoint: Scaffold FastAPI backend project structure --- backend/app/config.py | 24 ++++++++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/backend/app/config.py b/backend/app/config.py index 35109a7..6687b03 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -1,32 +1,48 @@ +"""Application configuration via pydantic-settings.""" + from pydantic_settings import BaseSettings from functools import lru_cache class Settings(BaseSettings): - # Database + # App + APP_NAME: str = "AGP Backend" + APP_VERSION: str = "0.1.0" + DEBUG: bool = True + + # Database (async) DATABASE_URL: str = "postgresql+asyncpg://agp_user:agp_password@localhost:5432/agp_db" # Redis REDIS_URL: str = "redis://localhost:6379/0" - # JWT + # JWT / Auth SECRET_KEY: str = "change-me-to-a-secure-random-string" ALGORITHM: str = "HS256" ACCESS_TOKEN_EXPIRE_MINUTES: int = 30 - # App - DEBUG: bool = True + # CORS CORS_ORIGINS: str = "http://localhost:8080,http://localhost:3000" + # File uploads + UPLOAD_DIR: str = "uploads" + MAX_UPLOAD_SIZE_MB: int = 100 + @property def cors_origins_list(self) -> list[str]: return [origin.strip() for origin in self.CORS_ORIGINS.split(",")] class Config: env_file = ".env" + env_file_encoding = "utf-8" case_sensitive = True @lru_cache() def get_settings() -> Settings: + """Return cached settings instance.""" return Settings() + + +# Convenience singleton +settings = get_settings() From f3c679faabdb397d4248a0e40711ac12e873c99a Mon Sep 17 00:00:00 2001 From: coder-bot Date: Mon, 17 Aug 2026 18:16:20 +0000 Subject: [PATCH 09/27] WIP checkpoint: Scaffold FastAPI backend project structure --- backend/app/routers/assets.py | 180 ++++++++++++++++++++++++++++++++++ backend/app/routers/auth.py | 134 +++++++++++++++++++++++++ backend/app/routers/users.py | 126 ++++++++++++++++++++++++ backend/app/routers/worlds.py | 151 ++++++++++++++++++++++++++++ 4 files changed, 591 insertions(+) create mode 100644 backend/app/routers/assets.py create mode 100644 backend/app/routers/auth.py create mode 100644 backend/app/routers/users.py create mode 100644 backend/app/routers/worlds.py diff --git a/backend/app/routers/assets.py b/backend/app/routers/assets.py new file mode 100644 index 0000000..a3a4032 --- /dev/null +++ b/backend/app/routers/assets.py @@ -0,0 +1,180 @@ +"""Asset management router.""" + +from typing import Optional + +from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Query +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload + +from app.database import get_db +from app.models.asset import Asset, AssetType +from app.models.user import User +from app.models.world import World +from pydantic import BaseModel, Field + +router = APIRouter() + + +# --- Pydantic schemas --- + +class AssetCreate(BaseModel): + """Schema for creating an asset reference.""" + name: str = Field(..., min_length=1, max_length=255) + description: Optional[str] = None + asset_type: AssetType + world_id: Optional[int] = None + + +class AssetResponse(BaseModel): + """Schema for asset responses.""" + id: int + name: str + description: Optional[str] + asset_type: AssetType + file_path: str + file_size: Optional[int] + mime_type: Optional[str] + world_id: Optional[int] + owner_id: int + is_public: bool + created_at: str + updated_at: str + + class Config: + from_attributes = True + + +class AssetUpdate(BaseModel): + """Schema for updating an asset.""" + name: Optional[str] = None + description: Optional[str] = None + is_public: Optional[bool] = None + + +# --- Endpoints --- + +@router.post("/", response_model=AssetResponse, status_code=201) +async def create_asset( + payload: AssetCreate, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """Create a new asset reference.""" + # Verify world ownership if world_id is provided + if payload.world_id is not None: + world = await db.get(World, payload.world_id) + if world is None: + raise HTTPException(status_code=404, detail="World not found") + if world.owner_id != current_user.id: + raise HTTPException(status_code=403, detail="Not owner of this world") + + asset = Asset( + name=payload.name, + description=payload.description, + asset_type=payload.asset_type, + file_path="", # Will be set after upload + owner_id=current_user.id, + world_id=payload.world_id, + is_public=False, + ) + db.add(asset) + await db.commit() + await db.refresh(asset) + return asset + + +@router.get("/", response_model=list[AssetResponse]) +async def list_assets( + world_id: Optional[int] = Query(None, description="Filter by world"), + asset_type: Optional[AssetType] = Query(None), + limit: int = Query(50, ge=1, le=100), + offset: int = Query(0, ge=0), + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """List assets with optional filters.""" + stmt = select(Asset).where(Asset.owner_id == current_user.id) + + if world_id is not None: + stmt = stmt.where(Asset.world_id == world_id) + if asset_type is not None: + stmt = stmt.where(Asset.asset_type == asset_type) + + stmt = stmt.order_by(Asset.created_at.desc()).limit(limit).offset(offset) + result = await db.execute(stmt) + assets = result.scalars().all() + return assets + + +@router.get("/{asset_id}", response_model=AssetResponse) +async def get_asset( + asset_id: int, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """Get a single asset by ID.""" + asset = await db.get(Asset, asset_id) + if asset is None or asset.owner_id != current_user.id: + raise HTTPException(status_code=404, detail="Asset not found") + return asset + + +@router.patch("/{asset_id}", response_model=AssetResponse) +async def update_asset( + asset_id: int, + payload: AssetUpdate, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """Update an asset.""" + asset = await db.get(Asset, asset_id) + if asset is None or asset.owner_id != current_user.id: + raise HTTPException(status_code=404, detail="Asset not found") + + update_data = payload.model_dump(exclude_unset=True) + for field, value in update_data.items(): + setattr(asset, field, value) + + await db.commit() + await db.refresh(asset) + return asset + + +@router.delete("/{asset_id}", status_code=204) +async def delete_asset( + asset_id: int, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """Delete an asset.""" + asset = await db.get(Asset, asset_id) + if asset is None or asset.owner_id != current_user.id: + raise HTTPException(status_code=404, detail="Asset not found") + + await db.delete(asset) + await db.commit() + + +@router.post("/{asset_id}/upload") +async def upload_asset_file( + asset_id: int, + file: UploadFile = File(...), + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """Upload a file for an existing asset.""" + asset = await db.get(Asset, asset_id) + if asset is None or asset.owner_id != current_user.id: + raise HTTPException(status_code=404, detail="Asset not found") + + # TODO: Implement actual file upload logic (S3, local filesystem, etc.) + file_path = f"uploads/{asset_id}_{file.filename}" + asset.file_path = file_path + asset.mime_type = file.content_type + asset.file_size = 0 # Will be updated after actual write + + await db.commit() + await db.refresh(asset) + + return {"message": "File upload endpoint", "asset_id": asset_id, "file_path": file_path} diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py new file mode 100644 index 0000000..49b22a5 --- /dev/null +++ b/backend/app/routers/auth.py @@ -0,0 +1,134 @@ +"""Authentication router - login, register, token refresh.""" + +from datetime import datetime, timedelta, timezone + +from fastapi import APIRouter, Depends, HTTPException, status +from fastapi.security import OAuth2PasswordRequestForm +from pydantic import BaseModel, EmailStr + +from jose import JWTError, jwt +from passlib.context import CryptContext + +from app.config import settings +from app.database import get_db +from app.models.user import User +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select + +pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") + +router = APIRouter() + + +# --- Pydantic schemas --- + +class TokenResponse(BaseModel): + access_token: str + token_type: str = "bearer" + + +class RegisterRequest(BaseModel): + username: str + email: EmailStr + password: str + + +class LoginRequest(BaseModel): + username: str + password: str + + +# --- Helpers --- + +def _create_access_token(data: dict) -> str: + 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.SECRET_KEY, algorithm=settings.ALGORITHM) + + +def _verify_password(plain_password: str, hashed_password: str) -> bool: + return pwd_context.verify(plain_password, hashed_password) + + +def _get_password_hash(password: str) -> str: + return pwd_context.hash(password) + + +# --- Endpoints --- + +@router.post("/register", response_model=TokenResponse, status_code=status.HTTP_201_CREATED) +async def register(req: RegisterRequest, db: AsyncSession = Depends(get_db)): + """Register a new user and return an access token.""" + # Check if username already exists + result = await db.execute(select(User).where(User.username == req.username)) + if result.scalar_one_or_none(): + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="Username already registered", + ) + + # Check if email already exists + result = await db.execute(select(User).where(User.email == req.email)) + if result.scalar_one_or_none(): + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="Email already registered", + ) + + user = User( + username=req.username, + email=req.email, + hashed_password=_get_password_hash(req.password), + ) + db.add(user) + await db.commit() + await db.refresh(user) + + access_token = _create_access_token({"sub": str(user.id)}) + return TokenResponse(access_token=access_token) + + +@router.post("/login", response_model=TokenResponse) +async def login(req: LoginRequest, db: AsyncSession = Depends(get_db)): + """Authenticate user and return an access token.""" + result = await db.execute(select(User).where(User.username == req.username)) + user = result.scalar_one_or_none() + + if not user or not _verify_password(req.password, user.hashed_password): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Incorrect username or password", + ) + + if not user.is_active: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="User account is inactive", + ) + + access_token = _create_access_token({"sub": str(user.id)}) + return TokenResponse(access_token=access_token) + + +@router.get("/me") +async def get_current_user( + # In production, extract from Authorization header via OAuth2PasswordBearer + # For now, accept a user_id query param for simplicity + user_id: int, + db: AsyncSession = Depends(get_db), +): + """Get current user profile.""" + result = await db.execute(select(User).where(User.id == user_id)) + user = result.scalar_one_or_none() + if not user: + raise HTTPException(status_code=404, detail="User not found") + return { + "id": user.id, + "username": user.username, + "email": user.email, + "is_active": user.is_active, + "created_at": user.created_at, + } diff --git a/backend/app/routers/users.py b/backend/app/routers/users.py new file mode 100644 index 0000000..ccdbc35 --- /dev/null +++ b/backend/app/routers/users.py @@ -0,0 +1,126 @@ +"""User management router.""" + +from typing import Annotated + +from fastapi import APIRouter, Depends, HTTPException, status +from pydantic import BaseModel, EmailStr +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.database import get_db +from app.models.user import User + +router = APIRouter() + + +# ── Pydantic schemas ──────────────────────────────────────────────── + +class UserCreate(BaseModel): + username: str + email: EmailStr + password: str + + +class UserResponse(BaseModel): + id: int + username: str + email: str + is_active: bool + + model_config = {"from_attributes": True} + + +class UserUpdate(BaseModel): + email: EmailStr | None = None + is_active: bool | None = None + + +# ── Endpoints ──────────────────────────────────────────────────────── + +@router.post( + "/", + response_model=UserResponse, + status_code=status.HTTP_201_CREATED, +) +async def create_user(user_in: UserCreate, db: Annotated[AsyncSession, Depends(get_db)]): + """Register a new user.""" + # Check uniqueness of username + existing = await db.execute(select(User).where(User.username == user_in.username)) + if existing.scalar_one_or_none(): + raise HTTPException(status_code=400, detail="Username already taken") + + # Check uniqueness of email + existing = await db.execute(select(User).where(User.email == user_in.email)) + if existing.scalar_one_or_none(): + raise HTTPException(status_code=400, detail="Email already registered") + + from app.routers.auth import hash_password + hashed = hash_password(user_in.password) + + user = User( + username=user_in.username, + email=user_in.email, + hashed_password=hashed, + ) + db.add(user) + await db.commit() + await db.refresh(user) + return user + + +@router.get("/me", response_model=UserResponse) +async def get_current_user( + db: Annotated[AsyncSession, Depends(get_db)], + # current_user would come from JWT dependency in real usage +): + """Get the currently authenticated user (placeholder).""" + raise HTTPException(status_code=501, detail="Not implemented") + + +@router.get("/{user_id}", response_model=UserResponse) +async def get_user( + user_id: int, + db: Annotated[AsyncSession, Depends(get_db)], +): + """Get a user by ID.""" + result = await db.execute(select(User).where(User.id == user_id)) + user = result.scalar_one_or_none() + if not user: + raise HTTPException(status_code=404, detail="User not found") + return user + + +@router.patch("/{user_id}", response_model=UserResponse) +async def update_user( + user_id: int, + user_update: UserUpdate, + db: Annotated[AsyncSession, Depends(get_db)], +): + """Update a user.""" + result = await db.execute(select(User).where(User.id == user_id)) + user = result.scalar_one_or_none() + if not user: + raise HTTPException(status_code=404, detail="User not found") + + if user_update.email is not None: + user.email = user_update.email + if user_update.is_active is not None: + user.is_active = user_update.is_active + + await db.commit() + await db.refresh(user) + return user + + +@router.delete("/{user_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_user( + user_id: int, + db: Annotated[AsyncSession, Depends(get_db)], +): + """Delete a user.""" + result = await db.execute(select(User).where(User.id == user_id)) + user = result.scalar_one_or_none() + if not user: + raise HTTPException(status_code=404, detail="User not found") + await db.delete(user) + await db.commit() diff --git a/backend/app/routers/worlds.py b/backend/app/routers/worlds.py new file mode 100644 index 0000000..43d4209 --- /dev/null +++ b/backend/app/routers/worlds.py @@ -0,0 +1,151 @@ +"""Worlds router - CRUD for game worlds.""" + +from typing import Annotated + +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload + +from app.database import get_db +from app.models.world import World, WorldVisibility + +router = APIRouter() + + +async def get_current_user(): + """Placeholder dependency for current user auth.""" + # TODO: Implement JWT auth dependency + return {"id": 1, "username": "testuser", "email": "test@example.com"} + + +@router.get("/") +async def list_worlds( + skip: int = Query(0, ge=0), + limit: int = Query(50, ge=1, le=100), + visibility: WorldVisibility | None = None, + db: Annotated[AsyncSession, Depends(get_db)] = Depends(), +): + """List worlds with optional visibility filter.""" + query = select(World).options(selectinload(World.owner)) + if visibility: + query = query.where(World.visibility == visibility) + query = query.offset(skip).limit(limit).order_by(World.created_at.desc()) + result = await db.execute(query) + worlds = result.scalars().all() + return [ + { + "id": w.id, + "name": w.name, + "description": w.description, + "visibility": w.visibility.value if w.visibility else None, + "owner_id": w.owner_id, + "created_at": w.created_at.isoformat() if w.created_at else None, + } + for w in worlds + ] + + +@router.get("/{world_id}") +async def get_world( + world_id: int, + db: Annotated[AsyncSession, Depends(get_db)] = Depends(), +): + """Get a single world by ID.""" + result = await db.execute( + select(World).where(World.id == world_id).options(selectinload(World.owner)) + ) + world = result.scalar_one_or_none() + if not world: + raise HTTPException(status_code=404, detail="World not found") + return { + "id": world.id, + "name": world.name, + "description": world.description, + "visibility": world.visibility.value if world.visibility else None, + "owner_id": world.owner_id, + "created_at": world.created_at.isoformat() if world.created_at else None, + } + + +@router.post("/") +async def create_world( + world_data: dict, + current_user=Depends(get_current_user), + db: Annotated[AsyncSession, Depends(get_db)] = Depends(), +): + """Create a new world.""" + name = world_data.get("name") + if not name: + raise HTTPException(status_code=400, detail="World name is required") + + world = World( + name=name, + description=world_data.get("description"), + owner_id=current_user["id"], + visibility=WorldVisibility( + world_data.get("visibility", "public") + ), + ) + db.add(world) + await db.commit() + await db.refresh(world) + + return { + "id": world.id, + "name": world.name, + "description": world.description, + "visibility": world.visibility.value, + "owner_id": world.owner_id, + "created_at": world.created_at.isoformat() if world.created_at else None, + } + + +@router.put("/{world_id}") +async def update_world( + world_id: int, + world_data: dict, + current_user=Depends(get_current_user), + db: Annotated[AsyncSession, Depends(get_db)] = Depends(), +): + """Update an existing world.""" + result = await db.execute(select(World).where(World.id == world_id)) + world = result.scalar_one_or_none() + if not world: + raise HTTPException(status_code=404, detail="World not found") + + if world_data.get("name"): + world.name = world_data["name"] + if world_data.get("description") is not None: + world.description = world_data["description"] + if world_data.get("visibility"): + world.visibility = WorldVisibility(world_data["visibility"]) + + await db.commit() + await db.refresh(world) + + return { + "id": world.id, + "name": world.name, + "description": world.description, + "visibility": world.visibility.value if world.visibility else None, + "owner_id": world.owner_id, + "created_at": world.created_at.isoformat() if world.created_at else None, + } + + +@router.delete("/{world_id}") +async def delete_world( + world_id: int, + current_user=Depends(get_current_user), + db: Annotated[AsyncSession, Depends(get_db)] = Depends(), +): + """Delete a world.""" + result = await db.execute(select(World).where(World.id == world_id)) + world = result.scalar_one_or_none() + if not world: + raise HTTPException(status_code=404, detail="World not found") + + await db.delete(world) + await db.commit() + return {"detail": "World deleted"} From 46a2ff672cd8207b05cf2a55de56bf8ef93a8526 Mon Sep 17 00:00:00 2001 From: coder-bot Date: Mon, 17 Aug 2026 18:27:26 +0000 Subject: [PATCH 10/27] WIP checkpoint: Scaffold FastAPI backend project structure --- backend/app/routers/assets.py | 1 + backend/app/routers/auth.py | 12 ++++++------ backend/app/routers/users.py | 14 +++++++++----- 3 files changed, 16 insertions(+), 11 deletions(-) diff --git a/backend/app/routers/assets.py b/backend/app/routers/assets.py index a3a4032..985e11c 100644 --- a/backend/app/routers/assets.py +++ b/backend/app/routers/assets.py @@ -11,6 +11,7 @@ from app.database import get_db from app.models.asset import Asset, AssetType from app.models.user import User from app.models.world import World +from app.routers.worlds import get_current_user from pydantic import BaseModel, Field router = APIRouter() diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py index 49b22a5..29e104d 100644 --- a/backend/app/routers/auth.py +++ b/backend/app/routers/auth.py @@ -49,11 +49,13 @@ def _create_access_token(data: dict) -> str: return jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM) -def _verify_password(plain_password: str, hashed_password: str) -> bool: +def verify_password(plain_password: str, hashed_password: str) -> bool: + """Verify a plain password against a hash.""" return pwd_context.verify(plain_password, hashed_password) -def _get_password_hash(password: str) -> str: +def hash_password(password: str) -> str: + """Hash a password.""" return pwd_context.hash(password) @@ -81,7 +83,7 @@ async def register(req: RegisterRequest, db: AsyncSession = Depends(get_db)): user = User( username=req.username, email=req.email, - hashed_password=_get_password_hash(req.password), + hashed_password=hash_password(req.password), ) db.add(user) await db.commit() @@ -97,7 +99,7 @@ async def login(req: LoginRequest, db: AsyncSession = Depends(get_db)): result = await db.execute(select(User).where(User.username == req.username)) user = result.scalar_one_or_none() - if not user or not _verify_password(req.password, user.hashed_password): + if not user or not verify_password(req.password, user.hashed_password): raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Incorrect username or password", @@ -115,8 +117,6 @@ async def login(req: LoginRequest, db: AsyncSession = Depends(get_db)): @router.get("/me") async def get_current_user( - # In production, extract from Authorization header via OAuth2PasswordBearer - # For now, accept a user_id query param for simplicity user_id: int, db: AsyncSession = Depends(get_db), ): diff --git a/backend/app/routers/users.py b/backend/app/routers/users.py index ccdbc35..df7805c 100644 --- a/backend/app/routers/users.py +++ b/backend/app/routers/users.py @@ -13,7 +13,7 @@ from app.models.user import User router = APIRouter() -# ── Pydantic schemas ──────────────────────────────────────────────── +# ── Pydantic schemas ────────────────────────────────────────────────────────── class UserCreate(BaseModel): username: str @@ -35,14 +35,17 @@ class UserUpdate(BaseModel): is_active: bool | None = None -# ── Endpoints ──────────────────────────────────────────────────────── +# ── Endpoints ───────────────────────────────────────────────────────────────── @router.post( "/", response_model=UserResponse, status_code=status.HTTP_201_CREATED, ) -async def create_user(user_in: UserCreate, db: Annotated[AsyncSession, Depends(get_db)]): +async def create_user( + user_in: UserCreate, + db: Annotated[AsyncSession, Depends(get_db)], +): """Register a new user.""" # Check uniqueness of username existing = await db.execute(select(User).where(User.username == user_in.username)) @@ -54,8 +57,9 @@ async def create_user(user_in: UserCreate, db: Annotated[AsyncSession, Depends(g if existing.scalar_one_or_none(): raise HTTPException(status_code=400, detail="Email already registered") - from app.routers.auth import hash_password - hashed = hash_password(user_in.password) + from app.routers.auth import _get_password_hash + + hashed = _get_password_hash(user_in.password) user = User( username=user_in.username, From 7a6658798f6a8fdd005105107109d51c078cffae Mon Sep 17 00:00:00 2001 From: coder-bot Date: Mon, 17 Aug 2026 18:52:58 +0000 Subject: [PATCH 11/27] Recovered orphaned WIP before switching to a new task --- backend/app/routers/auth.py | 1 - 1 file changed, 1 deletion(-) diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py index 29e104d..252b98d 100644 --- a/backend/app/routers/auth.py +++ b/backend/app/routers/auth.py @@ -3,7 +3,6 @@ from datetime import datetime, timedelta, timezone from fastapi import APIRouter, Depends, HTTPException, status -from fastapi.security import OAuth2PasswordRequestForm from pydantic import BaseModel, EmailStr from jose import JWTError, jwt From 2b1786558ad2762ff58d67bcbd09e3cbd2d81694 Mon Sep 17 00:00:00 2001 From: coder-bot Date: Mon, 17 Aug 2026 19:02:25 +0000 Subject: [PATCH 12/27] Recovered orphaned WIP before switching to a new task --- backend/alembic.ini | 42 +++++++++++++++++++++++ backend/alembic/env.py | 65 ++++++++++++++++++++++++++++++++++++ backend/app/routers/users.py | 8 ++--- 3 files changed, 111 insertions(+), 4 deletions(-) create mode 100644 backend/alembic.ini create mode 100644 backend/alembic/env.py diff --git a/backend/alembic.ini b/backend/alembic.ini new file mode 100644 index 0000000..47488b0 --- /dev/null +++ b/backend/alembic.ini @@ -0,0 +1,42 @@ +# Alembic configuration file. +# Run: alembic upgrade head (to apply migrations) +# Run: alembic revision --autogenerate -m "description" (to generate migration) + +[alembic] +script_location = alembic +prepend_sys_path = . +sqlalchemy.url = postgresql+asyncpg://agp_user:agp_password@localhost:5432/agp_db + +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/backend/alembic/env.py b/backend/alembic/env.py new file mode 100644 index 0000000..d9411ca --- /dev/null +++ b/backend/alembic/env.py @@ -0,0 +1,65 @@ +"""Alembic environment configuration.""" + +import asyncio +from logging.config import fileConfig + +from alembic import context +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 + +# this is the Alembic Config object, which provides +# access to the values within the .ini file in use. +config = context.config + +# Interpret the config file for Python logging. +# This line sets up loggers basically. +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +# add your model's MetaData object here +# for 'autogenerate' support +target_metadata = Base.metadata + + +def run_migrations_offline() -> None: + """Run migrations in 'offline' mode.""" + url = settings.DATABASE_URL + context.configure( + url=url, + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + ) + + with context.begin_transaction(): + context.run_migrations() + + +def do_run_migrations(connection): + context.configure(connection=connection, target_metadata=target_metadata) + with context.begin_transaction(): + context.run_migrations() + + +async def run_async_migrations(): + """Run migrations in 'online' mode with async engine.""" + connectable = create_async_engine(settings.DATABASE_URL, echo=False) + + async with connectable.connect() as connection: + await connection.run_sync(do_run_migrations) + + await connectable.dispose() + + +def run_migrations_online() -> None: + """Run migrations in 'online' mode.""" + asyncio.run(run_async_migrations()) + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/backend/app/routers/users.py b/backend/app/routers/users.py index df7805c..6dc6a9d 100644 --- a/backend/app/routers/users.py +++ b/backend/app/routers/users.py @@ -13,7 +13,7 @@ from app.models.user import User router = APIRouter() -# ── Pydantic schemas ────────────────────────────────────────────────────────── +# ── Pydantic schemas ────────────────────────────────────────────────────── class UserCreate(BaseModel): username: str @@ -35,7 +35,7 @@ class UserUpdate(BaseModel): is_active: bool | None = None -# ── Endpoints ───────────────────────────────────────────────────────────────── +# ── Endpoints ───────────────────────────────────────────────────────────── @router.post( "/", @@ -57,9 +57,9 @@ async def create_user( if existing.scalar_one_or_none(): raise HTTPException(status_code=400, detail="Email already registered") - from app.routers.auth import _get_password_hash + from app.routers.auth import hash_password - hashed = _get_password_hash(user_in.password) + hashed = hash_password(user_in.password) user = User( username=user_in.username, From 4df839ccd8881225a8669f243c5b2d505fee5a6f Mon Sep 17 00:00:00 2001 From: coder-bot Date: Mon, 17 Aug 2026 19:20:09 +0000 Subject: [PATCH 13/27] WIP checkpoint: Scaffold FastAPI backend project structure --- backend/alembic/script.py.mako | 27 +++++++++++++++++++++++++++ backend/docker-compose.yml | 34 ++++++++++++++++++++++++++++++++++ 2 files changed, 61 insertions(+) create mode 100644 backend/alembic/script.py.mako create mode 100644 backend/docker-compose.yml diff --git a/backend/alembic/script.py.mako b/backend/alembic/script.py.mako new file mode 100644 index 0000000..d5a44c8 --- /dev/null +++ b/backend/alembic/script.py.mako @@ -0,0 +1,27 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision: str = ${repr(up_revision)} +down_revision: Union[str, None] = ${repr(down_revision)} +branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} +depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} + + +def upgrade() -> None: + """Upgrade database schema.""" + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + """Downgrade database schema.""" + ${downgrades if downgrades else "pass"} diff --git a/backend/docker-compose.yml b/backend/docker-compose.yml new file mode 100644 index 0000000..c9698e7 --- /dev/null +++ b/backend/docker-compose.yml @@ -0,0 +1,34 @@ +version: "3.9" + +services: + postgres: + image: postgres:16-alpine + environment: + POSTGRES_USER: agp_user + POSTGRES_PASSWORD: agp_password + POSTGRES_DB: agp_db + ports: + - "5432:5432" + volumes: + - postgres_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U agp_user -d agp_db"] + interval: 5s + timeout: 5s + retries: 5 + + redis: + image: redis:7-alpine + ports: + - "6379:6379" + volumes: + - redis_data:/data + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 5s + timeout: 5s + retries: 5 + +volumes: + postgres_data: + redis_data: From b51c90c360e8b2ca1a816b5c76d23a4b70f56304 Mon Sep 17 00:00:00 2001 From: coder-bot Date: Mon, 17 Aug 2026 19:58:57 +0000 Subject: [PATCH 14/27] WIP checkpoint: Scaffold FastAPI backend project structure --- backend/app/models/session.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/app/models/session.py b/backend/app/models/session.py index 6f94edb..c797bde 100644 --- a/backend/app/models/session.py +++ b/backend/app/models/session.py @@ -8,7 +8,7 @@ class Session(Base): __tablename__ = "sessions" id = Column(String, primary_key=True) - user_id = Column(String, ForeignKey("users.id", ondelete="CASCADE"), nullable=False) + user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False) token = Column(String, unique=True, index=True, nullable=False) expires_at = Column(DateTime(timezone=True), nullable=False) is_active = Column(Boolean, default=True) From 33d2ef7f8ac02d2bea5c65271c1c8cac0db4867a Mon Sep 17 00:00:00 2001 From: coder-bot Date: Mon, 17 Aug 2026 20:18:32 +0000 Subject: [PATCH 15/27] WIP checkpoint: Scaffold FastAPI backend project structure --- backend/app/database.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/app/database.py b/backend/app/database.py index 1002c8d..095cf72 100644 --- a/backend/app/database.py +++ b/backend/app/database.py @@ -10,7 +10,7 @@ from sqlalchemy.orm import DeclarativeBase from app.config import settings engine = create_async_engine( - settings.database_url, + settings.DATABASE_URL, echo=settings.DEBUG, pool_size=10, max_overflow=20, From 295c44522c814314b85bbd159ba1de16d3ef67ba Mon Sep 17 00:00:00 2001 From: coder-bot Date: Mon, 17 Aug 2026 20:22:35 +0000 Subject: [PATCH 16/27] WIP checkpoint: Scaffold FastAPI backend project structure --- backend/app/models/session.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/app/models/session.py b/backend/app/models/session.py index c797bde..fa8bd07 100644 --- a/backend/app/models/session.py +++ b/backend/app/models/session.py @@ -1,4 +1,4 @@ -from sqlalchemy import Column, String, DateTime, ForeignKey, Boolean +from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, Boolean from sqlalchemy.orm import relationship from sqlalchemy.sql import func from app.database import Base From dc09f5ae3768efde0a44ad8033a2eb145e859c50 Mon Sep 17 00:00:00 2001 From: coder-bot Date: Mon, 17 Aug 2026 20:25:07 +0000 Subject: [PATCH 17/27] WIP checkpoint: Scaffold FastAPI backend project structure --- backend/app/config.py | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/backend/app/config.py b/backend/app/config.py index 6687b03..a6e3f1f 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -1,8 +1,9 @@ """Application configuration via pydantic-settings.""" -from pydantic_settings import BaseSettings from functools import lru_cache +from pydantic_settings import BaseSettings, SettingsConfigDict + class Settings(BaseSettings): # App @@ -28,15 +29,17 @@ class Settings(BaseSettings): UPLOAD_DIR: str = "uploads" MAX_UPLOAD_SIZE_MB: int = 100 + model_config = SettingsConfigDict( + env_file=".env", + env_file_encoding="utf-8", + case_sensitive=True, + extra="ignore", + ) + @property def cors_origins_list(self) -> list[str]: return [origin.strip() for origin in self.CORS_ORIGINS.split(",")] - class Config: - env_file = ".env" - env_file_encoding = "utf-8" - case_sensitive = True - @lru_cache() def get_settings() -> Settings: From 534da614dd3ab499761c92805177964252b3b1e5 Mon Sep 17 00:00:00 2001 From: coder-bot Date: Mon, 17 Aug 2026 20:25:28 +0000 Subject: [PATCH 18/27] WIP checkpoint: Scaffold FastAPI backend project structure --- backend/.env.example | 10 ++++++++-- backend/app/config.py | 1 - backend/app/main.py | 16 +++++++++++++--- backend/app/models/session.py | 6 ++++-- backend/app/models/world.py | 8 ++++++-- 5 files changed, 31 insertions(+), 10 deletions(-) diff --git a/backend/.env.example b/backend/.env.example index 508ec96..cc0bd94 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -1,5 +1,5 @@ -# Database -DATABASE_URL=postgresql://agp_user:agp_password@localhost:5432/agp_db +# Database (async driver) +DATABASE_URL=postgresql+asyncpg://agp_user:agp_password@localhost:5432/agp_db # Redis REDIS_URL=redis://localhost:6379/0 @@ -10,5 +10,11 @@ ALGORITHM=HS256 ACCESS_TOKEN_EXPIRE_MINUTES=30 # App +APP_NAME=AGP Backend +APP_VERSION=0.1.0 DEBUG=true CORS_ORIGINS=http://localhost:8080,http://localhost:3000 + +# File uploads +UPLOAD_DIR=uploads +MAX_UPLOAD_SIZE_MB=100 diff --git a/backend/app/config.py b/backend/app/config.py index a6e3f1f..81a3f6e 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -33,7 +33,6 @@ class Settings(BaseSettings): env_file=".env", env_file_encoding="utf-8", case_sensitive=True, - extra="ignore", ) @property diff --git a/backend/app/main.py b/backend/app/main.py index e3210e0..1260c12 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -3,6 +3,7 @@ from contextlib import asynccontextmanager from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware from app.config import settings from app.routers import auth, users, worlds, assets @@ -17,12 +18,21 @@ async def lifespan(app: FastAPI): app = FastAPI( - title=settings.app_name, - version=settings.app_version, + title=settings.APP_NAME, + version=settings.APP_VERSION, description="Agentic Game Platform - Backend API", lifespan=lifespan, ) +# CORS +app.add_middleware( + CORSMiddleware, + allow_origins=settings.cors_origins_list, + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + # Include routers app.include_router(auth.router, prefix="/api/auth", tags=["auth"]) app.include_router(users.router, prefix="/api/users", tags=["users"]) @@ -33,4 +43,4 @@ app.include_router(assets.router, prefix="/api/assets", tags=["assets"]) @app.get("/api/health") async def health_check(): """Simple health check endpoint.""" - return {"status": "ok", "app": settings.app_name} + return {"status": "ok", "app": settings.APP_NAME} diff --git a/backend/app/models/session.py b/backend/app/models/session.py index fa8bd07..9d804bf 100644 --- a/backend/app/models/session.py +++ b/backend/app/models/session.py @@ -1,4 +1,6 @@ -from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, Boolean +import uuid + +from sqlalchemy import Column, String, DateTime, ForeignKey, Boolean from sqlalchemy.orm import relationship from sqlalchemy.sql import func from app.database import Base @@ -7,7 +9,7 @@ from app.database import Base class Session(Base): __tablename__ = "sessions" - id = Column(String, primary_key=True) + id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4())) user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False) token = Column(String, unique=True, index=True, nullable=False) expires_at = Column(DateTime(timezone=True), nullable=False) diff --git a/backend/app/models/world.py b/backend/app/models/world.py index 97e37fc..d10e896 100644 --- a/backend/app/models/world.py +++ b/backend/app/models/world.py @@ -1,4 +1,5 @@ -from sqlalchemy import Column, Integer, String, Text, DateTime, ForeignKey, Enum +from sqlalchemy import Column, Integer, String, Text, DateTime, ForeignKey +from sqlalchemy.dialects.postgresql import ENUM from sqlalchemy.orm import relationship from sqlalchemy.sql import func import enum @@ -19,7 +20,10 @@ class World(Base): name = Column(String(255), nullable=False) description = Column(Text, nullable=True) owner_id = Column(Integer, ForeignKey("users.id"), nullable=False) - visibility = Column(Enum(WorldVisibility), default=WorldVisibility.PUBLIC) + visibility = Column( + ENUM(WorldVisibility, name="world_visibility", create_type=False), + default=WorldVisibility.PUBLIC, + ) created_at = Column(DateTime(timezone=True), server_default=func.now()) updated_at = Column(DateTime(timezone=True), onupdate=func.now()) From 2fe6d5dbf030124e1d0dc7b84976aee1d9cac8ce Mon Sep 17 00:00:00 2001 From: coder-bot Date: Mon, 17 Aug 2026 20:25:43 +0000 Subject: [PATCH 19/27] WIP checkpoint: Scaffold FastAPI backend project structure --- backend/app/dependencies.py | 50 +++++++++++++++++++++++++++++++++++ backend/app/models/asset.py | 10 ++++--- backend/app/models/session.py | 2 +- 3 files changed, 58 insertions(+), 4 deletions(-) create mode 100644 backend/app/dependencies.py diff --git a/backend/app/dependencies.py b/backend/app/dependencies.py new file mode 100644 index 0000000..89811b8 --- /dev/null +++ b/backend/app/dependencies.py @@ -0,0 +1,50 @@ +"""Shared FastAPI dependencies, including JWT-based auth.""" + +from typing import Annotated + +from fastapi import Depends, HTTPException, status +from fastapi.security import OAuth2PasswordBearer +from jose import JWTError, jwt +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.config import settings +from app.database import get_db +from app.models.user import User + +oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/login") + + +async def get_current_user( + token: Annotated[str, Depends(oauth2_scheme)], + db: Annotated[AsyncSession, Depends(get_db)], +) -> User: + """Resolve the JWT bearer token to a User, or raise 401.""" + credentials_exception = HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Could not validate credentials", + headers={"WWW-Authenticate": "Bearer"}, + ) + try: + payload = jwt.decode( + token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM] + ) + user_id: str | None = payload.get("sub") + if user_id is None: + raise credentials_exception + except JWTError: + raise credentials_exception + + result = await db.execute(select(User).where(User.id == int(user_id))) + user = result.scalar_one_or_none() + if user is None: + raise credentials_exception + if not user.is_active: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="User account is inactive", + ) + return user + + +CurrentUser = Annotated[User, Depends(get_current_user)] diff --git a/backend/app/models/asset.py b/backend/app/models/asset.py index ba27c53..2955515 100644 --- a/backend/app/models/asset.py +++ b/backend/app/models/asset.py @@ -1,4 +1,5 @@ -from sqlalchemy import Column, Integer, String, ForeignKey, Text, DateTime, Enum as SAEnum +from sqlalchemy import Column, Integer, String, ForeignKey, Text, DateTime, Boolean +from sqlalchemy.dialects.postgresql import ENUM from sqlalchemy.orm import relationship from sqlalchemy.sql import func import enum @@ -21,14 +22,17 @@ class Asset(Base): id = Column(Integer, primary_key=True, index=True) name = Column(String(255), nullable=False) description = Column(Text, nullable=True) - asset_type = Column(SAEnum(AssetType), nullable=False) + asset_type = Column( + ENUM(AssetType, name="asset_type", create_type=False), + nullable=False, + ) file_path = Column(String(512), nullable=False) file_size = Column(Integer, nullable=True) mime_type = Column(String(128), nullable=True) metadata_json = Column(Text, nullable=True) world_id = Column(Integer, ForeignKey("worlds.id"), nullable=True) owner_id = Column(Integer, ForeignKey("users.id"), nullable=False) - is_public = Column(Integer, default=0, nullable=False) + is_public = Column(Boolean, default=False, nullable=False) created_at = Column(DateTime(timezone=True), server_default=func.now()) updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) diff --git a/backend/app/models/session.py b/backend/app/models/session.py index 9d804bf..7a7d33a 100644 --- a/backend/app/models/session.py +++ b/backend/app/models/session.py @@ -1,6 +1,6 @@ import uuid -from sqlalchemy import Column, String, DateTime, ForeignKey, Boolean +from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, Boolean from sqlalchemy.orm import relationship from sqlalchemy.sql import func from app.database import Base From d7d77474f92e89b2c50413afa916ac1363dd81c9 Mon Sep 17 00:00:00 2001 From: coder-bot Date: Mon, 17 Aug 2026 20:26:26 +0000 Subject: [PATCH 20/27] WIP checkpoint: Scaffold FastAPI backend project structure --- backend/app/routers/assets.py | 16 +++++++--------- backend/app/routers/auth.py | 34 ++++++++++++++++------------------ backend/app/routers/worlds.py | 31 +++++++++++++++++-------------- 3 files changed, 40 insertions(+), 41 deletions(-) diff --git a/backend/app/routers/assets.py b/backend/app/routers/assets.py index 985e11c..e52e751 100644 --- a/backend/app/routers/assets.py +++ b/backend/app/routers/assets.py @@ -5,13 +5,11 @@ from typing import Optional from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Query from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy.orm import selectinload from app.database import get_db +from app.dependencies import CurrentUser from app.models.asset import Asset, AssetType -from app.models.user import User from app.models.world import World -from app.routers.worlds import get_current_user from pydantic import BaseModel, Field router = APIRouter() @@ -58,8 +56,8 @@ class AssetUpdate(BaseModel): @router.post("/", response_model=AssetResponse, status_code=201) async def create_asset( payload: AssetCreate, + current_user: CurrentUser, db: AsyncSession = Depends(get_db), - current_user: User = Depends(get_current_user), ): """Create a new asset reference.""" # Verify world ownership if world_id is provided @@ -87,12 +85,12 @@ async def create_asset( @router.get("/", response_model=list[AssetResponse]) async def list_assets( + current_user: CurrentUser, world_id: Optional[int] = Query(None, description="Filter by world"), asset_type: Optional[AssetType] = Query(None), limit: int = Query(50, ge=1, le=100), offset: int = Query(0, ge=0), db: AsyncSession = Depends(get_db), - current_user: User = Depends(get_current_user), ): """List assets with optional filters.""" stmt = select(Asset).where(Asset.owner_id == current_user.id) @@ -111,8 +109,8 @@ async def list_assets( @router.get("/{asset_id}", response_model=AssetResponse) async def get_asset( asset_id: int, + current_user: CurrentUser, db: AsyncSession = Depends(get_db), - current_user: User = Depends(get_current_user), ): """Get a single asset by ID.""" asset = await db.get(Asset, asset_id) @@ -125,8 +123,8 @@ async def get_asset( async def update_asset( asset_id: int, payload: AssetUpdate, + current_user: CurrentUser, db: AsyncSession = Depends(get_db), - current_user: User = Depends(get_current_user), ): """Update an asset.""" asset = await db.get(Asset, asset_id) @@ -145,8 +143,8 @@ async def update_asset( @router.delete("/{asset_id}", status_code=204) async def delete_asset( asset_id: int, + current_user: CurrentUser, db: AsyncSession = Depends(get_db), - current_user: User = Depends(get_current_user), ): """Delete an asset.""" asset = await db.get(Asset, asset_id) @@ -161,8 +159,8 @@ async def delete_asset( async def upload_asset_file( asset_id: int, file: UploadFile = File(...), + current_user: CurrentUser = None, db: AsyncSession = Depends(get_db), - current_user: User = Depends(get_current_user), ): """Upload a file for an existing asset.""" asset = await db.get(Asset, asset_id) diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py index 252b98d..5cf9157 100644 --- a/backend/app/routers/auth.py +++ b/backend/app/routers/auth.py @@ -5,11 +5,12 @@ from datetime import datetime, timedelta, timezone from fastapi import APIRouter, Depends, HTTPException, status from pydantic import BaseModel, EmailStr -from jose import JWTError, jwt +from jose import jwt from passlib.context import CryptContext from app.config import settings from app.database import get_db +from app.dependencies import CurrentUser from app.models.user import User from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select @@ -37,6 +38,16 @@ class LoginRequest(BaseModel): password: str +class UserResponse(BaseModel): + id: int + username: str + email: str + is_active: bool + created_at: datetime | None = None + + model_config = {"from_attributes": True} + + # --- Helpers --- def _create_access_token(data: dict) -> str: @@ -114,20 +125,7 @@ async def login(req: LoginRequest, db: AsyncSession = Depends(get_db)): return TokenResponse(access_token=access_token) -@router.get("/me") -async def get_current_user( - user_id: int, - db: AsyncSession = Depends(get_db), -): - """Get current user profile.""" - result = await db.execute(select(User).where(User.id == user_id)) - user = result.scalar_one_or_none() - if not user: - raise HTTPException(status_code=404, detail="User not found") - return { - "id": user.id, - "username": user.username, - "email": user.email, - "is_active": user.is_active, - "created_at": user.created_at, - } +@router.get("/me", response_model=UserResponse) +async def get_current_user(current_user: CurrentUser): + """Get current user profile from the JWT token.""" + return current_user diff --git a/backend/app/routers/worlds.py b/backend/app/routers/worlds.py index 43d4209..709574f 100644 --- a/backend/app/routers/worlds.py +++ b/backend/app/routers/worlds.py @@ -8,23 +8,20 @@ from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload from app.database import get_db +from app.dependencies import CurrentUser from app.models.world import World, WorldVisibility router = APIRouter() - -async def get_current_user(): - """Placeholder dependency for current user auth.""" - # TODO: Implement JWT auth dependency - return {"id": 1, "username": "testuser", "email": "test@example.com"} +DbDep = Annotated[AsyncSession, Depends(get_db)] @router.get("/") async def list_worlds( + db: DbDep, skip: int = Query(0, ge=0), limit: int = Query(50, ge=1, le=100), visibility: WorldVisibility | None = None, - db: Annotated[AsyncSession, Depends(get_db)] = Depends(), ): """List worlds with optional visibility filter.""" query = select(World).options(selectinload(World.owner)) @@ -49,7 +46,7 @@ async def list_worlds( @router.get("/{world_id}") async def get_world( world_id: int, - db: Annotated[AsyncSession, Depends(get_db)] = Depends(), + db: DbDep, ): """Get a single world by ID.""" result = await db.execute( @@ -71,8 +68,8 @@ async def get_world( @router.post("/") async def create_world( world_data: dict, - current_user=Depends(get_current_user), - db: Annotated[AsyncSession, Depends(get_db)] = Depends(), + current_user: CurrentUser, + db: DbDep, ): """Create a new world.""" name = world_data.get("name") @@ -82,7 +79,7 @@ async def create_world( world = World( name=name, description=world_data.get("description"), - owner_id=current_user["id"], + owner_id=current_user.id, visibility=WorldVisibility( world_data.get("visibility", "public") ), @@ -105,8 +102,8 @@ async def create_world( async def update_world( world_id: int, world_data: dict, - current_user=Depends(get_current_user), - db: Annotated[AsyncSession, Depends(get_db)] = Depends(), + current_user: CurrentUser, + db: DbDep, ): """Update an existing world.""" result = await db.execute(select(World).where(World.id == world_id)) @@ -114,6 +111,9 @@ async def update_world( if not world: raise HTTPException(status_code=404, detail="World not found") + if world.owner_id != current_user.id: + raise HTTPException(status_code=403, detail="Not owner of this world") + if world_data.get("name"): world.name = world_data["name"] if world_data.get("description") is not None: @@ -137,8 +137,8 @@ async def update_world( @router.delete("/{world_id}") async def delete_world( world_id: int, - current_user=Depends(get_current_user), - db: Annotated[AsyncSession, Depends(get_db)] = Depends(), + current_user: CurrentUser, + db: DbDep, ): """Delete a world.""" result = await db.execute(select(World).where(World.id == world_id)) @@ -146,6 +146,9 @@ async def delete_world( if not world: raise HTTPException(status_code=404, detail="World not found") + if world.owner_id != current_user.id: + raise HTTPException(status_code=403, detail="Not owner of this world") + await db.delete(world) await db.commit() return {"detail": "World deleted"} From 3f707bf6b09c549b46a0a70b7abb87ad710d33c6 Mon Sep 17 00:00:00 2001 From: coder-bot Date: Mon, 17 Aug 2026 20:42:41 +0000 Subject: [PATCH 21/27] WIP checkpoint: Scaffold FastAPI backend project structure --- backend/alembic/versions/0001_initial.py | 126 +++++++++++++++++++++++ 1 file changed, 126 insertions(+) create mode 100644 backend/alembic/versions/0001_initial.py diff --git a/backend/alembic/versions/0001_initial.py b/backend/alembic/versions/0001_initial.py new file mode 100644 index 0000000..a764b9a --- /dev/null +++ b/backend/alembic/versions/0001_initial.py @@ -0,0 +1,126 @@ +"""initial schema + +Revision ID: 0001_initial +Revises: +Create Date: 2024-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 = "0001_initial" +down_revision: Union[str, None] = None +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade database schema.""" + # Create ENUM types + world_visibility = postgresql.ENUM( + "public", "private", "friends_only", name="world_visibility" + ) + world_visibility.create(op.get_bind(), checkfirst=True) + + asset_type = postgresql.ENUM( + "image", "audio", "video", "document", "model_3d", "other", name="asset_type" + ) + asset_type.create(op.get_bind(), checkfirst=True) + + # Create users table + op.create_table( + "users", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("username", sa.String(length=50), nullable=False), + sa.Column("email", sa.String(length=120), nullable=False), + sa.Column("hashed_password", sa.String(length=255), nullable=False), + sa.Column("is_active", sa.Boolean(), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=True), + ) + op.create_index(op.f("ix_users_email"), "users", ["email"], unique=True) + op.create_index(op.f("ix_users_id"), "users", ["id"], unique=False) + op.create_index(op.f("ix_users_username"), "users", ["username"], unique=True) + + # Create sessions table + op.create_table( + "sessions", + sa.Column("id", sa.String(length=36), primary_key=True), + sa.Column("user_id", sa.Integer(), nullable=False), + sa.Column("token", sa.String(), nullable=False), + sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("is_active", sa.Boolean(), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()), + sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now()), + sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"), + ) + op.create_index(op.f("ix_sessions_token"), "sessions", ["token"], unique=True) + + # Create worlds table + op.create_table( + "worlds", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("name", sa.String(length=255), nullable=False), + sa.Column("description", sa.Text(), nullable=True), + sa.Column("owner_id", sa.Integer(), nullable=False), + sa.Column( + "visibility", + postgresql.ENUM( + "public", "private", "friends_only", name="world_visibility" + ), + nullable=True, + ), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=True), + sa.ForeignKeyConstraint(["owner_id"], ["users.id"]), + ) + op.create_index(op.f("ix_worlds_id"), "worlds", ["id"], unique=False) + + # Create assets table + op.create_table( + "assets", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("name", sa.String(length=255), nullable=False), + sa.Column("description", sa.Text(), nullable=True), + sa.Column( + "asset_type", + postgresql.ENUM( + "image", "audio", "video", "document", "model_3d", "other", + name="asset_type", + ), + nullable=False, + ), + sa.Column("file_path", sa.String(length=512), nullable=False), + sa.Column("file_size", sa.Integer(), nullable=True), + sa.Column("mime_type", sa.String(length=128), nullable=True), + sa.Column("metadata_json", sa.Text(), nullable=True), + sa.Column("world_id", sa.Integer(), nullable=True), + sa.Column("owner_id", sa.Integer(), nullable=False), + sa.Column("is_public", sa.Boolean(), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()), + sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now()), + sa.ForeignKeyConstraint(["owner_id"], ["users.id"]), + sa.ForeignKeyConstraint(["world_id"], ["worlds.id"]), + ) + op.create_index(op.f("ix_assets_id"), "assets", ["id"], unique=False) + + +def downgrade() -> None: + """Downgrade database schema.""" + op.drop_index(op.f("ix_assets_id"), table_name="assets") + op.drop_table("assets") + op.drop_index(op.f("ix_worlds_id"), table_name="worlds") + op.drop_table("worlds") + op.drop_index(op.f("ix_sessions_token"), table_name="sessions") + op.drop_table("sessions") + op.drop_index(op.f("ix_users_username"), table_name="users") + op.drop_index(op.f("ix_users_id"), table_name="users") + op.drop_index(op.f("ix_users_email"), table_name="users") + op.drop_table("users") + + # Drop ENUM types + postgresql.ENUM(name="asset_type").drop(op.get_bind(), checkfirst=True) + postgresql.ENUM(name="world_visibility").drop(op.get_bind(), checkfirst=True) From b547ca5785a72e9080ec79498f7c5e65f4b082d1 Mon Sep 17 00:00:00 2001 From: coder-bot Date: Mon, 17 Aug 2026 20:59:17 +0000 Subject: [PATCH 22/27] Add missing asyncpg dependency for async postgres driver config.py's DATABASE_URL uses postgresql+asyncpg but requirements.txt only listed the sync psycopg2-binary driver, causing ModuleNotFoundError on app startup. --- backend/requirements.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/backend/requirements.txt b/backend/requirements.txt index 040cea8..5cb3fb3 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -3,6 +3,7 @@ uvicorn[standard]==0.34.0 sqlalchemy==2.0.36 alembic==1.14.0 psycopg2-binary==2.9.10 +asyncpg==0.31.0 redis==5.2.1 pydantic==2.10.3 pydantic-settings==2.7.0 From 1f85ecc8125321f8cf11f0f1ccc088737b580240 Mon Sep 17 00:00:00 2001 From: coder-bot Date: Mon, 17 Aug 2026 23:31:01 +0000 Subject: [PATCH 23/27] WIP checkpoint: Address review feedback: Scaffold FastAPI backend project structure --- backend/app/database.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/backend/app/database.py b/backend/app/database.py index 095cf72..9d917b2 100644 --- a/backend/app/database.py +++ b/backend/app/database.py @@ -28,11 +28,15 @@ class Base(DeclarativeBase): async def get_db() -> AsyncSession: - """Dependency that yields an async database session.""" + """Dependency that yields an async database session. + + Handlers are responsible for committing or rolling back their own + transactions. This avoids the double-commit problem where the + session is committed both here and in the handler. + """ async with async_session_factory() as session: try: yield session - await session.commit() except Exception: await session.rollback() raise From 48dd177fa28b014d02539d36f66dc4bb0f4a10f7 Mon Sep 17 00:00:00 2001 From: coder-bot Date: Mon, 17 Aug 2026 23:36:31 +0000 Subject: [PATCH 24/27] WIP checkpoint: Address review feedback: Scaffold FastAPI backend project structure --- backend/app/routers/users.py | 77 ++++++++++++------------------------ 1 file changed, 26 insertions(+), 51 deletions(-) diff --git a/backend/app/routers/users.py b/backend/app/routers/users.py index 6dc6a9d..1ed37cd 100644 --- a/backend/app/routers/users.py +++ b/backend/app/routers/users.py @@ -8,18 +8,13 @@ from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.database import get_db +from app.dependencies import CurrentUser from app.models.user import User router = APIRouter() -# ── Pydantic schemas ────────────────────────────────────────────────────── - -class UserCreate(BaseModel): - username: str - email: EmailStr - password: str - +# ─── Pydantic schemas ──────────────────────────────────────────────────────── class UserResponse(BaseModel): id: int @@ -35,62 +30,33 @@ class UserUpdate(BaseModel): is_active: bool | None = None -# ── Endpoints ───────────────────────────────────────────────────────────── - -@router.post( - "/", - response_model=UserResponse, - status_code=status.HTTP_201_CREATED, -) -async def create_user( - user_in: UserCreate, - db: Annotated[AsyncSession, Depends(get_db)], -): - """Register a new user.""" - # Check uniqueness of username - existing = await db.execute(select(User).where(User.username == user_in.username)) - if existing.scalar_one_or_none(): - raise HTTPException(status_code=400, detail="Username already taken") - - # Check uniqueness of email - existing = await db.execute(select(User).where(User.email == user_in.email)) - if existing.scalar_one_or_none(): - raise HTTPException(status_code=400, detail="Email already registered") - - from app.routers.auth import hash_password - - hashed = hash_password(user_in.password) - - user = User( - username=user_in.username, - email=user_in.email, - hashed_password=hashed, - ) - db.add(user) - await db.commit() - await db.refresh(user) - return user - +# ─── Endpoints ──────────────────────────────────────────────────────────────── @router.get("/me", response_model=UserResponse) -async def get_current_user( - db: Annotated[AsyncSession, Depends(get_db)], - # current_user would come from JWT dependency in real usage +async def get_current_user_profile( + current_user: CurrentUser, ): - """Get the currently authenticated user (placeholder).""" - raise HTTPException(status_code=501, detail="Not implemented") + """Get the currently authenticated user's profile.""" + return current_user @router.get("/{user_id}", response_model=UserResponse) async def get_user( user_id: int, + current_user: CurrentUser, db: Annotated[AsyncSession, Depends(get_db)], ): - """Get a user by ID.""" + """Get a user by ID. Owners and admins can see any user; others see + only their own profile.""" result = await db.execute(select(User).where(User.id == user_id)) user = result.scalar_one_or_none() if not user: raise HTTPException(status_code=404, detail="User not found") + + # Allow viewing own profile or if user is active (admins always allowed) + if user.id != current_user.id and not current_user.is_active: + raise HTTPException(status_code=403, detail="Not authorized to view this user") + return user @@ -98,14 +64,18 @@ async def get_user( async def update_user( user_id: int, user_update: UserUpdate, + current_user: CurrentUser, db: Annotated[AsyncSession, Depends(get_db)], ): - """Update a user.""" + """Update a user. Only the owner (or an active admin) can update.""" result = await db.execute(select(User).where(User.id == user_id)) user = result.scalar_one_or_none() if not user: raise HTTPException(status_code=404, detail="User not found") + if user.id != current_user.id and not current_user.is_active: + raise HTTPException(status_code=403, detail="Not authorized to update this user") + if user_update.email is not None: user.email = user_update.email if user_update.is_active is not None: @@ -119,12 +89,17 @@ async def update_user( @router.delete("/{user_id}", status_code=status.HTTP_204_NO_CONTENT) async def delete_user( user_id: int, + current_user: CurrentUser, db: Annotated[AsyncSession, Depends(get_db)], ): - """Delete a user.""" + """Delete a user. Only the owner (or an active admin) can delete.""" result = await db.execute(select(User).where(User.id == user_id)) user = result.scalar_one_or_none() if not user: raise HTTPException(status_code=404, detail="User not found") + + if user.id != current_user.id and not current_user.is_active: + raise HTTPException(status_code=403, detail="Not authorized to delete this user") + await db.delete(user) await db.commit() From 48a43c44a59b57c8050d546eb7725bd5551f7988 Mon Sep 17 00:00:00 2001 From: coder-bot Date: Mon, 17 Aug 2026 23:38:24 +0000 Subject: [PATCH 25/27] WIP checkpoint: Address review feedback: Scaffold FastAPI backend project structure --- backend/app/config.py | 2 + backend/app/dependencies.py | 7 +- backend/app/routers/assets.py | 23 ++++-- backend/app/routers/auth.py | 18 ++--- backend/app/routers/worlds.py | 132 ++++++++++++++++++---------------- 5 files changed, 105 insertions(+), 77 deletions(-) diff --git a/backend/app/config.py b/backend/app/config.py index 81a3f6e..af23b68 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -18,6 +18,8 @@ class Settings(BaseSettings): REDIS_URL: str = "redis://localhost:6379/0" # JWT / Auth + # 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 diff --git a/backend/app/dependencies.py b/backend/app/dependencies.py index 89811b8..f388f5d 100644 --- a/backend/app/dependencies.py +++ b/backend/app/dependencies.py @@ -35,7 +35,12 @@ async def get_current_user( except JWTError: raise credentials_exception - result = await db.execute(select(User).where(User.id == int(user_id))) + try: + user_id_int = int(user_id) + except (ValueError, TypeError): + raise credentials_exception + + result = await db.execute(select(User).where(User.id == user_id_int)) user = result.scalar_one_or_none() if user is None: raise credentials_exception diff --git a/backend/app/routers/assets.py b/backend/app/routers/assets.py index e52e751..0d38860 100644 --- a/backend/app/routers/assets.py +++ b/backend/app/routers/assets.py @@ -6,6 +6,7 @@ from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Query 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.asset import Asset, AssetType @@ -40,8 +41,7 @@ class AssetResponse(BaseModel): created_at: str updated_at: str - class Config: - from_attributes = True + model_config = {"from_attributes": True} class AssetUpdate(BaseModel): @@ -167,11 +167,24 @@ async def upload_asset_file( if asset is None or asset.owner_id != current_user.id: raise HTTPException(status_code=404, detail="Asset not found") - # TODO: Implement actual file upload logic (S3, local filesystem, etc.) - file_path = f"uploads/{asset_id}_{file.filename}" + # Sanitize filename to prevent path traversal + safe_filename = file.filename.replace("/", "_").replace("\\", "_") if file.filename else f"asset_{asset_id}" + file_path = f"uploads/{asset_id}_{safe_filename}" + + # Validate file size against configured maximum + max_bytes = settings.MAX_UPLOAD_SIZE_MB * 1024 * 1024 + content = await file.read() + file_size = len(content) + if file_size > max_bytes: + raise HTTPException( + status_code=413, + detail=f"File size {file_size} exceeds maximum allowed size {max_bytes} bytes", + ) + + # TODO: Implement actual file write logic (S3, local filesystem, etc.) asset.file_path = file_path asset.mime_type = file.content_type - asset.file_size = 0 # Will be updated after actual write + asset.file_size = file_size await db.commit() await db.refresh(asset) diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py index 5cf9157..5059174 100644 --- a/backend/app/routers/auth.py +++ b/backend/app/routers/auth.py @@ -3,6 +3,7 @@ from datetime import datetime, timedelta, timezone from fastapi import APIRouter, Depends, HTTPException, status +from fastapi.security import OAuth2PasswordRequestForm from pydantic import BaseModel, EmailStr from jose import jwt @@ -33,11 +34,6 @@ class RegisterRequest(BaseModel): password: str -class LoginRequest(BaseModel): - username: str - password: str - - class UserResponse(BaseModel): id: int username: str @@ -104,12 +100,16 @@ async def register(req: RegisterRequest, db: AsyncSession = Depends(get_db)): @router.post("/login", response_model=TokenResponse) -async def login(req: LoginRequest, db: AsyncSession = Depends(get_db)): - """Authenticate user and return an access token.""" - result = await db.execute(select(User).where(User.username == req.username)) +async def login(form: OAuth2PasswordRequestForm = Depends(), db: AsyncSession = Depends(get_db)): + """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. + """ + result = await db.execute(select(User).where(User.username == form.username)) user = result.scalar_one_or_none() - if not user or not verify_password(req.password, user.hashed_password): + if not user or not verify_password(form.password, user.hashed_password): raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Incorrect username or password", diff --git a/backend/app/routers/worlds.py b/backend/app/routers/worlds.py index 709574f..f29a21c 100644 --- a/backend/app/routers/worlds.py +++ b/backend/app/routers/worlds.py @@ -3,6 +3,7 @@ from typing import Annotated from fastapi import APIRouter, Depends, HTTPException, Query +from pydantic import BaseModel, Field from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload @@ -16,92 +17,111 @@ router = APIRouter() DbDep = Annotated[AsyncSession, Depends(get_db)] -@router.get("/") +# --- Pydantic schemas --- + +class WorldCreate(BaseModel): + """Schema for creating a world.""" + name: str = Field(..., min_length=1, max_length=255) + description: str | None = None + visibility: WorldVisibility = WorldVisibility.PUBLIC + + +class WorldUpdate(BaseModel): + """Schema for updating a world.""" + name: str | None = Field(None, min_length=1, max_length=255) + description: str | None = None + visibility: WorldVisibility | None = None + + +class WorldResponse(BaseModel): + """Schema for world responses.""" + id: int + name: str + description: str | None + visibility: WorldVisibility + owner_id: int + created_at: str | None = None + + model_config = {"from_attributes": True} + + +# --- Endpoints --- + +@router.get("/", response_model=list[WorldResponse]) async def list_worlds( + current_user: CurrentUser, db: DbDep, skip: int = Query(0, ge=0), limit: int = Query(50, ge=1, le=100), visibility: WorldVisibility | None = None, ): - """List worlds with optional visibility filter.""" + """List worlds visible to the current user. + + PUBLIC worlds are visible to everyone. + PRIVATE/FRIENDS_ONLY worlds are visible to their owner. + """ query = select(World).options(selectinload(World.owner)) + + # Filter by visibility if specified if visibility: query = query.where(World.visibility == visibility) + else: + # Show PUBLIC worlds + owner's own worlds + query = query.where( + (World.visibility == WorldVisibility.PUBLIC) + | (World.owner_id == current_user.id) + ) + query = query.offset(skip).limit(limit).order_by(World.created_at.desc()) result = await db.execute(query) worlds = result.scalars().all() - return [ - { - "id": w.id, - "name": w.name, - "description": w.description, - "visibility": w.visibility.value if w.visibility else None, - "owner_id": w.owner_id, - "created_at": w.created_at.isoformat() if w.created_at else None, - } - for w in worlds - ] + return worlds -@router.get("/{world_id}") +@router.get("/{world_id}", response_model=WorldResponse) async def get_world( world_id: int, + current_user: CurrentUser, db: DbDep, ): - """Get a single world by ID.""" + """Get a single world by ID, respecting visibility.""" result = await db.execute( select(World).where(World.id == world_id).options(selectinload(World.owner)) ) world = result.scalar_one_or_none() if not world: raise HTTPException(status_code=404, detail="World not found") - return { - "id": world.id, - "name": world.name, - "description": world.description, - "visibility": world.visibility.value if world.visibility else None, - "owner_id": world.owner_id, - "created_at": world.created_at.isoformat() if world.created_at else None, - } + + # Allow access if world is PUBLIC or owned by current user + if world.visibility != WorldVisibility.PUBLIC and world.owner_id != current_user.id: + raise HTTPException(status_code=404, detail="World not found") + + return world -@router.post("/") +@router.post("/", response_model=WorldResponse, status_code=201) async def create_world( - world_data: dict, + world_data: WorldCreate, current_user: CurrentUser, db: DbDep, ): """Create a new world.""" - name = world_data.get("name") - if not name: - raise HTTPException(status_code=400, detail="World name is required") - world = World( - name=name, - description=world_data.get("description"), + name=world_data.name, + description=world_data.description, owner_id=current_user.id, - visibility=WorldVisibility( - world_data.get("visibility", "public") - ), + visibility=world_data.visibility, ) db.add(world) await db.commit() await db.refresh(world) - - return { - "id": world.id, - "name": world.name, - "description": world.description, - "visibility": world.visibility.value, - "owner_id": world.owner_id, - "created_at": world.created_at.isoformat() if world.created_at else None, - } + return world -@router.put("/{world_id}") +@router.put("/{world_id}", response_model=WorldResponse) async def update_world( world_id: int, - world_data: dict, + world_data: WorldUpdate, current_user: CurrentUser, db: DbDep, ): @@ -114,27 +134,16 @@ async def update_world( if world.owner_id != current_user.id: raise HTTPException(status_code=403, detail="Not owner of this world") - if world_data.get("name"): - world.name = world_data["name"] - if world_data.get("description") is not None: - world.description = world_data["description"] - if world_data.get("visibility"): - world.visibility = WorldVisibility(world_data["visibility"]) + update_data = world_data.model_dump(exclude_unset=True) + for field, value in update_data.items(): + setattr(world, field, value) await db.commit() await db.refresh(world) - - return { - "id": world.id, - "name": world.name, - "description": world.description, - "visibility": world.visibility.value if world.visibility else None, - "owner_id": world.owner_id, - "created_at": world.created_at.isoformat() if world.created_at else None, - } + return world -@router.delete("/{world_id}") +@router.delete("/{world_id}", status_code=204) async def delete_world( world_id: int, current_user: CurrentUser, @@ -151,4 +160,3 @@ async def delete_world( await db.delete(world) await db.commit() - return {"detail": "World deleted"} From 241fa9d0e0013eb31cbc9b9fda5ff0415ce8b35c Mon Sep 17 00:00:00 2001 From: coder-bot Date: Mon, 17 Aug 2026 23:42:56 +0000 Subject: [PATCH 26/27] WIP checkpoint: Address review feedback: Address review feedback: Scaffold FastAPI backend project structure --- backend/app/routers/assets.py | 37 +++++++++++++++++++++++++---------- backend/app/routers/users.py | 19 +++++++++--------- 2 files changed, 37 insertions(+), 19 deletions(-) diff --git a/backend/app/routers/assets.py b/backend/app/routers/assets.py index 0d38860..1f23e9b 100644 --- a/backend/app/routers/assets.py +++ b/backend/app/routers/assets.py @@ -1,5 +1,7 @@ """Asset management router.""" +import os +from pathlib import Path from typing import Optional from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Query @@ -51,6 +53,17 @@ class AssetUpdate(BaseModel): is_public: Optional[bool] = None +# --- Helpers --- + +def _sanitize_filename(filename: str) -> str: + """Strip path components and unsafe characters from a filename.""" + # Remove any directory components (path traversal protection). + base = os.path.basename(filename.replace("\\", "/")) + # Keep only safe characters. + safe = "".join(c for c in base if c.isalnum() or c in "._-") + return safe or "file" + + # --- Endpoints --- @router.post("/", response_model=AssetResponse, status_code=201) @@ -155,11 +168,11 @@ async def delete_asset( await db.commit() -@router.post("/{asset_id}/upload") +@router.post("/{asset_id}/upload", response_model=AssetResponse) async def upload_asset_file( asset_id: int, file: UploadFile = File(...), - current_user: CurrentUser = None, + current_user: CurrentUser = Depends(), db: AsyncSession = Depends(get_db), ): """Upload a file for an existing asset.""" @@ -167,11 +180,10 @@ async def upload_asset_file( if asset is None or asset.owner_id != current_user.id: raise HTTPException(status_code=404, detail="Asset not found") - # Sanitize filename to prevent path traversal - safe_filename = file.filename.replace("/", "_").replace("\\", "_") if file.filename else f"asset_{asset_id}" - file_path = f"uploads/{asset_id}_{safe_filename}" + # Sanitize filename to prevent path traversal. + safe_filename = _sanitize_filename(file.filename or f"asset_{asset_id}") - # Validate file size against configured maximum + # Validate file size against configured maximum. max_bytes = settings.MAX_UPLOAD_SIZE_MB * 1024 * 1024 content = await file.read() file_size = len(content) @@ -181,12 +193,17 @@ async def upload_asset_file( detail=f"File size {file_size} exceeds maximum allowed size {max_bytes} bytes", ) - # TODO: Implement actual file write logic (S3, local filesystem, etc.) - asset.file_path = file_path + # Write the file to the upload directory. + upload_dir = Path(settings.UPLOAD_DIR) + upload_dir.mkdir(parents=True, exist_ok=True) + stored_name = f"{asset_id}_{safe_filename}" + file_path = upload_dir / stored_name + file_path.write_bytes(content) + + asset.file_path = str(file_path) asset.mime_type = file.content_type asset.file_size = file_size await db.commit() await db.refresh(asset) - - return {"message": "File upload endpoint", "asset_id": asset_id, "file_path": file_path} + return asset diff --git a/backend/app/routers/users.py b/backend/app/routers/users.py index 1ed37cd..6ba19b5 100644 --- a/backend/app/routers/users.py +++ b/backend/app/routers/users.py @@ -30,7 +30,7 @@ class UserUpdate(BaseModel): is_active: bool | None = None -# ─── Endpoints ──────────────────────────────────────────────────────────────── +# ─── Endpoints ─────────────────────────────────────────────────────────────── @router.get("/me", response_model=UserResponse) async def get_current_user_profile( @@ -46,15 +46,14 @@ async def get_user( current_user: CurrentUser, db: Annotated[AsyncSession, Depends(get_db)], ): - """Get a user by ID. Owners and admins can see any user; others see - only their own profile.""" + """Get a user by ID. Only the owner can view their own profile.""" result = await db.execute(select(User).where(User.id == user_id)) user = result.scalar_one_or_none() if not user: raise HTTPException(status_code=404, detail="User not found") - # Allow viewing own profile or if user is active (admins always allowed) - if user.id != current_user.id and not current_user.is_active: + # Only the owner may view their own profile. + if user.id != current_user.id: raise HTTPException(status_code=403, detail="Not authorized to view this user") return user @@ -67,13 +66,14 @@ async def update_user( current_user: CurrentUser, db: Annotated[AsyncSession, Depends(get_db)], ): - """Update a user. Only the owner (or an active admin) can update.""" + """Update a user. Only the owner can update their own account.""" result = await db.execute(select(User).where(User.id == user_id)) user = result.scalar_one_or_none() if not user: raise HTTPException(status_code=404, detail="User not found") - if user.id != current_user.id and not current_user.is_active: + # Only the owner may update their own account. + if user.id != current_user.id: raise HTTPException(status_code=403, detail="Not authorized to update this user") if user_update.email is not None: @@ -92,13 +92,14 @@ async def delete_user( current_user: CurrentUser, db: Annotated[AsyncSession, Depends(get_db)], ): - """Delete a user. Only the owner (or an active admin) can delete.""" + """Delete a user. Only the owner can delete their own account.""" result = await db.execute(select(User).where(User.id == user_id)) user = result.scalar_one_or_none() if not user: raise HTTPException(status_code=404, detail="User not found") - if user.id != current_user.id and not current_user.is_active: + # Only the owner may delete their own account. + if user.id != current_user.id: raise HTTPException(status_code=403, detail="Not authorized to delete this user") await db.delete(user) From 5d7c61029c950bae79d54edaa5d4a0bfc3b846c5 Mon Sep 17 00:00:00 2001 From: coder-bot Date: Tue, 18 Aug 2026 00:14:37 +0000 Subject: [PATCH 27/27] Fix remaining critical review issue: list_worlds visibility filter bypass MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Requesting an explicit ?visibility=PRIVATE (or FRIENDS_ONLY) skipped the ownership check entirely — any authenticated user could list every other user's private worlds just by passing the filter. Only the no-filter default path enforced ownership. Now the ownership check applies whenever a non-PUBLIC visibility is requested, explicit or not. Also fixed current_user: CurrentUser = Depends() in assets.py (redundant empty Depends() on an already-Annotated dependency, inconsistent with every other endpoint) to plain current_user: CurrentUser per the review feedback, and reordered params since that made it a non-default arg following a defaulted one (SyntaxError otherwise). Verified: py_compile clean, full app imports and loads in a clean venv. --- backend/app/routers/assets.py | 2 +- backend/app/routers/worlds.py | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/backend/app/routers/assets.py b/backend/app/routers/assets.py index 1f23e9b..af9c2be 100644 --- a/backend/app/routers/assets.py +++ b/backend/app/routers/assets.py @@ -171,8 +171,8 @@ async def delete_asset( @router.post("/{asset_id}/upload", response_model=AssetResponse) async def upload_asset_file( asset_id: int, + current_user: CurrentUser, file: UploadFile = File(...), - current_user: CurrentUser = Depends(), db: AsyncSession = Depends(get_db), ): """Upload a file for an existing asset.""" diff --git a/backend/app/routers/worlds.py b/backend/app/routers/worlds.py index f29a21c..7f3aadd 100644 --- a/backend/app/routers/worlds.py +++ b/backend/app/routers/worlds.py @@ -65,6 +65,12 @@ async def list_worlds( # Filter by visibility if specified if visibility: query = query.where(World.visibility == visibility) + if visibility != WorldVisibility.PUBLIC: + # Requesting a non-public visibility explicitly must not bypass + # ownership — otherwise any authenticated user could list every + # PRIVATE/FRIENDS_ONLY world in the system just by passing the + # filter, defeating the point of the check below. + query = query.where(World.owner_id == current_user.id) else: # Show PUBLIC worlds + owner's own worlds query = query.where(