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
147 lines
5.8 KiB
Python
147 lines
5.8 KiB
Python
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
|
|
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)):
|
|
"""Who am I? In multi-user mode this also bootstraps the session: with no
|
|
(or an invalid) cookie it creates a guest user and sets one — the
|
|
frontend calls this 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 — cap how fast one IP can mint them.
|
|
limits.rate_limit("guest", request)
|
|
user = models.User(is_guest=True)
|
|
db.add(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),
|
|
):
|
|
"""Upgrade the current guest in place — same user_id, 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}
|