Phase 9: production hardening

Config via env, abuse/resource limits, and production serving so the app
is safe to expose publicly:

- Fail-fast on missing SECRET_KEY when MULTI_USER=true
- quickjs per-execution time/memory limits (while(true) can't hang server)
- Per-user/per-IP rate limiting on turn/script/auth endpoints
- Request body size limit + per-user row caps
- Security headers (CSP, X-Frame-Options, nosniff, referrer-policy) incl. SSE
- Debug router 403 and /docs disabled in multi-user mode
- DATABASE_URL support (defaults to Neon Postgres) alongside SQLite
- Documented all env vars in backend/.env.example

Verified locally via uvicorn (MULTI_USER=1, SQLite); see plan/09-phase-hardening.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017e6tQuojBLYPetUfmhit4X
This commit is contained in:
parththakkar106
2026-07-07 12:30:06 +05:30
co-authored by Claude Opus 4.8
parent de4db373f2
commit 4772171b6c
17 changed files with 612 additions and 139 deletions
+35 -8
View File
@@ -2,12 +2,12 @@ import json
import re
import threading
from fastapi import APIRouter, Body, Depends, HTTPException
from fastapi import APIRouter, Body, Depends, HTTPException, Request
from fastapi.responses import StreamingResponse
from sqlalchemy import func
from sqlalchemy.orm import Session
from .. import auth, memorybank, models, schemas
from .. import auth, limits, memorybank, models, schemas
from ..context import build_context
from ..database import get_db
from ..providers import OpenAICompatibleProvider, PromptParts, ProviderError
@@ -70,6 +70,7 @@ def create_adventure(
db: Session = Depends(get_db),
user: models.User = CurrentUser,
):
limits.check_row_cap("adventures", db, user)
scenario = None
if payload.scenario_id is not None:
scenario = db.get(models.Scenario, payload.scenario_id)
@@ -207,6 +208,12 @@ def sse(obj: dict) -> str:
return f"data: {json.dumps(obj)}\n\n"
# no-cache defeats any intermediary caching; X-Accel-Buffering makes
# nginx-style reverse proxies (hosted deploys) flush each event immediately
# instead of buffering the stream.
SSE_HEADERS = {"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}
def action_json(action: models.Action) -> dict:
return schemas.ActionOut.model_validate(action).model_dump(mode="json")
@@ -363,24 +370,32 @@ async def run_player_turn(
def create_action(
adventure_id: int,
payload: schemas.ActionCreate,
request: Request,
db: Session = Depends(get_db),
user: models.User = CurrentUser,
):
adventure = get_adventure_or_404(adventure_id, db, user)
limits.rate_limit("turn", request, user)
limits.check_row_cap("actions", db, user, adventure=adventure)
check_demo_cap(db, user)
acquire_turn_lock(adventure_id)
return StreamingResponse(
with_turn_lock(adventure_id, run_player_turn(adventure, db, payload, user)),
media_type="text/event-stream",
headers=SSE_HEADERS,
)
@router.post("/{adventure_id}/retry")
def retry_action(
adventure_id: int, db: Session = Depends(get_db), user: models.User = CurrentUser
adventure_id: int,
request: Request,
db: Session = Depends(get_db),
user: models.User = CurrentUser,
):
"""Delete the last AI action and regenerate from the same input."""
adventure = get_adventure_or_404(adventure_id, db, user)
limits.rate_limit("turn", request, user)
check_demo_cap(db, user)
acquire_turn_lock(adventure_id)
try:
@@ -397,6 +412,7 @@ def retry_action(
generate_turn(adventure, db, ScriptPipeline(adventure, db), user),
),
media_type="text/event-stream",
headers=SSE_HEADERS,
)
@@ -472,16 +488,26 @@ def export_adventure(
@router.post("/import", response_model=schemas.AdventureOut, status_code=201)
def import_adventure(
request: Request,
bundle: dict = Body(...),
db: Session = Depends(get_db),
user: models.User = CurrentUser,
):
if bundle.get("format") != "ai-dnd-adventure-v1":
raise HTTPException(400, "Not an adventure export file (expected format ai-dnd-adventure-v1).")
limits.rate_limit("import", request, user)
limits.check_row_cap("adventures", db, user)
limits.check_bundle_lists(
story_cards=bundle.get("storyCards"),
memories=bundle.get("memories"),
actions=bundle.get("actions"),
)
# Raw-dict import bypasses the schemas — clamp strings headed for VARCHAR
# columns (Postgres enforces the widths; see schemas.py).
adventure = models.Adventure(
user_id=user.id,
title=str(bundle.get("title") or "Imported Adventure"),
title=str(bundle.get("title") or "Imported Adventure")[:schemas.NAME_MAX],
memory=str(bundle.get("memory") or ""),
authors_note=str(bundle.get("authorsNote") or ""),
ai_instructions=str(bundle.get("aiInstructions") or ""),
@@ -511,8 +537,8 @@ def import_adventure(
if isinstance(card, dict):
db.add(models.StoryCard(
adventure_id=adventure.id,
type=str(card.get("type") or ""),
name=str(card.get("name") or ""),
type=str(card.get("type") or "")[:schemas.CARD_TYPE_MAX],
name=str(card.get("name") or "")[:schemas.NAME_MAX],
keys=str(card.get("keys") or ""),
entry=str(card.get("entry") or ""),
notes=str(card.get("notes") or ""),
@@ -524,7 +550,7 @@ def import_adventure(
adventure_id=adventure.id,
position=int(s.get("position", i)),
enabled=bool(s.get("enabled", True)),
name=str(s.get("name") or "Imported Script"),
name=str(s.get("name") or "Imported Script")[:schemas.NAME_MAX],
description=str(s.get("description") or ""),
library_js=str(s.get("library") or ""),
input_js=str(s.get("input") or ""),
@@ -537,7 +563,7 @@ def import_adventure(
db.add(models.Action(
adventure_id=adventure.id,
index=int(a.get("index", i)),
type=str(a.get("type") or "story"),
type=str(a.get("type") or "story")[:20], # VARCHAR(20)
text=str(a["text"]),
reasoning=str(a["reasoning"]) if a.get("reasoning") else None,
))
@@ -631,6 +657,7 @@ def create_memory(
):
"""Manually add a memory; it gets embedded by the next post-turn pass."""
adventure = get_adventure_or_404(adventure_id, db, user)
limits.check_row_cap("memories", db, user, adventure=adventure)
if not payload.text.strip():
raise HTTPException(400, "Memory text cannot be empty")
memory = models.Memory(adventure_id=adventure.id, text=payload.text.strip())