Complete M11 Creator Studio and M12 Trust & Safety for PC5
checkpoint / checkpoint (push) Failing after 3h14m9s

- Creator Studio: Visual behavior & property inspector, GLB model importer, binary persistence
- Trust & Safety: 18+ TOS confirmation (DOB removed), creator guidelines & briefing card on join, in-game reporting HUD
- Test Harness: Add --filter, --skip-backend, --skip-godot to run_checkpoint_tests.py
This commit is contained in:
2026-09-17 23:13:57 +00:00
parent 920354705a
commit 7ecf0d53bd
30 changed files with 2099 additions and 157 deletions
@@ -0,0 +1,30 @@
"""add content_guidelines column to worlds table
Revision ID: 0016_content_guidelines
Revises: 0015_add_discovery_metadata
Create Date: 2026-09-17 00:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "0016_content_guidelines"
down_revision: Union[str, None] = "0015_add_discovery_metadata"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Check if column already exists (in case partial run added column before version update failed)
bind = op.get_bind()
insp = sa.inspect(bind)
cols = [c["name"] for c in insp.get_columns("worlds")]
if "content_guidelines" not in cols:
op.add_column("worlds", sa.Column("content_guidelines", sa.Text(), nullable=True))
def downgrade() -> None:
op.drop_column("worlds", "content_guidelines")
+2
View File
@@ -31,11 +31,13 @@ class World(Base):
)
category = Column(String(100), nullable=True, index=True)
tags = Column(ARRAY(String), nullable=True, server_default="{}")
content_guidelines = Column(Text, nullable=True)
play_count = Column(Integer, nullable=False, server_default="0", index=True)
last_played_at = Column(DateTime(timezone=True), nullable=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
__table_args__ = (
Index("ix_worlds_tags", "tags", postgresql_using="gin"),
)
+18
View File
@@ -6,6 +6,7 @@ from pathlib import Path
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Query
from fastapi.responses import FileResponse
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
@@ -241,3 +242,20 @@ async def upload_asset_file(
await db.commit()
await db.refresh(asset)
return asset
@router.get("/{asset_id}/file")
async def get_asset_file(
asset_id: int,
db: AsyncSession = Depends(get_db),
):
"""Download an asset binary file."""
asset = await db.get(Asset, asset_id)
if asset is None or not asset.file_path or not os.path.exists(asset.file_path):
raise HTTPException(status_code=404, detail="Asset file not found")
return FileResponse(
asset.file_path,
media_type=asset.mime_type or "application/octet-stream",
filename=os.path.basename(asset.file_path),
)
+20 -17
View File
@@ -43,12 +43,10 @@ class RegisterRequest(BaseModel):
username: str
email: EmailStr
password: str
# Real date of birth -- the server computes age from it and rejects
# anyone under MIN_AGE. Still self-attested (no document check yet), but
# a concrete, stored, legally-meaningful data point rather than a
# throwaway checkbox, and the seam for a future hard ID-verification
# pass. See docs/architecture/content_moderation.md 5.2.
date_of_birth: date
# ModBison is an adults-only platform (18+). Users confirm agreement with
# 18+ Terms of Service. Date of birth is optional / deprecated.
over_18: bool = True
date_of_birth: date | None = None
class UserResponse(BaseModel):
@@ -159,20 +157,25 @@ def _issue_refresh_token(user_id: int, db: AsyncSession) -> str:
async def register(req: RegisterRequest, db: AsyncSession = Depends(get_db)):
"""Register a new user and return access + refresh tokens.
Requires a date of birth; the server computes age and rejects anyone
under MIN_AGE (18). See docs/architecture/content_moderation.md for the
verification design and its self-attestation limitation.
Requires confirming 18+ TOS agreement. If a date of birth is supplied,
verifies the user is >= MIN_AGE (18).
"""
age = calculate_age(req.date_of_birth)
if age < 0 or req.date_of_birth > date.today():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid date of birth",
)
if age < MIN_AGE:
if req.date_of_birth is not None:
age = calculate_age(req.date_of_birth)
if age < 0 or req.date_of_birth > date.today():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid date of birth",
)
if age < MIN_AGE:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"You must be at least {MIN_AGE} to use this platform",
)
elif not req.over_18:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"You must be at least {MIN_AGE} to use this platform",
detail="You must confirm you are 18 or older to register",
)
# Check if username already exists
+34
View File
@@ -124,9 +124,42 @@ async def report_message(
return report
class ModerationReportCreate(BaseModel):
target_type: Literal["message", "world", "user"]
target_id: int
reason: str = Field(..., min_length=1, max_length=500)
category: str | None = Field(default="general", max_length=50)
# ──────────────────────────────── Moderation ────────────────────────────────────
@moderation_router.post(
"/reports",
response_model=MessageReportResponse,
status_code=status.HTTP_201_CREATED,
)
async def submit_moderation_report(
payload: ModerationReportCreate,
current_user: CurrentUser,
db: DbDep,
):
"""Submit a moderation report for a world, user, or chat message."""
report_reason = f"[{payload.target_type.upper()}:{payload.target_id}] [{payload.category}] {payload.reason}"
msg_id = payload.target_id if payload.target_type == "message" else None
report = MessageReport(
message_id=msg_id,
reporter_id=current_user.id,
reason=report_reason,
status=ReportStatus.OPEN,
)
db.add(report)
await db.commit()
await db.refresh(report)
return report
@moderation_router.get("/reports", response_model=list[MessageReportResponse])
async def list_reports(
current_user: CurrentUser,
@@ -146,6 +179,7 @@ async def list_reports(
return list(result.scalars().all())
@moderation_router.post(
"/reports/{report_id}/resolve",
response_model=ResolveReportResponse,
+2
View File
@@ -37,10 +37,12 @@ class DiscoveredWorld(BaseModel):
description: str | None = None
category: str | None = None
tags: list[str] = []
content_guidelines: str | None = None
play_count: int = 0
created_at: datetime | None = None
owner: WorldOwnerSummary
model_config = {"from_attributes": True}
+6
View File
@@ -29,6 +29,7 @@ class WorldCreate(BaseModel):
visibility: WorldVisibility = WorldVisibility.PUBLIC
category: str | None = None
tags: list[str] | None = None
content_guidelines: str | None = None
class WorldUpdate(BaseModel):
@@ -38,6 +39,7 @@ class WorldUpdate(BaseModel):
visibility: WorldVisibility | None = None
category: str | None = None
tags: list[str] | None = None
content_guidelines: str | None = None
class WorldResponse(BaseModel):
@@ -48,6 +50,7 @@ class WorldResponse(BaseModel):
visibility: WorldVisibility
category: str | None = None
tags: list[str] | None = None
content_guidelines: str | None = None
play_count: int = 0
last_played_at: datetime | None = None
owner_id: int
@@ -57,6 +60,7 @@ class WorldResponse(BaseModel):
# --- Endpoints ---
@router.get("/", response_model=list[WorldResponse])
@@ -170,8 +174,10 @@ async def create_world(
visibility=world_data.visibility,
category=world_data.category,
tags=world_data.tags or [],
content_guidelines=world_data.content_guidelines,
)
db.add(world)
await db.flush() # assigns world.id, needed for the room's FK
await WorldStore(db).get_or_create_main_room(world.id)
+5 -3
View File
@@ -40,9 +40,10 @@ class TestRegisterRequestDob:
date_of_birth="1990-01-01")
assert req.date_of_birth == date(1990, 1, 1)
def test_dob_required(self):
with pytest.raises(Exception):
RegisterRequest(username="u", email="u@example.com", password="p")
def test_dob_optional_with_over_18_default(self):
req = RegisterRequest(username="u", email="u@example.com", password="p")
assert req.date_of_birth is None
assert req.over_18 is True
def test_bad_dob_string_rejected(self):
with pytest.raises(Exception):
@@ -50,6 +51,7 @@ class TestRegisterRequestDob:
date_of_birth="not-a-date")
class TestRegisterAgeGate:
"""End-to-end: the /register endpoint computes age from the DOB."""
+21 -5
View File
@@ -149,18 +149,34 @@ class TestRegister:
assert resp.status_code == 409
assert "Email already registered" in resp.json()["detail"]
def test_register_missing_dob(self):
"""Missing the required date_of_birth field returns 422."""
def test_register_without_dob_succeeds(self):
"""DOB is no longer collected; registration with 18+ TOS agreement succeeds."""
client = TestClient(app)
resp = client.post(
"/api/auth/register",
json={
"username": "user",
"email": "user@example.com",
"username": "adult_user_nodob",
"email": "adult_user_nodob@example.com",
"password": "secret123",
"over_18": True,
},
)
assert resp.status_code == 422
assert resp.status_code == 201
def test_register_not_over_18_rejected(self):
"""Declining 18+ confirmation returns 403."""
client = TestClient(app)
resp = client.post(
"/api/auth/register",
json={
"username": "underage_declined",
"email": "underage_declined@example.com",
"password": "secret123",
"over_18": False,
},
)
assert resp.status_code == 403
# ---------------------------------------------------------------------------
+42
View File
@@ -199,3 +199,45 @@ class TestModerationQueue:
# Attempting to use bad_user's token fails with 403 inactive account
bad_me = client.get("/api/users/me", headers=bad_user["headers"])
assert bad_me.status_code == 403
def test_submit_world_and_user_moderation_report(self):
"""Test submitting general moderation reports for worlds and users."""
client = TestClient(app)
reporter = _register(client, "gen_reporter")
admin = _register(client, "admin_gen_mod")
# Report a world
world_rep = client.post(
"/api/moderation/reports",
headers=reporter["headers"],
json={
"target_type": "world",
"target_id": 42,
"reason": "Violates world guidelines",
"category": "world_rules",
},
)
assert world_rep.status_code == 201, world_rep.text
assert "[WORLD:42]" in world_rep.json()["reason"]
# Report a user
user_rep = client.post(
"/api/moderation/reports",
headers=reporter["headers"],
json={
"target_type": "user",
"target_id": 99,
"reason": "Harassment in world",
"category": "harassment",
},
)
assert user_rep.status_code == 201, user_rep.text
assert "[USER:99]" in user_rep.json()["reason"]
# Admin lists open reports and sees both
rep_list = client.get("/api/moderation/reports", headers=admin["headers"])
assert rep_list.status_code == 200
reasons = [r["reason"] for r in rep_list.json()]
assert any("[WORLD:42]" in r for r in reasons)
assert any("[USER:99]" in r for r in reasons)