Files
interactive-story/plan/08-phase-accounts.md
T
parththakkar106andClaude Fable 5 de4db373f2 Phase 8: optional accounts, per-user data, shared demo key
Guest-first multi-user mode behind AIDND_MULTI_USER (local installs
unchanged): signed-cookie guest sessions bootstrapped by /api/auth/me,
register upgrades the guest in place, login/logout, per-IP rate limits.
Every router scoped by user_id; Settings become per-user with the API
key Fernet-encrypted at rest and write-only through the API. Users
without a key get a server-funded demo key (OpenRouter free models,
20 turns/day, memory bank disabled on demo turns). Public read-only
demo scenarios (seed_demo.py); debug log restricted to local mode.
Frontend: auth modal + guest nudge, 401 re-establish/retry, demo
banner and key management in Settings.

Migrations 13-23 adopt existing data under a local user and encrypt
stored keys. Verified: migration on a copy of real data.db, two-session
isolation + register/login via curl and Chrome, demo cap 429, live
OpenRouter turn through the encrypted-key path, vite build + oxlint.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KFsGHju9szibJJa2YJcdbg
2026-07-06 23:04:03 +05:30

5.3 KiB
Raw Blame History

Phase 8 — Optional accounts & multi-user ✅ (implemented 2026-07-06, branch phase-8-accounts)

Goal: turn the single-user app into a multi-user one where accounts are optional: a visitor can start playing instantly as a guest, and can register (email + password) at any point to keep their adventures across devices/browsers. This is the largest phase — it touches every router and most tables.

Decisions (confirmed)

Question Answer
Auth method Email + password, optional — guest sessions work without an account
Signup policy Open signup (rate-limited)
LLM API keys BYOK + shared demo key — users can paste their own key; users without one get limited turns on a server-funded key
Demo key funding OpenRouter free models (owner's key, :free whitelist; default google/gemma-4-26b-a4b-it:free)
Demo turn cap 20 successful turns/user/day (failed provider calls don't count)
Guest data retention Never delete for v1 (no cleanup job; revisit if the DB grows)
Password reset Skipped for v1 (no email provider; forgotten password = lost account)
Login with an active guest session Guest is abandoned, not merged (its data stays under the guest user)
Memory bank on demo key Disabled (no background AI calls on the server-funded key; visible note in the Memory panel/Insights)

Data model

  • User table: id, email (nullable — null means guest), password_hash (nullable), created_at, last_seen_at, is_guest flag, demo_turns_used + demo_turns_date.
  • user_id FK on Adventure, Scenario, Script, Settings. Story cards/actions/ memories inherit scope via their parent (ownership checks resolve the parent).
  • Settings per-user (row per user_id, unique index). API key encrypted at rest (Fernet; key derived from AIDND_SECRET_KEY or auto-generated secret.key next to the DB). Key is write-only through the API (has_api_key instead of echoing it).
  • Migrations 13–23: create "local user" id=1, assign all existing rows to it, unique index on settings.user_id; plus a Python bootstrap step that encrypts any plaintext api_key (enc: prefix marks encrypted values).
  • Demo/starter scenarios: user_id NULL + is_public — everyone sees them read-only; seed_demo.py seeds the Sunken Crypt scenario as public (its scripts are unowned and ship with it; the sample adventure belongs to the local user).

Auth & sessions

  • Guest flow: GET /api/auth/me with no/invalid cookie → creates guest User + signed long-lived httpOnly cookie (HMAC, security.py). Other endpoints 401 without a session; the frontend re-establishes via /me and retries once. No signup wall anywhere.
  • Register upgrades the guest in place (same user_id — data kept). scrypt password hashing (stdlib, no extra dep).
  • Login switches the session cookie to the account (guest abandoned). Logout clears it.
  • Every router handler resolves current_user; every query filtered by user_id (scenarios/adventures/scripts/story-cards/settings; debug log is local-mode only since it's a global buffer).
  • Rate limit on register/login: 10 attempts / 5 min per IP (in-memory).
  • Local/self-hosted mode stays frictionless: auto-created local user, no login UI unless AIDND_MULTI_USER=1. Local installs and docker compose behave exactly as before.

Shared demo key (BYOK fallback)

  • Env vars: AIDND_DEMO_API_KEY, AIDND_DEMO_ENDPOINT_URL (default OpenRouter), AIDND_DEMO_MODELS (comma whitelist), AIDND_DEMO_TURNS_PER_DAY (default 20). Demo only activates in multi-user mode.
  • No API key configured → demo endpoint/key/whitelisted model; per-user per-day counter; 429 with a friendly "add your own key in Settings" message when capped (checked before the turn starts so no orphaned player action).
  • Memory bank + auto-summarization disabled on demo turns (decided: disable, not count).

Frontend

  • Auth UI: Sign up / Log in modal (register default, toggle to login), "Playing as guest — sign up to keep your adventures" nudge in the header, account email + logout when registered. All hidden in local mode (multi_user:false from /me).
  • api.js: 401 → GET /auth/me (new guest session) → retry once, for both JSON and SSE.
  • Settings: demo banner ("Using the shared demo key — N of M free turns left today"), write-only API key field with Remove button, debug log hidden in multi-user mode.
  • Public scenarios: "demo ✦" badge in the list; read-only editor (fieldset-disabled) with an explainer banner; Play/Export still available.

Exit criteria — verified 2026-07-06

Two sessions (curl cookie jars + Chrome UI): each guest gets an isolated world; register mid-session keeps all data (same user id); logging in from the second session shows the same account data; duplicate email → 409; wrong password → 401; rate limiter kicks in. Demo cap returns 429 at 0 turns left. Migration tested on a copy of the real data.db (rows adopted by local user, api_key Fernet-encrypted and decrypts back to the original). Live OpenRouter turn through the encrypted-key path works in local mode. vite build + oxlint clean.