feat(ai): encrypt MCP env and headers at rest and mask them in the admin API

env/headers are sealed into secretCipher with the BYOK AES-GCM/HKDF box.
The admin GET returns masked values; a mask posted back for the same
server and key keeps the stored value, any other mask is a 400. The chat
and the test button use the stored values. upgradeAiData seals plaintext
v1 rows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Lucas WintherandClaude Opus 5.5 committed 2026-10-30 20:33:56 +01:00
1 parent a995900fba
commit ddc66f5be5
8 files changed
+261 -12

No files matched your search

+38
View File
@@ -245,3 +245,41 @@ forEachDb("authoring uses core canView: the global author role reaches restricte
await db.sql`INSERT INTO course_acl (course_id, principal_type, principal_id) VALUES ('secret', 'user', 'ann')`;
expect((await call("POST", "/api/ai/studio/generate", "ann", body)).status).toBe(403);
});
forEachDb("the admin API masks MCP secrets, and the test button and chat use the stored values", async (db) => {
const seen: (string | null)[] = [];
const mcpFetch = (async (_url: string | URL | Request, init?: RequestInit) => {
seen.push(new Headers(init?.headers).get("authorization"));
const msg = JSON.parse(String(init?.body ?? "{}"));
if (msg.id === undefined) return new Response(null, { status: 202 });
const result = msg.method === "initialize" ? { protocolVersion: "2025-06-18", capabilities: {}, serverInfo: { name: "f", version: "1" } }
: msg.method === "tools/list" ? { tools: [{ name: "ping", description: "Ping", inputSchema: { type: "object" } }] }
: {};
return Response.json({ jsonrpc: "2.0", id: msg.id, result });
}) as unknown as typeof fetch;
const { call } = await setup(db, [() => ({ text: "ok" })], { fetch: mcpFetch });
const web = { id: "web", name: "Web", transport: "http", url: "https://mcp.example.com/mcp", headers: { authorization: "Bearer hdr-SECRET-abcdef" } };
expect((await call("PUT", "/api/ai/admin/config", "owner", { provider: { kind: "anthropic", apiKey: "k" }, mcpServers: [web] })).status).toBe(200);
const text = await (await call("GET", "/api/ai/admin/config", "owner")).text();
expect(text).not.toContain("SECRET");
const shown = JSON.parse(text).mcpServers[0];
expect(shown.headers).toEqual({ authorization: "••••cdef" });
// Saving the page unchanged keeps the secret; a stale mask is a 400 and changes nothing.
expect((await call("PUT", "/api/ai/admin/config", "owner", { provider: { kind: "anthropic" }, mcpServers: [shown] })).status).toBe(200);
const stale = await call("PUT", "/api/ai/admin/config", "owner", { provider: { kind: "anthropic" }, mcpServers: [{ ...shown, id: "web2" }] });
expect(stale.status).toBe(400);
expect((await stale.json()).error).toContain("Re-enter");
// The test button posts the masked draft; the MCP server receives the stored header.
const t = await (await call("POST", "/api/ai/admin/mcp/test", "owner", shown)).json();
expect(t).toMatchObject({ ok: true, tools: [{ name: "ping" }] });
expect(seen.filter(Boolean).every((h) => h === "Bearer hdr-SECRET-abcdef")).toBe(true);
expect(seen.length).toBeGreaterThan(0);
// Chat connects with the stored header too.
seen.length = 0;
await (await call("POST", "/api/ai/courses/sample/chat", "ann", { message: "hello" })).text();
expect(seen.length).toBeGreaterThan(0);
expect(seen.filter(Boolean).every((h) => h === "Bearer hdr-SECRET-abcdef")).toBe(true);
});