Files
STCourses/packages/feature-ai/src/web/AdminAiPage.tsx
T
Lucas WintherandClaude Opus 5.5 f2169a1235 fix(ai): bind sealed secrets to purpose and row; never drop unreadable secrets
Review fix round 1.
- Secret box v2 tokens: one HKDF key per purpose (byok, server-api-key,
  mcp) and AES-GCM AAD bound to the row (user id + provider; server id +
  field). MCP env and headers are sealed per field. open() refuses v1
  tokens; the data upgrade re-seals v1 keys, v1 plaintext MCP values and
  the draft secretCipher, and marks ai_secrets_v2 only when nothing
  legacy is left (retried until the previous secret is back).
- Unreadable secrets are reported (admin: unreadableSecrets with server
  and field; user: byok.keyUnreadable), logged at startup, and kept on
  save unless replaced or cleared (clearApiKey, clearSecrets). The admin
  and account pages show the warnings.
- allowedOrigins and provider resolution no longer open MCP secrets.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 20:49:04 +01:00

199 lines
12 KiB
TypeScript

import { useEffect, useState } from "react";
import { Pill } from "@stc/ui";
import { api, ApiError } from "./api";
import { Field, PageHead, UnreadableSecretsWarning, type UnreadableSecretItem } from "./components";
type Kind = "" | "anthropic" | "openai";
interface ProviderOut { kind: "anthropic" | "openai"; model?: string; baseUrl?: string; effort?: string; hasApiKey: boolean }
interface McpServer { id: string; name: string; transport: "stdio" | "http"; command?: string; args?: string[]; env?: Record<string, string>; url?: string; headers?: Record<string, string>; courseIds?: string[]; clearSecrets?: ("env" | "headers")[] }
interface Config { provider: ProviderOut | null; mcpServers: McpServer[]; allowedBaseUrls: string[]; unreadableSecrets?: UnreadableSecretItem[] }
type ServerDraft = { id: string; name: string; transport: "stdio" | "http"; command: string; args: string; env: string; url: string; headers?: Record<string, string>; courses: string; test?: string; clearSecrets?: ("env" | "headers")[] };
const toDraft = (s: McpServer): ServerDraft => ({
id: s.id, name: s.name, transport: s.transport, command: s.command ?? "", args: (s.args ?? []).join(" "),
env: Object.entries(s.env ?? {}).map(([k, v]) => `${k}=${v}`).join("\n"), url: s.url ?? "", headers: s.headers, courses: (s.courseIds ?? []).join(", "),
});
const parseEnv = (text: string): Record<string, string> =>
Object.fromEntries(text.split("\n").map((l) => l.trim()).filter((l) => l.includes("=")).map((l) => [l.slice(0, l.indexOf("=")).trim(), l.slice(l.indexOf("=") + 1)]));
const fromDraft = (d: ServerDraft): McpServer => ({
id: d.id, name: d.name || d.id, transport: d.transport,
...(d.transport === "stdio" ? { command: d.command, args: d.args.trim() ? d.args.trim().split(/\s+/) : [], env: parseEnv(d.env) } : { url: d.url, ...(d.headers ? { headers: d.headers } : {}) }),
courseIds: d.courses.split(",").map((s) => s.trim()).filter(Boolean),
...(d.clearSecrets?.length ? { clearSecrets: d.clearSecrets } : {}),
});
export default function AdminAiPage() {
const [loaded, setLoaded] = useState<Config | null>(null);
const [denied, setDenied] = useState<string | null>(null);
const [kind, setKind] = useState<Kind>("");
const [model, setModel] = useState("");
const [baseUrl, setBaseUrl] = useState("");
const [effort, setEffort] = useState("");
const [apiKey, setApiKey] = useState("");
const [clearKey, setClearKey] = useState(false);
const [servers, setServers] = useState<ServerDraft[]>([]);
const [allowed, setAllowed] = useState("");
const [msg, setMsg] = useState<{ ok: boolean; text: string } | null>(null);
const apply = (c: Config) => {
setLoaded(c);
setKind(c.provider?.kind ?? "");
setModel(c.provider?.model ?? "");
setBaseUrl(c.provider?.baseUrl ?? "");
setEffort(c.provider?.effort ?? "");
setApiKey("");
setClearKey(false);
setServers(c.mcpServers.map(toDraft));
setAllowed((c.allowedBaseUrls ?? []).join("\n"));
};
useEffect(() => {
api<Config>("/api/ai/admin/config").then(apply, (e: ApiError) => setDenied(e.status === 401 ? "Sign in as the owner to change AI settings." : e.message));
}, []);
const save = async () => {
setMsg(null);
try {
const provider = kind ? { kind, model, baseUrl, effort, apiKey: apiKey || undefined, clearApiKey: clearKey } : null;
apply(await api<Config>("/api/ai/admin/config", { method: "PUT", body: { provider, mcpServers: servers.map(fromDraft), allowedBaseUrls: allowed.split(/[\s,]+/).filter(Boolean) } }));
setMsg({ ok: true, text: kind ? "Saved. The assistant is available to learners." : "Saved. AI is off for everyone without their own key." });
} catch (e) {
setMsg({ ok: false, text: (e as Error).message });
}
};
const setServer = (i: number, patch: Partial<ServerDraft>) => setServers((s) => s.map((x, j) => (j === i ? { ...x, ...patch } : x)));
const testServer = async (i: number) => {
setServer(i, { test: "Connecting…" });
try {
const r = await api<{ ok: boolean; tools?: { name: string }[]; error?: string }>("/api/ai/admin/mcp/test", { method: "POST", body: fromDraft(servers[i]!) });
setServer(i, { test: r.ok ? `Connected. Tools: ${r.tools!.map((t) => t.name).join(", ") || "none"}` : `Failed: ${r.error}` });
} catch (e) {
setServer(i, { test: `Failed: ${(e as Error).message}` });
}
};
if (denied) return <div className="ai-page"><PageHead title="AI settings" /><p className="ai-note">{denied}</p></div>;
if (!loaded) return <div className="ai-page"><PageHead title="AI settings" /></div>;
return (
<div className="ai-page">
<PageHead title="AI settings">
<p>AI is off until you choose a provider here. Learners and authors can also add their own key, which is used only for their own requests. Keys are encrypted on the server and never shown again.</p>
</PageHead>
<UnreadableSecretsWarning
items={loaded.unreadableSecrets ?? []}
action={(item) => {
if (item.kind !== "mcp") return <span className="ai-hint">Paste the key again below, or tick "Remove the saved key".</span>;
const i = servers.findIndex((x) => x.id === item.serverId);
const marked = i >= 0 && !!servers[i]!.clearSecrets?.includes(item.field);
if (i < 0) return null;
return marked ? <span className="ai-hint">Will be discarded when you save.</span> : (
<Pill variant="ghost" onClick={() => setServer(i, { clearSecrets: [...(servers[i]!.clearSecrets ?? []), item.field] })}>Discard stored value</Pill>
);
}}
/>
<form onSubmit={(e) => { e.preventDefault(); save(); }}>
<section className="ai-section">
<h2>Server provider</h2>
<span className="ai-hint">Used for everyone who has not added their own key.</span>
<div className="ai-grid">
<Field label="Provider">
<select className="ai-select" value={kind} onChange={(e) => setKind(e.target.value as Kind)}>
<option value="">None (AI off)</option>
<option value="anthropic">Anthropic</option>
<option value="openai">OpenAI-compatible (OpenAI, Ollama, vLLM …)</option>
</select>
</Field>
{kind ? (
<Field label="Model" hint={kind === "anthropic" ? "Leave empty for claude-opus-5-5." : "For example llama3.1 on Ollama."}>
<input className="ai-input" value={model} onChange={(e) => setModel(e.target.value)} placeholder={kind === "anthropic" ? "claude-opus-5-5" : "model name"} />
</Field>
) : null}
</div>
{kind ? (
<>
<Field label={kind === "anthropic" ? "Base URL (optional)" : "Base URL"} hint={kind === "openai" ? "The address that ends before /chat/completions, for example http://ollama:11434/v1." : "Leave empty for api.anthropic.com."}>
<input className="ai-input ai-mono" value={baseUrl} onChange={(e) => setBaseUrl(e.target.value)} placeholder={kind === "openai" ? "http://localhost:11434/v1" : "https://api.anthropic.com"} />
</Field>
<div className="ai-grid">
<Field label="API key" hint={loaded.provider?.hasApiKey ? "A key is saved. Leave empty to keep it." : kind === "openai" ? "Optional for a local Ollama." : undefined}>
<input className="ai-input ai-mono" type="password" autoComplete="off" value={apiKey} onChange={(e) => setApiKey(e.target.value)} placeholder={loaded.provider?.hasApiKey ? "••••••••" : "paste key"} />
</Field>
{kind === "anthropic" ? (
<Field label="Effort" hint="How hard the model thinks. Empty uses the model's default.">
<select className="ai-select" value={effort} onChange={(e) => setEffort(e.target.value)}>
<option value="">Default</option>
<option value="low">Low</option>
<option value="medium">Medium</option>
<option value="high">High</option>
</select>
</Field>
) : null}
</div>
{loaded.provider?.hasApiKey ? (
<label className="ai-check"><input type="checkbox" checked={clearKey} onChange={(e) => setClearKey(e.target.checked)} /> Remove the saved key</label>
) : null}
</>
) : null}
</section>
<section className="ai-section">
<h2>Endpoints for learners' own keys</h2>
<span className="ai-hint">Own keys go to api.anthropic.com or api.openai.com only. To let people use another endpoint, such as a shared Ollama, list its origin here (one per line, for example http://ollama.internal:11434). STC_AI_ALLOWED_BASE_URLS adds more.</span>
<Field label="Allowed origins">
<textarea className="ai-input ai-mono" rows={2} value={allowed} onChange={(e) => setAllowed(e.target.value)} placeholder="http://ollama.internal:11434" />
</Field>
</section>
<section className="ai-section">
<h2>MCP servers</h2>
<span className="ai-hint">Extra tools for the assistant. Leave "Courses" empty to offer a server in every course.</span>
{servers.map((s, i) => (
<div className="ai-server" key={i}>
<div className="ai-grid">
<Field label="Id"><input className="ai-input ai-mono" value={s.id} onChange={(e) => setServer(i, { id: e.target.value })} /></Field>
<Field label="Name"><input className="ai-input" value={s.name} onChange={(e) => setServer(i, { name: e.target.value })} /></Field>
<Field label="Transport">
<select className="ai-select" value={s.transport} onChange={(e) => setServer(i, { transport: e.target.value as "stdio" | "http" })}>
<option value="http">Streamable HTTP</option>
<option value="stdio">Local command (stdio)</option>
</select>
</Field>
</div>
{s.transport === "http" ? (
<Field label="URL"><input className="ai-input ai-mono" value={s.url} onChange={(e) => setServer(i, { url: e.target.value })} placeholder="https://mcp.example.com/mcp" /></Field>
) : (
<div className="ai-grid">
<Field label="Command"><input className="ai-input ai-mono" value={s.command} onChange={(e) => setServer(i, { command: e.target.value })} placeholder="bunx" /></Field>
<Field label="Arguments"><input className="ai-input ai-mono" value={s.args} onChange={(e) => setServer(i, { args: e.target.value })} placeholder="some-mcp-server --flag" /></Field>
</div>
)}
{s.transport === "stdio" ? (
<Field label="Environment" hint="One NAME=value per line. The command gets only PATH, HOME, LANG and these; server secrets are never passed on. Values are stored encrypted and shown masked; leave a masked value as it is to keep it.">
<textarea className="ai-input ai-mono" rows={2} value={s.env} onChange={(e) => setServer(i, { env: e.target.value })} placeholder="API_TOKEN=…" />
</Field>
) : null}
<Field label="Courses" hint="Course ids, separated by commas."><input className="ai-input ai-mono" value={s.courses} onChange={(e) => setServer(i, { courses: e.target.value })} /></Field>
<div className="ai-footer" style={{ margin: "0 0 10px" }}>
<Pill onClick={() => testServer(i)}>Test connection</Pill>
<Pill variant="ghost" onClick={() => setServers((x) => x.filter((_, j) => j !== i))}>Remove</Pill>
{s.test ? <span className="ai-hint">{s.test}</span> : null}
</div>
</div>
))}
<div className="ai-footer">
<Pill onClick={() => setServers((x) => [...x, { id: `server-${x.length + 1}`, name: "", transport: "http", command: "", args: "", env: "", url: "", courses: "" }])}>Add MCP server</Pill>
</div>
</section>
<div className="ai-footer">
<Pill variant="primary" type="submit">Save AI settings</Pill>
{msg ? <span className={msg.ok ? "ai-hint ai-ok" : "ai-error"} role="status" style={{ margin: 0 }}>{msg.text}</span> : null}
</div>
</form>
</div>
);
}