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
+45 -5
View File
@@ -46,6 +46,9 @@ ollama pull qwen2.5:3b-instruct
ollama pull nomic-embed-text # only if you want the memory bank
```
There is no account to create and nothing to log in to. The application is
single-user: whoever can reach it on loopback is its owner.
## Running
**Development** — backend on `:8000`, Vite dev server on `:5173`:
@@ -102,7 +105,37 @@ curl -X PUT http://127.0.0.1:8000/api/settings \
`POST /api/settings/test` (the **Test connection** button) returns
`{"ok": true, "models": [...]}` and is the fastest way to tell a wrong endpoint
from a missing model.
from a missing model. When it fails it says which kind of failure it was, and
they need different things done about them:
| `kind` | What it means |
| --- | --- |
| `rejected` | The endpoint is outside the policy below. Not a network problem. |
| `unreachable` | Nothing answered. Ollama is not running there, or the port is wrong. |
| `tls` | The certificate did not verify — install the CA (see below). |
| `timeout` | It accepted the connection and then said nothing. |
| `http` | It answered with an error status; the body is included. |
A successful test also warns when the endpoint is reachable but has no model by
the configured name, which is the commonest way for a correct endpoint to still
fail every turn.
### Which endpoints are allowed
`backend/app/endpoints.py` decides, and it is deliberately narrow: **loopback,
your own LAN, or nothing.** The allowed networks are `127.0.0.0/8`, the three
RFC1918 ranges, link-local, IPv6 loopback and unique-local, and `100.64.0.0/10`
(carrier-grade NAT, which is what a mesh VPN such as Tailscale hands out).
Every address the endpoint's hostname resolves to must be in one of them. A
public address is refused, a name resolving to both a private and a public
address is refused, and known cloud inference hosts are refused by name so the
error says why rather than looking like a DNS fault.
The rule is applied when you save the endpoint *and* again before every
outbound request, so a database edited by hand or a hostname that starts
resolving somewhere new cannot turn a local install into an exfiltration path.
There is no setting to relax it.
### Same host (the default)
@@ -137,9 +170,8 @@ Use an IP address or a name your own network resolves. Then:
- the inference machine needs the models installed, not the storyteller;
- no Internet is involved in either direction.
`app/netguard.py` refuses private addresses only in hosted multi-user mode
(`AIDND_MULTI_USER=1`), which local installs never turn on, so a LAN endpoint
is accepted as configured.
A LAN endpoint is accepted because it is on one of the allowed networks above.
Nothing else about it is special.
#### If that endpoint is HTTPS with your own CA
@@ -176,7 +208,7 @@ visible from within.
## Tests
```bash
cd backend && .venv/bin/python -m pytest tests/ -q # 648 tests
cd backend && .venv/bin/python -m pytest tests/ -q # 604 tests
cd frontend && npm run lint && npm run build
```
@@ -192,6 +224,14 @@ suite as complete evidence.
is lost from the union, or if a new HTTP client is added without the shared
verification context.
M2 added two more. `test_endpoint_policy.py` fails if the set of reachable
addresses widens, or if either place the rule is applied stops applying it —
it resolves hostnames through a stub, so it tests the policy rather than
whatever DNS the machine has. `test_local_only_surface.py` fails if a removed
subsystem comes back as a route, if an API key becomes settable again, if the
model timeout stops being configurable or becomes unbounded, or if a supported
start path stops binding loopback.
## What was made offline-safe, and how to check
Two runtime downloads were removed in Milestone M1. Both were invisible on a
+14 -22
View File
@@ -7,20 +7,15 @@ RUN npm ci
COPY frontend/ ./
RUN npm run build
# Stage 2 — build Python wheels (quickjs compiles from source if no wheel
# matches, so keep the toolchain out of the final image)
FROM python:3.12-slim AS python-build
RUN apt-get update && apt-get install -y --no-install-recommends gcc make \
&& rm -rf /var/lib/apt/lists/*
COPY backend/requirements.txt /tmp/requirements.txt
RUN pip wheel --no-cache-dir -r /tmp/requirements.txt -w /wheels
# Stage 3 — runtime
# Stage 2 — runtime. Every remaining dependency ships a wheel, so there is no
# compile step and no toolchain to keep out of the image. The wheel-building
# stage that used to sit here existed for quickjs, which compiled from source
# and which M2 removed with campaign scripting.
FROM python:3.12-slim
WORKDIR /app
COPY --from=python-build /wheels /wheels
RUN pip install --no-cache-dir /wheels/* && rm -rf /wheels
COPY backend/requirements.txt /tmp/requirements.txt
RUN pip install --no-cache-dir -r /tmp/requirements.txt && rm /tmp/requirements.txt
# Layout mirrors the repo: main.py finds the SPA at ../../frontend/dist
# relative to backend/app/main.py.
@@ -38,14 +33,11 @@ EXPOSE 8000
# invitation to put the storyteller on the LAN, which is single-user and
# unauthenticated in local mode.
WORKDIR /app/backend
# --proxy-headers lets uvicorn fix up the request scheme (https) behind the
# platform's edge. We deliberately do NOT pass --forwarded-allow-ips "*": that
# made uvicorn trust the LEFTMOST X-Forwarded-For value, which the client fully
# controls, so anyone could rotate the header to dodge the per-IP rate limits.
# The client IP used for rate limiting is derived in limits._client_ip from the
# hop the edge appends (rightmost), which a client cannot spoof past; tune with
# AIDND_TRUSTED_PROXY_HOPS if the platform adds more proxy hops.
# 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"]
# Single worker on purpose: the turn lock and the debug log are in-process
# state.
#
# No --proxy-headers. That existed for a hosted deployment behind a platform
# edge, along with the per-IP rate limiting that read X-Forwarded-For. Neither
# survives M2, and trusting a forwarded header on a loopback-published port
# would be a way to lie to the app rather than a feature.
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
+23
View File
@@ -78,6 +78,29 @@ text ships beside them as `OFL-cinzel.txt`, `OFL-crimsonpro.txt` and
Regenerate with `python3 frontend/tools/vendor_fonts.py`, which also rewrites
`frontend/src/styles/fonts.css`.
## What this fork changed in Milestone M2
M2 is subtractive. It reduced the inherited application to the intended
single-user, local-first trust boundary. **Nothing was added that upstream did
not have, except the endpoint policy and the tests that hold these removals in
place.**
Removed in full: campaign scripting and the QuickJS sandbox; multi-user
accounts, guest sessions, login, registration and the shared demo key; the
visitor analytics tables, dashboard and beacon; the access log; per-IP and
per-user rate limiting and quotas; Render deployment config; Postgres/Neon
support; cloud inference providers and the API-key field; session-cookie
signing and API-key encryption at rest.
Added: `backend/app/endpoints.py`, which decides what an inference endpoint may
be, and a configurable model timeout.
Three database tables (`scripts`, `adventure_scripts`, `analytics_daily`,
`analytics_visitor_days`, `access_log`) and four columns (`adventures.script_state`,
`settings.api_key`, `users.demo_turns_used`, `users.demo_turns_date`) are left
in place, unmapped or inert, so that an existing M1 campaign database opens
unchanged. They are not product functionality and nothing reads or writes them.
## What this fork changed in Milestone M1
Nothing was removed from upstream. The changes are the offline/locality
+14 -100
View File
@@ -1,110 +1,24 @@
# Environment variables read by the backend.
#
# NOTE: the app reads real environment variables — it does NOT auto-load this
# file. Set them in your shell, in docker-compose.yml, or in your host's
# dashboard. This file is documentation (and a template for deploy configs).
# file. Set them in your shell or in docker-compose.yml. This file is
# documentation.
#
# There are two, and neither is required. Everything about the model — the
# endpoint, the model names, the timeout, the context budget — is a runtime
# setting stored in the database and edited on the Settings page, because it is
# a preference rather than a deployment detail.
# Absolute path for the SQLite database file. Parent directory is created if
# missing. Default when unset: backend/data.db
# Docker compose sets this to /data/data.db (a named volume).
# Absolute path for the SQLite database file. The parent directory is created
# if missing. Default when unset: backend/data.db
# docker-compose.yml 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.
# served same-origin by FastAPI, so a normal run can leave this unset.
# Default: http://localhost:5173,http://127.0.0.1:5173 (the Vite dev server).
#
# A wildcard is rejected. The storyteller API is unauthenticated by design and
# bound to loopback; letting any origin call it would undo that.
AIDND_CORS_ORIGINS=
# How many proxy hops the rate limiter trusts in `X-Forwarded-For`. It reads
# the entry that many places from the right, because the trusted edge appends
# the real client IP last. Set this to the number of proxies in front of the
# app. Default: 1, which is correct for a single edge such as Render.
#
# Get it wrong in either direction and the rate limits weaken. Too low reads an
# entry the caller supplied, so anyone can rotate the header for a fresh
# rate-limit bucket per request and walk past the auth and guest limits. Too
# high reads past the real client. Only multi-user mode rate-limits at all, so
# local installs can ignore this.
AIDND_TRUSTED_PROXY_HOPS=
# ---------------------------------------------------------------------------
# Phase 8 — optional accounts & multi-user (all optional; defaults keep the
# app in frictionless single-user "local mode")
# ---------------------------------------------------------------------------
# "1"/"true" turns on multi-user mode: guest sessions via signed cookies,
# register/login UI, per-user data. Leave unset for local installs.
AIDND_MULTI_USER=
# Secret for signing session cookies and encrypting stored API keys at rest.
# 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=
# 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) ---
# Users with no API key of their own get this server-funded endpoint with a
# model whitelist and a per-day turn cap. Unset = no demo, users must bring
# their own key. Memory bank/auto-summarization are disabled on demo turns.
AIDND_DEMO_API_KEY=
# Default endpoint if unset: https://openrouter.ai/api/v1
AIDND_DEMO_ENDPOINT_URL=
# Comma-separated model whitelist. Default: google/gemma-4-26b-a4b-it:free
AIDND_DEMO_MODELS=
# Successful AI turns per user per day on the demo key. Default: 20
AIDND_DEMO_TURNS_PER_DAY=
# Comma-separated emails of "power users" (trusted testers) who bypass the daily
# demo cap entirely — unmetered turns on the shared demo key — and get the AI Chat
# page (a plain scratchpad for talking to a model, hidden from everyone else).
# Registered accounts only (guests have no email). Matched case-insensitively.
# Local (single-user) installs are always treated as power users.
AIDND_POWER_USERS=
# --- Visit analytics ---
# Comma-separated emails allowed to see the Visitors dashboard (/analytics) and
# its nav link. Deliberately separate from AIDND_POWER_USERS: a trusted tester
# gets unmetered turns, which is no reason to hand them the traffic numbers.
# Unset = nobody sees it in a hosted deploy. Local installs always can, and are
# the only mode where the viewer's own visits are still counted (excluding them
# would leave the page permanently empty on the machine it's developed on).
# Collection itself is always on; only the dashboard is gated.
AIDND_ANALYTICS_EMAILS=
# Days to keep the one-row-per-visitor-per-day table that makes the funnel
# count people rather than clicks. The daily counters are aggregate and kept
# forever. Default: 400. Set 0 to keep visitor-days forever.
AIDND_ANALYTICS_RETENTION_DAYS=
# --- Guest retention (only active when AIDND_MULTI_USER=1) ---
# Every first visit mints a guest account, so a public demo collects one row
# per visitor. A guest with no activity for this many days is deleted along
# with its scenarios, adventures and actions. Registered accounts are never
# touched. Default: 5. Set 0 to keep guests forever.
AIDND_GUEST_RETENTION_DAYS=
# How often a running process re-checks. The sweep also runs once at startup,
# which is what actually fires on hosts that sleep. Default: 6
AIDND_CLEANUP_INTERVAL_HOURS=
# 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.
#
# 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.
-168
View File
@@ -1,168 +0,0 @@
"""The access log: who arrived, when, and from where.
The deliberate opposite of analytics.py. That module counts and stores nothing
that points at a person; this one records addresses, email addresses and
devices, because an access log that cannot identify the access is not an access
log. The two live in separate modules and separate tables on purpose, so that
the anonymity of the counters is a property of the code rather than a convention
someone has to remember.
Owner-only, and never shown to the people it records.
Four kinds of row:
- `session` A browser that has a session made a request. For a guest, this
is their first visit.
- `login` An existing account signed in.
- `register` A guest upgraded to an account.
- `login_failed` A password attempt that did not match, with the address tried.
Session rows are the only ones that need thinning. `/auth/me` runs on every page
load, and one row per load would be noise rather than a log. A row is written
when the day or the address changes for that user. That is the granularity a log
is read at, such as seen on the 3rd from 1.2.3.4, and it still records someone
moving networks during a day.
"""
import logging
import threading
from sqlalchemy import desc, or_, select
from sqlalchemy.orm import Session
from . import analytics, models
logger = logging.getLogger(__name__)
SESSION = "session"
LOGIN = "login"
REGISTER = "register"
LOGIN_FAILED = "login_failed"
MAX_UA = 200
# user id -> (day, ip) of the last session row written for them. Process-local
# like the rate limiter's windows, and for the same reason: this is a single
# process, and the worst case after a restart is one redundant row per user.
_last_session: dict[int, tuple[str, str]] = {}
_guard = threading.Lock()
_MAX_TRACKED = 10_000
def _client_ip(request) -> str:
# This import is deferred. `limits` imports `auth`, which the routers that
# call this function import, so a module-level import here would create a
# cycle. The spoof resistance lives in `limits` and must not be
# reimplemented. A second, looser answer to which address belongs to the
# client is how one of them ends up trusting a header it should not.
from . import limits
return limits.client_ip(request)
def describe(user: models.User) -> str:
"""Returns how a user is named in the log.
A guest has no email, and their id is the only handle anyone has for them.
The third case is a local install's implicit single user, who also has no
email but is the operator rather than a visitor. Naming that user "Guest #1"
would be wrong in the one row they are certain to read.
"""
if user.email:
return user.email
return f"Guest #{user.id}" if user.is_guest else f"Local user #{user.id}"
def _country(request) -> str:
"""Returns the edge's country header, or "" when there is none.
The blank differs from the counters' "(unknown)" label. A table column reads
better as a dash than as a word, and an empty string is the correct value for
a country that is not known.
"""
country = analytics.country_of(request.headers)
return "" if country == analytics.UNKNOWN else country
def record(
db: Session,
kind: str,
request,
*,
user: models.User | None = None,
who: str | None = None,
) -> None:
"""Writes one row.
This function never raises. The log observes sign-in rather than guarding it,
and a logging failure must not lock anyone out.
"""
try:
event = models.AccessEvent(
kind=kind,
user_id=user.id if user is not None else None,
who=(who if who is not None else describe(user) if user else "")[:320],
is_guest=bool(user.is_guest) if user is not None else False,
ip=_client_ip(request)[:45],
country=_country(request),
device=analytics.device_of(request.headers.get("user-agent", "")),
user_agent=(request.headers.get("user-agent") or "")[:MAX_UA],
)
db.add(event)
db.commit()
except Exception: # pragma: no cover - defensive
db.rollback()
logger.exception("Access log write failed; continuing.")
def note_session(db: Session, user: models.User, request) -> None:
"""Records that a session made a request, at most one row per day per address."""
try:
today = analytics._today()
ip = _client_ip(request)
with _guard:
if _last_session.get(user.id) == (today, ip):
return
_last_session[user.id] = (today, ip)
if len(_last_session) > _MAX_TRACKED:
# Nothing here needs to persist. Clearing the map costs at most
# one extra row per active user.
_last_session.clear()
_last_session[user.id] = (today, ip)
except Exception: # pragma: no cover - defensive
logger.exception("Access log session check failed; continuing.")
return
record(db, SESSION, request, user=user)
def recent(
db: Session,
*,
limit: int = 50,
before_id: int | None = None,
kind: str | None = None,
query: str | None = None,
) -> dict:
"""Returns a page of the log, newest first.
The page is anchored on a row id rather than an offset, as the story pager
is. Rows keep arriving while the log is read, and an offset would shift the
page under whoever is reading it.
"""
statement = select(models.AccessEvent).order_by(desc(models.AccessEvent.id))
if before_id is not None:
statement = statement.where(models.AccessEvent.id < before_id)
if kind:
statement = statement.where(models.AccessEvent.kind == kind)
if query:
like = f"%{query.strip()}%"
statement = statement.where(or_(
models.AccessEvent.who.ilike(like),
models.AccessEvent.ip.ilike(like),
models.AccessEvent.country.ilike(like),
))
# Requesting one extra row reports whether more rows exist, without a
# second COUNT over the whole table.
rows = list(db.scalars(statement.limit(limit + 1)))
has_more = len(rows) > limit
return {"events": rows[:limit], "has_more": has_more}
-583
View File
@@ -1,583 +0,0 @@
"""Visit analytics for the hosted demo.
This is a small self-hosted counter that answers whether anyone visited and
whether they played. It is built into the app rather than added with a
third-party script, because the CSP in `main.py` allows scripts from 'self'
only, ad blockers block the popular trackers, and none of those trackers can see
what is worth knowing here: turns taken, demo-key spend, and which seeded
scenario people pick.
Three rules shape the design:
1. It stores nothing personal. It records no IP addresses, no user agents, no
user ids, and no title of anything a player wrote. A visitor appears only as
an HMAC of their user id, which is one-way and salted with the app's secret
key, so these tables cannot be joined back to an account even by someone
holding the database. Story content never reaches this module. What one
specific person did is unanswerable by design, and only totals are
available.
2. Egress is the budget. Neon bills for bytes leaving the database, and this
project has already paid for forgetting that once. Counts are therefore
aggregated in memory and flushed as UPSERTs, so a visit is a write and never
a read, and every dashboard query is a GROUP BY that returns tens of rows
rather than per-visit rows. A month of traffic costs a few kilobytes to read
back.
3. The numbers come from the server, not from the browser. The client reports
one thing, which is the page that was viewed. Everything with meaning, such
as a turn happening or an account being created, is recorded by the code that
performs it, where a stranger cannot fake it and an extension cannot block
it.
Storage is two tables, both bounded. `analytics_daily` holds one counter row per
day, metric, and label, which is a few dozen rows a day.
`analytics_visitor_days` holds one row per visitor per day carrying the funnel
flags, which is what makes the funnel count people rather than clicks. It is the
only table that grows with traffic, and cleanup ages it out.
"""
import hmac
import logging
import os
import re
import threading
from datetime import timedelta
from hashlib import sha256
from urllib.parse import urlsplit
from sqlalchemy import case, func, or_, select
from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.dialects.sqlite import insert as sqlite_insert
from sqlalchemy.orm import Session
from . import models, security
logger = logging.getLogger(__name__)
# ---------- Metrics ----------
# `metric` is the family, and `label` is the bucket within it. One generic
# counter table is better than a column per measurement, because adding a new
# question later costs nothing rather than a migration.
M_PAGE = "pageview"
M_EVENT = "event"
M_REFERRER = "referrer"
M_DEVICE = "device"
M_COUNTRY = "country"
M_SCENARIO = "scenario" # Which seeded or public scenario was played.
M_ERROR = "error" # "<status> <route>" for a 4xx or 5xx on /api.
EV_SCENARIO_OPEN = "scenario_opened"
EV_ADVENTURE = "adventure_created"
EV_IMPORT = "adventure_imported"
EV_TURN = "turn"
EV_DEMO_TURN = "demo_turn" # A turn billed to the shared demo key.
EV_TURN_ERROR = "turn_error"
EV_SIGNUP = "signup"
EV_LOGIN = "login"
# Events that are also funnel steps. Recording one sets a flag on the visitor's
# row for the day, so the funnel counts distinct visitor-days rather than repeat
# clicks. This name-to-column map is the whole definition of the funnel, and the
# dashboard reads it back in this order.
FUNNEL_FLAGS = {
EV_SCENARIO_OPEN: "opened",
EV_ADVENTURE: "created",
EV_TURN: "played",
EV_SIGNUP: "signed_up",
}
OTHER = "(other)"
NONE_LABEL = "(direct)"
UNKNOWN = "(unknown)"
# ---------- Bounds ----------
# These bounds exist so that a hostile visitor can add rows to these tables no
# faster than an honest one. The only label a client can influence is the
# referrer, and together these caps mean the worst it can do is fill one day's
# referrer list and then be folded into "(other)".
MAX_LABEL_LEN = 80
MAX_LABELS_PER_METRIC = 200 # Distinct labels per metric per day, then OTHER.
MAX_PENDING = 4000 # Buffered entries before an inline flush.
FLUSH_INTERVAL_SECONDS = 60
# How long the per-visitor-day rows are kept. The daily counters are small and
# are kept indefinitely. These rows are the ones that scale with traffic. A
# visitor whose last visit ages out counts as new again, which is an acceptable
# trade at this horizon and keeps the table from being a permanent record of
# anyone.
RETENTION_DAYS = int(os.environ.get("AIDND_ANALYTICS_RETENTION_DAYS", "400") or 400)
_HOST_OK = re.compile(r"^[a-z0-9.-]+$")
_COUNTRY_OK = re.compile(r"^[A-Z]{2}$")
_NUMERIC_SEGMENT = re.compile(r"^\d+$")
# SPA routes, in the form the dashboard shows them. Any other path a client
# reports becomes OTHER, so the page list cannot be filled with junk and cannot
# record which adventure someone is reading.
KNOWN_ROUTES = {
"/", "/adventures", "/scenarios", "/scenarios/:id", "/play/:id",
"/scripts", "/scripts/:id", "/settings", "/chat", "/analytics",
}
# ---------- In-process buffer ----------
# The deployment is a single process, which is the same assumption `limits.py`
# makes, so a plain dict under a lock is the whole design. Losing up to a minute
# of counts to a hard restart is acceptable for traffic numbers, and the flusher
# also runs on shutdown. On Render's free tier the service is idle when it
# sleeps, so the buffer it sleeps on is empty.
_counts: dict[tuple[str, str, str], int] = {}
_visits: dict[tuple[str, str], set[str]] = {} # (day, visitor) -> flags.
_labels_seen: dict[tuple[str, str], set[str]] = {} # (day, metric) -> labels.
_guard = threading.Lock()
def _today() -> str:
return models.utcnow().date().isoformat()
def record(metric: str, label: str = "", *, n: int = 1) -> None:
"""Adds `n` to one counter.
This function never raises. Analytics must not fail a request that it is
only observing.
"""
try:
day = _today()
label = (label or "").strip()[:MAX_LABEL_LEN]
with _guard:
seen = _labels_seen.setdefault((day, metric), set())
if label not in seen:
if len(seen) >= MAX_LABELS_PER_METRIC:
label = OTHER
else:
seen.add(label)
key = (day, metric, label)
_counts[key] = _counts.get(key, 0) + n
pending = len(_counts) + len(_visits)
except Exception: # pragma: no cover - defensive
logger.exception("Analytics counter failed; continuing.")
return
if pending >= MAX_PENDING:
flush()
def visitor_id(user: models.User) -> str:
"""Returns a stable, one-way handle for one visitor.
The handle is an HMAC of the user id under the app's secret key. It is
stable, so a returning visitor can be distinguished from a new one. It is
one-way, so nothing in the analytics tables points back at an account. It is
keyed, so a client cannot compute one and claim to be someone else. One
consequence follows: rotating `AIDND_SECRET_KEY` makes every returning
visitor look new.
"""
digest = hmac.new(security.SECRET_KEY, f"visitor:{user.id}".encode(), sha256)
return digest.hexdigest()[:32]
def record_visit(user: models.User | None, *, flag: str | None = None) -> None:
"""Records that this visitor was here today, and optionally sets one funnel
flag.
Without a user the call does nothing. A page loaded before a session exists
still counts as a pageview, but not as a person.
"""
if user is None:
return
try:
with _guard:
flags = _visits.setdefault((_today(), visitor_id(user)), set())
if flag:
flags.add(flag)
except Exception: # pragma: no cover - defensive
logger.exception("Analytics visit failed; continuing.")
def record_event(name: str, user: models.User | None = None) -> None:
"""Records one event, and credits the visitor's day if it is a funnel step.
This is the whole interface the call sites use.
"""
record(M_EVENT, name)
record_visit(user, flag=FUNNEL_FLAGS.get(name))
# ---------- Normalizing what the browser reports ----------
def normalize_route(path: str) -> str:
"""Reduces a client-reported path to one of `KNOWN_ROUTES`.
Numeric segments become ":id". That bounds the label count, and it keeps
which adventure someone opened out of the statistics.
"""
path = (path or "/").split("?")[0].split("#")[0]
if not path.startswith("/"):
path = "/" + path
if len(path) > 1:
path = path.rstrip("/")
parts = [":id" if _NUMERIC_SEGMENT.match(p) else p for p in path.split("/")]
route = "/".join(parts) or "/"
return route if route in KNOWN_ROUTES else OTHER
def normalize_referrer(referrer: str, own_host: str = "") -> str:
"""Returns the sending site as a bare host.
This app's own host means an internal navigation, which is not a referral.
In that case the function returns "", which tells the caller to skip it.
"""
if not referrer:
return NONE_LABEL
host = (urlsplit(referrer).hostname or "").lower().lstrip(".")
if not host or not _HOST_OK.match(host) or len(host) > MAX_LABEL_LEN:
return OTHER
if host == (own_host or "").lower() or host in ("localhost", "127.0.0.1"):
return ""
return host[4:] if host.startswith("www.") else host
def api_route_label(scope: dict, status: int) -> str:
"""Returns an error bucket such as "500 /api/adventures/{adventure_id}".
The label uses the route template, never the request path. That keeps one
bucket per endpoint rather than one per adventure id. It also bounds the
table: an unmatched path is chosen entirely by the caller, so labeling by it
would let anyone create rows by requesting arbitrary paths.
"""
template = getattr(scope.get("route"), "path", None)
return f"{status} {template}" if template else f"{status} (unmatched)"
def device_of(user_agent: str) -> str:
"""Returns "mobile", "tablet", or "desktop", and nothing more specific.
The user-agent string itself is never stored, because it is a fingerprint
and the useful answer is one word.
"""
ua = (user_agent or "").lower()
if not ua:
return UNKNOWN
if any(bot in ua for bot in ("bot", "crawler", "spider", "headless", "preview")):
return "bot"
if "ipad" in ua or "tablet" in ua or ("android" in ua and "mobile" not in ua):
return "tablet"
if any(m in ua for m in ("mobi", "iphone", "ipod", "android", "phone")):
return "mobile"
return "desktop"
# Geo headers an edge network may add. Render fronts services with a CDN that
# can set `cf-ipcountry`, and the others cost nothing to check. A value is
# trusted only if it looks like an ISO code, because a client can send any
# header, so the worst case is a wrong country rather than an unbounded label.
_GEO_HEADERS = ("cf-ipcountry", "x-vercel-ip-country", "x-geo-country", "x-country-code")
def country_of(headers) -> str:
for name in _GEO_HEADERS:
value = (headers.get(name) or "").strip().upper()
if _COUNTRY_OK.match(value) and value != "XX":
return value
return UNKNOWN
# ---------- Flushing ----------
def _insert(db: Session):
return sqlite_insert if db.get_bind().dialect.name == "sqlite" else pg_insert
def _drain() -> tuple[dict, dict]:
with _guard:
counts, visits = _counts.copy(), _visits.copy()
_counts.clear()
_visits.clear()
# The label sets bound cardinality within one day, so drop the
# previous day's rather than grow a map that never shrinks.
today = _today()
for key in [k for k in _labels_seen if k[0] != today]:
del _labels_seen[key]
return counts, visits
def _restore(counts: dict, visits: dict) -> None:
"""Returns a failed flush's work to the buffer, so the next flush retries it."""
with _guard:
for key, n in counts.items():
_counts[key] = _counts.get(key, 0) + n
for key, flags in visits.items():
_visits.setdefault(key, set()).update(flags)
def flush(db: Session | None = None) -> None:
"""Writes the buffer out. This is safe to call from anywhere and never raises."""
counts, visits = _drain()
if not counts and not visits:
return
own_session = db is None
if own_session:
from .database import SessionLocal
db = SessionLocal()
try:
_write_counts(db, counts)
_write_visits(db, visits)
db.commit()
except Exception:
db.rollback()
_restore(counts, visits)
logger.exception("Analytics flush failed; counts held for the next one.")
finally:
if own_session:
db.close()
def _write_counts(db: Session, counts: dict) -> None:
if not counts:
return
table = models.AnalyticsDaily.__table__
rows = [
{"day": day, "metric": metric, "label": label, "hits": hits}
for (day, metric, label), hits in counts.items()
]
stmt = _insert(db)(table).values(rows)
db.execute(stmt.on_conflict_do_update(
index_elements=["day", "metric", "label"],
set_={"hits": table.c.hits + stmt.excluded.hits},
))
def _write_visits(db: Session, visits: dict) -> None:
if not visits:
return
table = models.AnalyticsVisitorDay.__table__
ids = {visitor for _, visitor in visits}
# One indexed lookup decides new against returning for the whole batch. It
# is the only read this module makes outside the dashboard, and it returns
# short hashes for the visitors active right now, so the batch bounds it.
known = set(db.scalars(
select(models.AnalyticsVisitorDay.visitor)
.where(models.AnalyticsVisitorDay.visitor.in_(ids))
.distinct()
))
rows = [
{
"day": day,
"visitor": visitor,
"is_new": visitor not in known,
**{column: column in flags for column in FUNNEL_FLAGS.values()},
}
for (day, visitor), flags in visits.items()
]
stmt = _insert(db)(table).values(rows)
db.execute(stmt.on_conflict_do_update(
index_elements=["day", "visitor"],
# Flags only turn on, and `is_new` is absent on purpose. The first
# write of a visitor's first day is what decided it.
set_={
column: or_(table.c[column], stmt.excluded[column])
for column in FUNNEL_FLAGS.values()
},
))
def purge_old_visitor_days(db: Session) -> int:
"""Deletes visitor-day rows past the retention horizon.
The cleanup sweeper calls this. The daily counters are never purged, because
they are aggregates, they are small, and this project keeps its history.
"""
if RETENTION_DAYS <= 0:
return 0
cutoff = (models.utcnow().date() - timedelta(days=RETENTION_DAYS)).isoformat()
removed = db.query(models.AnalyticsVisitorDay).filter(
models.AnalyticsVisitorDay.day < cutoff
).delete(synchronize_session=False)
db.commit()
return removed or 0
# ---------- Reading it back ----------
# Every query below is an aggregate. The database does the counting and returns
# tens of rows, however much traffic is behind them. No query here can return a
# row that belongs to one visitor.
TOP_N = 12
def _top(rows: list[dict], limit: int = TOP_N) -> list[dict]:
return rows[:limit]
def summary(db: Session, days: int = 30) -> dict:
"""Returns everything the dashboard shows for the last `days` days, including
today.
The function flushes first, so the numbers include the last minute.
"""
flush(db)
today = models.utcnow().date()
since = (today - timedelta(days=days - 1)).isoformat()
daily = models.AnalyticsDaily
visitor = models.AnalyticsVisitorDay
# 1. Every counter in the window, reduced to (metric, label) totals. The
# page, referrer, country, device, scenario, and error tables all come
# from this one pass rather than from a query each.
by_metric: dict[str, list[dict]] = {}
for metric, label, hits in db.execute(
select(daily.metric, daily.label, func.sum(daily.hits))
.where(daily.day >= since)
.group_by(daily.metric, daily.label)
):
by_metric.setdefault(metric, []).append({"label": label, "hits": int(hits)})
for rows in by_metric.values():
rows.sort(key=lambda row: -row["hits"])
events = {row["label"]: row["hits"] for row in by_metric.get(M_EVENT, [])}
# 2. The two per-day series the dashboard draws.
pageviews_by_day = {
day: int(hits)
for day, hits in db.execute(
select(daily.day, func.sum(daily.hits))
.where(daily.day >= since, daily.metric == M_PAGE)
.group_by(daily.day)
)
}
turns_by_day = {
day: int(hits)
for day, hits in db.execute(
select(daily.day, func.sum(daily.hits))
.where(daily.day >= since, daily.metric == M_EVENT, daily.label == EV_TURN)
.group_by(daily.day)
)
}
# 3. People, per day. There is one row per visitor per day, so COUNT(*) is
# already the day's unique visitors and no DISTINCT is needed.
visitors_by_day: dict[str, dict] = {}
for day, total, fresh in db.execute(
select(
visitor.day,
func.count(),
func.sum(case((visitor.is_new, 1), else_=0)),
)
.where(visitor.day >= since)
.group_by(visitor.day)
):
visitors_by_day[day] = {"visitors": int(total), "new": int(fresh or 0)}
# 4. The funnel over the whole window, counting each person once.
# COUNT(DISTINCT CASE WHEN flag THEN visitor END) ignores the NULLs the
# CASE leaves for everyone who did not reach that step.
unique, unique_new, *reached = db.execute(
select(
func.count(func.distinct(visitor.visitor)),
func.count(func.distinct(case((visitor.is_new, visitor.visitor)))),
*[
func.count(func.distinct(case((visitor.__table__.c[column], visitor.visitor))))
for column in FUNNEL_FLAGS.values()
],
).where(visitor.day >= since)
).one()
series = []
for offset in range(days):
day = (today - timedelta(days=days - 1 - offset)).isoformat()
counted = visitors_by_day.get(day, {})
series.append({
"day": day,
"visitors": counted.get("visitors", 0),
"new": counted.get("new", 0),
"pageviews": pageviews_by_day.get(day, 0),
"turns": turns_by_day.get(day, 0),
})
visits = sum(row["visitors"] for row in series)
pageviews = sum(pageviews_by_day.values())
turns = events.get(EV_TURN, 0)
errors = by_metric.get(M_ERROR, [])
return {
"days": days,
"since": since,
"until": today.isoformat(),
"generated_at": models.utcnow().isoformat(),
"totals": {
# `visitors` counts each person once for the window. `visits`
# counts them once per day they returned, which is the closest
# measure to "sessions" that does not track sessions.
"visitors": int(unique),
"new_visitors": int(unique_new),
"visits": visits,
"pageviews": pageviews,
"turns": turns,
"demo_turns": events.get(EV_DEMO_TURN, 0),
"adventures": events.get(EV_ADVENTURE, 0),
"signups": events.get(EV_SIGNUP, 0),
"logins": events.get(EV_LOGIN, 0),
"turn_errors": events.get(EV_TURN_ERROR, 0),
"errors": sum(row["hits"] for row in errors),
"turns_per_visit": round(turns / visits, 1) if visits else 0,
"pages_per_visit": round(pageviews / visits, 1) if visits else 0,
},
"series": series,
# Step 0 is everyone who arrived, so the drop-off between it and
# "Opened a scenario" appears as a step like any other.
"funnel": [{"step": "Visited", "count": int(unique)}] + [
{"step": step, "count": int(count)}
for step, count in zip(
["Opened a scenario", "Started an adventure", "Played a turn", "Signed up"],
reached,
)
],
"pages": _top(by_metric.get(M_PAGE, [])),
"referrers": _top(by_metric.get(M_REFERRER, [])),
"countries": _top(by_metric.get(M_COUNTRY, [])),
"devices": by_metric.get(M_DEVICE, []),
"scenarios": _top(by_metric.get(M_SCENARIO, [])),
"errors": _top(errors),
"events": by_metric.get(M_EVENT, []),
}
# ---------- Background flusher ----------
# This matches the start and stop pair in `cleanup`, so the lifespan in
# `main.py` reads the same way for both. The interval bounds how much a hard
# restart can lose.
async def _flush_loop() -> None:
import asyncio
from starlette.concurrency import run_in_threadpool
while True:
await asyncio.sleep(FLUSH_INTERVAL_SECONDS)
# This is blocking database work, so keep it off the event loop, which
# is also serving SSE turn streams.
await run_in_threadpool(flush)
def start_flusher():
import asyncio
return asyncio.create_task(_flush_loop())
async def stop_flusher(task) -> None:
"""Cancels the loop and writes out whatever it was holding.
A deploy is the one restart that is both frequent and predictable, so it
should not be what loses a minute of counts.
"""
import asyncio
from starlette.concurrency import run_in_threadpool
if task is not None:
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
await run_in_threadpool(flush)
+6 -7
View File
@@ -41,11 +41,11 @@ from .context import lineage
# The slices of a context snapshot that belong to one attempt rather than to the
# turn. They are the world-state delta the attempt proposed and what the engine
# did with it, the script report, the model's literal reply, and the endpoint's
# did with it, the model's literal reply, and the endpoint's
# token accounting. Each attempt is its own API call, and a retry is the call
# most likely to read the prompt back out of cache. Everything else in a snapshot
# is the prompt, which is assembled once per turn.
ATTEMPT_KEYS = ("world_state", "script", "raw_output", "usage")
ATTEMPT_KEYS = ("world_state", "raw_output", "usage")
# ------------------------------------------------------------------ reading
@@ -152,7 +152,7 @@ def preceding(
# ------------------------------------------------------------------ writing
def restore_state(adventure: models.Adventure, node: models.Action | None) -> None:
"""Restores the script state and world state that `node` left behind.
"""Restores the world state that `node` left behind.
A NULL snapshot means leave the live state as it is, never reset it. Rows
written before SP4 that the migration could not derive an outcome for carry
@@ -161,17 +161,16 @@ def restore_state(adventure: models.Adventure, node: models.Action | None) -> No
"""
if node is None:
return
if isinstance(node.state_after, dict):
adventure.script_state = copy.deepcopy(node.state_after)
if isinstance(node.world_state_after, dict):
adventure.world_state = copy.deepcopy(node.world_state_after)
def snapshot_outcome(adventure: models.Adventure, node: models.Action) -> None:
"""Records on `node` the state of the adventure now that the node has played."""
state = adventure.script_state if isinstance(adventure.script_state, dict) else {}
world = adventure.world_state if isinstance(adventure.world_state, dict) else {}
node.state_after = copy.deepcopy(state)
# `state_after` held the scripting engine's shared state, which M2 removed.
# The column stays for schema compatibility and is written empty.
node.state_after = {}
node.world_state_after = copy.deepcopy(world)
+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
+4 -24
View File
@@ -109,7 +109,6 @@ def export(db: Session, adventure: models.Adventure) -> dict:
"pronouns": adventure.persona_pronouns,
"desc": adventure.persona_desc,
},
"scriptState": adventure.script_state,
"worldState": adventure.world_state,
"autoSummarize": adventure.auto_summarize,
"memoryBankEnabled": adventure.memory_bank_enabled,
@@ -126,15 +125,6 @@ def export(db: Session, adventure: models.Adventure) -> dict:
"entry": c.entry, "notes": c.notes}
for c in adventure.story_cards
],
"scripts": [
{
"position": s.position, "enabled": s.enabled,
"name": s.name, "description": s.description,
"library": s.library_js, "input": s.input_js,
"context": s.context_js, "output": s.output_js,
}
for s in adventure.scripts
],
"actions": [_exported_node(a, local) for a in nodes],
}
@@ -654,7 +644,6 @@ def materialize(
authors_note=str(payload.get("authorsNote") or ""),
ai_instructions=str(payload.get("aiInstructions") or ""),
story_summary=str(payload.get("storySummary") or ""),
script_state=payload.get("scriptState") or {},
world_state=payload.get("worldState") or {},
auto_summarize=bool(payload.get("autoSummarize", False)),
memory_bank_enabled=bool(payload.get("memoryBankEnabled", False)),
@@ -674,19 +663,10 @@ def materialize(
notes=str(card.get("notes") or ""),
))
for i, item in enumerate(payload.get("scripts") or []):
if isinstance(item, dict):
db.add(models.AdventureScript(
adventure_id=adventure.id,
position=int(item.get("position", i)),
enabled=bool(item.get("enabled", True)),
name=str(item.get("name") or "Imported Script")[:schemas.NAME_MAX],
description=str(item.get("description") or ""),
library_js=str(item.get("library") or ""),
input_js=str(item.get("input") or ""),
context_js=str(item.get("context") or ""),
output_js=str(item.get("output") or ""),
))
# A bundle exported before M2 may carry "scripts" and "scriptState".
# Campaign scripting is gone, so both are ignored rather than rejected: the
# story, its tree, its cards and its memories still import intact, which is
# what the bundle is for.
write(db, adventure, story)
db.flush()
-178
View File
@@ -1,178 +0,0 @@
"""Retention policy for throwaway guest accounts.
In multi-user mode every first visit creates a `users` row through
`GET /api/auth/me`, so a public demo accumulates one account per visitor. Most
of those visitors never return, and each one leaves behind whatever scenarios,
adventures, actions, and memories they generated. This module deletes guests
that have been inactive for `AIDND_GUEST_RETENTION_DAYS`, which defaults to 5,
along with everything they made.
Why this is safe to run unattended:
- Only rows with `is_guest` AND `email IS NULL` are ever touched, and both
clauses are checked rather than either alone. Registering upgrades the row
in place (is_guest -> False), so a guest who signs up keeps everything;
local mode's implicit single user is also is_guest=False.
- Idle time is `COALESCE(last_seen_at, created_at)`. `auth._touch` writes
`last_seen_at` at most once an hour, and a guest created by `/auth/me` has
NULL there until its second request, so `created_at` is the correct floor for
a new visitor. Without the coalesce, those rows look arbitrarily old.
- Nothing a guest owns is reachable by anyone else. `is_public` is an
output-only field, as `schemas.ScenarioBase` shows, so the only shared
scenarios are the seeded ones, which have a NULL `user_id` and are outside
this filter. Deleting a guest cannot remove content from another user.
The sweep uses one Core DELETE rather than an ORM cascade. `db.delete(user)`
would SELECT every adventure, action, memory, and story card into Python only to
delete them, which on Neon is the egress pattern that has already cost this
project once. Every foreign key from `users` downward is ON DELETE CASCADE, from
users to scenarios, adventures, scripts, and settings, and from those to actions,
memories, and cards, so the database deletes the whole graph in one statement and
returns a row count.
The scan gets no index. The sweep runs a few times a day against a table holding
at most a few thousand rows, which does not justify a migration and the schema
surface it adds.
"""
import asyncio
import logging
import os
from datetime import datetime, timedelta
from sqlalchemy import delete, func
from sqlalchemy.orm import Session
from starlette.concurrency import run_in_threadpool
from . import analytics, auth, models
from .database import SessionLocal
logger = logging.getLogger(__name__)
def _int_env(name: str, default: int) -> int:
try:
return int(os.environ.get(name, "").strip() or default)
except ValueError:
logger.warning("%s is not an integer; using %d.", name, default)
return default
# Days of inactivity before a guest account is deleted. A value of 0 or less
# disables the policy, for a deployment that keeps everything.
RETENTION_DAYS = _int_env("AIDND_GUEST_RETENTION_DAYS", 5)
# How often a long-lived process re-checks. Hours, not minutes: nothing here is
# time-critical, and on Render's free tier the service sleeps and cold-starts
# often enough that the startup sweep does most of the work by itself.
SWEEP_INTERVAL_SECONDS = _int_env("AIDND_CLEANUP_INTERVAL_HOURS", 6) * 3600
def enabled() -> bool:
"""Guests only exist in multi-user mode, so local runs skip the sweep
rather than pointing a DELETE at a database that has nothing to collect."""
return auth.MULTI_USER and RETENTION_DAYS > 0
def anything_to_sweep() -> bool:
"""Whether the periodic task is worth starting at all. The two jobs it runs
are independent: a deployment can keep every guest forever and still want
its analytics rows aged out, and vice versa."""
return enabled() or analytics.RETENTION_DAYS > 0
def delete_stale_guests(db: Session, *, now: datetime | None = None) -> int:
"""Delete guests idle for RETENTION_DAYS or more. Returns the row count.
The caller owns error handling; `sweep` is the safe wrapper.
"""
if RETENTION_DAYS <= 0:
return 0
# Stored timestamps are UTC without a timezone on both backends. SQLite
# drops the timezone, and the Postgres columns are TIMESTAMP WITHOUT TIME
# ZONE with the session pinned to UTC in `database.py`. Match that, so the
# comparison does not depend on how a dialect renders a value that carries a
# timezone.
reference = now or models.utcnow()
cutoff = reference.replace(tzinfo=None) - timedelta(days=RETENTION_DAYS)
stmt = (
delete(models.User)
.where(
models.User.is_guest.is_(True),
models.User.email.is_(None),
func.coalesce(models.User.last_seen_at, models.User.created_at) < cutoff,
)
# Without this option, the "auto" strategy cannot evaluate coalesce in
# Python and falls back to fetching every matching primary key first.
# That is a second round trip for no benefit, because this session holds
# no User objects to synchronize.
.execution_options(synchronize_session=False)
)
removed = db.execute(stmt).rowcount or 0
db.commit()
return removed
def sweep() -> int:
"""One pass, with its own session. Never raises: a failed cleanup must not
be able to take the app down (same rule as seeding). Returns the guest
count, which is the number worth logging about."""
if not anything_to_sweep():
return 0
db = SessionLocal()
try:
# Ages out the per-visitor analytics rows, on its own terms: it is not
# about guests, and it must still happen on a deployment that has
# chosen to keep every account it ever minted.
aged = analytics.purge_old_visitor_days(db)
if aged:
logger.info("Aged out %d analytics visitor-day row(s).", aged)
removed = delete_stale_guests(db) if enabled() else 0
if removed:
logger.info(
"Cleaned up %d guest account(s) idle for %d+ days.",
removed,
RETENTION_DAYS,
)
return removed
except Exception:
db.rollback()
logger.exception("Guest cleanup failed; continuing without it.")
return 0
finally:
db.close()
async def _sweep_loop() -> None:
while True:
# Blocking DB work: keep it off the event loop, which is also serving
# SSE turn streams.
await run_in_threadpool(sweep)
await asyncio.sleep(SWEEP_INTERVAL_SECONDS)
def start_sweeper() -> asyncio.Task | None:
"""Kick off the periodic sweep; None when there is nothing to sweep."""
if not enabled():
logger.info("Guest cleanup disabled (multi_user=%s, retention_days=%d).",
auth.MULTI_USER, RETENTION_DAYS)
else:
logger.info(
"Guest cleanup on: deleting guests idle %d+ days, every %d hour(s).",
RETENTION_DAYS,
SWEEP_INTERVAL_SECONDS // 3600,
)
if not anything_to_sweep():
return None
return asyncio.create_task(_sweep_loop())
async def stop_sweeper(task: asyncio.Task | None) -> None:
if task is None:
return
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
+22 -43
View File
@@ -4,10 +4,17 @@ from pathlib import Path
from sqlalchemy import create_engine, event
from sqlalchemy.orm import DeclarativeBase, sessionmaker
# AIDND_DB_PATH lets deployments (Docker volume, hosted disk) relocate the
# 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.
# The one database. It is SQLite, on this machine, in a file.
#
# Upstream could also point at a server database — `AIDND_DATABASE_URL` or the
# platform-conventional `DATABASE_URL`, normalised onto psycopg3, with
# pre-ping for a serverless Postgres that suspends when idle. That existed for
# a hosted deployment. M2 removed it along with the deployment: a local
# single-user storyteller has one reader, and a network database would be one
# more thing that has to be running, and one more place the story lives.
#
# `AIDND_DB_PATH` stays. It is how the Docker image puts the database on a
# volume, and how a test points at a throwaway file.
_env_db_path = os.environ.get("AIDND_DB_PATH")
DB_PATH = (
Path(_env_db_path).resolve()
@@ -15,48 +22,20 @@ DB_PATH = (
else Path(__file__).resolve().parent.parent / "data.db"
)
# `AIDND_DATABASE_URL`, or the conventional `DATABASE_URL`, switches the app to
# a server database. Any SQLAlchemy URL works, and hosted deploys use Postgres,
# which Phase 9 settled on Neon for. If neither variable is set, the app uses
# SQLite.
DATABASE_URL = (
os.environ.get("AIDND_DATABASE_URL", "").strip()
or os.environ.get("DATABASE_URL", "").strip()
DB_PATH.parent.mkdir(parents=True, exist_ok=True)
engine = create_engine(
f"sqlite:///{DB_PATH}",
connect_args={"check_same_thread": False},
)
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()
@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)
+179
View File
@@ -0,0 +1,179 @@
"""Which inference endpoints this product is willing to talk to.
The Adventure Storyteller sends the player's prose, the assembled context, the
retrieved memories, and the embedding inputs to whatever address the model
endpoint names. That makes the endpoint the single most consequential setting
in the application: point it somewhere else and the whole campaign goes there.
The v1 rule (`planning/DECISIONS/002-ollama-only-v1.md`, ADR 004) is that
inference runs on user-controlled local infrastructure. Two deployments are
supported and no third is:
* **same-host** — Ollama on loopback, the default;
* **explicitly configured trusted LAN** — Ollama on another machine the user
controls, named by them, reached over HTTP or over HTTPS with a certificate
their machine trusts.
Everything on the public Internet is refused. Not discouraged in the UI, not
absent from a dropdown — refused, here, on the way out, so that a hand-edited
database row or a hostname that starts resolving somewhere new cannot quietly
turn a local install into an exfiltration path.
## How the line is drawn
By **address**, not by name, and against an explicit allowlist of networks:
loopback, the three RFC1918 ranges, link-local, IPv6 unique-local, and
carrier-grade NAT — the last of which is what a mesh VPN such as Tailscale
hands out and is as user-controlled as a LAN.
Every address the host resolves to must be in one of them. One address outside
is enough to refuse the endpoint, so a name resolving to both a private and a
public address does not squeak through.
The networks are spelled out rather than inferred from `ipaddress`'s own
classifications, which do not mean what this rule needs: `is_private` is true
of the documentation ranges and of `0.0.0.0/8`, and `is_reserved` is true of
IPv6 loopback — so a rule written around it refuses `http://[::1]:11434/v1`,
which is an ordinary same-host Ollama. Naming the networks keeps the policy
readable and makes anything unnamed refused by default.
Checking addresses rather than hostnames is what makes the rule hard to talk
around. A cloud provider cannot be reached by spelling its name differently,
and `localhost.` or a DNS entry pointing at a public host is judged on where it
actually goes.
## What this is not
It is not a general network-policy framework, and there is nothing to configure.
There is one predicate, and it is applied in two places: when the endpoint is
saved, so the user gets a clear error immediately, and again before every
outbound request, because a name that resolved to `192.168.1.50` this morning
can resolve to something else this afternoon.
TLS is a separate matter and is never traded against this one. See
`tlstrust.py`: certificates are verified in full, and no endpoint — however
private its address — may skip that.
"""
import ipaddress
import socket
from urllib.parse import urlparse
#: The networks an inference endpoint may live on. Anything else is refused.
ALLOWED_NETWORKS = tuple(
ipaddress.ip_network(cidr)
for cidr in (
"127.0.0.0/8", # this machine
"10.0.0.0/8", # RFC1918
"172.16.0.0/12", # RFC1918
"192.168.0.0/16", # RFC1918
"169.254.0.0/16", # link-local
"100.64.0.0/10", # carrier-grade NAT, which mesh VPNs use
"::1/128", # this machine, v6
"fc00::/7", # unique-local, v6
"fe80::/10", # link-local, v6
)
)
def _is_local(ip) -> bool:
return any(ip in net for net in ALLOWED_NETWORKS)
#: Hosts that are only ever a cloud inference service. The address rule below
#: already refuses every one of them, because they all resolve to public
#: addresses; this list exists solely so the error says *why* rather than
#: leaving the user to wonder whether their DNS is broken.
CLOUD_HOSTS = (
"openrouter.ai",
"api.openai.com",
"api.anthropic.com",
"api.groq.com",
"api.mistral.ai",
"api.together.xyz",
"api.deepseek.com",
"generativelanguage.googleapis.com",
"api.cohere.ai",
"api.perplexity.ai",
)
_CLOUD_REASON = (
"this build talks to Ollama on your own machine or on your own network, "
"and has no cloud provider support"
)
def _cloud_host(host: str) -> bool:
host = host.lower().rstrip(".")
return any(host == h or host.endswith("." + h) for h in CLOUD_HOSTS)
def rejection_reason(url: str) -> str | None:
"""Why this endpoint may not be used, or None if it may.
The string is shown to the user, so it says what to do rather than what
went wrong internally.
"""
parsed = urlparse((url or "").strip())
if parsed.scheme not in ("http", "https"):
return "the endpoint URL must start with http:// or https://"
host = parsed.hostname
if not host:
return "the endpoint URL has no host"
if _cloud_host(host):
return f"{host} is a cloud inference service — {_CLOUD_REASON}"
port = parsed.port or (443 if parsed.scheme == "https" else 80)
try:
infos = socket.getaddrinfo(host, port, type=socket.SOCK_STREAM)
except socket.gaierror:
return (
f"the host {host!r} could not be resolved — check the address, and "
"that the machine running Ollama is reachable from here"
)
for info in infos:
try:
ip = ipaddress.ip_address(info[4][0])
except ValueError:
return "the endpoint host resolved to an address that could not be read"
if _is_local(ip):
continue
if ip.is_global:
return (
f"{host} resolves to {ip}, which is a public Internet address — "
f"{_CLOUD_REASON}. Use Ollama on this machine "
"(http://127.0.0.1:11434/v1) or on a machine on your own network"
)
return (
f"{host} resolves to {ip}, which is not on this machine and not on "
"your own network. Use http://127.0.0.1:11434/v1, or the address of "
"a machine on your network"
)
return None
def check(url: str) -> None:
"""Raises `EndpointRejected` if this endpoint is outside the policy."""
reason = rejection_reason(url)
if reason is not None:
raise EndpointRejected(reason)
class EndpointRejected(Exception):
"""The configured endpoint is not one this product will send a story to."""
def is_loopback(url: str) -> bool:
"""Whether this endpoint is on this machine. Used for reporting, not for
gating: a trusted-LAN endpoint is equally allowed."""
host = urlparse((url or "").strip()).hostname
if not host:
return False
try:
infos = socket.getaddrinfo(host, None, type=socket.SOCK_STREAM)
except socket.gaierror:
return False
try:
return all(ipaddress.ip_address(i[4][0]).is_loopback for i in infos)
except ValueError:
return False
+30 -194
View File
@@ -1,183 +1,35 @@
"""Phase 9: abuse guards for hosted, multi-user deployments.
"""Resource bounds on what a single request or a single story may cost.
Rate limits and row caps do nothing in local mode, because a single local player
should never be throttled by their own app. The values are hardcoded on purpose.
They are generous enough that a legitimate player never notices them, and tight
enough that a hostile visitor cannot exhaust the demo key, saturate the CPU, or
fill the database.
Upstream carried three things here, and only one of them belongs in a local
single-user product. Per-IP and per-user **rate limiting**, the login-attempt
throttle, and the per-user **quotas** were hosted-service policy: they existed
to stop a hostile visitor exhausting a shared demo key or filling a shared
database. M2 removed all of it. There are no visitors, and throttling the one
person who started the application would be a bug rather than a guard.
What is left is defensive programming, and it applies whatever the deployment:
* a ceiling on the **request body**, so a malformed or hostile payload cannot
be read into memory before anything looks at it;
* ceilings on how large **one adventure** may grow, in actions, memories,
story cards and branches. These bound storage and the cost of the queries
that walk them. They are per-story, not per-user: nothing here counts how
many campaigns a person may have.
An import is checked against the same per-adventure ceilings that live creation
uses, so a bundle cannot carry a story past a limit that play could not reach.
"""
import json
import os
import threading
import time
from collections import defaultdict, deque
from fastapi import HTTPException, Request
from fastapi import HTTPException
from sqlalchemy import func
from sqlalchemy.orm import Session
from . import auth, models
from . import models
# ---------- Rate limiting ----------
# Fixed windows per scope and caller. The windows live in memory, which is
# enough for the single-process deployment this app targets. The worst case
# after a restart is a brief extra allowance.
# ---------- Per-story row caps ----------
# Maps a scope to (max requests, window seconds).
RATE_LIMITS: dict[str, tuple[int, int]] = {
"turn": (10, 60), # AI turn generation. The demo key also has a daily cap.
"chat": (30, 60), # The AI Chat scratchpad, for power users.
"script-test": (30, 60), # Sandboxed, but each run costs up to 2s of CPU.
"connection-test": (10, 60), # Outbound HTTP to a user-supplied URL.
"import": (30, 60), # Large writes.
"auth": (10, 300), # Register and login attempts, per IP.
"guest": (30, 300), # New guest users, per IP. Each one is a database row.
# Pageview beacons. The limit is generous, because a real reader clicking
# around a SPA sends a handful a minute, and it is low enough that nobody
# can inflate the traffic numbers faster than by reloading the page.
"analytics": (120, 60),
}
_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 on
# most platforms, that is one, because the platform's edge appends the connecting
# IP to the right of `X-Forwarded-For`. A client can prepend any value on the
# left, but it cannot push a value past the edge's own append, so the trustworthy
# client IP is the entry that many places from the right rather than uvicorn's
# leftmost choice. Trusting the leftmost entry let anyone rotate
# `X-Forwarded-For` to get a fresh rate-limit bucket per request and bypass the
# auth and guest limits. If the deployment adds more hops, set
# `AIDND_TRUSTED_PROXY_HOPS`.
TRUSTED_PROXY_HOPS = max(1, int(os.environ.get("AIDND_TRUSTED_PROXY_HOPS", "1") or 1))
def client_ip(request: Request) -> str:
"""Returns the real client IP, resisting a spoofed `X-Forwarded-For`.
The function reads the hop the trusted edge appended, which is the rightmost
entry minus any extra trusted hops. If no forwarded header is present, which
happens locally, in development, and on a direct connection, it falls back to
the socket peer.
The function is public because the access log needs the same answer. Two
functions that each decide which address belongs to the caller is how one of
them ends up trusting a header it should not.
"""
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:
"""Raises a 429 when the caller exceeds the scope's window.
The window is keyed per user when a user is known, because an account
survives an IP change, and 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 ----------
# This is defense in depth next to the per-IP `auth` limit. A botnet dilutes
# that limit, because many real source IPs each get their own bucket, so it
# cannot by itself stop a distributed guessing run against one account. This cap
# keys on the target email rather than on the caller, so guessing one account's
# password stays expensive however many addresses the guesses come from.
#
# Only failures count, and a correct password clears the record. The window
# slides over a short period rather than locking the account, so a user who
# mistypes a few times recovers within minutes. The trade-off is that an
# attacker can keep a known account throttled, which is an inconvenience and is
# preferable to letting the account be brute-forced.
LOGIN_FAIL_LIMIT = 8 # Failed attempts per account.
LOGIN_FAIL_WINDOW = 900 # The window in seconds, which is 15 minutes.
_login_fails: dict[str, deque] = defaultdict(deque)
_login_guard = threading.Lock()
def check_login_allowed(email: str) -> None:
"""Raises a 429 when an account has too many recent failed logins.
Call this before verifying the password, so that a guess never reaches 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:
"""Records 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 against 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:
"""Clears the account's failure record after a correct password."""
with _login_guard:
_login_fails.pop(email, None)
def _prune(now: float) -> None:
"""Drops callers whose whole window has expired, so the per-IP dict stays bounded.
Call this with the guard held.
"""
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 per adventure.
MAX_MEMORIES_PER_ADVENTURE = 1000
MAX_ACTIONS_PER_ADVENTURE = 5000
@@ -200,28 +52,14 @@ def check_row_cap(
) -> None:
"""Raises a 409 when creating one more row of `kind` would exceed its cap.
The caller has already checked ownership of the scenario or adventure passed
in.
Only per-story kinds are capped. `adventures` and `scenarios` were per-user
quotas and are no longer checked; the callers still pass them, and they are
accepted and ignored so that adding a cap back is a change here rather than
at every call site.
"""
if not auth.MULTI_USER:
if kind in ("adventures", "scenarios"):
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":
if kind == "story_cards":
owner_filter = (
models.StoryCard.scenario_id == scenario_id
if scenario_id is not None
@@ -273,8 +111,6 @@ def check_bundle_lists(**lists) -> None:
The keyword arguments are `story_cards`, `memories`, `actions`, and
`branches`.
"""
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:
@@ -286,8 +122,8 @@ def check_bundle_lists(**lists) -> None:
# ---------- Request body size ----------
# The limit is generous enough for the largest legitimate payload, which is an
# adventure export holding thousands of actions. It applies in every mode, and no
# honest request approaches it.
# adventure export holding thousands of actions. No honest request approaches
# it.
MAX_BODY_BYTES = 2 * 1024 * 1024
MAX_IMPORT_BODY_BYTES = 20 * 1024 * 1024
+31 -68
View File
@@ -1,6 +1,5 @@
import mimetypes
import os
from contextlib import asynccontextmanager
from pathlib import Path
from fastapi import FastAPI
@@ -8,15 +7,10 @@ from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from starlette.exceptions import HTTPException as StarletteHTTPException
from . import analytics, cleanup
from .auth import MULTI_USER
from .database import engine
from .limits import BodySizeLimitMiddleware
from .migrations import bootstrap
from .routers import (
adventures, analytics as analytics_router, auth, chat, debug, scenarios, scripts,
settings, story_cards,
)
from .routers import adventures, chat, debug, scenarios, settings, story_cards
from .seed import seed_public_scenarios
bootstrap(engine)
@@ -24,37 +18,32 @@ seed_public_scenarios(engine)
# 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.
#
# A wildcard is refused rather than honoured. This API is unauthenticated by
# design and bound to loopback, so its only protection from a page the user
# happens to have open in another tab is the same-origin policy. `*` would hand
# every site on the Internet a write handle on the local campaign database. If the
# value is wrong the app refuses to start, because a permissive CORS policy that
# nobody notices is worse than one that fails loudly.
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"]
@asynccontextmanager
async def lifespan(_app: FastAPI):
# Sweeps once on boot, then on an interval. Booting is the reliable
# trigger on Render's free tier, where the service sleeps after ~15
# minutes and a long-running timer rarely gets to fire.
sweeper = cleanup.start_sweeper()
# Visit counters are buffered in memory and written in batches; this is
# what turns them into rows, and stop_flusher writes out the last batch so
# a deploy doesn't drop it.
flusher = analytics.start_flusher()
try:
yield
finally:
await cleanup.stop_sweeper(sweeper)
await analytics.stop_flusher(flusher)
if any(o == "*" or o.strip() == "*" for o in CORS_ORIGINS):
raise RuntimeError(
"AIDND_CORS_ORIGINS must not contain '*'. The storyteller API is "
"unauthenticated and loopback-bound; a wildcard origin would let any "
"web page read and rewrite every campaign. List the exact origins "
"instead."
)
# 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",
title="Adventure Storyteller",
docs_url="/docs",
redoc_url=None,
openapi_url=None if MULTI_USER else "/openapi.json",
lifespan=lifespan,
openapi_url="/openapi.json",
)
app.add_middleware(
@@ -117,50 +106,14 @@ class SecurityHeadersMiddleware:
await self.app(scope, receive, send_with_headers)
class ApiErrorMiddleware:
"""Counts failed API responses for the analytics dashboard.
This is middleware rather than an exception handler, because it observes
what the client received. A 429 from a rate limiter, a 404 from routing, and
a 500 from a handler that never returned all reach it the same way. It is
pure ASGI for the same reason as the headers above: an SSE turn must not be
buffered on its way out. It watches `/api` only, because a 404 on the SPA
mount is a page load rather than a fault.
"""
def __init__(self, app):
self.app = app
async def __call__(self, scope, receive, send):
if scope["type"] != "http" or not scope.get("path", "").startswith("/api"):
return await self.app(scope, receive, send)
async def send_counting(message):
if message["type"] == "http.response.start" and message["status"] >= 400:
# The router has already put the matched route on the scope by
# the time a response starts, so the label can name the
# endpoint rather than the caller's path.
analytics.record(
analytics.M_ERROR,
analytics.api_route_label(scope, message["status"]),
)
await send(message)
await self.app(scope, receive, send_counting)
app.add_middleware(ApiErrorMiddleware)
app.add_middleware(SecurityHeadersMiddleware)
app.include_router(auth.router)
app.include_router(scenarios.router)
app.include_router(adventures.router)
app.include_router(story_cards.router)
app.include_router(scripts.router)
app.include_router(settings.router)
app.include_router(chat.router)
app.include_router(debug.router)
app.include_router(analytics_router.router)
@app.get("/api/health")
@@ -171,7 +124,8 @@ def health():
# In production, serve the built frontend (frontend/dist) as static files.
class SPAStaticFiles(StaticFiles):
"""Serve index.html for unknown paths so client-side routes (/play/3)
survive a page reload. API routes are matched before this mount."""
survive a page reload. API routes are matched before this mount, and an
unmatched one 404s rather than falling through to the page."""
async def get_response(self, path, scope):
try:
@@ -179,11 +133,20 @@ class SPAStaticFiles(StaticFiles):
except StarletteHTTPException as exc:
if exc.status_code != 404:
raise
return await super().get_response("index.html", scope)
return await self._fallback(path, scope)
if response.status_code == 404:
return await super().get_response("index.html", scope)
return await self._fallback(path, scope)
return response
async def _fallback(self, path, scope):
# The mount is a catch-all, so an /api path no router claims — a typo,
# or an endpoint this build removed — used to come back as the SPA's
# HTML with status 200, and a client asking for JSON parsed a web page
# instead of seeing that the route is not there.
if path == "api" or path.startswith("api/"):
raise StarletteHTTPException(status_code=404)
return await super().get_response("index.html", scope)
# Python's mimetypes table has no entry for woff2 on a slim Debian image, so
# StaticFiles served the self-hosted fonts as application/octet-stream. Browsers
+4 -20
View File
@@ -338,6 +338,10 @@ MIGRATIONS: list[tuple[int, str | dict[str, str]]] = [
(74, "ALTER TABLE adventures ADD COLUMN persona_name VARCHAR(80) NOT NULL DEFAULT ''"),
(75, "ALTER TABLE adventures ADD COLUMN persona_pronouns VARCHAR(40) NOT NULL DEFAULT ''"),
(76, "ALTER TABLE adventures ADD COLUMN persona_desc TEXT NOT NULL DEFAULT ''"),
# M2: how long to wait for the model. Upstream hardcoded 120s in the HTTP
# client, which a cold model load on a CPU-only machine can exceed. The
# default matches `providers.openai_compatible.DEFAULT_READ_TIMEOUT`.
(77, "ALTER TABLE settings ADD COLUMN model_timeout_seconds INTEGER NOT NULL DEFAULT 300"),
]
LATEST_VERSION = max((v for v, _ in MIGRATIONS), default=1)
@@ -1106,23 +1110,3 @@ def bootstrap(engine: Engine, through: int = LATEST_VERSION) -> None:
_backfill_parents(conn)
current = version
_set_version(conn, current)
_encrypt_plaintext_api_keys(conn)
def _encrypt_plaintext_api_keys(conn) -> None:
"""Encrypts API keys saved before encryption at rest existed (Phase 8).
Those keys are stored in plain text, so this pass wraps them in Fernet. Plain
SQL cannot do it. The pass runs on every start, and it matches no rows once
every row carries the `enc:` prefix.
"""
from . import security # Deferred: security derives its key from DB_PATH setup.
rows = conn.execute(text(
"SELECT id, api_key FROM settings WHERE api_key != '' AND api_key NOT LIKE 'enc:%'"
)).all()
for row_id, plain in rows:
conn.execute(
text("UPDATE settings SET api_key = :key WHERE id = :id"),
{"key": security.encrypt_secret(plain), "id": row_id},
)
+29 -179
View File
@@ -1,8 +1,8 @@
from datetime import datetime, timezone
from sqlalchemy import (
JSON, Boolean, Column, DateTime, Float, ForeignKey, Index, Integer, LargeBinary,
String, Table, Text, UniqueConstraint, event,
JSON, Boolean, DateTime, Float, ForeignKey, Index, Integer, LargeBinary,
String, Text, event,
)
from sqlalchemy.orm import Mapped, Session, mapped_column, relationship
@@ -37,19 +37,12 @@ class User(Base):
is_guest: Mapped[bool] = mapped_column(Boolean, default=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow)
last_seen_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
# Shared demo key usage (resets when the UTC date changes).
# Was the shared demo key's per-day tally. M2 removed the demo key; these
# columns stay so existing databases open unchanged and are never written.
demo_turns_used: Mapped[int] = mapped_column(Integer, default=0)
demo_turns_date: Mapped[str] = mapped_column(String(10), default="")
scenario_scripts = Table(
"scenario_scripts",
Base.metadata,
Column("scenario_id", ForeignKey("scenarios.id", ondelete="CASCADE"), primary_key=True),
Column("script_id", ForeignKey("scripts.id", ondelete="CASCADE"), primary_key=True),
)
class Scenario(Base):
__tablename__ = "scenarios"
@@ -89,7 +82,6 @@ class Scenario(Base):
back_populates="scenario", cascade="all, delete-orphan"
)
adventures: Mapped[list["Adventure"]] = relationship(back_populates="scenario")
scripts: Mapped[list["Script"]] = relationship(secondary=scenario_scripts)
class Adventure(Base):
@@ -121,6 +113,9 @@ class Adventure(Base):
persona_name: Mapped[str] = mapped_column(String(80), default="")
persona_pronouns: Mapped[str] = mapped_column(String(40), default="")
persona_desc: Mapped[str] = mapped_column(Text, default="")
# Was the campaign scripting engine's shared `state` object. M2 removed
# scripting; the column stays so existing databases open unchanged, and it
# is never written with anything but an empty dict.
script_state: Mapped[dict] = mapped_column(JSON, default=dict)
# Phase 12: live RPG world state (world/player/npc stats + milestones),
# instantiated from the scenario's stat_schema. Empty when there's no RPG layer.
@@ -177,11 +172,6 @@ class Adventure(Base):
cascade="all, delete-orphan",
order_by="Action.id",
)
scripts: Mapped[list["AdventureScript"]] = relationship(
back_populates="adventure",
cascade="all, delete-orphan",
order_by="AdventureScript.position",
)
memories: Mapped[list["Memory"]] = relationship(
back_populates="adventure",
cascade="all, delete-orphan",
@@ -419,9 +409,10 @@ class Action(Base):
# and for re-attaching the emit block when replaying history to the model.
# Mirrors the active variant, same as text/reasoning/context_snapshot.
world_delta: Mapped[dict | None] = mapped_column(JSON, nullable=True)
# Phase 14, SP4: the shared script state and the RPG world state as they
# stood after this node was played. These columns record the node's outcome
# rather than its starting position.
# Phase 14, SP4: the RPG world state as it stood after this node was
# played. These columns record the node's outcome rather than its starting
# position. (`state_after` held the scripting engine's state, which M2
# removed; it is now always written empty.)
#
# Two operations need this outcome, and neither can use a snapshot taken
# before the turn. Switching between siblings must restore the state that
@@ -508,52 +499,6 @@ class Action(Base):
return out
class Script(Base):
__tablename__ = "scripts"
id: Mapped[int] = mapped_column(primary_key=True)
user_id: Mapped[int | None] = mapped_column(
ForeignKey("users.id", ondelete="CASCADE"), nullable=True
)
name: Mapped[str] = mapped_column(String(200), default="Untitled Script")
description: Mapped[str] = mapped_column(Text, default="")
library_js: Mapped[str] = mapped_column(Text, default="")
input_js: Mapped[str] = mapped_column(Text, default="")
context_js: Mapped[str] = mapped_column(Text, default="")
output_js: Mapped[str] = mapped_column(Text, default="")
created_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow, onupdate=utcnow)
class AdventureScript(Base):
"""A script copied into an adventure at creation, so library edits don't
change running adventures unless the player explicitly re-syncs it from
`source_script_id`. `state` lives on Adventure.script_state (one shared
state per adventure, as in AI Dungeon)."""
__tablename__ = "adventure_scripts"
id: Mapped[int] = mapped_column(primary_key=True)
adventure_id: Mapped[int] = mapped_column(ForeignKey("adventures.id", ondelete="CASCADE"))
# The library Script that this copy was made from, which lets the player
# re-sync it on demand. The value is NULL for legacy copies that predate
# this column, and for demo-derived copies whose source the player does not
# own. Those copies fall back to matching by name, or cannot be synced.
source_script_id: Mapped[int | None] = mapped_column(
ForeignKey("scripts.id", ondelete="SET NULL"), nullable=True
)
position: Mapped[int] = mapped_column(Integer, default=0)
enabled: Mapped[bool] = mapped_column(Boolean, default=True)
name: Mapped[str] = mapped_column(String(200), default="Untitled Script")
description: Mapped[str] = mapped_column(Text, default="")
library_js: Mapped[str] = mapped_column(Text, default="")
input_js: Mapped[str] = mapped_column(Text, default="")
context_js: Mapped[str] = mapped_column(Text, default="")
output_js: Mapped[str] = mapped_column(Text, default="")
adventure: Mapped[Adventure] = relationship(back_populates="scripts")
class Settings(Base):
__tablename__ = "settings"
@@ -564,8 +509,10 @@ class Settings(Base):
ForeignKey("users.id", ondelete="CASCADE"), nullable=True, unique=True
)
endpoint_url: Mapped[str] = mapped_column(String(500), default="http://localhost:11434/v1")
# Encrypted at rest with Fernet, which produces a value that starts with
# "enc:". See security.py. To read the key, use `api_key_plain`.
# Was a cloud provider's API key, encrypted at rest. Ollama does not use
# one and M2 removed cloud providers, so nothing reads or writes this now.
# The column stays so existing databases open unchanged; an old value is
# left where it is rather than migrated or decrypted.
api_key: Mapped[str] = mapped_column(String(500), default="")
model: Mapped[str] = mapped_column(String(200), default="")
api_mode: Mapped[str] = mapped_column(String(20), default="chat") # chat|completion
@@ -579,6 +526,11 @@ class Settings(Base):
# max_output_tokens so story output keeps its full budget.
reasoning_max_tokens: Mapped[int] = mapped_column(Integer, default=0)
context_token_budget: Mapped[int] = mapped_column(Integer, default=16384)
# How long to wait for the model, in seconds, before giving up on a turn.
# A cold load of a mid-sized model on a CPU-only machine can take minutes,
# while the same turn takes seconds once the model is resident. See
# `providers.openai_compatible.DEFAULT_READ_TIMEOUT`.
model_timeout_seconds: Mapped[int] = mapped_column(Integer, default=300)
narrator_prompt: Mapped[str] = mapped_column(
Text,
default=(
@@ -599,120 +551,18 @@ class Settings(Base):
memory_bank_capacity: Mapped[int] = mapped_column(Integer, default=80)
memory_top_k: Mapped[int] = mapped_column(Integer, default=5)
@property
def has_api_key(self) -> bool:
return bool(self.api_key)
@property
def api_key_plain(self) -> str:
from . import security # local import: models is imported before security
return security.decrypt_secret(self.api_key)
# ---------- Visit analytics (see analytics.py) ----------
# Two intentionally simple tables. Neither can hold text that a player wrote,
# and neither can be joined back to a `users` row, because the visitor column
# holds an HMAC and has no foreign key. When guest cleanup deletes an account,
# the history that account contributed remains intact and anonymous.
class AnalyticsDaily(Base):
"""One counter: how many times `label` happened within `metric` on `day`.
The table stores a generic triple of metric, label, and hits rather than one
column per statistic. Measuring something new therefore costs a constant
rather than a migration. The only writer is an UPSERT that runs from a
buffer. See `analytics.flush`.
"""
__tablename__ = "analytics_daily"
id: Mapped[int] = mapped_column(primary_key=True)
day: Mapped[str] = mapped_column(String(10), index=True) # YYYY-MM-DD, UTC
metric: Mapped[str] = mapped_column(String(32))
label: Mapped[str] = mapped_column(String(80), default="")
hits: Mapped[int] = mapped_column(Integer, default=0)
# The upsert target: one row per bucket per day, created or incremented.
__table_args__ = (
UniqueConstraint("day", "metric", "label", name="uq_analytics_daily_bucket"),
)
class AnalyticsVisitorDay(Base):
"""One visitor, one day, and which funnel steps they reached on it.
This table exists so that the funnel counts people rather than clicks. A
player who starts six adventures counts as one person who started an
adventure. `is_new` is set when the visitor has no earlier row, which is why
the visitor column also has an index of its own.
"""
__tablename__ = "analytics_visitor_days"
id: Mapped[int] = mapped_column(primary_key=True)
day: Mapped[str] = mapped_column(String(10))
# HMAC of the user id under the app secret; not reversible, not a key.
visitor: Mapped[str] = mapped_column(String(32))
is_new: Mapped[bool] = mapped_column(Boolean, default=False)
opened: Mapped[bool] = mapped_column(Boolean, default=False)
created: Mapped[bool] = mapped_column(Boolean, default=False)
played: Mapped[bool] = mapped_column(Boolean, default=False)
signed_up: Mapped[bool] = mapped_column(Boolean, default=False)
__table_args__ = (
UniqueConstraint("day", "visitor", name="uq_analytics_visitor_day"),
Index("ix_analytics_visitor", "visitor"),
)
class AccessEvent(Base):
"""One sign-in, registration, failed attempt, or session first-seen.
This table is the counterpart to the two above, and it is kept separate from
them on purpose. It identifies people by design, recording address, email,
and device. Keeping it in its own table and its own module means that the
structure enforces the anonymity of the counters rather than a convention.
`user_id` is a plain integer with no foreign key. An access log that
disappeared when the account did would not serve its purpose, and guest
cleanup deletes accounts on a schedule. `who` and `is_guest` are snapshots
for the same reason, so a row still reads correctly after the account is
gone.
"""
__tablename__ = "access_events"
id: Mapped[int] = mapped_column(primary_key=True)
at: Mapped[datetime] = mapped_column(DateTime, default=utcnow, index=True)
# session | login | register | login_failed
kind: Mapped[str] = mapped_column(String(16))
user_id: Mapped[int | None] = mapped_column(Integer, nullable=True)
# The email for a registered account, or a label such as "Guest #12"
# otherwise. For a failed sign-in, this holds the address that was tried,
# which is the reason the row exists.
who: Mapped[str] = mapped_column(String(320), default="")
is_guest: Mapped[bool] = mapped_column(Boolean, default=False)
ip: Mapped[str] = mapped_column(String(45), default="") # 45 = max IPv6
country: Mapped[str] = mapped_column(String(16), default="")
device: Mapped[str] = mapped_column(String(16), default="")
user_agent: Mapped[str] = mapped_column(String(200), default="")
# Phase 14: the fallback under `tree.place_action`.
# The hosted visitor dashboard's two counter tables and the access log that
# recorded sign-ins, addresses and devices used to be mapped here. M2 removed
# the hosted deployment they served.
#
# Since SP2, reads filter on `branch_id` and `depth`. A node written without
# them is invisible to every page, every context build, and every memory pass.
# The failure is silent, because nothing raises an error. Every current writer
# places its nodes explicitly, but relying on that would also mean relying on
# every fixture, script, and test written from now on. The session therefore
# enforces the rule as rows travel to the database.
#
# This listener is registered here rather than in tree.py so that importing the
# models is enough to enable it. The invariant belongs to the rows, not to the
# module that usually writes them. The import sits inside the callback because
# tree.py imports this module.
# The tables are left in the database rather than dropped: they are inert,
# nothing reads or writes them, and a destructive migration would risk an
# existing campaign database for tidiness alone. They are not product
# functionality.
@event.listens_for(Session, "before_flush")
def _place_new_nodes_on_the_tree(session, flush_context, instances):
from . import tree
-52
View File
@@ -1,52 +0,0 @@
"""SSRF guard for the one place the server makes an outbound request to a
user-supplied address: the BYOK `endpoint_url` (connection test + turns/chat).
Without this guard, a hosted user could point `endpoint_url` at an internal
service or at the cloud metadata endpoint, 169.254.169.254, and have the server
fetch it. The connection test even returns part of the response. The guard
therefore refuses any URL that resolves to a non-public address.
The guard does nothing in local mode. A local install talking to
http://localhost:11434, which is Ollama, is the intended case. The guard applies
only to a hosted, multi-user deployment, where the endpoint comes from an
untrusted visitor.
"""
import ipaddress
import socket
from urllib.parse import urlparse
from . import auth
def endpoint_block_reason(url: str) -> str | None:
"""A human-readable reason this URL must NOT be fetched server-side, or None
if it's allowed. Resolves the host and rejects it if any resulting address
is non-public (private, loopback, link-local/metadata, reserved, …).
Checking at request time (not just on save) is deliberate: it resists a DNS
record that flips to a private IP after the value was stored.
"""
if not auth.MULTI_USER:
return None
parsed = urlparse(url)
if parsed.scheme not in ("http", "https"):
return "the endpoint URL must start with http:// or https://"
host = parsed.hostname
if not host:
return "the endpoint URL has no host"
port = parsed.port or (443 if parsed.scheme == "https" else 80)
try:
infos = socket.getaddrinfo(host, port, type=socket.SOCK_STREAM)
except socket.gaierror:
return "the endpoint host could not be resolved"
for info in infos:
try:
ip = ipaddress.ip_address(info[4][0])
except ValueError:
return "the endpoint host resolved to an unrecognized address"
# is_global is the strict allowlist: private/loopback/link-local/CGNAT
# all report False, so this one check covers the metadata IP too.
if not ip.is_global or ip.is_multicast or ip.is_reserved:
return "the endpoint URL resolves to a non-public address"
return None
+54 -82
View File
@@ -3,32 +3,27 @@ from typing import AsyncIterator
import httpx
from .. import debuglog, netguard, tlstrust
from .. import debuglog, endpoints, tlstrust
from .base import PromptParts, Provider, ProviderError
# Appended after the story text in chat mode, so a chat-tuned model continues
# the prose rather than replying conversationally.
CHAT_CONTINUE_HINT = "\n\n[Continue the story directly. Output only story text.]"
# OpenRouter serves one model from whichever upstream is available, and every
# upstream holds its own prompt cache, so a request routed somewhere new starts
# with a cold cache however stable the prompt is. Naming a preferred upstream
# makes routing deterministic, which is what allows a cache hit at all.
#
# `allow_fallbacks` stays at its default of true on purpose, because this is a
# preference rather than a restriction. If the named upstream is down, the
# request still goes elsewhere and only misses the cache, which is the behavior
# without this setting.
#
# This is a list rather than a value derived from the model slug. The vendor half
# of a slug is usually the provider slug, such as "deepseek/..." mapping to
# "deepseek", which was verified against /api/v1/providers, but not reliably.
# Google's models are served by "google-ai-studio" and "google-vertex", and there
# is no "google". Look a vendor up on the model's Providers tab before adding it
# here. A slug that does not exist is a routing preference that, at best, does
# nothing.
_OPENROUTER_HOST = "openrouter.ai"
_PREFERRED_UPSTREAM = {"deepseek": "deepseek"}
# A machine that is not listening refuses in milliseconds, so a slow connect
# means the wrong address rather than a busy model.
CONNECT_TIMEOUT = 10.0
# How long to wait for generation when Settings names no value. Upstream
# hardcoded 120s, and M1 measured a *cold* load of a 3B model on a GPU-less
# four-core host exceeding it three times while the same turn took 6-9 seconds
# once the model was resident. 300s covers a cold start on modest hardware and
# is still a number: a wedged endpoint fails rather than hanging forever.
DEFAULT_READ_TIMEOUT = 300.0
# Embeddings are short and never cold-load a large model.
EMBED_READ_TIMEOUT = 60.0
# Completion endpoints have no roles, so a chat has to be flattened into one
@@ -44,74 +39,47 @@ def flatten_messages(messages: list[dict]) -> str:
class OpenAICompatibleProvider(Provider):
"""Adapter for any /v1-style endpoint.
"""Adapter for Ollama's OpenAI-compatible `/v1` API.
This covers Ollama, LM Studio, OpenAI, OpenRouter, vLLM, and Groq, among
others.
The protocol is OpenAI's, which is what the module is named for; the
product speaks it to Ollama and to nothing else. `endpoints.py` decides
which addresses may be reached, and every request re-checks — the shape of
the wire format is not the same thing as permission to use it.
"""
def __init__(
self,
endpoint_url: str,
api_key: str,
model: str,
api_mode: str = "chat",
reasoning_max_tokens: int = 0,
read_timeout: float | None = None,
):
self.base_url = endpoint_url.rstrip("/")
self.api_key = api_key
self.model = model
self.api_mode = api_mode # Either "chat" or "completion".
# The thinking budget for reasoning models, on top of `max_tokens`. A
# value of 0 means the `reasoning` parameter is not sent, because an
# endpoint that does not know the field may reject it. A negative value
# asks the endpoint to turn reasoning off.
self.reasoning_max_tokens = reasoning_max_tokens
# How long to wait for the model, in seconds. Cold-loading a model on a
# CPU-only machine can take minutes, and a fixed short timeout reports
# that as a failure. See `DEFAULT_READ_TIMEOUT`.
self.read_timeout = read_timeout or DEFAULT_READ_TIMEOUT
# The token accounting from the last call, when the endpoint reported
# any. It holds the prompt and completion counts, plus, on OpenRouter,
# `prompt_tokens_details.cached_tokens`, which is the number of prompt
# tokens read from cache rather than billed in full. Every request method
# writes it, so a caller reads it after the call it made. One provider is
# built per request.
# any. Every request method writes it, so a caller reads it after the
# call it made. One provider is built per request.
self.last_usage: dict | None = None
def _headers(self) -> dict:
headers = {"Content-Type": "application/json"}
if self.api_key:
headers["Authorization"] = f"Bearer {self.api_key}"
return headers
# No Authorization header: Ollama does not use one, and this build has
# no cloud provider to carry a key for.
return {"Content-Type": "application/json"}
def _apply_reasoning_budget(self, body: dict) -> None:
"""Gives reasoning models their own thinking budget, in the OpenRouter style.
def _timeout(self, seconds: float | None = None) -> httpx.Timeout:
"""Short to connect, patient to read.
The method raises `max_tokens`, so the output keeps its full budget.
A negative budget does the opposite. It sends `effort: "none"` to turn
reasoning off on a model that reasons by default, such as DeepSeek V4
Flash. That differs from `exclude: true`, which still reasons and still
bills for it while hiding the trace. Zero still means send nothing, so an
endpoint that rejects unknown fields, such as Ollama, keeps working.
A machine that is not listening says so in milliseconds, so a slow
connect is a wrong address rather than a busy model and should fail
fast. Generation is the opposite: the first token can be minutes away
while a model loads.
"""
if self.api_mode != "chat":
return
if self.reasoning_max_tokens < 0:
body["reasoning"] = {"effort": "none"}
elif self.reasoning_max_tokens > 0:
body["reasoning"] = {"max_tokens": self.reasoning_max_tokens}
body["max_tokens"] += self.reasoning_max_tokens
def _apply_provider_routing(self, body: dict) -> None:
"""Prefers one upstream on OpenRouter, so the prompt cache stays warm.
The method does nothing anywhere else. `provider` is an OpenRouter
extension, and Ollama and similar servers reject fields they do not know.
The `reasoning` parameter above is written around the same constraint.
"""
if _OPENROUTER_HOST not in self.base_url:
return
upstream = _PREFERRED_UPSTREAM.get(self.model.split("/", 1)[0].lower())
if upstream:
body["provider"] = {"order": [upstream]}
return httpx.Timeout(seconds or self.read_timeout, connect=CONNECT_TIMEOUT)
def _record_usage(self, payload: dict) -> None:
"""Records the endpoint's own token accounting, if it reported any.
@@ -147,8 +115,6 @@ class OpenAICompatibleProvider(Provider):
"max_tokens": max_tokens,
"stream": True,
}
self._apply_reasoning_budget(body)
self._apply_provider_routing(body)
return url, body
@staticmethod
@@ -227,8 +193,6 @@ class OpenAICompatibleProvider(Provider):
"max_tokens": max_tokens,
"stream": True,
}
self._apply_reasoning_budget(body)
self._apply_provider_routing(body)
async for event in self._stream(url, body):
yield event
@@ -238,17 +202,17 @@ class OpenAICompatibleProvider(Provider):
The method POSTs a streaming request, yields `("text", chunk)` and
`("reasoning", chunk)` pairs, and logs the exchange.
"""
# SSRF guard for hosted mode. A user-supplied `endpoint_url` must not
# point at an internal or metadata address. This does nothing for a
# local install.
reason = netguard.endpoint_block_reason(url)
# Re-checked on every request, not only when the endpoint was saved: a
# hostname that resolved to a LAN address yesterday can resolve
# somewhere else today, and a database row can be edited by hand.
reason = endpoints.rejection_reason(url)
if reason:
raise ProviderError(f"This endpoint can't be used — {reason}.")
log = debuglog.start_entry(url, self.model, body)
received: list[str] = []
try:
async with httpx.AsyncClient(
timeout=httpx.Timeout(120, connect=10), verify=tlstrust.ssl_context()
timeout=self._timeout(), verify=tlstrust.ssl_context()
) as client:
async with client.stream("POST", url, json=body, headers=self._headers()) as resp:
if resp.status_code != 200:
@@ -351,13 +315,16 @@ class OpenAICompatibleProvider(Provider):
"max_tokens": max_tokens,
"stream": False,
}
self._apply_reasoning_budget(body)
self._apply_provider_routing(body)
# Same check as `_stream`: every outbound request re-tests the
# endpoint, so no path reaches an address the policy refuses.
reason = endpoints.rejection_reason(url)
if reason:
raise ProviderError(f"This endpoint can't be used — {reason}.")
log = debuglog.start_entry(url, self.model, body)
try:
async with httpx.AsyncClient(
timeout=httpx.Timeout(120, connect=10), verify=tlstrust.ssl_context()
timeout=self._timeout(), verify=tlstrust.ssl_context()
) as client:
resp = await client.post(url, json=body, headers=self._headers())
except httpx.HTTPError as exc:
@@ -383,10 +350,15 @@ class OpenAICompatibleProvider(Provider):
raise ProviderError("No embedding model configured — set one in Settings.")
url = f"{self.base_url}/embeddings"
body = {"model": self.model, "input": texts}
# Same check as `_stream`: every outbound request re-tests the
# endpoint, so no path reaches an address the policy refuses.
reason = endpoints.rejection_reason(url)
if reason:
raise ProviderError(f"This endpoint can't be used — {reason}.")
log = debuglog.start_entry(url, self.model, body)
try:
async with httpx.AsyncClient(
timeout=httpx.Timeout(60, connect=10), verify=tlstrust.ssl_context()
timeout=self._timeout(EMBED_READ_TIMEOUT), verify=tlstrust.ssl_context()
) as client:
resp = await client.post(url, json=body, headers=self._headers())
except httpx.HTTPError as exc:
@@ -32,7 +32,6 @@ from . import ( # noqa: F401
takes,
branches,
bundle_io,
scripts,
refresh,
insights,
memories,
+1 -3
View File
@@ -7,7 +7,7 @@ only check ownership and hand the work over.
from fastapi import Body, Depends, Request
from sqlalchemy.orm import Session
from ... import analytics, bundle, limits, models, schemas
from ... import bundle, limits, models, schemas
from ...database import get_db
from .deps import CurrentUser, current_adventure, router
@@ -34,7 +34,6 @@ def import_adventure(
user: models.User = CurrentUser,
):
version = bundle.check_format(payload)
limits.rate_limit("import", request, user)
limits.check_row_cap("adventures", db, user)
limits.check_bundle_lists(
story_cards=payload.get("storyCards"),
@@ -66,5 +65,4 @@ def import_adventure(
# This is not a funnel step. A returning player imports a bundle, so it
# says nothing about how far a first-time visitor got. It is counted anyway,
# because it is the clearest evidence that anyone uses the export format.
analytics.record_event(analytics.EV_IMPORT, user)
return adventure
+1 -35
View File
@@ -9,7 +9,7 @@ from sqlalchemy import func
from sqlalchemy.orm import Session
from sqlalchemy.orm.attributes import set_committed_value
from ... import analytics, attempts, images, limits, memorybank, models, schemas, tree, worldstate
from ... import attempts, images, limits, memorybank, models, schemas, tree, worldstate
from ...database import get_db
from .deps import CurrentUser, current_adventure, router
@@ -195,20 +195,6 @@ def create_adventure(
if scenario:
for ref, spec in scenario_card_specs(scenario, values).items():
db.add(models.StoryCard(adventure_id=adventure.id, source_ref=ref, **spec))
for position, script in enumerate(scenario.scripts):
db.add(
models.AdventureScript(
adventure_id=adventure.id,
source_script_id=script.id,
position=position,
name=script.name,
description=script.description,
library_js=script.library_js,
input_js=script.input_js,
context_js=script.context_js,
output_js=script.output_js,
)
)
if scenario.prompt.strip():
opening = models.Action(
adventure_id=adventure.id,
@@ -223,12 +209,6 @@ def create_adventure(
db.commit()
db.refresh(adventure)
analytics.record_event(analytics.EV_ADVENTURE, user)
# Track which shared scenarios players pick. This is the only content this
# module records, and it records only public scenarios. A player's own
# scenario titles stay private.
if scenario is not None and scenario.is_public:
analytics.record(analytics.M_SCENARIO, scenario.title)
return adventure
@@ -262,20 +242,6 @@ def get_adventure(
return out
@router.get("/{adventure_id}/script-state")
def get_script_state(
adventure: models.Adventure = Depends(current_adventure),
):
"""Returns the scripting `state` object.
The object holds every variable that scripts read and write through
`state.x`, persisted after each hook. It stays `{}` until a script sets a
variable.
"""
state = adventure.script_state if isinstance(adventure.script_state, dict) else {}
return {"state": state}
@router.get("/{adventure_id}/world-state")
def get_world_state(
adventure: models.Adventure = Depends(current_adventure),
+2 -9
View File
@@ -7,7 +7,7 @@ returns the prompt a turn was actually generated from. Neither writes anything.
from fastapi import Depends, HTTPException
from sqlalchemy.orm import Session
from ... import auth, memorybank, models
from ... import memorybank, models
from ...context import build_context
from ...database import get_db
from ..settings import get_settings
@@ -23,14 +23,7 @@ async def dry_run_context(
):
"""Returns what the app would send to the AI if the player continued now."""
settings = get_settings(db, user)
if auth.resolve_provider_config(settings).using_demo:
memories = (
{"used": [], "error": "Memory bank is unavailable on the shared demo key."}
if adventure.memory_bank_enabled
else None
)
else:
memories = await memorybank.retrieve_memories(adventure, settings, update_stats=False)
memories = await memorybank.retrieve_memories(adventure, settings, update_stats=False)
_, _, report = build_context(adventure, settings, memories)
return report
-116
View File
@@ -1,116 +0,0 @@
"""The per-adventure copies of library scripts.
An adventure snapshots a library `Script` when it starts, so editing the library
does not change a story in progress. These endpoints report whether a snapshot
has fallen behind its library original, and copy the original over on request.
"""
from fastapi import Depends, HTTPException
from sqlalchemy.orm import Session
from ... import models, schemas
from ...database import get_db
from .deps import CurrentUser, current_adventure, router
# Fields that are copied from a library Script into its adventure-script
# snapshot, and compared to decide whether a copy is out of date.
SYNC_FIELDS = ("name", "description", "library_js", "input_js", "context_js", "output_js")
def resolve_library_script(
adv_script: models.AdventureScript, db: Session, user: models.User
) -> models.Script | None:
"""Returns the library Script an adventure script can re-sync from.
The result is the script this copy was made from. For a legacy copy with no
link, it is one of the player's own scripts with the same name. Only the
player's own scripts are considered, so a copy derived from a demo scenario
has nothing to sync to.
"""
if adv_script.source_script_id is not None:
script = db.get(models.Script, adv_script.source_script_id)
if script is not None and script.user_id == user.id:
return script
return (
db.query(models.Script)
.filter(models.Script.user_id == user.id, models.Script.name == adv_script.name)
.order_by(models.Script.updated_at.desc())
.first()
)
def _mark_out_of_date(
adv_script: models.AdventureScript, db: Session, user: models.User
) -> models.AdventureScript:
"""Attaches a transient `out_of_date` flag, which `AdventureScriptOut` reads.
The flag is `True` or `False` when a syncable library version exists, and
`None` when none exists.
"""
library = resolve_library_script(adv_script, db, user)
adv_script.out_of_date = (
None if library is None
else any(getattr(adv_script, f) != getattr(library, f) for f in SYNC_FIELDS)
)
return adv_script
@router.get("/{adventure_id}/scripts", response_model=list[schemas.AdventureScriptOut])
def list_adventure_scripts(
db: Session = Depends(get_db),
user: models.User = CurrentUser,
adventure: models.Adventure = Depends(current_adventure),
):
return [_mark_out_of_date(s, db, user) for s in adventure.scripts]
@router.post(
"/{adventure_id}/scripts/{adv_script_id}/sync",
response_model=schemas.AdventureScriptOut,
)
def sync_adventure_script(
adventure_id: int,
adv_script_id: int,
db: Session = Depends(get_db),
user: models.User = CurrentUser,
adventure: models.Adventure = Depends(current_adventure),
):
"""Overwrites this copy's code with the latest from its library script.
`enabled`, `position`, and the adventure's shared `script_state` are kept.
"""
script = db.get(models.AdventureScript, adv_script_id)
if script is None or script.adventure_id != adventure_id:
raise HTTPException(404, "Script not found")
library = resolve_library_script(script, db, user)
if library is None:
raise HTTPException(404, "No library script to sync from")
for field in SYNC_FIELDS:
setattr(script, field, getattr(library, field))
# Store the link, so that a name-matched legacy copy syncs by id next
# time.
script.source_script_id = library.id
db.commit()
db.refresh(script)
return _mark_out_of_date(script, db, user)
@router.patch(
"/{adventure_id}/scripts/{adv_script_id}", response_model=schemas.AdventureScriptOut
)
def update_adventure_script(
adventure_id: int,
adv_script_id: int,
payload: schemas.AdventureScriptUpdate,
db: Session = Depends(get_db),
adventure: models.Adventure = Depends(current_adventure),
):
script = db.get(models.AdventureScript, adv_script_id)
if script is None or script.adventure_id != adventure_id:
raise HTTPException(404, "Script not found")
for field, value in payload.model_dump(exclude_unset=True).items():
setattr(script, field, value)
db.commit()
return script
+2 -11
View File
@@ -15,7 +15,6 @@ from ... import attempts, limits, memorybank, models, schemas, tree
from ...context import cursors
from ...context import lineage
from ...database import get_db
from ...scripting import ScriptPipeline
from ...sse import SSE_HEADERS
from . import turns
@@ -39,8 +38,6 @@ def retry_action(
attempt is stored as a sibling at the same coordinate. No text the AI wrote
is rewritten or deleted.
"""
limits.rate_limit("turn", request, user)
turns.check_demo_cap(db, user)
turns.acquire_turn_lock(adventure_id)
last_ai = None
try:
@@ -62,9 +59,7 @@ def retry_action(
return StreamingResponse(
turns.with_turn_lock(
adventure_id,
turns.generate_turn(
adventure, db, ScriptPipeline(adventure, db), user, retry_of=last_ai
),
turns.generate_turn(adventure, db, user, retry_of=last_ai),
),
media_type="text/event-stream",
headers=SSE_HEADERS,
@@ -254,9 +249,7 @@ def add_take(
turn, so the new attempt is written at the same depth under the same parent,
and the line it leaves is unchanged. No node below is copied.
"""
limits.rate_limit("turn", request, user)
limits.check_row_cap("actions", db, user, adventure=adventure)
turns.check_demo_cap(db, user)
action = db.get(models.Action, action_id)
if action is None or action.adventure_id != adventure_id:
raise HTTPException(404, "Action not found")
@@ -292,9 +285,7 @@ def add_take(
if action.type == "ai":
# There is no player action to write. The action this turn answers is
# already on the path, borrowed from the line being left.
stream = turns.generate_turn(
adventure, db, ScriptPipeline(adventure, db), user, retry_of=retry_of
)
stream = turns.generate_turn(adventure, db, user, retry_of=retry_of)
else:
stream = turns.run_player_turn(
adventure,
+18 -97
View File
@@ -3,8 +3,8 @@
Everything a test needs to intercept lives here, and other modules reach it as
`turns.<name>` rather than importing it by value. That matters twice. The turn
lock guards one set only while one module owns it. And a test that replaces
`OpenAICompatibleProvider`, `generate_turn`, or `check_demo_cap` patches this
module, which every caller reads through.
`OpenAICompatibleProvider` or `generate_turn` patches this module, which every
caller reads through.
"""
import threading
@@ -13,12 +13,11 @@ from fastapi.responses import StreamingResponse
from sqlalchemy.orm import Session
from ... import (
analytics, attempts, auth, limits, memorybank, models, schemas, tree, worldstate,
attempts, limits, memorybank, models, schemas, tree, worldstate,
)
from ...context import build_context, cursors
from ...database import get_db
from ...providers import OpenAICompatibleProvider, PromptParts, ProviderError
from ...scripting import ScriptPipeline
from ...sse import SSE_HEADERS, sse, turn_error
from ..settings import get_settings
@@ -115,14 +114,11 @@ def action_json(action: models.Action, db: Session | None = None) -> dict:
async def generate_turn(
adventure: models.Adventure,
db: Session,
pipeline: ScriptPipeline,
user: models.User,
retry_of: models.Action | None = None,
):
"""Streams the AI continuation as SSE, then stores the result.
The continuation passes through the `context` and `output` script hooks.
If `retry_of` is set, the result is stored as a sibling of that AI action, at
the same turn and the same coordinate, and the discarded attempt stays where
it was written. Before calling, the caller must roll the adventure back to
@@ -132,15 +128,15 @@ async def generate_turn(
"""
saved = False
try:
async for event in _generate_turn(adventure, db, pipeline, user, retry_of):
async for event in _generate_turn(adventure, db, user, retry_of):
if event is _SAVED:
saved = True
continue
yield event
finally:
if retry_of is not None and not saved:
# The turn failed with a provider error, an empty reply, a script
# stop, or a disconnected client. No sibling was written, so the
# The turn failed with a provider error, an empty reply, or a
# disconnected client. No sibling was written, so the
# attempt on screen is still the live one. Restore the state it
# produced.
attempts.restore_state(adventure, retry_of)
@@ -155,55 +151,26 @@ _SAVED = object()
async def _generate_turn(
adventure: models.Adventure,
db: Session,
pipeline: ScriptPipeline,
user: models.User,
retry_of: models.Action | None = None,
):
settings = get_settings(db, user)
cfg = auth.resolve_provider_config(settings)
# On a retry, the attempt being replaced is still the live node of its turn,
# because it stays live until a replacement exists. Filter it out of the
# context. Otherwise the model reads the attempt it is replacing as
# established story and writes a sequel to it.
replacing_id = retry_of.id if retry_of is not None else None
if cfg.using_demo:
# The server-funded key makes no embedding or summarization calls, so
# memory retrieval is skipped. If the bank is on, return a note.
memories = (
{"used": [], "error": "Memory bank is unavailable on the shared demo key — add your own API key in Settings."}
if adventure.memory_bank_enabled
else None
)
else:
memories = await memorybank.retrieve_memories(
adventure, settings, update_stats=True, exclude_action_id=replacing_id
)
memories = await memorybank.retrieve_memories(
adventure, settings, update_stats=True, exclude_action_id=replacing_id
)
system_text, story_text, snapshot = build_context(
adventure, settings, memories, exclude_action_id=replacing_id
)
# onModelContext: scripts read, and can rewrite, the whole assembled
# context.
combined = f"{system_text}\n\n{story_text}" if system_text else story_text
modified, stop = pipeline.run("context", combined)
if stop:
yield sse({"type": "stopped", "script": pipeline.report()})
return
context_changed = modified != combined
parts = (
PromptParts(system="", story=modified)
if context_changed
else PromptParts(system=system_text, story=story_text)
)
snapshot["script"] = pipeline.report() | {
"context_changed": context_changed,
"context_before": combined if context_changed else None,
"context_after": modified if context_changed else None,
}
parts = PromptParts(system=system_text, story=story_text)
provider = OpenAICompatibleProvider(
cfg.endpoint_url, cfg.api_key, cfg.model, settings.api_mode,
settings.reasoning_max_tokens,
settings.endpoint_url, settings.model, settings.api_mode
)
chunks: list[str] = []
reasoning_chunks: list[str] = []
@@ -239,13 +206,6 @@ async def _generate_turn(
yield turn_error(detail)
return
# onOutput
text, _ = pipeline.run("output", text)
if not text.strip():
yield turn_error("A script's output modifier returned empty text.")
return
snapshot["script"] = snapshot["script"] | pipeline.report()
# RPG world state (Phase 12): read the AI's state delta out of the reply,
# apply it through the engine, and strip the block from the displayed text.
#
@@ -304,39 +264,15 @@ async def _generate_turn(
tree.place_action(db, adventure, ai_action)
db.add(ai_action)
adventure.updated_at = models.utcnow()
if cfg.using_demo:
# Successful demo turns count against the daily cap, which the endpoint
# checks before the turn starts. A failed provider call above returns
# before this line.
auth.count_demo_turn(user)
db.commit()
# Count the turn here, after every path on which it could still have failed,
# so the number means "stories advanced" rather than "requests attempted".
# The demo tally counts those same turns as spend on the server-funded key.
analytics.record_event(analytics.EV_TURN, user)
if cfg.using_demo:
analytics.record(analytics.M_EVENT, analytics.EV_DEMO_TURN)
db.refresh(ai_action)
yield _SAVED
yield sse({"type": "done", "action": action_json(ai_action, db), "script": pipeline.report()})
yield sse({"type": "done", "action": action_json(ai_action, db)})
# Phase 6: schedule summarization and embedding without waiting for them.
# The task opens its own database session. It is skipped on the demo key,
# because background AI calls are unmetered spend on the server-funded
# key.
if not cfg.using_demo:
memorybank.schedule_post_turn(adventure)
# The task opens its own database session.
memorybank.schedule_post_turn(adventure)
def check_demo_cap(db: Session, user: models.User) -> None:
"""Checks the demo cap before a turn starts.
Checking first avoids storing a capped player's input and then leaving it
without a reply.
"""
settings = get_settings(db, user)
if auth.resolve_provider_config(settings).using_demo and auth.demo_turns_left(user) <= 0:
raise HTTPException(429, auth.DEMO_CAP_MESSAGE)
async def run_player_turn(
adventure: models.Adventure,
@@ -353,29 +289,20 @@ async def run_player_turn(
formatted, and a plain edit puts that same text in the box and writes it back
verbatim. Formatting it a second time produces `> You > You ...`.
"""
pipeline = ScriptPipeline(adventure, db)
# An empty do, say, or story action behaves as a continue.
if payload.type != "continue" and payload.text.strip():
# onInput reads the formatted text, as in AI Dungeon: "> You ...".
formatted = (
payload.text.strip() if preformatted
else format_player_input(payload.type, payload.text)
)
modified, stop = pipeline.run("input", formatted)
if not modified.strip():
yield turn_error("A script's input modifier returned empty text.",
script=pipeline.report())
return
player_action = models.Action(
adventure_id=adventure.id,
depth=next_depth(adventure),
type=payload.type,
text=modified,
text=formatted,
)
# The state after the input hook has run. The node leaves this state
# behind. The AI turn after it starts here, and a retry of that turn
# rolls back to here.
# The state this node leaves behind. The AI turn after it starts here,
# and a retry of that turn rolls back to here.
attempts.snapshot_outcome(adventure, player_action)
tree.place_action(db, adventure, player_action)
db.add(player_action)
@@ -387,12 +314,8 @@ async def run_player_turn(
# that was just saved.
db.expire(adventure, ["actions"])
yield sse({"type": "player", "action": action_json(player_action, db)})
if stop:
# If onInput returns `{ stop: true }`, skip the AI call.
yield sse({"type": "stopped", "script": pipeline.report()})
return
async for event in generate_turn(adventure, db, pipeline, user):
async for event in generate_turn(adventure, db, user):
yield event
@@ -405,9 +328,7 @@ def create_action(
user: models.User = CurrentUser,
adventure: models.Adventure = Depends(current_adventure),
):
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)
try:
_move_to_after(db, adventure, payload.after_id)
-140
View File
@@ -1,140 +0,0 @@
"""Visit analytics: one endpoint the browser writes to, one the owner reads.
The split matters. `/collect` is public and accepts one fact, which page was
viewed, because anything a stranger can POST is a number a stranger can invent.
Everything the dashboard relies on, meaning turns, adventures, sign-ups, demo
spend, and errors, is recorded on the server by the code that performs it, so
those counts are as trustworthy as the app itself.
The two reading endpoints are owner-only and 404 for everyone else, the same
way the AI Chat router does: a feature nobody else can use is better off not
appearing to exist. `/summary` serves the anonymous counters (analytics.py,
which stores nothing that points at a person) and `/access` serves the access
log (accesslog.py, which identifies people on purpose).
"""
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
from .. import accesslog, analytics, auth, limits, models
from ..database import get_db
router = APIRouter(prefix="/api/analytics", tags=["analytics"])
def owner(
db: Session = Depends(get_db),
user: models.User = Depends(auth.get_current_user),
) -> models.User:
"""Gates the reading half. It returns 404 rather than 403. See the module
docstring."""
if not auth.is_owner(user):
raise HTTPException(404, "Not found")
return user
Owner = Depends(owner)
class Pageview(BaseModel):
"""What the SPA reports on a page load or a route change.
`first` marks a real page load rather than a client-side navigation. The
facts that describe a visit rather than a view, which are where it came from,
on what kind of device, and from which country, are recorded only on a page
load, so a visitor who clicks through five pages is still one referral.
"""
path: str = Field("", max_length=300)
referrer: str = Field("", max_length=500)
first: bool = False
@router.post("/collect", status_code=204)
def collect(
payload: Pageview,
request: Request,
db: Session = Depends(get_db),
) -> Response:
"""Record one pageview. Always 204, even when nothing was counted: the
browser has no business knowing whether it was."""
limits.rate_limit("analytics", request)
# Resolved by hand rather than through get_current_user: a pageview that
# arrives before /auth/me has minted a session should still be counted as a
# view, not turned into a 401 the SPA has to handle.
user = (
auth.resolve_session_user(request, db)
if auth.MULTI_USER
else auth.local_user(db)
)
# The operator's own clicks are not traffic. This applies only in
# multi-user mode. Locally every user is the owner, and excluding them would
# leave the dashboard empty on the machine the app is developed on.
if auth.MULTI_USER and user is not None and auth.is_owner(user):
return Response(status_code=204)
analytics.record(analytics.M_PAGE, analytics.normalize_route(payload.path))
analytics.record_visit(user)
if payload.first:
referrer = analytics.normalize_referrer(
payload.referrer, request.url.hostname or ""
)
if referrer: # "" means same-origin, which is not a referral
analytics.record(analytics.M_REFERRER, referrer)
analytics.record(
analytics.M_DEVICE,
analytics.device_of(request.headers.get("user-agent", "")),
)
analytics.record(analytics.M_COUNTRY, analytics.country_of(request.headers))
return Response(status_code=204)
@router.get("/summary")
def summary(
days: int = Query(30, ge=1, le=365),
db: Session = Depends(get_db),
_user: models.User = Owner,
) -> dict:
"""Returns the whole dashboard in one aggregate response.
The response is a few kilobytes however much traffic is behind it.
"""
return analytics.summary(db, days)
@router.get("/access")
def access_log(
limit: int = Query(50, ge=1, le=200),
before_id: int | None = Query(None),
kind: str | None = Query(None),
q: str | None = Query(None, max_length=120),
db: Session = Depends(get_db),
_user: models.User = Owner,
) -> dict:
"""A page of the access log, newest first.
Unlike `/summary`, this returns rows about people, which is what it is for.
It is therefore behind the same owner gate, it is paged rather than returned
in full, and the people it describes have no endpoint that reaches it.
"""
page = accesslog.recent(
db, limit=limit, before_id=before_id, kind=kind, query=q
)
return {
"events": [
{
"id": event.id,
"at": event.at.isoformat(),
"kind": event.kind,
"who": event.who,
"is_guest": event.is_guest,
"ip": event.ip,
"country": event.country,
"device": event.device,
"user_agent": event.user_agent,
}
for event in page["events"]
],
"has_more": page["has_more"],
}
-158
View File
@@ -1,158 +0,0 @@
import re
from fastapi import APIRouter, Depends, HTTPException, Request, Response
from sqlalchemy.orm import Session
from .. import (accesslog, analytics, auth, cleanup, limits, models, schemas,
security, starter)
from ..database import get_db
from .settings import get_settings
router = APIRouter(prefix="/api/auth", tags=["auth"])
EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
def _set_session_cookie(response: Response, user_id: int) -> None:
response.set_cookie(
auth.SESSION_COOKIE,
security.sign_session(user_id),
max_age=auth.COOKIE_MAX_AGE,
httponly=True,
samesite="lax",
secure=auth.COOKIE_SECURE,
path="/",
)
def me_payload(user: models.User, db: Session) -> dict:
settings = get_settings(db, user)
cfg = auth.resolve_provider_config(settings)
return {
"multi_user": auth.MULTI_USER,
"id": user.id,
"email": user.email,
"is_guest": user.is_guest,
# Trusted testers: unmetered demo turns, plus the AI Chat scratchpad.
"power_user": auth.is_power_user(user),
# Separate allowlist: shows the visit-analytics page and its nav link.
"analytics": auth.is_owner(user),
# How long an idle guest is kept before cleanup deletes it (None when
# the policy is off). Served rather than hardcoded in the UI so the
# number a guest is shown is the number actually enforced.
"guest_retention_days": cleanup.RETENTION_DAYS if cleanup.enabled() else None,
"demo": {
"enabled": auth.demo_enabled(),
"using_demo": cfg.using_demo,
"model": cfg.model if cfg.using_demo else None,
"turns_per_day": auth.DEMO_TURNS_PER_DAY,
"turns_left": auth.demo_turns_left(user) if auth.demo_enabled() else None,
"models": auth.DEMO_MODELS if auth.demo_enabled() else [],
},
}
@router.get("/me")
def me(request: Request, response: Response, db: Session = Depends(get_db)):
"""Returns the current user.
In multi-user mode this also establishes the session. If the cookie is
missing or invalid, the endpoint creates a guest user and sets a cookie. The
frontend calls it on load and after any 401.
"""
if not auth.MULTI_USER:
user = auth.local_user(db)
else:
user = auth.resolve_session_user(request, db)
if user is None:
# Each new guest is a database row, so cap how fast one IP can
# create them.
limits.rate_limit("guest", request)
user = models.User(is_guest=True)
db.add(user)
db.commit()
# The guest is committed first, so a failure while copying the
# starter adventure still leaves them with an account.
starter.give(db, user)
db.commit()
_set_session_cookie(response, user.id)
# This endpoint is the SPA's bootstrap call, so it is where a session first
# shows itself; accesslog thins the rows down to one per day per address.
accesslog.note_session(db, user, request)
return me_payload(user, db)
@router.post("/register")
def register(
payload: schemas.AuthCredentials,
request: Request,
db: Session = Depends(get_db),
user: models.User = Depends(auth.get_current_user),
):
"""Upgrades the current guest in place.
The `user_id` does not change, so every adventure, 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.")
limits.rate_limit("auth", request)
email = payload.email.strip().lower()
if not EMAIL_RE.match(email):
raise HTTPException(422, "Enter a valid email address.")
if len(payload.password) < 8:
raise HTTPException(422, "Password must be at least 8 characters.")
if not user.is_guest:
raise HTTPException(400, "This session is already registered.")
if db.query(models.User).filter(models.User.email == email).first():
raise HTTPException(409, "An account with this email already exists — log in instead.")
user.email = email
user.password_hash = security.hash_password(payload.password)
user.is_guest = False
db.commit()
analytics.record_event(analytics.EV_SIGNUP, user)
accesslog.record(db, accesslog.REGISTER, request, user=user)
return me_payload(user, db)
@router.post("/login")
def login(
payload: schemas.AuthCredentials,
request: Request,
response: Response,
db: Session = Depends(get_db),
):
"""Point this browser's session at an existing account. Any current guest
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.")
limits.rate_limit("auth", request)
email = payload.email.strip().lower()
# Per-account throttle: stops distributed guessing against one email even
# when the per-IP limit above is diluted across many source addresses.
limits.check_login_allowed(email)
user = db.query(models.User).filter(models.User.email == email).first()
if (
user is None
or not user.password_hash
or not security.verify_password(payload.password, user.password_hash)
):
limits.note_login_failure(email)
# Logged with the address that was tried, not the account that owns it:
# a guessing run against an address that has no account is exactly the
# thing worth being able to see.
accesslog.record(db, accesslog.LOGIN_FAILED, request, who=email)
raise HTTPException(401, "Incorrect email or password.")
limits.note_login_success(email)
_set_session_cookie(response, user.id)
analytics.record_event(analytics.EV_LOGIN, user)
accesslog.record(db, accesslog.LOGIN, request, user=user)
return me_payload(user, db)
@router.post("/logout")
def logout(response: Response):
if not auth.MULTI_USER:
raise HTTPException(400, "Accounts are disabled in local mode.")
response.delete_cookie(auth.SESSION_COOKIE, path="/")
return {"ok": True}
+36 -79
View File
@@ -1,22 +1,23 @@
"""AI Chat: a plain scratchpad for talking to a model directly.
"""AI Chat: a plain scratchpad for talking to the configured model directly.
Power users reach it, which means the `AIDND_POWER_USERS` email allowlist. It is
deliberately thin. It adds no story context, no scripts, and no world state, and
it persists nothing. The conversation lives in the browser and is posted in full
on each turn. It exists for testing models, prompts, and endpoints without
starting an adventure.
Deliberately thin. It adds no story context and no world state, and it persists
nothing. The conversation lives in the browser and is posted in full on each
turn. It exists for checking a model, a prompt, or an endpoint without starting
an adventure — which is exactly the kind of thing a local single-user install
wants a page for.
Model choice is free when the user brought their own API key. On the shared demo
key the model stays pinned to the `AIDND_DEMO_MODELS` allowlist, exactly as it is
for turns. The server funds that key, so this page must not let it reach paid
models.
Upstream gated this behind a "power user" email allowlist and pinned the model
when a shared demo key was in play. M2 removed both: there is one local user,
who owns the endpoint, and there is no server-funded key to protect. The model
this page talks to is the one in Settings, or one the user names per request —
either way it is their own Ollama.
"""
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import StreamingResponse
from sqlalchemy.orm import Session
from .. import auth, limits, models, schemas
from .. import auth, models, schemas
from ..database import get_db
from ..providers import OpenAICompatibleProvider, ProviderError
from ..sse import SSE_HEADERS, sse
@@ -25,80 +26,40 @@ from .settings import get_settings, list_endpoint_models
router = APIRouter(prefix="/api/chat", tags=["chat"])
def power_user(
db: Session = Depends(get_db),
user: models.User = Depends(auth.get_current_user),
) -> models.User:
"""Gate for the whole router. 404 rather than 403 so the feature simply
doesn't appear to exist for everyone else."""
if not auth.is_power_user(user):
raise HTTPException(404, "Not found")
return user
PowerUser = Depends(power_user)
def _resolve_model(
settings: models.Settings, requested: str | None
) -> tuple[auth.ProviderConfig, str | None]:
"""Returns the provider config for this chat, plus a note when the requested
model was not used.
The pinning rule lives in `resolve_provider_config`. This function only
reports the substitution that call made, so one place decides what the demo
key may talk to.
"""
cfg = auth.resolve_provider_config(settings, model_override=requested)
wanted = (requested or "").strip()
if wanted and wanted != cfg.model:
return cfg, (
f"'{wanted}' isn't available on the shared demo key — using "
f"{cfg.model}. Add your own API key in Settings to use any model."
)
return cfg, None
@router.get("/config")
async def chat_config(
db: Session = Depends(get_db),
user: models.User = PowerUser,
user: models.User = Depends(auth.get_current_user),
):
"""Returns what this page can talk to.
The response holds the resolved endpoint and model, whether model choice is
pinned to the demo allowlist, and the endpoint's model listing. The listing
is best effort, and an unreachable endpoint returns an empty list.
The model listing is best effort: an unreachable endpoint returns an empty
list and the reason, rather than failing the page.
"""
settings = get_settings(db, user)
cfg = auth.resolve_provider_config(settings)
listing = await list_endpoint_models(cfg)
listing = await list_endpoint_models(settings.endpoint_url)
return {
"endpoint_url": cfg.endpoint_url,
"model": cfg.model,
"using_demo": cfg.using_demo,
"endpoint_url": settings.endpoint_url,
"model": settings.model,
"api_mode": settings.api_mode,
"temperature": settings.temperature,
"max_tokens": settings.max_output_tokens,
# On the demo key the whitelist IS the list of choices; otherwise it's
# whatever the endpoint advertises (suggestions, not a restriction).
"models": auth.DEMO_MODELS if cfg.using_demo else listing.get("models", []),
# Suggestions from the endpoint, not a restriction.
"models": listing.get("models", []),
"models_error": None if listing.get("ok") else listing.get("detail"),
}
async def run_chat(cfg: auth.ProviderConfig, settings: models.Settings, payload: schemas.ChatRequest,
note: str | None, db: Session, user: models.User):
async def run_chat(
settings: models.Settings, model: str, payload: schemas.ChatRequest
):
"""Streams the reply as SSE, using the turn stream's event shape.
The generator emits `reasoning` and `chunk` events while generating and then
a `done` event, so the frontend reuses the same code.
"""
if note:
yield sse({"type": "note", "detail": note})
provider = OpenAICompatibleProvider(
cfg.endpoint_url, cfg.api_key, cfg.model, settings.api_mode,
settings.reasoning_max_tokens,
settings.endpoint_url, model, settings.api_mode
)
messages = [m.model_dump() for m in payload.messages]
chunks: list[str] = []
@@ -106,7 +67,11 @@ async def run_chat(cfg: auth.ProviderConfig, settings: models.Settings, payload:
try:
async for kind, chunk in provider.chat(
messages,
temperature=payload.temperature if payload.temperature is not None else settings.temperature,
temperature=(
payload.temperature
if payload.temperature is not None
else settings.temperature
),
max_tokens=payload.max_tokens or settings.max_output_tokens,
):
if kind == "reasoning":
@@ -123,33 +88,26 @@ async def run_chat(cfg: auth.ProviderConfig, settings: models.Settings, payload:
if not text:
detail = (
"The model used its entire token budget on reasoning and returned no "
"reply — raise max tokens, cap the reasoning budget in Settings, or "
"use a non-reasoning model."
"reply — raise max tokens or use a non-reasoning model."
if reasoning_chunks
else "The AI returned an empty response."
)
yield sse({"type": "error", "detail": detail})
return
if cfg.using_demo:
# Unmetered for power users (count_demo_turn is a no-op for them), but
# keep the call so the accounting stays right if the gate ever widens.
auth.count_demo_turn(user)
db.commit()
yield sse({
"type": "done",
"text": text,
"reasoning": "".join(reasoning_chunks).strip() or None,
"model": cfg.model,
"model": model,
})
@router.post("/stream")
def chat_stream(
payload: schemas.ChatRequest,
request: Request,
db: Session = Depends(get_db),
user: models.User = PowerUser,
user: models.User = Depends(auth.get_current_user),
):
total = sum(len(m.content) for m in payload.messages)
if total > schemas.CHAT_TOTAL_MAX:
@@ -157,13 +115,12 @@ def chat_stream(
413, f"This conversation is too long to send ({total:,} characters) — "
"clear it or start a new one."
)
limits.rate_limit("chat", request, user)
settings = get_settings(db, user)
cfg, note = _resolve_model(settings, payload.model)
if not cfg.model:
model = (payload.model or "").strip() or settings.model
if not model:
raise HTTPException(400, "No model configured — set one in Settings or pick one here.")
return StreamingResponse(
run_chat(cfg, settings, payload, note, db, user),
run_chat(settings, model, payload),
media_type="text/event-stream",
headers=SSE_HEADERS,
)
+6 -9
View File
@@ -1,19 +1,16 @@
from fastapi import APIRouter, HTTPException
from fastapi import APIRouter
from .. import auth, debuglog
from .. import debuglog
router = APIRouter(prefix="/api/debug", tags=["debug"])
@router.get("/requests")
def recent_requests():
"""Most-recent-first log of provider requests/responses (no API keys).
"""Most-recent-first log of provider requests and responses.
The log is a single process-wide ring buffer with no per-user attribution,
so in multi-user mode, which is how a hosted deployment runs, it would expose
other players' prompts. It is disabled there and available on a local
install.
A single process-wide ring buffer. It holds the prompts this install sent
to its own Ollama, which is exactly what the person running it needs to
diagnose a turn, and there is nobody else it could expose them to.
"""
if auth.MULTI_USER:
raise HTTPException(403, "The debug log is only available on local installs.")
return debuglog.recent()
+1 -40
View File
@@ -3,7 +3,7 @@ from fastapi.responses import Response
from sqlalchemy import or_
from sqlalchemy.orm import Session
from .. import analytics, auth, images, limits, models, schemas
from .. import auth, images, limits, models, schemas
from ..database import get_db
router = APIRouter(prefix="/api/scenarios", tags=["scenarios"])
@@ -54,12 +54,6 @@ def get_scenario(
user: models.User = Depends(auth.get_current_user),
):
scenario = get_scenario_or_404(scenario_id, db, user)
# A funnel step, recorded for shared scenarios only. Opening one is the
# first sign that a visitor is interested, and someone editing their own
# scenario is already past this point. Their titles are theirs rather than a
# statistic.
if scenario.is_public:
analytics.record_event(analytics.EV_SCENARIO_OPEN, user)
return scenario
@@ -96,18 +90,8 @@ def update_scenario(
):
scenario = get_scenario_or_404(scenario_id, db, user, edit=True)
data = payload.model_dump(exclude_unset=True)
script_ids = data.pop("script_ids", None)
for field, value in data.items():
setattr(scenario, field, value)
if script_ids is not None:
scripts = (
db.query(models.Script)
.filter(models.Script.id.in_(script_ids), models.Script.user_id == user.id)
.all()
)
if len(scripts) != len(set(script_ids)):
raise HTTPException(404, "One or more scripts not found")
scenario.scripts = sorted(scripts, key=lambda s: script_ids.index(s.id))
db.commit()
return scenario
@@ -148,13 +132,6 @@ def export_scenario(
{"type": c.type, "name": c.name, "keys": c.keys, "entry": c.entry, "notes": c.notes}
for c in s.story_cards
],
"scripts": [
{
"name": sc.name, "description": sc.description, "library": sc.library_js,
"input": sc.input_js, "context": sc.context_js, "output": sc.output_js,
}
for sc in s.scripts
],
}
@@ -185,7 +162,6 @@ def import_scenario(
):
"""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] = []
@@ -250,21 +226,6 @@ def import_scenario(
)
)
for item in bundle.get("scripts") or []:
if not isinstance(item, dict):
continue
script = models.Script(
user_id=user.id,
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 ""),
context_js=str(item.get("context") or item.get("onModelContext") or ""),
output_js=str(item.get("output") or item.get("onOutput") or ""),
)
db.add(script)
db.flush()
scenario.scripts.append(script)
db.commit()
out = schemas.ScenarioOut.model_validate(scenario).model_dump(mode="json")
-159
View File
@@ -1,159 +0,0 @@
from fastapi import APIRouter, Body, Depends, HTTPException, Request
from sqlalchemy.orm import Session
from .. import auth, limits, models, schemas
from ..database import get_db
from ..scripting import run_hook
router = APIRouter(prefix="/api/scripts", tags=["scripts"])
HOOK_FIELDS = {"input": "input_js", "context": "context_js", "output": "output_js"}
def get_script_or_404(script_id: int, db: Session, user: models.User) -> models.Script:
script = db.get(models.Script, script_id)
if script is None or script.user_id != user.id:
raise HTTPException(404, "Script not found")
return script
@router.get("", response_model=list[schemas.ScriptOut])
def list_scripts(
db: Session = Depends(get_db),
user: models.User = Depends(auth.get_current_user),
):
return (
db.query(models.Script)
.filter(models.Script.user_id == user.id)
.order_by(models.Script.updated_at.desc())
.all()
)
@router.post("", response_model=schemas.ScriptOut, status_code=201)
def create_script(
payload: schemas.ScriptCreate,
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()
return script
@router.get("/{script_id}", response_model=schemas.ScriptOut)
def get_script(
script_id: int,
db: Session = Depends(get_db),
user: models.User = Depends(auth.get_current_user),
):
return get_script_or_404(script_id, db, user)
@router.patch("/{script_id}", response_model=schemas.ScriptOut)
def update_script(
script_id: int,
payload: schemas.ScriptUpdate,
db: Session = Depends(get_db),
user: models.User = Depends(auth.get_current_user),
):
script = get_script_or_404(script_id, db, user)
for field, value in payload.model_dump(exclude_unset=True).items():
setattr(script, field, value)
db.commit()
return script
@router.delete("/{script_id}", status_code=204)
def delete_script(
script_id: int,
db: Session = Depends(get_db),
user: models.User = Depends(auth.get_current_user),
):
db.delete(get_script_or_404(script_id, db, user))
db.commit()
@router.post("/{script_id}/test")
def test_script(
script_id: int,
payload: schemas.ScriptTestRequest,
request: Request,
db: Session = Depends(get_db),
user: models.User = Depends(auth.get_current_user),
):
"""Runs one hook against sample text, making no AI call and storing nothing."""
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]),
payload.text,
payload.state,
history=[],
story_cards=[],
info={"actionCount": 0, "characterNames": [], "memoryLength": 0, "maxChars": 0},
)
return {
"text": result.text,
"stop": result.stop,
"state": result.state,
"storyCards": result.story_cards,
"logs": result.logs,
"error": result.error,
}
# ---------- Import / Export ----------
@router.get("/{script_id}/export")
def export_script(
script_id: int,
db: Session = Depends(get_db),
user: models.User = Depends(auth.get_current_user),
):
"""JSON bundle matching how AI Dungeon scripts circulate."""
script = get_script_or_404(script_id, db, user)
return {
"name": script.name,
"description": script.description,
"library": script.library_js,
"input": script.input_js,
"context": script.context_js,
"output": script.output_js,
}
@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)
if isinstance(value, str):
return value
return ""
script = models.Script(
user_id=user.id,
# A raw-dict import bypasses the schemas, so truncate 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"),
context_js=pick("context", "context_js", "onModelContext"),
output_js=pick("output", "output_js", "onOutput"),
)
db.add(script)
db.commit()
return script
+93 -37
View File
@@ -1,20 +1,33 @@
"""The model settings, and the connection test that tells you why they don't work.
There is one settings row, belonging to the one local user. It describes an
Ollama: where it is, which model to narrate with, which to embed with, and how
long to wait for it.
Upstream let this row name any OpenAI-compatible endpoint and carry an
encrypted API key for it. M2 narrowed both: `endpoints.py` decides which
addresses may be named, and there is no key field, because Ollama does not use
one and this build has no cloud provider to carry a key for.
"""
import httpx
from fastapi import APIRouter, Depends, Request
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from starlette.concurrency import run_in_threadpool
from .. import auth, limits, models, netguard, schemas, security, tlstrust
from .. import auth, endpoints, models, schemas, tlstrust
from ..database import get_db
from ..providers.openai_compatible import CONNECT_TIMEOUT
router = APIRouter(prefix="/api/settings", tags=["settings"])
#: The connection test is a listing, not a generation, so it never waits on a
#: model load and does not need the turn engine's patience.
TEST_TIMEOUT = 15.0
def get_settings(db: Session, user: models.User) -> models.Settings:
"""Returns the user's settings row, creating it on first access.
Phase 8 made settings per user rather than global. They cover the endpoint,
the key, the models, and the memory configuration.
"""
"""Returns the settings row, creating it on first access."""
settings = (
db.query(models.Settings).filter(models.Settings.user_id == user.id).first()
)
@@ -34,16 +47,24 @@ def read_settings(
@router.put("", response_model=schemas.SettingsOut)
def update_settings(
async def update_settings(
payload: schemas.SettingsUpdate,
db: Session = Depends(get_db),
user: models.User = Depends(auth.get_current_user),
):
settings = get_settings(db, user)
fields = payload.model_dump(exclude_unset=True)
# Write-only API key: absent = unchanged, "" = cleared, else encrypted.
if "api_key" in fields:
fields["api_key"] = security.encrypt_secret(fields["api_key"].strip())
if "endpoint_url" in fields:
# Refused here so the user finds out while they are looking at the
# field, rather than on their next turn. The provider re-checks before
# every request regardless; this is the friendly half of the same rule.
reason = await run_in_threadpool(
endpoints.rejection_reason, fields["endpoint_url"]
)
if reason is not None:
raise HTTPException(400, f"That endpoint can't be used — {reason}.")
embedding_model_changed = (
"embedding_model" in fields
and fields["embedding_model"] != settings.embedding_model
@@ -53,8 +74,6 @@ def update_settings(
if embedding_model_changed:
# Vectors from the old model have a different dimensionality/space;
# clear them so the post-turn task re-embeds with the new model.
# This covers only this user's adventures, because settings are per
# user now.
#
# Both columns, and the flag. This is the one place that clears vectors
# in bulk rather than through memorybank.set_vector, and when the
@@ -80,28 +99,61 @@ def update_settings(
return settings
async def list_endpoint_models(cfg: auth.ProviderConfig) -> dict:
"""Fetches the endpoint's /models listing.
async def list_endpoint_models(endpoint_url: str) -> dict:
"""Fetches the endpoint's `/models` listing, and doubles as the connection test.
The call also serves as a connectivity check, so a failure returns
`{"ok": False, "detail": ...}` rather than raising.
Returns `{"ok": False, "detail": ...}` rather than raising, because every
caller wants to show the reason rather than fail the page.
The failure cases are told apart on purpose. "Ollama isn't running", "that
address isn't allowed", "the certificate doesn't verify" and "it answered,
but with an error" need four different things done about them, and a single
"connection failed" leaves the user guessing which they have.
"""
# SSRF guard. Never probe a non-public address the user supplied.
reason = await run_in_threadpool(netguard.endpoint_block_reason, cfg.endpoint_url)
if reason:
return {"ok": False, "detail": f"Can't reach that endpoint — {reason}."}
url = cfg.endpoint_url.rstrip("/") + "/models"
headers = {}
if cfg.api_key:
headers["Authorization"] = f"Bearer {cfg.api_key}"
reason = await run_in_threadpool(endpoints.rejection_reason, endpoint_url)
if reason is not None:
return {
"ok": False, "kind": "rejected",
"detail": f"That endpoint can't be used — {reason}.",
}
url = endpoint_url.rstrip("/") + "/models"
try:
async with httpx.AsyncClient(timeout=10, verify=tlstrust.ssl_context()) as client:
resp = await client.get(url, headers=headers)
async with httpx.AsyncClient(
timeout=httpx.Timeout(TEST_TIMEOUT, connect=CONNECT_TIMEOUT),
verify=tlstrust.ssl_context(),
) as client:
resp = await client.get(url)
except httpx.ConnectError as exc:
# A TLS failure arrives as a ConnectError too, and it needs a different
# answer from "nothing is listening": install the CA, don't start Ollama.
if "CERTIFICATE_VERIFY" in str(exc).upper() or "SSL" in str(exc).upper():
return {
"ok": False, "kind": "tls",
"detail": (
"The endpoint's TLS certificate could not be verified. If it "
"uses a private or self-signed CA, install that CA on this "
"machine so the system trusts it. Certificate checking is "
"not optional."
),
}
return {
"ok": False, "kind": "unreachable",
"detail": f"Could not connect to {endpoint_url} — is Ollama running there?",
}
except httpx.TimeoutException:
return {
"ok": False, "kind": "timeout",
"detail": f"{endpoint_url} did not answer within {TEST_TIMEOUT:.0f}s.",
}
except httpx.HTTPError as exc:
return {"ok": False, "detail": f"Connection failed: {exc}"}
return {"ok": False, "kind": "error", "detail": f"Connection failed: {exc}"}
if resp.status_code != 200:
return {"ok": False, "detail": f"HTTP {resp.status_code}: {resp.text[:300]}"}
return {
"ok": False, "kind": "http",
"detail": f"HTTP {resp.status_code}: {resp.text[:300]}",
}
models_available: list[str] = []
try:
@@ -115,15 +167,19 @@ async def list_endpoint_models(cfg: auth.ProviderConfig) -> dict:
@router.post("/test")
async def test_connection(
request: Request,
db: Session = Depends(get_db),
user: models.User = Depends(auth.get_current_user),
):
"""Runs a cheap connectivity check against whatever the turn engine would use.
That includes the shared demo endpoint, when the user has no key of their
own.
"""
limits.rate_limit("connection-test", request, user)
"""Checks the endpoint the turn engine would use, and lists its models."""
settings = get_settings(db, user)
return await list_endpoint_models(auth.resolve_provider_config(settings))
result = await list_endpoint_models(settings.endpoint_url)
if result.get("ok") and settings.model and settings.model not in result["models"]:
# Reachable, but pointed at a model that is not installed there — the
# commonest way for a correct endpoint to still fail every turn.
return result | {
"warning": (
f"{settings.endpoint_url} is reachable, but has no model named "
f"'{settings.model}'. Pull it there, or pick one from the list."
)
}
return result
-1
View File
@@ -159,7 +159,6 @@ def import_story_cards(
raise HTTPException(422, 'Expected a "cards" array of story cards.')
cards_in = [c for c in cards_in if isinstance(c, dict)]
limits.rate_limit("import", request, user)
limits.check_bundle_lists(story_cards=cards_in)
existing = len(owner.story_cards)
if auth.MULTI_USER and existing + len(cards_in) > limits.MAX_STORY_CARDS_PER_OWNER:
+5 -76
View File
@@ -14,7 +14,6 @@ NAME_MAX = 200 # Titles and names. VARCHAR(200).
TAGS_MAX = 500 # VARCHAR(500).
CARD_TYPE_MAX = 100 # VARCHAR(100).
PROSE_MAX = 50_000 # Memory, author's note, prompts, entries, and notes.
SCRIPT_MAX = 200_000 # One JavaScript source file.
ACTION_MAX = 20_000 # One player action.
MEMORY_TEXT_MAX = 5_000
# A scenario cover image, stored inline as a base64 data URI. A 400x300 WebP at
@@ -31,7 +30,6 @@ 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)]
Image = Annotated[str, Field(max_length=IMAGE_MAX)]
Icon = Annotated[str, Field(max_length=ICON_MAX)]
@@ -106,7 +104,6 @@ class ScenarioUpdate(BaseModel):
image: Image | None = None
icon: Icon | None = None
stat_schema: dict | None = None
script_ids: list[int] | None = None
class ScenarioOut(ORMModel, ScenarioBase):
@@ -115,7 +112,6 @@ class ScenarioOut(ORMModel, ScenarioBase):
created_at: datetime
updated_at: datetime
story_cards: list[StoryCardOut] = []
scripts: list["ScriptOut"] = []
class ScenarioListItem(ORMModel):
@@ -374,67 +370,6 @@ class AdventureListItem(ORMModel):
icon: str = ""
# ---------- Scripts ----------
class ScriptBase(BaseModel):
name: Name = "Untitled Script"
description: Prose = ""
library_js: ScriptSource = ""
input_js: ScriptSource = ""
context_js: ScriptSource = ""
output_js: ScriptSource = ""
class ScriptCreate(ScriptBase):
pass
class ScriptUpdate(BaseModel):
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):
id: int
created_at: datetime
updated_at: datetime
class ScriptTestRequest(BaseModel):
hook: Literal["input", "context", "output"]
text: Prose = ""
state: dict = {}
class AdventureScriptOut(ORMModel):
id: int
adventure_id: int
position: int
enabled: bool
name: str
description: str
library_js: str
input_js: str
context_js: str
output_js: str
# The router sets this field, which is not stored. It is `True` when a
# syncable library version exists whose code differs from this copy, and
# `None` when there is nothing to sync from.
out_of_date: bool | None = None
class AdventureScriptUpdate(BaseModel):
enabled: bool | 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):
@@ -448,15 +383,12 @@ class AuthCredentials(BaseModel):
class SettingsOut(ORMModel):
endpoint_url: str
# The key itself is never returned. It is encrypted at rest and
# write-only.
has_api_key: bool
model: str
api_mode: str
temperature: float
max_output_tokens: int
reasoning_max_tokens: int
context_token_budget: int
model_timeout_seconds: int
narrator_prompt: str
summary_model: str
embedding_model: str
@@ -491,18 +423,15 @@ class ChatRequest(BaseModel):
class SettingsUpdate(BaseModel):
endpoint_url: Annotated[str, Field(max_length=500)] | None = None # VARCHAR(500).
# Encryption expands the stored value by about four thirds into the same
# VARCHAR(500), so 256 plaintext characters is the largest safe input. The
# stored form is "enc:" plus Fernet plus 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
# A value of -1 turns reasoning off explicitly, which sends
# `reasoning: {effort: none}`. A value of 0 sends nothing.
reasoning_max_tokens: Annotated[int, Field(ge=-1, le=100_000)] | None = None
context_token_budget: Annotated[int, Field(ge=256, le=200_000)] | None = None
# Seconds to wait for the model. The floor is high enough that a normal
# turn cannot trip it; the ceiling exists so that "wait longer" stays a
# number rather than becoming "wait forever".
model_timeout_seconds: Annotated[int, Field(ge=30, le=3600)] | None = None
narrator_prompt: Prose | None = None
summary_model: Name | None = None
embedding_model: Name | None = None
-4
View File
@@ -1,4 +0,0 @@
from .engine import HookResult, run_hook
from .pipeline import ScriptPipeline
__all__ = ["HookResult", "ScriptPipeline", "run_hook"]
-146
View File
@@ -1,146 +0,0 @@
"""AI Dungeon-compatible script execution in an embedded QuickJS sandbox.
Each hook run is fully isolated (fresh Context), capped at 16 MB memory and
2 seconds CPU, with no filesystem/network/process access (QuickJS has none by
default). Scripts follow the AI Dungeon contract: define a `modifier(text)`
and call it as the last line; its return value `{ text, stop }` is the result.
"""
import json
from dataclasses import dataclass, field
import quickjs
MEMORY_LIMIT = 16 * 1024 * 1024
TIME_LIMIT_SECONDS = 2
HISTORY_WINDOW = 100 # recent actions exposed as `history`
# Globals per the official docs: text, state, history, storyCards, info,
# log/console.log, story card functions, plus legacy worldInfo aliases.
PRELUDE = """
"use strict";
var __logs = [];
var state = __DATA__.state;
var text = __DATA__.text;
var history = __DATA__.history;
var storyCards = __DATA__.storyCards;
var info = __DATA__.info;
function log(msg) {
__logs.push(typeof msg === "string" ? msg : JSON.stringify(msg));
}
var console = { log: log };
// Returns the new card's index, or false if a card with those keys exists —
// matching real AI Dungeon. Note index 0 is falsy; that quirk is upstream's.
function addStoryCard(keys, entry, type) {
for (var i = 0; i < storyCards.length; i++) {
if (storyCards[i].keys === keys) return false;
}
storyCards.push({ id: null, keys: keys || "", entry: entry || "", type: type || "" });
return storyCards.length - 1;
}
function updateStoryCard(index, keys, entry, type) {
var card = storyCards[index];
if (!card) throw new Error("Story card not found");
card.keys = keys;
card.entry = entry;
card.type = type;
}
function removeStoryCard(index) {
if (!storyCards[index]) throw new Error("Story card not found");
storyCards.splice(index, 1);
}
// Legacy aliases used by older AI Dungeon scripts.
var worldInfo = storyCards;
var worldEntries = storyCards;
function addWorldEntry(keys, entry) { return addStoryCard(keys, entry, ""); }
function updateWorldEntry(index, keys, entry) {
var card = storyCards[index];
if (!card) throw new Error("World entry not found");
card.keys = keys;
card.entry = entry;
}
function removeWorldEntry(index) { return removeStoryCard(index); }
"""
COLLECT = """
JSON.stringify({
result: (typeof __result === "undefined" || __result === null) ? null : __result,
state: state,
storyCards: storyCards,
logs: __logs
})
"""
@dataclass
class HookResult:
text: str
stop: bool = False
state: dict = field(default_factory=dict)
story_cards: list = field(default_factory=list)
logs: list = field(default_factory=list)
error: str | None = None
def run_hook(
library_js: str,
hook_js: str,
text: str,
state: dict,
history: list[dict],
story_cards: list[dict],
info: dict,
) -> HookResult:
"""Run one modifier hook. This function never raises. Failures return as
`.error` with text, state, and cards unchanged, so a bad script cannot
break a turn."""
unchanged = HookResult(text=text, state=state, story_cards=story_cards)
source = f"{library_js}\n;\n{hook_js}" if library_js.strip() else hook_js
if not source.strip():
return unchanged
data = {
"state": state,
"text": text,
"history": history[-HISTORY_WINDOW:],
"storyCards": story_cards,
"info": info,
}
try:
ctx = quickjs.Context()
ctx.set_memory_limit(MEMORY_LIMIT)
ctx.set_time_limit(TIME_LIMIT_SECONDS)
ctx.eval(f"var __DATA__ = {json.dumps(data)};")
ctx.eval(PRELUDE)
ctx.eval(f"var __SRC__ = {json.dumps(source)};")
# Indirect eval keeps the script in global scope, so `modifier(text)` as the
# script's final expression statement becomes the completion value.
ctx.eval("var __result = (0, eval)(__SRC__);")
collected = json.loads(ctx.eval(COLLECT))
except quickjs.JSException as exc:
unchanged.error = f"Script error: {exc}"
return unchanged
except Exception as exc: # memory limit, invalid JSON state, engine faults
unchanged.error = f"Script execution failed: {exc}"
return unchanged
result = collected.get("result")
new_text, stop = text, False
if isinstance(result, dict):
if isinstance(result.get("text"), str):
new_text = result["text"]
stop = bool(result.get("stop"))
elif isinstance(result, str):
new_text = result
new_state = collected.get("state")
return HookResult(
text=new_text,
stop=stop,
state=new_state if isinstance(new_state, dict) else {},
story_cards=collected.get("storyCards") or [],
logs=collected.get("logs") or [],
)
-112
View File
@@ -1,112 +0,0 @@
"""Runs an adventure's enabled scripts through a turn's hook points, applying
state and story-card mutations back to the database after each hook."""
from sqlalchemy.orm import Session
from .. import models
from ..context import history as context_history
from .engine import run_hook
MAX_STORY_CARDS = 5000 # AI Dungeon's per-adventure sanity cap
class ScriptPipeline:
def __init__(self, adventure: models.Adventure, db: Session):
self.adventure = adventure
self.db = db
self.logs: list[str] = []
self.errors: list[str] = []
@property
def message(self) -> str | None:
state = self.adventure.script_state
msg = state.get("message") if isinstance(state, dict) else None
return msg if isinstance(msg, str) and msg.strip() else None
def _history(self) -> list[dict]:
# Read the path rather than `adventure.actions`. That collection holds
# every branch's actions, and this is the documented history API a user
# script reads. Giving a script the siblings of the turn it is running on
# would be the same bug as building a prompt from them, and visible to
# the user.
#
# `story_actions` also drops rows with blank text, which
# `adventure.actions` kept, so this array is shorter than it was for an
# adventure that has any such rows. `info.actionCount` counts the same
# way. That is intended. A row with no text is this app's bookkeeping, it
# has no counterpart in the AI Dungeon history a ported script was
# written against, and the prompt has never included one. A script keyed
# on every N actions lands on different turns than it did before phase
# 14, and no reading of this is compatible with both.
return [
{"text": a.text, "rawText": a.text, "type": a.type}
for a in context_history.story_actions(self.adventure)
]
def _cards(self) -> list[dict]:
return [
{"id": c.id, "keys": c.keys, "entry": c.entry, "type": c.type}
for c in self.adventure.story_cards
]
def _info(self) -> dict:
return {
"actionCount": context_history.count(self.adventure),
"characterNames": [],
"memoryLength": len(self.adventure.memory),
"maxChars": 0,
}
def _apply_cards(self, returned: list) -> None:
existing = {c.id: c for c in self.adventure.story_cards}
seen_ids = set()
added = 0
for item in returned:
if not isinstance(item, dict):
continue
card_id = item.get("id")
keys = str(item.get("keys") or "")
entry = str(item.get("entry") or "")
card_type = str(item.get("type") or "")
if card_id in existing:
seen_ids.add(card_id)
card = existing[card_id]
card.keys, card.entry, card.type = keys, entry, card_type
elif len(existing) + added < MAX_STORY_CARDS:
self.db.add(
models.StoryCard(
adventure_id=self.adventure.id,
keys=keys, entry=entry, type=card_type,
)
)
added += 1
for card_id, card in existing.items():
if card_id not in seen_ids:
self.db.delete(card)
def run(self, hook: str, text: str) -> tuple[str, bool]:
"""Chain `hook` across all enabled scripts. Returns (text, stop)."""
state = self.adventure.script_state if isinstance(self.adventure.script_state, dict) else {}
for script in self.adventure.scripts:
hook_js = getattr(script, f"{hook}_js")
if not script.enabled or not hook_js.strip():
continue
result = run_hook(
script.library_js, hook_js, text, state,
self._history(), self._cards(), self._info(),
)
if result.error:
self.errors.append(f"{script.name} ({hook}): {result.error}")
continue # a broken script never breaks the turn
self.logs.extend(f"[{script.name}/{hook}] {line}" for line in result.logs)
self._apply_cards(result.story_cards)
state = result.state
self.adventure.script_state = state
self.db.commit()
text = result.text
if result.stop:
return text, True
return text, False
def report(self) -> dict:
return {"logs": self.logs, "errors": self.errors, "message": self.message}
-131
View File
@@ -1,131 +0,0 @@
"""Phase 8: secrets and crypto primitives for optional accounts.
Everything derives from one server-side secret:
* Session cookies are HMAC-signed with it.
* Stored LLM API keys are Fernet-encrypted with a key derived from it.
The secret comes from `AIDND_SECRET_KEY`, or it is generated once into
`secret.key` next to the database, so a local install and a Docker volume work
with no configuration. Losing that file logs everyone out and makes the stored
API keys unreadable, and users then re-enter them. A multi-user deployment has
to set the environment variable, because a hosted filesystem is ephemeral and a
`secret.key` regenerated on every deploy would log out every user each time.
Passwords use `hashlib.scrypt`, which is in the standard library and backed by
OpenSSL, so this needs no separate hashing dependency.
"""
import base64
import hashlib
import hmac
import os
import secrets
from cryptography.fernet import Fernet, InvalidToken
from .database import DB_PATH
_SECRET_FILE = DB_PATH.parent / "secret.key"
def _load_secret() -> bytes:
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()
_SECRET_FILE.write_bytes(secret)
return secret
SECRET_KEY = _load_secret()
_fernet = Fernet(base64.urlsafe_b64encode(hashlib.sha256(SECRET_KEY).digest()))
# ---------- Password hashing (scrypt) ----------
_SCRYPT_N, _SCRYPT_R, _SCRYPT_P = 2**14, 8, 1
def hash_password(password: str) -> str:
salt = secrets.token_bytes(16)
key = hashlib.scrypt(
password.encode(), salt=salt, n=_SCRYPT_N, r=_SCRYPT_R, p=_SCRYPT_P
)
return f"scrypt${_SCRYPT_N}${_SCRYPT_R}${_SCRYPT_P}${salt.hex()}${key.hex()}"
def verify_password(password: str, stored: str) -> bool:
try:
scheme, n, r, p, salt_hex, key_hex = stored.split("$")
if scheme != "scrypt":
return False
key = hashlib.scrypt(
password.encode(), salt=bytes.fromhex(salt_hex),
n=int(n), r=int(r), p=int(p),
)
return hmac.compare_digest(key, bytes.fromhex(key_hex))
except (ValueError, AttributeError):
return False
# ---------- Session tokens ----------
# The token is "v1.<user_id>.<hmac>". It does not expire, because a long-lived
# guest session is what this is for.
def sign_session(user_id: int) -> str:
payload = f"v1.{user_id}"
sig = hmac.new(SECRET_KEY, payload.encode(), hashlib.sha256).hexdigest()
return f"{payload}.{sig}"
def verify_session(token: str) -> int | None:
try:
version, user_id, sig = token.split(".")
if version != "v1":
return None
payload = f"{version}.{user_id}"
expected = hmac.new(SECRET_KEY, payload.encode(), hashlib.sha256).hexdigest()
if not hmac.compare_digest(sig, expected):
return None
return int(user_id)
except (ValueError, AttributeError):
return None
# ---------- API-key encryption at rest ----------
# Stored values carry an "enc:" prefix so plaintext keys from pre-Phase-8
# databases can be recognized and migrated.
ENC_PREFIX = "enc:"
def encrypt_secret(plain: str) -> str:
if not plain:
return ""
return ENC_PREFIX + _fernet.encrypt(plain.encode()).decode()
def decrypt_secret(stored: str) -> str:
"""Returns the plaintext key. Tolerates legacy plaintext values (returned
as-is) and undecryptable tokens (secret rotated → treated as unset)."""
if not stored:
return ""
if not stored.startswith(ENC_PREFIX):
return stored
try:
return _fernet.decrypt(stored[len(ENC_PREFIX):].encode()).decode()
except (InvalidToken, ValueError):
return ""
+4 -41
View File
@@ -4,8 +4,7 @@ Every JSON file in ``seed_data/`` describes one demo scenario in the same
model-native shape the export endpoint produces. Seeded scenarios have a NULL
owner and ``is_public=True``, so every visitor (including guests) sees them and
can start an adventure from them, while nobody can edit them. Starting an
adventure copies the scenario's story cards and scripts into the adventure, so
the seeded scripts run for guests too.
adventure copies the scenario's story cards into the adventure.
Seed files are the source of truth for demo content: a scenario is inserted if
missing, reconciled in place when a seed file's content changes, and deleted
@@ -13,7 +12,7 @@ when no file claims its title any more, so an edit ships on the next deploy.
Rename a seed by changing its `title` and listing the old one under
`previous_titles`, which moves the rename onto the existing row. When a seed already matches, nothing is written, so
this stays cheap to run on every boot. An adventure already started from a demo
keeps its own copied cards and scripts and is unchanged. Only a new adventure
keeps its own copied cards and is unchanged. Only a new adventure
picks up the updated content.
"""
@@ -35,7 +34,6 @@ SEED_DIR = Path(__file__).resolve().parent / "seed_data"
_SCALARS = ("title", "description", "prompt", "memory", "authors_note", "ai_instructions",
"tags", "image", "icon")
_CARD_FIELDS = ("type", "name", "keys", "entry", "notes")
_SCRIPT_FIELDS = ("name", "library_js", "input_js", "context_js", "output_js")
def seed_public_scenarios(engine: Engine) -> None:
@@ -98,7 +96,7 @@ def _sweep_unclaimed(db, claimed: set[str]) -> int:
nothing anybody created can be reached from here.
An adventure started from a deleted demo survives. `adventures.scenario_id`
is `ON DELETE SET NULL`, so the story, its cards, and its scripts are its
is `ON DELETE SET NULL`, so the story and its cards are its
own copies and stay; the adventure loses the cover art it inherited.
The caller skips this when a seed file failed to parse. A file that cannot
@@ -117,11 +115,6 @@ def _sweep_unclaimed(db, claimed: set[str]) -> int:
)
for scenario in stale:
logger.info("Removing seeded scenario %r; no seed file claims it.", scenario.title)
# The scripts are joined through a secondary table, so nothing cascades
# to them. They have a NULL owner and no other reader.
for script in list(scenario.scripts):
db.delete(script)
scenario.scripts = []
db.delete(scenario)
return len(stale)
@@ -130,10 +123,6 @@ def _card_tuple(source, get) -> tuple:
return tuple(get(source, f) for f in _CARD_FIELDS)
def _script_tuple(source, get) -> tuple:
return tuple(get(source, f) for f in _SCRIPT_FIELDS)
def find_seeded(db, title: str) -> models.Scenario | None:
"""Returns the seeded scenario with this exact title, if there is one."""
return (
@@ -178,14 +167,7 @@ def _matches(scenario: models.Scenario, data: dict) -> bool:
_card_tuple(c, lambda o, f: o.get(f, ""))
for c in (data.get("story_cards") or []) if isinstance(c, dict)
)
if have_cards != want_cards:
return False
have_scripts = sorted(_script_tuple(s, lambda o, f: getattr(o, f)) for s in scenario.scripts)
want_scripts = sorted(
_script_tuple(s, lambda o, f: (o.get(f, "") or ("Script" if f == "name" else "")))
for s in (data.get("scripts") or []) if isinstance(s, dict)
)
return have_scripts == want_scripts
return have_cards == want_cards
def _insert_scenario(db, data: dict) -> None:
@@ -203,9 +185,6 @@ def _update_scenario(db, scenario: models.Scenario, data: dict) -> None:
# adventure foreign keys that point at it, intact.
for card in list(scenario.story_cards):
db.delete(card)
for script in list(scenario.scripts):
db.delete(script)
scenario.scripts = []
db.flush()
_populate_children(db, scenario, data)
@@ -231,19 +210,3 @@ def _populate_children(db, scenario: models.Scenario, data: dict) -> None:
notes=card.get("notes", ""),
)
)
for item in data.get("scripts") or []:
if not isinstance(item, dict):
continue
script = models.Script(
user_id=None,
name=item.get("name", "Script"),
description=item.get("description", ""),
library_js=item.get("library_js", ""),
input_js=item.get("input_js", ""),
context_js=item.get("context_js", ""),
output_js=item.get("output_js", ""),
)
db.add(script)
db.flush()
scenario.scripts.append(script)
+5 -9
View File
@@ -6,11 +6,9 @@ frames, so the format lives here rather than in either one.
"""
import json
from . import analytics
# `no-cache` stops an intermediary from caching the stream. `X-Accel-Buffering`
# makes nginx-style reverse proxies, which hosted deploys use, flush each event
# immediately rather than buffer it.
# makes an nginx-style reverse proxy flush each event immediately rather than
# buffer it, which matters if anyone puts one in front of the app.
SSE_HEADERS = {"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}
@@ -20,11 +18,9 @@ def sse(obj: dict) -> str:
def turn_error(detail: str, **extra) -> str:
"""Returns an SSE error for a turn that could not be produced, and counts it.
"""Returns an SSE error for a turn that could not be produced.
A failed turn is still an HTTP 200 response, so the middleware's status-code
tally cannot see it. This metric exists so that a demo whose model refuses
every request does not report as healthy.
A failed turn is still an HTTP 200 response, because the error is reported
inside the stream the client is already reading.
"""
analytics.record(analytics.M_EVENT, analytics.EV_TURN_ERROR)
return sse({"type": "error", "detail": detail, **extra})
-3
View File
@@ -8,6 +8,3 @@ httpx>=0.27
# code imports it by name.
certifi
tiktoken>=0.7
quickjs>=1.19
cryptography>=42
psycopg[binary]>=3.2
+41
View File
@@ -39,3 +39,44 @@ class ScriptedProvider:
if isinstance(reply, Exception):
raise reply
yield ("text", reply)
# ---------------------------------------------------------------------------
# Deterministic per-turn state instrumentation
# ---------------------------------------------------------------------------
# Several tests need a value that changes by a fixed amount on every turn, so
# that a rollback failure is arithmetic rather than a judgement call: if a take
# stacks instead of replacing, the total is off by exactly one turn's worth.
#
# That instrumentation used to be a JavaScript `output` hook doing
# `state.gold += 10` in the QuickJS sandbox. M2 removed campaign scripting, and
# the tests below are not about scripting — they are about the state snapshot,
# rollback, and branch-isolation machinery in `attempts.py` and `tree.py`,
# which is unchanged.
#
# The counter therefore moved to the world-state engine, which is a real
# remaining product path: the model emits a ```state delta block, the referee
# applies it, and the result lands in `adventure.world_state`. The fake
# provider decides what the model "emits", so it is exactly as deterministic as
# the script was, and it exercises production code rather than a test hook.
#: A schema with a plain unbounded counter. No `max_delta_per_turn` and no
#: `cooldown`, so every +10 is applied in full, every turn.
GOLD_SCHEMA = {
"player": {
"hp": {"min": 0, "max": 100, "initial": 100},
"gold": {"min": 0, "max": 1_000_000, "initial": 0},
}
}
GOLD_PER_TURN = 10
def gold_reply(text: str, amount: int = GOLD_PER_TURN) -> str:
"""A model reply that narrates `text` and banks `amount` gold."""
return f'{text}\n```state\n{{"player.gold": {amount}}}\n```'
def gold_replies(prefix: str = "Take", count: int = 40) -> list[str]:
"""`count` numbered replies, each banking one turn's worth of gold."""
return [gold_reply(f"{prefix} {n}.") for n in range(1, count + 1)]
-208
View File
@@ -1,208 +0,0 @@
"""The access log: app/accesslog.py and GET /api/analytics/access.
This is the half of the analytics work that identifies people on purpose,
so these tests pin the details that would quietly make it wrong. The
address recorded must be the hardened one, not a header a client chose.
Session rows must be thinned instead of written on every page load. And a
row must outlive the account it describes, because guest cleanup deletes
accounts on a schedule, and a log that deletes itself is not a log.
python -m pytest tests/test_accesslog.py -v
"""
import pytest
from fastapi import Depends
from fastapi.testclient import TestClient
from app import accesslog, auth, limits, models, security
from app.database import Base, SessionLocal, engine, get_db
from app.main import app
EDGE = "198.51.100.77" # what the trusted proxy appended
SPOOF = "10.0.0.1" # what a client put in front of it
@pytest.fixture(autouse=True)
def clean_state():
accesslog._last_session.clear()
yield
accesslog._last_session.clear()
@pytest.fixture()
def client(monkeypatch):
Base.metadata.create_all(bind=engine)
setup = SessionLocal()
owner = models.User(is_guest=False, email="owner@example.com")
member = models.User(
is_guest=False, email="player@example.com",
password_hash=security.hash_password("hunter2long"),
)
setup.add_all([owner, member])
setup.commit()
ids = {"owner": owner.id, "member": member.id}
setup.close()
monkeypatch.setattr(limits, "rate_limit", lambda *a, **k: None)
monkeypatch.setattr(limits, "check_login_allowed", lambda *a, **k: None)
monkeypatch.setattr(auth, "MULTI_USER", True)
monkeypatch.setattr(auth, "ANALYTICS_EMAILS", {"owner@example.com"})
# /auth/me resolves its own session, so the cookie flow below is the real
# one. Every other endpoint goes through get_current_user, and `act_as`
# decides who that is.
acting = {"id": ids["owner"]}
def _current(db=Depends(get_db)):
return db.get(models.User, acting["id"])
app.dependency_overrides[auth.get_current_user] = _current
try:
test_client = TestClient(app)
test_client.ids = ids
test_client.act_as = lambda user_id: acting.update(id=user_id)
yield test_client
finally:
app.dependency_overrides.clear()
Base.metadata.drop_all(bind=engine)
def visit(client, ip=EDGE, ua="Mozilla/5.0 (Windows NT 10.0; Win64; x64)"):
return client.get(
"/api/auth/me",
headers={"x-forwarded-for": f"{SPOOF}, {ip}", "user-agent": ua},
)
def rows(kind=None):
db = SessionLocal()
try:
query = db.query(models.AccessEvent).order_by(models.AccessEvent.id)
if kind:
query = query.filter_by(kind=kind)
return query.all()
finally:
db.close()
def read_log(client, **params):
return client.get("/api/analytics/access", params=params)
# ---------- Writing ----------
def test_a_new_session_is_logged(client):
visit(client)
logged = rows()
assert len(logged) == 1
entry = logged[0]
assert entry.kind == accesslog.SESSION
assert entry.is_guest and entry.who.startswith("Guest #")
assert entry.device == "desktop"
def test_the_address_is_the_hardened_one_not_the_clients(client):
visit(client)
# The client prepended its own value. Only the hop the edge appended counts.
# Recording the leftmost value would make every row forgeable, which is
# worse for a log than having no log at all.
assert rows()[0].ip == EDGE
def test_session_rows_are_thinned_to_one_per_day_per_address(client):
for _ in range(4):
visit(client)
assert len(rows(accesslog.SESSION)) == 1
def test_a_changed_address_writes_a_new_row(client):
visit(client)
visit(client, ip="203.0.113.9")
logged = rows(accesslog.SESSION)
assert [entry.ip for entry in logged] == [EDGE, "203.0.113.9"]
# Same session throughout, so both rows name the same visitor.
assert logged[0].who == logged[1].who
def test_sign_in_and_failure_are_both_logged(client):
client.post("/api/auth/login", json={"email": "player@example.com", "password": "wrong"},
headers={"x-forwarded-for": EDGE})
client.post("/api/auth/login", json={"email": "player@example.com", "password": "hunter2long"},
headers={"x-forwarded-for": EDGE})
kinds = [entry.kind for entry in rows()]
assert accesslog.LOGIN_FAILED in kinds and accesslog.LOGIN in kinds
failure = rows(accesslog.LOGIN_FAILED)[0]
# This records the address that was tried, not the account it belongs to.
# A failed attempt against an address with no matching account is
# exactly what this row exists to capture.
assert failure.who == "player@example.com"
assert failure.user_id is None
assert rows(accesslog.LOGIN)[0].user_id == client.ids["member"]
def test_registering_is_logged_against_the_upgraded_account(client):
visit(client) # creates the guest whose session then registers
client.act_as(rows()[0].user_id)
client.post("/api/auth/register", json={"email": "new@example.com", "password": "hunter2long"})
entry = rows(accesslog.REGISTER)[0]
assert entry.who == "new@example.com" and not entry.is_guest
def test_a_row_outlives_the_account_it_describes(client):
visit(client)
entry = rows()[0]
db = SessionLocal()
try:
db.delete(db.get(models.User, entry.user_id))
db.commit()
finally:
db.close()
# There is no foreign key, and `who` is a snapshot. Guest cleanup deletes
# accounts on a schedule, and a log that vanishes along with them is not
# a log.
survivor = rows()[0]
assert survivor.who == entry.who and survivor.ip == EDGE
def test_a_long_user_agent_is_truncated(client):
visit(client, ua="Mozilla/" + "x" * 500)
assert len(rows()[0].user_agent) == accesslog.MAX_UA
def test_a_logging_failure_does_not_break_the_request(client, monkeypatch):
monkeypatch.setattr(accesslog, "_client_ip", lambda request: 1 / 0)
# The log observes sign-in. A logging failure must not block the request.
assert visit(client).status_code == 200
# ---------- Reading ----------
def test_the_log_is_invisible_to_everyone_but_the_owner(client):
visit(client)
assert read_log(client).status_code == 200
client.act_as(client.ids["member"])
assert read_log(client).status_code == 404
def test_the_log_reads_newest_first_and_pages_backwards(client):
for index in range(5):
visit(client, ip=f"203.0.113.{index}")
first = read_log(client, limit=2).json()
assert [event["ip"] for event in first["events"]] == ["203.0.113.4", "203.0.113.3"]
assert first["has_more"]
older = read_log(client, limit=2, before_id=first["events"][-1]["id"]).json()
assert [event["ip"] for event in older["events"]] == ["203.0.113.2", "203.0.113.1"]
def test_the_log_filters_by_kind_and_searches(client):
visit(client)
client.post("/api/auth/login", json={"email": "player@example.com", "password": "hunter2long"},
headers={"x-forwarded-for": "203.0.113.44"})
assert len(read_log(client, kind="login").json()["events"]) == 1
by_email = read_log(client, q="player@example.com").json()["events"]
assert len(by_email) == 1 and by_email[0]["kind"] == "login"
by_ip = read_log(client, q="203.0.113.44").json()["events"]
assert len(by_ip) == 1
assert read_log(client, q="nobody@example.com").json()["events"] == []
-1
View File
@@ -48,7 +48,6 @@ def client(monkeypatch):
adv_id, user_id = adventure.id, user.id
setup.close()
monkeypatch.setattr(limits, "rate_limit", lambda *a, **k: None)
monkeypatch.setattr(limits, "check_row_cap", lambda *a, **k: None)
def _current_user(db=Depends(get_db)):
-385
View File
@@ -1,385 +0,0 @@
"""Visit analytics: app/analytics.py and the two endpoints in front of it.
This file tests three things, and the rest is arithmetic. The counters must
survive the buffer/UPSERT round trip: a flush adds to what is already
stored instead of replacing it, or every number would show only the last
minute. The funnel counts people rather than clicks, which is the only
reason the visitor-day table exists. The gate holds: a stranger cannot read
the dashboard, and cannot inflate what it reports beyond hitting the page.
python -m pytest tests/test_analytics.py -v
"""
from datetime import timedelta
import pytest
from fastapi import Depends
from fastapi.testclient import TestClient
from app import analytics, auth, limits, models
from app.database import Base, SessionLocal, engine, get_db
from app.main import app
@pytest.fixture(autouse=True)
def clean_buffer():
"""The buffer is process-wide, so a test that leaves counts in it would
show up inside the next one's flush."""
analytics._counts.clear()
analytics._visits.clear()
analytics._labels_seen.clear()
yield
analytics._counts.clear()
analytics._visits.clear()
analytics._labels_seen.clear()
@pytest.fixture()
def db():
Base.metadata.create_all(bind=engine)
session = SessionLocal()
try:
yield session
finally:
session.close()
Base.metadata.drop_all(bind=engine)
def counter(db, metric, label):
row = (
db.query(models.AnalyticsDaily)
.filter_by(metric=metric, label=label)
.one_or_none()
)
return row.hits if row else 0
def make_user(db, email=None):
user = models.User(is_guest=email is None, email=email)
db.add(user)
db.commit()
return user
# ---------- The buffer and its flush ----------
def test_counts_accumulate_across_flushes(db):
analytics.record(analytics.M_PAGE, "/")
analytics.record(analytics.M_PAGE, "/")
analytics.flush(db)
analytics.record(analytics.M_PAGE, "/")
analytics.flush(db)
# The second flush has to find the existing row and add to it. Replacing it
# would leave every counter showing only the newest minute of traffic.
assert counter(db, analytics.M_PAGE, "/") == 3
def test_flush_is_a_no_op_when_nothing_happened(db):
analytics.flush(db)
assert db.query(models.AnalyticsDaily).count() == 0
def test_a_failed_flush_keeps_the_counts(db, monkeypatch):
analytics.record(analytics.M_PAGE, "/")
monkeypatch.setattr(analytics, "_write_counts", lambda *a: 1 / 0)
analytics.flush(db) # must not raise
monkeypatch.undo()
analytics.flush(db)
assert counter(db, analytics.M_PAGE, "/") == 1
def test_label_cardinality_is_capped(db):
for i in range(analytics.MAX_LABELS_PER_METRIC + 25):
analytics.record(analytics.M_REFERRER, f"host{i}.example")
analytics.flush(db)
labels = db.query(models.AnalyticsDaily).filter_by(metric=analytics.M_REFERRER).count()
# Everything past the cap is folded into one bucket, so a referrer flood
# cannot create unlimited rows.
assert labels == analytics.MAX_LABELS_PER_METRIC + 1
assert counter(db, analytics.M_REFERRER, analytics.OTHER) == 25
# ---------- Visitors ----------
def test_visitor_id_is_stable_and_keyed(db, monkeypatch):
user = make_user(db)
handle = analytics.visitor_id(user)
assert handle == analytics.visitor_id(user) # a returning visitor
assert handle != analytics.visitor_id(make_user(db)) # is still one visitor
assert len(handle) == 32 and int(handle, 16) >= 0 # opaque hex, not an id
# Keyed on the app secret, not a bare hash of the user id. Otherwise
# anyone holding this table could rebuild the mapping by hashing
# sequential ids.
monkeypatch.setattr(analytics.security, "SECRET_KEY", b"a-different-secret")
assert analytics.visitor_id(user) != handle
def test_a_repeat_visitor_is_new_only_once(db):
user = make_user(db)
analytics.record_visit(user)
analytics.flush(db)
rows = db.query(models.AnalyticsVisitorDay).all()
assert len(rows) == 1 and rows[0].is_new
# Same visitor, a later day: seen before, so not new. The row is not
# merged into the first day's row either.
tomorrow = (models.utcnow().date() + timedelta(days=1)).isoformat()
analytics._visits[(tomorrow, analytics.visitor_id(user))] = set()
analytics.flush(db)
rows = db.query(models.AnalyticsVisitorDay).order_by(models.AnalyticsVisitorDay.day).all()
assert [row.is_new for row in rows] == [True, False]
def test_one_row_per_visitor_per_day_however_much_they_do(db):
user = make_user(db)
for _ in range(5):
analytics.record_event(analytics.EV_ADVENTURE, user)
analytics.flush(db)
assert db.query(models.AnalyticsVisitorDay).count() == 1
assert counter(db, analytics.M_EVENT, analytics.EV_ADVENTURE) == 5
def test_funnel_flags_only_ever_turn_on(db):
user = make_user(db)
analytics.record_event(analytics.EV_TURN, user)
analytics.flush(db)
# A later visit that reaches no funnel step must not clear the earlier one.
analytics.record_visit(user)
analytics.flush(db)
row = db.query(models.AnalyticsVisitorDay).one()
assert row.played and not row.created
def test_purge_drops_only_rows_past_the_horizon(db):
old = (models.utcnow().date() - timedelta(days=analytics.RETENTION_DAYS + 1)).isoformat()
db.add(models.AnalyticsVisitorDay(day=old, visitor="a" * 32))
db.add(models.AnalyticsVisitorDay(day=analytics._today(), visitor="b" * 32))
db.commit()
assert analytics.purge_old_visitor_days(db) == 1
assert [r.visitor for r in db.query(models.AnalyticsVisitorDay)] == ["b" * 32]
# ---------- Normalizing what a browser claims ----------
@pytest.mark.parametrize("path, expected", [
("/", "/"),
("/adventures", "/adventures"),
("/adventures/", "/adventures"),
("/play/12?x=1", "/play/:id"),
("/scenarios/9#top", "/scenarios/:id"),
("/wp-admin", "(other)"),
("/play/../../etc", "(other)"),
("", "/"),
])
def test_route_normalization(path, expected):
assert analytics.normalize_route(path) == expected
@pytest.mark.parametrize("referrer, expected", [
("", "(direct)"),
("https://news.ycombinator.com/item?id=1", "news.ycombinator.com"),
("https://www.google.com/", "google.com"),
("https://ai-dnd.example/scenarios", ""), # our own host: not a referral
("javascript:alert(1)", "(other)"),
("https://" + "x" * 200 + ".com", "(other)"),
])
def test_referrer_normalization(referrer, expected):
assert analytics.normalize_referrer(referrer, "ai-dnd.example") == expected
@pytest.mark.parametrize("ua, expected", [
("Mozilla/5.0 (iPhone; CPU iPhone OS 17_0) AppleWebKit", "mobile"),
("Mozilla/5.0 (iPad; CPU OS 17_0) AppleWebKit", "tablet"),
("Mozilla/5.0 (Windows NT 10.0; Win64; x64)", "desktop"),
("Googlebot/2.1", "bot"),
("", "(unknown)"),
])
def test_device_detection(ua, expected):
assert analytics.device_of(ua) == expected
def test_only_iso_looking_country_headers_are_trusted():
assert analytics.country_of({"cf-ipcountry": "de"}) == "DE"
assert analytics.country_of({"cf-ipcountry": "Norway"}) == analytics.UNKNOWN
assert analytics.country_of({"cf-ipcountry": "XX"}) == analytics.UNKNOWN
assert analytics.country_of({}) == analytics.UNKNOWN
def test_error_labels_use_the_route_not_the_path():
class Route:
path = "/api/adventures/{adventure_id}"
assert analytics.api_route_label({"route": Route()}, 500) == "500 /api/adventures/{adventure_id}"
# An unmatched path is entirely attacker-chosen, so it never becomes a label.
assert analytics.api_route_label({}, 404) == "404 (unmatched)"
# ---------- The summary ----------
def test_summary_counts_people_once_per_step(db):
one, two = make_user(db), make_user(db)
for _ in range(3):
analytics.record_event(analytics.EV_SCENARIO_OPEN, one)
analytics.record_event(analytics.EV_TURN, one)
analytics.record_event(analytics.EV_SCENARIO_OPEN, two)
result = analytics.summary(db, days=7)
steps = {row["step"]: row["count"] for row in result["funnel"]}
assert steps["Visited"] == 2
assert steps["Opened a scenario"] == 2
assert steps["Played a turn"] == 1 # not 3, because one person made three turns
assert steps["Signed up"] == 0
# Raw event totals still count every occurrence.
assert result["totals"]["turns"] == 3
assert result["totals"]["visitors"] == 2
def test_summary_series_covers_every_day_including_empty_ones(db):
analytics.record(analytics.M_PAGE, "/")
result = analytics.summary(db, days=7)
assert len(result["series"]) == 7
assert result["series"][-1]["day"] == models.utcnow().date().isoformat()
assert result["series"][-1]["pageviews"] == 1
assert result["series"][0]["pageviews"] == 0
def test_summary_flushes_before_reading(db):
analytics.record(analytics.M_EVENT, analytics.EV_TURN)
# Never flushed by hand: the dashboard must not be up to a minute stale.
assert analytics.summary(db, days=1)["totals"]["turns"] == 1
def test_summary_reports_pages_referrers_and_errors(db):
analytics.record(analytics.M_PAGE, "/play/:id", n=4)
analytics.record(analytics.M_REFERRER, "news.ycombinator.com", n=2)
analytics.record(analytics.M_ERROR, "500 /api/adventures/{adventure_id}")
result = analytics.summary(db, days=30)
assert result["pages"][0] == {"label": "/play/:id", "hits": 4}
assert result["referrers"][0]["label"] == "news.ycombinator.com"
assert result["totals"]["errors"] == 1
# ---------- The endpoints ----------
@pytest.fixture()
def client(monkeypatch):
Base.metadata.create_all(bind=engine)
setup = SessionLocal()
visitor = models.User(is_guest=True)
owner = models.User(is_guest=False, email="owner@example.com")
setup.add_all([visitor, owner])
setup.commit()
ids = {"visitor": visitor.id, "owner": owner.id}
setup.close()
monkeypatch.setattr(limits, "rate_limit", lambda *a, **k: None)
# Multi-user is what makes the gate mean anything: local mode trusts
# whoever is at the keyboard, because it is the operator's own machine.
monkeypatch.setattr(auth, "MULTI_USER", True)
monkeypatch.setattr(auth, "ANALYTICS_EMAILS", {"owner@example.com"})
current = {"id": ids["visitor"]}
def _current_user(db=Depends(get_db)):
return db.get(models.User, current["id"])
app.dependency_overrides[auth.get_current_user] = _current_user
monkeypatch.setattr(
auth, "resolve_session_user", lambda request, db: db.get(models.User, current["id"])
)
try:
client = TestClient(app)
client.ids, client.current = ids, current
yield client
finally:
app.dependency_overrides.clear()
Base.metadata.drop_all(bind=engine)
def read_summary(client, days=30):
return client.get(f"/api/analytics/summary?days={days}")
def test_dashboard_is_invisible_to_everyone_but_the_owner(client):
assert read_summary(client).status_code == 404
client.current["id"] = client.ids["owner"]
assert read_summary(client).status_code == 200
def test_collect_records_a_pageview_and_the_visit(client):
resp = client.post("/api/analytics/collect", json={"path": "/play/7", "first": True,
"referrer": "https://news.ycombinator.com/"})
assert resp.status_code == 204
client.current["id"] = client.ids["owner"]
body = read_summary(client).json()
assert body["pages"][0] == {"label": "/play/:id", "hits": 1}
assert body["referrers"][0]["label"] == "news.ycombinator.com"
assert body["totals"]["visitors"] == 1
def test_referrer_and_device_are_recorded_once_per_visit_not_per_view(client):
for path in ("/", "/scenarios", "/adventures"):
client.post("/api/analytics/collect", json={"path": path, "first": path == "/"})
client.current["id"] = client.ids["owner"]
body = read_summary(client).json()
assert body["totals"]["pageviews"] == 3
# Three views, one visit: the referral and the device are facts about the
# visit, so counting them per view would multiply every one of them.
assert sum(row["hits"] for row in body["devices"]) == 1
def test_the_owners_own_visits_are_not_traffic(client):
client.current["id"] = client.ids["owner"]
client.post("/api/analytics/collect", json={"path": "/", "first": True})
assert read_summary(client).json()["totals"]["pageviews"] == 0
def test_a_client_cannot_invent_pages_or_events(client):
client.post("/api/analytics/collect", json={"path": "/../../admin", "first": True})
# There is no field for it, so a made-up event is not even expressible.
client.post("/api/analytics/collect", json={"path": "/", "event": "signup"})
client.current["id"] = client.ids["owner"]
body = read_summary(client).json()
assert {row["label"] for row in body["pages"]} == {"(other)", "/"}
assert body["totals"]["signups"] == 0
def test_api_errors_are_counted_by_route(client):
client.get("/api/adventures/999999")
client.current["id"] = client.ids["owner"]
errors = read_summary(client).json()["errors"]
assert errors and errors[0]["label"].startswith("404 /api/adventures/")
# ---------- The dialect the tests never run on ----------
def test_the_upserts_compile_for_postgres():
"""Prod runs on Neon, but these tests run on SQLite, and a failed flush
is caught and logged instead of raised. A dialect mistake would
therefore stay invisible until the dashboard quietly stayed empty. This
test compiles both statements against Postgres without connecting to
one.
"""
from sqlalchemy import create_engine
from sqlalchemy.dialects import postgresql
from sqlalchemy.orm import sessionmaker
session = sessionmaker(bind=create_engine("postgresql+psycopg://u:p@localhost/db"))()
compiled = []
def capture(statement, *args, **kwargs):
compiled.append(str(statement.compile(dialect=postgresql.dialect())))
session.execute = capture
session.scalars = lambda *a, **k: []
analytics._write_counts(session, {("2026-01-01", "pageview", "/"): 2})
analytics._write_visits(session, {("2026-01-01", "f" * 32): {"played"}})
counts, visits = compiled
assert "ON CONFLICT (day, metric, label) DO UPDATE" in counts
assert "analytics_daily.hits + excluded.hits" in counts
assert "ON CONFLICT (day, visitor) DO UPDATE" in visits
assert "analytics_visitor_days.played OR excluded.played" in visits
# is_new is settled by the first write of a visitor's first day and must
# not be in the update clause at all.
assert "is_new" not in visits.split("DO UPDATE")[1]
+3 -16
View File
@@ -19,17 +19,10 @@ from app.database import Base, SessionLocal, engine, get_db
from app.main import app
from app.routers import adventures
from fakes import ScriptedProvider
from fakes import GOLD_SCHEMA, ScriptedProvider, gold_replies, gold_reply
SCHEMA = {"player": {"hp": {"min": 0, "max": 100, "initial": 100}}}
SCHEMA = GOLD_SCHEMA
GOLD_SCRIPT = """
const modifier = (text) => {
state.gold = (state.gold || 0) + 10;
return { text };
};
modifier(text);
"""
@pytest.fixture()
@@ -45,14 +38,11 @@ def client(monkeypatch):
setup.flush()
adv = models.Adventure(
user_id=user.id, title="Cave", scenario_id=scenario.id,
script_state={}, world_state={"player": {"hp": 100}},
world_state={"player": {"hp": 100, "gold": 0}},
)
setup.add(adv)
setup.flush()
setup.add(models.Action(adventure_id=adv.id, type="start", text="You enter a cave."))
setup.add(models.AdventureScript(
adventure_id=adv.id, position=0, enabled=True, name="Gold", output_js=GOLD_SCRIPT,
))
setup.commit()
adv_id, user_id = adv.id, user.id
setup.close()
@@ -61,9 +51,6 @@ def client(monkeypatch):
ScriptedProvider.calls = 0
ScriptedProvider.prompts = []
monkeypatch.setattr(adventures.turns, "OpenAICompatibleProvider", ScriptedProvider)
monkeypatch.setattr(auth, "resolve_provider_config", lambda s: auth.ProviderConfig(
"http://fake", "k", "test-model", False))
monkeypatch.setattr(limits, "rate_limit", lambda *a, **k: None)
monkeypatch.setattr(limits, "check_row_cap", lambda *a, **k: None)
def _current_user(db=Depends(get_db)):
-14
View File
@@ -30,7 +30,6 @@ from app import auth, limits, models, tree
from app.context import history, lineage
from app.database import Base, SessionLocal, engine, get_db
from app.main import app
from app.scripting import ScriptPipeline
from tools import dbmeter
@@ -224,24 +223,11 @@ def test_an_already_loaded_collection_is_cut_down_to_the_path(forked):
assert labels(history.tail(adventure, 3)) == ["B5", "C6", "C7"]
def test_user_scripts_are_handed_the_path(forked):
"""The same risk one layer up, in code visible to users:
`pipeline._history()` is the documented scripting history API."""
db, adventure, _ = forked
list(adventure.actions) # the pipeline's caller has usually loaded these
pipeline = ScriptPipeline(adventure, db)
assert [h["text"] for h in pipeline._history()] == [
"A0", "A1", "A2", "A3", "B4", "B5", "C6", "C7"
]
assert pipeline._info()["actionCount"] == 8
# ------------------------------------------------------------ over the wire
@pytest.fixture()
def client(forked, monkeypatch):
db, adventure, ids = forked
monkeypatch.setattr(limits, "rate_limit", lambda *a, **k: None)
monkeypatch.setattr(limits, "check_row_cap", lambda *a, **k: None)
def _current_user(session=Depends(get_db)):
+15 -22
View File
@@ -22,7 +22,7 @@ from app.database import Base, SessionLocal, engine, get_db
from app.main import app
from app.routers import adventures
from fakes import ScriptedProvider
from fakes import GOLD_SCHEMA, ScriptedProvider, gold_replies, gold_reply
# `hp` moves freely. `mana` has a cooldown of 2 turns, so an incorrect
# advance shows up as a change the referee should have rejected.
@@ -30,16 +30,13 @@ SCHEMA = {
"player": {
"hp": {"min": 0, "max": 100, "initial": 100},
"mana": {"min": 0, "max": 50, "initial": 50, "cooldown": 2},
# The per-turn counter these tests measure rollbacks with. Unbounded and
# uncapped on purpose, so every turn's +10 lands in full. See
# `fakes.gold_reply`.
"gold": {"min": 0, "max": 1_000_000, "initial": 0},
}
}
GOLD_SCRIPT = """
const modifier = (text) => {
state.gold = (state.gold || 0) + 10;
return { text };
};
modifier(text);
"""
@pytest.fixture()
@@ -55,14 +52,11 @@ def client(monkeypatch):
setup.flush()
adv = models.Adventure(
user_id=user.id, title="Cave", scenario_id=scenario.id,
script_state={}, world_state={"player": {"hp": 100, "mana": 50}},
world_state={"player": {"hp": 100, "mana": 50, "gold": 0}},
)
setup.add(adv)
setup.flush()
setup.add(models.Action(adventure_id=adv.id, type="start", text="You enter a cave."))
setup.add(models.AdventureScript(
adventure_id=adv.id, position=0, enabled=True, name="Gold", output_js=GOLD_SCRIPT,
))
setup.commit()
adv_id, user_id = adv.id, user.id
setup.close()
@@ -71,9 +65,6 @@ def client(monkeypatch):
ScriptedProvider.calls = 0
ScriptedProvider.prompts = []
monkeypatch.setattr(adventures.turns, "OpenAICompatibleProvider", ScriptedProvider)
monkeypatch.setattr(auth, "resolve_provider_config", lambda s: auth.ProviderConfig(
"http://fake", "k", "test-model", False))
monkeypatch.setattr(limits, "rate_limit", lambda *a, **k: None)
monkeypatch.setattr(limits, "check_row_cap", lambda *a, **k: None)
def _current_user(db=Depends(get_db)):
@@ -121,7 +112,7 @@ def _state(adv_id):
db = SessionLocal()
try:
adv = db.get(models.Adventure, adv_id)
return adv.script_state, adv.world_state
return (adv.world_state or {}).get("player", {}), adv.world_state
finally:
db.close()
@@ -322,11 +313,13 @@ def test_forking_a_live_node_on_another_branch_is_refused(client):
# -------------------------------------------------------------- the state
def test_switching_restores_the_script_and_world_state(client):
def test_switching_restores_the_state_a_branch_left_behind(client):
# Two stats move: hp differs per attempt, and gold counts turns. Between
# them, a switch that restored the wrong snapshot is visible either way.
ScriptedProvider.replies = [
"A scratch.\n```state\n{\"player.hp\": -5}\n```",
"A beating.\n```state\n{\"player.hp\": -40}\n```",
"Onward.",
'A scratch.\n```state\n{"player.hp": -5, "player.gold": 10}\n```',
'A beating.\n```state\n{"player.hp": -40, "player.gold": 10}\n```',
gold_reply("Onward."),
]
_play(client)
_retry(client)
@@ -336,9 +329,9 @@ def test_switching_restores_the_script_and_world_state(client):
discarded = [a.id for a in _rows(client.adv_id) if a.type == "ai" and not a.live][0]
_fork(client, discarded)
script_state, world_state = _state(client.adv_id)
player, world_state = _state(client.adv_id)
assert world_state["player"]["hp"] == 95, "the attempt this branch tells"
assert script_state == {"gold": 10}, "one turn of gold, not three"
assert player["gold"] == 10, "one turn of gold, not three"
client.post(f"/api/adventures/{client.adv_id}/branches/{parent}/switch")
assert _state(client.adv_id) == on_parent
-3
View File
@@ -55,9 +55,6 @@ def client(monkeypatch):
ScriptedProvider.replies = ["Attempt one.", "Attempt two.", "Next turn."]
ScriptedProvider.calls = 0
monkeypatch.setattr(adventures.turns, "OpenAICompatibleProvider", ScriptedProvider)
monkeypatch.setattr(auth, "resolve_provider_config", lambda s: auth.ProviderConfig(
"http://fake", "k", "test-model", False))
monkeypatch.setattr(limits, "rate_limit", lambda *a, **k: None)
monkeypatch.setattr(limits, "check_row_cap", lambda *a, **k: None)
def _current_user(db=Depends(get_db)):
+5 -20
View File
@@ -33,19 +33,12 @@ from app.database import Base, SessionLocal, engine, get_db
from app.main import app
from app.routers import adventures
from fakes import ScriptedProvider
from fakes import GOLD_SCHEMA, ScriptedProvider, gold_replies, gold_reply
SCHEMA = {"player": {"hp": {"min": 0, "max": 100, "initial": 100}}}
SCHEMA = GOLD_SCHEMA
# Ten gold a turn, so the stored gold total tells how many turns the
# story behind it played. This makes an after-snapshot visible from outside.
GOLD_SCRIPT = """
const modifier = (text) => {
state.gold = (state.gold || 0) + 10;
return { text };
};
modifier(text);
"""
OPENING = "You enter a cave."
@@ -63,14 +56,11 @@ def client(monkeypatch):
setup.flush()
adv = models.Adventure(
user_id=user.id, title="Cave", scenario_id=scenario.id,
script_state={}, world_state={"player": {"hp": 100}},
world_state={"player": {"hp": 100, "gold": 0}},
)
setup.add(adv)
setup.flush()
setup.add(models.Action(adventure_id=adv.id, type="start", text=OPENING))
setup.add(models.AdventureScript(
adventure_id=adv.id, position=0, enabled=True, name="Gold", output_js=GOLD_SCRIPT,
))
setup.commit()
adv_id, user_id = adv.id, user.id
setup.close()
@@ -78,9 +68,6 @@ def client(monkeypatch):
ScriptedProvider.replies = ["A reply."]
ScriptedProvider.calls = 0
monkeypatch.setattr(adventures.turns, "OpenAICompatibleProvider", ScriptedProvider)
monkeypatch.setattr(auth, "resolve_provider_config", lambda s: auth.ProviderConfig(
"http://fake", "k", "test-model", False))
monkeypatch.setattr(limits, "rate_limit", lambda *a, **k: None)
monkeypatch.setattr(limits, "check_row_cap", lambda *a, **k: None)
def _current_user(db=Depends(get_db)):
@@ -186,7 +173,7 @@ def _branch_rows(adv_id) -> list[models.Branch]:
def _script_state(adv_id) -> dict:
db = SessionLocal()
try:
return db.get(models.Adventure, adv_id).script_state
return (db.get(models.Adventure, adv_id).world_state or {}).get("player", {})
finally:
db.close()
@@ -272,7 +259,7 @@ def test_a_switch_in_the_copy_restores_what_that_branch_left_behind(client):
# Play one more turn on the fork, so the two tips end up at
# genuinely different totals. Turn for turn, both branches earn the
# same gold, so a switch that restored nothing would still look right.
ScriptedProvider.replies = ["Further still."]
ScriptedProvider.replies = [gold_reply("Further still.")]
_play(client, original, "press on")
per_branch = []
@@ -398,7 +385,6 @@ def test_a_fork_with_no_depth_is_refused(client):
def test_more_branches_than_the_cap_is_refused(client, monkeypatch):
monkeypatch.setattr(auth, "MULTI_USER", True)
payload = {
"format": bundle.FORMAT, "title": "Too many",
"branches": [{"parent": None, "forkDepth": None}]
@@ -558,7 +544,6 @@ def test_the_action_cap_counts_the_rows_a_v1_file_expands_into(client, monkeypat
multiple of it. The body-size limit does not help here: the text is
tiny, and the row count is the actual cost.
"""
monkeypatch.setattr(auth, "MULTI_USER", True)
monkeypatch.setattr(limits, "MAX_ACTIONS_PER_ADVENTURE", 6)
monkeypatch.setattr(limits, "_BUNDLE_LIST_CAPS",
{**limits._BUNDLE_LIST_CAPS, "actions": 6})
+43 -165
View File
@@ -1,8 +1,14 @@
"""HTTP tests for the AI Chat scratchpad (power users only).
"""HTTP tests for the AI Chat scratchpad.
Covers the access gate, the streamed reply, and the demo-key model pinning.
This pinning must not let a public visitor reach paid models through this
page.
Most of this file used to be about the shared demo key: an access gate on a
"power user" email allowlist, and a pinning rule that stopped a public visitor
reaching paid models on a server-funded key. M2 removed the hosted deployment
those defended, so the rules they tested no longer exist to be tested. See
`planning/reports/M2-*` for the accounting.
What remains is what the page still does: stream a reply from the configured
model, honour a system prompt and a per-request model override, and refuse a
conversation too large to send.
python -m pytest tests/test_chat.py -v
"""
@@ -10,25 +16,27 @@ import pytest
from fastapi import Depends
from fastapi.testclient import TestClient
from app import auth, limits, models
from app import auth, models
from app.database import Base, SessionLocal, engine, get_db
from app.main import app
from app.routers import chat
class FakeProvider:
"""Records what it was constructed with, then streams a fixed reply. Stands
in for the real egress point, so asserting on last_key/last_model is
asserting on exactly what would have gone over the wire."""
"""Records what it was constructed with, then streams a fixed reply.
It stands in for the real egress point, so asserting on `last_endpoint` and
`last_model` is asserting on exactly what would have gone over the wire.
There is no `last_key` any more: the provider takes no API key, because
Ollama does not use one.
"""
last_usage = None
last_model = None
last_key = None
last_endpoint = None
last_messages = None
def __init__(self, endpoint_url, api_key, model, api_mode="chat", reasoning_max_tokens=0):
def __init__(self, endpoint_url, model, api_mode="chat", read_timeout=None):
FakeProvider.last_model = model
FakeProvider.last_key = api_key
FakeProvider.last_endpoint = endpoint_url
async def chat(self, messages, *, temperature, max_tokens):
@@ -41,68 +49,35 @@ class FakeProvider:
def client(monkeypatch):
Base.metadata.create_all(bind=engine)
setup = SessionLocal()
user = models.User(is_guest=False, email="power@example.com")
user = models.User(is_guest=False)
setup.add(user)
setup.flush()
setup.add(models.Settings(user_id=user.id, api_key="enc:dummy", model="test-model"))
setup.add(models.Settings(user_id=user.id, model="test-model"))
setup.commit()
user_id = user.id
setup.close()
monkeypatch.setattr(chat, "OpenAICompatibleProvider", FakeProvider)
monkeypatch.setattr(limits, "rate_limit", lambda *a, **k: None)
# Multi-user mode is what makes the power-user gate meaningful, because
# local mode trusts everyone. The allowlist is set per test.
monkeypatch.setattr(auth, "MULTI_USER", True)
monkeypatch.setattr(auth, "POWER_USERS", {"power@example.com"})
# These tests deliberately do not stub resolve_provider_config. The
# point is to exercise the real BYOK-vs-demo decision, since that
# decision is what keeps the shared key off paid models. Each test
# picks a mode with _byok/_demo below.
def _current_user(db=Depends(get_db)):
return db.get(models.User, user_id)
app.dependency_overrides[auth.get_current_user] = _current_user
c = TestClient(app)
try:
yield TestClient(app)
yield c
finally:
app.dependency_overrides.clear()
Base.metadata.drop_all(bind=engine)
def _send(client, **extra):
return client.post("/api/chat/stream", json={"messages": [{"role": "user", "content": "hi"}], **extra})
def _send(client, **body):
payload = {"messages": [{"role": "user", "content": "hi"}]}
payload.update(body)
return client.post("/api/chat/stream", json=payload)
def _byok(monkeypatch):
"""The user brought their own key: no demo key in play, any model allowed."""
monkeypatch.setattr(auth, "demo_enabled", lambda: False)
db = SessionLocal()
try:
settings = db.query(models.Settings).first()
settings.api_key = "sk-my-own-key" # legacy-plaintext path: used as-is
db.commit()
finally:
db.close()
def _demo(monkeypatch, whitelist=("free/allowed",)):
"""The user has no key, so turns run on the server-funded demo key."""
monkeypatch.setattr(auth, "demo_enabled", lambda: True)
monkeypatch.setattr(auth, "DEMO_API_KEY", "demo-key")
monkeypatch.setattr(auth, "DEMO_ENDPOINT_URL", "http://demo")
monkeypatch.setattr(auth, "DEMO_MODELS", list(whitelist))
def test_non_power_user_gets_404(client, monkeypatch):
monkeypatch.setattr(auth, "POWER_USERS", set())
assert _send(client).status_code == 404
assert client.get("/api/chat/config").status_code == 404
def test_power_user_streams_a_reply(client, monkeypatch):
_byok(monkeypatch)
def test_the_page_streams_a_reply(client):
resp = _send(client)
assert resp.status_code == 200, resp.text
assert '"type": "reasoning"' in resp.text
@@ -111,136 +86,39 @@ def test_power_user_streams_a_reply(client, monkeypatch):
assert FakeProvider.last_messages == [{"role": "user", "content": "hi"}]
def test_system_prompt_and_model_override_are_honoured(client, monkeypatch):
_byok(monkeypatch)
def test_it_uses_the_configured_endpoint_and_model(client):
_send(client)
assert FakeProvider.last_model == "test-model"
# The default from `models.Settings`, and the only kind of address the
# endpoint policy allows without configuration.
assert FakeProvider.last_endpoint == "http://localhost:11434/v1"
def test_system_prompt_and_model_override_are_honoured(client):
resp = client.post("/api/chat/stream", json={
"messages": [
{"role": "system", "content": "Be terse."},
{"role": "user", "content": "hi"},
],
"model": "some/other-model",
"model": "some-other-model",
})
assert resp.status_code == 200, resp.text
# BYOK: any model the user names is passed straight through, on their key.
assert FakeProvider.last_model == "some/other-model"
assert FakeProvider.last_key == "sk-my-own-key"
assert FakeProvider.last_model == "some-other-model"
assert FakeProvider.last_messages[0] == {"role": "system", "content": "Be terse."}
def test_demo_key_pins_model_to_whitelist(client, monkeypatch):
_demo(monkeypatch)
resp = _send(client, model="expensive/paid-model")
assert resp.status_code == 200, resp.text
# Refused visibly: the whitelisted model runs instead, with a note. The
# paid slug must never reach the wire alongside the server-funded key.
assert FakeProvider.last_model == "free/allowed"
assert FakeProvider.last_key == "demo-key"
assert '"type": "note"' in resp.text
# A whitelisted model is still selectable on the demo key.
_demo(monkeypatch, ["free/allowed", "free/second"])
resp = _send(client, model="free/second")
assert resp.status_code == 200, resp.text
assert FakeProvider.last_model == "free/second"
def test_demo_key_ignores_an_off_whitelist_settings_model(client, monkeypatch):
"""The override is not the only untrusted input. `Settings.model` is
also user-set, and it must be pinned the same way when there is no
BYOK key."""
_demo(monkeypatch)
db = SessionLocal()
try:
db.query(models.Settings).first().model = "expensive/paid-model"
db.commit()
finally:
db.close()
resp = _send(client)
assert resp.status_code == 200, resp.text
assert FakeProvider.last_model == "free/allowed"
def test_demo_key_endpoint_cannot_be_redirected(client, monkeypatch):
"""A user-controlled `endpoint_url` would leak the key itself, which is
worse than spending it. The demo branch pins the URL too."""
_demo(monkeypatch)
db = SessionLocal()
try:
db.query(models.Settings).first().endpoint_url = "http://attacker.example/v1"
db.commit()
finally:
db.close()
assert _send(client).status_code == 200
assert FakeProvider.last_endpoint == "http://demo"
assert FakeProvider.last_key == "demo-key"
def test_provider_config_refuses_server_funded_paid_model(monkeypatch):
"""The structural backstop: a hand-built config (a future code path that
forgets to go through resolve_provider_config) cannot run a
server-funded turn on an off-whitelist model."""
monkeypatch.setattr(auth, "DEMO_API_KEY", "demo-key")
monkeypatch.setattr(auth, "DEMO_MODELS", ["free/allowed"])
with pytest.raises(ValueError):
auth.ProviderConfig("http://demo", "demo-key", "expensive/paid-model", True)
auth.ProviderConfig("http://demo", "demo-key", "free/allowed", True) # whitelisted: fine
# The user's own key with any model stays fine.
auth.ProviderConfig("http://any", "sk-mine", "expensive/paid-model", False)
def test_byok_user_may_reuse_the_demo_keys_value(client, monkeypatch):
"""Regression: the demo key is just an OpenRouter key, so a user can paste
that same value into their own Settings. That is still BYOK, because
the user is paying, and it must not trip the guard. It used to raise
on every resolution, which returned a 500 from `GET /auth/me` and
broke the entire SPA (no nav, no chat)."""
monkeypatch.setattr(auth, "demo_enabled", lambda: True)
monkeypatch.setattr(auth, "DEMO_API_KEY", "shared-key")
monkeypatch.setattr(auth, "DEMO_ENDPOINT_URL", "http://demo")
monkeypatch.setattr(auth, "DEMO_MODELS", ["free/allowed"])
def test_a_request_with_no_model_anywhere_is_refused(client):
db = SessionLocal()
try:
settings = db.query(models.Settings).first()
settings.api_key = "shared-key" # same value, but supplied by the user
settings.model = "expensive/paid-model" # their spend, their choice
settings.model = ""
db.commit()
finally:
db.close()
assert client.get("/api/auth/me").status_code == 200
assert client.get("/api/chat/config").status_code == 200
resp = _send(client)
assert resp.status_code == 200, resp.text
assert FakeProvider.last_model == "expensive/paid-model"
assert FakeProvider.last_key == "shared-key"
assert _send(client).status_code == 400
def test_resolve_provider_config_is_the_single_choke_point(monkeypatch):
"""Turns, AI Chat, and the connection test all resolve through this one
function, so pinning it here pins every caller. No DB or HTTP needed."""
monkeypatch.setattr(auth, "demo_enabled", lambda: True)
monkeypatch.setattr(auth, "DEMO_API_KEY", "demo-key")
monkeypatch.setattr(auth, "DEMO_ENDPOINT_URL", "http://demo")
monkeypatch.setattr(auth, "DEMO_MODELS", ["free/allowed"])
# No key of their own: both endpoint and model are pinned, regardless
# of what they set.
no_key = models.Settings(endpoint_url="http://mine/v1", api_key="", model="expensive/paid")
assert auth.resolve_provider_config(no_key) == auth.ProviderConfig(
"http://demo", "demo-key", "free/allowed", True)
assert auth.resolve_provider_config(
no_key, model_override="expensive/paid").model == "free/allowed"
assert auth.resolve_provider_config(
no_key, model_override="free/allowed").model == "free/allowed"
# Their own key: their endpoint, their key, their choice of model.
byok = models.Settings(endpoint_url="http://mine/v1", api_key="sk-mine", model="expensive/paid")
assert auth.resolve_provider_config(byok) == auth.ProviderConfig(
"http://mine/v1", "sk-mine", "expensive/paid", False)
def test_oversized_conversation_is_refused(client, monkeypatch):
_byok(monkeypatch)
def test_oversized_conversation_is_refused(client):
huge = "x" * 90_000
resp = client.post("/api/chat/stream", json={
"messages": [{"role": "user", "content": huge} for _ in range(5)],
+18 -25
View File
@@ -1,6 +1,6 @@
"""Deleting a turn puts the shared state back.
`script_state` and `world_state` belong to the adventure, not to the node
`world_state` belongs to the adventure, not to the node
that changed them. Undo, retry, a take and a branch switch all restore them;
the delete endpoint did not. Deleting an AI turn removed the text and left
everything the turn did to the numbers standing.
@@ -23,7 +23,7 @@ from app import auth, limits, models
from app.database import Base, SessionLocal, engine, get_db
from app.main import app
from app.routers import adventures
from fakes import ScriptedProvider
from fakes import GOLD_SCHEMA, ScriptedProvider, gold_replies, gold_reply
# `mana` carries a cooldown, so a clock that was not rolled back shows up as
# a refusal rather than as a number that is merely off.
@@ -31,20 +31,19 @@ SCHEMA = {
"player": {
"hp": {"min": 0, "max": 100, "initial": 100},
"mana": {"min": 0, "max": 50, "initial": 50, "cooldown": 2},
# The per-turn counter these tests measure rollbacks with. Unbounded and
# uncapped on purpose, so every turn's +10 lands in full. See
# `fakes.gold_reply`.
"gold": {"min": 0, "max": 1_000_000, "initial": 0},
}
}
# Ten gold a turn. A total that only ever climbs makes a missing rollback
# obvious: it is off by exactly one turn's worth.
GOLD_SCRIPT = """
const modifier = (text) => {
state.gold = (state.gold || 0) + 10;
return { text };
};
modifier(text);
"""
DRAIN = 'Drained.\n```state\n{"player.mana": -10}\n```'
# The same turn, also banking the per-turn counter the rollback tests measure.
DRAIN_AND_GOLD = 'Drained.\n```state\n{"player.mana": -10, "player.gold": 10}\n```'
@pytest.fixture()
@@ -60,24 +59,18 @@ def client(monkeypatch):
setup.flush()
adv = models.Adventure(
user_id=user.id, title="Tower", scenario_id=scenario.id,
script_state={}, world_state={"player": {"hp": 100, "mana": 50}},
world_state={"player": {"hp": 100, "mana": 50, "gold": 0}},
)
setup.add(adv)
setup.flush()
setup.add(models.Action(adventure_id=adv.id, type="start", text="You begin."))
setup.add(models.AdventureScript(
adventure_id=adv.id, position=0, enabled=True, name="Gold", output_js=GOLD_SCRIPT,
))
setup.commit()
adv_id, user_id = adv.id, user.id
setup.close()
ScriptedProvider.replies = [DRAIN]
ScriptedProvider.replies = [DRAIN_AND_GOLD]
ScriptedProvider.calls = 0
monkeypatch.setattr(adventures.turns, "OpenAICompatibleProvider", ScriptedProvider)
monkeypatch.setattr(auth, "resolve_provider_config", lambda s: auth.ProviderConfig(
"http://fake", "k", "test-model", False))
monkeypatch.setattr(limits, "rate_limit", lambda *a, **k: None)
monkeypatch.setattr(limits, "check_row_cap", lambda *a, **k: None)
def _current_user(db=Depends(get_db)):
@@ -116,7 +109,7 @@ def _state(adv_id):
db = SessionLocal()
try:
adv = db.get(models.Adventure, adv_id)
return adv.script_state, adv.world_state
return (adv.world_state or {}).get("player", {}), adv.world_state
finally:
db.close()
@@ -168,17 +161,17 @@ def test_the_next_turn_is_not_refused_for_a_deleted_turn_s_cooldown(client):
assert [c for c in _last_changes(client.adv_id) if c["kind"] == "rejected"] == []
def test_deleting_the_ai_turn_rewinds_the_script_state(client):
"""The same restore, on the other half of the shared state. Without it a
replayed turn stacks its script run on top of the deleted one's."""
def test_deleting_the_ai_turn_rewinds_the_counter(client):
"""The same restore, seen through a value that only ever climbs. Without it
a replayed turn stacks its effects on top of the deleted one's."""
_play(client)
assert _state(client.adv_id)[0] == {"gold": 10}
assert _state(client.adv_id)[0]["gold"] == 10
_delete(client, _ai_rows(client.adv_id)[-1].id)
assert _state(client.adv_id)[0] == {}
assert _state(client.adv_id)[0]["gold"] == 0
_continue(client)
assert _state(client.adv_id)[0] == {"gold": 10}, "one turn of gold, not two"
assert _state(client.adv_id)[0]["gold"] == 10, "one turn of gold, not two"
# ------------------------------------------------- deleting further back
@@ -190,7 +183,7 @@ def test_deleting_a_turn_the_story_moved_past_leaves_the_tip_alone(client):
_play(client)
_play(client, "press on")
before = _state(client.adv_id)
assert before[0] == {"gold": 20}
assert before[0]["gold"] == 20
first_ai = _ai_rows(client.adv_id)[0]
assert _delete(client, first_ai.id).status_code == 204
-1
View File
@@ -99,7 +99,6 @@ def client(monkeypatch):
adv_id, user_id = adventure.id, user.id
setup.close()
monkeypatch.setattr(limits, "rate_limit", lambda *a, **k: None)
monkeypatch.setattr(limits, "check_row_cap", lambda *a, **k: None)
def _current_user(db=Depends(get_db)):
@@ -57,7 +57,6 @@ def client(monkeypatch):
adv_id, user_id = adventure.id, user.id
setup.close()
monkeypatch.setattr(limits, "rate_limit", lambda *a, **k: None)
monkeypatch.setattr(limits, "check_row_cap", lambda *a, **k: None)
def _current_user(db=Depends(get_db)):
+224
View File
@@ -0,0 +1,224 @@
"""Which inference endpoints the product will and will not talk to.
The endpoint is where the whole campaign goes: prompts, narration, retrieved
memories, embedding inputs. `app/endpoints.py` is the rule that keeps that on
the user's own infrastructure, and these tests are what stops the rule quietly
widening.
Nothing here touches the network. Hostnames are resolved through a stub, so the
tests are about the *policy*, run identically offline, and cannot be made to
pass or fail by whatever DNS the machine happens to have.
python -m pytest tests/test_endpoint_policy.py -v
"""
import ipaddress
import socket
import pytest
from fastapi import Depends
from fastapi.testclient import TestClient
from app import auth, endpoints, models
from app.database import Base, SessionLocal, engine, get_db
from app.main import app
from app.providers import OpenAICompatibleProvider, ProviderError
@pytest.fixture()
def resolves(monkeypatch):
"""Points every hostname at addresses the test names."""
table: dict[str, list[str]] = {}
def fake_getaddrinfo(host, port, *a, **k):
# An address literal resolves to itself, as the real resolver does.
try:
ipaddress.ip_address(host)
except ValueError:
if host not in table:
raise socket.gaierror(-2, "Name or service not known")
answers = table[host]
else:
answers = [host]
return [
(
socket.AF_INET6 if ":" in ip else socket.AF_INET,
socket.SOCK_STREAM, 6, "", (ip, port or 0),
)
for ip in answers
]
monkeypatch.setattr(socket, "getaddrinfo", fake_getaddrinfo)
return table
# --- allowed: the user's own machine, and the user's own network ----------
@pytest.mark.parametrize("url", [
"http://127.0.0.1:11434/v1",
"http://[::1]:11434/v1",
"http://192.168.1.50:11434/v1", # RFC1918
"http://10.0.0.7:11434/v1", # RFC1918
"http://172.16.4.4:11434/v1", # RFC1918
"https://192.168.1.50:8443/v1", # TLS on the LAN
"http://100.100.5.6:11434/v1", # CGNAT, which is what a mesh VPN hands out
"http://[fd00::1]:11434/v1", # IPv6 unique-local
])
def test_local_and_lan_addresses_are_allowed(url, resolves):
assert endpoints.rejection_reason(url) is None
def test_a_hostname_resolving_to_the_lan_is_allowed(resolves):
resolves["ollama.home.arpa"] = ["192.168.1.50"]
assert endpoints.rejection_reason("https://ollama.home.arpa:8443/v1") is None
# --- refused: anywhere else ----------------------------------------------
@pytest.mark.parametrize("url", [
"http://8.8.8.8:11434/v1",
"http://1.1.1.1:11434/v1",
"http://[2001:4860:4860::8888]:11434/v1",
])
def test_public_addresses_are_refused(url, resolves):
reason = endpoints.rejection_reason(url)
assert reason is not None
assert "public Internet address" in reason
@pytest.mark.parametrize("url", [
"https://198.51.100.9/v1", # TEST-NET-2, a documentation range
"http://0.0.0.0:11434/v1", # "this host", not an address to dial
])
def test_addresses_that_are_neither_local_nor_public_are_refused(url, resolves):
"""Deny by default. `ipaddress` calls both of these "private", which is why
the policy names the networks it allows instead of asking that question."""
assert endpoints.rejection_reason(url) is not None
def test_a_hostname_resolving_to_the_public_internet_is_refused(resolves):
resolves["ollama.example.com"] = ["93.184.216.34"]
reason = endpoints.rejection_reason("https://ollama.example.com/v1")
assert reason is not None and "public Internet address" in reason
def test_a_split_horizon_answer_is_refused(resolves):
"""One public address among the answers is enough. A name that resolves to
both a LAN address and a public one must not be usable: which one the
request actually reaches is not ours to decide."""
resolves["sneaky.example"] = ["192.168.1.50", "93.184.216.34"]
assert endpoints.rejection_reason("http://sneaky.example:11434/v1") is not None
@pytest.mark.parametrize("host", [
"openrouter.ai", "api.openai.com", "api.anthropic.com", "api.groq.com",
])
def test_known_cloud_providers_are_named_in_the_refusal(host, resolves):
"""They would be refused by address anyway — every one resolves publicly.
Naming them turns 'your DNS might be broken' into 'this build has no cloud
provider support'."""
resolves[host] = ["192.168.1.50"] # even if DNS said otherwise
reason = endpoints.rejection_reason(f"https://{host}/v1")
assert reason is not None
assert "cloud inference service" in reason
def test_a_subdomain_of_a_cloud_provider_is_refused(resolves):
resolves["eu.api.openai.com"] = ["192.168.1.50"]
assert endpoints.rejection_reason("https://eu.api.openai.com/v1") is not None
@pytest.mark.parametrize("url", [
"ftp://127.0.0.1/v1",
"file:///etc/passwd",
"127.0.0.1:11434", # no scheme
"",
"http://",
])
def test_things_that_are_not_usable_urls_are_refused(url, resolves):
assert endpoints.rejection_reason(url) is not None
def test_an_unresolvable_host_is_refused_with_advice(resolves):
reason = endpoints.rejection_reason("http://nope.invalid:11434/v1")
assert reason is not None and "could not be resolved" in reason
# --- the rule is applied, not merely available ---------------------------
@pytest.fixture()
def client(monkeypatch):
Base.metadata.create_all(bind=engine)
setup = SessionLocal()
user = models.User(is_guest=False)
setup.add(user)
setup.flush()
setup.add(models.Settings(user_id=user.id, model="test-model"))
setup.commit()
user_id = user.id
setup.close()
def _current_user(db=Depends(get_db)):
return db.get(models.User, user_id)
app.dependency_overrides[auth.get_current_user] = _current_user
c = TestClient(app)
try:
yield c
finally:
app.dependency_overrides.clear()
Base.metadata.drop_all(bind=engine)
def test_saving_a_public_endpoint_is_refused(client, resolves):
resolves["openrouter.ai"] = ["93.184.216.34"]
r = client.put("/api/settings", json={"endpoint_url": "https://openrouter.ai/api/v1"})
assert r.status_code == 400, r.text
assert "can't be used" in r.json()["detail"]
# And it was not written.
assert client.get("/api/settings").json()["endpoint_url"] != "https://openrouter.ai/api/v1"
def test_saving_a_lan_endpoint_is_allowed(client, resolves):
r = client.put("/api/settings", json={"endpoint_url": "http://192.168.1.50:11434/v1"})
assert r.status_code == 200, r.text
assert client.get("/api/settings").json()["endpoint_url"] == "http://192.168.1.50:11434/v1"
def test_the_connection_test_reports_a_refused_endpoint_as_such(client, resolves):
"""A row edited by hand, or a name that has started resolving elsewhere,
must not simply look 'unreachable'."""
db = SessionLocal()
try:
db.query(models.Settings).first().endpoint_url = "https://openrouter.ai/api/v1"
db.commit()
finally:
db.close()
resolves["openrouter.ai"] = ["93.184.216.34"]
body = client.post("/api/settings/test").json()
assert body["ok"] is False
assert body["kind"] == "rejected"
@pytest.mark.anyio
async def test_the_provider_refuses_before_it_sends_anything(resolves):
"""The check that actually matters. Whatever is stored, no request leaves
for an address outside the policy — so a database edited behind the app's
back cannot turn into an exfiltration path."""
resolves["openrouter.ai"] = ["93.184.216.34"]
provider = OpenAICompatibleProvider("https://openrouter.ai/api/v1", "m")
with pytest.raises(ProviderError) as exc:
async for _ in provider.generate(
_parts(), temperature=0.8, max_tokens=10
):
pass
assert "can't be used" in str(exc.value)
def _parts():
from app.providers.base import PromptParts
return PromptParts(system="s", story="t")
@pytest.fixture
def anyio_backend():
return "asyncio"
-205
View File
@@ -1,205 +0,0 @@
"""Guest retention policy: app/cleanup.py.
These tests cover the two things that matter. Idle guests and their whole
data graph must actually be deleted, and nothing else must ever be
deleted.
python -m pytest tests/test_guest_cleanup.py -v
"""
from datetime import timedelta
import pytest
from sqlalchemy import create_engine, event
from sqlalchemy.orm import sessionmaker
from app import cleanup, models
from app.database import Base
from app.migrations import bootstrap
@pytest.fixture()
def db(tmp_path):
engine = create_engine(f"sqlite:///{tmp_path/'t.db'}", connect_args={"check_same_thread": False})
@event.listens_for(engine, "connect")
def _fk(dbapi_connection, _record):
# The whole policy relies on ON DELETE CASCADE. SQLite ignores every
# one of them unless this is set, the same as database.py does.
cur = dbapi_connection.cursor()
cur.execute("PRAGMA foreign_keys=ON")
cur.close()
bootstrap(engine)
Base.metadata.create_all(bind=engine)
session = sessionmaker(bind=engine, autoflush=False, expire_on_commit=False)()
yield session
session.close()
NOW = models.utcnow().replace(tzinfo=None)
def make_user(db, *, days_idle=None, days_old=0, guest=True, email=None):
"""A user last seen `days_idle` ago (None = never seen, only created)."""
user = models.User(
is_guest=guest,
email=email,
password_hash=None if email is None else "x",
created_at=NOW - timedelta(days=days_old),
last_seen_at=None if days_idle is None else NOW - timedelta(days=days_idle),
)
db.add(user)
db.commit()
return user
def sweep(db):
return cleanup.delete_stale_guests(db, now=NOW)
def alive(db, user_id):
# A count, not db.get: the sweep deletes with synchronize_session=False, so
# the session's identity map still holds the object and db.get would answer
# from memory without ever asking the database.
return db.query(models.User).filter(models.User.id == user_id).count() == 1
# ---------- what goes ----------
def test_deletes_guest_idle_past_the_window(db):
user = make_user(db, days_idle=6)
assert sweep(db) == 1
assert not alive(db, user.id)
def test_keeps_guest_inside_the_window(db):
user = make_user(db, days_idle=4)
assert sweep(db) == 0
assert alive(db, user.id)
def test_boundary_is_not_yet_stale(db):
# Exactly 5 days survives. The comparison is strict.
user = make_user(db, days_idle=cleanup.RETENTION_DAYS)
assert sweep(db) == 0
assert alive(db, user.id)
def test_never_seen_guest_falls_back_to_created_at(db):
"""last_seen_at is NULL until a guest's second request (auth._touch runs
hourly), so a coalesce-less query would delete brand-new visitors."""
fresh = make_user(db, days_idle=None, days_old=0)
stale = make_user(db, days_idle=None, days_old=9)
assert sweep(db) == 1
assert alive(db, fresh.id)
assert not alive(db, stale.id)
def test_recent_visit_beats_an_old_created_at(db):
# A long-standing guest who came back yesterday stays.
user = make_user(db, days_idle=1, days_old=90)
assert sweep(db) == 0
assert alive(db, user.id)
# ---------- what must never go ----------
def test_spares_registered_users(db):
"""Registering upgrades the guest row in place. An idle account here is
a real user with real data, which is what signing up is meant to
protect."""
user = make_user(db, days_idle=400, guest=False, email="a@b.com")
assert sweep(db) == 0
assert alive(db, user.id)
def test_spares_the_local_mode_user(db):
# email NULL but is_guest False: local mode's implicit owner of everything.
user = make_user(db, days_idle=400, guest=False)
assert sweep(db) == 0
assert alive(db, user.id)
def test_spares_a_guest_flagged_row_that_has_an_email(db):
# This row should not exist, but both clauses are checked so it cannot be collected.
user = make_user(db, days_idle=400, guest=True, email="odd@b.com")
assert sweep(db) == 0
assert alive(db, user.id)
def test_leaves_seeded_public_scenarios_alone(db):
"""Seeded demo content has user_id NULL, so it is outside the filter."""
seeded = models.Scenario(user_id=None, is_public=True, title="Demo")
db.add(seeded)
make_user(db, days_idle=30)
db.commit()
assert sweep(db) == 1
assert db.query(models.Scenario).filter(models.Scenario.id == seeded.id).count() == 1
def test_disabled_when_retention_is_zero(db, monkeypatch):
monkeypatch.setattr(cleanup, "RETENTION_DAYS", 0)
user = make_user(db, days_idle=999)
assert sweep(db) == 0
assert alive(db, user.id)
def test_enabled_requires_multi_user(monkeypatch):
from app import auth
monkeypatch.setattr(auth, "MULTI_USER", False)
assert cleanup.enabled() is False
monkeypatch.setattr(auth, "MULTI_USER", True)
monkeypatch.setattr(cleanup, "RETENTION_DAYS", 5)
assert cleanup.enabled() is True
monkeypatch.setattr(cleanup, "RETENTION_DAYS", 0)
assert cleanup.enabled() is False
# ---------- the cascade ----------
def test_deletes_the_whole_data_graph(db):
"""One DELETE must remove the adventure, its actions and memories, the
story cards, and the settings row. Nothing is loaded into Python, so if
the FK cascade does not reach a table, its rows are silently orphaned,
or the statement fails, instead of being removed."""
user = make_user(db, days_idle=30)
scenario = models.Scenario(user_id=user.id, title="S")
db.add(scenario)
db.commit()
adventure = models.Adventure(user_id=user.id, scenario_id=scenario.id, title="A")
db.add(adventure)
db.commit()
db.add_all([
models.Action(adventure_id=adventure.id, type="ai", text="t"),
models.Memory(adventure_id=adventure.id, text="m", source_start=0, source_end=0),
models.StoryCard(adventure_id=adventure.id, name="c"),
models.Settings(user_id=user.id),
])
db.commit()
assert sweep(db) == 1
for model in (models.Scenario, models.Adventure, models.Action,
models.Memory, models.StoryCard, models.Settings):
assert db.query(model).count() == 0, f"{model.__name__} rows survived"
def test_one_users_cleanup_does_not_touch_another(db):
keeper = make_user(db, days_idle=1)
keep_adv = models.Adventure(user_id=keeper.id, title="mine")
goner = make_user(db, days_idle=30)
db.add_all([keep_adv, models.Adventure(user_id=goner.id, title="theirs")])
db.commit()
assert sweep(db) == 1
remaining = db.query(models.Adventure).all()
assert [a.title for a in remaining] == ["mine"]
def test_sweep_swallows_errors(monkeypatch):
"""A broken cleanup must not take the app down (same rule as seeding)."""
monkeypatch.setattr(cleanup.auth, "MULTI_USER", True)
monkeypatch.setattr(cleanup, "delete_stale_guests",
lambda *a, **k: (_ for _ in ()).throw(RuntimeError("boom")))
assert cleanup.sweep() == 0
+181
View File
@@ -0,0 +1,181 @@
"""What M2 removed stays removed, and what it must not break stays working.
A subtractive milestone needs tests that fail if the surface grows back. These
are cheap, blunt, and deliberately not clever: they assert against the running
app's route table, the shipped configuration files, and the settings API.
python -m pytest tests/test_local_only_surface.py -v
"""
import re
from pathlib import Path
import pytest
from fastapi import Depends
from fastapi.testclient import TestClient
from app import auth, models
from app.database import Base, SessionLocal, engine, get_db
from app.main import app
from app.providers.openai_compatible import (
CONNECT_TIMEOUT, DEFAULT_READ_TIMEOUT, OpenAICompatibleProvider,
)
REPO = Path(__file__).resolve().parents[2]
def _paths() -> set[str]:
"""Every path the app serves, including those inside included routers."""
found = set()
def walk(routes):
for r in routes:
path = getattr(r, "path", None)
if path:
found.add(path)
walk(getattr(r, "routes", []) or [])
walk(app.routes)
return found
@pytest.fixture()
def client():
Base.metadata.create_all(bind=engine)
setup = SessionLocal()
user = models.User(is_guest=False)
setup.add(user)
setup.flush()
setup.add(models.Settings(user_id=user.id, model="test-model"))
setup.commit()
user_id = user.id
setup.close()
def _current_user(db=Depends(get_db)):
return db.get(models.User, user_id)
app.dependency_overrides[auth.get_current_user] = _current_user
c = TestClient(app)
try:
yield c
finally:
app.dependency_overrides.clear()
Base.metadata.drop_all(bind=engine)
# --- the removed surfaces ------------------------------------------------
@pytest.mark.parametrize("prefix", ["/api/auth", "/api/analytics", "/api/scripts"])
def test_no_route_serves_a_removed_subsystem(prefix):
"""Accounts, the visitor dashboard, and campaign scripting are gone as
routes, not merely hidden behind a flag."""
assert not [p for p in _paths() if p.startswith(prefix)], prefix
@pytest.mark.parametrize("path", [
"/api/auth/me", "/api/auth/login", "/api/auth/register", "/api/auth/logout",
"/api/analytics/summary", "/api/analytics/collect", "/api/analytics/access",
"/api/scripts", "/api/adventures/1/scripts", "/api/adventures/1/script-state",
])
def test_a_removed_endpoint_answers_404(client, path):
assert client.get(path).status_code == 404, path
def test_the_application_has_no_scripting_engine():
with pytest.raises(ImportError):
__import__("app.scripting")
def test_no_module_imports_quickjs():
"""The dependency is gone from requirements; this catches an import that
would put it back."""
for py in (REPO / "backend" / "app").rglob("*.py"):
assert "import quickjs" not in py.read_text(), py
def test_requirements_carry_no_hosted_dependencies():
text = (REPO / "backend" / "requirements.txt").read_text()
for gone in ("quickjs", "psycopg", "cryptography"):
assert gone not in text, gone
def test_no_render_deployment_config():
assert not (REPO / "render.yaml").exists()
# --- no cloud provider, no key ------------------------------------------
def test_settings_expose_no_api_key_field(client):
body = client.get("/api/settings").json()
assert "api_key" not in body
assert "has_api_key" not in body
def test_an_api_key_cannot_be_set_through_the_api(client):
"""Pydantic ignores unknown fields, so this asserts the value does not
land rather than that the request is refused."""
client.put("/api/settings", json={"api_key": "sk-should-not-stick"})
db = SessionLocal()
try:
assert db.query(models.Settings).first().api_key == ""
finally:
db.close()
def test_the_provider_sends_no_authorization_header():
provider = OpenAICompatibleProvider("http://127.0.0.1:11434/v1", "m")
assert "Authorization" not in provider._headers()
# --- the model timeout ---------------------------------------------------
def test_the_default_timeout_is_generous_but_finite():
"""M1 measured a cold model load exceeding the inherited hardcoded 120s on
a CPU-only host. It must be longer than that, and it must be a number."""
assert DEFAULT_READ_TIMEOUT > 120
assert DEFAULT_READ_TIMEOUT <= 3600
def test_connect_stays_short_while_reading_stays_patient():
"""A wrong address should fail in seconds; a loading model should not."""
provider = OpenAICompatibleProvider("http://127.0.0.1:11434/v1", "m")
timeout = provider._timeout()
assert timeout.connect == CONNECT_TIMEOUT <= 30
assert timeout.read == DEFAULT_READ_TIMEOUT
def test_the_timeout_is_configurable(client):
r = client.put("/api/settings", json={"model_timeout_seconds": 900})
assert r.status_code == 200, r.text
assert client.get("/api/settings").json()["model_timeout_seconds"] == 900
@pytest.mark.parametrize("value", [0, 29, 3601, -1])
def test_an_unusable_timeout_is_refused(client, value):
"""Not zero, not negative, and not "wait forever" spelled as a big number."""
assert client.put(
"/api/settings", json={"model_timeout_seconds": value}
).status_code == 422
def test_the_provider_honours_the_configured_timeout():
provider = OpenAICompatibleProvider("http://127.0.0.1:11434/v1", "m", read_timeout=45)
assert provider._timeout().read == 45
# --- the storyteller stays on loopback -----------------------------------
def test_the_native_start_scripts_bind_loopback():
for script in ("start.sh", "start.ps1"):
text = (REPO / script).read_text(errors="ignore")
assert "--host 127.0.0.1" in text, script
assert "--host 0.0.0.0" not in text, script
def test_compose_publishes_to_loopback_only():
"""The container listens on 0.0.0.0 because a published port cannot reach
anything else. What must stay loopback is the *host* side of the mapping."""
text = (REPO / "docker-compose.yml").read_text()
published = re.findall(r'^\s*-\s*"([^"]+)"', text, re.M)
assert published, "no published ports found — has the file moved?"
for mapping in published:
assert mapping.startswith("127.0.0.1:"), mapping
+24 -27
View File
@@ -128,7 +128,7 @@ def fill_bank(db, adventure, *, count=2):
def options(**overrides):
args = dict(write=False, adventure=None, email=None, limit=None,
include_forgotten=False, embed=False, endpoint=None,
model=None, api_key=None)
model=None)
args.update(overrides)
return argparse.Namespace(**args)
@@ -248,23 +248,6 @@ def test_the_rewrite_prompt_is_the_one_the_app_sends(db):
# --------------------------------------------------------------------- the tool
def test_the_database_line_carries_no_password():
"""The report names the database it is about to rewrite. That line ends up
in a console, a screenshot or a pasted bug report."""
shown = rewrite_memories.safe_dsn(
"postgresql://parth:hunter2@ep-cool-frost.us-east-1.aws.neon.tech/aidnd"
"?sslmode=require")
assert "hunter2" not in shown
assert "sslmode" not in shown # a password can be passed there too
assert shown == ("postgresql://parth@ep-cool-frost.us-east-1.aws.neon.tech"
"/aidnd")
def test_an_unparseable_database_url_shows_nothing_at_all():
assert rewrite_memories.safe_dsn("not-a-url") == "(configured)"
def test_without_write_nothing_changes(db, monkeypatch):
adventure = make_adventure(db)
first, _ = fill_bank(db, adventure)
@@ -316,9 +299,12 @@ def test_a_hand_written_memory_is_left_alone(db, monkeypatch):
assert typed.text == "Kaelen owes a debt."
def test_an_owner_with_no_api_key_is_skipped(db, monkeypatch):
"""Summarization spends the user's own key and never the shared demo key."""
adventure = make_adventure(db, key="")
def test_an_adventure_with_no_model_configured_is_skipped(db, monkeypatch):
"""Nothing to summarize with, so the memory is reported rather than
silently left as it was. This used to be about the owner's API key; after
M2 the thing that can be missing is the model name."""
adventure = make_adventure(db)
_blank_model(db, adventure)
first, _ = fill_bank(db, adventure)
stub = StubSummarizer()
monkeypatch.setattr(memorybank, "summary_provider", lambda s: stub)
@@ -328,10 +314,10 @@ def test_an_owner_with_no_api_key_is_skipped(db, monkeypatch):
assert first.text == "You entered the crypt 1."
def test_an_api_key_on_the_command_line_covers_that_owner(db, monkeypatch):
"""The way to summarize for an owner who has no key of their own, and the
way to point a run at the local Claude shim instead of a paid endpoint."""
adventure = make_adventure(db, key="")
def test_a_model_on_the_command_line_covers_that_adventure(db, monkeypatch):
"""How to run the rewrite against a model other than the configured one."""
adventure = make_adventure(db)
_blank_model(db, adventure)
first, _ = fill_bank(db, adventure)
built: list[tuple] = []
@@ -340,11 +326,22 @@ def test_an_api_key_on_the_command_line_covers_that_owner(db, monkeypatch):
return StubSummarizer()
monkeypatch.setattr("app.providers.OpenAICompatibleProvider", build)
assert run_tool(options(write=True, api_key="sk-cli", model="sonnet",
assert run_tool(options(write=True, model="other-model",
endpoint="http://127.0.0.1:8787/v1")) == 0
db.expire_all()
assert first.text == "Kaelen entered the crypt 1."
assert built[0][:3] == ("http://127.0.0.1:8787/v1", "sk-cli", "sonnet")
assert built[0][:2] == ("http://127.0.0.1:8787/v1", "other-model")
def _blank_model(db, adventure) -> None:
settings = (
db.query(models.Settings)
.filter(models.Settings.user_id == adventure.user_id)
.first()
)
settings.model = ""
settings.summary_model = ""
db.commit()
def test_limit_stops_early(db, monkeypatch):
-60
View File
@@ -1,60 +0,0 @@
"""Tests for the SSRF guard on the user-supplied BYOK endpoint_url.
python -m pytest tests/test_netguard.py -v
"""
import pytest
from app import auth, netguard
@pytest.fixture
def hosted(monkeypatch):
monkeypatch.setattr(auth, "MULTI_USER", True)
def _resolves_to(monkeypatch, ip: str):
"""Pin `getaddrinfo` so the test exercises the address decision, not real DNS."""
monkeypatch.setattr(
netguard.socket, "getaddrinfo",
lambda *a, **k: [(2, 1, 6, "", (ip, 443))],
)
@pytest.mark.parametrize("ip", [
"127.0.0.1", # loopback
"169.254.169.254", # cloud metadata (link-local)
"10.0.0.5", # RFC1918
"192.168.1.1", # RFC1918
"172.16.0.9", # RFC1918
"0.0.0.0", # unspecified
"100.64.0.1", # carrier-grade NAT
"::1", # IPv6 loopback
"fd00::1", # IPv6 unique-local
])
def test_blocks_non_public_addresses(hosted, monkeypatch, ip):
_resolves_to(monkeypatch, ip)
assert netguard.endpoint_block_reason("https://evil.example.com/v1") is not None
def test_allows_public_address(hosted, monkeypatch):
_resolves_to(monkeypatch, "104.18.0.1") # a public IP
assert netguard.endpoint_block_reason("https://openrouter.ai/api/v1") is None
def test_rejects_non_http_scheme(hosted):
assert netguard.endpoint_block_reason("file:///etc/passwd") is not None
assert netguard.endpoint_block_reason("gopher://x/") is not None
def test_unresolvable_host_is_blocked(hosted, monkeypatch):
def boom(*a, **k):
raise netguard.socket.gaierror("no such host")
monkeypatch.setattr(netguard.socket, "getaddrinfo", boom)
assert netguard.endpoint_block_reason("https://nope.invalid/v1") is not None
def test_noop_in_local_mode(monkeypatch):
monkeypatch.setattr(auth, "MULTI_USER", False)
# Local installs must reach localhost (Ollama). The guard never blocks local mode.
assert netguard.endpoint_block_reason("http://localhost:11434/v1") is None
assert netguard.endpoint_block_reason("http://127.0.0.1:11434/v1") is None
-1
View File
@@ -275,7 +275,6 @@ def client(monkeypatch):
user_id = user.id
setup.close()
monkeypatch.setattr(limits, "rate_limit", lambda *a, **k: None)
monkeypatch.setattr(limits, "check_row_cap", lambda *a, **k: None)
def _current_user(db=Depends(get_db)):
+6 -45
View File
@@ -17,10 +17,10 @@ Three things must hold, and each is easy to undo by accident:
* Moving a section out of the system block does not drop it from the
token budget. It is still in the prompt.
This file also covers two request-level concerns: preferring one
OpenRouter upstream, since each upstream holds its own cache, and reading
back the usage the endpoint reports, so the hit rate is measurable rather
than assumed.
This file also covers one request-level concern: reading back the usage
the endpoint reports, so the hit rate is measurable rather than assumed.
It covered OpenRouter upstream pinning too, until M2 removed cloud
provider support.
python -m pytest tests/test_prompt_caching.py -v
"""
@@ -40,50 +40,11 @@ SCHEMA = {
}
# ------------------------------------------------- preferring one upstream
def _routed(endpoint, model):
provider = OpenAICompatibleProvider(endpoint, "k", model, "chat", 0)
body = {"max_tokens": 100}
provider._apply_provider_routing(body)
return body
def test_openrouter_deepseek_pins_the_upstream():
"""Each upstream has its own cache, so routing has to be deterministic."""
body = _routed("https://openrouter.ai/api/v1", "deepseek/deepseek-v4-flash-0731")
assert body["provider"] == {"order": ["deepseek"]}
def test_fallbacks_stay_on():
"""A preference, not a restriction: if deepseek is down the turn still runs
somewhere else and merely misses the cache."""
body = _routed("https://openrouter.ai/api/v1", "deepseek/deepseek-v4-flash-0731")
assert "allow_fallbacks" not in body["provider"]
def test_non_openrouter_endpoints_get_no_provider_field():
"""Ollama and other providers reject fields they do not know. This is
the same problem the `reasoning` param works around."""
body = _routed("http://localhost:11434/v1", "deepseek/deepseek-v4-flash-0731")
assert "provider" not in body
def test_unknown_vendors_are_left_alone():
"""The vendor half of a slug is not reliably a provider slug: Google's
models are served by "google-ai-studio", and there is no "google". A guess
would be a routing preference naming an upstream that does not exist."""
body = _routed("https://openrouter.ai/api/v1", "google/gemma-4-26b-a4b-it:free")
assert "provider" not in body
# ------------------------------------------------------ reading usage back
def test_usage_is_recorded_from_a_final_chunk():
"""In a stream, the usage block arrives in a final chunk that carries
no choices, which is why it is read separately from the text
extraction."""
provider = OpenAICompatibleProvider("https://openrouter.ai/api/v1", "k", "m")
provider = OpenAICompatibleProvider("http://127.0.0.1:11434/v1", "m")
assert provider.last_usage is None
provider._record_usage({"choices": [{"delta": {"content": "hi"}}]})
assert provider.last_usage is None
@@ -95,7 +56,7 @@ def test_usage_is_recorded_from_a_final_chunk():
def test_a_later_chunk_without_usage_does_not_erase_it():
provider = OpenAICompatibleProvider("https://openrouter.ai/api/v1", "k", "m")
provider = OpenAICompatibleProvider("http://127.0.0.1:11434/v1", "m")
provider._record_usage({"usage": {"prompt_tokens": 5}})
provider._record_usage({"choices": [{"delta": {"content": "x"}}]})
provider._record_usage({"usage": {}})
-99
View File
@@ -1,99 +0,0 @@
"""Regression tests for the X-Forwarded-For rate-limit bypass and the
per-account login throttle added to close it.
Background: uvicorn's `--forwarded-allow-ips "*"` trusted the leftmost
`X-Forwarded-For` entry. The client controls that entry, so rotating the
header issued a fresh rate-limit bucket on every request. `client_ip` now
reads the hop the trusted edge appends, which is the rightmost one. Login
also has an email-keyed throttle that no IP trick can weaken.
python -m pytest tests/test_ratelimit_hardening.py -v
"""
import pytest
from app import auth, limits
class _Req:
"""Minimal stand-in for starlette's Request: a header lookup and a peer."""
def __init__(self, xff: str | None, peer: str | None = "10.0.0.1"):
self.headers = {} if xff is None else {"x-forwarded-for": xff}
self.client = None if peer is None else type("C", (), {"host": peer})()
# ---------- client_ip: the spoof-resistant hop ----------
def test_client_ip_takes_appended_rightmost_hop(monkeypatch):
monkeypatch.setattr(limits, "TRUSTED_PROXY_HOPS", 1)
# An attacker prepends a fake IP. The edge appends the real one on the right.
req = _Req("203.0.113.9, 198.51.100.77")
assert limits.client_ip(req) == "198.51.100.77"
def test_client_ip_ignores_spoofed_leftmost(monkeypatch):
monkeypatch.setattr(limits, "TRUSTED_PROXY_HOPS", 1)
# The keyed IP stays the real hop regardless of what the client adds on
# the left, so rotating that value no longer creates a new bucket.
a = limits.client_ip(_Req("1.1.1.1, 198.51.100.77"))
b = limits.client_ip(_Req("2.2.2.2, 198.51.100.77"))
c = limits.client_ip(_Req("evil, junk, 198.51.100.77"))
assert a == b == c == "198.51.100.77"
def test_client_ip_honours_extra_trusted_hops(monkeypatch):
monkeypatch.setattr(limits, "TRUSTED_PROXY_HOPS", 2)
# Two trusted hops: real client is second from the right.
req = _Req("9.9.9.9, 203.0.113.5, 198.51.100.77")
assert limits.client_ip(req) == "203.0.113.5"
def test_client_ip_falls_back_to_socket_peer():
assert limits.client_ip(_Req(None, peer="172.16.0.4")) == "172.16.0.4"
assert limits.client_ip(_Req(None, peer=None)) == "unknown"
# ---------- per-account login throttle ----------
@pytest.fixture(autouse=True)
def _multi_user(monkeypatch):
monkeypatch.setattr(auth, "MULTI_USER", True)
# Isolate the module-level failure map for each test.
from collections import defaultdict, deque
monkeypatch.setattr(limits, "_login_fails", defaultdict(deque))
def test_login_throttle_blocks_after_limit():
email = "victim@example.com"
# Each attempt up to the limit is allowed and recorded as a failure.
for _ in range(limits.LOGIN_FAIL_LIMIT):
limits.check_login_allowed(email) # does not raise
limits.note_login_failure(email)
# One more failure exceeds the limit.
with pytest.raises(limits.HTTPException) as exc:
limits.check_login_allowed(email)
assert exc.value.status_code == 429
def test_login_throttle_is_per_account():
for _ in range(limits.LOGIN_FAIL_LIMIT):
limits.note_login_failure("a@example.com")
with pytest.raises(limits.HTTPException):
limits.check_login_allowed("a@example.com")
# A different account is unaffected because the throttle keys on email, not IP.
limits.check_login_allowed("b@example.com") # must not raise
def test_successful_login_clears_the_streak():
email = "typo@example.com"
for _ in range(limits.LOGIN_FAIL_LIMIT):
limits.note_login_failure(email)
limits.note_login_success(email)
limits.check_login_allowed(email) # failure streak cleared, must not raise
def test_throttle_is_noop_in_local_mode(monkeypatch):
monkeypatch.setattr(auth, "MULTI_USER", False)
for _ in range(limits.LOGIN_FAIL_LIMIT * 3):
limits.note_login_failure("solo@example.com")
limits.check_login_allowed("solo@example.com") # never throttled locally
-45
View File
@@ -1,45 +0,0 @@
"""What the provider puts in the `reasoning` request field for each budget
setting: a positive budget asks for thinking, 0 stays silent, -1 turns it off.
python -m pytest tests/test_reasoning_param.py -v
"""
from app.providers.openai_compatible import OpenAICompatibleProvider
def _body(reasoning_max_tokens, api_mode="chat", max_tokens=1000):
provider = OpenAICompatibleProvider(
"https://openrouter.ai/api/v1", "k", "deepseek/deepseek-v4-flash-0731",
api_mode, reasoning_max_tokens,
)
body = {"max_tokens": max_tokens}
provider._apply_reasoning_budget(body)
return body
def test_zero_sends_nothing():
"""Ollama and other providers reject unknown fields. Sending 0 must not add a `reasoning` field."""
assert "reasoning" not in _body(0)
def test_positive_budget_adds_thinking_tokens():
body = _body(500)
assert body["reasoning"] == {"max_tokens": 500}
# the story output keeps its own full budget on top of the thinking budget
assert body["max_tokens"] == 1500
def test_negative_turns_reasoning_off():
body = _body(-1)
assert body["reasoning"] == {"effort": "none"}
# "off" must not inflate the output budget
assert body["max_tokens"] == 1000
def test_off_is_not_merely_excluded():
"""`exclude: true` still generates and bills for reasoning tokens. The off setting must omit the field entirely instead of relying on `exclude`."""
assert _body(-1)["reasoning"].get("exclude") is None
def test_completion_mode_never_sends_reasoning():
for budget in (-1, 0, 500):
assert "reasoning" not in _body(budget, api_mode="completion")
+15 -28
View File
@@ -14,18 +14,11 @@ from app.main import app
from app.providers import ProviderError
from app.routers import adventures
from fakes import ScriptedProvider
from fakes import GOLD_SCHEMA, ScriptedProvider, gold_replies, gold_reply
SCHEMA = {"player": {"hp": {"min": 0, "max": 100, "initial": 100}}}
SCHEMA = GOLD_SCHEMA
# Each turn spends 10 gold, so a double-applied or un-rolled-back attempt shows.
GOLD_SCRIPT = """
const modifier = (text) => {
state.gold = (state.gold || 0) + 10;
return { text };
};
modifier(text);
"""
@pytest.fixture()
@@ -41,14 +34,11 @@ def client(monkeypatch):
setup.flush()
adv = models.Adventure(
user_id=user.id, title="Cave", scenario_id=scenario.id,
script_state={}, world_state={"player": {"hp": 100}},
world_state={"player": {"hp": 100, "gold": 0}},
)
setup.add(adv)
setup.flush()
setup.add(models.Action(adventure_id=adv.id, type="start", text="You enter a cave."))
setup.add(models.AdventureScript(
adventure_id=adv.id, position=0, enabled=True, name="Gold", output_js=GOLD_SCRIPT,
))
setup.commit()
adv_id, user_id = adv.id, user.id
setup.close()
@@ -57,9 +47,6 @@ def client(monkeypatch):
ScriptedProvider.calls = 0
ScriptedProvider.prompts = []
monkeypatch.setattr(adventures.turns, "OpenAICompatibleProvider", ScriptedProvider)
monkeypatch.setattr(auth, "resolve_provider_config", lambda s: auth.ProviderConfig(
"http://fake", "k", "test-model", False))
monkeypatch.setattr(limits, "rate_limit", lambda *a, **k: None)
monkeypatch.setattr(limits, "check_row_cap", lambda *a, **k: None)
def _current_user(db=Depends(get_db)):
@@ -80,7 +67,7 @@ def _adv(adv_id):
db = SessionLocal()
try:
adv = db.get(models.Adventure, adv_id)
return adv.script_state, adv.world_state
return (adv.world_state or {}).get("player", {}), adv.world_state
finally:
db.close()
@@ -177,15 +164,15 @@ def test_three_attempts_all_kept_in_order(client):
def test_switching_back_restores_that_attempt_state(client):
ScriptedProvider.replies = [
"You take a scratch.\n```state\n{\"player.hp\": -5}\n```",
"You take a beating.\n```state\n{\"player.hp\": -40}\n```",
'You take a scratch.\n```state\n{"player.hp": -5, "player.gold": 10}\n```',
'You take a beating.\n```state\n{"player.hp": -40, "player.gold": 10}\n```',
]
_play(client)
assert _adv(client.adv_id)[1]["player"]["hp"] == 95
_retry(client)
script_state, world_state = _adv(client.adv_id)
player, world_state = _adv(client.adv_id)
assert world_state["player"]["hp"] == 60
assert script_state == {"gold": 10} # rolled back, not stacked to 20
assert player["gold"] == 10 # rolled back, not stacked to 20
last = _actions(client)[-1]
r = client.post(
@@ -194,9 +181,9 @@ def test_switching_back_restores_that_attempt_state(client):
assert r.json()["text"].startswith("You take a scratch")
assert r.json()["take_index"] == 0
# The stats follow the narration back.
script_state, world_state = _adv(client.adv_id)
player, world_state = _adv(client.adv_id)
assert world_state["player"]["hp"] == 95
assert script_state == {"gold": 10}
assert player["gold"] == 10
# And forward again.
client.post(f"/api/adventures/{client.adv_id}/actions/{last['id']}/variant",
@@ -251,26 +238,26 @@ def test_switching_to_a_missing_index_is_rejected(client):
def test_failed_retry_leaves_the_previous_attempt_in_charge(client):
"""A provider error mid-retry must undo the rollback, or the stats on
screen would silently disagree with the text still shown."""
ScriptedProvider.replies = ["Attempt one.", ProviderError("upstream is down")]
ScriptedProvider.replies = [gold_reply("Attempt one."), ProviderError("upstream is down")]
_play(client)
assert _adv(client.adv_id)[0] == {"gold": 10}
assert _adv(client.adv_id)[0]["gold"] == 10
client.post(f"/api/adventures/{client.adv_id}/retry")
actions = _actions(client)
assert actions[-1]["text"] == "Attempt one." # text never lost
assert _adv(client.adv_id)[0] == {"gold": 10} # and the state still matches it
assert _adv(client.adv_id)[0]["gold"] == 10 # and the state still matches it
def test_undo_removes_the_action_and_its_history(client):
ScriptedProvider.replies = ["One.", "Two."]
ScriptedProvider.replies = [gold_reply(t) for t in ["One.", "Two."]]
_play(client)
_retry(client)
r = client.post(f"/api/adventures/{client.adv_id}/undo")
assert r.status_code == 200, r.text
# Undo returns the newest window now, not the whole story.
assert [a["type"] for a in r.json()["actions"]] == ["start"]
assert _adv(client.adv_id)[0] == {}
assert _adv(client.adv_id)[0]["gold"] == 0
def test_editing_the_text_updates_the_live_variant(client):
-1
View File
@@ -125,7 +125,6 @@ def client(monkeypatch):
user_id = user.id
setup.close()
monkeypatch.setattr(limits, "rate_limit", lambda *a, **k: None)
monkeypatch.setattr(limits, "check_row_cap", lambda *a, **k: None)
def _current_user(db=Depends(get_db)):
-1
View File
@@ -34,7 +34,6 @@ def client(monkeypatch):
user_id = user.id
setup.close()
monkeypatch.setattr(limits, "rate_limit", lambda *a, **k: None)
monkeypatch.setattr(limits, "check_row_cap", lambda *a, **k: None)
def _current_user(db=Depends(get_db)):
+22 -18
View File
@@ -1,6 +1,12 @@
"""Tests for undo and retry rolling back the shared `script_state`
"""Tests for undo and retry rolling back the shared `world_state`
(plan/11-state-revert-and-retry-fix.md).
The state being rolled back was the scripting engine's `script_state` until
M2 removed campaign scripting. The machinery under test — `attempts.restore_state`,
`roll_back_before`, and the per-node outcome snapshot — is unchanged; only the
column it moves has. `world_state`/`world_state_after` is now the shared state
an adventure carries, so that is what these tests exercise.
Phase 14 SP4 reversed the snapshots. An action used to carry the state as
it stood before it ran, and rolling back read the snapshot off the action
being removed. Now it carries the state it left behind, and rolling back
@@ -36,11 +42,11 @@ def db():
adventures.turns._active_turns.clear()
def _make_adventure(db, script_state):
def _make_adventure(db, world_state):
user = models.User(is_guest=False)
db.add(user)
db.flush()
adv = models.Adventure(user_id=user.id, title="T", script_state=script_state)
adv = models.Adventure(user_id=user.id, title="T", world_state=world_state)
db.add(adv)
db.flush()
return user, adv
@@ -49,7 +55,7 @@ def _make_adventure(db, script_state):
def _add(db, adv, index, type_, text="x", state_after=None):
a = models.Action(
adventure_id=adv.id, type=type_, text=text,
state_after=state_after,
world_state_after=state_after,
)
db.add(a)
db.flush()
@@ -72,7 +78,7 @@ def _forget_snapshots(db, adv):
# ---------------------------------------------------------------- undo
def test_undo_reverts_state_to_before_the_turn(db):
# A turn moved script_state from {gold:0} to {gold:10}. The node in
# A turn moved world_state from {gold:0} to {gold:10}. The node in
# front of the turn records where it started. The current state is
# the mutated one.
user, adv = _make_adventure(db, {"gold": 10})
@@ -83,7 +89,7 @@ def test_undo_reverts_state_to_before_the_turn(db):
adventures.undo_turn(adv.id, db=db, adventure=adv)
assert adv.script_state == {"gold": 0}
assert adv.world_state == {"gold": 0}
assert [a.type for a in adv.actions] == ["start"]
@@ -97,7 +103,7 @@ def test_undo_of_bare_continue_uses_the_node_in_front(db):
adventures.undo_turn(adv.id, db=db, adventure=adv)
assert adv.script_state == {"gold": 0}
assert adv.world_state == {"gold": 0}
assert [a.type for a in adv.actions] == ["start"]
@@ -112,7 +118,7 @@ def test_undo_leaves_state_untouched_when_snapshot_missing(db):
adventures.undo_turn(adv.id, db=db, adventure=adv)
assert adv.script_state == {"gold": 10}
assert adv.world_state == {"gold": 10}
def test_undo_raises_when_nothing_to_undo(db):
@@ -188,24 +194,24 @@ def test_snapshot_outcome_is_an_independent_deep_copy(db):
_, adv = _make_adventure(db, {"nested": {"n": 1}})
node = models.Action(adventure_id=adv.id, type="ai", text="x")
attempts.snapshot_outcome(adv, node)
adv.script_state["nested"]["n"] = 99
assert node.state_after == {"nested": {"n": 1}} # unaffected by later mutation
adv.world_state["nested"]["n"] = 99
assert node.world_state_after == {"nested": {"n": 1}} # unaffected by later mutation
def test_snapshot_outcome_handles_non_dict(db):
_, adv = _make_adventure(db, {})
adv.script_state = None
adv.world_state = None
node = models.Action(adventure_id=adv.id, type="ai", text="x")
attempts.snapshot_outcome(adv, node)
assert node.state_after == {}
assert node.world_state_after == {}
def test_restore_state_ignores_a_node_with_no_outcome(db):
_, adv = _make_adventure(db, {"gold": 7})
attempts.restore_state(adv, models.Action(adventure_id=adv.id, type="ai"))
assert adv.script_state == {"gold": 7}
assert adv.world_state == {"gold": 7}
attempts.restore_state(adv, None)
assert adv.script_state == {"gold": 7}
assert adv.world_state == {"gold": 7}
# ---------------------------------------------------------------- retry
@@ -220,8 +226,6 @@ def test_retry_restores_the_state_the_turn_started_from(db, monkeypatch):
_add(db, adv, 2, "ai", state_after={"gold": 20})
db.commit()
monkeypatch.setattr(adventures.limits, "rate_limit", lambda *a, **k: None)
monkeypatch.setattr(adventures.turns, "check_demo_cap", lambda *a, **k: None)
async def _noop(*a, **k):
if False:
@@ -230,12 +234,12 @@ def test_retry_restores_the_state_the_turn_started_from(db, monkeypatch):
adventures.retry_action(adv.id, request=None, db=db, user=user, adventure=adv)
assert adv.script_state == {"gold": 10}
assert adv.world_state == {"gold": 10}
# Nothing is written until a replacement actually arrives: the attempt on
# screen is left exactly as it was, and stays the live one.
assert [a.type for a in adv.actions] == ["start", "do", "ai"]
last = adv.actions[-1]
assert last.live is True
assert len(attempts.group(db, last)) == 1 # no sibling was filed
assert last.state_after == {"gold": 20} # its own outcome, untouched
assert last.world_state_after == {"gold": 20} # its own outcome, untouched
adventures.turns._active_turns.discard(adv.id)
+21 -34
View File
@@ -26,20 +26,13 @@ from app.database import Base, SessionLocal, engine, get_db
from app.main import app
from app.routers import adventures
from fakes import ScriptedProvider
from fakes import GOLD_SCHEMA, ScriptedProvider, gold_replies, gold_reply
# A world-state schema, so the RPG layer is exercised rather than skipped.
SCHEMA = {"player": {"hp": {"min": 0, "max": 100, "initial": 100}}}
SCHEMA = GOLD_SCHEMA
# Ten gold a turn. A turn that double-applies its hooks, or one that fails to
# roll back on retry, shows up here as a wrong total rather than as nothing.
GOLD_SCRIPT = """
const modifier = (text) => {
state.gold = (state.gold || 0) + 10;
return { text };
};
modifier(text);
"""
OPENING = "You enter a cave."
@@ -59,7 +52,7 @@ def _make_world(monkeypatch, *, seeded_actions: int = 0):
setup.flush()
adv = models.Adventure(
user_id=user.id, title="Cave", scenario_id=scenario.id,
script_state={}, world_state={"player": {"hp": 100}},
world_state={"player": {"hp": 100, "gold": 0}},
)
setup.add(adv)
setup.flush()
@@ -70,20 +63,14 @@ def _make_world(monkeypatch, *, seeded_actions: int = 0):
type="ai" if i % 2 else "do",
text=f"Seeded turn {i}.",
))
setup.add(models.AdventureScript(
adventure_id=adv.id, position=0, enabled=True, name="Gold", output_js=GOLD_SCRIPT,
))
setup.commit()
adv_id, user_id = adv.id, user.id
setup.close()
ScriptedProvider.replies = ["A reply."]
ScriptedProvider.replies = [gold_reply(t) for t in ["A reply."]]
ScriptedProvider.calls = 0
ScriptedProvider.prompts = []
monkeypatch.setattr(adventures.turns, "OpenAICompatibleProvider", ScriptedProvider)
monkeypatch.setattr(auth, "resolve_provider_config", lambda s: auth.ProviderConfig(
"http://fake", "k", "test-model", False))
monkeypatch.setattr(limits, "rate_limit", lambda *a, **k: None)
monkeypatch.setattr(limits, "check_row_cap", lambda *a, **k: None)
def _current_user(db=Depends(get_db)):
@@ -145,7 +132,7 @@ def _state(adv_id):
db = SessionLocal()
try:
adv = db.get(models.Adventure, adv_id)
return adv.script_state, adv.world_state
return (adv.world_state or {}).get("player", {}), adv.world_state
finally:
db.close()
@@ -172,7 +159,7 @@ def test_short_adventure_returns_everything(client):
# --------------------------------------------------------------- the turn
def test_a_turn_appends_the_player_action_then_the_ai_action(client):
ScriptedProvider.replies = ["The dark presses in."]
ScriptedProvider.replies = [gold_reply(t) for t in ["The dark presses in."]]
_play(client, "light a torch")
actions = _actions(client)
assert [a["type"] for a in actions] == ["start", "do", "ai"]
@@ -181,7 +168,7 @@ def test_a_turn_appends_the_player_action_then_the_ai_action(client):
def test_say_and_story_and_continue_all_work(client):
ScriptedProvider.replies = ["One.", "Two.", "Three."]
ScriptedProvider.replies = [gold_reply(t) for t in ["One.", "Two.", "Three."]]
_play(client, "hello", type="say")
_play(client, "The wind rises.", type="story")
_play(client, "", type="continue")
@@ -190,7 +177,7 @@ def test_say_and_story_and_continue_all_work(client):
def test_the_story_so_far_is_replayed_into_the_prompt(client):
ScriptedProvider.replies = ["First.", "Second."]
ScriptedProvider.replies = [gold_reply(t) for t in ["First.", "Second."]]
_play(client, "go north")
_play(client, "go south")
story = ScriptedProvider.prompts[-1][1]
@@ -201,7 +188,7 @@ def test_the_story_so_far_is_replayed_into_the_prompt(client):
def test_scripts_run_once_per_turn(client):
"""The gold script adds ten a turn. Two turns is twenty — not forty."""
ScriptedProvider.replies = ["One.", "Two."]
ScriptedProvider.replies = [gold_reply(t) for t in ["One.", "Two."]]
_play(client)
_play(client)
script_state, _ = _state(client.adv_id)
@@ -211,7 +198,7 @@ def test_scripts_run_once_per_turn(client):
# ------------------------------------------------------------------ retry
def test_retry_replaces_the_text_and_keeps_the_attempt(client):
ScriptedProvider.replies = ["Attempt one.", "Attempt two."]
ScriptedProvider.replies = [gold_reply(t) for t in ["Attempt one.", "Attempt two."]]
_play(client)
assert _texts(client)[-1] == "Attempt one."
@@ -233,7 +220,7 @@ def test_retry_replaces_the_text_and_keeps_the_attempt(client):
def test_retry_does_not_stack_script_effects(client):
"""The discarded attempt's ten gold is rolled back, so one turn plus one
retry is still ten, not twenty."""
ScriptedProvider.replies = ["Attempt one.", "Attempt two."]
ScriptedProvider.replies = [gold_reply(t) for t in ["Attempt one.", "Attempt two."]]
_play(client)
client.post(f"/api/adventures/{client.adv_id}/retry")
script_state, _ = _state(client.adv_id)
@@ -241,7 +228,7 @@ def test_retry_does_not_stack_script_effects(client):
def test_switching_back_to_an_earlier_attempt_restores_it(client):
ScriptedProvider.replies = ["Attempt one.", "Attempt two."]
ScriptedProvider.replies = [gold_reply(t) for t in ["Attempt one.", "Attempt two."]]
_play(client)
client.post(f"/api/adventures/{client.adv_id}/retry")
action_id = _actions(client)[-1]["id"]
@@ -259,7 +246,7 @@ def test_switching_back_to_an_earlier_attempt_restores_it(client):
def test_only_the_newest_turn_can_be_switched(client):
"""An older turn's alternatives stay readable but not selectable. The
story after it continues from what is live."""
ScriptedProvider.replies = ["One.", "Again.", "Two."]
ScriptedProvider.replies = [gold_reply(t) for t in ["One.", "Again.", "Two."]]
_play(client)
client.post(f"/api/adventures/{client.adv_id}/retry")
older_id = _actions(client)[-1]["id"]
@@ -274,7 +261,7 @@ def test_only_the_newest_turn_can_be_switched(client):
# ------------------------------------------------------------------- undo
def test_undo_removes_the_whole_turn_and_rolls_state_back(client):
ScriptedProvider.replies = ["One.", "Two."]
ScriptedProvider.replies = [gold_reply(t) for t in ["One.", "Two."]]
_play(client, "go north")
_play(client, "go south")
assert len(_actions(client)) == 5
@@ -365,7 +352,7 @@ def test_paging_past_the_start_reports_the_end(long_client):
# ------------------------------------------------------- editing and deleting
def test_editing_an_action_sticks(client):
ScriptedProvider.replies = ["Original."]
ScriptedProvider.replies = [gold_reply(t) for t in ["Original."]]
_play(client)
action_id = _actions(client)[-1]["id"]
r = client.patch(f"/api/adventures/{client.adv_id}/actions/{action_id}",
@@ -380,7 +367,7 @@ def test_editing_an_action_sticks(client):
def test_editing_a_retried_action_survives_a_reload(client):
"""The edit has to reach the live attempt too, or paging away and back
reverts it."""
ScriptedProvider.replies = ["One.", "Two."]
ScriptedProvider.replies = [gold_reply(t) for t in ["One.", "Two."]]
_play(client)
client.post(f"/api/adventures/{client.adv_id}/retry")
action_id = _actions(client)[-1]["id"]
@@ -390,7 +377,7 @@ def test_editing_a_retried_action_survives_a_reload(client):
def test_deleting_an_action_removes_it(client):
ScriptedProvider.replies = ["One."]
ScriptedProvider.replies = [gold_reply(t) for t in ["One."]]
_play(client)
action_id = _actions(client)[-1]["id"]
r = client.delete(f"/api/adventures/{client.adv_id}/actions/{action_id}")
@@ -431,7 +418,7 @@ def test_export_carries_the_whole_story(client):
same fact: a bundle that stores coordinates has no use for a repeating
group. Everything else here still passes unmodified.
"""
ScriptedProvider.replies = ["One.", "Two."]
ScriptedProvider.replies = [gold_reply(t) for t in ["One.", "Two."]]
_play(client, "go north")
_play(client, "go south")
@@ -446,7 +433,7 @@ def test_export_carries_the_whole_story(client):
def test_export_round_trips_through_import(client):
ScriptedProvider.replies = ["One."]
ScriptedProvider.replies = [gold_reply(t) for t in ["One."]]
_play(client, "go north")
bundle = client.get(f"/api/adventures/{client.adv_id}/export").json()
@@ -461,7 +448,7 @@ def test_export_round_trips_through_import(client):
def test_export_keeps_retry_attempts(client):
ScriptedProvider.replies = ["Attempt one.", "Attempt two."]
ScriptedProvider.replies = [gold_reply(t) for t in ["Attempt one.", "Attempt two."]]
_play(client)
client.post(f"/api/adventures/{client.adv_id}/retry")
@@ -474,7 +461,7 @@ def test_export_keeps_retry_attempts(client):
# -------------------------------------------------------------- world state
def test_world_state_is_readable_and_survives_a_turn(client):
ScriptedProvider.replies = ["Nothing changes."]
ScriptedProvider.replies = [gold_reply(t) for t in ["Nothing changes."]]
_play(client)
r = client.get(f"/api/adventures/{client.adv_id}/world-state")
assert r.status_code == 200, r.text
-3
View File
@@ -57,9 +57,6 @@ def client(monkeypatch):
ScriptedProvider.replies = [f"Take {n}." for n in range(1, 40)]
ScriptedProvider.calls = 0
monkeypatch.setattr(adventures.turns, "OpenAICompatibleProvider", ScriptedProvider)
monkeypatch.setattr(auth, "resolve_provider_config", lambda s: auth.ProviderConfig(
"http://fake", "k", "test-model", False))
monkeypatch.setattr(limits, "rate_limit", lambda *a, **k: None)
monkeypatch.setattr(limits, "check_row_cap", lambda *a, **k: None)
def _current_user(db=Depends(get_db)):
-6
View File
@@ -58,12 +58,6 @@ def client(monkeypatch):
ScriptedProvider.replies = [f"Take {n}." for n in range(1, 40)]
ScriptedProvider.calls = 0
monkeypatch.setattr(adventures.turns, "OpenAICompatibleProvider", ScriptedProvider)
monkeypatch.setattr(
auth,
"resolve_provider_config",
lambda s: auth.ProviderConfig("http://fake", "k", "test-model", False),
)
monkeypatch.setattr(limits, "rate_limit", lambda *a, **k: None)
monkeypatch.setattr(limits, "check_row_cap", lambda *a, **k: None)
def _current_user(db=Depends(get_db)):
+9 -21
View File
@@ -24,20 +24,14 @@ from app.database import Base, SessionLocal, engine, get_db
from app.main import app
from app.routers import adventures
from fakes import ScriptedProvider
from fakes import GOLD_SCHEMA, ScriptedProvider, gold_replies, gold_reply
SCHEMA = {"player": {"hp": {"min": 0, "max": 100, "initial": 100}}}
SCHEMA = GOLD_SCHEMA
# Ten gold a turn, every turn. A number that only ever increases makes a
# rollback failure obvious: if a take stacks instead of replacing, the gold
# total is off by exactly one turn's worth.
GOLD_SCRIPT = """
const modifier = (text) => {
state.gold = (state.gold || 0) + 10;
return { text };
};
modifier(text);
"""
# Ten gold a turn, every turn, banked through the world-state engine. A number
# that only ever increases makes a rollback failure obvious: if a take stacks
# instead of replacing, the gold total is off by exactly one turn's worth. See
# `fakes.gold_reply` for why this is not a script any more.
@pytest.fixture()
@@ -53,24 +47,18 @@ def client(monkeypatch):
setup.flush()
adv = models.Adventure(
user_id=user.id, title="Vault", scenario_id=scenario.id,
script_state={}, world_state={"player": {"hp": 100}},
world_state={"player": {"hp": 100, "gold": 0}},
)
setup.add(adv)
setup.flush()
setup.add(models.Action(adventure_id=adv.id, type="start", text="You begin."))
setup.add(models.AdventureScript(
adventure_id=adv.id, position=0, enabled=True, name="Gold", output_js=GOLD_SCRIPT,
))
setup.commit()
adv_id, user_id = adv.id, user.id
setup.close()
ScriptedProvider.replies = [f"Take {n}." for n in range(1, 40)]
ScriptedProvider.replies = gold_replies("Take", 39)
ScriptedProvider.calls = 0
monkeypatch.setattr(adventures.turns, "OpenAICompatibleProvider", ScriptedProvider)
monkeypatch.setattr(auth, "resolve_provider_config", lambda s: auth.ProviderConfig(
"http://fake", "k", "test-model", False))
monkeypatch.setattr(limits, "rate_limit", lambda *a, **k: None)
monkeypatch.setattr(limits, "check_row_cap", lambda *a, **k: None)
def _current_user(db=Depends(get_db)):
@@ -107,7 +95,7 @@ def _take(client, action_id, text=""):
def _gold(adv_id) -> int:
db = SessionLocal()
try:
return (db.get(models.Adventure, adv_id).script_state or {}).get("gold", 0)
return (db.get(models.Adventure, adv_id).world_state or {}).get("player", {}).get("gold", 0)
finally:
db.close()
-1
View File
@@ -397,7 +397,6 @@ def client(monkeypatch):
user_id, scenario_id = user.id, scenario.id
setup.close()
monkeypatch.setattr(limits, "rate_limit", lambda *a, **k: None)
monkeypatch.setattr(limits, "check_row_cap", lambda *a, **k: None)
def _current_user(db=Depends(get_db)):
+28 -33
View File
@@ -1,9 +1,11 @@
"""End-to-end HTTP tests for undo and retry state revert. These tests drive
real turns through the actual routes and scripting engine, with only the LLM
provider mocked.
real turns through the actual routes and the world-state engine, with only the
LLM provider mocked.
A script's output hook adds 10 gold each turn. These tests confirm that the
adventure's stored gold total stays correct across play, undo, and retry.
The model banks 10 gold each turn through its ```state delta block. These tests
confirm that the adventure's stored gold total stays correct across play, undo,
and retry. It was a JavaScript output hook that added the gold until M2 removed
campaign scripting; the state machinery under test is the same either way.
python -m pytest tests/test_turn_flow_integration.py -v
"""
@@ -16,15 +18,8 @@ from app.database import Base, SessionLocal, engine, get_db
from app.main import app
from app.routers import adventures
from fakes import ScriptedProvider
from fakes import GOLD_SCHEMA, ScriptedProvider, gold_replies, gold_reply
GOLD_SCRIPT = """
const modifier = (text) => {
state.gold = (state.gold || 0) + 10;
return { text };
};
modifier(text);
"""
AI_REPLY = "The torch flickers as you press onward."
@@ -38,24 +33,21 @@ def client(monkeypatch):
setup.add(user)
setup.flush()
setup.add(models.Settings(user_id=user.id, api_key="enc:dummy", model="test-model"))
adv = models.Adventure(user_id=user.id, title="Cave", script_state={})
scenario = models.Scenario(user_id=user.id, title="S", stat_schema=GOLD_SCHEMA)
setup.add(scenario)
setup.flush()
adv = models.Adventure(user_id=user.id, title="Cave", scenario_id=scenario.id,
world_state={"player": {"hp": 100, "gold": 0}})
setup.add(adv)
setup.flush()
setup.add(models.Action(adventure_id=adv.id, type="start", text="You enter a cave."))
setup.add(models.AdventureScript(
adventure_id=adv.id, position=0, enabled=True, name="Gold",
output_js=GOLD_SCRIPT,
))
setup.commit()
adv_id, user_id = adv.id, user.id
setup.close()
# Force a real, non-demo turn that uses the fake provider.
ScriptedProvider.replies = [AI_REPLY]
ScriptedProvider.replies = [gold_reply(AI_REPLY)]
monkeypatch.setattr(adventures.turns, "OpenAICompatibleProvider", ScriptedProvider)
monkeypatch.setattr(auth, "resolve_provider_config", lambda s: auth.ProviderConfig(
"http://fake", "k", "test-model", False))
monkeypatch.setattr(limits, "rate_limit", lambda *a, **k: None)
monkeypatch.setattr(limits, "check_row_cap", lambda *a, **k: None)
def _current_user(db=Depends(get_db)):
@@ -73,10 +65,13 @@ def client(monkeypatch):
Base.metadata.drop_all(bind=engine)
def _state(adv_id):
def _gold(adv_id) -> int:
"""The banked total. Reading one stat rather than the whole section keeps
these assertions about the rollback, not about the schema's shape."""
db = SessionLocal()
try:
return db.get(models.Adventure, adv_id).script_state
player = (db.get(models.Adventure, adv_id).world_state or {}).get("player", {})
return player.get("gold", 0)
finally:
db.close()
@@ -88,38 +83,38 @@ def _play(client, type_="do", text="look around"):
def test_play_then_undo_reverts_gold(client):
assert _state(client.adv_id) == {}
assert _gold(client.adv_id) == 0
_play(client)
assert _state(client.adv_id) == {"gold": 10}
assert _gold(client.adv_id) == 10
r = client.post(f"/api/adventures/{client.adv_id}/undo")
assert r.status_code == 200, r.text
assert _state(client.adv_id) == {} # gold reverted to zero
assert _gold(client.adv_id) == 0 # gold reverted to zero
def test_two_turns_then_undo_reverts_only_last(client):
_play(client)
_play(client)
assert _state(client.adv_id) == {"gold": 20}
assert _gold(client.adv_id) == 20
client.post(f"/api/adventures/{client.adv_id}/undo")
assert _state(client.adv_id) == {"gold": 10} # back to after turn 1, not 0
assert _gold(client.adv_id) == 10 # back to after turn 1, not 0
def test_retry_does_not_double_apply_gold(client):
_play(client)
assert _state(client.adv_id) == {"gold": 10}
assert _gold(client.adv_id) == 10
# Before the fix, this produced 20 because the output hook ran twice.
# Before the fix, this produced 20 because the turn's effects ran twice.
# Now it stays 10.
r = client.post(f"/api/adventures/{client.adv_id}/retry")
assert r.status_code == 200, r.text
assert _state(client.adv_id) == {"gold": 10}
assert _gold(client.adv_id) == 10
def test_retry_then_undo_still_clean(client):
_play(client)
client.post(f"/api/adventures/{client.adv_id}/retry")
assert _state(client.adv_id) == {"gold": 10}
assert _gold(client.adv_id) == 10
client.post(f"/api/adventures/{client.adv_id}/undo")
assert _state(client.adv_id) == {}
assert _gold(client.adv_id) == 0
@@ -63,9 +63,6 @@ def client(monkeypatch):
ScriptedProvider.replies = [AI_REPLY]
monkeypatch.setattr(adventures.turns, "OpenAICompatibleProvider", ScriptedProvider)
monkeypatch.setattr(auth, "resolve_provider_config", lambda s: auth.ProviderConfig(
"http://fake", "k", "test-model", False))
monkeypatch.setattr(limits, "rate_limit", lambda *a, **k: None)
monkeypatch.setattr(limits, "check_row_cap", lambda *a, **k: None)
def _current_user(db=Depends(get_db)):
+16 -62
View File
@@ -56,66 +56,22 @@ Usage, from `backend/`:
python -m tools.rewrite_memories --write --limit 3 # try three of them
python -m tools.rewrite_memories --write --embed # the whole backfill
Without `--write` it makes no model calls, spends nothing, and only reports the
scope. Every run reads the database the app reads: `AIDND_DB_PATH`, or
`DATABASE_URL` for a hosted Postgres. Take a copy of it first — the old text is
overwritten and is not kept anywhere.
Without `--write` it makes no model calls and only reports the scope. Every run
reads the database the app reads, at `AIDND_DB_PATH`. Take a copy of it first —
the old text is overwritten and is not kept anywhere.
**On the hosted deploy, name whose adventures you mean.** That database holds
other people's stories, and each adventure is summarized with *its owner's* key,
so an unfiltered `--write` spends other people's money on memories they did not
ask to have rewritten. `--email` restricts the run to the accounts you name and
`--adventure` to single adventures; a dry run costs nothing and lists both, with
the owner of each. Guests have no email and can only be reached by id.
Two environment variables reach that database from a checkout. In PowerShell,
which is where this project is developed, they are set first and then persist
for the rest of the session:
$env:AIDND_DATABASE_URL = Read-Host 'Neon URL'
$env:AIDND_SECRET_KEY = Read-Host 'Secret key'
.venv\Scripts\python.exe -m tools.rewrite_memories --email you@example.com
`Read-Host` keeps both values out of the PowerShell history file. On a POSIX
shell the same thing is one line:
AIDND_DATABASE_URL=... AIDND_SECRET_KEY=... \
python -m tools.rewrite_memories --email you@example.com
`AIDND_SECRET_KEY` is not optional there. Stored API keys are encrypted with it,
and with the wrong one `decrypt_secret` returns "" and every adventure is skipped
as having no key (see `security.py`). The deployed image does not carry this
directory — the Dockerfile copies `backend/app` alone — so run it from a
checkout against the hosted database rather than from a shell on the box.
`--adventure` restricts the run to single adventures, and a dry run lists what
would change. The deployed image does not carry this directory — the Dockerfile
copies `backend/app` alone — so run it from a checkout.
"""
import argparse
import asyncio
import sys
from pathlib import Path
from urllib.parse import urlsplit
from sqlalchemy import func, inspect as sa_inspect, select
def safe_dsn(url: str) -> str:
"""A connection string with the credentials taken out.
The report says which database it is about to rewrite, which is worth
printing. The password in a Neon URL is not: this output goes to a console,
a screenshot, or a pasted bug report, and the operator has no way to know
the line carried a credential until it is somewhere else.
"""
parsed = urlsplit(url)
if not parsed.hostname:
return "(configured)"
who = f"{parsed.username}@" if parsed.username else ""
port = f":{parsed.port}" if parsed.port else ""
# The query string is dropped whole. `sslmode` is the only part anyone
# wants to see, and some drivers accept a password there too.
return f"{parsed.scheme}://{who}{parsed.hostname}{port}{parsed.path}"
def words(text: str) -> int:
return len(text.split())
@@ -127,16 +83,16 @@ def one_line(text: str, width: int = 96) -> str:
async def main(args) -> int:
from app import memorybank, models
from app.database import DB_PATH, DATABASE_URL, SessionLocal
from app.database import DB_PATH, SessionLocal
from app.providers import OpenAICompatibleProvider, ProviderError
db = SessionLocal()
print(f"database: {safe_dsn(DATABASE_URL) if DATABASE_URL else DB_PATH}")
print(f"database: {DB_PATH}")
if not sa_inspect(db.get_bind()).has_table(models.Adventure.__tablename__):
# A mistyped path creates an empty SQLite file rather than failing, so
# say what is wrong instead of raising "no such table: adventures".
print("There are no tables here. Point AIDND_DB_PATH, or DATABASE_URL "
"for a hosted deploy, at the database the app uses.")
print("There are no tables here. Point AIDND_DB_PATH at the database "
"the app uses.")
return 2
adventures = db.query(models.Adventure).order_by(models.Adventure.id)
@@ -177,14 +133,13 @@ async def main(args) -> int:
def provider_for(settings: models.Settings) -> OpenAICompatibleProvider:
"""The adventure owner's own summarizer, unless the run overrides it."""
if not (args.endpoint or args.model or args.api_key):
if not (args.endpoint or args.model):
return memorybank.summary_provider(settings)
return OpenAICompatibleProvider(
args.endpoint or settings.endpoint_url,
args.api_key or settings.api_key_plain,
args.model or settings.summary_model or settings.model,
settings.api_mode,
settings.reasoning_max_tokens,
settings.model_timeout_seconds,
)
totals = {"rewritten": 0, "would rewrite": 0, "no source": 0,
@@ -205,11 +160,11 @@ async def main(args) -> int:
continue
settings = settings_for(adventure.user_id)
# An adventure whose owner has no key is reported rather than skipped
# silently: it is the one reason a memory this tool can rewrite is left
# alone, and the operator can fix it with --api-key.
# An adventure with no model configured is reported rather than
# skipped silently: it is the one reason a memory this tool can rewrite
# is left alone, and the operator can fix it with --model.
usable = settings is not None and bool(
args.api_key or args.endpoint or settings.api_key_plain
args.model or settings.summary_model or settings.model
)
owner = db.get(models.User, adventure.user_id)
who = (owner.email if owner and owner.email
@@ -333,7 +288,6 @@ if __name__ == "__main__":
help="re-embed here. Stop the app first; see the docstring.")
parser.add_argument("--endpoint", help="override the owner's endpoint URL.")
parser.add_argument("--model", help="override the owner's summary model.")
parser.add_argument("--api-key", help="override the owner's API key.")
args = parser.parse_args()
# A Windows console defaults to cp1252, which cannot encode the arrow this
-247
View File
@@ -8,9 +8,6 @@
"name": "frontend",
"version": "0.0.0",
"dependencies": {
"@codemirror/lang-javascript": "^6.2.5",
"@codemirror/theme-one-dark": "^6.1.3",
"@uiw/react-codemirror": "^4.25.10",
"react": "^19.2.7",
"react-dom": "^19.2.7",
"react-router-dom": "^7.18.1"
@@ -23,123 +20,6 @@
"vite": "^8.1.1"
}
},
"node_modules/@babel/runtime": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz",
"integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@codemirror/autocomplete": {
"version": "6.20.3",
"resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.20.3.tgz",
"integrity": "sha512-tlosUqb+3BbxCxZdu4tKeRghPFC+QM7q4X5YhKV2eCmPG+1r2F3f4AaSz5sCrFqUtX4Jh20VFTKecl16MgiV9g==",
"license": "MIT",
"dependencies": {
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.17.0",
"@lezer/common": "^1.0.0"
}
},
"node_modules/@codemirror/commands": {
"version": "6.10.4",
"resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.10.4.tgz",
"integrity": "sha512-Ryk9y9T0FFVF0cUGhAknveAyUOl/A1qReTFi+qPKtOh2Z9F4AUBz3XOrYD4ZEgZirdugVzHvd/2/Wcwy5OliTg==",
"license": "MIT",
"dependencies": {
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.7.0",
"@codemirror/view": "^6.27.0",
"@lezer/common": "^1.1.0"
}
},
"node_modules/@codemirror/lang-javascript": {
"version": "6.2.5",
"resolved": "https://registry.npmjs.org/@codemirror/lang-javascript/-/lang-javascript-6.2.5.tgz",
"integrity": "sha512-zD4e5mS+50htS7F+TYjBPsiIFGanfVqg4HyUz6WNFikgOPf2BgKlx+TQedI1w6n/IqRBVBbBWmGFdLB/7uxO4A==",
"license": "MIT",
"dependencies": {
"@codemirror/autocomplete": "^6.0.0",
"@codemirror/language": "^6.6.0",
"@codemirror/lint": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.17.0",
"@lezer/common": "^1.0.0",
"@lezer/javascript": "^1.0.0"
}
},
"node_modules/@codemirror/language": {
"version": "6.12.4",
"resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.12.4.tgz",
"integrity": "sha512-1q4PaT+o6PbgpkJt4Q8Fv5XJxTy4FUZ4MWETtyiDw3J0Pyr9E2vqcKL+k9wcvjNTIsauxvE7OfmWj3FRPHQ76A==",
"license": "MIT",
"dependencies": {
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.23.0",
"@lezer/common": "^1.5.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.0.0",
"style-mod": "^4.0.0"
}
},
"node_modules/@codemirror/lint": {
"version": "6.9.7",
"resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.9.7.tgz",
"integrity": "sha512-28/+iWLYxKxsvGYhSYL7zaCZqLz5+FFFDq9tVsvGv9kv8RY4fFAchJ5WX9M3YrrRlTIsECjsXPqeNgnSmNP2dg==",
"license": "MIT",
"dependencies": {
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.42.0",
"crelt": "^1.0.5"
}
},
"node_modules/@codemirror/search": {
"version": "6.7.1",
"resolved": "https://registry.npmjs.org/@codemirror/search/-/search-6.7.1.tgz",
"integrity": "sha512-uMe5UO6PamJtSHrXhhHOzSX3ReWtiJrva6GnPMwSOrZtiExb5X5eExhr2OUZQVvdxPsKpY3Ro2mFbQadpPWmHA==",
"license": "MIT",
"dependencies": {
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.37.0",
"crelt": "^1.0.5"
}
},
"node_modules/@codemirror/state": {
"version": "6.7.0",
"resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.7.0.tgz",
"integrity": "sha512-Zbl9NyscLMZkfXPQnNAIIAFftidrA1UbcJEIMp24C0Bukc2I5T8wJS0wsXYsnDOqCFJUeJ1BITGNs5CqPDSmSg==",
"license": "MIT",
"dependencies": {
"@marijn/find-cluster-break": "^1.0.0"
}
},
"node_modules/@codemirror/theme-one-dark": {
"version": "6.1.3",
"resolved": "https://registry.npmjs.org/@codemirror/theme-one-dark/-/theme-one-dark-6.1.3.tgz",
"integrity": "sha512-NzBdIvEJmx6fjeremiGp3t/okrLPYT0d9orIc7AFun8oZcRk58aejkqhv6spnz4MLAevrKNPMQYXEWMg4s+sKA==",
"license": "MIT",
"dependencies": {
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.0.0",
"@lezer/highlight": "^1.0.0"
}
},
"node_modules/@codemirror/view": {
"version": "6.43.5",
"resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.43.5.tgz",
"integrity": "sha512-7uT/vUgH6dfXWn3WqOe23KneILMvGy5wQjNMEcRXLKzziJ9NOktpW6tGoyQpwVkBgE5Gj6hKkCcsddbnkaWrOQ==",
"license": "MIT",
"dependencies": {
"@codemirror/state": "^6.7.0",
"crelt": "^1.0.6",
"style-mod": "^4.1.0",
"w3c-keyname": "^2.2.4"
}
},
"node_modules/@emnapi/core": {
"version": "1.11.2",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.2.tgz",
@@ -176,47 +56,6 @@
"tslib": "^2.4.0"
}
},
"node_modules/@lezer/common": {
"version": "1.5.2",
"resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.5.2.tgz",
"integrity": "sha512-sxQE460fPZyU3sdc8lafxiPwJHBzZRy/udNFynGQky1SePYBdhkBl1kOagA9uT3pxR8K09bOrmTUqA9wb/PjSQ==",
"license": "MIT"
},
"node_modules/@lezer/highlight": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.3.tgz",
"integrity": "sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.3.0"
}
},
"node_modules/@lezer/javascript": {
"version": "1.5.4",
"resolved": "https://registry.npmjs.org/@lezer/javascript/-/javascript-1.5.4.tgz",
"integrity": "sha512-vvYx3MhWqeZtGPwDStM2dwgljd5smolYD2lR2UyFcHfxbBQebqx8yjmFmxtJ/E6nN6u1D9srOiVWm3Rb4tmcUA==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.1.3",
"@lezer/lr": "^1.3.0"
}
},
"node_modules/@lezer/lr": {
"version": "1.4.10",
"resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.10.tgz",
"integrity": "sha512-rnCpTIBafOx4mRp43xOxDJbFipJm/c0cia/V5TiGlhmMa+wsSdoGmUN3w5Bqrks/09Q/D4tNAmWaT8p6NRi77A==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.0.0"
}
},
"node_modules/@marijn/find-cluster-break": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.3.tgz",
"integrity": "sha512-FY+MKLBoTsLNJF/eLWaOsXGdz6uh3Iu1axjPf6TUq92IYumcTcXWHoS747JARLkcdlJ/Waiaxc5wQfFO8jC6NA==",
"license": "MIT"
},
"node_modules/@napi-rs/wasm-runtime": {
"version": "1.1.6",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz",
@@ -887,59 +726,6 @@
"@types/react": "^19.2.0"
}
},
"node_modules/@uiw/codemirror-extensions-basic-setup": {
"version": "4.25.10",
"resolved": "https://registry.npmjs.org/@uiw/codemirror-extensions-basic-setup/-/codemirror-extensions-basic-setup-4.25.10.tgz",
"integrity": "sha512-P3vytLlpE62KYSWrMUnwDCv2lvaQDuDZzyj03mHntuHo5bSl34fRZpjTY3kQTPGuXHxkGSYpoPFFj+hMTqaaMQ==",
"license": "MIT",
"dependencies": {
"@codemirror/autocomplete": "^6.0.0",
"@codemirror/commands": "^6.0.0",
"@codemirror/language": "^6.0.0",
"@codemirror/lint": "^6.0.0",
"@codemirror/search": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.0.0"
},
"funding": {
"url": "https://jaywcjlove.github.io/#/sponsor"
},
"peerDependencies": {
"@codemirror/autocomplete": ">=6.0.0",
"@codemirror/commands": ">=6.0.0",
"@codemirror/language": ">=6.0.0",
"@codemirror/lint": ">=6.0.0",
"@codemirror/search": ">=6.0.0",
"@codemirror/state": ">=6.0.0",
"@codemirror/view": ">=6.0.0"
}
},
"node_modules/@uiw/react-codemirror": {
"version": "4.25.10",
"resolved": "https://registry.npmjs.org/@uiw/react-codemirror/-/react-codemirror-4.25.10.tgz",
"integrity": "sha512-DzgSMwM5qzB7v1FIb4gEeriYt67iiay756/HIOM9mAbeOVK0MO7rqefHf0O5c0269pJKMW7AH9FjclExD23V9w==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.18.6",
"@codemirror/commands": "^6.1.0",
"@codemirror/state": "^6.1.1",
"@codemirror/theme-one-dark": "^6.0.0",
"@uiw/codemirror-extensions-basic-setup": "4.25.10",
"codemirror": "^6.0.0"
},
"funding": {
"url": "https://jaywcjlove.github.io/#/sponsor"
},
"peerDependencies": {
"@babel/runtime": ">=7.11.0",
"@codemirror/state": ">=6.0.0",
"@codemirror/theme-one-dark": ">=6.0.0",
"@codemirror/view": ">=6.0.0",
"codemirror": ">=6.0.0",
"react": ">=17.0.0",
"react-dom": ">=17.0.0"
}
},
"node_modules/@vitejs/plugin-react": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.3.tgz",
@@ -966,21 +752,6 @@
}
}
},
"node_modules/codemirror": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/codemirror/-/codemirror-6.0.2.tgz",
"integrity": "sha512-VhydHotNW5w1UGK0Qj96BwSk/Zqbp9WbnyK2W/eVMv4QyF41INRGpjUhFJY7/uDNuudSc33a/PKr4iDqRduvHw==",
"license": "MIT",
"dependencies": {
"@codemirror/autocomplete": "^6.0.0",
"@codemirror/commands": "^6.0.0",
"@codemirror/language": "^6.0.0",
"@codemirror/lint": "^6.0.0",
"@codemirror/search": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.0.0"
}
},
"node_modules/cookie": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
@@ -994,12 +765,6 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/crelt": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.7.tgz",
"integrity": "sha512-aK6BbWfhf4U/wCcLHKPJl/xa6VkVstRaPywWtMKGwuOLc/wZTyQYuoxgvZnNsBvv7Kg3YTBQYYBCggcviQczuA==",
"license": "MIT"
},
"node_modules/csstype": {
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
@@ -1543,12 +1308,6 @@
"node": ">=0.10.0"
}
},
"node_modules/style-mod": {
"version": "4.1.3",
"resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.3.tgz",
"integrity": "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==",
"license": "MIT"
},
"node_modules/tinyglobby": {
"version": "0.2.17",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
@@ -1651,12 +1410,6 @@
"optional": true
}
}
},
"node_modules/w3c-keyname": {
"version": "2.2.8",
"resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
"license": "MIT"
}
}
}
-3
View File
@@ -10,9 +10,6 @@
"preview": "vite preview"
},
"dependencies": {
"@codemirror/lang-javascript": "^6.2.5",
"@codemirror/theme-one-dark": "^6.1.3",
"@uiw/react-codemirror": "^4.25.10",
"react": "^19.2.7",
"react-dom": "^19.2.7",
"react-router-dom": "^7.18.1"
+15 -85
View File
@@ -1,56 +1,23 @@
import { useEffect, useRef, useState } from 'react'
import { NavLink, Outlet, useLocation } from 'react-router-dom'
import { api } from './api'
import { AuthModal, ToastHost } from './components'
import { useState } from 'react'
import { NavLink, Outlet } from 'react-router-dom'
import { ToastHost } from './components'
import Embers from './Embers.jsx'
// The shell: the nav bar and whatever page is routed under it.
//
// Upstream also carried the hosted deployment's account furniture here — a
// /auth/me lookup on mount, guest and sign-up prompts, a log-out button, a
// pageview beacon, and two nav links gated on server-side allowlists. M2
// removed the deployment those served. There is one local user, nothing to log
// in to, and nothing counting.
export default function App() {
// null until /auth/me resolves; in local mode multi_user=false hides all auth UI.
const [me, setMe] = useState(null)
const [authMode, setAuthMode] = useState(null) // 'register' | 'login' | null
const [navOpen, setNavOpen] = useState(false) // mobile hamburger menu
const location = useLocation()
const lastPath = useRef(null)
useEffect(() => {
api.getMe().then(setMe).catch(() => {})
}, [])
// One pageview per route the reader actually lands on. Guarded on the path
// rather than fired on every render: StrictMode runs effects twice in dev,
// and a re-render for unrelated state is not a new page.
useEffect(() => {
if (lastPath.current === location.pathname) return
const first = lastPath.current === null
lastPath.current = location.pathname
// document.referrer survives client-side navigation, so it is only honest
// on the first view — after that this was our own page, not a referral.
api.trackPageview(location.pathname, {
referrer: first ? document.referrer : '',
first,
})
}, [location.pathname])
const onAuthed = (newMe, mode) => {
setAuthMode(null)
if (mode === 'login') {
// Different user now — reload so every page refetches its scoped data.
window.location.reload()
} else {
setMe(newMe) // register upgrades the same user in place; data unchanged
}
}
const logout = async () => {
try { await api.logout() } catch { /* already logged out */ }
window.location.reload()
}
return (
<ToastHost>
<Embers />
<nav className="topnav">
<span className="brand">⚔ AI D&D</span>
<span className="brand">⚔ Adventure Storyteller</span>
<button
className="nav-hamburger"
aria-label="Menu"
@@ -69,52 +36,15 @@ export default function App() {
<NavLink to="/scenarios" className={({ isActive }) => `navlink${isActive ? ' active' : ''}`}>
Scenarios
</NavLink>
<NavLink to="/scripts" className={({ isActive }) => `navlink${isActive ? ' active' : ''}`}>
Scripts
</NavLink>
<NavLink to="/settings" className={({ isActive }) => `navlink${isActive ? ' active' : ''}`}>
Settings
</NavLink>
{/* Power-user tooling, not part of the game — hidden for everyone else. */}
{me?.power_user && (
<NavLink to="/chat" className={({ isActive }) => `navlink${isActive ? ' active' : ''}`}>
AI Chat
</NavLink>
)}
{/* Owner only: the site's own traffic, on its own allowlist. */}
{me?.analytics && (
<NavLink to="/analytics" className={({ isActive }) => `navlink${isActive ? ' active' : ''}`}>
Visitors
</NavLink>
)}
{me?.multi_user && (
<div className="nav-account">
{me.is_guest ? (
<>
<span className="guest-nudge"
title={me.guest_retention_days
? `Guest adventures are deleted after ${me.guest_retention_days} days without a visit.`
: undefined}>
Playing as guest — sign up to keep your adventures
</span>
<button onClick={() => setAuthMode('login')}>Log in</button>
<button className="primary" onClick={() => setAuthMode('register')}>Sign up</button>
</>
) : (
<>
<span className="account-email" title={me.email}>{me.email}</span>
<button onClick={logout}>Log out</button>
</>
)}
</div>
)}
<NavLink to="/chat" className={({ isActive }) => `navlink${isActive ? ' active' : ''}`}>
AI Chat
</NavLink>
</div>
</nav>
<Outlet context={{ me, setMe }} />
{authMode && (
<AuthModal mode={authMode} onClose={() => setAuthMode(null)} onAuthed={onAuthed}
retentionDays={me?.guest_retention_days} />
)}
<Outlet />
</ToastHost>
)
}
+5 -63
View File
@@ -1,19 +1,11 @@
// Multi-user mode: a 401 means our session cookie is missing/stale. Hitting
// /api/auth/me creates a fresh guest session, after which the original call
// is retried once.
async function ensureSession() {
await fetch('/api/auth/me')
}
async function request(path, options = {}, isRetry = false) {
// There is no session. The API is single-user and loopback-bound, so a request
// either works or reports why. The 401-and-retry dance here served the hosted
// deployment's guest cookies, which M2 removed.
async function request(path, options = {}) {
const resp = await fetch(`/api${path}`, {
headers: { 'Content-Type': 'application/json' },
...options,
})
if (resp.status === 401 && !isRetry && path !== '/auth/me') {
await ensureSession()
return request(path, options, true)
}
if (!resp.ok) {
let detail = resp.statusText
try {
@@ -27,17 +19,13 @@ async function request(path, options = {}, isRetry = false) {
}
// POSTs to an SSE endpoint and dispatches events: {type: 'player'|'chunk'|'done'|'error', ...}
async function streamSSE(path, payload, onEvent, signal, isRetry = false) {
async function streamSSE(path, payload, onEvent, signal) {
const resp = await fetch(`/api${path}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
signal,
})
if (resp.status === 401 && !isRetry) {
await ensureSession()
return streamSSE(path, payload, onEvent, signal, true)
}
if (!resp.ok) {
let detail = resp.statusText
try { detail = (await resp.json()).detail || detail } catch { /* non-JSON */ }
@@ -61,36 +49,6 @@ async function streamSSE(path, payload, onEvent, signal, isRetry = false) {
}
export const api = {
// Analytics. The beacon is deliberately not a `request()`: it must never
// retry, never bootstrap a session, and never surface an error — a counter
// that can interrupt the app it is counting is worse than no counter.
trackPageview: (path, { referrer = '', first = false } = {}) => {
try {
fetch('/api/analytics/collect', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path, referrer, first }),
keepalive: true,
}).catch(() => {})
} catch { /* no beacon, no problem */ }
},
getAnalytics: (days) => request(`/analytics/summary?days=${days}`),
getAccessLog: ({ beforeId, kind, q, limit = 50 } = {}) => {
const params = new URLSearchParams({ limit })
if (beforeId != null) params.set('before_id', beforeId)
if (kind) params.set('kind', kind)
if (q) params.set('q', q)
return request(`/analytics/access?${params}`)
},
// Auth (Phase 8 — no-ops in local mode beyond getMe)
getMe: () => request('/auth/me'),
register: (email, password) =>
request('/auth/register', { method: 'POST', body: JSON.stringify({ email, password }) }),
login: (email, password) =>
request('/auth/login', { method: 'POST', body: JSON.stringify({ email, password }) }),
logout: () => request('/auth/logout', { method: 'POST' }),
// Scenarios
listScenarios: () => request('/scenarios'),
getScenario: (id) => request(`/scenarios/${id}`),
@@ -101,7 +59,6 @@ export const api = {
// Adventures
listAdventures: () => request('/adventures'),
getAdventure: (id) => request(`/adventures/${id}`),
getScriptState: (id) => request(`/adventures/${id}/script-state`),
getWorldState: (id) => request(`/adventures/${id}/world-state`),
overrideWorldState: (id, overrides) =>
request(`/adventures/${id}/world-state`, { method: 'PUT', body: JSON.stringify(overrides) }),
@@ -185,21 +142,6 @@ export const api = {
deleteMemory: (advId, memoryId) =>
request(`/adventures/${advId}/memories/${memoryId}`, { method: 'DELETE' }),
listAdventureScripts: (advId) => request(`/adventures/${advId}/scripts`),
updateAdventureScript: (advId, scriptId, data) =>
request(`/adventures/${advId}/scripts/${scriptId}`, { method: 'PATCH', body: JSON.stringify(data) }),
syncAdventureScript: (advId, scriptId) =>
request(`/adventures/${advId}/scripts/${scriptId}/sync`, { method: 'POST' }),
// Scripts
listScripts: () => request('/scripts'),
getScript: (id) => request(`/scripts/${id}`),
createScript: (data) => request('/scripts', { method: 'POST', body: JSON.stringify(data) }),
updateScript: (id, data) => request(`/scripts/${id}`, { method: 'PATCH', body: JSON.stringify(data) }),
deleteScript: (id) => request(`/scripts/${id}`, { method: 'DELETE' }),
testScript: (id, data) => request(`/scripts/${id}/test`, { method: 'POST', body: JSON.stringify(data) }),
exportScript: (id) => request(`/scripts/${id}/export`),
importScript: (bundle) => request('/scripts/import', { method: 'POST', body: JSON.stringify(bundle) }),
// Scenario import/export
exportScenario: (id) => request(`/scenarios/${id}/export`),
-105
View File
@@ -287,111 +287,6 @@ export function BeginAdventureModal({ title, names = [], onSubmit, onCancel }) {
// Phase 8: register/login for the hosted multi-user mode. `onAuthed(me)` gets
// the fresh /auth/me payload after success.
export function AuthModal({ mode: initialMode, onClose, onAuthed, retentionDays }) {
const [mode, setMode] = useState(initialMode || 'register')
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [reveal, setReveal] = useState(false)
const [error, setError] = useState('')
const [busy, setBusy] = useState(false)
const registering = mode === 'register'
// Esc dismisses, like the overlay click already does.
useEffect(() => {
const onKey = (e) => { if (e.key === 'Escape') onClose() }
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [onClose])
const switchTo = (next) => {
if (next === mode) return
setMode(next)
setError('')
setReveal(false)
}
const submit = async (e) => {
e.preventDefault()
setBusy(true)
setError('')
try {
const address = email.trim()
const me = registering
? await api.register(address, password)
: await api.login(address, password)
onAuthed(me, mode)
} catch (err) {
setError(err.message)
setBusy(false)
}
}
return (
<div className="modal-overlay" onClick={onClose}>
<form className="modal auth-modal" onClick={(e) => e.stopPropagation()} onSubmit={submit}
aria-labelledby="auth-title">
<div className="auth-crest" aria-hidden="true">❖</div>
<h2 id="auth-title">{registering ? 'Create an account' : 'Welcome back'}</h2>
<p className="modal-hint auth-hint">
{registering
? 'Everything you’ve played as a guest stays with your new account, and you can pick it up from any device.'
: 'Log in to reach your adventures.'}
{/* Guest data really is deleted, so say so where the decision is
being made. The window comes from the server (see cleanup.py) so
it can't drift from what's enforced. */}
{registering && retentionDays ? (
<> Guest adventures are deleted after {retentionDays} days without a visit.</>
) : null}
</p>
<div className="auth-tabs" role="tablist">
<button type="button" role="tab" aria-selected={!registering}
className={`auth-tab${registering ? '' : ' active'}`}
onClick={() => switchTo('login')}>Log in</button>
<button type="button" role="tab" aria-selected={registering}
className={`auth-tab${registering ? ' active' : ''}`}
onClick={() => switchTo('register')}>Sign up</button>
</div>
<label className="field">
<span className="label">Email</span>
<input type="email" autoFocus required value={email}
autoComplete="email" placeholder="you@example.com"
onChange={(e) => setEmail(e.target.value)} />
</label>
<label className="field">
<span className="label">Password</span>
<div className="auth-password">
<input type={reveal ? 'text' : 'password'} required
minLength={registering ? 8 : undefined} value={password}
autoComplete={registering ? 'new-password' : 'current-password'}
onChange={(e) => setPassword(e.target.value)} />
<button type="button" className="auth-reveal" tabIndex={-1}
aria-label={reveal ? 'Hide password' : 'Show password'}
onClick={() => setReveal((r) => !r)}>
{reveal ? 'Hide' : 'Show'}
</button>
</div>
{registering && <span className="auth-help">At least 8 characters.</span>}
</label>
{error && <div className="auth-error" role="alert">{error}</div>}
<div className="modal-buttons">
<button type="button" onClick={onClose}>Cancel</button>
<button type="submit" className="primary" disabled={busy}>
{busy ? 'Please wait…' : registering ? 'Sign up' : 'Log in'}
</button>
</div>
</form>
</div>
)
}
/** A textarea that grows to fit its content instead of sitting at a fixed
height. Editing an AI beat means editing a few paragraphs, and a fixed box
made that a keyhole. CSS min/max-height still bound it (the cap scrolls),
so callers control the floor and ceiling from the stylesheet. */
export function AutoTextarea({ value, ...props }) {
const ref = useRef(null)
-8
View File
@@ -7,11 +7,8 @@ import Adventures from './pages/Adventures.jsx'
import Scenarios from './pages/Scenarios.jsx'
import ScenarioEditor from './pages/ScenarioEditor.jsx'
import Play from './pages/Play'
import Scripts from './pages/Scripts.jsx'
import ScriptEditor from './pages/ScriptEditor.jsx'
import Settings from './pages/Settings.jsx'
import Chat from './pages/Chat.jsx'
import Analytics from './pages/Analytics.jsx'
import { trackKeyboardInset } from './keyboard.js'
import './index.css'
@@ -27,13 +24,8 @@ const router = createBrowserRouter([
{ path: 'scenarios', element: <Scenarios /> },
{ path: 'scenarios/:id', element: <ScenarioEditor /> },
{ path: 'play/:id', element: <Play /> },
{ path: 'scripts', element: <Scripts /> },
{ path: 'scripts/:id', element: <ScriptEditor /> },
{ path: 'settings', element: <Settings /> },
// Power users only — the page redirects home and the API 404s otherwise.
{ path: 'chat', element: <Chat /> },
// Owner only, by a separate allowlist; same redirect-and-404 treatment.
{ path: 'analytics', element: <Analytics /> },
],
},
])
-476
View File
@@ -1,476 +0,0 @@
/* Visit analytics — the owner's view of who came by and what they did.
Everything on this page arrives in a single aggregate response (see
backend/app/analytics.py), so changing the range is one small request, not a
scan of anything. The page is hidden from everyone else: the nav link is
gated on `me.analytics`, this component bounces, and the API 404s.
Charts are plain HTML — a flex row of columns, a row of bars — rather than
SVG or a charting library. At this size that is less code, responsive for
free, and keeps the CSP as tight as it is. */
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useNavigate, useOutletContext } from 'react-router-dom'
import { api } from '../api'
const RANGES = [
{ days: 7, label: '7 days' },
{ days: 30, label: '30 days' },
{ days: 90, label: '90 days' },
]
const nf = new Intl.NumberFormat()
// Weekday + day for a short range, day + month for a long one: a 90-day axis
// has no room for "Mon".
function dayLabel(iso, days) {
const date = new Date(`${iso}T00:00:00Z`)
const opts = days <= 14
? { weekday: 'short', timeZone: 'UTC' }
: { day: 'numeric', month: 'short', timeZone: 'UTC' }
return date.toLocaleDateString(undefined, opts)
}
function fullDate(iso) {
return new Date(`${iso}T00:00:00Z`)
.toLocaleDateString(undefined, { dateStyle: 'medium', timeZone: 'UTC' })
}
/* ---------- Pieces ---------- */
function StatTile({ label, value, hint }) {
return (
<div className="an-tile" title={hint || undefined}>
<div className="an-tile-value">{nf.format(value ?? 0)}</div>
<div className="an-tile-label">{label}</div>
{hint && <div className="an-tile-hint">{hint}</div>}
</div>
)
}
/* A day-by-day column chart. `series` names the stacked segments bottom-up;
one segment means one plain bar and no legend, since the title already says
what it is. */
function DayChart({ title, data, series, days }) {
const [hover, setHover] = useState(null)
const max = Math.max(1, ...data.map((d) => series.reduce((sum, s) => sum + (d[s.key] || 0), 0)))
// Only ever three x labels. Every column labelled is unreadable at 90 days
// and redundant at 7 — the tooltip carries the exact date either way.
const ticks = new Set([0, Math.floor((data.length - 1) / 2), data.length - 1])
const empty = data.every((d) => series.every((s) => !d[s.key]))
return (
<section className="an-card an-chart">
<header className="an-card-head">
<h2>{title}</h2>
{series.length > 1 && (
<div className="an-legend">
{[...series].reverse().map((s) => (
<span key={s.key} className="an-legend-item">
<i className="an-swatch" style={{ background: s.color }} />
{s.label}
</span>
))}
</div>
)}
</header>
<div className="an-plot" onMouseLeave={() => setHover(null)}>
<div className="an-gridline" style={{ bottom: '100%' }}><span>{nf.format(max)}</span></div>
<div className="an-gridline" style={{ bottom: '50%' }}><span>{nf.format(Math.round(max / 2))}</span></div>
<div className="an-columns">
{data.map((point, i) => {
const total = series.reduce((sum, s) => sum + (point[s.key] || 0), 0)
return (
<div
key={point.day}
className={`an-column${hover === i ? ' hot' : ''}`}
onMouseEnter={() => setHover(i)}
onFocus={() => setHover(i)}
tabIndex={0}
aria-label={`${fullDate(point.day)}: ${total}`}
>
<div className="an-stack">
{[...series].reverse().map((s) => (
(point[s.key] || 0) > 0 && (
<div
key={s.key}
className="an-bar"
style={{
height: `${((point[s.key] || 0) / max) * 100}%`,
background: s.color,
}}
/>
)
))}
</div>
{hover === i && (
<div className="an-tip">
<strong>{fullDate(point.day)}</strong>
{series.map((s) => (
<span key={s.key}>
<i className="an-swatch" style={{ background: s.color }} />
{s.label}: {nf.format(point[s.key] || 0)}
</span>
))}
</div>
)}
</div>
)
})}
</div>
</div>
<div className="an-axis">
{data.map((point, i) => (
<span key={point.day}>{ticks.has(i) ? dayLabel(point.day, days) : ''}</span>
))}
</div>
{empty && <div className="an-overlay-empty">Nothing recorded in this range</div>}
</section>
)
}
/* The funnel. Each step's bar is drawn against the first step, so the shape of
the drop-off is the picture; the percentage beside it is of the step above,
which is the number you act on. */
function Funnel({ steps }) {
const top = steps[0]?.count || 0
return (
<section className="an-card">
<header className="an-card-head">
<h2>Where visitors get to</h2>
<span className="an-note">people, counted once each</span>
</header>
{top === 0 ? (
<div className="an-empty">No visitors in this range.</div>
) : (
<div className="an-funnel">
{steps.map((step, i) => {
const previous = i === 0 ? step.count : steps[i - 1].count
const share = previous ? Math.round((step.count / previous) * 100) : 0
return (
<div key={step.step} className="an-funnel-row">
<div className="an-funnel-label">{step.step}</div>
<div className="an-funnel-track">
<div
className="an-funnel-bar"
style={{ width: `${top ? (step.count / top) * 100 : 0}%` }}
/>
</div>
<div className="an-funnel-value">
{nf.format(step.count)}
{i > 0 && <span className="an-funnel-share">{share}%</span>}
</div>
</div>
)
})}
</div>
)}
</section>
)
}
function TopList({ title, rows, note, empty = 'Nothing yet' }) {
const max = Math.max(1, ...rows.map((r) => r.hits))
return (
<section className="an-card">
<header className="an-card-head">
<h2>{title}</h2>
{note && <span className="an-note">{note}</span>}
</header>
{rows.length === 0 ? (
<div className="an-empty">{empty}</div>
) : (
<ul className="an-list">
{rows.map((row) => (
<li key={row.label}>
{/* The bar is the row's own background, so a long label stays
readable on top of it instead of being squeezed beside it. */}
<span className="an-list-fill" style={{ width: `${(row.hits / max) * 100}%` }} />
<span className="an-list-label" title={row.label}>{row.label}</span>
<span className="an-list-value">{nf.format(row.hits)}</span>
</li>
))}
</ul>
)}
</section>
)
}
/* ---------- Access log ---------- */
const KINDS = [
{ key: '', label: 'Everything' },
{ key: 'session', label: 'Sessions' },
{ key: 'login', label: 'Sign-ins' },
{ key: 'register', label: 'Registrations' },
{ key: 'login_failed', label: 'Failed' },
]
const KIND_LABEL = {
session: 'Session',
login: 'Signed in',
register: 'Registered',
login_failed: 'Failed sign-in',
}
function when(iso) {
const date = new Date(iso.endsWith('Z') ? iso : `${iso}Z`)
return date.toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' })
}
function AccessLog() {
const [kind, setKind] = useState('')
const [search, setSearch] = useState('')
const [query, setQuery] = useState('')
const [page, setPage] = useState(null) // { events, has_more }
const [error, setError] = useState(null)
const [busy, setBusy] = useState(false)
// Typing shouldn't fire a request per keystroke against a table scan.
useEffect(() => {
const timer = setTimeout(() => setQuery(search.trim()), 350)
return () => clearTimeout(timer)
}, [search])
useEffect(() => {
let live = true
setPage(null)
api.getAccessLog({ kind, q: query }).then(
(result) => { if (live) { setPage(result); setError(null) } },
(err) => { if (live) setError(err.message) },
)
return () => { live = false }
}, [kind, query])
const loadMore = useCallback(async () => {
if (!page?.events.length || busy) return
setBusy(true)
try {
// Anchored on the oldest row already on screen, so rows arriving while
// this is open can't shift the next page.
const next = await api.getAccessLog({
kind, q: query, beforeId: page.events[page.events.length - 1].id,
})
setPage({ events: [...page.events, ...next.events], has_more: next.has_more })
} catch (err) {
setError(err.message)
} finally {
setBusy(false)
}
}, [page, kind, query, busy])
return (
<section className="an-card">
<header className="an-card-head an-log-head">
<div className="an-ranges">
{KINDS.map((option) => (
<button
key={option.key}
className={kind === option.key ? 'primary' : ''}
onClick={() => setKind(option.key)}
>
{option.label}
</button>
))}
</div>
<input
type="text"
className="search-input an-log-search"
placeholder="Search email, IP, country…"
value={search}
onChange={(event) => setSearch(event.target.value)}
/>
</header>
{error && <div className="an-empty">Couldn’t load the log: {error}</div>}
{!page && !error && <div className="an-empty">Reading…</div>}
{page && (page.events.length === 0 ? (
<div className="an-empty">
{query || kind ? 'Nothing matches that.' : 'Nothing logged yet.'}
</div>
) : (
<>
<div className="an-table-scroll">
<table className="an-table">
<thead>
<tr>
<th>When</th><th>Who</th><th>Event</th>
<th>IP</th><th>Country</th><th>Device</th>
</tr>
</thead>
<tbody>
{page.events.map((event) => (
<tr key={event.id} className={event.kind === 'login_failed' ? 'failed' : undefined}>
<td className="an-cell-dim">{when(event.at)}</td>
<td>
{event.who}
{event.is_guest && <span className="an-tag">guest</span>}
</td>
<td>{KIND_LABEL[event.kind] || event.kind}</td>
<td className="an-cell-mono">{event.ip || '—'}</td>
<td>{event.country || '—'}</td>
{/* The full user-agent is a wall of text; it lives on the
hover instead of in a column that would push the rest
of the table off screen. */}
<td className="an-cell-dim" title={event.user_agent || undefined}>
{event.device || '—'}
</td>
</tr>
))}
</tbody>
</table>
</div>
{page.has_more && (
<div className="an-more">
<button onClick={loadMore} disabled={busy}>
{busy ? 'Loading…' : 'Load older'}
</button>
</div>
)}
</>
))}
</section>
)
}
/* ---------- Page ---------- */
export default function Analytics() {
const { me } = useOutletContext() ?? {}
const navigate = useNavigate()
const [tab, setTab] = useState('overview')
const [days, setDays] = useState(30)
const [data, setData] = useState(null)
const [error, setError] = useState(null)
const loaded = useRef(false)
// me is null until /auth/me resolves; only bounce once we know.
useEffect(() => {
if (me && !me.analytics) navigate('/', { replace: true })
}, [me, navigate])
useEffect(() => {
if (tab !== 'overview') return undefined
let live = true
api.getAnalytics(days).then(
(result) => { if (live) { setData(result); setError(null); loaded.current = true } },
(err) => { if (live) setError(err.message) },
)
return () => { live = false }
}, [days, tab])
const totals = data?.totals ?? {}
const visitorSeries = useMemo(() => ([
{ key: 'returning', label: 'Returning', color: 'var(--chart-2)' },
{ key: 'new', label: 'New', color: 'var(--chart-1)' },
]), [])
// The server sends new-vs-total; the chart stacks, so it wants the remainder.
const visitorDays = useMemo(
() => (data?.series ?? []).map((d) => ({ ...d, returning: d.visitors - d.new })),
[data],
)
if (me && !me.analytics) return null
return (
<div className="page an-page">
<div className="page-header">
<h1>Visitors</h1>
{tab === 'overview' && (
<div className="an-ranges">
{RANGES.map((range) => (
<button
key={range.days}
className={days === range.days ? 'primary' : ''}
onClick={() => setDays(range.days)}
>
{range.label}
</button>
))}
</div>
)}
</div>
<div className="an-tabs">
<button
className={tab === 'overview' ? 'active' : ''}
onClick={() => setTab('overview')}
>
Overview
</button>
<button
className={tab === 'access' ? 'active' : ''}
onClick={() => setTab('access')}
>
Access log
</button>
</div>
{tab === 'access' && <AccessLog />}
{tab === 'overview' && error && (
<div className="an-empty">Couldn’t load analytics: {error}</div>
)}
{tab === 'overview' && !data && !error && <div className="an-empty">Counting…</div>}
{tab === 'overview' && data && (
<>
<div className="an-tiles">
<StatTile label="Visitors" value={totals.visitors}
hint={`${nf.format(totals.new_visitors || 0)} first-time`} />
<StatTile label="Visits" value={totals.visits}
hint={`${totals.pages_per_visit || 0} pages each`} />
<StatTile label="Pageviews" value={totals.pageviews} />
<StatTile label="Adventures started" value={totals.adventures} />
<StatTile label="Turns played" value={totals.turns}
hint={`${totals.turns_per_visit || 0} per visit`} />
<StatTile label="Sign-ups" value={totals.signups} />
<StatTile label="Demo-key turns" value={totals.demo_turns}
hint="billed to the shared key" />
<StatTile label="Failed turns" value={totals.turn_errors}
hint={`${nf.format(totals.errors || 0)} API errors`} />
</div>
<div className="an-grid">
<DayChart
title="Visitors per day"
data={visitorDays}
series={visitorSeries}
days={days}
/>
<DayChart
title="Turns played per day"
data={data.series}
series={[{ key: 'turns', label: 'Turns', color: 'var(--chart-3)' }]}
days={days}
/>
</div>
<Funnel steps={data.funnel} />
<div className="an-grid">
<TopList title="Pages" rows={data.pages} />
<TopList
title="Where they came from"
rows={data.referrers}
note="per visit"
empty="No referrals yet — every visit was typed or bookmarked."
/>
<TopList title="Scenarios started" rows={data.scenarios}
note="shared scenarios only" empty="No adventures started yet." />
<TopList title="Countries" rows={data.countries} />
<TopList title="Devices" rows={data.devices} />
<TopList title="API errors" rows={data.errors} empty="None — clean run." />
</div>
<p className="an-footnote">
{fullDate(data.since)} – {fullDate(data.until)}, UTC. Your own visits aren’t
counted here. These totals are anonymous — visitors are counted as one-way
hashes, and nothing on this tab can be traced back to a player or their
stories. The access log tab is the separate, identifying record.
</p>
</>
)}
</div>
)
}
+3 -11
View File
@@ -1,12 +1,11 @@
/* AI Chat — a plain scratchpad for talking to a model, with none of the game's
context assembly in the way. Power users only (the backend 404s the routes
for everyone else, and the nav link is hidden).
/* AI Chat — a plain scratchpad for talking to the configured model, with none
of the game's context assembly in the way. Useful for checking a model or a
prompt without starting an adventure.
Deliberately client-side: the conversation lives in localStorage, not the
database. Nothing here is part of an adventure, so there's nothing worth a
migration — and a refresh still keeps what you were poking at. */
import { useCallback, useEffect, useRef, useState } from 'react'
import { useNavigate, useOutletContext } from 'react-router-dom'
import { api } from '../api'
import { useToast } from '../components'
@@ -64,8 +63,6 @@ function Message({ message, onDelete }) {
}
export default function Chat() {
const { me } = useOutletContext() ?? {}
const navigate = useNavigate()
const toast = useToast()
const initial = useRef(load()).current
@@ -88,10 +85,6 @@ export default function Chat() {
const inputRef = useRef(null)
const pinnedRef = useRef(true)
// me is null until /auth/me resolves; only bounce once we know.
useEffect(() => {
if (me && !me.power_user) navigate('/', { replace: true })
}, [me, navigate])
useEffect(() => {
api.getChatConfig().then(setConfig).catch(() => setConfig(null))
@@ -226,7 +219,6 @@ export default function Chat() {
}
}
if (me && !me.power_user) return null
const waitingForFirstToken = streaming === '' && reasoningStream === null
const canRegenerate = !busy && messages.some((m) => m.role === 'user')
@@ -1,99 +0,0 @@
// The left drawer: the script state an adventure's scripts read and write.
//
// `StateTree` and `StateValue` render a value of any shape, because script
// state is whatever the scripts put there.
import { useCallback, useEffect, useState } from 'react'
import { api } from '../../../api'
// Renders one script-state value. A primitive renders inline, typed and
// colored. An object or an array renders as a collapsible indented tree, and
// the render recurses, so deep state shows its structure rather than one flat
// JSON blob.
function StateValue({ value, depth = 0 }) {
if (value === null || value === undefined) return <span className="jv-null">null</span>
if (typeof value === 'boolean') return <span className="jv-bool">{String(value)}</span>
if (typeof value === 'number') return <span className="jv-number">{value}</span>
if (typeof value === 'string') return <span className="jv-string">{value}</span>
if (Array.isArray(value)) return <StateTree entries={value.map((v, i) => [i, v])} empty="[ ]" depth={depth} />
if (typeof value === 'object') return <StateTree entries={Object.entries(value)} empty="{ }" depth={depth} />
return <span className="jv-string">{String(value)}</span>
}
// Only the first level is expanded by default (depth 0); nested trees start
// collapsed and can be opened on demand.
function StateTree({ entries, empty, depth = 0 }) {
const [open, setOpen] = useState(depth < 1)
if (entries.length === 0) return <span className="jv-empty">{empty}</span>
return (
<div className="jv-tree">
<button className="jv-toggle" onClick={() => setOpen((o) => !o)}>
{open ? '▾' : '▸'} {entries.length} {entries.length === 1 ? 'item' : 'items'}
</button>
{open && (
<ul className="jv-children">
{entries.map(([k, v]) => (
<li key={k}>
<span className="jv-key">{k}</span>
<StateValue value={v} depth={depth + 1} />
</li>
))}
</ul>
)}
</div>
)
}
// Collapsible left rail showing the scripting `state` object — every variable
// scripts read/write via state.x, refreshed after each turn.
function StatusDrawer({ advId, refreshKey }) {
const [open, setOpen] = useState(false)
const [state, setState] = useState(null)
const [failed, setFailed] = useState(false)
const load = useCallback(() => {
api.getScriptState(advId)
.then((r) => { setState(r.state || {}); setFailed(false) })
.catch(() => setFailed(true))
}, [advId])
// Only fetch while open; re-fetch after each turn so values stay live.
useEffect(() => { if (open) load() }, [open, refreshKey, load])
const entries = state ? Object.entries(state) : []
return (
<div className={`status-drawer ${open ? 'open' : ''}`}>
<button className="status-toggle" onClick={() => setOpen((o) => !o)}
title="Script state variables">
{open ? '‹' : '›'}<span className="status-toggle-label">State</span>
</button>
{open && (
<div className="status-body">
<div className="side-panel-header">
<h2>Script State</h2>
<button onClick={load} title="Refresh">↻</button>
</div>
{failed ? (
<div className="empty">Couldn’t load state.</div>
) : entries.length === 0 ? (
<div className="empty">
No variables yet. Scripts that use <code>state</code> will appear here after a turn.
</div>
) : (
<ul className="status-vars">
{entries.map(([k, v]) => (
<li key={k}>
<span className="status-key">{k}</span>
<div className="status-val"><StateValue value={v} /></div>
</li>
))}
</ul>
)}
</div>
)}
</div>
)
}
export { StatusDrawer }
-2
View File
@@ -6,7 +6,6 @@
const SECTION_LABELS = {
narrator: 'Narrator prompt',
script_context: 'Script context',
ai_instructions: 'AI Instructions',
plot_essentials: 'Plot Essentials',
story_summary: 'Story Summary',
@@ -32,7 +31,6 @@ const SECTION_COLORS = {
narrator: '#7d8fc9',
ai_instructions: '#9c8fd6',
plot_essentials: '#c97dc0',
script_context: '#d99ad0',
story_summary: '#7dc9a2',
used_memories: '#5fb8c9',
world_lore: '#c9b47d',
+4 -25
View File
@@ -17,13 +17,11 @@ import { api } from '../../api'
import { AutoTextarea } from '../../components'
import { StateChangeChips } from './reports'
import { TakePager } from './TakePager'
import { StatusDrawer } from './drawers/StatusDrawer'
import { WorldStateDrawer } from './drawers/WorldStateDrawer'
import { BranchPanel } from './panels/BranchPanel'
import { InsightsPanel } from './panels/InsightsPanel'
import { MemoryPanel } from './panels/MemoryPanel'
import { PlotPanel } from './panels/PlotPanel'
import { ScriptsPanel } from './panels/ScriptsPanel'
const MODES = ['do', 'say', 'story']
const PLAYER_TYPES = ['do', 'say', 'story']
@@ -175,7 +173,7 @@ export default function Play() {
setActions(page.actions)
setTotal(page.total)
setHasMore(page.has_more)
// The script and world state come back to what that branch's tip left
// The world state comes back to what that branch's tip left
// behind, so anything drawn from them is now showing another line's
// numbers until it re-reads.
setStateKey((k) => k + 1)
@@ -264,15 +262,6 @@ export default function Play() {
}
}, [actions, streaming, reasoningStream])
const handleScriptReport = useCallback((script) => {
if (!script) return
if (script.errors?.length) {
setToast({ text: `Script error: ${script.errors[0]}`, isError: true })
} else if (script.message) {
setToast({ text: script.message, isError: false })
}
}, [])
const handleEvent = useCallback((event) => {
if (event.type === 'player') {
setActions((prev) => [...prev, event.action])
@@ -288,17 +277,12 @@ export default function Play() {
setReasoningStream(null)
setActions((prev) => [...prev, event.action])
setTotal((n) => n + 1)
handleScriptReport(event.script)
} else if (event.type === 'stopped') {
setStreaming(null)
setReasoningStream(null)
handleScriptReport(event.script)
} else if (event.type === 'error') {
setStreaming(null)
setReasoningStream(null)
setToast({ text: event.detail, isError: true })
}
}, [handleScriptReport])
}, [])
async function runTurn(run) {
const controller = new AbortController()
@@ -483,7 +467,7 @@ export default function Play() {
setActions(adv.actions)
setTotal(adv.action_count ?? adv.actions.length)
setHasMore(adv.actions.length < (adv.action_count ?? adv.actions.length))
// The branch, the script state and the world state can all have moved.
// The branch and the world state can both have moved.
setStateKey((k) => k + 1)
} catch { /* stale beats wrong */ }
}
@@ -516,7 +500,6 @@ export default function Play() {
without changing `actions.length` by one. */}
<WorldStateDrawer advId={id} refreshKey={`${actions.length}:${stateKey}`}
personaName={adventure?.persona_name} />
<StatusDrawer advId={id} refreshKey={`${actions.length}:${stateKey}`} />
<div className="page play-page">
<div className="page-header">
<h1>{adventure.title}</h1>
@@ -525,8 +508,6 @@ export default function Play() {
onClick={() => setPanel(panel === 'plot' ? null : 'plot')}>Plot</button>
<button className={panel === 'memory' ? 'active' : ''}
onClick={() => setPanel(panel === 'memory' ? null : 'memory')}>Memory</button>
<button className={panel === 'scripts' ? 'active' : ''}
onClick={() => setPanel(panel === 'scripts' ? null : 'scripts')}>Scripts</button>
<button className={panel === 'branches' ? 'active' : ''}
onClick={() => setPanel(panel === 'branches' ? null : 'branches')}>Branches</button>
<button className={panel === 'insights' ? 'active' : ''}
@@ -731,7 +712,7 @@ export default function Play() {
{panel && (
<div className="side-panel">
<div className="side-panel-header">
<h2>{{ plot: 'Plot Components', memory: 'Memory Bank', scripts: 'Scripts', branches: 'Branches', insights: 'Insights' }[panel]}</h2>
<h2>{{ plot: 'Plot Components', memory: 'Memory Bank', branches: 'Branches', insights: 'Insights' }[panel]}</h2>
<button onClick={() => setPanel(null)}>✕</button>
</div>
{panel === 'plot' ? (
@@ -743,8 +724,6 @@ export default function Play() {
// but deleting a branch deletes the memories that hung off it,
// and that happens without a turn being played.
refreshKey={`${actions.length}:${stateKey}`} />
) : panel === 'scripts' ? (
<ScriptsPanel advId={id} />
) : panel === 'branches' ? (
<BranchPanel
advId={id}
@@ -3,7 +3,7 @@
import { useEffect, useState } from 'react'
import { api } from '../../../api'
import { SECTION_LABELS, pctLabel, sectionColor } from '../format'
import { CacheReport, ScriptReport, TokenBreakdown, WorldStateReport } from '../reports'
import { CacheReport, TokenBreakdown, WorldStateReport } from '../reports'
function InsightsPanel({ advId, inspectActionId, onClearInspect, refreshKey }) {
const [report, setReport] = useState(null)
@@ -104,7 +104,6 @@ function InsightsPanel({ advId, inspectActionId, onClearInspect, refreshKey }) {
<pre>{s.text}</pre>
</div>
))}
<ScriptReport script={report.script} />
<WorldStateReport worldState={report.world_state} />
{report.raw_output && (
<div className="ctx-section">
@@ -1,116 +0,0 @@
// The scripts panel: this adventure's copies of the library scripts.
import { useEffect, useState } from 'react'
import { api } from '../../../api'
import { downloadJSON } from '../../../components'
const SCRIPT_HOOKS = [
['Shared library', 'library_js'],
['onInput', 'input_js'],
['onModelContext', 'context_js'],
['onOutput', 'output_js'],
]
function ScriptsPanel({ advId }) {
const [scripts, setScripts] = useState(null)
const [openId, setOpenId] = useState(null)
useEffect(() => {
api.listAdventureScripts(advId).then(setScripts).catch(() => setScripts([]))
}, [advId])
const [syncingId, setSyncingId] = useState(null)
const toggle = async (script) => {
const updated = await api.updateAdventureScript(advId, script.id, { enabled: !script.enabled })
setScripts((prev) => prev.map((s) => (s.id === script.id ? updated : s)))
}
// Pull the latest code from the library script this copy was made from.
const sync = async (script) => {
setSyncingId(script.id)
try {
const updated = await api.syncAdventureScript(advId, script.id)
setScripts((prev) => prev.map((s) => (s.id === script.id ? updated : s)))
} finally {
setSyncingId(null)
}
}
// Download as an import-compatible bundle (matches /scripts export), so demo
// scripts can be forked into your own library via the Scripts page's Import.
const download = (s) => {
downloadJSON(
{
name: s.name,
description: s.description,
library: s.library_js,
input: s.input_js,
context: s.context_js,
output: s.output_js,
},
`${(s.name || 'script').replace(/\W+/g, '-')}.json`,
)
}
if (!scripts) return <div className="empty">Loading…</div>
if (scripts.length === 0) {
return (
<div className="empty">
No scripts on this adventure. Attach scripts to a scenario before starting an
adventure from it.
</div>
)
}
return (
<div>
{scripts.map((s) => {
const hooks = SCRIPT_HOOKS.filter(([, f]) => (s[f] || '').trim())
const open = openId === s.id
return (
<div key={s.id} className="adv-script">
<div className="adv-script-head">
<label className="script-attach" style={{ margin: 0 }}>
<input type="checkbox" checked={s.enabled} onChange={() => toggle(s)} />
<span>{s.name}</span>
</label>
<div style={{ display: 'flex', gap: 12, flexShrink: 0 }}>
{s.out_of_date && (
<button
className="linklike"
title="Replace this adventure's copy with the latest from your script library"
disabled={syncingId === s.id}
onClick={() => sync(s)}
>
{syncingId === s.id ? 'Syncing…' : '⟳ Sync from library'}
</button>
)}
<button className="linklike" onClick={() => setOpenId(open ? null : s.id)}>
{open ? 'Hide code' : 'View code'}
</button>
<button className="linklike" onClick={() => download(s)}>Download</button>
</div>
</div>
{s.description && <div className="dim adv-script-desc">{s.description}</div>}
{open && (
<div className="adv-script-code">
{hooks.length === 0 ? (
<div className="empty">This script has no code.</div>
) : (
hooks.map(([label, f]) => (
<div key={f}>
<div className="adv-script-hook">{label}</div>
<pre>{s[f]}</pre>
</div>
))
)}
</div>
)}
</div>
)
})}
</div>
)
}
export { ScriptsPanel }
+3 -40
View File
@@ -1,5 +1,5 @@
// What a turn did, rendered five ways: world-state chips, the world-state
// report, the script report, the cache report, and the token breakdown.
// What a turn did, rendered four ways: world-state chips, the world-state
// report, the cache report, and the token breakdown.
//
// These read a turn's result and render it. None of them fetch.
@@ -109,43 +109,6 @@ function WorldStateReport({ worldState }) {
)
}
function ScriptReport({ script }) {
if (!script || (!script.logs?.length && !script.errors?.length && !script.context_changed)) {
return null
}
return (
<div className="script-report">
<div className="ctx-header" style={{ padding: '4px 0 2px' }}><span>Scripts</span></div>
{script.errors?.map((e, i) => <div key={i} className="script-error">⚠ {e}</div>)}
{script.logs?.length > 0 && (
<pre className="script-logs">{script.logs.join('\n')}</pre>
)}
{script.context_changed && (
<>
<div className="ctx-section" style={{ borderLeftColor: sectionColor('script_context') }}>
<div className="ctx-header" style={{ color: sectionColor('script_context') }}>
<span>Context before script</span>
</div>
<pre>{script.context_before}</pre>
</div>
<div className="ctx-section" style={{ borderLeftColor: sectionColor('script_context') }}>
<div className="ctx-header" style={{ color: sectionColor('script_context') }}>
<span>Context after script (sent to AI)</span>
</div>
<pre>{script.context_after}</pre>
</div>
</>
)}
</div>
)
}
// What the endpoint charged for the turn, and how much of the prompt it read
// back out of its cache instead of billing in full. Only shown on a past turn:
// the "next turn" view has not been sent anywhere yet, so it has no usage. A
// cached read costs a tenth of a fresh one, which is the whole reason the
// prompt is laid out static-first — so this is the number that says whether
// that layout is working.
function CacheReport({ usage }) {
if (!usage) return null
const prompt = usage.prompt_tokens || 0
@@ -244,4 +207,4 @@ function TokenBreakdown({ sections, tokens, used, onJump }) {
)
}
export { StateChangeChips, WorldStateReport, ScriptReport, CacheReport, TokenBreakdown }
export { StateChangeChips, WorldStateReport, CacheReport, TokenBreakdown }
-31
View File
@@ -10,7 +10,6 @@ export default function ScenarioEditor() {
const { id } = useParams()
const navigate = useNavigate()
const [scenario, setScenario] = useState(null)
const [allScripts, setAllScripts] = useState([])
const [status, setStatus] = useState('')
// Raw text buffer for the stat_schema JSON editor + a live parse error.
const [schemaText, setSchemaText] = useState('')
@@ -27,7 +26,6 @@ export default function ScenarioEditor() {
setSchemaText(s.stat_schema ? JSON.stringify(s.stat_schema, null, 2) : '')
setParsedSchema(s.stat_schema || null)
}).catch(() => navigate('/scenarios'))
api.listScripts().then(setAllScripts).catch(() => {})
}, [id, navigate])
const setField = (field, value) => {
@@ -149,15 +147,6 @@ export default function ScenarioEditor() {
}
}
const toggleScript = async (scriptId) => {
const current = scenario.scripts.map((s) => s.id)
const next = current.includes(scriptId)
? current.filter((sid) => sid !== scriptId)
: [...current, scriptId]
const updated = await api.updateScenario(id, { script_ids: next })
setScenario(updated)
}
const exportScenario = async () => {
downloadJSON(await api.exportScenario(id), `${scenario.title.replace(/\W+/g, '-')}.json`)
}
@@ -297,26 +286,6 @@ export default function ScenarioEditor() {
</div>
)}
<div className="page-header" style={{ marginTop: 28 }}>
<h2 style={{ margin: 0, fontFamily: 'Georgia, serif', fontSize: '1.2rem' }}>Attached Scripts</h2>
</div>
{allScripts.length === 0 ? (
<div className="empty" style={{ padding: '20px 0' }}>
No scripts in your library. Create some on the Scripts page.
</div>
) : (
allScripts.map((s) => (
<label key={s.id} className="script-attach">
<input
type="checkbox"
checked={scenario.scripts.some((att) => att.id === s.id)}
onChange={() => toggleScript(s.id)}
/>
<span>{s.name}</span>
<span className="dim">{s.description}</span>
</label>
))
)}
</fieldset>
</div>
)
-149
View File
@@ -1,149 +0,0 @@
import { useEffect, useState } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import CodeMirror from '@uiw/react-codemirror'
import { javascript } from '@codemirror/lang-javascript'
import { oneDark } from '@codemirror/theme-one-dark'
import { api } from '../api'
import { Field, downloadJSON } from '../components'
import { useDebouncedSave } from '../hooks/useDebouncedSave'
const SLOTS = [
{ key: 'library_js', label: 'Library', hint: 'Shared code prepended to all three hooks.' },
{ key: 'input_js', label: 'Input', hint: "onInput — modifies the player's input before context construction." },
{ key: 'context_js', label: 'Context', hint: 'onModelContext — modifies the assembled text sent to the model.' },
{ key: 'output_js', label: 'Output', hint: 'onOutput — modifies the model output before it is shown.' },
]
export default function ScriptEditor() {
const { id } = useParams()
const navigate = useNavigate()
const [script, setScript] = useState(null)
const [slot, setSlot] = useState('input_js')
const [status, setStatus] = useState('')
const debounceSave = useDebouncedSave()
// Test-run state
const [testHook, setTestHook] = useState('input')
const [testText, setTestText] = useState('> You look around.')
const [testState, setTestState] = useState('{}')
const [testResult, setTestResult] = useState(null)
useEffect(() => {
api.getScript(id).then(setScript).catch(() => navigate('/scripts'))
}, [id, navigate])
const setField = (field, value) => {
setScript((prev) => ({ ...prev, [field]: value }))
debounceSave(field, async () => {
await api.updateScript(id, { [field]: value })
setStatus('Saved')
setTimeout(() => setStatus(''), 1500)
})
}
const runTest = async () => {
let state
try {
state = JSON.parse(testState || '{}')
} catch {
setTestResult({ error: 'Test state is not valid JSON' })
return
}
try {
setTestResult(await api.testScript(id, { hook: testHook, text: testText, state }))
} catch (err) {
setTestResult({ error: err.message })
}
}
const exportScript = async () => {
downloadJSON(await api.exportScript(id), `${script.name.replace(/\W+/g, '-')}.json`)
}
const deleteScript = async () => {
if (!confirm('Delete this script permanently?')) return
await api.deleteScript(id)
navigate('/scripts')
}
if (!script) return null
const activeSlot = SLOTS.find((s) => s.key === slot)
return (
<div className="page">
<div className="page-header">
<h1>Edit Script</h1>
<div style={{ display: 'flex', gap: 10, alignItems: 'center' }}>
<span style={{ color: 'var(--text-dim)', fontSize: '0.85rem' }}>{status}</span>
<button onClick={exportScript}>Export</button>
<button className="danger" onClick={deleteScript}>Delete</button>
</div>
</div>
<Field label="Name" value={script.name} onChange={(v) => setField('name', v)} />
<Field label="Description" value={script.description} onChange={(v) => setField('description', v)}
textarea rows={2} placeholder="What this script does." />
<div className="slot-tabs">
{SLOTS.map((s) => (
<button key={s.key} className={slot === s.key ? 'active' : ''} onClick={() => setSlot(s.key)}>
{s.label}{script[s.key].trim() ? ' •' : ''}
</button>
))}
</div>
<div className="slot-hint">{activeSlot.hint}</div>
<CodeMirror
value={script[slot]}
height="360px"
theme={oneDark}
extensions={[javascript()]}
onChange={(value) => setField(slot, value)}
/>
<div className="page-header" style={{ marginTop: 26 }}>
<h2 style={{ margin: 0, fontFamily: 'Georgia, serif', fontSize: '1.2rem' }}>Test Run</h2>
</div>
<div className="test-run">
<div className="row">
<label className="field" style={{ flex: '0 0 140px' }}>
<span className="label">Hook</span>
<select value={testHook} onChange={(e) => setTestHook(e.target.value)}>
<option value="input">Input</option>
<option value="context">Context</option>
<option value="output">Output</option>
</select>
</label>
<label className="field" style={{ flex: 1 }}>
<span className="label">Sample text</span>
<input type="text" value={testText} onChange={(e) => setTestText(e.target.value)} />
</label>
</div>
<label className="field">
<span className="label">State (JSON)</span>
<input type="text" value={testState} onChange={(e) => setTestState(e.target.value)} />
</label>
<button className="primary" onClick={runTest}>Run</button>
{testResult && (
<div className="test-result">
{testResult.error ? (
<div className="test-error">{testResult.error}</div>
) : (
<>
<div><span className="label">text</span> <pre>{testResult.text}</pre></div>
{testResult.stop && <div><span className="label">stop</span> true — the AI call would be skipped</div>}
<div><span className="label">state</span> <pre>{JSON.stringify(testResult.state)}</pre></div>
{testResult.storyCards.length > 0 && (
<div><span className="label">storyCards</span> <pre>{JSON.stringify(testResult.storyCards, null, 1)}</pre></div>
)}
{testResult.logs.length > 0 && (
<div><span className="label">logs</span> <pre>{testResult.logs.join('\n')}</pre></div>
)}
</>
)}
</div>
)}
</div>
</div>
)
}
-64
View File
@@ -1,64 +0,0 @@
import { useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { api } from '../api'
import { pickJSONFile, useToast } from '../components'
export default function Scripts() {
const [scripts, setScripts] = useState(null)
const navigate = useNavigate()
const toast = useToast()
useEffect(() => {
api.listScripts().then(setScripts).catch(() => setScripts([]))
}, [])
const createScript = async () => {
const script = await api.createScript({
name: 'New Script',
input_js: '// onInput — modify the player\'s input\nconst modifier = (text) => {\n return { text }\n}\nmodifier(text)\n',
})
navigate(`/scripts/${script.id}`)
}
const importScript = async () => {
try {
const bundle = await pickJSONFile()
const script = await api.importScript(bundle)
navigate(`/scripts/${script.id}`)
} catch (err) {
toast(err.message, 'error')
}
}
return (
<div className="page">
<div className="page-header">
<h1>Scripts</h1>
<div style={{ display: 'flex', gap: 10 }}>
<button onClick={importScript}>Import</button>
<button className="primary" onClick={createScript}>+ New Script</button>
</div>
</div>
{scripts === null ? null : scripts.length === 0 ? (
<div className="empty">
No scripts yet. Scripts are AI Dungeon-compatible JavaScript modifiers
(onInput / onModelContext / onOutput) you can attach to scenarios.
</div>
) : (
<div className="card-grid">
{scripts.map((s) => (
<div key={s.id} className="card" onClick={() => navigate(`/scripts/${s.id}`)}>
<h3>{s.name}</h3>
<p>{s.description || 'No description'}</p>
<div className="meta">
{['library', 'input', 'context', 'output']
.filter((slot) => s[`${slot}_js`].trim())
.join(' · ') || 'empty'}
</div>
</div>
))}
</div>
)}
</div>
)
}
+19 -58
View File
@@ -1,5 +1,4 @@
import { useEffect, useState } from 'react'
import { useOutletContext } from 'react-router-dom'
import { api } from '../api'
function DebugLog() {
@@ -46,11 +45,7 @@ function DebugLog() {
}
export default function Settings() {
const { me, setMe } = useOutletContext() ?? {}
const [settings, setSettings] = useState(null)
// The API key is write-only: the server only reports has_api_key, and this
// holds whatever new key the user has typed (empty = leave unchanged).
const [apiKey, setApiKey] = useState('')
const [testResult, setTestResult] = useState(null)
const [saved, setSaved] = useState('')
@@ -60,17 +55,11 @@ export default function Settings() {
const setField = (field, value) => setSettings({ ...settings, [field]: value })
const buildPayload = () => {
const { has_api_key: _hasKey, ...payload } = settings
if (apiKey.trim()) payload.api_key = apiKey.trim()
return payload
}
const buildPayload = () => ({ ...settings })
const afterSave = async () => {
const fresh = await api.getSettings()
setSettings(fresh)
setApiKey('')
if (me?.multi_user) api.getMe().then(setMe).catch(() => {}) // demo banner state
return fresh
}
@@ -85,17 +74,6 @@ export default function Settings() {
setTimeout(() => setSaved(''), 4000)
}
const clearKey = async () => {
try {
await api.updateSettings({ api_key: '' })
await afterSave()
setSaved('API key removed')
} catch (err) {
setSaved(`Failed: ${err.message}`)
}
setTimeout(() => setSaved(''), 4000)
}
const test = async () => {
setTestResult({ pending: true })
try {
@@ -108,7 +86,6 @@ export default function Settings() {
}
if (!settings) return null
const demo = me?.demo
return (
<div className="page" style={{ maxWidth: 640 }}>
@@ -117,34 +94,21 @@ export default function Settings() {
<span style={{ color: 'var(--text-dim)', fontSize: '0.85rem' }}>{saved}</span>
</div>
{demo?.using_demo && (
<div className="demo-banner">
<strong>Using the shared demo key</strong> — {demo.turns_left} of {demo.turns_per_day} free
turns left today (model: {demo.model}). Add your own API key below for unlimited play,
your choice of models, and the memory bank.
</div>
)}
<label className="field">
<span className="label">Endpoint URL (OpenAI-compatible)</span>
<span className="label">Ollama endpoint</span>
<input type="text" value={settings.endpoint_url}
placeholder="http://localhost:11434/v1"
placeholder="http://127.0.0.1:11434/v1"
onChange={(e) => setField('endpoint_url', e.target.value)} />
</label>
<label className="field">
<span className="label">API Key {settings.has_api_key ? '(saved — enter a new one to replace it)' : ''}</span>
<div style={{ display: 'flex', gap: 8 }}>
<input type="password" value={apiKey} style={{ flex: 1 }}
placeholder={settings.has_api_key ? '••••••••••••' : 'Leave empty for local endpoints'}
onChange={(e) => setApiKey(e.target.value)} />
{settings.has_api_key && (
<button type="button" onClick={clearKey}>Remove key</button>
)}
</div>
<span className="label" style={{ marginTop: 4 }}>
Ollama on this machine, or on a machine on your own network. A public
Internet address is refused — your story never leaves your own
infrastructure. An https:// endpoint whose certificate comes from your
own CA works once that CA is installed on this machine.
</span>
</label>
<label className="field">
<span className="label">Model</span>
<input type="text" value={settings.model} placeholder="e.g. llama3.1"
<input type="text" value={settings.model} placeholder="e.g. qwen2.5:3b-instruct"
onChange={(e) => setField('model', e.target.value)} />
</label>
@@ -166,16 +130,13 @@ export default function Settings() {
</label>
</div>
<label className="field">
<span className="label">Reasoning budget (tokens)</span>
<input type="number" min="-1" value={settings.reasoning_max_tokens}
onChange={(e) => setField(
'reasoning_max_tokens', Math.max(-1, Number(e.target.value)))} />
<span className="label">Model timeout (seconds)</span>
<input type="number" min="30" max="3600" value={settings.model_timeout_seconds}
onChange={(e) => setField('model_timeout_seconds', Number(e.target.value))} />
<span className="label" style={{ marginTop: 4 }}>
For reasoning models: separate thinking budget on top of max output tokens,
and thinking is shown collapsed above each response. 0 = nothing extra is
sent (keep 0 for endpoints without reasoning support, e.g. Ollama).
−1 = actively turn reasoning off, for models that think by default
(DeepSeek V4 Flash) — saves the thinking tokens rather than just hiding them.
How long to wait for a turn. Loading a model for the first time can
take minutes on a machine without a GPU, while later turns take
seconds. Raise this if turns time out only after Ollama has been idle.
</span>
</label>
@@ -232,13 +193,13 @@ export default function Settings() {
<div style={{ marginTop: 16, color: testResult.ok ? 'var(--accent)' : 'var(--danger)' }}>
{testResult.pending ? 'Testing…'
: testResult.ok
? `Connected. Models: ${testResult.models?.slice(0, 8).join(', ') || '(none listed)'}`
? (testResult.warning
|| `Connected. Models: ${testResult.models?.slice(0, 8).join(', ') || '(none listed)'}`)
: testResult.detail}
</div>
)}
{/* The provider debug log is a single global buffer — local installs only. */}
{me?.multi_user !== true && <DebugLog />}
<DebugLog />
</div>
)
}
-68
View File
@@ -1,68 +0,0 @@
# Render Blueprint — https://render.com/docs/blueprint-spec
#
# One web service: the multi-stage Dockerfile builds the React SPA and the
# FastAPI backend into a single image that serves both same-origin. Database
# is external Neon Postgres (set AIDND_DATABASE_URL in the dashboard); the
# free tier has no persistent disk, which is why the DB lives off-box.
#
# First deploy: create a Blueprint from this repo in the Render dashboard,
# then fill the `sync: false` secrets (Neon URL + demo key). Pushes to `main`
# auto-deploy thereafter.
services:
- type: web
name: ai-dnd
runtime: docker
dockerfilePath: ./Dockerfile
dockerContext: .
plan: free # sleeps after ~15 min idle; first wake takes ~30-60s
region: virginia # us-east, closest to the Neon us-east-1 database
healthCheckPath: /api/health
autoDeploy: true
envVars:
# Multi-user hardening on (guest sessions, per-user data, rate limits).
- key: AIDND_MULTI_USER
value: "1"
# Signs session cookies + encrypts stored API keys. Render generates a
# strong value once and keeps it stable across deploys (a regenerated
# secret would log out every user on each deploy).
- key: AIDND_SECRET_KEY
generateValue: true
# Neon Postgres connection string (pooled, sslmode=require). Secret —
# set it in the dashboard; never commit it.
- key: AIDND_DATABASE_URL
sync: false
# --- Shared demo key (optional): lets first-time visitors play without
# bringing their own API key. Set these in the dashboard to enable;
# leave unset to require BYOK. ---
- key: AIDND_DEMO_API_KEY
sync: false
- key: AIDND_DEMO_MODELS
sync: false
- key: AIDND_DEMO_TURNS_PER_DAY
value: "20"
# Comma-separated emails of trusted testers: unmetered demo turns, plus
# the AI Chat page (hidden from everyone else). Set in the dashboard;
# leave unset and nobody gets it. Registered accounts only.
- key: AIDND_POWER_USERS
sync: false
# Comma-separated emails allowed to see the Visitors dashboard
# (/analytics). Separate from AIDND_POWER_USERS on purpose — an unmetered
# tester is not automatically someone who sees the traffic. Leave unset
# and the page is invisible to everyone. Set it in the dashboard.
- key: AIDND_ANALYTICS_EMAILS
sync: false
# Guest retention: one account is minted per first-time visitor, so idle
# ones are collected (with their adventures) to keep the free-tier
# Postgres from filling with abandoned demo data. Registered accounts are
# never touched. 0 would disable it.
- key: AIDND_GUEST_RETENTION_DAYS
value: "5"
# CORS is unset on purpose: the SPA is served same-origin by FastAPI.