Files
interactive-story/backend/.env.example
T
parththakkar106andClaude Opus 5 041f9e25f3 Count the visits, and say whether anyone got anywhere
A hosted demo raises a question a local app never does: is anyone using it,
and do they reach the part that matters? `/analytics` answers it — visitors,
pages, referrers, countries, devices, which shared scenarios get played, turns
and demo-key spend, API and turn errors, and a funnel from visited to played a
turn to signed up.

Not a third-party script, for reasons specific to this one. The CSP allows
`script-src 'self'`, so a tracker means loosening it; adblockers eat the
popular ones, which silently biases exactly the technical audience this
project gets shown to; and none of them can see the measurement that actually
matters here, which is a turn, not a pageview.

**A visit is a write and never a read.** After the 189x egress fix it would be
perverse to add a feature that reads rows per request, so counts accumulate in
a process-local dict and flush every 60s as UPSERTs. Storage is a generic
`(day, metric, label) -> hits` counter, so measuring something new later costs
a constant rather than a migration, plus one row per visitor per day for the
funnel flags. Every dashboard query is a GROUP BY returning tens of rows
however much traffic sits behind it; a month reads back in a few kilobytes.
The buffer's cost is that a hard restart can lose up to a minute — the flusher
also runs on shutdown, and a tier that sleeps when idle sleeps on an empty
buffer anyway.

**The counters are anonymous; the access log beside them is not, on purpose.**
A visitor is `HMAC(secret, "visitor:<user id>")` truncated to 32 chars —
one-way, so `analytics_daily` and `analytics_visitor_days` cannot be joined
back to `users`, and keyed, so no client can compute one. Story content never
reaches that module, and the only content it ever names is a seeded public
scenario's title; a player's own titles are theirs. `accesslog.py` is the
identifying half and is a separate module writing a separate table so that
separation is a property of the code rather than a convention: `access_events`
records sessions, sign-ins, registrations and failed attempts with address,
email and device, read on a second tab of the same page behind the same gate.

Both halves are gated on `AIDND_ANALYTICS_EMAILS`, not `POWER_USERS`. An
unmetered tester is not automatically someone who should see the traffic. The
route 404s and the nav link is absent for everyone else, the same treatment
AI Chat gets; unset in a hosted deploy means nobody sees it, including me.

Three things came out of building it that a test would not have suggested.

**A failed turn is an HTTP 200 with a bad ending.** The status-code middleware
cannot see one, so a demo whose model had started refusing every request would
look perfectly healthy from outside. All five SSE error paths in
`_generate_turn` now go through a `turn_error()` helper that counts on the way
out. Error buckets elsewhere are labelled by the matched route template rather
than the requested path — one bucket per endpoint instead of one per adventure
id, and, the reason it isn't merely tidier, an unmatched path is entirely
attacker-chosen, so labelling by it would let anyone mint rows.

**The funnel counts people, not clicks.** A player who starts six adventures
is one person who started an adventure. That is the whole reason the
per-visitor-day table exists; its flags only ever turn on, and `is_new` is
settled by the first write of a visitor's first day.

**The tests run on SQLite and production is Neon.** A flush that raises is
caught and logged, so a dialect mistake in the UPSERTs would have stayed
invisible until the dashboard quietly never filled.
`test_the_upserts_compile_for_postgres` compiles both statements against the
Postgres dialect without connecting to one.

Two things this leans on elsewhere. `limits._client_ip` is now public
`client_ip`: the access log needs the same answer, and two functions both
deciding which hop is the caller's is how one of them ends up trusting a
header it shouldn't. And the cleanup sweeper now starts if *either* job has
work — a deployment can keep every guest forever and still want its
visitor-day rows aged out.

No migration. Both tables are new and `bootstrap()` calls `create_all` on
existing databases too, the route `branches` took in Phase 14, so
`LATEST_VERSION` is still 64.

497 tests green, frontend lint and build clean, driven by hand against a
synthetic 90-day fixture at 1568px. The narrow-screen layout follows the
existing 720px block but is unverified: `resize_window` is ignored on a
maximized Chrome and `frame-ancestors 'none'` rules out checking it in a sized
iframe. Also repaired here: a rename in test_ratelimit_hardening.py had run
through the test names themselves, leaving `testclient_ip_*` — still collected
by pytest, which is why it passed unnoticed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DfMCsN1KBLsTqMkj5hSgrY
2026-08-22 16:24:42 +05:30

99 lines
5.1 KiB
Bash

# 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).
# 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).
AIDND_DB_PATH=
# ---------------------------------------------------------------------------
# Phase 9 — production hardening
# ---------------------------------------------------------------------------
# Switch from SQLite to a server database (hosted deploys use Neon Postgres).
# Any SQLAlchemy URL; postgres:// and postgresql:// schemes are rewritten to
# the psycopg3 driver automatically. The platform-conventional DATABASE_URL
# is honored too (AIDND_DATABASE_URL wins if both are set). Unset = SQLite.
AIDND_DATABASE_URL=
# Comma-separated list of allowed CORS origins. Only needed when the frontend
# is served from a different origin than the API; the production build is
# served same-origin by FastAPI, so hosted deploys can leave this unset.
# Default: http://localhost:5173,http://127.0.0.1:5173 (the Vite dev server).
AIDND_CORS_ORIGINS=
# ---------------------------------------------------------------------------
# Phase 8 — optional accounts & multi-user (all optional; defaults keep the
# app in frictionless single-user "local mode")
# ---------------------------------------------------------------------------
# "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.