Compare commits

..

7 Commits

Author SHA1 Message Date
architect-bot 073813f3e7 WIP checkpoint: US-1: Asset Import Pipeline — glTF upload endpoint with metadata extraction 2026-08-19 12:37:46 +00:00
architect-bot cc971ee9f5 WIP checkpoint: US-1: Asset Import Pipeline — glTF upload endpoint with metadata extraction 2026-08-19 12:32:46 +00:00
architect-bot 54abe7ee96 WIP checkpoint: US-1: Asset Import Pipeline — glTF upload endpoint with metadata extraction 2026-08-19 12:32:06 +00:00
coder-bot a7cf385d0d WIP checkpoint: US-1: Asset Import Pipeline — glTF upload endpoint with metadata extraction 2026-08-19 12:31:05 +00:00
coder-bot b5525665dd WIP checkpoint: US-1: Asset Import Pipeline — glTF upload endpoint with metadata extraction 2026-08-19 12:31:05 +00:00
agent-bot ff610c0466 Merge pull request 'Fix migration 0001_initial double CREATE TYPE + port collision' (#25) from fix/migration-0001-double-enum-and-port-collision into main 2026-08-18 21:57:01 +00:00
claude-mvp-fix acbc518b4a Fix migration 0001_initial: double CREATE TYPE crashes every clean bootstrap
world_visibility and asset_type were each declared twice: once explicitly
via `.create(checkfirst=True)`, and again implicitly by SQLAlchemy when
create_table() compiled the inline postgresql.ENUM() column type (which
defaults create_type=True). On a genuinely empty database this raises
asyncpg.DuplicateObjectError mid-transaction and the migration never
completes -- verified this was true 100% of the time from a fresh
container, meaning the backend has never successfully bootstrapped from
scratch. Fixed by passing create_type=False on the inline column
declarations, since the type is already created explicitly above.

Also parameterized both services' host ports (POSTGRES_HOST_PORT /
REDIS_HOST_PORT, defaulting to the original 5432/6379) so concurrent
agent workspaces sharing this same docker-compose.yml can run isolated
databases without colliding on the host port.
2026-08-18 21:55:34 +00:00
8 changed files with 459 additions and 166 deletions
+3 -2
View File
@@ -69,7 +69,8 @@ def upgrade() -> None:
sa.Column( sa.Column(
"visibility", "visibility",
postgresql.ENUM( postgresql.ENUM(
"public", "private", "friends_only", name="world_visibility" "public", "private", "friends_only", name="world_visibility",
create_type=False,
), ),
nullable=True, nullable=True,
), ),
@@ -89,7 +90,7 @@ def upgrade() -> None:
"asset_type", "asset_type",
postgresql.ENUM( postgresql.ENUM(
"image", "audio", "video", "document", "model_3d", "other", "image", "audio", "video", "document", "model_3d", "other",
name="asset_type", name="asset_type", create_type=False,
), ),
nullable=False, nullable=False,
), ),
+1
View File
@@ -30,6 +30,7 @@ class Settings(BaseSettings):
# File uploads # File uploads
UPLOAD_DIR: str = "uploads" UPLOAD_DIR: str = "uploads"
MAX_UPLOAD_SIZE_MB: int = 100 MAX_UPLOAD_SIZE_MB: int = 100
GLTF_MAX_FILE_SIZE_MB: int = 200
model_config = SettingsConfigDict( model_config = SettingsConfigDict(
env_file=".env", env_file=".env",
+36 -2
View File
@@ -1,5 +1,6 @@
"""Asset management router.""" """Asset management router."""
import json
import os import os
from pathlib import Path from pathlib import Path
from typing import Optional from typing import Optional
@@ -13,6 +14,7 @@ from app.database import get_db
from app.dependencies import CurrentUser from app.dependencies import CurrentUser
from app.models.asset import Asset, AssetType from app.models.asset import Asset, AssetType
from app.models.world import World from app.models.world import World
from app.services import gltf as gltf_service
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
router = APIRouter() router = APIRouter()
@@ -64,6 +66,14 @@ def _sanitize_filename(filename: str) -> str:
return safe or "file" return safe or "file"
def _detect_asset_type(filename: Optional[str], content: bytes) -> AssetType:
"""Detect the asset type from filename and content."""
if filename and gltf_service.looks_like_gltf(content, filename):
return AssetType.MODEL_3D
# Fallback: let the caller decide based on mime_type or default.
return AssetType.OTHER
# --- Endpoints --- # --- Endpoints ---
@router.post("/", response_model=AssetResponse, status_code=201) @router.post("/", response_model=AssetResponse, status_code=201)
@@ -183,10 +193,16 @@ async def upload_asset_file(
# Sanitize filename to prevent path traversal. # Sanitize filename to prevent path traversal.
safe_filename = _sanitize_filename(file.filename or f"asset_{asset_id}") safe_filename = _sanitize_filename(file.filename or f"asset_{asset_id}")
# Validate file size against configured maximum. # Read the file content.
max_bytes = settings.MAX_UPLOAD_SIZE_MB * 1024 * 1024
content = await file.read() content = await file.read()
file_size = len(content) file_size = len(content)
# Determine which size limit to apply.
if gltf_service.looks_like_gltf(content, file.filename):
max_bytes = settings.GLTF_MAX_FILE_SIZE_MB * 1024 * 1024
else:
max_bytes = settings.MAX_UPLOAD_SIZE_MB * 1024 * 1024
if file_size > max_bytes: if file_size > max_bytes:
raise HTTPException( raise HTTPException(
status_code=413, status_code=413,
@@ -200,10 +216,28 @@ async def upload_asset_file(
file_path = upload_dir / stored_name file_path = upload_dir / stored_name
file_path.write_bytes(content) file_path.write_bytes(content)
# Detect asset type from content/filename.
detected_type = _detect_asset_type(file.filename, content)
# Update asset record.
asset.file_path = str(file_path) asset.file_path = str(file_path)
asset.mime_type = file.content_type asset.mime_type = file.content_type
asset.file_size = file_size asset.file_size = file_size
# If this is a glTF file, extract and store metadata.
if detected_type == AssetType.MODEL_3D:
try:
metadata = gltf_service.extract_gltf_metadata(content)
asset.metadata_json = json.dumps(metadata)
# Also set the asset type to model_3d if it was auto-detected.
if asset.asset_type == AssetType.OTHER:
asset.asset_type = AssetType.MODEL_3D
except ValueError as exc:
raise HTTPException(
status_code=400,
detail=f"Invalid glTF file: {exc}",
)
await db.commit() await db.commit()
await db.refresh(asset) await db.refresh(asset)
return asset return asset
View File
+197
View File
@@ -0,0 +1,197 @@
"""Lightweight glTF / GLB metadata extraction.
This module intentionally avoids heavyweight dependencies such as trimesh or
pygltflib. For the metadata fields the asset upload pipeline needs (mesh and
vertex counts, animation presence, texture references, and the bounding box)
a small amount of glTF-specific parsing is sufficient and keeps the backend
dependency-free.
"""
from __future__ import annotations
import json
import struct
from typing import Any, Optional
# GLB binary header magic bytes ("glTF").
_GLB_MAGIC = b"glTF"
_GLB_MAGIC_INT = 0x46546C67 # b"glTF" interpreted as little-endian uint32
_GLB_JSON_CHUNK_TYPE = 0x4E4F534A # "JSON"
_GLB_BIN_CHUNK_TYPE = 0x004E4942 # "BIN\0"
# Whitespace allowed around a GLB JSON chunk. The spec pads with spaces (0x20),
# but be lenient with null bytes and other whitespace that real exporters emit.
_JSON_CHUNK_TRIM = b" \t\r\n\x00"
def looks_like_gltf(content: bytes, filename: Optional[str] = None) -> bool:
"""Return True when the bytes or filename indicate a glTF/GLB asset."""
if content.startswith(_GLB_MAGIC):
return True
if filename:
lowered = filename.lower()
if lowered.endswith(".gltf") or lowered.endswith(".glb"):
return True
# A bare JSON buffer (e.g. uploaded without an extension) that has the
# glTF asset marker is also a glTF file.
stripped = content.lstrip()
if stripped.startswith(b"{"):
try:
payload = json.loads(stripped.decode("utf-8"))
except (ValueError, UnicodeDecodeError):
return False
return isinstance(payload, dict) and "asset" in payload and "version" in payload.get("asset", {})
return False
def _parse_glb(content: bytes) -> dict[str, Any]:
"""Parse the JSON chunk of a GLB binary container."""
if len(content) < 12:
raise ValueError("GLB file is too short")
magic, _version, _length = struct.unpack_from("<III", content, 0)
if magic != _GLB_MAGIC_INT:
raise ValueError("invalid GLB magic")
offset = 12
json_chunk: Optional[dict[str, Any]] = None
while offset + 8 <= len(content):
chunk_length, chunk_type = struct.unpack_from("<II", content, offset)
offset += 8
chunk_end = offset + chunk_length
if chunk_end > len(content):
raise ValueError("GLB chunk overruns file boundary")
chunk_data = content[offset:chunk_end]
if chunk_type == _GLB_JSON_CHUNK_TYPE:
try:
json_chunk = json.loads(chunk_data.strip(_JSON_CHUNK_TRIM).decode("utf-8"))
except (ValueError, UnicodeDecodeError) as exc:
raise ValueError(f"invalid GLB JSON chunk: {exc}") from exc
offset = chunk_end
if json_chunk is None:
raise ValueError("GLB file does not contain a JSON chunk")
return json_chunk
def _parse_gltf_json(content: bytes) -> dict[str, Any]:
"""Parse a plain .gltf JSON file."""
stripped = content.lstrip()
try:
payload = json.loads(stripped.decode("utf-8"))
except (ValueError, UnicodeDecodeError) as exc:
raise ValueError(f"invalid glTF JSON: {exc}") from exc
if not isinstance(payload, dict):
raise ValueError("glTF JSON root must be an object")
return payload
def _position_accessor_counts(gltf: dict[str, Any]) -> list[int]:
"""Return POSITION accessor counts for every mesh primitive."""
counts: list[int] = []
accessors = gltf.get("accessors", [])
for mesh in gltf.get("meshes", []):
for primitive in mesh.get("primitives", []):
attributes = primitive.get("attributes", {})
position_index = attributes.get("POSITION")
if position_index is None:
continue
try:
accessor = accessors[int(position_index)]
except (IndexError, TypeError, ValueError):
continue
count = accessor.get("count")
if isinstance(count, int):
counts.append(count)
return counts
def _position_bounds(gltf: dict[str, Any]) -> list[list[float]]:
"""Return min/max bounds for every POSITION accessor that declares them."""
bounds: list[list[float]] = []
accessors = gltf.get("accessors", [])
for mesh in gltf.get("meshes", []):
for primitive in mesh.get("primitives", []):
attributes = primitive.get("attributes", {})
position_index = attributes.get("POSITION")
if position_index is None:
continue
try:
accessor = accessors[int(position_index)]
except (IndexError, TypeError, ValueError):
continue
minimum = accessor.get("min")
maximum = accessor.get("max")
if isinstance(minimum, list) and isinstance(maximum, list):
bounds.append([float(x) for x in minimum])
bounds.append([float(x) for x in maximum])
return bounds
def _texture_refs(gltf: dict[str, Any]) -> list[str]:
"""Collect external texture source references from the image array."""
refs: list[str] = []
for index, image in enumerate(gltf.get("images", [])):
uri = image.get("uri")
if isinstance(uri, str) and not uri.startswith("data:"):
refs.append(uri)
continue
name = image.get("name")
if isinstance(name, str) and name:
refs.append(name)
continue
if isinstance(uri, str):
# Embedded data URI; keep a stable, non-huge reference.
refs.append(f"data:{index}")
continue
buffer_view = image.get("bufferView")
if buffer_view is not None:
refs.append(f"bufferView:{buffer_view}")
continue
refs.append(f"image:{index}")
return refs
def extract_gltf_metadata(content: bytes) -> dict[str, Any]:
"""Extract upload-relevant metadata from glTF/GLB bytes.
Raises ValueError for malformed glTF content. The returned dict contains:
mesh_count, vertex_count, has_animation, texture_refs,
bounding_box_min, bounding_box_max.
"""
if content.startswith(_GLB_MAGIC):
gltf = _parse_glb(content)
else:
gltf = _parse_gltf_json(content)
position_counts = _position_accessor_counts(gltf)
bounds = _position_bounds(gltf)
if bounds:
bounding_box_min = [min(axis) for axis in zip(*bounds[::2])]
bounding_box_max = [max(axis) for axis in zip(*bounds[1::2])]
else:
bounding_box_min = [0.0, 0.0, 0.0]
bounding_box_max = [0.0, 0.0, 0.0]
return {
"mesh_count": len(gltf.get("meshes", [])),
"vertex_count": sum(position_counts),
"has_animation": bool(gltf.get("animations")),
"texture_refs": _texture_refs(gltf),
"bounding_box_min": bounding_box_min,
"bounding_box_max": bounding_box_max,
}
+2 -2
View File
@@ -8,7 +8,7 @@ services:
POSTGRES_PASSWORD: agp_password POSTGRES_PASSWORD: agp_password
POSTGRES_DB: agp_db POSTGRES_DB: agp_db
ports: ports:
- "5432:5432" - "${POSTGRES_HOST_PORT:-5432}:5432"
volumes: volumes:
- postgres_data:/var/lib/postgresql/data - postgres_data:/var/lib/postgresql/data
healthcheck: healthcheck:
@@ -20,7 +20,7 @@ services:
redis: redis:
image: redis:7-alpine image: redis:7-alpine
ports: ports:
- "6379:6379" - "${REDIS_HOST_PORT:-6379}:6379"
volumes: volumes:
- redis_data:/data - redis_data:/data
healthcheck: healthcheck:
+220
View File
@@ -0,0 +1,220 @@
"""Tests for the lightweight glTF metadata extraction service."""
import json
import struct
import pytest
from app.services.gltf import (
extract_gltf_metadata,
looks_like_gltf,
_parse_glb,
_parse_gltf_json,
)
# ---------------------------------------------------------------------------
# Test data helpers
# ---------------------------------------------------------------------------
def _make_gltf_json(
meshes: int = 1,
vertices_per_mesh: int = 24,
has_animations: bool = False,
images: list[dict] | None = None,
) -> dict:
"""Build a minimal glTF JSON structure."""
accessors = []
meshes_data = []
for i in range(meshes):
acc_index = len(accessors)
accessors.append({
"bufferView": 0,
"componentType": 5126, # FLOAT
"count": vertices_per_mesh,
"type": "VEC3",
"max": [1.0, 1.0, 1.0],
"min": [-1.0, -1.0, -1.0],
})
meshes_data.append({
"primitives": [{
"attributes": {"POSITION": acc_index},
}],
})
gltf = {
"asset": {"version": "2.0"},
"accessors": accessors,
"meshes": meshes_data,
}
if has_animations:
gltf["animations"] = [{"name": "anim1", "channels": [], "samplers": []}]
if images:
gltf["images"] = images
return gltf
def _make_glb(gltf_json: dict) -> bytes:
"""Wrap *gltf_json* in a minimal GLB binary container."""
json_bytes = json.dumps(gltf_json).encode("utf-8")
json_length = len(json_bytes)
# Pad JSON chunk to 4-byte boundary.
json_padding = (4 - json_length % 4) % 4
json_chunk = json_bytes + b"\x00" * json_padding
json_chunk_type = 0x4E4F534A # "JSON"
# No BIN chunk.
bin_length = 0
bin_chunk_type = 0x004E4942
total_length = 12 + 8 + len(json_chunk) + 8 + bin_length
header = struct.pack("<III", 0x46546C67, 2, total_length) # magic, version, length
json_header = struct.pack("<II", len(json_chunk), json_chunk_type)
if bin_length > 0:
bin_header = struct.pack("<II", bin_length, bin_chunk_type)
else:
bin_header = b""
return header + json_header + json_chunk + bin_header
# ---------------------------------------------------------------------------
# looks_like_gltf
# ---------------------------------------------------------------------------
class TestLooksLikeGltf:
def test_glb_magic(self):
assert looks_like_gltf(b"glTF\x00\x00\x00\x00") is True
def test_gltf_extension(self):
assert looks_like_gltf(b'{"asset":{"version":"2.0"}}', "model.gltf") is True
def test_glb_extension(self):
assert looks_like_gltf(b"some binary", "model.glb") is True
def test_json_with_asset_version(self):
payload = json.dumps({"asset": {"version": "2.0"}}).encode()
assert looks_like_gltf(payload) is True
def test_plain_json_no_asset(self):
payload = json.dumps({"foo": "bar"}).encode()
assert looks_like_gltf(payload) is False
def test_non_json_bytes(self):
assert looks_like_gltf(b"\x89PNG\r\n\x1a\n") is False
# ---------------------------------------------------------------------------
# _parse_glb
# ---------------------------------------------------------------------------
class TestParseGlb:
def test_parses_valid_glb(self):
gltf_json = _make_gltf_json()
glb = _make_glb(gltf_json)
result = _parse_glb(glb)
assert result["asset"]["version"] == "2.0"
assert len(result["meshes"]) == 1
def test_rejects_too_short(self):
with pytest.raises(ValueError, match="too short"):
_parse_glb(b"glTF\x00\x00")
def test_rejects_invalid_magic(self):
with pytest.raises(ValueError, match="invalid GLB magic"):
_parse_glb(b"NOTglTF\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00")
# ---------------------------------------------------------------------------
# _parse_gltf_json
# ---------------------------------------------------------------------------
class TestParseGltfJson:
def test_parses_valid_gltf(self):
gltf_json = _make_gltf_json()
result = _parse_gltf_json(json.dumps(gltf_json).encode())
assert result["asset"]["version"] == "2.0"
def test_rejects_non_object(self):
with pytest.raises(ValueError, match="root must be an object"):
_parse_gltf_json(b"[1, 2, 3]")
# ---------------------------------------------------------------------------
# extract_gltf_metadata (GLB path)
# ---------------------------------------------------------------------------
class TestExtractGltfMetadataGlb:
def test_basic_glb(self):
gltf_json = _make_gltf_json(meshes=1, vertices_per_mesh=24)
glb = _make_glb(gltf_json)
meta = extract_gltf_metadata(glb)
assert meta["mesh_count"] == 1
assert meta["vertex_count"] == 24
assert meta["has_animation"] is False
assert meta["bounding_box_min"] == [-1.0, -1.0, -1.0]
assert meta["bounding_box_max"] == [1.0, 1.0, 1.0]
assert meta["texture_refs"] == []
def test_multiple_meshes(self):
gltf_json = _make_gltf_json(meshes=3, vertices_per_mesh=12)
glb = _make_glb(gltf_json)
meta = extract_gltf_metadata(glb)
assert meta["mesh_count"] == 3
assert meta["vertex_count"] == 36
def test_with_animation(self):
gltf_json = _make_gltf_json(has_animations=True)
glb = _make_glb(gltf_json)
meta = extract_gltf_metadata(glb)
assert meta["has_animation"] is True
def test_with_external_texture(self):
gltf_json = _make_gltf_json(images=[{"uri": "textures/rock.png"}])
glb = _make_glb(gltf_json)
meta = extract_gltf_metadata(glb)
assert meta["texture_refs"] == ["textures/rock.png"]
def test_with_named_texture(self):
gltf_json = _make_gltf_json(images=[{"name": "my_texture"}])
glb = _make_glb(gltf_json)
meta = extract_gltf_metadata(glb)
assert meta["texture_refs"] == ["my_texture"]
def test_empty_meshes(self):
gltf_json = _make_gltf_json(meshes=0)
glb = _make_glb(gltf_json)
meta = extract_gltf_metadata(glb)
assert meta["mesh_count"] == 0
assert meta["vertex_count"] == 0
assert meta["bounding_box_min"] == [0.0, 0.0, 0.0]
assert meta["bounding_box_max"] == [0.0, 0.0, 0.0]
# ---------------------------------------------------------------------------
# extract_gltf_metadata (plain .gltf JSON path)
# ---------------------------------------------------------------------------
class TestExtractGltfMetadataGltfJson:
def test_plain_gltf(self):
gltf_json = _make_gltf_json(meshes=2, vertices_per_mesh=8)
raw = json.dumps(gltf_json).encode()
meta = extract_gltf_metadata(raw)
assert meta["mesh_count"] == 2
assert meta["vertex_count"] == 16
assert meta["has_animation"] is False
def test_plain_gltf_with_animation(self):
gltf_json = _make_gltf_json(has_animations=True)
raw = json.dumps(gltf_json).encode()
meta = extract_gltf_metadata(raw)
assert meta["has_animation"] is True
# ---------------------------------------------------------------------------
# Error handling
# ---------------------------------------------------------------------------
class TestExtractGltfMetadataErrors:
def test_invalid_glb(self):
with pytest.raises(ValueError):
extract_gltf_metadata(b"not a glb")
def test_invalid_gltf_json(self):
with pytest.raises(ValueError):
_parse_gltf_json(b"[1, 2, 3]")
-160
View File
@@ -1,160 +0,0 @@
# Trust & Safety Operations — Plan
> **Status:** Planned
> **Owner:** Platform team
> **Feature ID:** `trust_safety`
> **Godot version:** 4.6.2 stable
> **Backend:** FastAPI + SQLAlchemy (async) + PostgreSQL (+ Redis available, unused)
> **Related:** `content_moderation.md` (M5 — content rating/flagging at upload time),
> `social.md` (chat + report-message), `app/dependencies.py` (JWT auth)
## 1. Overview
This feature is the **enforcement workflow** for content/behavior that is
**already live** and has been reported by a real user. M5
(`content_moderation.md`) covers rating/flagging at authoring/upload time; this
feature covers what happens **after** something is live and someone reports it:
1. **Reporting** — report a user, an experience (world/asset), or a chat
message with a reason and optional detail.
2. **Moderation queue** — staff review incoming reports, see report + target
context, and resolve them.
3. **Ban / suspension** — apply a temporary suspension or permanent ban to a
user, with enforcement at the auth boundary.
4. **Appeals** — a sanctioned user can appeal; staff can uphold or overturn.
The userbase being 18+ does **not** shrink the moderation need — harassment,
hate speech, spam, griefing, and CSAM/policy violations still require real
enforcement. Safety is a **blocking requirement**, not an add-on.
## 2. Existing constraints (do not re-build)
- **Auth:** JWT bearer; `get_current_user` / `CurrentUser` in
`app/dependencies.py`. Every new endpoint reuses it.
- **DB:** async SQLAlchemy + Alembic. Current head is `0002_add_date_of_birth_to_users`.
- **Models:** `User`, `World` (owner_id), `Asset` (owner_id, is_public). There
is no `role` field yet, and no chat/message table yet (social.md is planned,
not built).
- **Redis:** configured (`REDIS_URL`) but unused; not required by this feature.
- **Client:** Godot 4.6.2, minimal `main_menu.gd`; no report/chat UI yet.
## 3. Key design decisions
| Concern | Decision |
|---|---|
| Report target addressing | Generic `target_type` + `target_id` (string) so user/world/asset/message all flow through one report path; router resolves target existence via a small registry |
| Report reasons | Fixed enum `reason_category` (harassment, hate_speech, sexual_content, violence, spam, self_harm, other) + free-text `details` |
| Staff roles | Add `role` enum on `User` (`user` / `moderator` / `admin`); `RequireStaff` dependency gates the queue/action/appeal-review endpoints |
| Enforcement | Denormalized `banned_until` / `suspended_until` timestamps on `User` (kept in sync by moderation actions); `get_current_user` rejects sanctioned users with `403` so enforcement applies to **all** existing endpoints |
| Ban vs. suspension | Suspension = temporary (`expires_at`); ban = permanent (`expires_at IS NULL`). Both are rows in `moderation_actions` for auditability |
| Appeals | Appeal references the specific `moderation_action`; approving an appeal revokes the action and clears the user's sanction |
| Audit | Every moderation action is an append-only row with `moderator_id`, `reason`, `created_at`, `revoked_at`; reports record `resolved_by` + `resolution` |
## 4. Data model (target shape)
```
users
+ role ENUM(user, moderator, admin) default 'user'
+ banned_until TIMESTAMPTZ NULL
+ suspended_until TIMESTAMPTZ NULL
reports
id, reporter_id FK users, target_type ENUM(user,world,asset,chat_message),
target_id VARCHAR(64), reason_category ENUM(...), details TEXT,
status ENUM(open,in_review,resolved,dismissed) default 'open',
resolved_by FK users NULL, resolution ENUM(...) NULL,
created_at, updated_at, resolved_at
moderation_actions
id, moderator_id FK users, target_user_id FK users,
target_type ENUM(user,world,asset,chat_message), target_id VARCHAR(64),
action ENUM(warn,suspend,ban,restrict,remove,restore),
reason TEXT, report_id FK reports NULL,
expires_at TIMESTAMPTZ NULL, revoked_at TIMESTAMPTZ NULL, created_at
appeals
id, moderation_action_id FK moderation_actions,
user_id FK users, reason TEXT,
status ENUM(pending,approved,rejected) default 'pending',
reviewed_by FK users NULL, reviewed_at TIMESTAMPTZ NULL, created_at
```
## 5. User stories
### US-1 Reporting
As a player, I can report a user, an experience (world/asset), or a chat
message, choosing a reason and adding optional detail.
**Acceptance criteria**
- Report requires a valid target of the matching type (404 otherwise) and a
reason from the fixed category set.
- Duplicate reports from the same reporter for the same target are coalesced
(409) rather than creating unbounded duplicates.
- A report cannot be filed by a banned/suspended user (enforced by existing auth).
- Each report stores reporter, target, reason, and created_at; status starts `open`.
### US-2 Moderation queue & review
As a moderator, I can list open reports (filterable by type/status/reason),
view a report with its target, and resolve it with an action.
**Acceptance criteria**
- Queue is staff-only (`RequireStaff`); non-staff get 403.
- List supports status/type/reason filters + pagination (offset/limit).
- Resolving sets `status`, `resolution`, `resolved_by`, `resolved_at` and
(where applicable) records a `moderation_actions` row.
### US-3 Ban / suspension enforcement
As a moderator, I can suspend a user temporarily or ban them permanently, and
the sanction blocks the user from authenticated endpoints immediately.
**Acceptance criteria**
- Suspension sets `suspended_until`; ban sets `banned_until` (permanent). Warn
records an action without blocking.
- `get_current_user` returns `403` for any user past a live sanction.
- Expired suspensions no longer block (time-based, no cron required).
- Removing a ban/suspension is itself a recorded `restore` action.
### US-4 Appeals
As a sanctioned user, I can appeal an active ban/suspension; as a moderator I
can approve (overturn) or reject (uphold) it.
**Acceptance criteria**
- A user can submit exactly one open appeal per sanction.
- Appeal is tied to the specific `moderation_action`.
- Approving revokes the action and clears the user's sanction; rejecting
upholds it. Both record reviewer + timestamp.
## 6. Task breakdown
Right-sized to ~1–3 files / one coherent piece of functionality. Task IDs are
dependency order.
| # | Task | Story | Depends on |
|---|------|-------|-----------|
| 1 | Moderation data model + Alembic migration (reports, moderation_actions, appeals, user `role`/`banned_until`/`suspended_until`) | US-1..4 | — |
| 2 | Staff authorization + sanction enforcement dependency (`RequireStaff`; extend `get_current_user` to reject sanctioned users) | US-2, US-3 | 1 |
| 3 | Report submission endpoints (report user / experience / chat message) | US-1 | 1 |
| 4 | Moderation queue + review endpoints (list, detail, resolve→action) | US-2 | 1, 2 |
| 5 | Ban/suspend/restore action application + user-sanction sync | US-3 | 2, 4 |
| 6 | Appeals endpoints (submit, list, review → uphold/overturn) | US-4 | 1, 5 |
| 7 | Integration tests: report → review → ban → appeal → overturn | US-1..4 | 3, 4, 5, 6 |
## 7. Phasing
- **Phase 1 (data + enforcement core):** tasks 1–2. Land the schema and make
sanctioned users actually unable to act — highest value.
- **Phase 2 (report → review → action):** tasks 3–5. The moderation loop.
- **Phase 3 (appeals):** task 6.
- **Phase 4 (verification):** task 7 integration tests.
## 8. Out of scope for this feature
- **Client (Godot) report/appeal UI** — no chat/social UI exists yet; report
endpoints are consumed once `social.md` US-6 lands. Client is a follow-up.
- Automated content scanning / hash detection (covered by M5 /
`content_moderation.md`).
- Third-party moderation/CSAM-scanning vendors.
- Rate limiting of report submission (Redis is available; add a token-bucket
dependency later if abuse is observed).
- Voice chat moderation.
- Per-jurisdiction legal-takedown workflows (DMCA etc.).