diff --git a/.env.example b/.env.example index b07fa9b1..5cacacd3 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/backend/chat/access.py b/backend/chat/access.py new file mode 100644 index 00000000..65949592 --- /dev/null +++ b/backend/chat/access.py @@ -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) diff --git a/backend/chat/config.py b/backend/chat/config.py index 6a43f0f3..04c908cc 100644 --- a/backend/chat/config.py +++ b/backend/chat/config.py @@ -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: @@ -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. diff --git a/backend/chat/router.py b/backend/chat/router.py index 0fa8a243..8374f403 100644 --- a/backend/chat/router.py +++ b/backend/chat/router.py @@ -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``: @@ -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 @@ -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. @@ -94,6 +101,15 @@ 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: @@ -101,20 +117,64 @@ def create_router( 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." ) @@ -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( diff --git a/backend/tests/test_chat.py b/backend/tests/test_chat.py index 8309827c..19765daf 100644 --- a/backend/tests/test_chat.py +++ b/backend/tests/test_chat.py @@ -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" @@ -193,6 +194,7 @@ def test_status_route(warehouse): "enabled": True, "available": True, "resets_at": None, + "unlocked": True, } @@ -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" diff --git a/docs/architecture/data-chat.md b/docs/architecture/data-chat.md index f7ad09c5..680a77f7 100644 --- a/docs/architecture/data-chat.md +++ b/docs/architecture/data-chat.md @@ -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 @@ -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`. @@ -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 diff --git a/frontend/nginx.conf b/frontend/nginx.conf index 1c47963d..f8023d93 100644 --- a/frontend/nginx.conf +++ b/frontend/nginx.conf @@ -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; diff --git a/frontend/src/app/chat/page.tsx b/frontend/src/app/chat/page.tsx index 75d747c8..062f8b12 100644 --- a/frontend/src/app/chat/page.tsx +++ b/frontend/src/app/chat/page.tsx @@ -2,7 +2,8 @@ /** * Data Chat (v0): ask questions about the warehouse. Hidden unless the backend - * sets CHAT_ENABLED; chats live in localStorage. See + * sets CHAT_ENABLED, and behind an access code when it sets CHAT_ACCESS_CODE; + * chats live in localStorage. See * docs/architecture/data-chat.md. */ import { useEffect, useRef, useState } from 'react'; @@ -36,7 +37,11 @@ import { streamChat } from '@/components/Chat/api'; import { AnswerMarkdown, Steps } from '@/components/Chat/AnswerParts'; import { ChatChart } from '@/components/Chat/ChatChart'; import { type ChatTurn, toHistory, useChat } from '@/components/Chat/chatStore'; -import { useChatStatus } from '@/components/Chat/useChatStatus'; +import { AccessCodeForm } from '@/components/Chat/AccessCodeForm'; +import { + refreshChatStatus, + useChatStatus, +} from '@/components/Chat/useChatStatus'; import { type Location, useProfile, @@ -245,6 +250,7 @@ export default function ChatPage() { lastFlush = Date.now(); update((t) => ({ ...t, content: answer })); }; + let failed = false; try { for await (const event of streamChat( toHistory([...prior, userTurn]), @@ -288,6 +294,7 @@ export default function ChatPage() { })); break; case 'error': + failed = true; update((t) => ({ ...t, error: event.message })); break; } @@ -309,6 +316,8 @@ export default function ChatPage() { })); setStreaming(false); abortRef.current = null; + // If the access code changed or expired, this brings back the code form. + if (failed) refreshChatStatus(); } } @@ -331,6 +340,8 @@ export default function ChatPage() { ); } + if (!status.unlocked) return ; + return (
diff --git a/frontend/src/components/Chat/AccessCodeForm.tsx b/frontend/src/components/Chat/AccessCodeForm.tsx new file mode 100644 index 00000000..54f9d9c0 --- /dev/null +++ b/frontend/src/components/Chat/AccessCodeForm.tsx @@ -0,0 +1,62 @@ +'use client'; + +/** + * Shown on /chat when the backend requires an access code and this browser + * hasn't entered it. The code is shared with testers until there are + * accounts; the server remembers it in a cookie. + */ +import { useState } from 'react'; +import { + Button, + Container, + Group, + PasswordInput, + Text, + Title, +} from '@mantine/core'; +import { unlockChat } from './api'; +import { setChatStatus } from './useChatStatus'; + +export function AccessCodeForm() { + const [code, setCode] = useState(''); + const [error, setError] = useState(null); + const [sending, setSending] = useState(false); + + async function submit(e: React.FormEvent) { + e.preventDefault(); + if (!code.trim() || sending) return; + setSending(true); + setError(null); + try { + setChatStatus(await unlockChat(code)); + } catch (err) { + setError((err as Error).message); + setSending(false); + } + } + + return ( + + Ask the Data + + Chat is in testing. Enter the access code you were given. + +
+ setCode(e.currentTarget.value)} + error={error} + autoComplete="off" + autoFocus + /> + + + + +
+ ); +} diff --git a/frontend/src/components/Chat/api.ts b/frontend/src/components/Chat/api.ts index f4ef996c..5b353de4 100644 --- a/frontend/src/components/Chat/api.ts +++ b/frontend/src/components/Chat/api.ts @@ -1,7 +1,9 @@ /** * Client for the data chat API (backend/chat/router.py). The chat routes only * exist when the backend sets CHAT_ENABLED, so every caller must treat a - * failed status check as "chat is off". + * failed status check as "chat is off". When the backend also sets + * CHAT_ACCESS_CODE, entering the code sets an HttpOnly cookie, so every request + * sends credentials (the dev frontend calls the API on another port). */ import { BASE_API_URL } from '@/config'; import type { AgentChartKind } from '@/components/Charts/AgentChart'; @@ -44,6 +46,8 @@ export interface ChatStatus { enabled: boolean; available: boolean; resets_at: string | null; + // False until this browser has entered the access code. + unlocked: boolean; } export type ChatEvent = @@ -70,11 +74,18 @@ export interface HistoryMessage { content: string; } -const OFF: ChatStatus = { enabled: false, available: false, resets_at: null }; +const OFF: ChatStatus = { + enabled: false, + available: false, + resets_at: null, + unlocked: false, +}; export async function fetchChatStatus(): Promise { try { - const res = await fetch(`${BASE_API_URL}/chat/status`); + const res = await fetch(`${BASE_API_URL}/chat/status`, { + credentials: 'include', + }); if (!res.ok) return OFF; return (await res.json()) as ChatStatus; } catch { @@ -99,6 +110,7 @@ export async function* streamChat( ): AsyncGenerator { const res = await fetch(`${BASE_API_URL}/chat`, { method: 'POST', + credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ messages }), signal, @@ -129,9 +141,22 @@ export async function* streamChat( export async function fetchChartData(ref: DataRef): Promise { const res = await fetch(`${BASE_API_URL}/chat/data`, { method: 'POST', + credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(ref), }); if (!res.ok) throw new Error(await errorMessage(res)); return (await res.json()) as ChartData; } + +/** Send the access code; resolves to the new status, or throws the reason. */ +export async function unlockChat(code: string): Promise { + const res = await fetch(`${BASE_API_URL}/chat/unlock`, { + method: 'POST', + credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ code }), + }); + if (!res.ok) throw new Error(await errorMessage(res)); + return (await res.json()) as ChatStatus; +} diff --git a/frontend/src/components/Chat/useChatStatus.ts b/frontend/src/components/Chat/useChatStatus.ts index 6bc65811..28565a98 100644 --- a/frontend/src/components/Chat/useChatStatus.ts +++ b/frontend/src/components/Chat/useChatStatus.ts @@ -3,16 +3,35 @@ import { fetchChatStatus, type ChatStatus } from './api'; // One request per page load, shared by the header and the chat page. let pending: Promise | null = null; +// Mounted hooks, told when the status changes after the first check. +const listeners = new Set<(status: ChatStatus) => void>(); + +function publish(next: Promise) { + pending = next; + next.then((s) => listeners.forEach((listener) => listener(s))); +} + +/** Use a status the server just returned (e.g. from entering the code). */ +export function setChatStatus(status: ChatStatus) { + publish(Promise.resolve(status)); +} + +/** Check again, e.g. after a request was refused because the code changed. */ +export function refreshChatStatus() { + publish(fetchChatStatus()); +} /** Whether the backend has chat turned on; null while checking. */ export function useChatStatus(): ChatStatus | null { const [status, setStatus] = useState(null); useEffect(() => { let cancelled = false; + listeners.add(setStatus); pending ??= fetchChatStatus(); pending.then((s) => !cancelled && setStatus(s)); return () => { cancelled = true; + listeners.delete(setStatus); }; }, []); return status; diff --git a/frontend/src/components/HeaderMenu/index.tsx b/frontend/src/components/HeaderMenu/index.tsx index c609ddde..3a066f8e 100644 --- a/frontend/src/components/HeaderMenu/index.tsx +++ b/frontend/src/components/HeaderMenu/index.tsx @@ -259,8 +259,10 @@ export default function HeaderMenu() { return () => window.removeEventListener('scroll', onScroll); }, []); - // Data Chat sits after Data, only when the backend has CHAT_ENABLED set. - const chatEnabled = useChatStatus()?.enabled ?? false; + // Data Chat sits after Data, only when the backend has CHAT_ENABLED set and + // this browser has entered the access code; testers go to /chat to enter it. + const chatStatus = useChatStatus(); + const chatEnabled = !!(chatStatus?.enabled && chatStatus.unlocked); const navLinks: NavItem[] = chatEnabled ? [...links.slice(0, 3), { link: '/chat', label: 'Ask' }, ...links.slice(3)] : links; diff --git a/justfile b/justfile index ca3d1fea..228d8c22 100644 --- a/justfile +++ b/justfile @@ -12,7 +12,7 @@ mcp_port := env("MCP_PORT", "6768") # Pass named variables without placing bearer-token values in podman arguments. mcp_container_env := "-e MCP_ENABLED -e MCP_AUTH_MODE -e MCP_BEARER_TOKENS -e MCP_ALLOWED_HOSTS -e MCP_ALLOWED_ORIGINS -e MCP_MAX_ROWS -e MCP_MAX_BYTES -e MCP_QUERY_TIMEOUT -e MCP_MAX_CONCURRENCY -e MCP_RATE_LIMIT -e MCP_MAX_REQUEST_BYTES -e MCP_REQUEST_BODY_TIMEOUT" # Data chat (docs/architecture/data-chat.md); the provider key is passed by name. -chat_container_env := "-e CHAT_ENABLED -e CHAT_MODEL -e OPENROUTER_API_KEY -e OPENAI_API_KEY -e CHAT_MAX_MODEL_REQUESTS -e CHAT_MAX_TOOL_CALLS -e CHAT_MAX_OUTPUT_TOKENS -e CHAT_PREVIEW_ROWS -e CHAT_MAX_HISTORY_MESSAGES -e CHAT_MAX_MESSAGE_CHARS -e CHAT_MAX_HISTORY_CHARS -e CHAT_RATE_LIMIT -e CHAT_CHART_MAX_ROWS" +chat_container_env := "-e CHAT_ENABLED -e CHAT_ACCESS_CODE -e CHAT_ACCESS_DAYS -e CHAT_MODEL -e OPENROUTER_API_KEY -e OPENAI_API_KEY -e CHAT_MAX_MODEL_REQUESTS -e CHAT_MAX_TOOL_CALLS -e CHAT_MAX_OUTPUT_TOKENS -e CHAT_PREVIEW_ROWS -e CHAT_MAX_HISTORY_MESSAGES -e CHAT_MAX_MESSAGE_CHARS -e CHAT_MAX_HISTORY_CHARS -e CHAT_RATE_LIMIT -e CHAT_CHART_MAX_ROWS" # Host-specific podman flags. Empty by default (Docker, macOS, rootful podman). # On the VM with rootless podman, override: just podman_flags="--userns=keep-id:uid=1000,gid=1000" podman_flags := env("PODMAN_FLAGS", "")