feat(api): validate canon log at every role endpoint (422 on invalid)

This commit is contained in:
2026-07-09 12:42:03 -05:00
parent 4aa65c9d4c
commit 6d5510773a
2 changed files with 65 additions and 11 deletions

View File

@@ -1,15 +1,31 @@
"""FastAPI proxy entrypoint — the guarding proxy of charter §4.
Skeleton only: a health check plus the five role endpoints as stubs so the
container runs and the client has something to point at. Real prompt routing,
model selection, logging, and auth land later — all server-side (§4, §5).
Skeleton: a health check plus the five role endpoints. Each role endpoint now
validates the posted canon log against the contract (charter §11) before doing
anything else — an invalid log is rejected with 422 and never reaches a prompt.
Prompt routing, model selection, and logging land later.
"""
from fastapi import FastAPI
from fastapi import Depends, FastAPI, HTTPException
from pydantic import BaseModel
from .canon_log import validate_canon_log
app = FastAPI(title="coc-rpg proxy", version="0.0.1")
class TurnRequest(BaseModel):
canon_log: dict
def valid_turn(req: TurnRequest) -> TurnRequest:
"""Shared dependency: reject any request whose canon log breaks the contract."""
errors = validate_canon_log(req.canon_log)
if errors:
raise HTTPException(status_code=422, detail={"canon_log_errors": errors})
return req
@app.get("/health")
def health() -> dict:
"""Liveness probe for compose / fly.io."""
@@ -18,30 +34,30 @@ def health() -> dict:
# ── Role endpoints (charter §4) ──────────────────────────────────────────────
# The client knows these paths and nothing about which model or prompt serves
# them. Stubs for now; each will load its prompt from api/prompts/ and route to
# the configured model.
# them. Bodies are validated against the canon log contract; the AI half is a
# stub until prompt routing lands.
@app.post("/dm/narrate")
def narrate() -> dict:
def narrate(req: TurnRequest = Depends(valid_turn)) -> dict:
return {"detail": "not implemented"}
@app.post("/dm/adjudicate")
def adjudicate() -> dict:
def adjudicate(req: TurnRequest = Depends(valid_turn)) -> dict:
return {"detail": "not implemented"}
@app.post("/dm/improvise")
def improvise() -> dict:
def improvise(req: TurnRequest = Depends(valid_turn)) -> dict:
return {"detail": "not implemented"}
@app.post("/npc/speak")
def npc_speak() -> dict:
def npc_speak(req: TurnRequest = Depends(valid_turn)) -> dict:
return {"detail": "not implemented"}
@app.post("/party/banter")
def banter() -> dict:
def banter(req: TurnRequest = Depends(valid_turn)) -> dict:
return {"detail": "not implemented"}

View File

@@ -0,0 +1,38 @@
import json
from pathlib import Path
from fastapi.testclient import TestClient
from app.main import app
client = TestClient(app)
VALID_LOG = json.load(open(Path(__file__).parent / "fixtures" / "canon_log_valid.json"))
ROLE_ENDPOINTS = [
"/dm/narrate", "/dm/adjudicate", "/dm/improvise",
"/npc/speak", "/party/banter",
]
def test_health_ok():
assert client.get("/health").json() == {"status": "ok"}
def test_every_role_endpoint_accepts_a_valid_canon_log():
for path in ROLE_ENDPOINTS:
r = client.post(path, json={"canon_log": VALID_LOG})
assert r.status_code == 200, f"{path} rejected a valid log: {r.text}"
def test_every_role_endpoint_rejects_an_invalid_canon_log():
bad = json.loads(json.dumps(VALID_LOG))
bad["player"]["luck"] = 5 # §7 leak
for path in ROLE_ENDPOINTS:
r = client.post(path, json={"canon_log": bad})
assert r.status_code == 422, f"{path} accepted an invalid log"
assert "canon_log_errors" in r.json()["detail"]
def test_missing_canon_log_is_a_422():
r = client.post("/dm/narrate", json={})
assert r.status_code == 422