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
82 lines
5.3 KiB
Markdown
82 lines
5.3 KiB
Markdown
# 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
|
||
|
||
- [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
|
||
|
||
- [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)
|
||
|
||
- [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
|
||
|
||
- [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 — 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.
|