M2: cut the hosted product away from the local one

94 files, +1,395 -6,578. Three files are new; twenty-four are gone. The
milestone is subtraction, and what is left is the single-user local
storyteller the specification describes.

Removed in full: campaign scripting and its QuickJS sandbox; multi-user
accounts, guest sessions, login, registration and the shared demo key;
the visitor-analytics tables, dashboard and page beacon; the access log
of sign-ins, addresses and devices; per-IP and per-user rate limiting
and quotas; Render deployment config; Postgres and psycopg; cloud
inference providers, the API-key field and the key encryption that
existed to store it; session-cookie signing. None of it was hidden
behind a flag — the routes are gone and answer 404.

Two things were kept that the brief allowed keeping. The `users` table
and its foreign keys stay as an internal ownership detail, because
rewriting them out means a migration across most of the schema to
delete a column that costs nothing; nothing creates a second user and
no request carries an identity. Five inert tables and four inert
columns stay for the same reason, so an M1 campaign database opens
unchanged.

The one addition is app/endpoints.py, which decides where a story may
be sent. Loopback, RFC1918, link-local, unique-local and CGNAT — an
explicit allowlist of networks, not a guess at what `ipaddress` means
by "private", which calls the documentation ranges private and IPv6
loopback reserved. Every address a hostname resolves to must be in it,
so a split answer does not squeak through, and the rule runs both when
the endpoint is saved and before every outbound request, because a name
that resolved to the LAN this morning can resolve elsewhere this
afternoon. Known cloud hosts are named in the refusal so the error says
why rather than looking like broken DNS. TLS is never traded against
it: M1's shared trust context is intact on all four clients and there
is no way to skip verification.

The hardcoded 120-second model timeout is now a setting. That was not
theoretical — on this GPU-less four-core host a cold load of
qwen2.5:3b-instruct took 648.9 seconds to produce the first turn, while
turns 2 to 5 of the same campaign took 3.6 to 13.1. Connect stays short
at 10s so a wrong address still fails fast; the read timeout defaults
to 300s and is bounded at 3600, because "wait longer" must stay a
number.

Two defects found while testing and fixed here. An unknown /api path
fell through the SPA catch-all and came back as HTML with status 200,
so a client asking for JSON parsed a web page instead of learning the
route was gone. And AIDND_CORS_ORIGINS accepted "*", which on an
unauthenticated loopback API would hand every page on the Internet a
write handle on the campaign database; it now refuses to start.

Verified rather than assumed. Offline, on a network with no route out
and no DNS: five turns, retry with both takes retained, restart with an
identical transcript digest, a failed model call leaving the accepted
AI-turn count untouched, and a capture with zero non-loopback unicast
packets. Against a real second machine on the LAN over HTTPS with a
private CA: four turns, restart, and a capture showing 289 packets to
the approved host, 344 loopback, zero anywhere else, zero DNS queries.
Cloud and public endpoints refused with their reasons; no API key
settable; every removed route 404.

604 backend tests pass, down from 648 by the fifteen retired with the
subsystems they tested and up by the twenty-nine added for the endpoint
policy and the removed surface. The scripting tests were not deleted:
eight files used a JavaScript counter as instrumentation for the state
snapshot and rollback machinery, which M2 does not touch, so the
counter moved to the world-state engine and those tests still assert
what they always did. Frontend lint and build are clean; the image
builds, and its wheel-building stage is gone with quickjs.

No M3 work. Undo is still destructive and there is still no Redo.
This commit is contained in:
JesseMarkowitz
2026-09-02 11:27:14 -04:00
parent 1a28a9a708
commit 8c65ae99de
94 changed files with 1384 additions and 6567 deletions
+34 -219
View File
@@ -1,216 +1,44 @@
"""Phase 8: user resolution, sessions, and the shared demo key.
"""Resolving the one local user. **There is no authentication in this product.**
The `AIDND_MULTI_USER` environment variable selects one of two modes:
The module keeps its name so the dependency every router already depends on
keeps working, but nothing here authenticates anybody. The Adventure
Storyteller is a single-user application that binds to loopback: whoever can
reach the API is the person who started it, and there is nobody else to tell
them apart from.
* Local mode, the default. Every request resolves to one automatically created
local user. There are no cookies and no login UI, so a clone or a
docker-compose run behaves like the single-user app from before Phase 8.
* Multi-user mode, used for hosted deployments. Requests carry a signed session
cookie. `GET /api/auth/me` creates a guest user on the first visit, and
registering upgrades that guest in place so their data survives. A request
without a valid session gets a 401, and the frontend re-establishes the
session through `/me`.
Upstream had two modes. `AIDND_MULTI_USER` selected a hosted deployment with
signed session cookies, guest accounts, registration, login, a shared demo API
key with a per-day cap, "power users", and an owner allowlist for the analytics
dashboard. M2 removed all of it: this product has no hosted mode to protect, and
every one of those surfaces was a way for the application to be reached by
someone other than its owner.
The shared demo key, which is the fallback when a user brings no key of their
own, is also configured here. A user whose settings hold no API key is routed to
a server-funded endpoint with a model allowlist and a per-day turn cap.
What is left is the local path that upstream already had. Every request
resolves to one automatically created user row.
The `users` table and the `user_id` foreign keys on scenarios, adventures and
settings stay. They are an **internal ownership detail**, not an account
system: nothing creates a second user, nothing logs in, and no request carries
an identity. They remain because rewriting them out would mean a migration
across most of the schema to delete a column that costs nothing and keeps every
existing M1 database readable.
"""
import os
from dataclasses import dataclass
from datetime import timezone
from fastapi import Depends, HTTPException, Request
from fastapi import Depends, Request
from sqlalchemy.orm import Session
from . import models, security
from . import models
from .database import get_db
def _env_flag(name: str) -> bool:
return os.environ.get(name, "").strip().lower() in ("1", "true", "yes", "on")
MULTI_USER = _env_flag("AIDND_MULTI_USER")
SESSION_COOKIE = "aidnd_session"
# Secure cookies are on by default in multi-user mode, because a hosted
# deployment serves HTTPS and browsers also accept Secure on http://localhost.
# `AIDND_COOKIE_SECURE` overrides the default with 0 or 1. Use 0 when testing
# multi-user mode 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) ----------
DEMO_API_KEY = os.environ.get("AIDND_DEMO_API_KEY", "").strip()
DEMO_ENDPOINT_URL = (
os.environ.get("AIDND_DEMO_ENDPOINT_URL", "").strip()
or "https://openrouter.ai/api/v1"
)
DEMO_MODELS = [
m.strip()
for m in os.environ.get("AIDND_DEMO_MODELS", "").split(",")
if m.strip()
] or ["google/gemma-4-26b-a4b-it:free"]
DEMO_TURNS_PER_DAY = int(os.environ.get("AIDND_DEMO_TURNS_PER_DAY", "20") or 20)
# Trusted testers, listed by email, who bypass the daily demo cap and take
# unmetered turns on the shared demo key. The list is comma-separated, and the
# match ignores case.
POWER_USERS = {
e.strip().lower()
for e in os.environ.get("AIDND_POWER_USERS", "").split(",")
if e.strip()
}
# Who can see the visit analytics. This is a separate list from `POWER_USERS` on
# purpose. A trusted tester gets unmetered turns and the AI Chat page, which is
# not a reason to give them the site's traffic numbers. An empty list, which is
# the default, means nobody sees the dashboard in a hosted deployment.
ANALYTICS_EMAILS = {
e.strip().lower()
for e in os.environ.get("AIDND_ANALYTICS_EMAILS", "").split(",")
if e.strip()
}
DEMO_CAP_MESSAGE = (
f"You've used all {DEMO_TURNS_PER_DAY} free demo turns for today. "
"Add your own API key in Settings to keep playing (it resets tomorrow)."
)
def demo_enabled() -> bool:
# The demo key is a hosted-deployment feature. A local install talks to
# whatever endpoint Settings points at, even with no API key, such as
# Ollama.
return MULTI_USER and bool(DEMO_API_KEY)
@dataclass
class ProviderConfig:
"""What the turn engine connects with, after the decision between a
user-supplied key and the demo key.
Build one of these with `resolve_provider_config()`.
"""
endpoint_url: str
api_key: str
model: str
using_demo: bool
def __post_init__(self) -> None:
# A second guard around server-funded turns. `resolve_provider_config()`
# already pins the model, and this makes the pin a property of the config
# object too, so a later caller cannot construct an unpinned one. This
# raise is unreachable by design. Reaching it means a new code path
# bypassed the pinning, which is worth failing on rather than billing
# for.
#
# The test is `using_demo`, not `api_key == DEMO_API_KEY`. Keying on the
# key value looks stricter and is wrong. The demo key is an ordinary
# OpenRouter key, so a user can legitimately paste that same key into
# their own Settings. Every resolution then raised, which returned a 500
# even from `GET /auth/me` and took the whole SPA down. `using_demo` is
# what means the server is paying, and only the demo branch below sets
# it.
if self.using_demo and self.model not in DEMO_MODELS:
raise ValueError(
f"Refusing to use the shared demo key with non-whitelisted model {self.model!r}"
)
def resolve_provider_config(
settings: models.Settings, *, model_override: str | None = None
) -> ProviderConfig:
"""Returns the user's own key when they have one, and the shared demo key
otherwise.
The demo branch is the security-relevant one, and it is the only place the
allowlist rule lives. Every caller has to come through this function rather
than build a `ProviderConfig` itself. On the demo key:
* The model is pinned to `DEMO_MODELS`, so a caller-supplied override from
the AI Chat page, or a hand-edited Settings row, cannot point a
server-funded key at a paid model. An unrecognized model falls back to
`DEMO_MODELS[0]`.
* The endpoint is pinned to `DEMO_ENDPOINT_URL`, so the key cannot be
redirected to a URL the user controls and captured there.
`model_override` is a per-request preference and never a grant. It is used
verbatim with the user's own key, and on the demo key only when the model is
on the allowlist.
"""
key = settings.api_key_plain
requested = (model_override or "").strip() or settings.model
if key or not demo_enabled():
return ProviderConfig(settings.endpoint_url, key, requested, False)
model = requested if requested in DEMO_MODELS else DEMO_MODELS[0]
return ProviderConfig(DEMO_ENDPOINT_URL, DEMO_API_KEY, model, True)
def _today() -> str:
return models.utcnow().date().isoformat()
def is_power_user(user: models.User) -> bool:
"""Returns whether this user is a trusted tester.
A trusted tester gets unmetered demo turns, plus tooling that is not part of
the game, such as the AI Chat scratchpad. A local install is always trusted,
because it runs on the operator's own machine with their own API key. The
provider debug log is local-only for the same reason.
"""
if not MULTI_USER:
return True
return bool(user.email) and user.email.lower() in POWER_USERS
def is_owner(user: models.User) -> bool:
"""Returns whether this user may see the visit analytics.
A local install always may, because it runs on the operator's own machine and
shows their own visits. The provider debug log follows the same reasoning. A
hosted deployment checks `AIDND_ANALYTICS_EMAILS`.
"""
if not MULTI_USER:
return True
return bool(user.email) and user.email.lower() in ANALYTICS_EMAILS
def demo_turns_left(user: models.User) -> int:
# A power user is never capped, so report the full cap and let the banner
# read "N of N" rather than count down.
if is_power_user(user):
return DEMO_TURNS_PER_DAY
used = user.demo_turns_used if user.demo_turns_date == _today() else 0
return max(0, DEMO_TURNS_PER_DAY - used)
def count_demo_turn(user: models.User) -> None:
"""Records one demo turn. The caller's commit stores it."""
if is_power_user(user):
return # A power user's turns do not count against the cap.
today = _today()
if user.demo_turns_date != today:
user.demo_turns_date = today
user.demo_turns_used = 0
user.demo_turns_used += 1
# ---------- User resolution ----------
def local_user(db: Session) -> models.User:
"""Returns the single implicit user used in local mode.
"""Returns the single implicit user, creating it on first use.
A migration gives this user ownership of data written before Phase 8. On a
fresh database the user is created on first use.
A migration gives this user ownership of data written before per-user rows
existed, so an older database resolves to the row that already owns its
campaigns rather than to a fresh empty one.
"""
user = (
db.query(models.User)
@@ -237,27 +65,14 @@ def _touch(user: models.User, db: Session) -> None:
db.commit()
def resolve_session_user(request: Request, db: Session) -> models.User | None:
token = request.cookies.get(SESSION_COOKIE)
if not token:
return None
user_id = security.verify_session(token)
if user_id is None:
return None
return db.get(models.User, user_id)
def get_current_user(
request: Request, db: Session = Depends(get_db)
) -> models.User:
"""The dependency every router uses. It always succeeds.
def get_current_user(request: Request, db: Session = Depends(get_db)) -> models.User:
"""The dependency every router uses to resolve the current user.
In multi-user mode a 401 means the frontend has to establish a session again
through `GET /api/auth/me`.
`request` is unused and kept so the signature stays a FastAPI dependency
the routers can depend on unchanged.
"""
if not MULTI_USER:
user = local_user(db)
else:
user = resolve_session_user(request, db)
if user is None:
raise HTTPException(401, "No session. Call GET /api/auth/me first.")
user = local_user(db)
_touch(user, db)
return user