* Rewrite comments in Google developer documentation style Rewrite the comments and docstrings across the backend core modules so they read plainly. The previous prose was accurate but dense and figurative, which made it slow to skim. Applies the Google developer documentation style guide: short sentences, active voice, present tense, American spelling, and no metaphors, idioms, or rhetorical asides. Replaces em-dash chains with separate sentences.
264 lines
9.6 KiB
Python
264 lines
9.6 KiB
Python
"""Phase 8: user resolution, sessions, and the shared demo key.
|
|
|
|
The `AIDND_MULTI_USER` environment variable selects one of two modes:
|
|
|
|
* Local mode, the default. Every request resolves to one automatically created
|
|
local user. There are no cookies and no login UI, so a clone or a
|
|
docker-compose run behaves like the single-user app from before Phase 8.
|
|
* Multi-user mode, used for hosted deployments. Requests carry a signed session
|
|
cookie. `GET /api/auth/me` creates a guest user on the first visit, and
|
|
registering upgrades that guest in place so their data survives. A request
|
|
without a valid session gets a 401, and the frontend re-establishes the
|
|
session through `/me`.
|
|
|
|
The shared demo key, which is the fallback when a user brings no key of their
|
|
own, is also configured here. A user whose settings hold no API key is routed to
|
|
a server-funded endpoint with a model allowlist and a per-day turn cap.
|
|
"""
|
|
|
|
import os
|
|
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"
|
|
# Secure cookies are on by default in multi-user mode, because a hosted
|
|
# deployment serves HTTPS and browsers also accept Secure on http://localhost.
|
|
# `AIDND_COOKIE_SECURE` overrides the default with 0 or 1. Use 0 when testing
|
|
# multi-user mode over plain HTTP on a LAN address.
|
|
_cookie_secure_env = os.environ.get("AIDND_COOKIE_SECURE", "").strip().lower()
|
|
COOKIE_SECURE = (
|
|
_cookie_secure_env in ("1", "true", "yes", "on")
|
|
if _cookie_secure_env
|
|
else MULTI_USER
|
|
)
|
|
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)
|
|
|
|
# Trusted testers, listed by email, who bypass the daily demo cap and take
|
|
# unmetered turns on the shared demo key. The list is comma-separated, and the
|
|
# match ignores case.
|
|
POWER_USERS = {
|
|
e.strip().lower()
|
|
for e in os.environ.get("AIDND_POWER_USERS", "").split(",")
|
|
if e.strip()
|
|
}
|
|
|
|
# Who can see the visit analytics. This is a separate list from `POWER_USERS` on
|
|
# purpose. A trusted tester gets unmetered turns and the AI Chat page, which is
|
|
# not a reason to give them the site's traffic numbers. An empty list, which is
|
|
# the default, means nobody sees the dashboard in a hosted deployment.
|
|
ANALYTICS_EMAILS = {
|
|
e.strip().lower()
|
|
for e in os.environ.get("AIDND_ANALYTICS_EMAILS", "").split(",")
|
|
if e.strip()
|
|
}
|
|
|
|
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. A local install talks to
|
|
# whatever endpoint Settings points at, even with no API key, such as
|
|
# Ollama.
|
|
return MULTI_USER and bool(DEMO_API_KEY)
|
|
|
|
|
|
@dataclass
|
|
class ProviderConfig:
|
|
"""What the turn engine connects with, after the decision between a
|
|
user-supplied key and the demo key.
|
|
|
|
Build one of these with `resolve_provider_config()`.
|
|
"""
|
|
|
|
endpoint_url: str
|
|
api_key: str
|
|
model: str
|
|
using_demo: bool
|
|
|
|
def __post_init__(self) -> None:
|
|
# A second guard around server-funded turns. `resolve_provider_config()`
|
|
# already pins the model, and this makes the pin a property of the config
|
|
# object too, so a later caller cannot construct an unpinned one. This
|
|
# raise is unreachable by design. Reaching it means a new code path
|
|
# bypassed the pinning, which is worth failing on rather than billing
|
|
# for.
|
|
#
|
|
# The test is `using_demo`, not `api_key == DEMO_API_KEY`. Keying on the
|
|
# key value looks stricter and is wrong. The demo key is an ordinary
|
|
# OpenRouter key, so a user can legitimately paste that same key into
|
|
# their own Settings. Every resolution then raised, which returned a 500
|
|
# even from `GET /auth/me` and took the whole SPA down. `using_demo` is
|
|
# what means the server is paying, and only the demo branch below sets
|
|
# it.
|
|
if self.using_demo and self.model not in DEMO_MODELS:
|
|
raise ValueError(
|
|
f"Refusing to use the shared demo key with non-whitelisted model {self.model!r}"
|
|
)
|
|
|
|
|
|
def resolve_provider_config(
|
|
settings: models.Settings, *, model_override: str | None = None
|
|
) -> ProviderConfig:
|
|
"""Returns the user's own key when they have one, and the shared demo key
|
|
otherwise.
|
|
|
|
The demo branch is the security-relevant one, and it is the only place the
|
|
allowlist rule lives. Every caller has to come through this function rather
|
|
than build a `ProviderConfig` itself. On the demo key:
|
|
|
|
* The model is pinned to `DEMO_MODELS`, so a caller-supplied override from
|
|
the AI Chat page, or a hand-edited Settings row, cannot point a
|
|
server-funded key at a paid model. An unrecognized model falls back to
|
|
`DEMO_MODELS[0]`.
|
|
* The endpoint is pinned to `DEMO_ENDPOINT_URL`, so the key cannot be
|
|
redirected to a URL the user controls and captured there.
|
|
|
|
`model_override` is a per-request preference and never a grant. It is used
|
|
verbatim with the user's own key, and on the demo key only when the model is
|
|
on the allowlist.
|
|
"""
|
|
key = settings.api_key_plain
|
|
requested = (model_override or "").strip() or settings.model
|
|
if key or not demo_enabled():
|
|
return ProviderConfig(settings.endpoint_url, key, requested, False)
|
|
model = requested if requested 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 is_power_user(user: models.User) -> bool:
|
|
"""Returns whether this user is a trusted tester.
|
|
|
|
A trusted tester gets unmetered demo turns, plus tooling that is not part of
|
|
the game, such as the AI Chat scratchpad. A local install is always trusted,
|
|
because it runs on the operator's own machine with their own API key. The
|
|
provider debug log is local-only for the same reason.
|
|
"""
|
|
if not MULTI_USER:
|
|
return True
|
|
return bool(user.email) and user.email.lower() in POWER_USERS
|
|
|
|
|
|
def is_owner(user: models.User) -> bool:
|
|
"""Returns whether this user may see the visit analytics.
|
|
|
|
A local install always may, because it runs on the operator's own machine and
|
|
shows their own visits. The provider debug log follows the same reasoning. A
|
|
hosted deployment checks `AIDND_ANALYTICS_EMAILS`.
|
|
"""
|
|
if not MULTI_USER:
|
|
return True
|
|
return bool(user.email) and user.email.lower() in ANALYTICS_EMAILS
|
|
|
|
|
|
def demo_turns_left(user: models.User) -> int:
|
|
# A power user is never capped, so report the full cap and let the banner
|
|
# read "N of N" rather than count down.
|
|
if is_power_user(user):
|
|
return DEMO_TURNS_PER_DAY
|
|
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:
|
|
"""Records one demo turn. The caller's commit stores it."""
|
|
if is_power_user(user):
|
|
return # A power user's turns do not count against the cap.
|
|
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:
|
|
"""Returns the single implicit user used in local mode.
|
|
|
|
A migration gives this user ownership of data written before Phase 8. On a
|
|
fresh database the user is created on first use.
|
|
"""
|
|
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 returns DateTime columns without a timezone. 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:
|
|
"""The dependency every router uses to resolve the current user.
|
|
|
|
In multi-user mode a 401 means the frontend has to establish a session again
|
|
through `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
|