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
This commit is contained in:
parththakkar106
2026-07-06 23:04:03 +05:30
co-authored by Claude Fable 5
parent 253b533d3b
commit de4db373f2
26 changed files with 1247 additions and 189 deletions
+54 -44
View File
@@ -1,4 +1,4 @@
# Phase 8 — Optional accounts & multi-user
# 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
@@ -12,60 +12,70 @@ every router and most tables.
| 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 |
**Ask before implementing:**
- Demo-key specifics: which provider/key funds it, model whitelist (free models only?),
per-user turn/day cap, what the "out of demo turns" message says.
- Guest data retention: how long before unclaimed guest data is deleted (suggestion: 30 days
of inactivity).
- Password reset: skip for v1, or implement email-based reset (requires an email provider)?
| 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 (derivable from email; keep explicit for clarity).
- [ ] Add `user_id` FK to: `Adventure`, `Scenario`, `Script`, `Settings` (and anything else
global today — audit `models.py`). Story cards/actions inherit scope via their parent.
- [ ] Settings becomes **per-user** (endpoint URL, API key, models, memory-bank config).
API key **encrypted at rest** (Fernet with a server-side `SECRET_KEY` env var).
- [ ] Migration (in `migrations.py` style): create a "local user", assign all existing rows to
it — a fresh clone/local install keeps working exactly as before.
- [ ] Demo/starter scenarios: mark as `user_id = NULL` + `is_public` so everyone sees them
(decide exact mechanism when implementing; seed via `seed_demo.py`).
- [x] `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.
- [x] `user_id` FK on `Adventure`, `Scenario`, `Script`, `Settings`. Story cards/actions/
memories inherit scope via their parent (ownership checks resolve the parent).
- [x] 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).
- [x] 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).
- [x] 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: first API call with no session → create guest User, set a signed, long-lived
httpOnly session cookie. No signup wall anywhere.
- [ ] Register: email + password (hashed with bcrypt/argon2) **upgrades the current guest user
in place** — same user_id, data automatically kept.
- [ ] Login: standard session issue; logging in from a fresh guest session with existing account
discards the empty guest (or merges — ask if guest has data).
- [ ] Session middleware/dependency: every router handler resolves `current_user`; **every query
filtered by `user_id`** (this is the bulk of the diff — go router by router).
- [ ] Rate limits on register/login endpoints (brute-force protection).
- [ ] Local/self-hosted mode stays frictionless: single auto-created local user, no login UI
unless `MULTI_USER=true` (env var) — resume demo runs multi-user, local clones don't care.
- [x] 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.
- [x] Register upgrades the guest **in place** (same user_id — data kept). scrypt password
hashing (stdlib, no extra dep).
- [x] Login switches the session cookie to the account (guest abandoned). Logout clears it.
- [x] 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).
- [x] Rate limit on register/login: 10 attempts / 5 min per IP (in-memory).
- [x] 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)
- [ ] Server env vars: `DEMO_API_KEY`, `DEMO_ENDPOINT_URL`, `DEMO_MODEL_WHITELIST`,
`DEMO_TURNS_PER_DAY`.
- [ ] If a user has no API key configured: use demo key, restrict model picker to the whitelist,
count turns per user per day, friendly error + "add your own key in Settings" when capped.
- [ ] Turn counting includes memory-bank background calls (or disable memory bank on demo key —
decide when implementing).
- [x] 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.
- [x] 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).
- [x] Memory bank + auto-summarization disabled on demo turns (decided: disable, not count).
## Frontend
- [ ] Auth UI: register/login modal or page, "Save your progress" nudge for guests (subtle,
e.g. in the header), logout, account menu.
- [ ] `api.js`: send cookies (`credentials: include`), handle 401 → re-establish guest session.
- [ ] Settings page: per-user; show demo-key status ("Using shared demo key — N turns left today").
- [x] 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).
- [x] `api.js`: 401 → GET /auth/me (new guest session) → retry once, for both JSON and SSE.
- [x] 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.
- [x] Public scenarios: "demo ✦" badge in the list; read-only editor (fieldset-disabled) with
an explainer banner; Play/Export still available.
## Exit criteria
## Exit criteria — verified 2026-07-06
Two different browsers hit the deployed app: each gets its own guest world (adventures invisible
to the other), both can play immediately on the demo key. One registers mid-adventure and its
data survives; logging in from the other browser shows the same account data. Local
`start.ps1` / `docker compose up` still works with zero auth friction.
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.