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
229 lines
11 KiB
JavaScript
229 lines
11 KiB
JavaScript
// 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) {
|
|
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 {
|
|
const body = await resp.json()
|
|
detail = body.detail || detail
|
|
} catch { /* non-JSON error body */ }
|
|
throw new Error(detail)
|
|
}
|
|
if (resp.status === 204) return null
|
|
return resp.json()
|
|
}
|
|
|
|
// POSTs to an SSE endpoint and dispatches events: {type: 'player'|'chunk'|'done'|'error', ...}
|
|
async function streamSSE(path, payload, onEvent, signal, isRetry = false) {
|
|
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 */ }
|
|
throw new Error(detail)
|
|
}
|
|
const reader = resp.body.getReader()
|
|
const decoder = new TextDecoder()
|
|
let buffer = ''
|
|
for (;;) {
|
|
const { done, value } = await reader.read()
|
|
if (done) break
|
|
buffer += decoder.decode(value, { stream: true })
|
|
const events = buffer.split('\n\n')
|
|
buffer = events.pop() // keep incomplete tail
|
|
for (const block of events) {
|
|
for (const line of block.split('\n')) {
|
|
if (line.startsWith('data:')) onEvent(JSON.parse(line.slice(5)))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
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}`),
|
|
createScenario: (data) => request('/scenarios', { method: 'POST', body: JSON.stringify(data) }),
|
|
updateScenario: (id, data) => request(`/scenarios/${id}`, { method: 'PATCH', body: JSON.stringify(data) }),
|
|
deleteScenario: (id) => request(`/scenarios/${id}`, { method: 'DELETE' }),
|
|
|
|
// 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) }),
|
|
createAdventure: (data) => request('/adventures', { method: 'POST', body: JSON.stringify(data) }),
|
|
updateAdventure: (id, data) => request(`/adventures/${id}`, { method: 'PATCH', body: JSON.stringify(data) }),
|
|
deleteAdventure: (id) => request(`/adventures/${id}`, { method: 'DELETE' }),
|
|
// A page of the story, walking backwards. `beforeId` is the oldest action
|
|
// already on screen; omit it for the newest window. Anchored on an action
|
|
// rather than an offset so a turn landing mid-scroll cannot shift the page
|
|
// out from under the reader.
|
|
getActions: (advId, { beforeId, limit } = {}) => {
|
|
const params = new URLSearchParams()
|
|
if (beforeId != null) params.set('before_id', beforeId)
|
|
if (limit != null) params.set('limit', limit)
|
|
const query = params.toString()
|
|
return request(`/adventures/${advId}/actions${query ? `?${query}` : ''}`)
|
|
},
|
|
updateAction: (advId, actionId, text) =>
|
|
request(`/adventures/${advId}/actions/${actionId}`, { method: 'PATCH', body: JSON.stringify({ text }) }),
|
|
deleteAction: (advId, actionId) =>
|
|
request(`/adventures/${advId}/actions/${actionId}`, { method: 'DELETE' }),
|
|
// Retry history. The adventure payload carries only the counts, so the
|
|
// attempts themselves are fetched when the reader actually pages through.
|
|
listVariants: (advId, actionId) =>
|
|
request(`/adventures/${advId}/actions/${actionId}/variants`),
|
|
// The same endpoint under the name the pager uses. "Take" is what the UI
|
|
// calls one of these now, and the vocabulary is worth keeping straight —
|
|
// `variant` belongs to the pre-tree pair of columns SP8 drops.
|
|
listTakes: (advId, actionId) =>
|
|
request(`/adventures/${advId}/actions/${actionId}/variants`),
|
|
// No `selectVariant` / `forkFromAttempt` here any more. Both endpoints still
|
|
// exist and are tested, but the pager needs neither: stepping between takes
|
|
// tells the server nothing, and what used to be "take this path" is now
|
|
// whatever the reader writes next, carried by `after_id` on the turn itself.
|
|
|
|
// The story tree (Phase 14). One request draws the whole shape however many
|
|
// forks there are. The three that change it answer with the story as it now
|
|
// stands, so the caller replaces its window instead of reloading everything.
|
|
listBranches: (advId) => request(`/adventures/${advId}/branches`),
|
|
switchBranch: (advId, branchId) =>
|
|
request(`/adventures/${advId}/branches/${branchId}/switch`, { method: 'POST' }),
|
|
renameBranch: (advId, branchId, name) =>
|
|
request(`/adventures/${advId}/branches/${branchId}`, {
|
|
method: 'PATCH', body: JSON.stringify({ name }),
|
|
}),
|
|
deleteBranch: (advId, branchId) =>
|
|
request(`/adventures/${advId}/branches/${branchId}`, { method: 'DELETE' }),
|
|
// Play a turn again, differently (SP9). An AI turn regenerates; a player's
|
|
// own takes the text given. Streams, because it is a turn like any other.
|
|
//
|
|
// Reaches any turn, not only the newest — which is the whole difference from
|
|
// `retry`, and the reason the pager can offer this on every message.
|
|
addTake: (advId, actionId, text, handlers, signal) =>
|
|
streamSSE(`/adventures/${advId}/actions/${actionId}/takes`, { text }, handlers, signal),
|
|
|
|
// `afterId` names the take the turn is played after. Omitted it means the
|
|
// tip, which is every ordinary turn. Naming a take the story moved past is
|
|
// what forks a branch — stepping between takes to read them does not, and
|
|
// the server is never told about it.
|
|
sendAction: (advId, payload, handlers, signal) =>
|
|
streamSSE(`/adventures/${advId}/actions`, payload, handlers, signal),
|
|
retry: (advId, handlers, signal) => streamSSE(`/adventures/${advId}/retry`, {}, handlers, signal),
|
|
exportAdventure: (id) => request(`/adventures/${id}/export`),
|
|
importAdventure: (bundle) => request('/adventures/import', { method: 'POST', body: JSON.stringify(bundle) }),
|
|
undo: (advId) => request(`/adventures/${advId}/undo`, { method: 'POST' }),
|
|
getAdventureContext: (advId) => request(`/adventures/${advId}/context`),
|
|
// "Update from scenario": GET describes what would change, POST applies it.
|
|
previewRefresh: (advId) => request(`/adventures/${advId}/refresh`),
|
|
refreshFromScenario: (advId, placeholders = {}) =>
|
|
request(`/adventures/${advId}/refresh`, {
|
|
method: 'POST', body: JSON.stringify({ placeholders }),
|
|
}),
|
|
getActionContext: (advId, actionId) => request(`/adventures/${advId}/actions/${actionId}/context`),
|
|
|
|
// Memory bank
|
|
listMemories: (advId) => request(`/adventures/${advId}/memories`),
|
|
createMemory: (advId, text) =>
|
|
request(`/adventures/${advId}/memories`, { method: 'POST', body: JSON.stringify({ text }) }),
|
|
updateMemory: (advId, memoryId, data) =>
|
|
request(`/adventures/${advId}/memories/${memoryId}`, { method: 'PATCH', body: JSON.stringify(data) }),
|
|
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`),
|
|
importScenario: (bundle) => request('/scenarios/import', { method: 'POST', body: JSON.stringify(bundle) }),
|
|
|
|
// Story cards
|
|
createStoryCard: (data) => request('/story-cards', { method: 'POST', body: JSON.stringify(data) }),
|
|
updateStoryCard: (id, data) => request(`/story-cards/${id}`, { method: 'PATCH', body: JSON.stringify(data) }),
|
|
deleteStoryCard: (id) => request(`/story-cards/${id}`, { method: 'DELETE' }),
|
|
// Bulk import/export in AI Dungeon world-info format. `owner` is
|
|
// { scenario_id } or { adventure_id }.
|
|
exportStoryCards: (owner) => request(`/story-cards/export?${new URLSearchParams(owner)}`),
|
|
importStoryCards: (payload) => request('/story-cards/import', { method: 'POST', body: JSON.stringify(payload) }),
|
|
|
|
// AI Chat (power users only — 404 for everyone else)
|
|
getChatConfig: () => request('/chat/config'),
|
|
chatStream: (payload, handlers, signal) => streamSSE('/chat/stream', payload, handlers, signal),
|
|
|
|
// Debug
|
|
getDebugRequests: () => request('/debug/requests'),
|
|
|
|
// Settings
|
|
getSettings: () => request('/settings'),
|
|
updateSettings: (data) => request('/settings', { method: 'PUT', body: JSON.stringify(data) }),
|
|
testConnection: () => request('/settings/test', { method: 'POST' }),
|
|
}
|