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
+3
View File
@@ -16,3 +16,6 @@ frontend/dist/
# Misc # Misc
.claude/ .claude/
# Phase 8: auto-generated session/encryption secret (lives next to the DB)
secret.key
+10 -2
View File
@@ -33,6 +33,12 @@ models make the whole experience $0.
(`backend/app/memorybank.py`). (`backend/app/memorybank.py`).
- **Import/export** — AI Dungeon-compatible formats for scripts and scenarios; JSON for - **Import/export** — AI Dungeon-compatible formats for scripts and scenarios; JSON for
everything. everything.
- **Optional accounts for hosted deployments** — by default the app is single-user with zero
auth friction; set `AIDND_MULTI_USER=1` and visitors play instantly as guests (signed
session cookie), can register (email + password) at any point to keep their data, and each
user gets isolated data plus their own encrypted-at-rest API key. A server-funded **shared
demo key** with a daily turn cap lets people try it without bringing a key
(`backend/app/auth.py`).
## Quick start ## Quick start
@@ -95,8 +101,10 @@ player input
``` ```
frontend/ React + Vite SPA ──HTTP/SSE──► backend/ FastAPI frontend/ React + Vite SPA ──HTTP/SSE──► backend/ FastAPI
├─ routers/ scenarios, adventures, story cards, scripts, settings, debug ├─ routers/ auth, scenarios, adventures, story cards, scripts, settings, debug
├─ models.py SQLAlchemy: Scenario, Adventure, Action, StoryCard, Script, Settings, Memory ├─ models.py SQLAlchemy: User, Scenario, Adventure, Action, StoryCard, Script, Settings, Memory
├─ auth.py guest/registered users, sessions, shared demo key
├─ security.py password hashing, cookie signing, API-key encryption
├─ context/ prompt assembly under a token budget ├─ context/ prompt assembly under a token budget
├─ scripting/ quickjs sandbox + AI Dungeon API surface ├─ scripting/ quickjs sandbox + AI Dungeon API surface
├─ memorybank.py auto-summarization + embedding retrieval ├─ memorybank.py auto-summarization + embedding retrieval
+32 -5
View File
@@ -9,9 +9,36 @@
# Docker compose sets this to /data/data.db (a named volume). # Docker compose sets this to /data/data.db (a named volume).
AIDND_DB_PATH= AIDND_DB_PATH=
# --- Coming in later phases (documented here as they land) --- # ---------------------------------------------------------------------------
# Phase 8/9 will add: SECRET_KEY, MULTI_USER, DEMO_API_KEY, DEMO_ENDPOINT_URL, # Phase 8 — optional accounts & multi-user (all optional; defaults keep the
# DEMO_MODEL_WHITELIST, DEMO_TURNS_PER_DAY, CORS_ORIGINS. # app in frictionless single-user "local mode")
# # ---------------------------------------------------------------------------
# "1"/"true" turns on multi-user mode: guest sessions via signed cookies,
# register/login UI, per-user data. Leave unset for local installs.
AIDND_MULTI_USER=
# Secret for signing session cookies and encrypting stored API keys at rest.
# If unset, one is auto-generated into `secret.key` next to the database
# (fine for local/docker-volume runs). Set it explicitly on hosted deploys so
# sessions survive redeploys when the disk is ephemeral or replaced.
AIDND_SECRET_KEY=
# "1" marks session cookies Secure (HTTPS-only). Turn on in production.
AIDND_COOKIE_SECURE=
# --- Shared demo key (BYOK fallback; only active when AIDND_MULTI_USER=1) ---
# Users with no API key of their own get this server-funded endpoint with a
# model whitelist and a per-day turn cap. Unset = no demo, users must bring
# their own key. Memory bank/auto-summarization are disabled on demo turns.
AIDND_DEMO_API_KEY=
# Default endpoint if unset: https://openrouter.ai/api/v1
AIDND_DEMO_ENDPOINT_URL=
# Comma-separated model whitelist. Default: google/gemma-4-26b-a4b-it:free
AIDND_DEMO_MODELS=
# Successful AI turns per user per day on the demo key. Default: 20
AIDND_DEMO_TURNS_PER_DAY=
# The AI endpoint/API key/model are NOT env vars — they are configured at # The AI endpoint/API key/model are NOT env vars — they are configured at
# runtime in the app's Settings page and stored in the database. # runtime in the app's Settings page and stored (encrypted) in the database.
# Phase 9 will add: rate limiting and CORS_ORIGINS.
+171
View File
@@ -0,0 +1,171 @@
"""Phase 8 — user resolution, sessions, and the shared demo key.
Two modes, chosen by the AIDND_MULTI_USER env var:
- Local mode (default): every request resolves to one auto-created "local
user". No cookies, no login UI — a clone/docker-compose behaves exactly
like the pre-Phase-8 single-user app.
- Multi-user mode (hosted): requests carry a signed session cookie. GET
/api/auth/me creates a guest user on first visit; registering upgrades the
guest in place so their data survives. Requests without a valid session get
401 and the frontend re-establishes via /me.
The shared demo key (BYOK fallback) is also configured here: users whose
settings have no API key are routed to a server-funded endpoint with a model
whitelist and a per-day turn cap.
"""
import os
import time
from collections import defaultdict, deque
from dataclasses import dataclass
from datetime import timezone
from fastapi import Depends, HTTPException, Request
from sqlalchemy.orm import Session
from . import models, security
from .database import get_db
def _env_flag(name: str) -> bool:
return os.environ.get(name, "").strip().lower() in ("1", "true", "yes", "on")
MULTI_USER = _env_flag("AIDND_MULTI_USER")
SESSION_COOKIE = "aidnd_session"
COOKIE_SECURE = _env_flag("AIDND_COOKIE_SECURE") # enable behind HTTPS in prod
COOKIE_MAX_AGE = 60 * 60 * 24 * 365
# ---------- Shared demo key (BYOK fallback) ----------
DEMO_API_KEY = os.environ.get("AIDND_DEMO_API_KEY", "").strip()
DEMO_ENDPOINT_URL = (
os.environ.get("AIDND_DEMO_ENDPOINT_URL", "").strip()
or "https://openrouter.ai/api/v1"
)
DEMO_MODELS = [
m.strip()
for m in os.environ.get("AIDND_DEMO_MODELS", "").split(",")
if m.strip()
] or ["google/gemma-4-26b-a4b-it:free"]
DEMO_TURNS_PER_DAY = int(os.environ.get("AIDND_DEMO_TURNS_PER_DAY", "20") or 20)
DEMO_CAP_MESSAGE = (
f"You've used all {DEMO_TURNS_PER_DAY} free demo turns for today. "
"Add your own API key in Settings to keep playing (it resets tomorrow)."
)
def demo_enabled() -> bool:
# The demo key is a hosted-deployment feature; local installs talk to
# whatever endpoint Settings points at, even with no API key (Ollama).
return MULTI_USER and bool(DEMO_API_KEY)
@dataclass
class ProviderConfig:
"""What the turn engine should actually connect with, after the
BYOK-vs-demo decision."""
endpoint_url: str
api_key: str
model: str
using_demo: bool
def resolve_provider_config(settings: models.Settings) -> ProviderConfig:
key = settings.api_key_plain
if key or not demo_enabled():
return ProviderConfig(settings.endpoint_url, key, settings.model, False)
model = settings.model if settings.model in DEMO_MODELS else DEMO_MODELS[0]
return ProviderConfig(DEMO_ENDPOINT_URL, DEMO_API_KEY, model, True)
def _today() -> str:
return models.utcnow().date().isoformat()
def demo_turns_left(user: models.User) -> int:
used = user.demo_turns_used if user.demo_turns_date == _today() else 0
return max(0, DEMO_TURNS_PER_DAY - used)
def count_demo_turn(user: models.User) -> None:
"""Record one demo turn; the caller's commit persists it."""
today = _today()
if user.demo_turns_date != today:
user.demo_turns_date = today
user.demo_turns_used = 0
user.demo_turns_used += 1
# ---------- User resolution ----------
def local_user(db: Session) -> models.User:
"""The single implicit user in local mode (owns pre-Phase-8 data via
migration; created lazily on a fresh database)."""
user = (
db.query(models.User)
.filter(models.User.email.is_(None), models.User.is_guest.is_(False))
.order_by(models.User.id)
.first()
)
if user is None:
user = models.User(is_guest=False)
db.add(user)
db.commit()
return user
def _touch(user: models.User, db: Session) -> None:
now = models.utcnow()
last = user.last_seen_at
if last is not None and last.tzinfo is None:
# SQLite hands DateTime columns back naive; they were stored as UTC.
last = last.replace(tzinfo=timezone.utc)
if last is None or (now - last).total_seconds() > 3600:
user.last_seen_at = now
db.commit()
def resolve_session_user(request: Request, db: Session) -> models.User | None:
token = request.cookies.get(SESSION_COOKIE)
if not token:
return None
user_id = security.verify_session(token)
if user_id is None:
return None
return db.get(models.User, user_id)
def get_current_user(request: Request, db: Session = Depends(get_db)) -> models.User:
"""Dependency used by every router. 401 in multi-user mode means the
frontend must (re)establish a session via GET /api/auth/me."""
if not MULTI_USER:
user = local_user(db)
else:
user = resolve_session_user(request, db)
if user is None:
raise HTTPException(401, "No session. Call GET /api/auth/me first.")
_touch(user, db)
return user
# ---------- Brute-force limiter for register/login ----------
_ATTEMPT_LIMIT = 10
_ATTEMPT_WINDOW = 300 # seconds
_attempts: dict[str, deque] = defaultdict(deque)
def rate_limit_auth(request: Request) -> None:
ip = request.client.host if request.client else "unknown"
now = time.time()
window = _attempts[ip]
while window and window[0] < now - _ATTEMPT_WINDOW:
window.popleft()
if len(window) >= _ATTEMPT_LIMIT:
raise HTTPException(429, "Too many attempts. Try again in a few minutes.")
window.append(now)
+2 -1
View File
@@ -7,7 +7,7 @@ from starlette.exceptions import HTTPException as StarletteHTTPException
from .database import engine from .database import engine
from .migrations import bootstrap from .migrations import bootstrap
from .routers import adventures, debug, scenarios, scripts, settings, story_cards from .routers import adventures, auth, debug, scenarios, scripts, settings, story_cards
bootstrap(engine) bootstrap(engine)
@@ -20,6 +20,7 @@ app.add_middleware(
allow_headers=["*"], allow_headers=["*"],
) )
app.include_router(auth.router)
app.include_router(scenarios.router) app.include_router(scenarios.router)
app.include_router(adventures.router) app.include_router(adventures.router)
app.include_router(story_cards.router) app.include_router(story_cards.router)
+11 -4
View File
@@ -57,7 +57,7 @@ _tasks: set[asyncio.Task] = set()
def summary_provider(settings: models.Settings) -> OpenAICompatibleProvider: def summary_provider(settings: models.Settings) -> OpenAICompatibleProvider:
return OpenAICompatibleProvider( return OpenAICompatibleProvider(
settings.endpoint_url, settings.endpoint_url,
settings.api_key, settings.api_key_plain,
settings.summary_model or settings.model, settings.summary_model or settings.model,
settings.api_mode, settings.api_mode,
settings.reasoning_max_tokens, settings.reasoning_max_tokens,
@@ -66,7 +66,7 @@ def summary_provider(settings: models.Settings) -> OpenAICompatibleProvider:
def embedding_provider(settings: models.Settings) -> OpenAICompatibleProvider: def embedding_provider(settings: models.Settings) -> OpenAICompatibleProvider:
return OpenAICompatibleProvider( return OpenAICompatibleProvider(
settings.endpoint_url, settings.api_key, settings.embedding_model settings.endpoint_url, settings.api_key_plain, settings.embedding_model
) )
@@ -165,8 +165,15 @@ async def run_post_turn(adventure_id: int) -> None:
db = SessionLocal() db = SessionLocal()
try: try:
adventure = db.get(models.Adventure, adventure_id) adventure = db.get(models.Adventure, adventure_id)
settings = db.get(models.Settings, 1) if adventure is None:
if adventure is None or settings is None: return
# Settings are per-user (Phase 8): use the adventure owner's row.
settings = (
db.query(models.Settings)
.filter(models.Settings.user_id == adventure.user_id)
.first()
)
if settings is None:
return return
# Undo/retry can shrink the action list below a stored cursor, which # Undo/retry can shrink the action list below a stored cursor, which
# would stall summarization until the story grew past it again. # would stall summarization until the story grew past it again.
+36
View File
@@ -46,6 +46,25 @@ MIGRATIONS: list[tuple[int, str]] = [
# Reasoning-model support: separate thinking budget + stored reasoning text. # Reasoning-model support: separate thinking budget + stored reasoning text.
(11, "ALTER TABLE settings ADD COLUMN reasoning_max_tokens INTEGER NOT NULL DEFAULT 0"), (11, "ALTER TABLE settings ADD COLUMN reasoning_max_tokens INTEGER NOT NULL DEFAULT 0"),
(12, "ALTER TABLE actions ADD COLUMN reasoning TEXT"), (12, "ALTER TABLE actions ADD COLUMN reasoning TEXT"),
# Phase 8: optional accounts. The `users` table itself comes from
# create_all; these adopt all pre-existing rows under a "local user"
# (id=1) so a single-user install keeps working unchanged.
(13, """
INSERT INTO users (id, email, password_hash, is_guest, created_at,
demo_turns_used, demo_turns_date)
SELECT 1, NULL, NULL, 0, CURRENT_TIMESTAMP, 0, ''
WHERE NOT EXISTS (SELECT 1 FROM users)
"""),
(14, "ALTER TABLE scenarios ADD COLUMN user_id INTEGER REFERENCES users(id) ON DELETE CASCADE"),
(15, "UPDATE scenarios SET user_id = 1"),
(16, "ALTER TABLE scenarios ADD COLUMN is_public BOOLEAN NOT NULL DEFAULT 0"),
(17, "ALTER TABLE scripts ADD COLUMN user_id INTEGER REFERENCES users(id) ON DELETE CASCADE"),
(18, "UPDATE scripts SET user_id = 1"),
(19, "ALTER TABLE adventures ADD COLUMN user_id INTEGER REFERENCES users(id) ON DELETE CASCADE"),
(20, "UPDATE adventures SET user_id = 1"),
(21, "ALTER TABLE settings ADD COLUMN user_id INTEGER REFERENCES users(id) ON DELETE CASCADE"),
(22, "UPDATE settings SET user_id = 1"),
(23, "CREATE UNIQUE INDEX IF NOT EXISTS ix_settings_user_id ON settings (user_id)"),
] ]
LATEST_VERSION = max((v for v, _ in MIGRATIONS), default=1) LATEST_VERSION = max((v for v, _ in MIGRATIONS), default=1)
@@ -64,3 +83,20 @@ def bootstrap(engine: Engine) -> None:
conn.execute(text(sql)) conn.execute(text(sql))
current = version current = version
conn.execute(text(f"PRAGMA user_version = {current}")) conn.execute(text(f"PRAGMA user_version = {current}"))
_encrypt_plaintext_api_keys(conn)
def _encrypt_plaintext_api_keys(conn) -> None:
"""Phase 8 data migration (can't be plain SQL): API keys saved before
encryption-at-rest existed are stored bare; wrap them in Fernet. Runs on
every start but matches nothing once all rows carry the enc: prefix."""
from . import security # deferred: security derives its key from DB_PATH setup
rows = conn.execute(text(
"SELECT id, api_key FROM settings WHERE api_key != '' AND api_key NOT LIKE 'enc:%'"
)).all()
for row_id, plain in rows:
conn.execute(
text("UPDATE settings SET api_key = :key WHERE id = :id"),
{"key": security.encrypt_secret(plain), "id": row_id},
)
+53 -1
View File
@@ -12,6 +12,31 @@ def utcnow() -> datetime:
return datetime.now(timezone.utc) return datetime.now(timezone.utc)
class User(Base):
"""Phase 8 — optional accounts.
Three kinds of rows share this table:
- the "local user" (email NULL, is_guest False): auto-created in
single-user/local mode; owns everything a pre-Phase-8 DB had;
- guests (email NULL, is_guest True): created on first visit in
multi-user mode, identified only by their session cookie;
- registered users (email set): a guest upgraded in place, so their
data survives registration with no re-parenting.
"""
__tablename__ = "users"
id: Mapped[int] = mapped_column(primary_key=True)
email: Mapped[str | None] = mapped_column(String(320), unique=True, nullable=True)
password_hash: Mapped[str | None] = mapped_column(String(300), nullable=True)
is_guest: Mapped[bool] = mapped_column(Boolean, default=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow)
last_seen_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
# Shared demo key usage (resets when the UTC date changes).
demo_turns_used: Mapped[int] = mapped_column(Integer, default=0)
demo_turns_date: Mapped[str] = mapped_column(String(10), default="")
scenario_scripts = Table( scenario_scripts = Table(
"scenario_scripts", "scenario_scripts",
Base.metadata, Base.metadata,
@@ -24,6 +49,11 @@ class Scenario(Base):
__tablename__ = "scenarios" __tablename__ = "scenarios"
id: Mapped[int] = mapped_column(primary_key=True) id: Mapped[int] = mapped_column(primary_key=True)
# NULL owner + is_public = seeded demo content, readable by everyone.
user_id: Mapped[int | None] = mapped_column(
ForeignKey("users.id", ondelete="CASCADE"), nullable=True
)
is_public: Mapped[bool] = mapped_column(Boolean, default=False)
title: Mapped[str] = mapped_column(String(200), default="Untitled Scenario") title: Mapped[str] = mapped_column(String(200), default="Untitled Scenario")
description: Mapped[str] = mapped_column(Text, default="") description: Mapped[str] = mapped_column(Text, default="")
prompt: Mapped[str] = mapped_column(Text, default="") prompt: Mapped[str] = mapped_column(Text, default="")
@@ -46,6 +76,9 @@ class Adventure(Base):
__tablename__ = "adventures" __tablename__ = "adventures"
id: Mapped[int] = mapped_column(primary_key=True) id: Mapped[int] = mapped_column(primary_key=True)
user_id: Mapped[int | None] = mapped_column(
ForeignKey("users.id", ondelete="CASCADE"), nullable=True
)
scenario_id: Mapped[int | None] = mapped_column( scenario_id: Mapped[int | None] = mapped_column(
ForeignKey("scenarios.id", ondelete="SET NULL"), nullable=True ForeignKey("scenarios.id", ondelete="SET NULL"), nullable=True
) )
@@ -157,6 +190,9 @@ class Script(Base):
__tablename__ = "scripts" __tablename__ = "scripts"
id: Mapped[int] = mapped_column(primary_key=True) id: Mapped[int] = mapped_column(primary_key=True)
user_id: Mapped[int | None] = mapped_column(
ForeignKey("users.id", ondelete="CASCADE"), nullable=True
)
name: Mapped[str] = mapped_column(String(200), default="Untitled Script") name: Mapped[str] = mapped_column(String(200), default="Untitled Script")
description: Mapped[str] = mapped_column(Text, default="") description: Mapped[str] = mapped_column(Text, default="")
library_js: Mapped[str] = mapped_column(Text, default="") library_js: Mapped[str] = mapped_column(Text, default="")
@@ -191,8 +227,14 @@ class AdventureScript(Base):
class Settings(Base): class Settings(Base):
__tablename__ = "settings" __tablename__ = "settings"
id: Mapped[int] = mapped_column(primary_key=True) # single row, id=1 id: Mapped[int] = mapped_column(primary_key=True)
# Phase 8: one row per user (pre-Phase-8 DBs had a single id=1 row, which
# the migration assigns to the local user).
user_id: Mapped[int | None] = mapped_column(
ForeignKey("users.id", ondelete="CASCADE"), nullable=True, unique=True
)
endpoint_url: Mapped[str] = mapped_column(String(500), default="http://localhost:11434/v1") endpoint_url: Mapped[str] = mapped_column(String(500), default="http://localhost:11434/v1")
# Fernet-encrypted at rest ("enc:..." — see security.py); use api_key_plain.
api_key: Mapped[str] = mapped_column(String(500), default="") api_key: Mapped[str] = mapped_column(String(500), default="")
model: Mapped[str] = mapped_column(String(200), default="") model: Mapped[str] = mapped_column(String(200), default="")
api_mode: Mapped[str] = mapped_column(String(20), default="chat") # chat|completion api_mode: Mapped[str] = mapped_column(String(20), default="chat") # chat|completion
@@ -219,3 +261,13 @@ class Settings(Base):
embedding_model: Mapped[str] = mapped_column(String(200), default="") # "" = bank disabled embedding_model: Mapped[str] = mapped_column(String(200), default="") # "" = bank disabled
memory_bank_capacity: Mapped[int] = mapped_column(Integer, default=200) memory_bank_capacity: Mapped[int] = mapped_column(Integer, default=200)
memory_top_k: Mapped[int] = mapped_column(Integer, default=5) memory_top_k: Mapped[int] = mapped_column(Integer, default=5)
@property
def has_api_key(self) -> bool:
return bool(self.api_key)
@property
def api_key_plain(self) -> str:
from . import security # local import: models is imported before security
return security.decrypt_secret(self.api_key)
+158 -46
View File
@@ -7,7 +7,7 @@ from fastapi.responses import StreamingResponse
from sqlalchemy import func from sqlalchemy import func
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from .. import memorybank, models, schemas from .. import auth, memorybank, models, schemas
from ..context import build_context from ..context import build_context
from ..database import get_db from ..database import get_db
from ..providers import OpenAICompatibleProvider, PromptParts, ProviderError from ..providers import OpenAICompatibleProvider, PromptParts, ProviderError
@@ -16,20 +16,25 @@ from .settings import get_settings
router = APIRouter(prefix="/api/adventures", tags=["adventures"]) router = APIRouter(prefix="/api/adventures", tags=["adventures"])
CurrentUser = Depends(auth.get_current_user)
def get_adventure_or_404(adventure_id: int, db: Session) -> models.Adventure:
def get_adventure_or_404(
adventure_id: int, db: Session, user: models.User
) -> models.Adventure:
adventure = db.get(models.Adventure, adventure_id) adventure = db.get(models.Adventure, adventure_id)
if adventure is None: if adventure is None or adventure.user_id != user.id:
raise HTTPException(404, "Adventure not found") raise HTTPException(404, "Adventure not found")
return adventure return adventure
@router.get("", response_model=list[schemas.AdventureListItem]) @router.get("", response_model=list[schemas.AdventureListItem])
def list_adventures(db: Session = Depends(get_db)): def list_adventures(db: Session = Depends(get_db), user: models.User = CurrentUser):
rows = ( rows = (
db.query(models.Adventure, func.count(models.Action.id), models.Scenario.title) db.query(models.Adventure, func.count(models.Action.id), models.Scenario.title)
.outerjoin(models.Action) .outerjoin(models.Action)
.outerjoin(models.Scenario, models.Adventure.scenario_id == models.Scenario.id) .outerjoin(models.Scenario, models.Adventure.scenario_id == models.Scenario.id)
.filter(models.Adventure.user_id == user.id)
.group_by(models.Adventure.id) .group_by(models.Adventure.id)
.order_by(models.Adventure.updated_at.desc()) .order_by(models.Adventure.updated_at.desc())
.all() .all()
@@ -60,15 +65,21 @@ def fill_placeholders(text: str, values: dict[str, str]) -> str:
@router.post("", response_model=schemas.AdventureOut, status_code=201) @router.post("", response_model=schemas.AdventureOut, status_code=201)
def create_adventure(payload: schemas.AdventureCreate, db: Session = Depends(get_db)): def create_adventure(
payload: schemas.AdventureCreate,
db: Session = Depends(get_db),
user: models.User = CurrentUser,
):
scenario = None scenario = None
if payload.scenario_id is not None: if payload.scenario_id is not None:
scenario = db.get(models.Scenario, payload.scenario_id) scenario = db.get(models.Scenario, payload.scenario_id)
if scenario is None: # Playable = your own scenario or a shared demo one.
if scenario is None or (scenario.user_id != user.id and not scenario.is_public):
raise HTTPException(404, "Scenario not found") raise HTTPException(404, "Scenario not found")
values = payload.placeholders values = payload.placeholders
adventure = models.Adventure( adventure = models.Adventure(
user_id=user.id,
scenario_id=scenario.id if scenario else None, scenario_id=scenario.id if scenario else None,
title=payload.title or (scenario.title if scenario else "Untitled Adventure"), title=payload.title or (scenario.title if scenario else "Untitled Adventure"),
memory=fill_placeholders(scenario.memory, values) if scenario else "", memory=fill_placeholders(scenario.memory, values) if scenario else "",
@@ -119,15 +130,20 @@ def create_adventure(payload: schemas.AdventureCreate, db: Session = Depends(get
@router.get("/{adventure_id}", response_model=schemas.AdventureOut) @router.get("/{adventure_id}", response_model=schemas.AdventureOut)
def get_adventure(adventure_id: int, db: Session = Depends(get_db)): def get_adventure(
return get_adventure_or_404(adventure_id, db) adventure_id: int, db: Session = Depends(get_db), user: models.User = CurrentUser
):
return get_adventure_or_404(adventure_id, db, user)
@router.patch("/{adventure_id}", response_model=schemas.AdventureOut) @router.patch("/{adventure_id}", response_model=schemas.AdventureOut)
def update_adventure( def update_adventure(
adventure_id: int, payload: schemas.AdventureUpdate, db: Session = Depends(get_db) adventure_id: int,
payload: schemas.AdventureUpdate,
db: Session = Depends(get_db),
user: models.User = CurrentUser,
): ):
adventure = get_adventure_or_404(adventure_id, db) adventure = get_adventure_or_404(adventure_id, db, user)
for field, value in payload.model_dump(exclude_unset=True).items(): for field, value in payload.model_dump(exclude_unset=True).items():
setattr(adventure, field, value) setattr(adventure, field, value)
db.commit() db.commit()
@@ -135,8 +151,10 @@ def update_adventure(
@router.delete("/{adventure_id}", status_code=204) @router.delete("/{adventure_id}", status_code=204)
def delete_adventure(adventure_id: int, db: Session = Depends(get_db)): def delete_adventure(
adventure = get_adventure_or_404(adventure_id, db) adventure_id: int, db: Session = Depends(get_db), user: models.User = CurrentUser
):
adventure = get_adventure_or_404(adventure_id, db, user)
db.delete(adventure) db.delete(adventure)
db.commit() db.commit()
@@ -197,11 +215,26 @@ def next_index(adventure: models.Adventure) -> int:
return max((a.index for a in adventure.actions), default=-1) + 1 return max((a.index for a in adventure.actions), default=-1) + 1
async def generate_turn(adventure: models.Adventure, db: Session, pipeline: ScriptPipeline): async def generate_turn(
adventure: models.Adventure,
db: Session,
pipeline: ScriptPipeline,
user: models.User,
):
"""SSE generator: streams the AI continuation through the context/output """SSE generator: streams the AI continuation through the context/output
script hooks, then stores the result.""" script hooks, then stores the result."""
settings = get_settings(db) settings = get_settings(db, user)
memories = await memorybank.retrieve_memories(adventure, settings, update_stats=True) cfg = auth.resolve_provider_config(settings)
if cfg.using_demo:
# No embedding/summarization calls on the server-funded key: memory
# retrieval is skipped (with a visible note when the bank is on).
memories = (
{"used": [], "error": "Memory bank is unavailable on the shared demo key — add your own API key in Settings."}
if adventure.memory_bank_enabled
else None
)
else:
memories = await memorybank.retrieve_memories(adventure, settings, update_stats=True)
system_text, story_text, snapshot = build_context(adventure, settings, memories) system_text, story_text, snapshot = build_context(adventure, settings, memories)
# onModelContext: scripts see (and may rewrite) the whole assembled context. # onModelContext: scripts see (and may rewrite) the whole assembled context.
@@ -223,7 +256,7 @@ async def generate_turn(adventure: models.Adventure, db: Session, pipeline: Scri
} }
provider = OpenAICompatibleProvider( provider = OpenAICompatibleProvider(
settings.endpoint_url, settings.api_key, settings.model, settings.api_mode, cfg.endpoint_url, cfg.api_key, cfg.model, settings.api_mode,
settings.reasoning_max_tokens, settings.reasoning_max_tokens,
) )
chunks: list[str] = [] chunks: list[str] = []
@@ -264,15 +297,33 @@ async def generate_turn(adventure: models.Adventure, db: Session, pipeline: Scri
) )
db.add(ai_action) db.add(ai_action)
adventure.updated_at = models.utcnow() adventure.updated_at = models.utcnow()
if cfg.using_demo:
# Successful demo turns count against the daily cap (checked up front
# in the endpoint); failed provider calls above don't reach here.
auth.count_demo_turn(user)
db.commit() db.commit()
db.refresh(ai_action) db.refresh(ai_action)
yield sse({"type": "done", "action": action_json(ai_action), "script": pipeline.report()}) yield sse({"type": "done", "action": action_json(ai_action), "script": pipeline.report()})
# Phase 6: fire-and-forget summarization/embedding (opens its own DB session). # Phase 6: fire-and-forget summarization/embedding (opens its own DB
memorybank.schedule_post_turn(adventure) # session). Skipped on the demo key — background AI calls would be
# unmetered spend on the server-funded key.
if not cfg.using_demo:
memorybank.schedule_post_turn(adventure)
def check_demo_cap(db: Session, user: models.User) -> None:
"""409/429-style guard before a turn starts, so a capped player's input
isn't stored and then left without a reply."""
settings = get_settings(db, user)
if auth.resolve_provider_config(settings).using_demo and auth.demo_turns_left(user) <= 0:
raise HTTPException(429, auth.DEMO_CAP_MESSAGE)
async def run_player_turn( async def run_player_turn(
adventure: models.Adventure, db: Session, payload: schemas.ActionCreate adventure: models.Adventure,
db: Session,
payload: schemas.ActionCreate,
user: models.User,
): ):
pipeline = ScriptPipeline(adventure, db) pipeline = ScriptPipeline(adventure, db)
@@ -304,26 +355,33 @@ async def run_player_turn(
yield sse({"type": "stopped", "script": pipeline.report()}) yield sse({"type": "stopped", "script": pipeline.report()})
return return
async for event in generate_turn(adventure, db, pipeline): async for event in generate_turn(adventure, db, pipeline, user):
yield event yield event
@router.post("/{adventure_id}/actions") @router.post("/{adventure_id}/actions")
def create_action( def create_action(
adventure_id: int, payload: schemas.ActionCreate, db: Session = Depends(get_db) adventure_id: int,
payload: schemas.ActionCreate,
db: Session = Depends(get_db),
user: models.User = CurrentUser,
): ):
adventure = get_adventure_or_404(adventure_id, db) adventure = get_adventure_or_404(adventure_id, db, user)
check_demo_cap(db, user)
acquire_turn_lock(adventure_id) acquire_turn_lock(adventure_id)
return StreamingResponse( return StreamingResponse(
with_turn_lock(adventure_id, run_player_turn(adventure, db, payload)), with_turn_lock(adventure_id, run_player_turn(adventure, db, payload, user)),
media_type="text/event-stream", media_type="text/event-stream",
) )
@router.post("/{adventure_id}/retry") @router.post("/{adventure_id}/retry")
def retry_action(adventure_id: int, db: Session = Depends(get_db)): def retry_action(
adventure_id: int, db: Session = Depends(get_db), user: models.User = CurrentUser
):
"""Delete the last AI action and regenerate from the same input.""" """Delete the last AI action and regenerate from the same input."""
adventure = get_adventure_or_404(adventure_id, db) adventure = get_adventure_or_404(adventure_id, db, user)
check_demo_cap(db, user)
acquire_turn_lock(adventure_id) acquire_turn_lock(adventure_id)
try: try:
if adventure.actions and adventure.actions[-1].type == "ai": if adventure.actions and adventure.actions[-1].type == "ai":
@@ -334,15 +392,20 @@ def retry_action(adventure_id: int, db: Session = Depends(get_db)):
_active_turns.discard(adventure_id) _active_turns.discard(adventure_id)
raise raise
return StreamingResponse( return StreamingResponse(
with_turn_lock(adventure_id, generate_turn(adventure, db, ScriptPipeline(adventure, db))), with_turn_lock(
adventure_id,
generate_turn(adventure, db, ScriptPipeline(adventure, db), user),
),
media_type="text/event-stream", media_type="text/event-stream",
) )
@router.post("/{adventure_id}/undo", response_model=list[schemas.ActionOut]) @router.post("/{adventure_id}/undo", response_model=list[schemas.ActionOut])
def undo_turn(adventure_id: int, db: Session = Depends(get_db)): def undo_turn(
adventure_id: int, db: Session = Depends(get_db), user: models.User = CurrentUser
):
"""Delete the last turn: the trailing AI action plus its player action, if any.""" """Delete the last turn: the trailing AI action plus its player action, if any."""
adventure = get_adventure_or_404(adventure_id, db) adventure = get_adventure_or_404(adventure_id, db, user)
actions = list(adventure.actions) actions = list(adventure.actions)
if not actions or actions[-1].type == "start": if not actions or actions[-1].type == "start":
raise HTTPException(400, "Nothing to undo") raise HTTPException(400, "Nothing to undo")
@@ -358,9 +421,11 @@ def undo_turn(adventure_id: int, db: Session = Depends(get_db)):
# ---------- Import / Export ---------- # ---------- Import / Export ----------
@router.get("/{adventure_id}/export") @router.get("/{adventure_id}/export")
def export_adventure(adventure_id: int, db: Session = Depends(get_db)): def export_adventure(
adventure_id: int, db: Session = Depends(get_db), user: models.User = CurrentUser
):
"""Full backup: plot components, story cards, scripts (+state), every action.""" """Full backup: plot components, story cards, scripts (+state), every action."""
adv = get_adventure_or_404(adventure_id, db) adv = get_adventure_or_404(adventure_id, db, user)
return { return {
"format": "ai-dnd-adventure-v1", "format": "ai-dnd-adventure-v1",
"title": adv.title, "title": adv.title,
@@ -406,11 +471,16 @@ def export_adventure(adventure_id: int, db: Session = Depends(get_db)):
@router.post("/import", response_model=schemas.AdventureOut, status_code=201) @router.post("/import", response_model=schemas.AdventureOut, status_code=201)
def import_adventure(bundle: dict = Body(...), db: Session = Depends(get_db)): def import_adventure(
bundle: dict = Body(...),
db: Session = Depends(get_db),
user: models.User = CurrentUser,
):
if bundle.get("format") != "ai-dnd-adventure-v1": if bundle.get("format") != "ai-dnd-adventure-v1":
raise HTTPException(400, "Not an adventure export file (expected format ai-dnd-adventure-v1).") raise HTTPException(400, "Not an adventure export file (expected format ai-dnd-adventure-v1).")
adventure = models.Adventure( adventure = models.Adventure(
user_id=user.id,
title=str(bundle.get("title") or "Imported Adventure"), title=str(bundle.get("title") or "Imported Adventure"),
memory=str(bundle.get("memory") or ""), memory=str(bundle.get("memory") or ""),
authors_note=str(bundle.get("authorsNote") or ""), authors_note=str(bundle.get("authorsNote") or ""),
@@ -480,8 +550,10 @@ def import_adventure(bundle: dict = Body(...), db: Session = Depends(get_db)):
# ---------- Adventure scripts ---------- # ---------- Adventure scripts ----------
@router.get("/{adventure_id}/scripts", response_model=list[schemas.AdventureScriptOut]) @router.get("/{adventure_id}/scripts", response_model=list[schemas.AdventureScriptOut])
def list_adventure_scripts(adventure_id: int, db: Session = Depends(get_db)): def list_adventure_scripts(
return get_adventure_or_404(adventure_id, db).scripts adventure_id: int, db: Session = Depends(get_db), user: models.User = CurrentUser
):
return get_adventure_or_404(adventure_id, db, user).scripts
@router.patch( @router.patch(
@@ -492,7 +564,9 @@ def update_adventure_script(
adv_script_id: int, adv_script_id: int,
payload: schemas.AdventureScriptUpdate, payload: schemas.AdventureScriptUpdate,
db: Session = Depends(get_db), db: Session = Depends(get_db),
user: models.User = CurrentUser,
): ):
get_adventure_or_404(adventure_id, db, user)
script = db.get(models.AdventureScript, adv_script_id) script = db.get(models.AdventureScript, adv_script_id)
if script is None or script.adventure_id != adventure_id: if script is None or script.adventure_id != adventure_id:
raise HTTPException(404, "Script not found") raise HTTPException(404, "Script not found")
@@ -505,17 +579,32 @@ def update_adventure_script(
# ---------- Insights ---------- # ---------- Insights ----------
@router.get("/{adventure_id}/context") @router.get("/{adventure_id}/context")
async def dry_run_context(adventure_id: int, db: Session = Depends(get_db)): async def dry_run_context(
adventure_id: int, db: Session = Depends(get_db), user: models.User = CurrentUser
):
"""What would be sent to the AI if the player continued right now.""" """What would be sent to the AI if the player continued right now."""
adventure = get_adventure_or_404(adventure_id, db) adventure = get_adventure_or_404(adventure_id, db, user)
settings = get_settings(db) settings = get_settings(db, user)
memories = await memorybank.retrieve_memories(adventure, settings, update_stats=False) if auth.resolve_provider_config(settings).using_demo:
memories = (
{"used": [], "error": "Memory bank is unavailable on the shared demo key."}
if adventure.memory_bank_enabled
else None
)
else:
memories = await memorybank.retrieve_memories(adventure, settings, update_stats=False)
_, _, report = build_context(adventure, settings, memories) _, _, report = build_context(adventure, settings, memories)
return report return report
@router.get("/{adventure_id}/actions/{action_id}/context") @router.get("/{adventure_id}/actions/{action_id}/context")
def action_context(adventure_id: int, action_id: int, db: Session = Depends(get_db)): def action_context(
adventure_id: int,
action_id: int,
db: Session = Depends(get_db),
user: models.User = CurrentUser,
):
get_adventure_or_404(adventure_id, db, user)
action = db.get(models.Action, action_id) action = db.get(models.Action, action_id)
if action is None or action.adventure_id != adventure_id: if action is None or action.adventure_id != adventure_id:
raise HTTPException(404, "Action not found") raise HTTPException(404, "Action not found")
@@ -527,16 +616,21 @@ def action_context(adventure_id: int, action_id: int, db: Session = Depends(get_
# ---------- Memory bank (Phase 6) ---------- # ---------- Memory bank (Phase 6) ----------
@router.get("/{adventure_id}/memories", response_model=list[schemas.MemoryOut]) @router.get("/{adventure_id}/memories", response_model=list[schemas.MemoryOut])
def list_memories(adventure_id: int, db: Session = Depends(get_db)): def list_memories(
return get_adventure_or_404(adventure_id, db).memories adventure_id: int, db: Session = Depends(get_db), user: models.User = CurrentUser
):
return get_adventure_or_404(adventure_id, db, user).memories
@router.post("/{adventure_id}/memories", response_model=schemas.MemoryOut, status_code=201) @router.post("/{adventure_id}/memories", response_model=schemas.MemoryOut, status_code=201)
def create_memory( def create_memory(
adventure_id: int, payload: schemas.MemoryCreate, db: Session = Depends(get_db) adventure_id: int,
payload: schemas.MemoryCreate,
db: Session = Depends(get_db),
user: models.User = CurrentUser,
): ):
"""Manually add a memory; it gets embedded by the next post-turn pass.""" """Manually add a memory; it gets embedded by the next post-turn pass."""
adventure = get_adventure_or_404(adventure_id, db) adventure = get_adventure_or_404(adventure_id, db, user)
if not payload.text.strip(): if not payload.text.strip():
raise HTTPException(400, "Memory text cannot be empty") raise HTTPException(400, "Memory text cannot be empty")
memory = models.Memory(adventure_id=adventure.id, text=payload.text.strip()) memory = models.Memory(adventure_id=adventure.id, text=payload.text.strip())
@@ -552,7 +646,9 @@ def update_memory(
memory_id: int, memory_id: int,
payload: schemas.MemoryUpdate, payload: schemas.MemoryUpdate,
db: Session = Depends(get_db), db: Session = Depends(get_db),
user: models.User = CurrentUser,
): ):
get_adventure_or_404(adventure_id, db, user)
memory = db.get(models.Memory, memory_id) memory = db.get(models.Memory, memory_id)
if memory is None or memory.adventure_id != adventure_id: if memory is None or memory.adventure_id != adventure_id:
raise HTTPException(404, "Memory not found") raise HTTPException(404, "Memory not found")
@@ -566,7 +662,13 @@ def update_memory(
@router.delete("/{adventure_id}/memories/{memory_id}", status_code=204) @router.delete("/{adventure_id}/memories/{memory_id}", status_code=204)
def delete_memory(adventure_id: int, memory_id: int, db: Session = Depends(get_db)): def delete_memory(
adventure_id: int,
memory_id: int,
db: Session = Depends(get_db),
user: models.User = CurrentUser,
):
get_adventure_or_404(adventure_id, db, user)
memory = db.get(models.Memory, memory_id) memory = db.get(models.Memory, memory_id)
if memory is None or memory.adventure_id != adventure_id: if memory is None or memory.adventure_id != adventure_id:
raise HTTPException(404, "Memory not found") raise HTTPException(404, "Memory not found")
@@ -577,8 +679,10 @@ def delete_memory(adventure_id: int, memory_id: int, db: Session = Depends(get_d
# ---------- Actions (CRUD) ---------- # ---------- Actions (CRUD) ----------
@router.get("/{adventure_id}/actions", response_model=list[schemas.ActionOut]) @router.get("/{adventure_id}/actions", response_model=list[schemas.ActionOut])
def list_actions(adventure_id: int, db: Session = Depends(get_db)): def list_actions(
get_adventure_or_404(adventure_id, db) adventure_id: int, db: Session = Depends(get_db), user: models.User = CurrentUser
):
get_adventure_or_404(adventure_id, db, user)
return ( return (
db.query(models.Action) db.query(models.Action)
.filter(models.Action.adventure_id == adventure_id) .filter(models.Action.adventure_id == adventure_id)
@@ -593,7 +697,9 @@ def update_action(
action_id: int, action_id: int,
payload: schemas.ActionUpdate, payload: schemas.ActionUpdate,
db: Session = Depends(get_db), db: Session = Depends(get_db),
user: models.User = CurrentUser,
): ):
get_adventure_or_404(adventure_id, db, user)
action = db.get(models.Action, action_id) action = db.get(models.Action, action_id)
if action is None or action.adventure_id != adventure_id: if action is None or action.adventure_id != adventure_id:
raise HTTPException(404, "Action not found") raise HTTPException(404, "Action not found")
@@ -603,7 +709,13 @@ def update_action(
@router.delete("/{adventure_id}/actions/{action_id}", status_code=204) @router.delete("/{adventure_id}/actions/{action_id}", status_code=204)
def delete_action(adventure_id: int, action_id: int, db: Session = Depends(get_db)): def delete_action(
adventure_id: int,
action_id: int,
db: Session = Depends(get_db),
user: models.User = CurrentUser,
):
get_adventure_or_404(adventure_id, db, user)
action = db.get(models.Action, action_id) action = db.get(models.Action, action_id)
if action is None or action.adventure_id != adventure_id: if action is None or action.adventure_id != adventure_id:
raise HTTPException(404, "Action not found") raise HTTPException(404, "Action not found")
+120
View File
@@ -0,0 +1,120 @@
import re
from fastapi import APIRouter, Depends, HTTPException, Request, Response
from sqlalchemy.orm import Session
from .. import auth, models, schemas, security
from ..database import get_db
from .settings import get_settings
router = APIRouter(prefix="/api/auth", tags=["auth"])
EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
def _set_session_cookie(response: Response, user_id: int) -> None:
response.set_cookie(
auth.SESSION_COOKIE,
security.sign_session(user_id),
max_age=auth.COOKIE_MAX_AGE,
httponly=True,
samesite="lax",
secure=auth.COOKIE_SECURE,
path="/",
)
def me_payload(user: models.User, db: Session) -> dict:
settings = get_settings(db, user)
cfg = auth.resolve_provider_config(settings)
return {
"multi_user": auth.MULTI_USER,
"id": user.id,
"email": user.email,
"is_guest": user.is_guest,
"demo": {
"enabled": auth.demo_enabled(),
"using_demo": cfg.using_demo,
"model": cfg.model if cfg.using_demo else None,
"turns_per_day": auth.DEMO_TURNS_PER_DAY,
"turns_left": auth.demo_turns_left(user) if auth.demo_enabled() else None,
"models": auth.DEMO_MODELS if auth.demo_enabled() else [],
},
}
@router.get("/me")
def me(request: Request, response: Response, db: Session = Depends(get_db)):
"""Who am I? In multi-user mode this also bootstraps the session: with no
(or an invalid) cookie it creates a guest user and sets one — the
frontend calls this on load and after any 401."""
if not auth.MULTI_USER:
user = auth.local_user(db)
else:
user = auth.resolve_session_user(request, db)
if user is None:
user = models.User(is_guest=True)
db.add(user)
db.commit()
_set_session_cookie(response, user.id)
return me_payload(user, db)
@router.post("/register")
def register(
payload: schemas.AuthCredentials,
request: Request,
db: Session = Depends(get_db),
user: models.User = Depends(auth.get_current_user),
):
"""Upgrade the current guest in place — same user_id, so every adventure,
scenario, script and setting they created as a guest is kept."""
if not auth.MULTI_USER:
raise HTTPException(400, "Accounts are disabled in local mode.")
auth.rate_limit_auth(request)
email = payload.email.strip().lower()
if not EMAIL_RE.match(email):
raise HTTPException(422, "Enter a valid email address.")
if len(payload.password) < 8:
raise HTTPException(422, "Password must be at least 8 characters.")
if not user.is_guest:
raise HTTPException(400, "This session is already registered.")
if db.query(models.User).filter(models.User.email == email).first():
raise HTTPException(409, "An account with this email already exists — log in instead.")
user.email = email
user.password_hash = security.hash_password(payload.password)
user.is_guest = False
db.commit()
return me_payload(user, db)
@router.post("/login")
def login(
payload: schemas.AuthCredentials,
request: Request,
response: Response,
db: Session = Depends(get_db),
):
"""Point this browser's session at an existing account. Any current guest
session is simply abandoned (its data stays under the guest user)."""
if not auth.MULTI_USER:
raise HTTPException(400, "Accounts are disabled in local mode.")
auth.rate_limit_auth(request)
email = payload.email.strip().lower()
user = db.query(models.User).filter(models.User.email == email).first()
if (
user is None
or not user.password_hash
or not security.verify_password(payload.password, user.password_hash)
):
raise HTTPException(401, "Incorrect email or password.")
_set_session_cookie(response, user.id)
return me_payload(user, db)
@router.post("/logout")
def logout(response: Response):
if not auth.MULTI_USER:
raise HTTPException(400, "Accounts are disabled in local mode.")
response.delete_cookie(auth.SESSION_COOKIE, path="/")
return {"ok": True}
+9 -3
View File
@@ -1,11 +1,17 @@
from fastapi import APIRouter from fastapi import APIRouter, HTTPException
from .. import debuglog from .. import auth, debuglog
router = APIRouter(prefix="/api/debug", tags=["debug"]) router = APIRouter(prefix="/api/debug", tags=["debug"])
@router.get("/requests") @router.get("/requests")
def recent_requests(): def recent_requests():
"""Most-recent-first log of provider requests/responses (no API keys).""" """Most-recent-first log of provider requests/responses (no API keys).
The log is a single process-wide ring buffer with no per-user
attribution, so in multi-user (hosted) mode it would leak other players'
prompts — disabled there, available on local installs."""
if auth.MULTI_USER:
raise HTTPException(403, "The debug log is only available on local installs.")
return debuglog.recent() return debuglog.recent()
+55 -17
View File
@@ -1,52 +1,77 @@
from fastapi import APIRouter, Body, Depends, HTTPException from fastapi import APIRouter, Body, Depends, HTTPException
from sqlalchemy import or_
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from .. import models, schemas from .. import auth, models, schemas
from ..database import get_db from ..database import get_db
router = APIRouter(prefix="/api/scenarios", tags=["scenarios"]) router = APIRouter(prefix="/api/scenarios", tags=["scenarios"])
def get_scenario_or_404(scenario_id: int, db: Session) -> models.Scenario: def get_scenario_or_404(
scenario_id: int, db: Session, user: models.User, *, edit: bool = False
) -> models.Scenario:
"""Visible = owned or public; editable = owned only."""
scenario = db.get(models.Scenario, scenario_id) scenario = db.get(models.Scenario, scenario_id)
if scenario is None: if scenario is None or (scenario.user_id != user.id and not scenario.is_public):
raise HTTPException(404, "Scenario not found") raise HTTPException(404, "Scenario not found")
if edit and scenario.user_id != user.id:
raise HTTPException(403, "This is a shared demo scenario — it can't be edited. Start an adventure from it, or duplicate it.")
return scenario return scenario
@router.get("", response_model=list[schemas.ScenarioListItem]) @router.get("", response_model=list[schemas.ScenarioListItem])
def list_scenarios(db: Session = Depends(get_db)): def list_scenarios(
db: Session = Depends(get_db),
user: models.User = Depends(auth.get_current_user),
):
return ( return (
db.query(models.Scenario) db.query(models.Scenario)
.filter(or_(models.Scenario.user_id == user.id, models.Scenario.is_public))
.order_by(models.Scenario.updated_at.desc()) .order_by(models.Scenario.updated_at.desc())
.all() .all()
) )
@router.post("", response_model=schemas.ScenarioOut, status_code=201) @router.post("", response_model=schemas.ScenarioOut, status_code=201)
def create_scenario(payload: schemas.ScenarioCreate, db: Session = Depends(get_db)): def create_scenario(
scenario = models.Scenario(**payload.model_dump()) payload: schemas.ScenarioCreate,
db: Session = Depends(get_db),
user: models.User = Depends(auth.get_current_user),
):
scenario = models.Scenario(**payload.model_dump(), user_id=user.id)
db.add(scenario) db.add(scenario)
db.commit() db.commit()
return scenario return scenario
@router.get("/{scenario_id}", response_model=schemas.ScenarioOut) @router.get("/{scenario_id}", response_model=schemas.ScenarioOut)
def get_scenario(scenario_id: int, db: Session = Depends(get_db)): def get_scenario(
return get_scenario_or_404(scenario_id, db) scenario_id: int,
db: Session = Depends(get_db),
user: models.User = Depends(auth.get_current_user),
):
return get_scenario_or_404(scenario_id, db, user)
@router.patch("/{scenario_id}", response_model=schemas.ScenarioOut) @router.patch("/{scenario_id}", response_model=schemas.ScenarioOut)
def update_scenario( def update_scenario(
scenario_id: int, payload: schemas.ScenarioUpdate, db: Session = Depends(get_db) scenario_id: int,
payload: schemas.ScenarioUpdate,
db: Session = Depends(get_db),
user: models.User = Depends(auth.get_current_user),
): ):
scenario = get_scenario_or_404(scenario_id, db) scenario = get_scenario_or_404(scenario_id, db, user, edit=True)
data = payload.model_dump(exclude_unset=True) data = payload.model_dump(exclude_unset=True)
script_ids = data.pop("script_ids", None) script_ids = data.pop("script_ids", None)
for field, value in data.items(): for field, value in data.items():
setattr(scenario, field, value) setattr(scenario, field, value)
if script_ids is not None: if script_ids is not None:
scripts = db.query(models.Script).filter(models.Script.id.in_(script_ids)).all() scripts = (
db.query(models.Script)
.filter(models.Script.id.in_(script_ids), models.Script.user_id == user.id)
.all()
)
if len(scripts) != len(set(script_ids)): if len(scripts) != len(set(script_ids)):
raise HTTPException(404, "One or more scripts not found") raise HTTPException(404, "One or more scripts not found")
scenario.scripts = sorted(scripts, key=lambda s: script_ids.index(s.id)) scenario.scripts = sorted(scripts, key=lambda s: script_ids.index(s.id))
@@ -55,8 +80,12 @@ def update_scenario(
@router.delete("/{scenario_id}", status_code=204) @router.delete("/{scenario_id}", status_code=204)
def delete_scenario(scenario_id: int, db: Session = Depends(get_db)): def delete_scenario(
scenario = get_scenario_or_404(scenario_id, db) scenario_id: int,
db: Session = Depends(get_db),
user: models.User = Depends(auth.get_current_user),
):
scenario = get_scenario_or_404(scenario_id, db, user, edit=True)
db.delete(scenario) db.delete(scenario)
db.commit() db.commit()
@@ -64,8 +93,12 @@ def delete_scenario(scenario_id: int, db: Session = Depends(get_db)):
# ---------- Import / Export ---------- # ---------- Import / Export ----------
@router.get("/{scenario_id}/export") @router.get("/{scenario_id}/export")
def export_scenario(scenario_id: int, db: Session = Depends(get_db)): def export_scenario(
s = get_scenario_or_404(scenario_id, db) scenario_id: int,
db: Session = Depends(get_db),
user: models.User = Depends(auth.get_current_user),
):
s = get_scenario_or_404(scenario_id, db, user)
return { return {
"format": "ai-dnd-scenario-v1", "format": "ai-dnd-scenario-v1",
"title": s.title, "title": s.title,
@@ -107,7 +140,11 @@ _IGNORED_KEYS = {"format", "storyCards", "worldInfo", "worldInformation", "scrip
@router.post("/import", status_code=201) @router.post("/import", status_code=201)
def import_scenario(bundle: dict = Body(...), db: Session = Depends(get_db)): def import_scenario(
bundle: dict = Body(...),
db: Session = Depends(get_db),
user: models.User = Depends(auth.get_current_user),
):
"""Accepts our export format and AI Dungeon scenario exports best-effort; """Accepts our export format and AI Dungeon scenario exports best-effort;
reports any keys it didn't understand.""" reports any keys it didn't understand."""
fields: dict = {} fields: dict = {}
@@ -124,7 +161,7 @@ def import_scenario(bundle: dict = Body(...), db: Session = Depends(get_db)):
elif isinstance(tags, str): elif isinstance(tags, str):
fields["tags"] = tags fields["tags"] = tags
scenario = models.Scenario(**fields) scenario = models.Scenario(**fields, user_id=user.id)
if not scenario.title: if not scenario.title:
scenario.title = "Imported Scenario" scenario.title = "Imported Scenario"
db.add(scenario) db.add(scenario)
@@ -156,6 +193,7 @@ def import_scenario(bundle: dict = Body(...), db: Session = Depends(get_db)):
if not isinstance(item, dict): if not isinstance(item, dict):
continue continue
script = models.Script( script = models.Script(
user_id=user.id,
name=str(item.get("name") or "Imported Script"), name=str(item.get("name") or "Imported Script"),
description=str(item.get("description") or ""), description=str(item.get("description") or ""),
library_js=str(item.get("library") or item.get("sharedLibrary") or ""), library_js=str(item.get("library") or item.get("sharedLibrary") or ""),
+55 -18
View File
@@ -1,7 +1,7 @@
from fastapi import APIRouter, Body, Depends, HTTPException from fastapi import APIRouter, Body, Depends, HTTPException
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from .. import models, schemas from .. import auth, models, schemas
from ..database import get_db from ..database import get_db
from ..scripting import run_hook from ..scripting import run_hook
@@ -10,34 +10,55 @@ router = APIRouter(prefix="/api/scripts", tags=["scripts"])
HOOK_FIELDS = {"input": "input_js", "context": "context_js", "output": "output_js"} HOOK_FIELDS = {"input": "input_js", "context": "context_js", "output": "output_js"}
def get_script_or_404(script_id: int, db: Session) -> models.Script: def get_script_or_404(script_id: int, db: Session, user: models.User) -> models.Script:
script = db.get(models.Script, script_id) script = db.get(models.Script, script_id)
if script is None: if script is None or script.user_id != user.id:
raise HTTPException(404, "Script not found") raise HTTPException(404, "Script not found")
return script return script
@router.get("", response_model=list[schemas.ScriptOut]) @router.get("", response_model=list[schemas.ScriptOut])
def list_scripts(db: Session = Depends(get_db)): def list_scripts(
return db.query(models.Script).order_by(models.Script.updated_at.desc()).all() db: Session = Depends(get_db),
user: models.User = Depends(auth.get_current_user),
):
return (
db.query(models.Script)
.filter(models.Script.user_id == user.id)
.order_by(models.Script.updated_at.desc())
.all()
)
@router.post("", response_model=schemas.ScriptOut, status_code=201) @router.post("", response_model=schemas.ScriptOut, status_code=201)
def create_script(payload: schemas.ScriptCreate, db: Session = Depends(get_db)): def create_script(
script = models.Script(**payload.model_dump()) payload: schemas.ScriptCreate,
db: Session = Depends(get_db),
user: models.User = Depends(auth.get_current_user),
):
script = models.Script(**payload.model_dump(), user_id=user.id)
db.add(script) db.add(script)
db.commit() db.commit()
return script return script
@router.get("/{script_id}", response_model=schemas.ScriptOut) @router.get("/{script_id}", response_model=schemas.ScriptOut)
def get_script(script_id: int, db: Session = Depends(get_db)): def get_script(
return get_script_or_404(script_id, db) script_id: int,
db: Session = Depends(get_db),
user: models.User = Depends(auth.get_current_user),
):
return get_script_or_404(script_id, db, user)
@router.patch("/{script_id}", response_model=schemas.ScriptOut) @router.patch("/{script_id}", response_model=schemas.ScriptOut)
def update_script(script_id: int, payload: schemas.ScriptUpdate, db: Session = Depends(get_db)): def update_script(
script = get_script_or_404(script_id, db) script_id: int,
payload: schemas.ScriptUpdate,
db: Session = Depends(get_db),
user: models.User = Depends(auth.get_current_user),
):
script = get_script_or_404(script_id, db, user)
for field, value in payload.model_dump(exclude_unset=True).items(): for field, value in payload.model_dump(exclude_unset=True).items():
setattr(script, field, value) setattr(script, field, value)
db.commit() db.commit()
@@ -45,17 +66,24 @@ def update_script(script_id: int, payload: schemas.ScriptUpdate, db: Session = D
@router.delete("/{script_id}", status_code=204) @router.delete("/{script_id}", status_code=204)
def delete_script(script_id: int, db: Session = Depends(get_db)): def delete_script(
db.delete(get_script_or_404(script_id, db)) script_id: int,
db: Session = Depends(get_db),
user: models.User = Depends(auth.get_current_user),
):
db.delete(get_script_or_404(script_id, db, user))
db.commit() db.commit()
@router.post("/{script_id}/test") @router.post("/{script_id}/test")
def test_script( def test_script(
script_id: int, payload: schemas.ScriptTestRequest, db: Session = Depends(get_db) script_id: int,
payload: schemas.ScriptTestRequest,
db: Session = Depends(get_db),
user: models.User = Depends(auth.get_current_user),
): ):
"""Dry-run one hook against sample text — no AI call, no persistence.""" """Dry-run one hook against sample text — no AI call, no persistence."""
script = get_script_or_404(script_id, db) script = get_script_or_404(script_id, db, user)
result = run_hook( result = run_hook(
script.library_js, script.library_js,
getattr(script, HOOK_FIELDS[payload.hook]), getattr(script, HOOK_FIELDS[payload.hook]),
@@ -78,9 +106,13 @@ def test_script(
# ---------- Import / Export ---------- # ---------- Import / Export ----------
@router.get("/{script_id}/export") @router.get("/{script_id}/export")
def export_script(script_id: int, db: Session = Depends(get_db)): def export_script(
script_id: int,
db: Session = Depends(get_db),
user: models.User = Depends(auth.get_current_user),
):
"""JSON bundle matching how AI Dungeon scripts circulate.""" """JSON bundle matching how AI Dungeon scripts circulate."""
script = get_script_or_404(script_id, db) script = get_script_or_404(script_id, db, user)
return { return {
"name": script.name, "name": script.name,
"description": script.description, "description": script.description,
@@ -92,7 +124,11 @@ def export_script(script_id: int, db: Session = Depends(get_db)):
@router.post("/import", response_model=schemas.ScriptOut, status_code=201) @router.post("/import", response_model=schemas.ScriptOut, status_code=201)
def import_script(bundle: dict = Body(...), db: Session = Depends(get_db)): def import_script(
bundle: dict = Body(...),
db: Session = Depends(get_db),
user: models.User = Depends(auth.get_current_user),
):
"""Accepts our export bundle; tolerates *_js key names too.""" """Accepts our export bundle; tolerates *_js key names too."""
def pick(*keys: str) -> str: def pick(*keys: str) -> str:
for key in keys: for key in keys:
@@ -102,6 +138,7 @@ def import_script(bundle: dict = Body(...), db: Session = Depends(get_db)):
return "" return ""
script = models.Script( script = models.Script(
user_id=user.id,
name=pick("name") or "Imported Script", name=pick("name") or "Imported Script",
description=pick("description"), description=pick("description"),
library_js=pick("library", "library_js", "sharedLibrary"), library_js=pick("library", "library_js", "sharedLibrary"),
+43 -15
View File
@@ -2,30 +2,44 @@ import httpx
from fastapi import APIRouter, Depends from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from .. import models, schemas from .. import auth, models, schemas, security
from ..database import get_db from ..database import get_db
router = APIRouter(prefix="/api/settings", tags=["settings"]) router = APIRouter(prefix="/api/settings", tags=["settings"])
def get_settings(db: Session) -> models.Settings: def get_settings(db: Session, user: models.User) -> models.Settings:
settings = db.get(models.Settings, 1) """Per-user settings row, created on first access (Phase 8: settings —
endpoint, key, models, memory config — are per user, not global)."""
settings = (
db.query(models.Settings).filter(models.Settings.user_id == user.id).first()
)
if settings is None: if settings is None:
settings = models.Settings(id=1) settings = models.Settings(user_id=user.id)
db.add(settings) db.add(settings)
db.commit() db.commit()
return settings return settings
@router.get("", response_model=schemas.SettingsOut) @router.get("", response_model=schemas.SettingsOut)
def read_settings(db: Session = Depends(get_db)): def read_settings(
return get_settings(db) db: Session = Depends(get_db),
user: models.User = Depends(auth.get_current_user),
):
return get_settings(db, user)
@router.put("", response_model=schemas.SettingsOut) @router.put("", response_model=schemas.SettingsOut)
def update_settings(payload: schemas.SettingsUpdate, db: Session = Depends(get_db)): def update_settings(
settings = get_settings(db) payload: schemas.SettingsUpdate,
db: Session = Depends(get_db),
user: models.User = Depends(auth.get_current_user),
):
settings = get_settings(db, user)
fields = payload.model_dump(exclude_unset=True) fields = payload.model_dump(exclude_unset=True)
# Write-only API key: absent = unchanged, "" = cleared, else encrypted.
if "api_key" in fields:
fields["api_key"] = security.encrypt_secret(fields["api_key"].strip())
embedding_model_changed = ( embedding_model_changed = (
"embedding_model" in fields "embedding_model" in fields
and fields["embedding_model"] != settings.embedding_model and fields["embedding_model"] != settings.embedding_model
@@ -35,19 +49,33 @@ def update_settings(payload: schemas.SettingsUpdate, db: Session = Depends(get_d
if embedding_model_changed: if embedding_model_changed:
# Vectors from the old model have a different dimensionality/space; # Vectors from the old model have a different dimensionality/space;
# clear them so the post-turn task re-embeds with the new model. # clear them so the post-turn task re-embeds with the new model.
db.query(models.Memory).update({"embedding": None}) # (This user's adventures only — settings are per-user now.)
owned = (
db.query(models.Adventure.id)
.filter(models.Adventure.user_id == user.id)
.scalar_subquery()
)
db.query(models.Memory).filter(models.Memory.adventure_id.in_(owned)).update(
{"embedding": None}, synchronize_session=False
)
db.commit() db.commit()
return settings return settings
@router.post("/test") @router.post("/test")
async def test_connection(db: Session = Depends(get_db)): async def test_connection(
"""Hit the endpoint's /models listing as a cheap connectivity check.""" db: Session = Depends(get_db),
settings = get_settings(db) user: models.User = Depends(auth.get_current_user),
url = settings.endpoint_url.rstrip("/") + "/models" ):
"""Hit the endpoint's /models listing as a cheap connectivity check.
Tests whatever the turn engine would actually use — including the shared
demo endpoint when the user has no key of their own."""
settings = get_settings(db, user)
cfg = auth.resolve_provider_config(settings)
url = cfg.endpoint_url.rstrip("/") + "/models"
headers = {} headers = {}
if settings.api_key: if cfg.api_key:
headers["Authorization"] = f"Bearer {settings.api_key}" headers["Authorization"] = f"Bearer {cfg.api_key}"
try: try:
async with httpx.AsyncClient(timeout=10) as client: async with httpx.AsyncClient(timeout=10) as client:
resp = await client.get(url, headers=headers) resp = await client.get(url, headers=headers)
+40 -16
View File
@@ -1,33 +1,54 @@
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from .. import models, schemas from .. import auth, models, schemas
from ..database import get_db from ..database import get_db
router = APIRouter(prefix="/api/story-cards", tags=["story-cards"]) router = APIRouter(prefix="/api/story-cards", tags=["story-cards"])
def _card_editable_or_404(card: models.StoryCard | None, user: models.User) -> models.StoryCard:
"""Cards inherit their scope from the owning scenario/adventure. Public
(demo) scenarios are visible to everyone but editable by no one."""
if card is not None:
owner = card.scenario if card.scenario_id is not None else card.adventure
if owner is not None and owner.user_id == user.id:
return card
raise HTTPException(404, "Story card not found")
@router.get("", response_model=list[schemas.StoryCardOut]) @router.get("", response_model=list[schemas.StoryCardOut])
def list_story_cards( def list_story_cards(
scenario_id: int | None = None, scenario_id: int | None = None,
adventure_id: int | None = None, adventure_id: int | None = None,
db: Session = Depends(get_db), db: Session = Depends(get_db),
user: models.User = Depends(auth.get_current_user),
): ):
query = db.query(models.StoryCard) if (scenario_id is None) == (adventure_id is None):
raise HTTPException(422, "Provide exactly one of scenario_id or adventure_id")
if scenario_id is not None: if scenario_id is not None:
query = query.filter(models.StoryCard.scenario_id == scenario_id) scenario = db.get(models.Scenario, scenario_id)
if adventure_id is not None: if scenario is None or (scenario.user_id != user.id and not scenario.is_public):
query = query.filter(models.StoryCard.adventure_id == adventure_id) raise HTTPException(404, "Owner not found")
return query.order_by(models.StoryCard.id).all() return sorted(scenario.story_cards, key=lambda c: c.id)
adventure = db.get(models.Adventure, adventure_id)
if adventure is None or adventure.user_id != user.id:
raise HTTPException(404, "Owner not found")
return sorted(adventure.story_cards, key=lambda c: c.id)
@router.post("", response_model=schemas.StoryCardOut, status_code=201) @router.post("", response_model=schemas.StoryCardOut, status_code=201)
def create_story_card(payload: schemas.StoryCardCreate, db: Session = Depends(get_db)): def create_story_card(
payload: schemas.StoryCardCreate,
db: Session = Depends(get_db),
user: models.User = Depends(auth.get_current_user),
):
if (payload.scenario_id is None) == (payload.adventure_id is None): if (payload.scenario_id is None) == (payload.adventure_id is None):
raise HTTPException(422, "Provide exactly one of scenario_id or adventure_id") raise HTTPException(422, "Provide exactly one of scenario_id or adventure_id")
owner_model = models.Scenario if payload.scenario_id else models.Adventure owner_model = models.Scenario if payload.scenario_id else models.Adventure
owner_id = payload.scenario_id or payload.adventure_id owner_id = payload.scenario_id or payload.adventure_id
if db.get(owner_model, owner_id) is None: owner = db.get(owner_model, owner_id)
if owner is None or owner.user_id != user.id:
raise HTTPException(404, "Owner not found") raise HTTPException(404, "Owner not found")
card = models.StoryCard(**payload.model_dump()) card = models.StoryCard(**payload.model_dump())
db.add(card) db.add(card)
@@ -37,11 +58,12 @@ def create_story_card(payload: schemas.StoryCardCreate, db: Session = Depends(ge
@router.patch("/{card_id}", response_model=schemas.StoryCardOut) @router.patch("/{card_id}", response_model=schemas.StoryCardOut)
def update_story_card( def update_story_card(
card_id: int, payload: schemas.StoryCardUpdate, db: Session = Depends(get_db) card_id: int,
payload: schemas.StoryCardUpdate,
db: Session = Depends(get_db),
user: models.User = Depends(auth.get_current_user),
): ):
card = db.get(models.StoryCard, card_id) card = _card_editable_or_404(db.get(models.StoryCard, card_id), user)
if card is None:
raise HTTPException(404, "Story card not found")
for field, value in payload.model_dump(exclude_unset=True).items(): for field, value in payload.model_dump(exclude_unset=True).items():
setattr(card, field, value) setattr(card, field, value)
db.commit() db.commit()
@@ -49,9 +71,11 @@ def update_story_card(
@router.delete("/{card_id}", status_code=204) @router.delete("/{card_id}", status_code=204)
def delete_story_card(card_id: int, db: Session = Depends(get_db)): def delete_story_card(
card = db.get(models.StoryCard, card_id) card_id: int,
if card is None: db: Session = Depends(get_db),
raise HTTPException(404, "Story card not found") user: models.User = Depends(auth.get_current_user),
):
card = _card_editable_or_404(db.get(models.StoryCard, card_id), user)
db.delete(card) db.delete(card)
db.commit() db.commit()
+11 -1
View File
@@ -66,6 +66,7 @@ class ScenarioUpdate(BaseModel):
class ScenarioOut(ORMModel, ScenarioBase): class ScenarioOut(ORMModel, ScenarioBase):
id: int id: int
is_public: bool = False # shared demo content — read-only for everyone
created_at: datetime created_at: datetime
updated_at: datetime updated_at: datetime
story_cards: list[StoryCardOut] = [] story_cards: list[StoryCardOut] = []
@@ -77,6 +78,7 @@ class ScenarioListItem(ORMModel):
title: str title: str
description: str description: str
tags: str tags: str
is_public: bool = False
updated_at: datetime updated_at: datetime
@@ -226,11 +228,19 @@ class AdventureScriptUpdate(BaseModel):
output_js: str | None = None output_js: str | None = None
# ---------- Auth (Phase 8) ----------
class AuthCredentials(BaseModel):
email: str
password: str
# ---------- Settings ---------- # ---------- Settings ----------
class SettingsOut(ORMModel): class SettingsOut(ORMModel):
endpoint_url: str endpoint_url: str
api_key: str # The key itself is never echoed back (encrypted at rest, write-only).
has_api_key: bool
model: str model: str
api_mode: str api_mode: str
temperature: float temperature: float
+117
View File
@@ -0,0 +1,117 @@
"""Phase 8 — secrets and crypto primitives for optional accounts.
Everything keys off one server-side secret:
- session cookies are HMAC-signed with it,
- stored LLM API keys are Fernet-encrypted with a key derived from it.
The secret comes from AIDND_SECRET_KEY, or is auto-generated once into
`secret.key` next to the database so local installs and Docker volumes work
with zero configuration (losing the file logs everyone out and orphans
stored API keys — users just re-enter them).
Passwords use hashlib.scrypt (stdlib, OpenSSL-backed) so we don't need a
separate hashing dependency.
"""
import base64
import hashlib
import hmac
import os
import secrets
from cryptography.fernet import Fernet, InvalidToken
from .database import DB_PATH
_SECRET_FILE = DB_PATH.parent / "secret.key"
def _load_secret() -> bytes:
env = os.environ.get("AIDND_SECRET_KEY")
if env:
return env.encode()
if _SECRET_FILE.exists():
return _SECRET_FILE.read_bytes().strip()
secret = secrets.token_urlsafe(48).encode()
_SECRET_FILE.write_bytes(secret)
return secret
SECRET_KEY = _load_secret()
_fernet = Fernet(base64.urlsafe_b64encode(hashlib.sha256(SECRET_KEY).digest()))
# ---------- Password hashing (scrypt) ----------
_SCRYPT_N, _SCRYPT_R, _SCRYPT_P = 2**14, 8, 1
def hash_password(password: str) -> str:
salt = secrets.token_bytes(16)
key = hashlib.scrypt(
password.encode(), salt=salt, n=_SCRYPT_N, r=_SCRYPT_R, p=_SCRYPT_P
)
return f"scrypt${_SCRYPT_N}${_SCRYPT_R}${_SCRYPT_P}${salt.hex()}${key.hex()}"
def verify_password(password: str, stored: str) -> bool:
try:
scheme, n, r, p, salt_hex, key_hex = stored.split("$")
if scheme != "scrypt":
return False
key = hashlib.scrypt(
password.encode(), salt=bytes.fromhex(salt_hex),
n=int(n), r=int(r), p=int(p),
)
return hmac.compare_digest(key, bytes.fromhex(key_hex))
except (ValueError, AttributeError):
return False
# ---------- Session tokens ----------
# "v1.<user_id>.<hmac>" — no expiry (long-lived guest sessions are the point).
def sign_session(user_id: int) -> str:
payload = f"v1.{user_id}"
sig = hmac.new(SECRET_KEY, payload.encode(), hashlib.sha256).hexdigest()
return f"{payload}.{sig}"
def verify_session(token: str) -> int | None:
try:
version, user_id, sig = token.split(".")
if version != "v1":
return None
payload = f"{version}.{user_id}"
expected = hmac.new(SECRET_KEY, payload.encode(), hashlib.sha256).hexdigest()
if not hmac.compare_digest(sig, expected):
return None
return int(user_id)
except (ValueError, AttributeError):
return None
# ---------- API-key encryption at rest ----------
# Stored values carry an "enc:" prefix so plaintext keys from pre-Phase-8
# databases can be recognized and migrated.
ENC_PREFIX = "enc:"
def encrypt_secret(plain: str) -> str:
if not plain:
return ""
return ENC_PREFIX + _fernet.encrypt(plain.encode()).decode()
def decrypt_secret(stored: str) -> str:
"""Returns the plaintext key. Tolerates legacy plaintext values (returned
as-is) and undecryptable tokens (secret rotated → treated as unset)."""
if not stored:
return ""
if not stored.startswith(ENC_PREFIX):
return stored
try:
return _fernet.decrypt(stored[len(ENC_PREFIX):].encode()).decode()
except (InvalidToken, ValueError):
return ""
+14 -4
View File
@@ -2,9 +2,14 @@
Run from the backend folder: .venv\\Scripts\\python.exe seed_demo.py Run from the backend folder: .venv\\Scripts\\python.exe seed_demo.py
Safe to rerun: it deletes any previous rows titled "[Demo] ..." first. Safe to rerun: it deletes any previous rows titled "[Demo] ..." first.
Phase 8: the scenario is seeded as PUBLIC (user_id NULL + is_public), so in
multi-user mode every guest sees it as read-only starter content. The sample
adventure and script-library copies belong to the local user (only relevant
on single-user installs).
""" """
from app import models, migrations from app import auth, models, migrations
from app.database import SessionLocal, engine from app.database import SessionLocal, engine
# create_all + user_version stamp; plain create_all would leave a fresh DB at # create_all + user_version stamp; plain create_all would leave a fresh DB at
@@ -205,6 +210,8 @@ STORY_CARDS = [
db = SessionLocal() db = SessionLocal()
try: try:
owner = auth.local_user(db)
# Remove earlier demo rows so reruns stay clean. # Remove earlier demo rows so reruns stay clean.
for adv in db.query(models.Adventure).filter(models.Adventure.title.like(f"{DEMO_PREFIX}%")): for adv in db.query(models.Adventure).filter(models.Adventure.title.like(f"{DEMO_PREFIX}%")):
db.delete(adv) db.delete(adv)
@@ -214,12 +221,14 @@ try:
db.delete(s) db.delete(s)
db.commit() db.commit()
# Script library # Scripts attached to the public scenario are unowned (user_id NULL) so
# they ship with it everywhere; they're copied into each adventure at
# creation, so they never need to appear in anyone's script library.
scripts = [models.Script(**s) for s in SCRIPTS] scripts = [models.Script(**s) for s in SCRIPTS]
db.add_all(scripts) db.add_all(scripts)
# Scenario with cards and scripts attached # Scenario with cards and scripts attached — public starter content.
scenario = models.Scenario(**SCENARIO) scenario = models.Scenario(**SCENARIO, is_public=True)
scenario.scripts = scripts scenario.scripts = scripts
db.add(scenario) db.add(scenario)
db.flush() db.flush()
@@ -228,6 +237,7 @@ try:
# Adventure created from the scenario, mirroring POST /api/adventures # Adventure created from the scenario, mirroring POST /api/adventures
adventure = models.Adventure( adventure = models.Adventure(
user_id=owner.id,
scenario_id=scenario.id, scenario_id=scenario.id,
title=scenario.title, title=scenario.title,
memory=scenario.memory, memory=scenario.memory,
+46 -1
View File
@@ -1,6 +1,32 @@
import { useEffect, useState } from 'react'
import { NavLink, Outlet } from 'react-router-dom' import { NavLink, Outlet } from 'react-router-dom'
import { api } from './api'
import { AuthModal } from './components'
export default function App() { export default function App() {
// null until /auth/me resolves; in local mode multi_user=false hides all auth UI.
const [me, setMe] = useState(null)
const [authMode, setAuthMode] = useState(null) // 'register' | 'login' | null
useEffect(() => {
api.getMe().then(setMe).catch(() => {})
}, [])
const onAuthed = (newMe, mode) => {
setAuthMode(null)
if (mode === 'login') {
// Different user now — reload so every page refetches its scoped data.
window.location.reload()
} else {
setMe(newMe) // register upgrades the same user in place; data unchanged
}
}
const logout = async () => {
try { await api.logout() } catch { /* already logged out */ }
window.location.reload()
}
return ( return (
<> <>
<nav className="topnav"> <nav className="topnav">
@@ -17,8 +43,27 @@ export default function App() {
<NavLink to="/settings" className={({ isActive }) => `navlink${isActive ? ' active' : ''}`}> <NavLink to="/settings" className={({ isActive }) => `navlink${isActive ? ' active' : ''}`}>
Settings Settings
</NavLink> </NavLink>
{me?.multi_user && (
<div className="nav-account">
{me.is_guest ? (
<>
<span className="guest-nudge">Playing as guest — sign up to keep your adventures</span>
<button onClick={() => setAuthMode('login')}>Log in</button>
<button className="primary" onClick={() => setAuthMode('register')}>Sign up</button>
</>
) : (
<>
<span className="account-email" title={me.email}>{me.email}</span>
<button onClick={logout}>Log out</button>
</>
)}
</div>
)}
</nav> </nav>
<Outlet /> <Outlet context={{ me, setMe }} />
{authMode && (
<AuthModal mode={authMode} onClose={() => setAuthMode(null)} onAuthed={onAuthed} />
)}
</> </>
) )
} }
+25 -2
View File
@@ -1,8 +1,19 @@
async function request(path, options = {}) { // 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}`, { const resp = await fetch(`/api${path}`, {
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
...options, ...options,
}) })
if (resp.status === 401 && !isRetry && path !== '/auth/me') {
await ensureSession()
return request(path, options, true)
}
if (!resp.ok) { if (!resp.ok) {
let detail = resp.statusText let detail = resp.statusText
try { try {
@@ -16,13 +27,17 @@ async function request(path, options = {}) {
} }
// POSTs to an SSE endpoint and dispatches events: {type: 'player'|'chunk'|'done'|'error', ...} // POSTs to an SSE endpoint and dispatches events: {type: 'player'|'chunk'|'done'|'error', ...}
async function streamSSE(path, payload, onEvent, signal) { async function streamSSE(path, payload, onEvent, signal, isRetry = false) {
const resp = await fetch(`/api${path}`, { const resp = await fetch(`/api${path}`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload), body: JSON.stringify(payload),
signal, signal,
}) })
if (resp.status === 401 && !isRetry) {
await ensureSession()
return streamSSE(path, payload, onEvent, signal, true)
}
if (!resp.ok) { if (!resp.ok) {
let detail = resp.statusText let detail = resp.statusText
try { detail = (await resp.json()).detail || detail } catch { /* non-JSON */ } try { detail = (await resp.json()).detail || detail } catch { /* non-JSON */ }
@@ -46,6 +61,14 @@ async function streamSSE(path, payload, onEvent, signal) {
} }
export const api = { export const api = {
// 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 // Scenarios
listScenarios: () => request('/scenarios'), listScenarios: () => request('/scenarios'),
getScenario: (id) => request(`/scenarios/${id}`), getScenario: (id) => request(`/scenarios/${id}`),
+62
View File
@@ -1,4 +1,5 @@
import { useState } from 'react' import { useState } from 'react'
import { api } from './api'
export function downloadJSON(obj, filename) { export function downloadJSON(obj, filename) {
const blob = new Blob([JSON.stringify(obj, null, 2)], { type: 'application/json' }) const blob = new Blob([JSON.stringify(obj, null, 2)], { type: 'application/json' })
@@ -75,6 +76,67 @@ export function PlaceholderModal({ title, names, onSubmit, onCancel }) {
) )
} }
// Phase 8: register/login for the hosted multi-user mode. `onAuthed(me)` gets
// the fresh /auth/me payload after success.
export function AuthModal({ mode: initialMode, onClose, onAuthed }) {
const [mode, setMode] = useState(initialMode || 'register')
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState('')
const [busy, setBusy] = useState(false)
const registering = mode === 'register'
const submit = async (e) => {
e.preventDefault()
setBusy(true)
setError('')
try {
const me = registering
? await api.register(email, password)
: await api.login(email, password)
onAuthed(me, mode)
} catch (err) {
setError(err.message)
setBusy(false)
}
}
return (
<div className="modal-overlay" onClick={onClose}>
<form className="modal" onClick={(e) => e.stopPropagation()} onSubmit={submit}>
<h2>{registering ? 'Create an account' : 'Log in'}</h2>
<p className="modal-hint">
{registering
? 'Everything you’ve played as a guest stays with your new account, and you can pick it up from any device.'
: 'Welcome back — log in to reach your adventures.'}
</p>
<label className="field">
<span className="label">Email</span>
<input type="email" autoFocus required value={email}
onChange={(e) => setEmail(e.target.value)} />
</label>
<label className="field">
<span className="label">Password{registering ? ' (at least 8 characters)' : ''}</span>
<input type="password" required minLength={registering ? 8 : undefined} value={password}
onChange={(e) => setPassword(e.target.value)} />
</label>
{error && <div className="test-error" style={{ marginTop: 4 }}>{error}</div>}
<div className="modal-buttons" style={{ justifyContent: 'space-between', alignItems: 'center' }}>
<button type="button" className="linklike" onClick={() => { setMode(registering ? 'login' : 'register'); setError('') }}>
{registering ? 'Have an account? Log in' : 'New here? Create an account'}
</button>
<div style={{ display: 'flex', gap: 10 }}>
<button type="button" onClick={onClose}>Cancel</button>
<button type="submit" className="primary" disabled={busy}>
{busy ? '…' : registering ? 'Sign up' : 'Log in'}
</button>
</div>
</div>
</form>
</div>
)
}
export function Field({ label, value, onChange, textarea, rows, placeholder }) { export function Field({ label, value, onChange, textarea, rows, placeholder }) {
return ( return (
<label className="field"> <label className="field">
+50
View File
@@ -94,6 +94,56 @@ a:hover { color: var(--accent-bright); }
} }
.topnav a.navlink:hover { color: var(--text); } .topnav a.navlink:hover { color: var(--text); }
/* Phase 8: account area (hosted multi-user mode only) */
.nav-account {
margin-left: auto;
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
}
.nav-account button { padding: 5px 12px; font-size: 0.82rem; }
.guest-nudge {
color: var(--text-dim);
font-size: 0.78rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.account-email {
color: var(--text-dim);
font-size: 0.82rem;
max-width: 220px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
button.linklike {
background: none;
border: none;
padding: 0;
color: var(--accent);
font-size: 0.82rem;
cursor: pointer;
}
button.linklike:hover { text-decoration: underline; }
@media (max-width: 900px) {
.guest-nudge { display: none; }
}
/* Phase 8: demo-key banner (Settings) */
.demo-banner {
border: 1px solid var(--border-bright);
border-left: 3px solid var(--accent);
border-radius: 8px;
padding: 12px 16px;
margin-bottom: 18px;
color: var(--text-dim);
font-size: 0.88rem;
line-height: 1.5;
}
.demo-banner strong { color: var(--accent-bright); }
.page { .page {
flex: 1; flex: 1;
width: 100%; width: 100%;
+15 -2
View File
@@ -79,19 +79,31 @@ export default function ScenarioEditor() {
} }
if (!scenario) return null if (!scenario) return null
// Shared demo scenarios (Phase 8) are visible to everyone but owned by no
// one; the backend rejects edits, so present them read-only.
const readOnly = !!scenario.is_public
return ( return (
<div className="page"> <div className="page">
<div className="page-header"> <div className="page-header">
<h1>Edit Scenario</h1> <h1>{readOnly ? 'Scenario (read-only)' : 'Edit Scenario'}</h1>
<div style={{ display: 'flex', gap: 10, alignItems: 'center' }}> <div style={{ display: 'flex', gap: 10, alignItems: 'center' }}>
<span style={{ color: 'var(--text-dim)', fontSize: '0.85rem' }}>{status}</span> <span style={{ color: 'var(--text-dim)', fontSize: '0.85rem' }}>{status}</span>
<button onClick={exportScenario}>Export</button> <button onClick={exportScenario}>Export</button>
<button className="danger" onClick={deleteScenario}>Delete</button> {!readOnly && <button className="danger" onClick={deleteScenario}>Delete</button>}
<button className="primary" onClick={play}>Play</button> <button className="primary" onClick={play}>Play</button>
</div> </div>
</div> </div>
{readOnly && (
<div className="demo-banner">
This is a shared demo scenario — it can’t be edited, but you can hit
<strong> Play</strong> to start your own adventure from it, or <strong>Export</strong> and
re-import it as your own copy.
</div>
)}
<fieldset disabled={readOnly} style={{ border: 'none', padding: 0, margin: 0, minWidth: 0 }}>
<Field label="Title" value={scenario.title} onChange={(v) => setField('title', v)} /> <Field label="Title" value={scenario.title} onChange={(v) => setField('title', v)} />
<Field label="Description" value={scenario.description} onChange={(v) => setField('description', v)} <Field label="Description" value={scenario.description} onChange={(v) => setField('description', v)}
textarea placeholder="Shown in the scenario list; not sent to the AI." /> textarea placeholder="Shown in the scenario list; not sent to the AI." />
@@ -139,6 +151,7 @@ export default function ScenarioEditor() {
</label> </label>
)) ))
)} )}
</fieldset>
</div> </div>
) )
} }
+1
View File
@@ -115,6 +115,7 @@ export default function Scenarios() {
<h3>{sc.title}</h3> <h3>{sc.title}</h3>
<p>{sc.description || 'No description'}</p> <p>{sc.description || 'No description'}</p>
<div className="meta"> <div className="meta">
{sc.is_public && <span className="tag small" title="Shared demo scenario (read-only)">demo ✦</span>}
{splitTags(sc.tags).map((tag) => ( {splitTags(sc.tags).map((tag) => (
<span key={tag} className="tag small">{tag}</span> <span key={tag} className="tag small">{tag}</span>
))} ))}
+54 -7
View File
@@ -1,4 +1,5 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { useOutletContext } from 'react-router-dom'
import { api } from '../api' import { api } from '../api'
function DebugLog() { function DebugLog() {
@@ -45,7 +46,11 @@ function DebugLog() {
} }
export default function Settings() { export default function Settings() {
const { me, setMe } = useOutletContext() ?? {}
const [settings, setSettings] = useState(null) const [settings, setSettings] = useState(null)
// The API key is write-only: the server only reports has_api_key, and this
// holds whatever new key the user has typed (empty = leave unchanged).
const [apiKey, setApiKey] = useState('')
const [testResult, setTestResult] = useState(null) const [testResult, setTestResult] = useState(null)
const [saved, setSaved] = useState('') const [saved, setSaved] = useState('')
@@ -55,9 +60,24 @@ export default function Settings() {
const setField = (field, value) => setSettings({ ...settings, [field]: value }) const setField = (field, value) => setSettings({ ...settings, [field]: value })
const buildPayload = () => {
const { has_api_key: _hasKey, ...payload } = settings
if (apiKey.trim()) payload.api_key = apiKey.trim()
return payload
}
const afterSave = async () => {
const fresh = await api.getSettings()
setSettings(fresh)
setApiKey('')
if (me?.multi_user) api.getMe().then(setMe).catch(() => {}) // demo banner state
return fresh
}
const save = async () => { const save = async () => {
try { try {
await api.updateSettings(settings) await api.updateSettings(buildPayload())
await afterSave()
setSaved('Settings saved') setSaved('Settings saved')
} catch (err) { } catch (err) {
setSaved(`Save failed: ${err.message}`) setSaved(`Save failed: ${err.message}`)
@@ -65,10 +85,22 @@ export default function Settings() {
setTimeout(() => setSaved(''), 4000) setTimeout(() => setSaved(''), 4000)
} }
const clearKey = async () => {
try {
await api.updateSettings({ api_key: '' })
await afterSave()
setSaved('API key removed')
} catch (err) {
setSaved(`Failed: ${err.message}`)
}
setTimeout(() => setSaved(''), 4000)
}
const test = async () => { const test = async () => {
setTestResult({ pending: true }) setTestResult({ pending: true })
try { try {
await api.updateSettings(settings) await api.updateSettings(buildPayload())
await afterSave()
setTestResult(await api.testConnection()) setTestResult(await api.testConnection())
} catch (err) { } catch (err) {
setTestResult({ ok: false, detail: err.message }) setTestResult({ ok: false, detail: err.message })
@@ -76,6 +108,7 @@ export default function Settings() {
} }
if (!settings) return null if (!settings) return null
const demo = me?.demo
return ( return (
<div className="page" style={{ maxWidth: 640 }}> <div className="page" style={{ maxWidth: 640 }}>
@@ -84,6 +117,14 @@ export default function Settings() {
<span style={{ color: 'var(--text-dim)', fontSize: '0.85rem' }}>{saved}</span> <span style={{ color: 'var(--text-dim)', fontSize: '0.85rem' }}>{saved}</span>
</div> </div>
{demo?.using_demo && (
<div className="demo-banner">
<strong>Using the shared demo key</strong> — {demo.turns_left} of {demo.turns_per_day} free
turns left today (model: {demo.model}). Add your own API key below for unlimited play,
your choice of models, and the memory bank.
</div>
)}
<label className="field"> <label className="field">
<span className="label">Endpoint URL (OpenAI-compatible)</span> <span className="label">Endpoint URL (OpenAI-compatible)</span>
<input type="text" value={settings.endpoint_url} <input type="text" value={settings.endpoint_url}
@@ -91,10 +132,15 @@ export default function Settings() {
onChange={(e) => setField('endpoint_url', e.target.value)} /> onChange={(e) => setField('endpoint_url', e.target.value)} />
</label> </label>
<label className="field"> <label className="field">
<span className="label">API Key</span> <span className="label">API Key {settings.has_api_key ? '(saved — enter a new one to replace it)' : ''}</span>
<input type="password" value={settings.api_key} <div style={{ display: 'flex', gap: 8 }}>
placeholder="Leave empty for local endpoints" <input type="password" value={apiKey} style={{ flex: 1 }}
onChange={(e) => setField('api_key', e.target.value)} /> placeholder={settings.has_api_key ? '••••••••••••' : 'Leave empty for local endpoints'}
onChange={(e) => setApiKey(e.target.value)} />
{settings.has_api_key && (
<button type="button" onClick={clearKey}>Remove key</button>
)}
</div>
</label> </label>
<label className="field"> <label className="field">
<span className="label">Model</span> <span className="label">Model</span>
@@ -188,7 +234,8 @@ export default function Settings() {
</div> </div>
)} )}
<DebugLog /> {/* The provider debug log is a single global buffer — local installs only. */}
{me?.multi_user !== true && <DebugLog />}
</div> </div>
) )
} }
+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**: **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 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 | | Auth method | **Email + password, optional** — guest sessions work without an account |
| Signup policy | **Open signup** (rate-limited) | | 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 | | 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`) |
**Ask before implementing:** | Demo turn cap | **20 successful turns/user/day** (failed provider calls don't count) |
- Demo-key specifics: which provider/key funds it, model whitelist (free models only?), | Guest data retention | **Never delete** for v1 (no cleanup job; revisit if the DB grows) |
per-user turn/day cap, what the "out of demo turns" message says. | Password reset | **Skipped for v1** (no email provider; forgotten password = lost account) |
- Guest data retention: how long before unclaimed guest data is deleted (suggestion: 30 days | Login with an active guest session | Guest is **abandoned**, not merged (its data stays under the guest user) |
of inactivity). | Memory bank on demo key | **Disabled** (no background AI calls on the server-funded key; visible note in the Memory panel/Insights) |
- Password reset: skip for v1, or implement email-based reset (requires an email provider)?
## Data model ## Data model
- [ ] `User` table: id, email (nullable — null means guest), password_hash (nullable), - [x] `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). created_at, last_seen_at, is_guest flag, demo_turns_used + demo_turns_date.
- [ ] Add `user_id` FK to: `Adventure`, `Scenario`, `Script`, `Settings` (and anything else - [x] `user_id` FK on `Adventure`, `Scenario`, `Script`, `Settings`. Story cards/actions/
global today — audit `models.py`). Story cards/actions inherit scope via their parent. memories inherit scope via their parent (ownership checks resolve the parent).
- [ ] Settings becomes **per-user** (endpoint URL, API key, models, memory-bank config). - [x] Settings **per-user** (row per user_id, unique index). API key **encrypted at rest**
API key **encrypted at rest** (Fernet with a server-side `SECRET_KEY` env var). (Fernet; key derived from `AIDND_SECRET_KEY` or auto-generated `secret.key` next to the
- [ ] Migration (in `migrations.py` style): create a "local user", assign all existing rows to DB). Key is write-only through the API (`has_api_key` instead of echoing it).
it — a fresh clone/local install keeps working exactly as before. - [x] Migrations 13–23: create "local user" id=1, assign all existing rows to it, unique
- [ ] Demo/starter scenarios: mark as `user_id = NULL` + `is_public` so everyone sees them index on settings.user_id; plus a Python bootstrap step that encrypts any plaintext
(decide exact mechanism when implementing; seed via `seed_demo.py`). 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 ## Auth & sessions
- [ ] Guest flow: first API call with no session → create guest User, set a signed, long-lived - [x] Guest flow: `GET /api/auth/me` with no/invalid cookie → creates guest User + signed
httpOnly session cookie. No signup wall anywhere. long-lived httpOnly cookie (HMAC, `security.py`). Other endpoints 401 without a session;
- [ ] Register: email + password (hashed with bcrypt/argon2) **upgrades the current guest user the frontend re-establishes via /me and retries once. No signup wall anywhere.
in place** — same user_id, data automatically kept. - [x] Register upgrades the guest **in place** (same user_id — data kept). scrypt password
- [ ] Login: standard session issue; logging in from a fresh guest session with existing account hashing (stdlib, no extra dep).
discards the empty guest (or merges — ask if guest has data). - [x] Login switches the session cookie to the account (guest abandoned). Logout clears it.
- [ ] Session middleware/dependency: every router handler resolves `current_user`; **every query - [x] Every router handler resolves `current_user`; every query filtered by user_id
filtered by `user_id`** (this is the bulk of the diff — go router by router). (scenarios/adventures/scripts/story-cards/settings; debug log is local-mode only since
- [ ] Rate limits on register/login endpoints (brute-force protection). it's a global buffer).
- [ ] Local/self-hosted mode stays frictionless: single auto-created local user, no login UI - [x] Rate limit on register/login: 10 attempts / 5 min per IP (in-memory).
unless `MULTI_USER=true` (env var) — resume demo runs multi-user, local clones don't care. - [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) ## Shared demo key (BYOK fallback)
- [ ] Server env vars: `DEMO_API_KEY`, `DEMO_ENDPOINT_URL`, `DEMO_MODEL_WHITELIST`, - [x] Env vars: `AIDND_DEMO_API_KEY`, `AIDND_DEMO_ENDPOINT_URL` (default OpenRouter),
`DEMO_TURNS_PER_DAY`. `AIDND_DEMO_MODELS` (comma whitelist), `AIDND_DEMO_TURNS_PER_DAY` (default 20).
- [ ] If a user has no API key configured: use demo key, restrict model picker to the whitelist, Demo only activates in multi-user mode.
count turns per user per day, friendly error + "add your own key in Settings" when capped. - [x] No API key configured → demo endpoint/key/whitelisted model; per-user per-day counter;
- [ ] Turn counting includes memory-bank background calls (or disable memory bank on demo key — 429 with a friendly "add your own key in Settings" message when capped (checked before
decide when implementing). the turn starts so no orphaned player action).
- [x] Memory bank + auto-summarization disabled on demo turns (decided: disable, not count).
## Frontend ## Frontend
- [ ] Auth UI: register/login modal or page, "Save your progress" nudge for guests (subtle, - [x] Auth UI: Sign up / Log in modal (register default, toggle to login), "Playing as guest —
e.g. in the header), logout, account menu. sign up to keep your adventures" nudge in the header, account email + logout when
- [ ] `api.js`: send cookies (`credentials: include`), handle 401 → re-establish guest session. registered. All hidden in local mode (`multi_user:false` from /me).
- [ ] Settings page: per-user; show demo-key status ("Using shared demo key — N turns left today"). - [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 Two sessions (curl cookie jars + Chrome UI): each guest gets an isolated world; register
to the other), both can play immediately on the demo key. One registers mid-adventure and its mid-session keeps all data (same user id); logging in from the second session shows the same
data survives; logging in from the other browser shows the same account data. Local account data; duplicate email → 409; wrong password → 401; rate limiter kicks in. Demo cap
`start.ps1` / `docker compose up` still works with zero auth friction. 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.