"""Internal, dedicated-server-only content API. Implements `docs/architecture/world_persistence_impl.md` §7 + §8.3. This is how a spawned Godot dedicated server (`scripts/server.gd`, spawned by `app.services.game_servers`) pulls its world's content read-only over HTTP before binding its ENet port. Mounted at ``/api/internal`` -- a prefix that can also be blocked at the reverse proxy, per the doc's rejected-alternative note in §7.3. Gated by ``require_game_server`` (§7.3): a bearer token minted per spawn by ``GameServerSupervisor._spawn`` and checked against ``supervisor.validate_token(world_id, token)``. There is no user identity here at all -- these routes skip the per-user visibility check public routes apply, and apply no mature-content gating (gating is a client/user concern; the dedicated server still needs the geometry for collision). Read-only by construction: there is no write route here, and adding one would violate world_persistence_impl.md D2 (the FastAPI backend is the only writer). """ from __future__ import annotations from typing import Annotated from fastapi import APIRouter, Depends, Header, HTTPException, Response from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.database import get_db from app.models.world import World from app.models.world_content import MAIN_ROOM_SLUG from app.services import chunk_codec from app.services.game_servers import supervisor from app.services.world_store import Bounds, WorldStore router = APIRouter() DbDep = Annotated[AsyncSession, Depends(get_db)] async def require_game_server( world_id: int, authorization: Annotated[str | None, Header()] = None, ) -> None: """world_persistence_impl.md §7.3. A path whose ``world_id`` differs from the token's world is 403 -- ``validate_token`` enforces that by construction (it looks the token up *by* ``world_id``).""" if not authorization or not authorization.lower().startswith("bearer "): raise HTTPException(status_code=401, detail="missing bearer token") token = authorization.split(" ", 1)[1].strip() if not token or not supervisor.validate_token(world_id, token): raise HTTPException(status_code=403, detail="invalid or expired server token") async def _get_world_or_404(world_id: int, db: AsyncSession) -> World: result = await db.execute(select(World).where(World.id == world_id)) world = result.scalar_one_or_none() if world is None: raise HTTPException(status_code=404, detail="World not found") return world async def _resolve_room(store: WorldStore, db: AsyncSession, world_id: int, slug: str): room = await store.get_room(world_id, slug) if room is None and slug == MAIN_ROOM_SLUG: room = await store.get_or_create_main_room(world_id) await db.commit() return room @router.get("/worlds/{world_id}/manifest", dependencies=[Depends(require_game_server)]) async def manifest(world_id: int, db: DbDep): await _get_world_or_404(world_id, db) store = WorldStore(db) rooms = await store.list_rooms(world_id) if not rooms: rooms = [await store.get_or_create_main_room(world_id)] await db.commit() room_payloads = [] for r in rooms: room_payloads.append({ "slug": r.slug, "name": r.name, "grid": r.grid, "bounds": r.bounds, "spawn": r.spawn, "chunk_count": await store.count_chunks(r.id), }) return { "world_id": world_id, "content_revision": await store.content_revision(world_id), "rooms": room_payloads, } @router.get("/worlds/{world_id}/rooms/{slug}/chunks", dependencies=[Depends(require_game_server)]) async def internal_list_chunks( world_id: int, slug: str, db: DbDep, cx_min: int | None = None, cx_max: int | None = None, cy_min: int | None = None, cy_max: int | None = None, cz: int | None = None, ): await _get_world_or_404(world_id, db) store = WorldStore(db) room = await _resolve_room(store, db, world_id, slug) if room is None: raise HTTPException(status_code=404, detail="room not found") bounds = Bounds(cx_min=cx_min, cx_max=cx_max, cy_min=cy_min, cy_max=cy_max, cz=cz) chunks = await store.list_chunks(world_id, room.id, bounds) return { "room": room.slug, "content_revision": await store.content_revision(world_id), "chunks": [ { "key": c.chunk_key, "cx": c.cx, "cy": c.cy, "cz": c.cz, "revision": c.revision, "checksum": c.checksum, "payload_len": c.payload_len, "is_mature": c.is_mature, "updated_at": c.updated_at.isoformat() if c.updated_at else None, } for c in chunks ], } @router.get( "/worlds/{world_id}/rooms/{slug}/chunks/{cx}/{cy}/{cz}", dependencies=[Depends(require_game_server)], ) async def internal_get_chunk(world_id: int, slug: str, cx: int, cy: int, cz: int, db: DbDep): await _get_world_or_404(world_id, db) store = WorldStore(db) room = await _resolve_room(store, db, world_id, slug) if room is None: raise HTTPException(status_code=404, detail="room not found") key = chunk_codec.build_key(world_id, slug, cx, cy, cz) chunk = await store.get_chunk(key) if chunk is None: raise HTTPException(status_code=404, detail="chunk not found") return Response( content=chunk.payload, media_type="application/octet-stream", headers={ "ETag": f'"{chunk.checksum}"', "X-Chunk-Revision": str(chunk.revision), "X-Chunk-Key": key, }, )