From 6d5510773a57db1e856f4a27bfbf70246a7e8ff1 Mon Sep 17 00:00:00 2001 From: Phillip Tarrant Date: Thu, 9 Jul 2026 12:42:03 -0500 Subject: [PATCH] feat(api): validate canon log at every role endpoint (422 on invalid) --- api/app/main.py | 38 ++++++++++++++++++++++++++----------- api/tests/test_endpoints.py | 38 +++++++++++++++++++++++++++++++++++++ 2 files changed, 65 insertions(+), 11 deletions(-) create mode 100644 api/tests/test_endpoints.py diff --git a/api/app/main.py b/api/app/main.py index 2223c21..6ae1412 100644 --- a/api/app/main.py +++ b/api/app/main.py @@ -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"} diff --git a/api/tests/test_endpoints.py b/api/tests/test_endpoints.py new file mode 100644 index 0000000..ee536f2 --- /dev/null +++ b/api/tests/test_endpoints.py @@ -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