AI Chat is a plain scratchpad for talking to a model directly — no story context, scripts or world state — for poking at models, prompts and endpoints without starting an adventure. Power users only: the router 404s (rather than 403s) for everyone else and the nav link is hidden. The conversation lives in localStorage, so there's no new table or migration. is_power_user() now also returns True in local mode: it's the operator's own machine and their own key, the same reasoning that makes the provider debug log local-only. Alongside that, the rule keeping the shared demo key off paid models now lives in exactly one place. It had been duplicated into the chat router, which is how one copy eventually drifts: - resolve_provider_config() takes an optional model_override and is the only place the whitelist is applied, so turns, AI Chat and the connection test all inherit it. An override is a per-request preference, never a grant. - ProviderConfig.__post_init__ refuses to exist when api_key is the demo key and the model isn't whitelisted. It keys on the key itself rather than the using_demo flag, so a mislabelled config can't slip past, and it raises so a future path that bypasses the resolver fails loudly instead of billing. - The demo branch still pins endpoint_url too — a user-controlled endpoint would leak the key itself, which is worse than spending it. Provider gained chat(messages, ...) beside generate(), both delegating to a shared _stream(url, body); completion-mode endpoints get the messages flattened into a labelled transcript. Settings' /models fetch moved to list_endpoint_models() and is shared with /api/chat/config. Tests: 10 new in tests/test_chat.py (70 total). These deliberately do not stub resolve_provider_config — the point is to exercise the real BYOK-vs-demo decision and assert on what the provider actually received: off-whitelist override pinned, off-whitelist Settings.model pinned, redirected endpoint pinned, BYOK passed through untouched. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014FGY1yvzSeKgTtRfeVtDmx
125 lines
4.4 KiB
Python
125 lines
4.4 KiB
Python
import re
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, Request, Response
|
|
from sqlalchemy.orm import Session
|
|
|
|
from .. import auth, limits, 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,
|
|
# Trusted testers: unmetered demo turns, plus the AI Chat scratchpad.
|
|
"power_user": auth.is_power_user(user),
|
|
"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:
|
|
# Each new guest is a database row — cap how fast one IP can mint them.
|
|
limits.rate_limit("guest", request)
|
|
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.")
|
|
limits.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.")
|
|
limits.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}
|