Files
interactive-story/backend/app/limits.py
T
parththakkar106andClaude Opus 5 563b9af9cf Read one story, and know which one
Every read of an action now goes through a single module. `context/lineage.py`
turns a branch's stored lineage into the OR-of-ranges that is "this story", and
history, paging, the newest-action lookups, the index screen and the scripting
history API all select through it. A forgotten clause does not raise — it
quietly assembles a page, or a prompt, out of two different stories — so the
clause lives in one place rather than in a convention.

The read that mattered most was the shortcut: `_from_memory` sliced
`adventure.actions`, which is every branch's actions, not the path. It now cuts
the loaded collection down with the same predicate the SQL uses. Same trap one
layer up, and user-visible: `pipeline._history()` hands user scripts the story,
and was handing them the collection.

Tail reads window the lineage as well as the rows: the newest few entries cover
the context budget, so a story forked twenty times reads its tail with one
clause and costs 1.07x what an unforked story of the same length costs. The
estimate is depth arithmetic, and where a deleted action leaves a gap the read
notices it came up short and widens to the whole ancestry.

Ordering moves from `index` to `depth`, with `id` breaking ties. The two hold
the same numbers until retry stops mutating rows in SP4, but only one of them
is a position along a path.

One thing SP1 did not anticipate: wiring the writers was not enough. From here
a row without a branch is a row no read can see, and "every writer remembers"
has to hold for every fixture, script and test ever written — including the
SP0 baseline, which writes its actions straight to the database and must pass
unmodified. So the session enforces it: `tree.place_new_nodes` runs from
before_flush and places anything unplaced. The call sites keep their explicit
calls, because a node placed at the call site is placed before the code around
it reads it back.

316 tests green: the 297 from SP1, plus 19 in test_branch_clause.py.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Dvvqn9ZDR4ixeFPHNbww7
2026-08-18 19:14:07 +05:30

307 lines
12 KiB
Python

"""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 os
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)
"chat": (30, 60), # AI Chat scratchpad (power users only)
"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()
# How many proxy hops sit between the app and the real client. On Render (and
# most PaaS) that's one: the platform's edge appends the connecting IP to the
# RIGHT of X-Forwarded-For. A client can prepend anything it likes to the left,
# but it cannot push a value past the edge's own append — so the trustworthy
# client IP is the (hops)-th entry from the right, NOT uvicorn's leftmost pick.
# Trusting the leftmost let anyone rotate X-Forwarded-For to mint a fresh
# rate-limit bucket per request and bypass the auth/guest limits entirely.
# Override with AIDND_TRUSTED_PROXY_HOPS if the deployment adds more hops.
TRUSTED_PROXY_HOPS = max(1, int(os.environ.get("AIDND_TRUSTED_PROXY_HOPS", "1") or 1))
def _client_ip(request: Request) -> str:
"""The real client IP for rate-limit keying, resistant to a spoofed
X-Forwarded-For. Takes the hop the trusted edge appended (rightmost minus
any extra trusted hops); falls back to the socket peer when no forwarded
header is present (local/dev, or a direct connection)."""
forwarded = request.headers.get("x-forwarded-for")
if forwarded:
parts = [p.strip() for p in forwarded.split(",") if p.strip()]
if parts:
return parts[-min(TRUSTED_PROXY_HOPS, len(parts))]
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)
# ---------- Per-account login throttle ----------
# Defense in depth beside the per-IP `auth` limit: that one can be diluted by a
# botnet (many real source IPs, one bucket each), so it can't by itself stop a
# distributed guessing run against a single account. This cap keys on the target
# email instead of the caller, so guessing ONE account's password stays
# expensive regardless of how many addresses it comes from. Failures only — a
# correct password clears the record — and it's a short sliding window, not a
# hard lock, so a user mistyping a few times recovers on their own in minutes.
# Tradeoff: an attacker can keep a known account throttled (a nuisance), which
# is strictly preferable to letting it be brute-forced.
LOGIN_FAIL_LIMIT = 8 # failed attempts per account...
LOGIN_FAIL_WINDOW = 900 # ...within this many seconds (15 min)
_login_fails: dict[str, deque] = defaultdict(deque)
_login_guard = threading.Lock()
def check_login_allowed(email: str) -> None:
"""429 when an account has too many recent failed logins. Call before
verifying the password so guesses don't even reach the hash."""
if not auth.MULTI_USER:
return
now = time.time()
with _login_guard:
window = _login_fails[email]
while window and window[0] < now - LOGIN_FAIL_WINDOW:
window.popleft()
if len(window) >= LOGIN_FAIL_LIMIT:
raise HTTPException(
429,
"Too many failed sign-in attempts for this account — "
"wait a few minutes and try again.",
)
def note_login_failure(email: str) -> None:
"""Record one failed attempt against `email`."""
if not auth.MULTI_USER:
return
now = time.time()
with _login_guard:
_login_fails[email].append(now)
if len(_login_fails) > 10_000: # bound the map on a flood of unique emails
stale = [
key for key, window in _login_fails.items()
if not window or window[-1] < now - LOGIN_FAIL_WINDOW
]
for key in stale:
del _login_fails[key]
def note_login_success(email: str) -> None:
"""A correct password wipes the account's failure streak."""
with _login_guard:
_login_fails.pop(email, None)
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":
# Every action of the adventure — the whole tree, not the path being
# played. That is the number that costs storage, and nothing is ever
# auto-pruned, so it is the right one to cap on. It does mean a heavily
# branched adventure reaches the cap while its *story* is shorter than
# the cap, which is why the message counts "actions in this adventure"
# rather than turns.
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})