From 4772171b6ca69a6f4aeda17f265a91e3e2731e4c Mon Sep 17 00:00:00 2001 From: parththakkar106 Date: Tue, 7 Jul 2026 12:30:06 +0530 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_017e6tQuojBLYPetUfmhit4X --- Dockerfile | 7 +- backend/.env.example | 34 ++++- backend/app/auth.py | 30 ++-- backend/app/database.py | 55 +++++-- backend/app/limits.py | 221 +++++++++++++++++++++++++++++ backend/app/main.py | 64 ++++++++- backend/app/migrations.py | 45 +++++- backend/app/routers/adventures.py | 43 ++++-- backend/app/routers/auth.py | 8 +- backend/app/routers/scenarios.py | 19 ++- backend/app/routers/scripts.py | 13 +- backend/app/routers/settings.py | 6 +- backend/app/routers/story_cards.py | 6 +- backend/app/schemas.py | 159 ++++++++++++--------- backend/app/security.py | 16 ++- backend/requirements.txt | 2 + plan/09-phase-hardening.md | 23 +++ 17 files changed, 612 insertions(+), 139 deletions(-) create mode 100644 backend/app/limits.py diff --git a/Dockerfile b/Dockerfile index 9047967..896a58f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -33,4 +33,9 @@ VOLUME /data EXPOSE 8000 WORKDIR /app/backend -CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] +# --proxy-headers: behind a reverse proxy (any hosted deploy), trust +# X-Forwarded-For so per-IP rate limits key on the client, not the proxy. +# Single worker on purpose: the turn lock, rate limiter, and debug log are +# in-process state. +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", \ + "--proxy-headers", "--forwarded-allow-ips", "*"] diff --git a/backend/.env.example b/backend/.env.example index 2ecdc96..67abd78 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -9,6 +9,22 @@ # Docker compose sets this to /data/data.db (a named volume). AIDND_DB_PATH= +# --------------------------------------------------------------------------- +# Phase 9 — production hardening +# --------------------------------------------------------------------------- + +# Switch from SQLite to a server database (hosted deploys use Neon Postgres). +# Any SQLAlchemy URL; postgres:// and postgresql:// schemes are rewritten to +# the psycopg3 driver automatically. The platform-conventional DATABASE_URL +# is honored too (AIDND_DATABASE_URL wins if both are set). Unset = SQLite. +AIDND_DATABASE_URL= + +# Comma-separated list of allowed CORS origins. Only needed when the frontend +# is served from a different origin than the API; the production build is +# served same-origin by FastAPI, so hosted deploys can leave this unset. +# Default: http://localhost:5173,http://127.0.0.1:5173 (the Vite dev server). +AIDND_CORS_ORIGINS= + # --------------------------------------------------------------------------- # Phase 8 — optional accounts & multi-user (all optional; defaults keep the # app in frictionless single-user "local mode") @@ -19,12 +35,17 @@ AIDND_DB_PATH= AIDND_MULTI_USER= # Secret for signing session cookies and encrypting stored API keys at rest. -# If unset, one is auto-generated into `secret.key` next to the database -# (fine for local/docker-volume runs). Set it explicitly on hosted deploys so -# sessions survive redeploys when the disk is ephemeral or replaced. +# If unset in local mode, one is auto-generated into `secret.key` next to the +# database (fine for local/docker-volume runs). REQUIRED when +# AIDND_MULTI_USER is on — the app refuses to start without it, because a +# regenerated secret on an ephemeral hosted filesystem would log out every +# user on each deploy. Generate one: +# python -c "import secrets; print(secrets.token_urlsafe(48))" AIDND_SECRET_KEY= -# "1" marks session cookies Secure (HTTPS-only). Turn on in production. +# Session cookie Secure flag (HTTPS-only). Defaults to on when +# AIDND_MULTI_USER is on, off otherwise — set 0/1 only to override (e.g. 0 +# when testing multi-user mode over plain http on a LAN address). AIDND_COOKIE_SECURE= # --- Shared demo key (BYOK fallback; only active when AIDND_MULTI_USER=1) --- @@ -41,4 +62,7 @@ AIDND_DEMO_TURNS_PER_DAY= # The AI endpoint/API key/model are NOT env vars — they are configured at # runtime in the app's Settings page and stored (encrypted) in the database. -# Phase 9 will add: rate limiting and CORS_ORIGINS. +# +# Rate limits, request size limits, and per-user row caps are hardcoded with +# generous values (see backend/app/limits.py) and active only in multi-user +# mode — local installs are never throttled. diff --git a/backend/app/auth.py b/backend/app/auth.py index 47063a3..fde9c4e 100644 --- a/backend/app/auth.py +++ b/backend/app/auth.py @@ -16,8 +16,6 @@ whitelist and a per-day turn cap. """ import os -import time -from collections import defaultdict, deque from dataclasses import dataclass from datetime import timezone @@ -35,7 +33,15 @@ def _env_flag(name: str) -> bool: MULTI_USER = _env_flag("AIDND_MULTI_USER") SESSION_COOKIE = "aidnd_session" -COOKIE_SECURE = _env_flag("AIDND_COOKIE_SECURE") # enable behind HTTPS in prod +# Secure cookies default on in multi-user (hosted = HTTPS; browsers also +# accept Secure on http://localhost). AIDND_COOKIE_SECURE=0/1 overrides — +# e.g. 0 when testing multi-user over plain http on a LAN address. +_cookie_secure_env = os.environ.get("AIDND_COOKIE_SECURE", "").strip().lower() +COOKIE_SECURE = ( + _cookie_secure_env in ("1", "true", "yes", "on") + if _cookie_secure_env + else MULTI_USER +) COOKIE_MAX_AGE = 60 * 60 * 24 * 365 # ---------- Shared demo key (BYOK fallback) ---------- @@ -151,21 +157,3 @@ def get_current_user(request: Request, db: Session = Depends(get_db)) -> models. raise HTTPException(401, "No session. Call GET /api/auth/me first.") _touch(user, db) return user - - -# ---------- Brute-force limiter for register/login ---------- - -_ATTEMPT_LIMIT = 10 -_ATTEMPT_WINDOW = 300 # seconds -_attempts: dict[str, deque] = defaultdict(deque) - - -def rate_limit_auth(request: Request) -> None: - ip = request.client.host if request.client else "unknown" - now = time.time() - window = _attempts[ip] - while window and window[0] < now - _ATTEMPT_WINDOW: - window.popleft() - if len(window) >= _ATTEMPT_LIMIT: - raise HTTPException(429, "Too many attempts. Try again in a few minutes.") - window.append(now) diff --git a/backend/app/database.py b/backend/app/database.py index 01cb33a..dc4e02d 100644 --- a/backend/app/database.py +++ b/backend/app/database.py @@ -5,28 +5,59 @@ from sqlalchemy import create_engine, event from sqlalchemy.orm import DeclarativeBase, sessionmaker # AIDND_DB_PATH lets deployments (Docker volume, hosted disk) relocate the -# database; default stays backend/data.db for local runs. +# SQLite database; default stays backend/data.db for local runs. The parent +# directory also hosts the auto-generated secret.key (see security.py), so +# DB_PATH stays defined even when Postgres is in use. _env_db_path = os.environ.get("AIDND_DB_PATH") DB_PATH = ( Path(_env_db_path).resolve() if _env_db_path else Path(__file__).resolve().parent.parent / "data.db" ) -DB_PATH.parent.mkdir(parents=True, exist_ok=True) -engine = create_engine( - f"sqlite:///{DB_PATH}", - connect_args={"check_same_thread": False}, +# AIDND_DATABASE_URL (or the platform-conventional DATABASE_URL) switches the +# app to a server database — any SQLAlchemy URL works, but Postgres is what +# hosted deploys use (Phase 9 decision: Neon). Unset = SQLite, as always. +DATABASE_URL = ( + os.environ.get("AIDND_DATABASE_URL", "").strip() + or os.environ.get("DATABASE_URL", "").strip() ) -@event.listens_for(engine, "connect") -def _enable_sqlite_foreign_keys(dbapi_connection, _record): - # SQLite ships with foreign keys OFF per connection; without this every - # ondelete=CASCADE/SET NULL in models.py is silently ignored. - cursor = dbapi_connection.cursor() - cursor.execute("PRAGMA foreign_keys=ON") - cursor.close() +def _normalize_url(url: str) -> str: + """Map the postgres:// / postgresql:// schemes hosts hand out to the + psycopg3 driver installed in requirements.txt.""" + for prefix in ("postgres://", "postgresql://"): + if url.startswith(prefix): + return "postgresql+psycopg://" + url[len(prefix):] + return url + + +if DATABASE_URL: + engine = create_engine( + _normalize_url(DATABASE_URL), + # Serverless Postgres (Neon) suspends idle databases; pre-ping + # replaces silently-dead pooled connections instead of erroring. + pool_pre_ping=True, + # Store/read naive UTC like SQLite does, regardless of server default. + connect_args={"options": "-c timezone=UTC"}, + ) +else: + DB_PATH.parent.mkdir(parents=True, exist_ok=True) + engine = create_engine( + f"sqlite:///{DB_PATH}", + connect_args={"check_same_thread": False}, + ) + + @event.listens_for(engine, "connect") + def _enable_sqlite_foreign_keys(dbapi_connection, _record): + # SQLite ships with foreign keys OFF per connection; without this every + # ondelete=CASCADE/SET NULL in models.py is silently ignored. + cursor = dbapi_connection.cursor() + cursor.execute("PRAGMA foreign_keys=ON") + cursor.close() + + SessionLocal = sessionmaker(bind=engine, autoflush=False, expire_on_commit=False) diff --git a/backend/app/limits.py b/backend/app/limits.py new file mode 100644 index 0000000..6b5cd44 --- /dev/null +++ b/backend/app/limits.py @@ -0,0 +1,221 @@ +"""Phase 9 — abuse guards for hosted (multi-user) deployments. + +Rate limits and row caps are no-ops in local mode: a single local player +should never be throttled by their own app. Values are hardcoded on purpose — +generous enough that a legitimate player never notices, tight enough that a +hostile visitor can't burn the demo key, peg the CPU, or bloat the database. +""" + +import json +import threading +import time +from collections import defaultdict, deque + +from fastapi import HTTPException, Request +from sqlalchemy import func +from sqlalchemy.orm import Session + +from . import auth, models + +# ---------- Rate limiting ---------- +# Fixed windows per (scope, caller). In-memory: fine for the single-process +# deployment this app targets (and the worst case after a restart is a brief +# extra allowance). + +# scope -> (max requests, window seconds) +RATE_LIMITS: dict[str, tuple[int, int]] = { + "turn": (10, 60), # AI turn generation (demo key also has a daily cap) + "script-test": (30, 60), # sandboxed, but each run costs up to 2s CPU + "connection-test": (10, 60), # outbound HTTP to a user-supplied URL + "import": (30, 60), # large writes + "auth": (10, 300), # register/login attempts, per IP + "guest": (30, 300), # new guest users, per IP (each is a DB row) +} + +_windows: dict[tuple[str, str], deque] = defaultdict(deque) +_windows_guard = threading.Lock() + + +def _client_ip(request: Request) -> str: + return request.client.host if request.client else "unknown" + + +def rate_limit(scope: str, request: Request, user: models.User | None = None) -> None: + """429 when the caller exceeds the scope's window. Keyed per user when one + is known (accounts survive IP changes), per IP otherwise.""" + if not auth.MULTI_USER: + return + limit, window_seconds = RATE_LIMITS[scope] + key = (scope, f"u{user.id}" if user else f"ip{_client_ip(request)}") + now = time.time() + with _windows_guard: + window = _windows[key] + while window and window[0] < now - window_seconds: + window.popleft() + if len(window) >= limit: + raise HTTPException( + 429, "You're doing that too fast — wait a minute and try again." + ) + window.append(now) + if len(_windows) > 10_000: + _prune(now) + + +def _prune(now: float) -> None: + """Drop callers whose whole window has expired (call with guard held) so + the per-IP dict can't grow without bound.""" + longest = max(seconds for _, seconds in RATE_LIMITS.values()) + stale = [key for key, window in _windows.items() + if not window or window[-1] < now - longest] + for key in stale: + del _windows[key] + + +# ---------- Per-user row caps ---------- + +MAX_ADVENTURES_PER_USER = 100 +MAX_SCENARIOS_PER_USER = 200 +MAX_SCRIPTS_PER_USER = 200 +MAX_STORY_CARDS_PER_OWNER = 200 # per scenario or adventure +MAX_MEMORIES_PER_ADVENTURE = 1000 +MAX_ACTIONS_PER_ADVENTURE = 5000 + + +def check_row_cap( + kind: str, + db: Session, + user: models.User, + *, + adventure: models.Adventure | None = None, + scenario_id: int | None = None, + adventure_id: int | None = None, +) -> None: + """409 with a friendly message when creating one more row of `kind` would + exceed its cap. Ownership of the passed scenario/adventure has already + been checked by the caller.""" + if not auth.MULTI_USER: + return + if kind == "adventures": + count = _count(db, models.Adventure, models.Adventure.user_id == user.id) + cap, subject, hint = ( + MAX_ADVENTURES_PER_USER, "adventures", + "delete one you no longer play to make room", + ) + elif kind == "scenarios": + count = _count(db, models.Scenario, models.Scenario.user_id == user.id) + cap, subject, hint = ( + MAX_SCENARIOS_PER_USER, "scenarios", "delete one to make room" + ) + elif kind == "scripts": + count = _count(db, models.Script, models.Script.user_id == user.id) + cap, subject, hint = ( + MAX_SCRIPTS_PER_USER, "scripts", "delete one to make room" + ) + elif kind == "story_cards": + owner_filter = ( + models.StoryCard.scenario_id == scenario_id + if scenario_id is not None + else models.StoryCard.adventure_id == adventure_id + ) + count = _count(db, models.StoryCard, owner_filter) + cap, subject, hint = ( + MAX_STORY_CARDS_PER_OWNER, "story cards here", "delete one to make room" + ) + elif kind == "memories": + count = _count(db, models.Memory, models.Memory.adventure_id == adventure.id) + cap, subject, hint = ( + MAX_MEMORIES_PER_ADVENTURE, "memories in this adventure", + "delete some to make room", + ) + elif kind == "actions": + count = _count(db, models.Action, models.Action.adventure_id == adventure.id) + cap, subject, hint = ( + MAX_ACTIONS_PER_ADVENTURE, "actions in this adventure", + "export it and continue in a new adventure", + ) + else: # pragma: no cover — programming error, not user input + raise ValueError(f"Unknown row cap kind: {kind}") + if count >= cap: + raise HTTPException(409, f"You've reached the limit of {cap} {subject} — {hint}.") + + +def _count(db: Session, model, condition) -> int: + return db.query(func.count(model.id)).filter(condition).scalar() or 0 + + +_BUNDLE_LIST_CAPS = { + "story_cards": MAX_STORY_CARDS_PER_OWNER, + "memories": MAX_MEMORIES_PER_ADVENTURE, + "actions": MAX_ACTIONS_PER_ADVENTURE, +} + + +def check_bundle_lists(**lists) -> None: + """409 when an import bundle's lists exceed the same caps live creation + enforces (kwargs: story_cards=, memories=, actions=).""" + if not auth.MULTI_USER: + return + for name, value in lists.items(): + cap = _BUNDLE_LIST_CAPS[name] + if isinstance(value, list) and len(value) > cap: + noun = name.replace("_", " ") + raise HTTPException( + 409, f"This file contains {len(value)} {noun} — the limit is {cap}." + ) + + +# ---------- Request body size ---------- +# Generous enough for the biggest legitimate payload (an adventure export with +# thousands of actions), applied in every mode — no honest request comes close. + +MAX_BODY_BYTES = 2 * 1024 * 1024 +MAX_IMPORT_BODY_BYTES = 20 * 1024 * 1024 + + +class BodySizeLimitMiddleware: + """Rejects oversized request bodies by declared Content-Length. Pure ASGI + (not BaseHTTPMiddleware) so SSE responses stream through untouched. + Chunked uploads without a length are refused — every real client of this + API (browser fetch, curl with a file) sends Content-Length.""" + + def __init__(self, app): + self.app = app + + async def __call__(self, scope, receive, send): + if scope["type"] == "http" and scope.get("method") in ("POST", "PUT", "PATCH"): + headers = {k.decode("latin-1").lower(): v.decode("latin-1") + for k, v in scope.get("headers", [])} + limit = ( + MAX_IMPORT_BODY_BYTES + if scope.get("path", "").endswith("/import") + else MAX_BODY_BYTES + ) + length = headers.get("content-length") + problem = None + if length is None: + if "chunked" in headers.get("transfer-encoding", "").lower(): + problem = (411, "Content-Length is required.") + else: + try: + if int(length) > limit: + problem = ( + 413, + f"Request too large (limit {limit // (1024 * 1024)} MB).", + ) + except ValueError: + problem = (400, "Invalid Content-Length.") + if problem: + await _send_json_error(send, *problem) + return + await self.app(scope, receive, send) + + +async def _send_json_error(send, status: int, detail: str) -> None: + body = json.dumps({"detail": detail}).encode() + await send({ + "type": "http.response.start", + "status": status, + "headers": [(b"content-type", b"application/json"), + (b"content-length", str(len(body)).encode())], + }) + await send({"type": "http.response.body", "body": body}) diff --git a/backend/app/main.py b/backend/app/main.py index 72eab8c..6de4ae6 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1,3 +1,4 @@ +import os from pathlib import Path from fastapi import FastAPI @@ -5,21 +6,80 @@ from fastapi.middleware.cors import CORSMiddleware from fastapi.staticfiles import StaticFiles from starlette.exceptions import HTTPException as StarletteHTTPException +from .auth import MULTI_USER from .database import engine +from .limits import BodySizeLimitMiddleware from .migrations import bootstrap from .routers import adventures, auth, debug, scenarios, scripts, settings, story_cards bootstrap(engine) -app = FastAPI(title="AI D&D") +# Production serves the SPA same-origin, so CORS only matters for the Vite dev +# server; AIDND_CORS_ORIGINS overrides for any other cross-origin setup. +CORS_ORIGINS = [ + o.strip() + for o in os.environ.get("AIDND_CORS_ORIGINS", "").split(",") + if o.strip() +] or ["http://localhost:5173", "http://127.0.0.1:5173"] + +# The interactive API docs stay local-only: in multi-user mode they just hand +# strangers a map of the API surface. +app = FastAPI( + title="AI D&D", + docs_url=None if MULTI_USER else "/docs", + redoc_url=None, + openapi_url=None if MULTI_USER else "/openapi.json", +) app.add_middleware( CORSMiddleware, - allow_origins=["http://localhost:5173", "http://127.0.0.1:5173"], + allow_origins=CORS_ORIGINS, allow_methods=["*"], allow_headers=["*"], ) +app.add_middleware(BodySizeLimitMiddleware) + + +class SecurityHeadersMiddleware: + """Standard hardening headers on every response. Pure ASGI (wraps `send`) + so SSE streams pass through unbuffered. The CSP allows exactly what the + SPA uses: same-origin everything, inline styles (React), Google Fonts.""" + + _HEADERS = [ + (b"x-content-type-options", b"nosniff"), + (b"referrer-policy", b"same-origin"), + (b"x-frame-options", b"DENY"), + ( + b"content-security-policy", + b"default-src 'self'; " + b"script-src 'self'; " + b"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; " + b"font-src https://fonts.gstatic.com; " + b"img-src 'self' data:; " + b"connect-src 'self'; " + b"frame-ancestors 'none'", + ), + ] + + def __init__(self, app): + self.app = app + + async def __call__(self, scope, receive, send): + if scope["type"] != "http": + return await self.app(scope, receive, send) + + async def send_with_headers(message): + if message["type"] == "http.response.start": + message.setdefault("headers", []) + message["headers"] = list(message["headers"]) + self._HEADERS + await send(message) + + await self.app(scope, receive, send_with_headers) + + +app.add_middleware(SecurityHeadersMiddleware) + app.include_router(auth.router) app.include_router(scenarios.router) app.include_router(adventures.router) diff --git a/backend/app/migrations.py b/backend/app/migrations.py index a7ee7b2..0ed1dd6 100644 --- a/backend/app/migrations.py +++ b/backend/app/migrations.py @@ -1,14 +1,20 @@ -"""Lightweight versioned schema migrations over SQLite's PRAGMA user_version. +"""Lightweight versioned schema migrations. How it works: - A fresh database is created by `Base.metadata.create_all()` (always current) and stamped with LATEST_VERSION. - An existing database runs every migration whose version is greater than its - stored user_version, in order, then is stamped. + stored version, in order, then is stamped. + +The version lives in SQLite's PRAGMA user_version, or a one-row +`schema_version` table on Postgres (no PRAGMA there). To change the schema: update models.py (keeps fresh DBs current) AND append a (version, sql) pair here (upgrades existing DBs). Keep migrations idempotent -where cheap (IF NOT EXISTS etc.). +where cheap (IF NOT EXISTS etc.). Migrations up to 23 predate Postgres support +and use SQLite-only syntax — that's fine because every Postgres database +starts fresh (created by create_all, stamped LATEST, never replays them), but +migrations added from Phase 9 on must run on both dialects. """ from sqlalchemy import inspect, text @@ -70,19 +76,46 @@ MIGRATIONS: list[tuple[int, str]] = [ LATEST_VERSION = max((v for v, _ in MIGRATIONS), default=1) +def _get_version(conn) -> int: + if conn.dialect.name == "sqlite": + return conn.execute(text("PRAGMA user_version")).scalar() or 1 + conn.execute(text( + "CREATE TABLE IF NOT EXISTS schema_version (version INTEGER NOT NULL)" + )) + version = conn.execute(text("SELECT version FROM schema_version")).scalar() + # A non-fresh database with no stamp can only have been created by an + # earlier create_all of this same codebase — i.e. already at LATEST. + return version if version is not None else LATEST_VERSION + + +def _set_version(conn, version: int) -> None: + if conn.dialect.name == "sqlite": + conn.execute(text(f"PRAGMA user_version = {version}")) + return + conn.execute(text( + "CREATE TABLE IF NOT EXISTS schema_version (version INTEGER NOT NULL)" + )) + if conn.execute(text("SELECT version FROM schema_version")).scalar() is None: + conn.execute( + text("INSERT INTO schema_version (version) VALUES (:v)"), {"v": version} + ) + else: + conn.execute(text("UPDATE schema_version SET version = :v"), {"v": version}) + + def bootstrap(engine: Engine) -> None: fresh = not inspect(engine).get_table_names() Base.metadata.create_all(bind=engine) with engine.begin() as conn: if fresh: - conn.execute(text(f"PRAGMA user_version = {LATEST_VERSION}")) + _set_version(conn, LATEST_VERSION) return - current = conn.execute(text("PRAGMA user_version")).scalar() or 1 + current = _get_version(conn) for version, sql in MIGRATIONS: if version > current: conn.execute(text(sql)) current = version - conn.execute(text(f"PRAGMA user_version = {current}")) + _set_version(conn, current) _encrypt_plaintext_api_keys(conn) diff --git a/backend/app/routers/adventures.py b/backend/app/routers/adventures.py index 2446123..9a96b1f 100644 --- a/backend/app/routers/adventures.py +++ b/backend/app/routers/adventures.py @@ -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()) diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py index 4141425..c9dda13 100644 --- a/backend/app/routers/auth.py +++ b/backend/app/routers/auth.py @@ -3,7 +3,7 @@ import re from fastapi import APIRouter, Depends, HTTPException, Request, Response from sqlalchemy.orm import Session -from .. import auth, models, schemas, security +from .. import auth, limits, models, schemas, security from ..database import get_db from .settings import get_settings @@ -53,6 +53,8 @@ def me(request: Request, response: Response, db: Session = Depends(get_db)): else: user = auth.resolve_session_user(request, db) if user is None: + # Each new guest is a database row — cap how fast one IP can mint them. + limits.rate_limit("guest", request) user = models.User(is_guest=True) db.add(user) db.commit() @@ -71,7 +73,7 @@ def register( scenario, script and setting they created as a guest is kept.""" if not auth.MULTI_USER: raise HTTPException(400, "Accounts are disabled in local mode.") - auth.rate_limit_auth(request) + limits.rate_limit("auth", request) email = payload.email.strip().lower() if not EMAIL_RE.match(email): raise HTTPException(422, "Enter a valid email address.") @@ -99,7 +101,7 @@ def login( session is simply abandoned (its data stays under the guest user).""" if not auth.MULTI_USER: raise HTTPException(400, "Accounts are disabled in local mode.") - auth.rate_limit_auth(request) + limits.rate_limit("auth", request) email = payload.email.strip().lower() user = db.query(models.User).filter(models.User.email == email).first() if ( diff --git a/backend/app/routers/scenarios.py b/backend/app/routers/scenarios.py index 61a016f..ba11c32 100644 --- a/backend/app/routers/scenarios.py +++ b/backend/app/routers/scenarios.py @@ -1,8 +1,8 @@ -from fastapi import APIRouter, Body, Depends, HTTPException +from fastapi import APIRouter, Body, Depends, HTTPException, Request from sqlalchemy import or_ from sqlalchemy.orm import Session -from .. import auth, models, schemas +from .. import auth, limits, models, schemas from ..database import get_db router = APIRouter(prefix="/api/scenarios", tags=["scenarios"]) @@ -39,6 +39,7 @@ def create_scenario( db: Session = Depends(get_db), user: models.User = Depends(auth.get_current_user), ): + limits.check_row_cap("scenarios", db, user) scenario = models.Scenario(**payload.model_dump(), user_id=user.id) db.add(scenario) db.commit() @@ -141,12 +142,15 @@ _IGNORED_KEYS = {"format", "storyCards", "worldInfo", "worldInformation", "scrip @router.post("/import", status_code=201) def import_scenario( + request: Request, bundle: dict = Body(...), db: Session = Depends(get_db), user: models.User = Depends(auth.get_current_user), ): """Accepts our export format and AI Dungeon scenario exports best-effort; reports any keys it didn't understand.""" + limits.rate_limit("import", request, user) + limits.check_row_cap("scenarios", db, user) fields: dict = {} unmapped: list[str] = [] for key, value in bundle.items(): @@ -164,6 +168,10 @@ def import_scenario( scenario = models.Scenario(**fields, user_id=user.id) if not scenario.title: scenario.title = "Imported Scenario" + # Raw-dict import bypasses the schemas — clamp to VARCHAR widths + # (Postgres enforces them; see schemas.py). + scenario.title = scenario.title[:schemas.NAME_MAX] + scenario.tags = scenario.tags[:schemas.TAGS_MAX] db.add(scenario) db.flush() @@ -174,14 +182,15 @@ def import_scenario( or bundle.get("worldInformation") or [] ) + limits.check_bundle_lists(story_cards=cards) for card in cards: if not isinstance(card, dict): continue db.add( models.StoryCard( scenario_id=scenario.id, - type=str(card.get("type") or ""), - name=str(card.get("name") or card.get("title") or ""), + type=str(card.get("type") or "")[:schemas.CARD_TYPE_MAX], + name=str(card.get("name") or card.get("title") or "")[:schemas.NAME_MAX], keys=str(card.get("keys") or ""), # AI Dungeon world info uses "value"; story cards use "entry". entry=str(card.get("entry") or card.get("value") or ""), @@ -194,7 +203,7 @@ def import_scenario( continue script = models.Script( user_id=user.id, - name=str(item.get("name") or "Imported Script"), + name=str(item.get("name") or "Imported Script")[:schemas.NAME_MAX], description=str(item.get("description") or ""), library_js=str(item.get("library") or item.get("sharedLibrary") or ""), input_js=str(item.get("input") or item.get("onInput") or ""), diff --git a/backend/app/routers/scripts.py b/backend/app/routers/scripts.py index 3e92453..542d26e 100644 --- a/backend/app/routers/scripts.py +++ b/backend/app/routers/scripts.py @@ -1,7 +1,7 @@ -from fastapi import APIRouter, Body, Depends, HTTPException +from fastapi import APIRouter, Body, Depends, HTTPException, Request from sqlalchemy.orm import Session -from .. import auth, models, schemas +from .. import auth, limits, models, schemas from ..database import get_db from ..scripting import run_hook @@ -36,6 +36,7 @@ def create_script( db: Session = Depends(get_db), user: models.User = Depends(auth.get_current_user), ): + limits.check_row_cap("scripts", db, user) script = models.Script(**payload.model_dump(), user_id=user.id) db.add(script) db.commit() @@ -79,11 +80,13 @@ def delete_script( def test_script( script_id: int, payload: schemas.ScriptTestRequest, + request: Request, db: Session = Depends(get_db), user: models.User = Depends(auth.get_current_user), ): """Dry-run one hook against sample text — no AI call, no persistence.""" script = get_script_or_404(script_id, db, user) + limits.rate_limit("script-test", request, user) result = run_hook( script.library_js, getattr(script, HOOK_FIELDS[payload.hook]), @@ -125,11 +128,14 @@ def export_script( @router.post("/import", response_model=schemas.ScriptOut, status_code=201) def import_script( + request: Request, bundle: dict = Body(...), db: Session = Depends(get_db), user: models.User = Depends(auth.get_current_user), ): """Accepts our export bundle; tolerates *_js key names too.""" + limits.rate_limit("import", request, user) + limits.check_row_cap("scripts", db, user) def pick(*keys: str) -> str: for key in keys: value = bundle.get(key) @@ -139,7 +145,8 @@ def import_script( script = models.Script( user_id=user.id, - name=pick("name") or "Imported Script", + # Raw-dict import bypasses the schemas — clamp to the VARCHAR width. + name=(pick("name") or "Imported Script")[:schemas.NAME_MAX], description=pick("description"), library_js=pick("library", "library_js", "sharedLibrary"), input_js=pick("input", "input_js", "onInput"), diff --git a/backend/app/routers/settings.py b/backend/app/routers/settings.py index fc41954..a33be0f 100644 --- a/backend/app/routers/settings.py +++ b/backend/app/routers/settings.py @@ -1,8 +1,8 @@ import httpx -from fastapi import APIRouter, Depends +from fastapi import APIRouter, Depends, Request from sqlalchemy.orm import Session -from .. import auth, models, schemas, security +from .. import auth, limits, models, schemas, security from ..database import get_db router = APIRouter(prefix="/api/settings", tags=["settings"]) @@ -64,12 +64,14 @@ def update_settings( @router.post("/test") async def test_connection( + request: Request, db: Session = Depends(get_db), user: models.User = Depends(auth.get_current_user), ): """Hit the endpoint's /models listing as a cheap connectivity check. Tests whatever the turn engine would actually use — including the shared demo endpoint when the user has no key of their own.""" + limits.rate_limit("connection-test", request, user) settings = get_settings(db, user) cfg = auth.resolve_provider_config(settings) url = cfg.endpoint_url.rstrip("/") + "/models" diff --git a/backend/app/routers/story_cards.py b/backend/app/routers/story_cards.py index 74a4e32..96a3fd0 100644 --- a/backend/app/routers/story_cards.py +++ b/backend/app/routers/story_cards.py @@ -1,7 +1,7 @@ from fastapi import APIRouter, Depends, HTTPException from sqlalchemy.orm import Session -from .. import auth, models, schemas +from .. import auth, limits, models, schemas from ..database import get_db router = APIRouter(prefix="/api/story-cards", tags=["story-cards"]) @@ -50,6 +50,10 @@ def create_story_card( owner = db.get(owner_model, owner_id) if owner is None or owner.user_id != user.id: raise HTTPException(404, "Owner not found") + limits.check_row_cap( + "story_cards", db, user, + scenario_id=payload.scenario_id, adventure_id=payload.adventure_id, + ) card = models.StoryCard(**payload.model_dump()) db.add(card) db.commit() diff --git a/backend/app/schemas.py b/backend/app/schemas.py index f8aed8c..e45e61f 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -1,7 +1,26 @@ from datetime import datetime -from typing import Literal +from typing import Annotated, Literal -from pydantic import BaseModel, ConfigDict +from pydantic import BaseModel, ConfigDict, Field + +# Length caps (Phase 9). The VARCHAR ones are correctness, not just abuse +# limits: Postgres enforces column lengths (SQLite never did), so anything +# longer must be a 422 here rather than a 500 at INSERT. Text-column caps are +# generous abuse ceilings a legitimate player won't hit. +NAME_MAX = 200 # titles/names — VARCHAR(200) +TAGS_MAX = 500 # VARCHAR(500) +CARD_TYPE_MAX = 100 # VARCHAR(100) +PROSE_MAX = 50_000 # memory, author's note, prompts, entries, notes... +SCRIPT_MAX = 200_000 # one JS source +ACTION_MAX = 20_000 # one player action +MEMORY_TEXT_MAX = 5_000 + +Name = Annotated[str, Field(max_length=NAME_MAX)] +Tags = Annotated[str, Field(max_length=TAGS_MAX)] +CardType = Annotated[str, Field(max_length=CARD_TYPE_MAX)] +Prose = Annotated[str, Field(max_length=PROSE_MAX)] +ScriptSource = Annotated[str, Field(max_length=SCRIPT_MAX)] +ActionText = Annotated[str, Field(max_length=ACTION_MAX)] class ORMModel(BaseModel): @@ -11,11 +30,11 @@ class ORMModel(BaseModel): # ---------- Story cards ---------- class StoryCardBase(BaseModel): - type: str = "" - name: str = "" - keys: str = "" - entry: str = "" - notes: str = "" + type: CardType = "" + name: Name = "" + keys: Prose = "" + entry: Prose = "" + notes: Prose = "" class StoryCardCreate(StoryCardBase): @@ -24,11 +43,11 @@ class StoryCardCreate(StoryCardBase): class StoryCardUpdate(BaseModel): - type: str | None = None - name: str | None = None - keys: str | None = None - entry: str | None = None - notes: str | None = None + type: CardType | None = None + name: Name | None = None + keys: Prose | None = None + entry: Prose | None = None + notes: Prose | None = None class StoryCardOut(ORMModel, StoryCardBase): @@ -40,13 +59,13 @@ class StoryCardOut(ORMModel, StoryCardBase): # ---------- Scenarios ---------- class ScenarioBase(BaseModel): - title: str = "Untitled Scenario" - description: str = "" - prompt: str = "" - memory: str = "" - authors_note: str = "" - ai_instructions: str = "" - tags: str = "" + title: Name = "Untitled Scenario" + description: Prose = "" + prompt: Prose = "" + memory: Prose = "" + authors_note: Prose = "" + ai_instructions: Prose = "" + tags: Tags = "" class ScenarioCreate(ScenarioBase): @@ -54,13 +73,13 @@ class ScenarioCreate(ScenarioBase): class ScenarioUpdate(BaseModel): - title: str | None = None - description: str | None = None - prompt: str | None = None - memory: str | None = None - authors_note: str | None = None - ai_instructions: str | None = None - tags: str | None = None + title: Name | None = None + description: Prose | None = None + prompt: Prose | None = None + memory: Prose | None = None + authors_note: Prose | None = None + ai_instructions: Prose | None = None + tags: Tags | None = None script_ids: list[int] | None = None @@ -86,17 +105,17 @@ class ScenarioListItem(ORMModel): class AdventureCreate(BaseModel): scenario_id: int | None = None - title: str | None = None + title: Name | None = None # ${Placeholder} values collected from the player at start (AI Dungeon behavior). placeholders: dict[str, str] = {} class AdventureUpdate(BaseModel): - title: str | None = None - memory: str | None = None - authors_note: str | None = None - ai_instructions: str | None = None - story_summary: str | None = None + title: Name | None = None + memory: Prose | None = None + authors_note: Prose | None = None + ai_instructions: Prose | None = None + story_summary: Prose | None = None auto_summarize: bool | None = None memory_bank_enabled: bool | None = None @@ -112,12 +131,12 @@ class ActionOut(ORMModel): class ActionUpdate(BaseModel): - text: str + text: ActionText class ActionCreate(BaseModel): type: Literal["do", "say", "story", "continue"] - text: str = "" + text: ActionText = "" class AdventureOut(ORMModel): @@ -153,11 +172,11 @@ class MemoryOut(ORMModel): class MemoryCreate(BaseModel): - text: str + text: Annotated[str, Field(max_length=MEMORY_TEXT_MAX)] class MemoryUpdate(BaseModel): - text: str | None = None + text: Annotated[str, Field(max_length=MEMORY_TEXT_MAX)] | None = None pinned: bool | None = None forgotten: bool | None = None @@ -174,12 +193,12 @@ class AdventureListItem(ORMModel): # ---------- Scripts ---------- class ScriptBase(BaseModel): - name: str = "Untitled Script" - description: str = "" - library_js: str = "" - input_js: str = "" - context_js: str = "" - output_js: str = "" + name: Name = "Untitled Script" + description: Prose = "" + library_js: ScriptSource = "" + input_js: ScriptSource = "" + context_js: ScriptSource = "" + output_js: ScriptSource = "" class ScriptCreate(ScriptBase): @@ -187,12 +206,12 @@ class ScriptCreate(ScriptBase): class ScriptUpdate(BaseModel): - name: str | None = None - description: str | None = None - library_js: str | None = None - input_js: str | None = None - context_js: str | None = None - output_js: str | None = None + name: Name | None = None + description: Prose | None = None + library_js: ScriptSource | None = None + input_js: ScriptSource | None = None + context_js: ScriptSource | None = None + output_js: ScriptSource | None = None class ScriptOut(ORMModel, ScriptBase): @@ -203,7 +222,7 @@ class ScriptOut(ORMModel, ScriptBase): class ScriptTestRequest(BaseModel): hook: Literal["input", "context", "output"] - text: str = "" + text: Prose = "" state: dict = {} @@ -222,17 +241,19 @@ class AdventureScriptOut(ORMModel): class AdventureScriptUpdate(BaseModel): enabled: bool | None = None - library_js: str | None = None - input_js: str | None = None - context_js: str | None = None - output_js: str | None = None + library_js: ScriptSource | None = None + input_js: ScriptSource | None = None + context_js: ScriptSource | None = None + output_js: ScriptSource | None = None # ---------- Auth (Phase 8) ---------- class AuthCredentials(BaseModel): - email: str - password: str + email: Annotated[str, Field(max_length=320)] # VARCHAR(320) + # Upper bound keeps scrypt cost flat — hashing megabyte "passwords" is CPU + # an attacker would otherwise get for free. + password: Annotated[str, Field(max_length=128)] # ---------- Settings ---------- @@ -259,17 +280,19 @@ ScenarioOut.model_rebuild() class SettingsUpdate(BaseModel): - endpoint_url: str | None = None - api_key: str | None = None - model: str | None = None - api_mode: str | None = None - temperature: float | None = None - max_output_tokens: int | None = None - reasoning_max_tokens: int | None = None - context_token_budget: int | None = None - narrator_prompt: str | None = None + endpoint_url: Annotated[str, Field(max_length=500)] | None = None # VARCHAR(500) + # Encryption expands the stored value ~4/3 into the same VARCHAR(500): + # 256 plaintext chars is the largest safe input ("enc:" + Fernet + base64). + api_key: Annotated[str, Field(max_length=256)] | None = None + model: Name | None = None + api_mode: Annotated[str, Field(max_length=20)] | None = None + temperature: Annotated[float, Field(ge=0, le=5)] | None = None + max_output_tokens: Annotated[int, Field(ge=1, le=100_000)] | None = None + reasoning_max_tokens: Annotated[int, Field(ge=0, le=100_000)] | None = None + context_token_budget: Annotated[int, Field(ge=256, le=200_000)] | None = None + narrator_prompt: Prose | None = None stream: bool | None = None - summary_model: str | None = None - embedding_model: str | None = None - memory_bank_capacity: int | None = None - memory_top_k: int | None = None + summary_model: Name | None = None + embedding_model: Name | None = None + memory_bank_capacity: Annotated[int, Field(ge=1, le=1000)] | None = None + memory_top_k: Annotated[int, Field(ge=1, le=50)] | None = None diff --git a/backend/app/security.py b/backend/app/security.py index d50e1c6..9de2f52 100644 --- a/backend/app/security.py +++ b/backend/app/security.py @@ -7,7 +7,9 @@ Everything keys off one server-side secret: The secret comes from AIDND_SECRET_KEY, or is auto-generated once into `secret.key` next to the database so local installs and Docker volumes work with zero configuration (losing the file logs everyone out and orphans -stored API keys — users just re-enter them). +stored API keys — users just re-enter them). Multi-user deploys must set the +env var: hosted filesystems are ephemeral, and a secret.key regenerated on +every deploy would silently log out all users each time. Passwords use hashlib.scrypt (stdlib, OpenSSL-backed) so we don't need a separate hashing dependency. @@ -27,9 +29,19 @@ _SECRET_FILE = DB_PATH.parent / "secret.key" def _load_secret() -> bytes: - env = os.environ.get("AIDND_SECRET_KEY") + env = os.environ.get("AIDND_SECRET_KEY", "").strip() if env: return env.encode() + # Same flag parse as auth.MULTI_USER (auth imports this module, so it + # can't be imported from there). + if os.environ.get("AIDND_MULTI_USER", "").strip().lower() in ("1", "true", "yes", "on"): + raise RuntimeError( + "AIDND_SECRET_KEY must be set when AIDND_MULTI_USER is on: an " + "auto-generated secret.key on an ephemeral hosted filesystem would " + "rotate on every deploy, logging out every user and orphaning " + "their stored API keys. Generate one with: " + "python -c \"import secrets; print(secrets.token_urlsafe(48))\"" + ) if _SECRET_FILE.exists(): return _SECRET_FILE.read_bytes().strip() secret = secrets.token_urlsafe(48).encode() diff --git a/backend/requirements.txt b/backend/requirements.txt index e621f4b..fba864e 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -5,3 +5,5 @@ pydantic>=2.7 httpx>=0.27 tiktoken>=0.7 quickjs>=1.19 +cryptography>=42 +psycopg[binary]>=3.2 diff --git a/plan/09-phase-hardening.md b/plan/09-phase-hardening.md index 3323de6..c613ec2 100644 --- a/plan/09-phase-hardening.md +++ b/plan/09-phase-hardening.md @@ -53,3 +53,26 @@ Render tier: free tier sleeps after idle + has no persistent disk). Running the production Docker image locally with `MULTI_USER=true`: a hostile user cannot hang the server with a `while(true)` script, cannot see another user's data or the debug log, gets rate-limited instead of burning the demo key, and the app streams turns normally the whole time. + +### Verified 2026-07-07 (uvicorn, `MULTI_USER=1`, fresh SQLite DB, curl) + +- **Fail-fast secret:** `import app.main` with `MULTI_USER=1` and no `AIDND_SECRET_KEY` raises + the RuntimeError as designed (won't boot). +- **`while(true)` script:** `POST /api/scripts/{id}/test` on an `input_js` infinite loop returns + `InternalError: interrupted` (engine time limit) — server stays responsive afterward. +- **Cross-user isolation:** guest B sees `[]` for scripts, gets 404 on guest A's script id; + guest A keeps its own row. No leakage. +- **Debug log:** `GET /api/debug/requests` → 403 in multi-user mode. +- **Rate limiting:** 12 rapid `POST /api/auth/register` → 429 after the 10th (auth scope, 10/300s). +- **Body size:** 3 MB body to `POST /api/scenarios` → 413 (limit 2 MB) via BodySizeLimitMiddleware. +- **Security headers:** CSP, `x-frame-options: DENY`, `x-content-type-options: nosniff`, + `referrer-policy: same-origin` on every response — including the SSE stream. +- **Docs disabled:** Swagger UI and OpenAPI schema not served (`/docs`, `/openapi.json` fall + through to the SPA `index.html`; no `swagger-ui`, no API schema exposed). +- **SSE streaming:** `POST /api/adventures/{id}/actions` streams `text/event-stream` with + `x-accel-buffering: no`, chunked, incremental events — the pure-ASGI middlewares don't buffer. + (No LLM key configured here, so it streams the "No model configured" error event; a *live* + provider turn through this path was verified end-to-end in Phase 8.) + +All Phase 9 exit criteria met. Not yet exercised: Postgres (`DATABASE_URL`) path and the +Docker production image specifically — both are Phase 10 deploy steps.