Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,13 @@ CENSUS_API_KEY=
# production except for short, watched test windows.
# CHAT_ENABLED=false

# Shared access code testers enter on /chat; until then the "Ask" link is
# hidden and the chat routes answer 401. Unset, chat is open to everyone who
# can reach the API, so set it anywhere but your own machine. Changing it signs
# everyone out. CHAT_ACCESS_DAYS is how long a browser stays signed in.
# CHAT_ACCESS_CODE=
# CHAT_ACCESS_DAYS=30

# Model, as an OpenRouter slug (see `just chat-eval-models` for options and
# prices, and `just chat-eval` to compare them; docs/guides/chat-evals.md).
# Any Pydantic AI model string also works ("openai:...", "anthropic:..."), and
Expand Down
44 changes: 44 additions & 0 deletions backend/chat/access.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
"""A shared access code for the data chat, until there are user accounts.

Entering the code (POST /api/chat/unlock) sets a signed, HttpOnly cookie that
the chat routes require. The signing key comes from the code itself, so
changing CHAT_ACCESS_CODE signs everyone out with no other secret to manage.
This gates spend, not data: anyone given the code can pass it on, and access
can only be revoked for everyone at once.
"""

from __future__ import annotations

import hashlib
import hmac
import time

COOKIE_NAME = "vt_chat_access"


def _key(code: str) -> bytes:
return hashlib.sha256(f"vt-data-chat-access:{code}".encode()).digest()


def code_matches(code: str, submitted: str) -> bool:
# Constant-time, so response timing doesn't hint at the code.
return hmac.compare_digest(code.encode(), submitted.strip().encode())


def issue_token(code: str, max_age: int, now: float | None = None) -> str:
"""A cookie value that stays valid for max_age seconds."""
expires = int((time.time() if now is None else now) + max_age)
signature = hmac.new(_key(code), str(expires).encode(), "sha256").hexdigest()
return f"{expires}.{signature}"


def token_valid(code: str, token: str | None, now: float | None = None) -> bool:
if not token:
return False
expires, _, signature = token.partition(".")
if not expires.isdigit():
return False
expected = hmac.new(_key(code), expires.encode(), "sha256").hexdigest()
if not hmac.compare_digest(expected, signature):
return False
return int(expires) > (time.time() if now is None else now)
7 changes: 7 additions & 0 deletions backend/chat/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,11 @@ class ChatSettings:
rate_limit: int = 30
# Rows /api/chat/data returns for one chart reference.
chart_max_rows: int = 5_000
# Shared code people enter to use chat (chat/access.py). Unset, chat is
# open to anyone who can reach it, which is only fine locally.
access_code: str | None = None
# How long an entered code is remembered in that browser.
access_days: int = 30

@classmethod
def from_env(cls, environ: Mapping[str, str] | None = None) -> ChatSettings:
Expand All @@ -68,6 +73,8 @@ def from_env(cls, environ: Mapping[str, str] | None = None) -> ChatSettings:
max_history_chars=int(env.get("CHAT_MAX_HISTORY_CHARS", "40000")),
rate_limit=int(env.get("CHAT_RATE_LIMIT", "30")),
chart_max_rows=int(env.get("CHAT_CHART_MAX_ROWS", "5000")),
access_code=env.get("CHAT_ACCESS_CODE", "").strip() or None,
access_days=int(env.get("CHAT_ACCESS_DAYS", "30")),
)
except ValueError:
# Don't echo environment contents, which can sit next to secrets.
Expand Down
80 changes: 70 additions & 10 deletions backend/chat/router.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
"""HTTP routes for the data chat. Mounted only when CHAT_ENABLED is set.
"""HTTP routes for the data chat. Mounted only when CHAT_ENABLED is set, and
when CHAT_ACCESS_CODE is set the chat and data routes need the cookie that
POST /api/chat/unlock gives for the right code (chat/access.py).

Streaming protocol for POST /api/chat: server-sent events, one JSON object per
``data:`` line, each with a ``type``:
Expand All @@ -20,10 +22,11 @@
from typing import Literal

import anyio
from fastapi import APIRouter, HTTPException, Request, Response
from fastapi import APIRouter, Depends, HTTPException, Request, Response
from fastapi.responses import StreamingResponse
from pydantic import BaseModel, Field, model_validator

from chat import access
from chat.agent import build_agent, stream_answer, to_history, tool_error
from chat.charts import DataRef, resolve_rows
from chat.config import ChatSettings
Expand All @@ -45,6 +48,10 @@ def ends_with_question(self):
return self


class UnlockRequest(BaseModel):
code: str = Field(max_length=200)


class RateLimiter:
"""Rolling one-hour window per client, in process memory.

Expand Down Expand Up @@ -94,27 +101,80 @@ def bounded_history(
return question, kept


# Code attempts per client IP per rolling hour, so the code can't be guessed by
# trying many.
UNLOCK_ATTEMPTS = 10


def client_ip(request: Request) -> str:
return request.client.host if request.client else "unknown"


def create_router(
settings: ChatSettings | None = None, service: DataToolService | None = None
) -> APIRouter:
settings = settings or ChatSettings.from_env()
service = service or DataToolService(settings.warehouse_path)
agent = build_agent(settings, service)
limiter = RateLimiter(settings.rate_limit)
unlock_limiter = RateLimiter(UNLOCK_ATTEMPTS)
router = APIRouter(prefix="/chat", tags=["chat"])

def unlocked(request: Request) -> bool:
code = settings.access_code
return code is None or access.token_valid(
code, request.cookies.get(access.COOKIE_NAME)
)

def require_access(request: Request) -> None:
if not unlocked(request):
raise HTTPException(401, "Enter the access code to use chat.")

def status_body(is_unlocked: bool) -> dict:
# v0 has no spend cap, so chat is always available while enabled.
return {
"enabled": True,
"available": True,
"resets_at": None,
"unlocked": is_unlocked,
}

@router.get("/status")
def status(response: Response):
# The API caches GETs for an hour; flipping the flag must show at once.
def status(request: Request, response: Response):
# The API caches GETs for an hour; flipping the flag (or entering the
# code) must show at once.
response.headers["Cache-Control"] = "no-store"
# v0 has no spend cap, so chat is always available while enabled.
return {"enabled": True, "available": True, "resets_at": None}
return status_body(unlocked(request))

@router.post("/unlock")
def unlock(body: UnlockRequest, request: Request, response: Response):
code = settings.access_code
if code is None:
return status_body(True)
# Every attempt counts, checked before the code so a client over the
# limit learns nothing even from the right one.
if not unlock_limiter.allow(client_ip(request)):
raise HTTPException(429, "Too many tries. Try again in an hour.")
if not access.code_matches(code, body.code):
raise HTTPException(403, "That code didn't work.")
max_age = settings.access_days * 24 * 3600
response.set_cookie(
access.COOKIE_NAME,
access.issue_token(code, max_age),
max_age=max_age,
path="/api/chat",
httponly=True,
samesite="lax",
# nginx passes on the public scheme; local http dev can't keep a
# Secure cookie in every browser.
secure=request.url.scheme == "https",
)
return status_body(True)

@router.post("")
@router.post("", dependencies=[Depends(require_access)])
async def chat(body: ChatRequest, request: Request):
question, history = bounded_history(body.messages, settings)
client = request.client.host if request.client else "unknown"
if not limiter.allow(client):
if not limiter.allow(client_ip(request)):
raise HTTPException(
429, "You've reached the hourly question limit. Try again later."
)
Expand All @@ -131,7 +191,7 @@ async def events():
headers={"Cache-Control": "no-store", "X-Accel-Buffering": "no"},
)

@router.post("/data")
@router.post("/data", dependencies=[Depends(require_access)])
async def chart_data(ref: DataRef):
try:
return await anyio.to_thread.run_sync(
Expand Down
64 changes: 63 additions & 1 deletion backend/tests/test_chat.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,12 @@
from pydantic_ai.exceptions import ModelHTTPError
from pydantic_ai.models.function import FunctionModel

from chat import access
from chat.agent import build_agent, chart_tool, preview, rank_tool, stream_answer
from chat.charts import ChartSpec, DataRef, overlapping_rows, resolve_rows
from chat.config import ChatSettings
from chat.demo import CHART
from chat.router import ChatMessage, bounded_history, create_router
from chat.router import UNLOCK_ATTEMPTS, ChatMessage, bounded_history, create_router
from data_tools.service import DataToolService

INCOME = "acs5_ts_household_income"
Expand Down Expand Up @@ -193,6 +194,7 @@ def test_status_route(warehouse):
"enabled": True,
"available": True,
"resets_at": None,
"unlocked": True,
}


Expand Down Expand Up @@ -246,6 +248,66 @@ def test_rate_limit_is_per_client(warehouse):
assert "hourly" in limited.json()["detail"]


def test_chat_needs_the_access_code_when_one_is_set(warehouse):
client = make_client(warehouse, access_code="maple")
assert client.get("/api/chat/status").json()["unlocked"] is False
assert ask(client).status_code == 401
assert client.post("/api/chat/data", json=CHART["data"]).status_code == 401

assert client.post("/api/chat/unlock", json={"code": "birch"}).status_code == 403
unlocked = client.post("/api/chat/unlock", json={"code": " maple "})
assert unlocked.status_code == 200
assert unlocked.json()["unlocked"] is True
cookie = unlocked.headers["set-cookie"]
assert "HttpOnly" in cookie and "Path=/api/chat" in cookie
# The test client is plain http, so the cookie can't be Secure.
assert "Secure" not in cookie

assert client.get("/api/chat/status").json()["unlocked"] is True
assert ask(client).status_code == 200
assert client.post("/api/chat/data", json=CHART["data"]).status_code == 200


def test_access_cookie_is_secure_over_https(warehouse):
settings = ChatSettings(model="demo", warehouse_path=warehouse, access_code="maple")
app = FastAPI()
app.include_router(create_router(settings), prefix="/api")
client = TestClient(app, base_url="https://testserver")
response = client.post("/api/chat/unlock", json={"code": "maple"})
assert "Secure" in response.headers["set-cookie"]


def test_changing_the_code_signs_everyone_out(warehouse):
old = make_client(warehouse, access_code="maple")
old.post("/api/chat/unlock", json={"code": "maple"})
new = make_client(warehouse, access_code="birch")
new.cookies = old.cookies
assert new.get("/api/chat/status").json()["unlocked"] is False
assert ask(new).status_code == 401


def test_unlock_attempts_are_rate_limited_even_for_the_right_code(warehouse):
client = make_client(warehouse, access_code="maple")
for _ in range(UNLOCK_ATTEMPTS):
client.post("/api/chat/unlock", json={"code": "wrong"})
limited = client.post("/api/chat/unlock", json={"code": "maple"})
assert limited.status_code == 429
assert client.get("/api/chat/status").json()["unlocked"] is False


def test_access_tokens_expire_and_reject_tampering():
token = access.issue_token("maple", max_age=60, now=1_000)
assert access.token_valid("maple", token, now=1_059)
assert not access.token_valid("maple", token, now=1_061)
assert not access.token_valid("birch", token, now=1_000)
expires, _, signature = token.partition(".")
assert not access.token_valid(
"maple", f"{int(expires) + 999}.{signature}", now=1_000
)
assert not access.token_valid("maple", "garbage", now=1_000)
assert not access.token_valid("maple", None)


def test_tool_call_limit_ends_turn_with_a_readable_error(warehouse):
events = stream_events(ask(make_client(warehouse, max_tool_calls=1)))
assert events[-1]["type"] == "error"
Expand Down
38 changes: 21 additions & 17 deletions docs/architecture/data-chat.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,9 @@ Decisions so far:
candidates first: [guides/chat-evals.md](../guides/chat-evals.md).
- **Data routing:** every OpenRouter request sets `data_collection: "deny"`, so
it never goes to a provider that stores or trains on inputs.
- **Feature flag:** backend environment flag only. No invite codes or per-user gating.
- **Feature flag:** a backend environment flag, plus an optional shared access
code (`CHAT_ACCESS_CODE`) for testers until there are accounts. See
[Feature flag design](#feature-flag-design).
- **v0 has no database and no spend cap.** Chats live in the browser's
localStorage, and the browser sends the conversation history with each request,
so the server stores nothing. The budget ledger, accounts, and server-side chat
Expand Down Expand Up @@ -44,18 +46,19 @@ internal testers. That environment is a prerequisite for enabling chat anywhere.

## v0 implementation map

| Piece | Where |
| -------------------- | ----------------------------------------------------------------------------------------------- |
| Flag + mount | `backend/api/main.py` (`CHAT_ENABLED`), settings in `backend/chat/config.py` |
| Agent (Pydantic AI) | `backend/chat/agent.py`: data tools with trimmed previews, `render_chart`, event streaming |
| Chart contract | `backend/chat/charts.py`: `ChartSpec`, `DataRef`, `resolve_rows` |
| Routes | `backend/chat/router.py`: `GET /api/chat/status`, `POST /api/chat` (SSE), `POST /api/chat/data` |
| Offline model | `backend/chat/demo.py` (`CHAT_MODEL=demo`), for UI work without an API key |
| Tests | `backend/tests/test_chat.py` (fixture warehouse + demo model, no network) |
| Page | `frontend/src/app/chat/page.tsx`; nav link in `HeaderMenu` appears only when status is enabled |
| Client, store, chart | `frontend/src/components/Chat/` (localStorage store, SSE client, `ChatChart`) |
| Report chart | `frontend/src/components/Charts/AgentChart.tsx` (subtype `AgentChart`, works in working report) |
| Deploy | `frontend/nginx.conf` (`/api/chat` unbuffered + client IP), `justfile` `chat_container_env` |
| Piece | Where |
| -------------------- | ------------------------------------------------------------------------------------------------------------------------ |
| Flag + mount | `backend/api/main.py` (`CHAT_ENABLED`), settings in `backend/chat/config.py` |
| Agent (Pydantic AI) | `backend/chat/agent.py`: data tools with trimmed previews, `render_chart`, event streaming |
| Chart contract | `backend/chat/charts.py`: `ChartSpec`, `DataRef`, `resolve_rows` |
| Routes | `backend/chat/router.py`: `GET /api/chat/status`, `POST /api/chat` (SSE), `POST /api/chat/data`, `POST /api/chat/unlock` |
| Access code | `backend/chat/access.py` (signed cookie); `frontend/src/components/Chat/AccessCodeForm.tsx` |
| Offline model | `backend/chat/demo.py` (`CHAT_MODEL=demo`), for UI work without an API key |
| Tests | `backend/tests/test_chat.py` (fixture warehouse + demo model, no network) |
| Page | `frontend/src/app/chat/page.tsx`; nav link in `HeaderMenu` appears only when status is enabled |
| Client, store, chart | `frontend/src/components/Chat/` (localStorage store, SSE client, `ChatChart`) |
| Report chart | `frontend/src/components/Charts/AgentChart.tsx` (subtype `AgentChart`, works in working report) |
| Deploy | `frontend/nginx.conf` (`/api/chat` unbuffered + client IP), `justfile` `chat_container_env` |

Run locally with the demo model: `CHAT_ENABLED=1 CHAT_MODEL=demo just local-api`
and `just local-frontend`, then open `/chat`.
Expand Down Expand Up @@ -248,13 +251,14 @@ When the cap is reached, `GET /api/chat/status` returns `{ "available": false, "

## Feature flag design

One backend environment flag:
A backend environment flag, and a shared access code for testers:

- `CHAT_ENABLED` (backend env). The chat router is only mounted when it's set, the same pattern as `MCP_ENABLED`. Off means the endpoints don't exist.
- The frontend calls `GET /api/chat/status` → `{ enabled, available, resets_at }`. The nav entry and chat page render only when `enabled` is true. If the call fails or the route is missing (flag off, or the GitHub Pages build with no API), chat stays hidden.
- Flipping the flag needs an API restart, not a frontend rebuild.
- `CHAT_ACCESS_CODE` (backend env, optional). When set, `/chat` asks for the code and `POST /api/chat/unlock` checks it (10 tries per IP per hour). The right code sets an HttpOnly cookie, signed with a key derived from the code and valid for `CHAT_ACCESS_DAYS` (default 30). `POST /api/chat` and `POST /api/chat/data` answer 401 without it. Changing the code signs everyone out; there's no way to sign out one person.
- The frontend calls `GET /api/chat/status` → `{ enabled, available, resets_at, unlocked }`. The nav entry appears only when `enabled` and `unlocked` are both true, so testers go to `/chat` directly to enter the code. If the call fails or the route is missing (flag off, or the GitHub Pages build with no API), chat stays hidden.
- Flipping the flag or changing the code needs an API restart, not a frontend rebuild.

Trade-off: there's no "on for testers only" state. To test in production we turn it on for everyone, so the per-client throttle and global cap have to be in place before the first time we enable it. Local and staging testing just set the flag in `.env`.
The code is a stopgap until accounts (likely a magic link and a token): anyone given it can pass it on, so it limits who runs up the bill, not who can see data that's already public. The check is one FastAPI dependency (`require_access` in `chat/router.py`), which a login check can replace. Local testing can leave the code unset.

## Proposed request flow

Expand Down
6 changes: 4 additions & 2 deletions frontend/nginx.conf
Original file line number Diff line number Diff line change
Expand Up @@ -77,14 +77,16 @@ http {
proxy_send_timeout 300s;
}

# Data chat streams its answers (server-sent events), and its rate
# limit needs the real client address, which Uvicorn trusts from
# Data chat streams its answers (server-sent events). Its rate limit
# needs the real client address, and its access cookie is Secure only
# over https, so it needs the public scheme; Uvicorn trusts both from
# this loopback proxy.
location ~ ^/api/chat(?:/|$) {
proxy_pass http://127.0.0.1:6767;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $mcp_forwarded_proto;
proxy_buffering off;
proxy_cache off;
proxy_read_timeout 300s;
Expand Down
Loading
Loading