import type { Db } from "@stc/db"; import type { SecretBox } from "./crypto"; export type ProviderKind = "anthropic" | "openai"; export type Effort = "low" | "medium" | "high" | "xhigh" | "max"; export interface ProviderConfig { kind: ProviderKind; model?: string; baseUrl?: string; effort?: Effort } export type PublicProviderConfig = ProviderConfig & { hasApiKey: boolean }; export interface McpServerConfig { id: string; name: string; transport: "stdio" | "http"; command?: string; args?: string[]; env?: Record; url?: string; headers?: Record; /** Courses this server is offered in. Absent or empty: every course (global). */ courseIds?: string[]; } export interface ServerSettings { provider: PublicProviderConfig | null; mcpServers: McpServerConfig[] } export interface ServerSettingsInput { provider: (ProviderConfig & { apiKey?: string; clearApiKey?: boolean }) | null; mcpServers: McpServerConfig[]; } export interface UserSettings { byok: PublicProviderConfig | null; mentorOff: Record } type ServerRow = { provider: ProviderConfig | null; apiKeyCipher: string | null; mcpServers: McpServerConfig[] }; type UserRow = { byok: ProviderConfig | null; mentorOff: Record }; const pickProvider = (p: ProviderConfig): ProviderConfig => { const out: ProviderConfig = { kind: p.kind }; if (p.model) out.model = p.model; if (p.baseUrl) out.baseUrl = p.baseUrl; if (p.effort) out.effort = p.effort; return out; }; const parse = (s: string | undefined, fallback: T): T => { if (!s) return fallback; try { return { ...fallback, ...JSON.parse(s) }; } catch { return fallback; } }; /** * AI settings in the ai_settings and user_api_keys tables of 0001_init.sql. * - ('server', '') holds the admin provider (key encrypted) and MCP servers. * - ('user', ) holds the BYOK provider choice and per-course mentor toggles. The frozen primary key * is (scope, owner_id), so the per-course flag lives in config_json; the mentor_off column means * "off for every course" and stays 0 (see docs/contract-requests/ai.md). * Keys never leave this module except through serverApiKey/userApiKey, which only server code calls. */ export function aiSettingsRepo(db: Db, box: SecretBox) { const read = async (scope: "server" | "user", owner: string): Promise => { const rows = await db.sql`SELECT config_json FROM ai_settings WHERE scope = ${scope} AND owner_id = ${owner}`; return rows[0]?.config_json; }; const write = async (scope: "server" | "user", owner: string, value: unknown) => { const json = JSON.stringify(value); const now = new Date().toISOString(); await db.sql.begin(async (tx) => { const exists = await tx`SELECT 1 AS x FROM ai_settings WHERE scope = ${scope} AND owner_id = ${owner}`; if (exists.length) await tx`UPDATE ai_settings SET config_json = ${json}, updated_at = ${now} WHERE scope = ${scope} AND owner_id = ${owner}`; else await tx`INSERT INTO ai_settings (scope, owner_id, mentor_off, config_json, updated_at) VALUES (${scope}, ${owner}, 0, ${json}, ${now})`; }); }; const serverRow = async (): Promise => parse(await read("server", ""), { provider: null, apiKeyCipher: null, mcpServers: [] }); const userRow = async (userId: string): Promise => parse(await read("user", userId), { byok: null, mentorOff: {} }); 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; return { async getServer(): Promise { const r = await serverRow(); return { provider: r.provider ? { ...r.provider, hasApiKey: !!r.apiKeyCipher } : null, mcpServers: r.mcpServers ?? [] }; }, async saveServer(input: ServerSettingsInput): Promise { const prev = await serverRow(); let apiKeyCipher: string | null = null; if (input.provider) { if (input.provider.apiKey) apiKeyCipher = await box.encrypt(input.provider.apiKey); else if (!input.provider.clearApiKey) apiKeyCipher = prev.apiKeyCipher; } await write("server", "", { provider: input.provider ? pickProvider(input.provider) : null, apiKeyCipher, mcpServers: input.mcpServers ?? [], }); }, async serverApiKey(): Promise { const r = await serverRow(); return r.provider && r.apiKeyCipher ? box.decrypt(r.apiKeyCipher) : null; }, async getUser(userId: string): Promise { const r = await userRow(userId); const k = await keyRow(userId); return { byok: r.byok && k ? { ...r.byok, hasApiKey: true } : null, mentorOff: r.mentorOff ?? {} }; }, async saveUserKey(userId: string, cfg: ProviderConfig & { apiKey: string }): Promise { const ciphertext = await box.encrypt(cfg.apiKey); const now = new Date().toISOString(); await db.sql.begin(async (tx) => { await tx`DELETE FROM user_api_keys WHERE user_id = ${userId}`; await tx`INSERT INTO user_api_keys (user_id, provider, ciphertext, created_at) VALUES (${userId}, ${cfg.kind}, ${ciphertext}, ${now})`; }); const r = await userRow(userId); await write("user", userId, { ...r, byok: pickProvider(cfg) }); }, async removeUserKey(userId: string): Promise { await db.sql`DELETE FROM user_api_keys WHERE user_id = ${userId}`; const r = await userRow(userId); await write("user", userId, { ...r, byok: null }); }, async userApiKey(userId: string): Promise { const k = await keyRow(userId); return k ? box.decrypt(k.ciphertext) : null; }, async setMentorOff(userId: string, courseId: string, off: boolean): Promise { const r = await userRow(userId); const mentorOff = { ...r.mentorOff }; if (off) mentorOff[courseId] = true; else delete mentorOff[courseId]; await write("user", userId, { ...r, mentorOff }); }, async isMentorOff(userId: string, courseId: string): Promise { return (await userRow(userId)).mentorOff?.[courseId] === true; }, }; } export type AiSettingsRepo = ReturnType;