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 <[email protected]>
This commit is contained in:
Lucas Winther
2026-10-30 20:33:56 +01:00
co-authored by Claude Opus 5.5
parent a995900fba
commit ddc66f5be5
8 changed files with 261 additions and 12 deletions
+14 -3
View File
@@ -14,6 +14,7 @@ import { connectMcp, mcpServersFor } from "./mcp/client";
import { createProvider } from "./providers";
import { PRESET_BASE_URLS, byokBaseUrl, noRedirectFetch, parseAllowedOrigins, publicProviderError, sanitizeProviderErrors } from "./endpoints";
import { getCourseIndex, noteChunks } from "./retrieval/course-index";
import { MaskedSecretError } from "./mcp/secrets";
import { aiSettingsRepo, type McpServerConfig, type ProviderConfig } from "./settings";
export interface AiFeatureOptions {
@@ -267,7 +268,7 @@ export function aiFeature(ctx: ServerContext, opts: AiFeatureOptions = {}): Feat
let tools: ToolSet | null = null;
try {
const course_ = courseTools({ repo: ctx.courses, course, index, listNotes: (lid) => notes.list(user.id, course.id, lid), getProgress: () => progress.get(user.id, course.id) });
const servers = mcpServersFor(course.id, (await settings.getServer()).mcpServers);
const servers = mcpServersFor(course.id, await settings.mcpServersWithSecrets());
const mcp = servers.length ? await mcpTools(servers, { fetch: opts.fetch, timeoutMs: opts.mcpTimeoutMs }) : null;
for (const w of mcp?.warnings ?? []) send({ type: "notice", message: w });
tools = combineTools(mcp ? [course_, mcp] : [course_]);
@@ -344,7 +345,12 @@ export function aiFeature(ctx: ServerContext, opts: AiFeatureOptions = {}): Feat
allowedBaseUrls = parseAllowedOrigins(list);
if (allowedBaseUrls.length !== new Set(list.map((x) => x.replace(/\/+$/, ""))).size) return err(400, "Each allowed base URL must be an origin such as http://ollama.internal:11434.");
}
await settings.saveServer({ provider, mcpServers, allowedBaseUrls });
try {
await settings.saveServer({ provider, mcpServers, allowedBaseUrls });
} catch (e) {
if (e instanceof MaskedSecretError) return err(400, e.message);
throw e;
}
return json(await settings.getServer());
},
},
@@ -355,8 +361,13 @@ export function aiFeature(ctx: ServerContext, opts: AiFeatureOptions = {}): Feat
const b = await body(req);
const parsed = parseMcp(b ? [b] : null);
if (parsed === "invalid" || !parsed.length) return err(400, "Invalid MCP server.");
let cfg: McpServerConfig;
try { cfg = await settings.resolveMcpMasks(parsed[0]!); } catch (e) {
if (e instanceof MaskedSecretError) return err(400, e.message);
throw e;
}
try {
const c = await connectMcp(parsed[0]!, { fetch: opts.fetch, timeoutMs: opts.mcpTimeoutMs ?? 15_000 });
const c = await connectMcp(cfg, { fetch: opts.fetch, timeoutMs: opts.mcpTimeoutMs ?? 15_000 });
try { return json({ ok: true, tools: (await c.tools()).map((t) => ({ name: t.name, description: t.description })) }); } finally { await c.close(); }
} catch (e) {
return json({ ok: false, error: (e as Error).message });
+87
View File
@@ -0,0 +1,87 @@
import type { SecretBox } from "../crypto";
import type { McpServerConfig } from "../settings";
/**
* MCP `env` and `headers` at rest: both move into `secretCipher`, sealed with the same AES-GCM/HKDF box as API
* keys. The admin API only ever sees masked values; server code opens them to start or call an MCP server.
*/
export type StoredMcpServer = Omit<McpServerConfig, "env" | "headers"> & { secretCipher?: string; env?: Record<string, string>; headers?: Record<string, string> };
type Secrets = { env?: Record<string, string>; headers?: Record<string, string> };
export const MASK_PREFIX = "••••";
/** `••••` plus the last 4 characters for values of 12 characters or more, otherwise `••••••••`. */
export function maskSecret(value: string): string {
return value.length >= 12 ? `${MASK_PREFIX}${value.slice(-4)}` : `${MASK_PREFIX}${MASK_PREFIX}`;
}
/** A posted value looks like a mask but matches no stored value for that server and key. */
export class MaskedSecretError extends Error {
constructor(serverId: string, key: string) {
super(`Re-enter the value of ${key} for MCP server "${serverId}": a masked value cannot be saved.`);
this.name = "MaskedSecretError";
}
}
const nonEmpty = (r: Record<string, string> | undefined) => (r && Object.keys(r).length ? r : undefined);
/** Strips `env`/`headers` from a config and seals them (if any) into `secretCipher`. */
export async function sealMcpServer(box: SecretBox, cfg: McpServerConfig | StoredMcpServer): Promise<StoredMcpServer> {
const { env, headers, secretCipher: _old, ...rest } = cfg as StoredMcpServer;
const secrets: Secrets = {};
if (nonEmpty(env)) secrets.env = env;
if (nonEmpty(headers)) secrets.headers = headers;
return secrets.env || secrets.headers ? { ...rest, secretCipher: await box.encrypt(JSON.stringify(secrets)) } : rest;
}
/**
* The full config for server code. A value that cannot be opened (STC_SECRET changed) is dropped with the server
* still listed, so a broken secret never crashes the assistant. Plaintext left over from v1 is passed through.
*/
export async function openMcpServer(box: SecretBox, s: StoredMcpServer): Promise<McpServerConfig> {
const { secretCipher, env, headers, ...rest } = s;
let secrets: Secrets = {};
if (secretCipher) {
try { secrets = JSON.parse(await box.decrypt(secretCipher)) as Secrets; } catch { secrets = {}; }
}
const out: McpServerConfig = { ...rest };
const e = nonEmpty(secrets.env) ?? nonEmpty(env);
const h = nonEmpty(secrets.headers) ?? nonEmpty(headers);
if (e) out.env = e;
if (h) out.headers = h;
return out;
}
const maskRecord = (r: Record<string, string> | undefined) => (r ? Object.fromEntries(Object.entries(r).map(([k, v]) => [k, maskSecret(v)])) : undefined);
/** What the admin API returns: keys visible, values masked. */
export function maskMcpServer(s: McpServerConfig): McpServerConfig {
const out: McpServerConfig = { ...s };
if (s.env) out.env = maskRecord(s.env);
if (s.headers) out.headers = maskRecord(s.headers);
return out;
}
/**
* Replaces each posted value equal to the mask of the stored value (same server id, same key) with the stored
* value. Any other value starting with the mask prefix throws MaskedSecretError, so a mask is never saved or sent.
*/
export function unmaskMcpServer(posted: McpServerConfig, previous: McpServerConfig[]): McpServerConfig {
const prev = previous.find((p) => p.id === posted.id);
const fill = (field: "env" | "headers") => {
const r = posted[field];
if (!r) return undefined;
return Object.fromEntries(Object.entries(r).map(([k, v]) => {
if (!v.startsWith(MASK_PREFIX)) return [k, v];
const old = prev?.[field]?.[k];
if (old !== undefined && maskSecret(old) === v) return [k, old];
throw new MaskedSecretError(posted.id, k);
}));
};
const out: McpServerConfig = { ...posted };
const env = fill("env");
const headers = fill("headers");
if (env) out.env = env;
if (headers) out.headers = headers;
return out;
}
+28 -6
View File
@@ -1,7 +1,10 @@
import type { Db } from "@stc/db";
import type { SecretBox } from "./crypto";
import { maskMcpServer, openMcpServer, sealMcpServer, unmaskMcpServer, type StoredMcpServer } from "./mcp/secrets";
import { upgradeAiData } from "./upgrade";
export { maskSecret, MaskedSecretError } from "./mcp/secrets";
export type ProviderKind = "anthropic" | "openai";
export type Effort = "low" | "medium" | "high" | "xhigh" | "max";
export interface ProviderConfig { kind: ProviderKind; model?: string; baseUrl?: string; effort?: Effort }
@@ -20,7 +23,10 @@ export interface McpServerConfig {
courseIds?: string[];
}
/** `allowedBaseUrls`: origins learners' own keys (BYOK) may be sent to, besides the official endpoints. */
/**
* `allowedBaseUrls`: origins learners' own keys (BYOK) may be sent to, besides the official endpoints.
* `mcpServers` here carry masked `env`/`headers` values (see mcp/secrets.ts); full values: mcpServersWithSecrets().
*/
export interface ServerSettings { provider: PublicProviderConfig | null; mcpServers: McpServerConfig[]; allowedBaseUrls: string[] }
export interface ServerSettingsInput {
provider: (ProviderConfig & { apiKey?: string; clearApiKey?: boolean }) | null;
@@ -30,7 +36,7 @@ export interface ServerSettingsInput {
}
export interface UserSettings { byok: PublicProviderConfig | null; mentorOff: Record<string, boolean> }
type ServerRow = { provider: ProviderConfig | null; apiKeyCipher: string | null; mcpServers: McpServerConfig[]; allowedBaseUrls?: string[] };
type ServerRow = { provider: ProviderConfig | null; apiKeyCipher: string | null; mcpServers: StoredMcpServer[]; allowedBaseUrls?: string[] };
type UserRow = { byok: ProviderConfig | null };
const pickProvider = (p: ProviderConfig): ProviderConfig => {
@@ -48,10 +54,11 @@ const parse = <T>(s: string | undefined, fallback: T): T => {
/**
* AI settings in ai_settings and user_api_keys (core 0001_init.sql) and ai_mentor_prefs (this package's 0002).
* - ('server', '') holds the admin provider (key encrypted) and MCP servers.
* - ('server', '') holds the admin provider (key encrypted) and MCP servers (env and headers encrypted).
* - ('user', <userId>) holds the BYOK provider choice. The mentor_off column ("off for every course") stays 0.
* - ai_mentor_prefs has a row per (user, course) whose mentor rules the learner switched off.
* Keys never leave this module except through serverApiKey/userApiKey, which only server code calls.
* Keys and MCP secrets leave this module only through serverApiKey, userApiKey and mcpServersWithSecrets, which
* only server code calls.
* Every method first awaits the one-time v1 data upgrade (upgradeAiData); a failed attempt is retried next call.
*/
export function aiSettingsRepo(db: Db, box: SecretBox) {
@@ -77,6 +84,7 @@ export function aiSettingsRepo(db: Db, box: SecretBox) {
const { mentorOff: _legacy, ...row } = parse(await read("user", userId), { byok: null } as UserRow & { mentorOff?: unknown });
return row;
};
const openServers = async (r: ServerRow) => Promise.all((r.mcpServers ?? []).map((m) => openMcpServer(box, m)));
const keyRow = async (userId: string) =>
(await db.sql`SELECT provider, ciphertext FROM user_api_keys WHERE user_id = ${userId}`)[0] as { provider: string; ciphertext: string } | undefined;
@@ -86,7 +94,18 @@ export function aiSettingsRepo(db: Db, box: SecretBox) {
async getServer(): Promise<ServerSettings> {
await ready();
const r = await serverRow();
return { provider: r.provider ? { ...r.provider, hasApiKey: !!r.apiKeyCipher } : null, mcpServers: r.mcpServers ?? [], allowedBaseUrls: r.allowedBaseUrls ?? [] };
const mcpServers = (await openServers(r)).map(maskMcpServer);
return { provider: r.provider ? { ...r.provider, hasApiKey: !!r.apiKeyCipher } : null, mcpServers, allowedBaseUrls: r.allowedBaseUrls ?? [] };
},
/** Server code only: MCP servers with their real env and headers. */
async mcpServersWithSecrets(): Promise<McpServerConfig[]> {
await ready();
return openServers(await serverRow());
},
/** A posted (masked) MCP config with stored values filled in; throws MaskedSecretError for a stray mask. */
async resolveMcpMasks(cfg: McpServerConfig): Promise<McpServerConfig> {
await ready();
return unmaskMcpServer(cfg, await openServers(await serverRow()));
},
async saveServer(input: ServerSettingsInput): Promise<void> {
await ready();
@@ -96,10 +115,13 @@ export function aiSettingsRepo(db: Db, box: SecretBox) {
if (input.provider.apiKey) apiKeyCipher = await box.encrypt(input.provider.apiKey);
else if (!input.provider.clearApiKey) apiKeyCipher = prev.apiKeyCipher;
}
const previous = await openServers(prev);
const unmasked = (input.mcpServers ?? []).map((m) => unmaskMcpServer(m, previous));
const mcpServers = await Promise.all(unmasked.map((m) => sealMcpServer(box, m)));
await write("server", "", {
provider: input.provider ? pickProvider(input.provider) : null,
apiKeyCipher,
mcpServers: input.mcpServers ?? [],
mcpServers,
allowedBaseUrls: input.allowedBaseUrls ?? prev.allowedBaseUrls ?? [],
});
},
+14 -1
View File
@@ -1,5 +1,6 @@
import type { Db } from "@stc/db";
import type { SecretBox } from "./crypto";
import { sealMcpServer, type StoredMcpServer } from "./mcp/secrets";
/** Recorded in schema_migrations once the data upgrade has run (no `.sql` suffix, so no file can share it). */
export const AI_DATA_UPGRADE = "ai_data_v11";
@@ -30,15 +31,27 @@ async function moveMentorToggles(tx: Tx, now: string) {
}
}
/** v1 stored MCP `env`/`headers` in plaintext in the server row; seal them like API keys. */
async function sealMcpSecrets(tx: Tx, box: SecretBox, now: string) {
const rows = (await tx`SELECT config_json FROM ai_settings WHERE scope = 'server' AND owner_id = ''`) as { config_json: string }[];
const cfg = rows[0] ? parseObject(rows[0].config_json) : null;
if (!cfg || !Array.isArray(cfg.mcpServers)) return;
const servers = cfg.mcpServers as StoredMcpServer[];
if (!servers.some((s) => s && (s.env || s.headers))) return;
const mcpServers = await Promise.all(servers.map((s) => (s && (s.env || s.headers) ? sealMcpServer(box, s) : s)));
await tx`UPDATE ai_settings SET config_json = ${JSON.stringify({ ...cfg, mcpServers })}, updated_at = ${now} WHERE scope = 'server' AND owner_id = ''`;
}
/**
* Data the v1.1 SQL migrations cannot move portably. Runs after the package's migrations, in one transaction,
* once (marked in schema_migrations). Returns true if it ran now. The settings repo awaits it before first use.
*/
export async function upgradeAiData(db: Db, _box: SecretBox): Promise<boolean> {
export async function upgradeAiData(db: Db, box: SecretBox): Promise<boolean> {
return db.sql.begin(async (tx) => {
if ((await tx`SELECT name FROM schema_migrations WHERE name = ${AI_DATA_UPGRADE}`).length) return false;
const now = new Date().toISOString();
await moveMentorToggles(tx, now);
await sealMcpSecrets(tx, box, now);
await tx`INSERT INTO schema_migrations (name, applied_at) VALUES (${AI_DATA_UPGRADE}, ${now})`;
return true;
});
+1 -1
View File
@@ -157,7 +157,7 @@ export default function AdminAiPage() {
</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.">
<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}
+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);
});
+47 -1
View File
@@ -1,7 +1,7 @@
import { expect } from "bun:test";
import { forEachAiDb as forEachDb } from "./helpers";
import { createSecretBox } from "../src/crypto";
import { aiSettingsRepo } from "../src/settings";
import { aiSettingsRepo, maskSecret, MaskedSecretError } from "../src/settings";
const box = createSecretBox("test-secret");
@@ -75,3 +75,49 @@ forEachDb("mentor toggles are rows in ai_mentor_prefs, not config_json", async (
await r.setMentorOff("u1", "sample", false);
expect(await db.sql`SELECT user_id FROM ai_mentor_prefs`).toHaveLength(0);
});
forEachDb("MCP env and headers are encrypted, masked when read and kept when the mask is sent back", async (db) => {
const r = aiSettingsRepo(db, box);
await r.saveServer({ provider: null, mcpServers: [
{ id: "fs", name: "Files", transport: "stdio", command: "mcp-fs", env: { FS_TOKEN: "tok-SECRET-123456", SHORT: "abc" } },
{ id: "web", name: "Web", transport: "http", url: "https://mcp.example.com/mcp", headers: { authorization: "Bearer hdr-SECRET-abcdef" } },
] });
const raw = (await db.sql`SELECT config_json FROM ai_settings WHERE scope = 'server'`)[0].config_json;
expect(raw).not.toContain("SECRET");
expect(raw).not.toContain("abc\"");
const shown = await r.getServer();
expect(JSON.stringify(shown)).not.toContain("SECRET");
expect(shown.mcpServers[0]!.env).toEqual({ FS_TOKEN: "••••3456", SHORT: "••••••••" });
expect(shown.mcpServers[1]!.headers).toEqual({ authorization: "••••cdef" });
expect(maskSecret("abc")).toBe("••••••••");
// The admin page sends the masks back unchanged (plus a new variable): stored values are kept.
await r.saveServer({ provider: null, mcpServers: [
{ ...shown.mcpServers[0]!, env: { ...shown.mcpServers[0]!.env, NEW: "plain-new-value" } },
shown.mcpServers[1]!,
] });
const full = await r.mcpServersWithSecrets();
expect(full[0]!.env).toEqual({ FS_TOKEN: "tok-SECRET-123456", SHORT: "abc", NEW: "plain-new-value" });
expect(full[1]!.headers).toEqual({ authorization: "Bearer hdr-SECRET-abcdef" });
// A new value replaces the old one; a removed key is gone.
await r.saveServer({ provider: null, mcpServers: [{ id: "fs", name: "Files", transport: "stdio", command: "mcp-fs", env: { FS_TOKEN: "tok-ROTATED-999999" } }] });
expect((await r.mcpServersWithSecrets())).toEqual([{ id: "fs", name: "Files", transport: "stdio", command: "mcp-fs", env: { FS_TOKEN: "tok-ROTATED-999999" } }]);
// A mask that does not match the stored value (other server id, or a stale mask) is refused, never stored.
await expect(r.saveServer({ provider: null, mcpServers: [{ id: "fs2", name: "Files", transport: "stdio", command: "mcp-fs", env: { FS_TOKEN: "••••9999" } }] })).rejects.toThrow(MaskedSecretError);
await expect(r.saveServer({ provider: null, mcpServers: [{ id: "fs", name: "Files", transport: "stdio", command: "mcp-fs", env: { FS_TOKEN: "••••0000" } }] })).rejects.toThrow(/Re-enter/);
expect((await r.mcpServersWithSecrets())[0]!.env).toEqual({ FS_TOKEN: "tok-ROTATED-999999" });
// resolveMcpMasks: the admin "test" button sends a masked draft; the server fills in the stored values.
expect(await r.resolveMcpMasks({ id: "fs", name: "Files", transport: "stdio", command: "mcp-fs", env: { FS_TOKEN: "••••9999" } }))
.toEqual({ id: "fs", name: "Files", transport: "stdio", command: "mcp-fs", env: { FS_TOKEN: "tok-ROTATED-999999" } });
});
forEachDb("MCP secrets sealed with another STC_SECRET are dropped, not crashed on", async (db) => {
await aiSettingsRepo(db, createSecretBox("old-secret")).saveServer({ provider: null, mcpServers: [{ id: "fs", name: "Files", transport: "stdio", command: "x", env: { T: "tok-SECRET-123456" } }] });
const r = aiSettingsRepo(db, box);
expect((await r.getServer()).mcpServers).toEqual([{ id: "fs", name: "Files", transport: "stdio", command: "x" }]);
expect(await r.mcpServersWithSecrets()).toEqual([{ id: "fs", name: "Files", transport: "stdio", command: "x" }]);
});
+32
View File
@@ -82,3 +82,35 @@ forEachV1Db("v1 upgrade: the settings repo upgrades on first use", async (db) =>
expect(await aiSettingsRepo(db, box).isMentorOff("u1", "sample")).toBe(true);
expect(JSON.parse((await db.sql`SELECT config_json FROM ai_settings WHERE owner_id = 'u1'`)[0].config_json)).toEqual({ byok: null });
});
forEachV1Db("v1 upgrade: plaintext MCP env and headers are encrypted at rest", async (db) => {
const apiKeyCipher = await box.encrypt("sk-ant-SERVER");
await seedSettings(db, "server", "", {
provider: { kind: "anthropic" }, apiKeyCipher, allowedBaseUrls: ["http://ollama:11434"],
mcpServers: [
{ id: "fs", name: "Files", transport: "stdio", command: "mcp-fs", args: ["--root", "/x"], env: { FS_TOKEN: "tok-PLAIN-123456" } },
{ id: "web", name: "Web", transport: "http", url: "https://mcp.example.com/mcp", headers: { authorization: "Bearer hdr-PLAIN-abcdef" }, courseIds: ["sample"] },
{ id: "bare", name: "Bare", transport: "http", url: "https://bare.example.com/mcp" },
],
});
await migrate(db, aiMigrations);
expect(await upgradeAiData(db, box)).toBe(true);
const raw = (await db.sql`SELECT config_json FROM ai_settings WHERE scope = 'server'`)[0].config_json as string;
expect(raw).not.toContain("PLAIN");
const stored = JSON.parse(raw);
expect(stored.apiKeyCipher).toBe(apiKeyCipher);
expect(stored.allowedBaseUrls).toEqual(["http://ollama:11434"]);
expect(stored.mcpServers.map((s: { id: string; env?: unknown; headers?: unknown; secretCipher?: string }) => [s.id, !!s.env, !!s.headers, !!s.secretCipher]))
.toEqual([["fs", false, false, true], ["web", false, false, true], ["bare", false, false, false]]);
const repo = aiSettingsRepo(db, box);
expect(await repo.serverApiKey()).toBe("sk-ant-SERVER");
expect(await repo.mcpServersWithSecrets()).toEqual([
{ id: "fs", name: "Files", transport: "stdio", command: "mcp-fs", args: ["--root", "/x"], env: { FS_TOKEN: "tok-PLAIN-123456" } },
{ id: "web", name: "Web", transport: "http", url: "https://mcp.example.com/mcp", headers: { authorization: "Bearer hdr-PLAIN-abcdef" }, courseIds: ["sample"] },
{ id: "bare", name: "Bare", transport: "http", url: "https://bare.example.com/mcp" },
]);
const shown = JSON.stringify(await repo.getServer());
expect(shown).not.toContain("PLAIN");
});