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:
co-authored by
Claude Fable 5
parent
253b533d3b
commit
de4db373f2
@@ -16,3 +16,6 @@ frontend/dist/
|
|||||||
|
|
||||||
# Misc
|
# Misc
|
||||||
.claude/
|
.claude/
|
||||||
|
|
||||||
|
# Phase 8: auto-generated session/encryption secret (lives next to the DB)
|
||||||
|
secret.key
|
||||||
|
|||||||
@@ -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
@@ -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.
|
||||||
|
|||||||
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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,10 +215,25 @@ 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)
|
||||||
|
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)
|
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)
|
||||||
|
|
||||||
@@ -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
|
||||||
|
# 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)
|
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)
|
||||||
|
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)
|
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")
|
||||||
|
|||||||
@@ -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}
|
||||||
@@ -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()
|
||||||
|
|||||||
@@ -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 ""),
|
||||||
|
|||||||
@@ -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"),
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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}`),
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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%;
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -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
@@ -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.
|
||||||
|
|||||||
Reference in New Issue
Block a user