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
5.3 KiB
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
Usertable: id, email (nullable — null means guest), password_hash (nullable), created_at, last_seen_at, is_guest flag, demo_turns_used + demo_turns_date.user_idFK onAdventure,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_KEYor auto-generatedsecret.keynext to the DB). Key is write-only through the API (has_api_keyinstead 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.pyseeds 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/mewith 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:falsefrom /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.