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
121 lines
4.6 KiB
React
121 lines
4.6 KiB
React
import { useEffect, useRef, useState } from 'react'
|
|
import { NavLink, Outlet, useLocation } from 'react-router-dom'
|
|
import { api } from './api'
|
|
import { AuthModal, ToastHost } from './components'
|
|
import Embers from './Embers.jsx'
|
|
|
|
export default function App() {
|
|
// null until /auth/me resolves; in local mode multi_user=false hides all auth UI.
|
|
const [me, setMe] = useState(null)
|
|
const [authMode, setAuthMode] = useState(null) // 'register' | 'login' | null
|
|
const [navOpen, setNavOpen] = useState(false) // mobile hamburger menu
|
|
const location = useLocation()
|
|
const lastPath = useRef(null)
|
|
|
|
useEffect(() => {
|
|
api.getMe().then(setMe).catch(() => {})
|
|
}, [])
|
|
|
|
// One pageview per route the reader actually lands on. Guarded on the path
|
|
// rather than fired on every render: StrictMode runs effects twice in dev,
|
|
// and a re-render for unrelated state is not a new page.
|
|
useEffect(() => {
|
|
if (lastPath.current === location.pathname) return
|
|
const first = lastPath.current === null
|
|
lastPath.current = location.pathname
|
|
// document.referrer survives client-side navigation, so it is only honest
|
|
// on the first view — after that this was our own page, not a referral.
|
|
api.trackPageview(location.pathname, {
|
|
referrer: first ? document.referrer : '',
|
|
first,
|
|
})
|
|
}, [location.pathname])
|
|
|
|
const onAuthed = (newMe, mode) => {
|
|
setAuthMode(null)
|
|
if (mode === 'login') {
|
|
// Different user now — reload so every page refetches its scoped data.
|
|
window.location.reload()
|
|
} else {
|
|
setMe(newMe) // register upgrades the same user in place; data unchanged
|
|
}
|
|
}
|
|
|
|
const logout = async () => {
|
|
try { await api.logout() } catch { /* already logged out */ }
|
|
window.location.reload()
|
|
}
|
|
|
|
return (
|
|
<ToastHost>
|
|
<Embers />
|
|
<nav className="topnav">
|
|
<span className="brand">⚔ AI D&D</span>
|
|
<button
|
|
className="nav-hamburger"
|
|
aria-label="Menu"
|
|
aria-expanded={navOpen}
|
|
onClick={() => setNavOpen((o) => !o)}
|
|
>
|
|
{navOpen ? '✕' : '☰'}
|
|
</button>
|
|
<div className={`nav-links${navOpen ? ' open' : ''}`} onClick={() => setNavOpen(false)}>
|
|
<NavLink to="/" end className={({ isActive }) => `navlink${isActive ? ' active' : ''}`}>
|
|
Home
|
|
</NavLink>
|
|
<NavLink to="/adventures" className={({ isActive }) => `navlink${isActive ? ' active' : ''}`}>
|
|
Adventures
|
|
</NavLink>
|
|
<NavLink to="/scenarios" className={({ isActive }) => `navlink${isActive ? ' active' : ''}`}>
|
|
Scenarios
|
|
</NavLink>
|
|
<NavLink to="/scripts" className={({ isActive }) => `navlink${isActive ? ' active' : ''}`}>
|
|
Scripts
|
|
</NavLink>
|
|
<NavLink to="/settings" className={({ isActive }) => `navlink${isActive ? ' active' : ''}`}>
|
|
Settings
|
|
</NavLink>
|
|
{/* Power-user tooling, not part of the game — hidden for everyone else. */}
|
|
{me?.power_user && (
|
|
<NavLink to="/chat" className={({ isActive }) => `navlink${isActive ? ' active' : ''}`}>
|
|
AI Chat
|
|
</NavLink>
|
|
)}
|
|
{/* Owner only: the site's own traffic, on its own allowlist. */}
|
|
{me?.analytics && (
|
|
<NavLink to="/analytics" className={({ isActive }) => `navlink${isActive ? ' active' : ''}`}>
|
|
Visitors
|
|
</NavLink>
|
|
)}
|
|
{me?.multi_user && (
|
|
<div className="nav-account">
|
|
{me.is_guest ? (
|
|
<>
|
|
<span className="guest-nudge"
|
|
title={me.guest_retention_days
|
|
? `Guest adventures are deleted after ${me.guest_retention_days} days without a visit.`
|
|
: undefined}>
|
|
Playing as guest — sign up to keep your adventures
|
|
</span>
|
|
<button onClick={() => setAuthMode('login')}>Log in</button>
|
|
<button className="primary" onClick={() => setAuthMode('register')}>Sign up</button>
|
|
</>
|
|
) : (
|
|
<>
|
|
<span className="account-email" title={me.email}>{me.email}</span>
|
|
<button onClick={logout}>Log out</button>
|
|
</>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</nav>
|
|
<Outlet context={{ me, setMe }} />
|
|
{authMode && (
|
|
<AuthModal mode={authMode} onClose={() => setAuthMode(null)} onAuthed={onAuthed}
|
|
retentionDays={me?.guest_retention_days} />
|
|
)}
|
|
</ToastHost>
|
|
)
|
|
}
|