From 8c65ae99deda49b22f415d5868987e00bbcb173c Mon Sep 17 00:00:00 2001 From: JesseMarkowitz Date: Wed, 2 Sep 2026 11:27:14 -0400 Subject: [PATCH] M2: cut the hosted product away from the local one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- DEVELOPMENT.md | 50 +- Dockerfile | 36 +- PROVENANCE.md | 23 + backend/.env.example | 114 +--- backend/app/accesslog.py | 168 ----- backend/app/analytics.py | 583 ------------------ backend/app/attempts.py | 13 +- backend/app/auth.py | 253 +------- backend/app/bundle.py | 28 +- backend/app/cleanup.py | 178 ------ backend/app/database.py | 65 +- backend/app/endpoints.py | 179 ++++++ backend/app/limits.py | 224 +------ backend/app/main.py | 99 +-- backend/app/migrations.py | 24 +- backend/app/models.py | 208 +------ backend/app/netguard.py | 52 -- backend/app/providers/openai_compatible.py | 136 ++-- backend/app/routers/adventures/__init__.py | 1 - backend/app/routers/adventures/bundle_io.py | 4 +- backend/app/routers/adventures/crud.py | 36 +- backend/app/routers/adventures/insights.py | 11 +- backend/app/routers/adventures/scripts.py | 116 ---- backend/app/routers/adventures/takes.py | 13 +- backend/app/routers/adventures/turns.py | 115 +--- backend/app/routers/analytics.py | 140 ----- backend/app/routers/auth.py | 158 ----- backend/app/routers/chat.py | 115 ++-- backend/app/routers/debug.py | 15 +- backend/app/routers/scenarios.py | 41 +- backend/app/routers/scripts.py | 159 ----- backend/app/routers/settings.py | 130 ++-- backend/app/routers/story_cards.py | 1 - backend/app/schemas.py | 81 +-- backend/app/scripting/__init__.py | 4 - backend/app/scripting/engine.py | 146 ----- backend/app/scripting/pipeline.py | 112 ---- backend/app/security.py | 131 ---- backend/app/seed.py | 45 +- backend/app/sse.py | 14 +- backend/requirements.txt | 3 - backend/tests/fakes.py | 41 ++ backend/tests/test_accesslog.py | 208 ------- backend/tests/test_action_paging.py | 1 - backend/tests/test_analytics.py | 385 ------------ backend/tests/test_attempt_siblings.py | 19 +- backend/tests/test_branch_clause.py | 14 - backend/tests/test_branch_forking.py | 37 +- backend/tests/test_branch_management.py | 3 - backend/tests/test_bundle_v2.py | 25 +- backend/tests/test_chat.py | 208 ++----- backend/tests/test_delete_state.py | 43 +- backend/tests/test_egress.py | 1 - backend/tests/test_embedding_model_switch.py | 1 - backend/tests/test_endpoint_policy.py | 224 +++++++ backend/tests/test_guest_cleanup.py | 205 ------ backend/tests/test_local_only_surface.py | 181 ++++++ backend/tests/test_memory_rewrite.py | 51 +- backend/tests/test_netguard.py | 60 -- backend/tests/test_persona.py | 1 - backend/tests/test_prompt_caching.py | 51 +- backend/tests/test_ratelimit_hardening.py | 99 --- backend/tests/test_reasoning_param.py | 45 -- backend/tests/test_retry_variants.py | 43 +- backend/tests/test_scenario_art.py | 1 - backend/tests/test_scenario_refresh.py | 1 - backend/tests/test_state_revert.py | 40 +- backend/tests/test_story_tree_baseline.py | 55 +- backend/tests/test_take_edit.py | 3 - backend/tests/test_take_parentage.py | 6 - backend/tests/test_take_state.py | 30 +- backend/tests/test_tree_migration.py | 1 - backend/tests/test_turn_flow_integration.py | 61 +- backend/tests/test_worldstate_integration.py | 3 - backend/tools/rewrite_memories.py | 78 +-- frontend/package-lock.json | 247 -------- frontend/package.json | 3 - frontend/src/App.jsx | 100 +-- frontend/src/api.js | 68 +- frontend/src/components.jsx | 105 ---- frontend/src/main.jsx | 8 - frontend/src/pages/Analytics.jsx | 476 -------------- frontend/src/pages/Chat.jsx | 14 +- .../src/pages/Play/drawers/StatusDrawer.jsx | 99 --- frontend/src/pages/Play/format.js | 2 - frontend/src/pages/Play/index.jsx | 29 +- .../src/pages/Play/panels/InsightsPanel.jsx | 3 +- .../src/pages/Play/panels/ScriptsPanel.jsx | 116 ---- frontend/src/pages/Play/reports.jsx | 43 +- frontend/src/pages/ScenarioEditor.jsx | 31 - frontend/src/pages/ScriptEditor.jsx | 149 ----- frontend/src/pages/Scripts.jsx | 64 -- frontend/src/pages/Settings.jsx | 77 +-- render.yaml | 68 -- 94 files changed, 1384 insertions(+), 6567 deletions(-) delete mode 100644 backend/app/accesslog.py delete mode 100644 backend/app/analytics.py delete mode 100644 backend/app/cleanup.py create mode 100644 backend/app/endpoints.py delete mode 100644 backend/app/netguard.py delete mode 100644 backend/app/routers/adventures/scripts.py delete mode 100644 backend/app/routers/analytics.py delete mode 100644 backend/app/routers/auth.py delete mode 100644 backend/app/routers/scripts.py delete mode 100644 backend/app/scripting/__init__.py delete mode 100644 backend/app/scripting/engine.py delete mode 100644 backend/app/scripting/pipeline.py delete mode 100644 backend/app/security.py delete mode 100644 backend/tests/test_accesslog.py delete mode 100644 backend/tests/test_analytics.py create mode 100644 backend/tests/test_endpoint_policy.py delete mode 100644 backend/tests/test_guest_cleanup.py create mode 100644 backend/tests/test_local_only_surface.py delete mode 100644 backend/tests/test_netguard.py delete mode 100644 backend/tests/test_ratelimit_hardening.py delete mode 100644 backend/tests/test_reasoning_param.py delete mode 100644 frontend/src/pages/Analytics.jsx delete mode 100644 frontend/src/pages/Play/drawers/StatusDrawer.jsx delete mode 100644 frontend/src/pages/Play/panels/ScriptsPanel.jsx delete mode 100644 frontend/src/pages/ScriptEditor.jsx delete mode 100644 frontend/src/pages/Scripts.jsx delete mode 100644 render.yaml diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 19ce1b4..ddf7875 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -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 diff --git a/Dockerfile b/Dockerfile index 9e17dce..59a3f23 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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"] diff --git a/PROVENANCE.md b/PROVENANCE.md index 7427ea0..cc6ef26 100644 --- a/PROVENANCE.md +++ b/PROVENANCE.md @@ -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 diff --git a/backend/.env.example b/backend/.env.example index 97e7e5c..8cafe34 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -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. diff --git a/backend/app/accesslog.py b/backend/app/accesslog.py deleted file mode 100644 index 361e1b8..0000000 --- a/backend/app/accesslog.py +++ /dev/null @@ -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} diff --git a/backend/app/analytics.py b/backend/app/analytics.py deleted file mode 100644 index 86c1489..0000000 --- a/backend/app/analytics.py +++ /dev/null @@ -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" # " " 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) diff --git a/backend/app/attempts.py b/backend/app/attempts.py index aeb4154..8647f08 100644 --- a/backend/app/attempts.py +++ b/backend/app/attempts.py @@ -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) diff --git a/backend/app/auth.py b/backend/app/auth.py index 20da441..15aa07d 100644 --- a/backend/app/auth.py +++ b/backend/app/auth.py @@ -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 diff --git a/backend/app/bundle.py b/backend/app/bundle.py index 9381244..21066db 100644 --- a/backend/app/bundle.py +++ b/backend/app/bundle.py @@ -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() diff --git a/backend/app/cleanup.py b/backend/app/cleanup.py deleted file mode 100644 index b727fd8..0000000 --- a/backend/app/cleanup.py +++ /dev/null @@ -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 diff --git a/backend/app/database.py b/backend/app/database.py index 20284ca..6943d05 100644 --- a/backend/app/database.py +++ b/backend/app/database.py @@ -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) diff --git a/backend/app/endpoints.py b/backend/app/endpoints.py new file mode 100644 index 0000000..47e90df --- /dev/null +++ b/backend/app/endpoints.py @@ -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 diff --git a/backend/app/limits.py b/backend/app/limits.py index 43ec35f..16a0c24 100644 --- a/backend/app/limits.py +++ b/backend/app/limits.py @@ -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 diff --git a/backend/app/main.py b/backend/app/main.py index f5dfaca..4f372db 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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 diff --git a/backend/app/migrations.py b/backend/app/migrations.py index 5270361..435011f 100644 --- a/backend/app/migrations.py +++ b/backend/app/migrations.py @@ -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}, - ) diff --git a/backend/app/models.py b/backend/app/models.py index dba938a..fdb9e4d 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -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 diff --git a/backend/app/netguard.py b/backend/app/netguard.py deleted file mode 100644 index 068d843..0000000 --- a/backend/app/netguard.py +++ /dev/null @@ -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 diff --git a/backend/app/providers/openai_compatible.py b/backend/app/providers/openai_compatible.py index 7d98675..d139127 100644 --- a/backend/app/providers/openai_compatible.py +++ b/backend/app/providers/openai_compatible.py @@ -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: diff --git a/backend/app/routers/adventures/__init__.py b/backend/app/routers/adventures/__init__.py index 0996c11..fdc2c48 100644 --- a/backend/app/routers/adventures/__init__.py +++ b/backend/app/routers/adventures/__init__.py @@ -32,7 +32,6 @@ from . import ( # noqa: F401 takes, branches, bundle_io, - scripts, refresh, insights, memories, diff --git a/backend/app/routers/adventures/bundle_io.py b/backend/app/routers/adventures/bundle_io.py index 3c1fd50..8cf3f24 100644 --- a/backend/app/routers/adventures/bundle_io.py +++ b/backend/app/routers/adventures/bundle_io.py @@ -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 diff --git a/backend/app/routers/adventures/crud.py b/backend/app/routers/adventures/crud.py index e2d2876..b90ac11 100644 --- a/backend/app/routers/adventures/crud.py +++ b/backend/app/routers/adventures/crud.py @@ -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), diff --git a/backend/app/routers/adventures/insights.py b/backend/app/routers/adventures/insights.py index 254f676..f86358c 100644 --- a/backend/app/routers/adventures/insights.py +++ b/backend/app/routers/adventures/insights.py @@ -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 diff --git a/backend/app/routers/adventures/scripts.py b/backend/app/routers/adventures/scripts.py deleted file mode 100644 index 5e878ec..0000000 --- a/backend/app/routers/adventures/scripts.py +++ /dev/null @@ -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 diff --git a/backend/app/routers/adventures/takes.py b/backend/app/routers/adventures/takes.py index 13bfb37..b292151 100644 --- a/backend/app/routers/adventures/takes.py +++ b/backend/app/routers/adventures/takes.py @@ -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, diff --git a/backend/app/routers/adventures/turns.py b/backend/app/routers/adventures/turns.py index 3f24d15..cae0fb9 100644 --- a/backend/app/routers/adventures/turns.py +++ b/backend/app/routers/adventures/turns.py @@ -3,8 +3,8 @@ Everything a test needs to intercept lives here, and other modules reach it as `turns.` 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) diff --git a/backend/app/routers/analytics.py b/backend/app/routers/analytics.py deleted file mode 100644 index 79bf8be..0000000 --- a/backend/app/routers/analytics.py +++ /dev/null @@ -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"], - } diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py deleted file mode 100644 index 138f200..0000000 --- a/backend/app/routers/auth.py +++ /dev/null @@ -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} diff --git a/backend/app/routers/chat.py b/backend/app/routers/chat.py index 379db67..fc9479b 100644 --- a/backend/app/routers/chat.py +++ b/backend/app/routers/chat.py @@ -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, ) diff --git a/backend/app/routers/debug.py b/backend/app/routers/debug.py index 37f26d4..0a01916 100644 --- a/backend/app/routers/debug.py +++ b/backend/app/routers/debug.py @@ -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() diff --git a/backend/app/routers/scenarios.py b/backend/app/routers/scenarios.py index 738624b..0dcb8a7 100644 --- a/backend/app/routers/scenarios.py +++ b/backend/app/routers/scenarios.py @@ -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") diff --git a/backend/app/routers/scripts.py b/backend/app/routers/scripts.py deleted file mode 100644 index 24ba879..0000000 --- a/backend/app/routers/scripts.py +++ /dev/null @@ -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 diff --git a/backend/app/routers/settings.py b/backend/app/routers/settings.py index aaeda5e..f169a0a 100644 --- a/backend/app/routers/settings.py +++ b/backend/app/routers/settings.py @@ -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 diff --git a/backend/app/routers/story_cards.py b/backend/app/routers/story_cards.py index 991a085..74da440 100644 --- a/backend/app/routers/story_cards.py +++ b/backend/app/routers/story_cards.py @@ -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: diff --git a/backend/app/schemas.py b/backend/app/schemas.py index 9ff2c28..02c81ee 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -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 diff --git a/backend/app/scripting/__init__.py b/backend/app/scripting/__init__.py deleted file mode 100644 index d8814e6..0000000 --- a/backend/app/scripting/__init__.py +++ /dev/null @@ -1,4 +0,0 @@ -from .engine import HookResult, run_hook -from .pipeline import ScriptPipeline - -__all__ = ["HookResult", "ScriptPipeline", "run_hook"] diff --git a/backend/app/scripting/engine.py b/backend/app/scripting/engine.py deleted file mode 100644 index 774a117..0000000 --- a/backend/app/scripting/engine.py +++ /dev/null @@ -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 [], - ) diff --git a/backend/app/scripting/pipeline.py b/backend/app/scripting/pipeline.py deleted file mode 100644 index 7dee364..0000000 --- a/backend/app/scripting/pipeline.py +++ /dev/null @@ -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} diff --git a/backend/app/security.py b/backend/app/security.py deleted file mode 100644 index c41f5a4..0000000 --- a/backend/app/security.py +++ /dev/null @@ -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..". 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 "" diff --git a/backend/app/seed.py b/backend/app/seed.py index 3b14cb1..1c29b48 100644 --- a/backend/app/seed.py +++ b/backend/app/seed.py @@ -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) diff --git a/backend/app/sse.py b/backend/app/sse.py index 21365d4..7366e68 100644 --- a/backend/app/sse.py +++ b/backend/app/sse.py @@ -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}) diff --git a/backend/requirements.txt b/backend/requirements.txt index 7572857..8de90bb 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -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 diff --git a/backend/tests/fakes.py b/backend/tests/fakes.py index 8b035c9..6c67efe 100644 --- a/backend/tests/fakes.py +++ b/backend/tests/fakes.py @@ -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)] diff --git a/backend/tests/test_accesslog.py b/backend/tests/test_accesslog.py deleted file mode 100644 index 5a1da0c..0000000 --- a/backend/tests/test_accesslog.py +++ /dev/null @@ -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"] == [] diff --git a/backend/tests/test_action_paging.py b/backend/tests/test_action_paging.py index f314f42..7bda050 100644 --- a/backend/tests/test_action_paging.py +++ b/backend/tests/test_action_paging.py @@ -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)): diff --git a/backend/tests/test_analytics.py b/backend/tests/test_analytics.py deleted file mode 100644 index f0e3eb4..0000000 --- a/backend/tests/test_analytics.py +++ /dev/null @@ -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] diff --git a/backend/tests/test_attempt_siblings.py b/backend/tests/test_attempt_siblings.py index c8c97ec..632659c 100644 --- a/backend/tests/test_attempt_siblings.py +++ b/backend/tests/test_attempt_siblings.py @@ -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)): diff --git a/backend/tests/test_branch_clause.py b/backend/tests/test_branch_clause.py index d557211..411897a 100644 --- a/backend/tests/test_branch_clause.py +++ b/backend/tests/test_branch_clause.py @@ -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)): diff --git a/backend/tests/test_branch_forking.py b/backend/tests/test_branch_forking.py index 8b5bb40..5ab2721 100644 --- a/backend/tests/test_branch_forking.py +++ b/backend/tests/test_branch_forking.py @@ -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 diff --git a/backend/tests/test_branch_management.py b/backend/tests/test_branch_management.py index 9d94090..35f2ea6 100644 --- a/backend/tests/test_branch_management.py +++ b/backend/tests/test_branch_management.py @@ -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)): diff --git a/backend/tests/test_bundle_v2.py b/backend/tests/test_bundle_v2.py index 9d97c0c..3b04442 100644 --- a/backend/tests/test_bundle_v2.py +++ b/backend/tests/test_bundle_v2.py @@ -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}) diff --git a/backend/tests/test_chat.py b/backend/tests/test_chat.py index 42acfce..36294df 100644 --- a/backend/tests/test_chat.py +++ b/backend/tests/test_chat.py @@ -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)], diff --git a/backend/tests/test_delete_state.py b/backend/tests/test_delete_state.py index 533d121..d7746d3 100644 --- a/backend/tests/test_delete_state.py +++ b/backend/tests/test_delete_state.py @@ -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 diff --git a/backend/tests/test_egress.py b/backend/tests/test_egress.py index 25e5bd1..3891bf8 100644 --- a/backend/tests/test_egress.py +++ b/backend/tests/test_egress.py @@ -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)): diff --git a/backend/tests/test_embedding_model_switch.py b/backend/tests/test_embedding_model_switch.py index 292d62d..a5a8e75 100644 --- a/backend/tests/test_embedding_model_switch.py +++ b/backend/tests/test_embedding_model_switch.py @@ -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)): diff --git a/backend/tests/test_endpoint_policy.py b/backend/tests/test_endpoint_policy.py new file mode 100644 index 0000000..02d6da0 --- /dev/null +++ b/backend/tests/test_endpoint_policy.py @@ -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" diff --git a/backend/tests/test_guest_cleanup.py b/backend/tests/test_guest_cleanup.py deleted file mode 100644 index 7e11b30..0000000 --- a/backend/tests/test_guest_cleanup.py +++ /dev/null @@ -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 diff --git a/backend/tests/test_local_only_surface.py b/backend/tests/test_local_only_surface.py new file mode 100644 index 0000000..30f9113 --- /dev/null +++ b/backend/tests/test_local_only_surface.py @@ -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 diff --git a/backend/tests/test_memory_rewrite.py b/backend/tests/test_memory_rewrite.py index efc0e5b..c681e38 100644 --- a/backend/tests/test_memory_rewrite.py +++ b/backend/tests/test_memory_rewrite.py @@ -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): diff --git a/backend/tests/test_netguard.py b/backend/tests/test_netguard.py deleted file mode 100644 index 4b4e962..0000000 --- a/backend/tests/test_netguard.py +++ /dev/null @@ -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 diff --git a/backend/tests/test_persona.py b/backend/tests/test_persona.py index 9eec972..1b4ec26 100644 --- a/backend/tests/test_persona.py +++ b/backend/tests/test_persona.py @@ -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)): diff --git a/backend/tests/test_prompt_caching.py b/backend/tests/test_prompt_caching.py index 0aa6c29..56f354c 100644 --- a/backend/tests/test_prompt_caching.py +++ b/backend/tests/test_prompt_caching.py @@ -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": {}}) diff --git a/backend/tests/test_ratelimit_hardening.py b/backend/tests/test_ratelimit_hardening.py deleted file mode 100644 index a83455f..0000000 --- a/backend/tests/test_ratelimit_hardening.py +++ /dev/null @@ -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 diff --git a/backend/tests/test_reasoning_param.py b/backend/tests/test_reasoning_param.py deleted file mode 100644 index 4b50baf..0000000 --- a/backend/tests/test_reasoning_param.py +++ /dev/null @@ -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") diff --git a/backend/tests/test_retry_variants.py b/backend/tests/test_retry_variants.py index 484188f..6788d31 100644 --- a/backend/tests/test_retry_variants.py +++ b/backend/tests/test_retry_variants.py @@ -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): diff --git a/backend/tests/test_scenario_art.py b/backend/tests/test_scenario_art.py index 5a0ad78..9c84520 100644 --- a/backend/tests/test_scenario_art.py +++ b/backend/tests/test_scenario_art.py @@ -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)): diff --git a/backend/tests/test_scenario_refresh.py b/backend/tests/test_scenario_refresh.py index 40e5dc8..4159e2b 100644 --- a/backend/tests/test_scenario_refresh.py +++ b/backend/tests/test_scenario_refresh.py @@ -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)): diff --git a/backend/tests/test_state_revert.py b/backend/tests/test_state_revert.py index 7a2f45e..dec9e08 100644 --- a/backend/tests/test_state_revert.py +++ b/backend/tests/test_state_revert.py @@ -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) diff --git a/backend/tests/test_story_tree_baseline.py b/backend/tests/test_story_tree_baseline.py index 0f2b699..d4bd8d8 100644 --- a/backend/tests/test_story_tree_baseline.py +++ b/backend/tests/test_story_tree_baseline.py @@ -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 diff --git a/backend/tests/test_take_edit.py b/backend/tests/test_take_edit.py index 6dd505e..3cbaad3 100644 --- a/backend/tests/test_take_edit.py +++ b/backend/tests/test_take_edit.py @@ -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)): diff --git a/backend/tests/test_take_parentage.py b/backend/tests/test_take_parentage.py index b965ff7..7d69bac 100644 --- a/backend/tests/test_take_parentage.py +++ b/backend/tests/test_take_parentage.py @@ -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)): diff --git a/backend/tests/test_take_state.py b/backend/tests/test_take_state.py index 34c2d9c..dc4e96e 100644 --- a/backend/tests/test_take_state.py +++ b/backend/tests/test_take_state.py @@ -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() diff --git a/backend/tests/test_tree_migration.py b/backend/tests/test_tree_migration.py index 4047bdb..541b006 100644 --- a/backend/tests/test_tree_migration.py +++ b/backend/tests/test_tree_migration.py @@ -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)): diff --git a/backend/tests/test_turn_flow_integration.py b/backend/tests/test_turn_flow_integration.py index 54bb25f..d1230e4 100644 --- a/backend/tests/test_turn_flow_integration.py +++ b/backend/tests/test_turn_flow_integration.py @@ -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 diff --git a/backend/tests/test_worldstate_integration.py b/backend/tests/test_worldstate_integration.py index d6473d9..a3ead3d 100644 --- a/backend/tests/test_worldstate_integration.py +++ b/backend/tests/test_worldstate_integration.py @@ -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)): diff --git a/backend/tools/rewrite_memories.py b/backend/tools/rewrite_memories.py index f30faeb..f034457 100644 --- a/backend/tools/rewrite_memories.py +++ b/backend/tools/rewrite_memories.py @@ -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 diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 68ac0b7..8f05d3d 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -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" } } } diff --git a/frontend/package.json b/frontend/package.json index 175a306..44efe43 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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" diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index a8da339..31d53cc 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -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 ( - - {authMode && ( - setAuthMode(null)} onAuthed={onAuthed} - retentionDays={me?.guest_retention_days} /> - )} + ) } diff --git a/frontend/src/api.js b/frontend/src/api.js index 6287efd..6599428 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -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`), diff --git a/frontend/src/components.jsx b/frontend/src/components.jsx index 2d2170b..e5c5239 100644 --- a/frontend/src/components.jsx +++ b/frontend/src/components.jsx @@ -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 ( -
-
e.stopPropagation()} onSubmit={submit} - aria-labelledby="auth-title"> - -

{registering ? 'Create an account' : 'Welcome back'}

-

- {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} -

- -
- - -
- - - - - {error &&
{error}
} - -
- - -
-
-
- ) -} - -/** 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) diff --git a/frontend/src/main.jsx b/frontend/src/main.jsx index 1a4311f..7cb8512 100644 --- a/frontend/src/main.jsx +++ b/frontend/src/main.jsx @@ -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: }, { path: 'scenarios/:id', element: }, { path: 'play/:id', element: }, - { path: 'scripts', element: }, - { path: 'scripts/:id', element: }, { path: 'settings', element: }, - // Power users only — the page redirects home and the API 404s otherwise. { path: 'chat', element: }, - // Owner only, by a separate allowlist; same redirect-and-404 treatment. - { path: 'analytics', element: }, ], }, ]) diff --git a/frontend/src/pages/Analytics.jsx b/frontend/src/pages/Analytics.jsx deleted file mode 100644 index 4b512a5..0000000 --- a/frontend/src/pages/Analytics.jsx +++ /dev/null @@ -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 ( -
-
{nf.format(value ?? 0)}
-
{label}
- {hint &&
{hint}
} -
- ) -} - -/* 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 ( -
-
-

{title}

- {series.length > 1 && ( -
- {[...series].reverse().map((s) => ( - - - {s.label} - - ))} -
- )} -
-
setHover(null)}> -
{nf.format(max)}
-
{nf.format(Math.round(max / 2))}
-
- {data.map((point, i) => { - const total = series.reduce((sum, s) => sum + (point[s.key] || 0), 0) - return ( -
setHover(i)} - onFocus={() => setHover(i)} - tabIndex={0} - aria-label={`${fullDate(point.day)}: ${total}`} - > -
- {[...series].reverse().map((s) => ( - (point[s.key] || 0) > 0 && ( -
- ) - ))} -
- {hover === i && ( -
- {fullDate(point.day)} - {series.map((s) => ( - - - {s.label}: {nf.format(point[s.key] || 0)} - - ))} -
- )} -
- ) - })} -
-
-
- {data.map((point, i) => ( - {ticks.has(i) ? dayLabel(point.day, days) : ''} - ))} -
- {empty &&
Nothing recorded in this range
} -
- ) -} - -/* 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 ( -
-
-

Where visitors get to

- people, counted once each -
- {top === 0 ? ( -
No visitors in this range.
- ) : ( -
- {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 ( -
-
{step.step}
-
-
-
-
- {nf.format(step.count)} - {i > 0 && {share}%} -
-
- ) - })} -
- )} -
- ) -} - -function TopList({ title, rows, note, empty = 'Nothing yet' }) { - const max = Math.max(1, ...rows.map((r) => r.hits)) - return ( -
-
-

{title}

- {note && {note}} -
- {rows.length === 0 ? ( -
{empty}
- ) : ( -
    - {rows.map((row) => ( -
  • - {/* The bar is the row's own background, so a long label stays - readable on top of it instead of being squeezed beside it. */} - - {row.label} - {nf.format(row.hits)} -
  • - ))} -
- )} -
- ) -} - -/* ---------- 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 ( -
-
-
- {KINDS.map((option) => ( - - ))} -
- setSearch(event.target.value)} - /> -
- - {error &&
Couldn’t load the log: {error}
} - {!page && !error &&
Reading…
} - - {page && (page.events.length === 0 ? ( -
- {query || kind ? 'Nothing matches that.' : 'Nothing logged yet.'} -
- ) : ( - <> -
- - - - - - - - - {page.events.map((event) => ( - - - - - - - {/* 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. */} - - - ))} - -
WhenWhoEventIPCountryDevice
{when(event.at)} - {event.who} - {event.is_guest && guest} - {KIND_LABEL[event.kind] || event.kind}{event.ip || '—'}{event.country || '—'} - {event.device || '—'} -
-
- {page.has_more && ( -
- -
- )} - - ))} -
- ) -} - -/* ---------- 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 ( -
-
-

Visitors

- {tab === 'overview' && ( -
- {RANGES.map((range) => ( - - ))} -
- )} -
- -
- - -
- - {tab === 'access' && } - - {tab === 'overview' && error && ( -
Couldn’t load analytics: {error}
- )} - {tab === 'overview' && !data && !error &&
Counting…
} - - {tab === 'overview' && data && ( - <> -
- - - - - - - - -
- -
- - -
- - - -
- - - - - - -
- -

- {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. -

- - )} -
- ) -} diff --git a/frontend/src/pages/Chat.jsx b/frontend/src/pages/Chat.jsx index e87e970..72ed375 100644 --- a/frontend/src/pages/Chat.jsx +++ b/frontend/src/pages/Chat.jsx @@ -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') diff --git a/frontend/src/pages/Play/drawers/StatusDrawer.jsx b/frontend/src/pages/Play/drawers/StatusDrawer.jsx deleted file mode 100644 index 4a47cc7..0000000 --- a/frontend/src/pages/Play/drawers/StatusDrawer.jsx +++ /dev/null @@ -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 null - if (typeof value === 'boolean') return {String(value)} - if (typeof value === 'number') return {value} - if (typeof value === 'string') return {value} - if (Array.isArray(value)) return [i, v])} empty="[ ]" depth={depth} /> - if (typeof value === 'object') return - return {String(value)} -} - -// 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 {empty} - return ( -
- - {open && ( -
    - {entries.map(([k, v]) => ( -
  • - {k} - -
  • - ))} -
- )} -
- ) -} - -// 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 ( -
- - {open && ( -
-
-

Script State

- -
- {failed ? ( -
Couldn’t load state.
- ) : entries.length === 0 ? ( -
- No variables yet. Scripts that use state will appear here after a turn. -
- ) : ( -
    - {entries.map(([k, v]) => ( -
  • - {k} -
    -
  • - ))} -
- )} -
- )} -
- ) -} - -export { StatusDrawer } diff --git a/frontend/src/pages/Play/format.js b/frontend/src/pages/Play/format.js index 6f8e7a8..2ebc25d 100644 --- a/frontend/src/pages/Play/format.js +++ b/frontend/src/pages/Play/format.js @@ -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', diff --git a/frontend/src/pages/Play/index.jsx b/frontend/src/pages/Play/index.jsx index 308a050..2bc71f0 100644 --- a/frontend/src/pages/Play/index.jsx +++ b/frontend/src/pages/Play/index.jsx @@ -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. */} -

{adventure.title}

@@ -525,8 +508,6 @@ export default function Play() { onClick={() => setPanel(panel === 'plot' ? null : 'plot')}>Plot -
{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' ? ( - ) : panel === 'branches' ? ( {s.text}
))} - {report.raw_output && (
diff --git a/frontend/src/pages/Play/panels/ScriptsPanel.jsx b/frontend/src/pages/Play/panels/ScriptsPanel.jsx deleted file mode 100644 index 6e08eb8..0000000 --- a/frontend/src/pages/Play/panels/ScriptsPanel.jsx +++ /dev/null @@ -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
Loading…
- if (scripts.length === 0) { - return ( -
- No scripts on this adventure. Attach scripts to a scenario before starting an - adventure from it. -
- ) - } - return ( -
- {scripts.map((s) => { - const hooks = SCRIPT_HOOKS.filter(([, f]) => (s[f] || '').trim()) - const open = openId === s.id - return ( -
-
- -
- {s.out_of_date && ( - - )} - - -
-
- {s.description &&
{s.description}
} - {open && ( -
- {hooks.length === 0 ? ( -
This script has no code.
- ) : ( - hooks.map(([label, f]) => ( -
-
{label}
-
{s[f]}
-
- )) - )} -
- )} -
- ) - })} -
- ) -} - -export { ScriptsPanel } diff --git a/frontend/src/pages/Play/reports.jsx b/frontend/src/pages/Play/reports.jsx index 8c04a98..6e5e90a 100644 --- a/frontend/src/pages/Play/reports.jsx +++ b/frontend/src/pages/Play/reports.jsx @@ -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 ( -
-
Scripts
- {script.errors?.map((e, i) =>
⚠ {e}
)} - {script.logs?.length > 0 && ( -
{script.logs.join('\n')}
- )} - {script.context_changed && ( - <> -
-
- Context before script -
-
{script.context_before}
-
-
-
- Context after script (sent to AI) -
-
{script.context_after}
-
- - )} -
- ) -} - -// 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 } diff --git a/frontend/src/pages/ScenarioEditor.jsx b/frontend/src/pages/ScenarioEditor.jsx index f1df5df..7032635 100644 --- a/frontend/src/pages/ScenarioEditor.jsx +++ b/frontend/src/pages/ScenarioEditor.jsx @@ -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() {
)} -
-

Attached Scripts

-
- {allScripts.length === 0 ? ( -
- No scripts in your library. Create some on the Scripts page. -
- ) : ( - allScripts.map((s) => ( - - )) - )} ) diff --git a/frontend/src/pages/ScriptEditor.jsx b/frontend/src/pages/ScriptEditor.jsx deleted file mode 100644 index 3096930..0000000 --- a/frontend/src/pages/ScriptEditor.jsx +++ /dev/null @@ -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 ( -
-
-

Edit Script

-
- {status} - - -
-
- - setField('name', v)} /> - setField('description', v)} - textarea rows={2} placeholder="What this script does." /> - -
- {SLOTS.map((s) => ( - - ))} -
-
{activeSlot.hint}
- setField(slot, value)} - /> - -
-

Test Run

-
-
-
- - -
- - - {testResult && ( -
- {testResult.error ? ( -
{testResult.error}
- ) : ( - <> -
text
{testResult.text}
- {testResult.stop &&
stop true — the AI call would be skipped
} -
state
{JSON.stringify(testResult.state)}
- {testResult.storyCards.length > 0 && ( -
storyCards
{JSON.stringify(testResult.storyCards, null, 1)}
- )} - {testResult.logs.length > 0 && ( -
logs
{testResult.logs.join('\n')}
- )} - - )} -
- )} -
-
- ) -} diff --git a/frontend/src/pages/Scripts.jsx b/frontend/src/pages/Scripts.jsx deleted file mode 100644 index 0f0d9f9..0000000 --- a/frontend/src/pages/Scripts.jsx +++ /dev/null @@ -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 ( -
-
-

Scripts

-
- - -
-
- {scripts === null ? null : scripts.length === 0 ? ( -
- No scripts yet. Scripts are AI Dungeon-compatible JavaScript modifiers - (onInput / onModelContext / onOutput) you can attach to scenarios. -
- ) : ( -
- {scripts.map((s) => ( -
navigate(`/scripts/${s.id}`)}> -

{s.name}

-

{s.description || 'No description'}

-
- {['library', 'input', 'context', 'output'] - .filter((slot) => s[`${slot}_js`].trim()) - .join(' · ') || 'empty'} -
-
- ))} -
- )} -
- ) -} diff --git a/frontend/src/pages/Settings.jsx b/frontend/src/pages/Settings.jsx index 58a2164..b7b1820 100644 --- a/frontend/src/pages/Settings.jsx +++ b/frontend/src/pages/Settings.jsx @@ -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 (
@@ -117,34 +94,21 @@ export default function Settings() { {saved}
- {demo?.using_demo && ( -
- Using the shared demo key — {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. -
- )} - - @@ -166,16 +130,13 @@ export default function Settings() { @@ -232,13 +193,13 @@ export default function Settings() {
{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}
)} - {/* The provider debug log is a single global buffer — local installs only. */} - {me?.multi_user !== true && } + ) } diff --git a/render.yaml b/render.yaml deleted file mode 100644 index d4de6d1..0000000 --- a/render.yaml +++ /dev/null @@ -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.