diff --git a/packages/feature-ai/src/index.ts b/packages/feature-ai/src/index.ts index 404b2e2..19b39f1 100644 --- a/packages/feature-ai/src/index.ts +++ b/packages/feature-ai/src/index.ts @@ -2,3 +2,4 @@ export { createSecretBox, type SecretBox } from "./crypto"; /** Migration folders for this package. Empty: the AI package uses the ai_* tables from 0001_init.sql. */ export const migrationsDirs: string[] = []; +export * from "./settings"; diff --git a/packages/feature-ai/src/settings.ts b/packages/feature-ai/src/settings.ts new file mode 100644 index 0000000..0d1360c --- /dev/null +++ b/packages/feature-ai/src/settings.ts @@ -0,0 +1,131 @@ +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; diff --git a/packages/feature-ai/test/settings.test.ts b/packages/feature-ai/test/settings.test.ts new file mode 100644 index 0000000..589c47f --- /dev/null +++ b/packages/feature-ai/test/settings.test.ts @@ -0,0 +1,63 @@ +import { expect } from "bun:test"; +import { forEachDb } from "../../db/test/helpers"; +import { createSecretBox } from "../src/crypto"; +import { aiSettingsRepo } from "../src/settings"; + +const box = createSecretBox("test-secret"); + +forEachDb("server config is empty by default and hides the key", async (db) => { + const r = aiSettingsRepo(db, box); + expect(await r.getServer()).toEqual({ provider: null, mcpServers: [] }); + expect(await r.serverApiKey()).toBeNull(); + + await r.saveServer({ provider: { kind: "anthropic", model: "claude-opus-5-5", apiKey: "sk-ant-SECRET" }, mcpServers: [] }); + const got = await r.getServer(); + expect(got.provider).toEqual({ kind: "anthropic", model: "claude-opus-5-5", hasApiKey: true }); + expect(JSON.stringify(got)).not.toContain("SECRET"); + const raw = await db.sql`SELECT config_json FROM ai_settings WHERE scope = 'server'`; + expect(raw[0].config_json).not.toContain("SECRET"); + expect(await r.serverApiKey()).toBe("sk-ant-SECRET"); + + // Saving without apiKey keeps the old key; clearApiKey removes it. + await r.saveServer({ provider: { kind: "anthropic", model: "other" }, mcpServers: [{ id: "fs", name: "Files", transport: "stdio", command: "x" }] }); + expect(await r.serverApiKey()).toBe("sk-ant-SECRET"); + expect((await r.getServer()).mcpServers).toHaveLength(1); + await r.saveServer({ provider: { kind: "openai", baseUrl: "http://ollama:11434/v1", model: "llama3", clearApiKey: true }, mcpServers: [] }); + expect(await r.serverApiKey()).toBeNull(); + expect((await r.getServer()).provider).toEqual({ kind: "openai", baseUrl: "http://ollama:11434/v1", model: "llama3", hasApiKey: false }); + await r.saveServer({ provider: null, mcpServers: [] }); + expect((await r.getServer()).provider).toBeNull(); +}); + +forEachDb("BYOK keys are encrypted, per user and removable", async (db) => { + const r = aiSettingsRepo(db, box); + expect((await r.getUser("u1")).byok).toBeNull(); + await r.saveUserKey("u1", { kind: "anthropic", apiKey: "sk-user-SECRET" }); + expect((await r.getUser("u1")).byok).toEqual({ kind: "anthropic", hasApiKey: true }); + expect((await r.getUser("u2")).byok).toBeNull(); + const raw = await db.sql`SELECT ciphertext FROM user_api_keys WHERE user_id = 'u1'`; + expect(raw[0].ciphertext).not.toContain("SECRET"); + expect(await r.userApiKey("u1")).toBe("sk-user-SECRET"); + expect(await r.userApiKey("u2")).toBeNull(); + // replacing with another provider drops the old key + await r.saveUserKey("u1", { kind: "openai", baseUrl: "https://api.example.com/v1", model: "m", apiKey: "k2" }); + expect(await r.userApiKey("u1")).toBe("k2"); + expect((await db.sql`SELECT COUNT(*) AS n FROM user_api_keys WHERE user_id = 'u1'`)[0].n.toString()).toBe("1"); + await r.removeUserKey("u1"); + expect((await r.getUser("u1")).byok).toBeNull(); + expect(await r.userApiKey("u1")).toBeNull(); +}); + +forEachDb("mentor mode is on by default and toggles per course", async (db) => { + const r = aiSettingsRepo(db, box); + expect(await r.isMentorOff("u1", "sample")).toBe(false); + await r.setMentorOff("u1", "sample", true); + expect(await r.isMentorOff("u1", "sample")).toBe(true); + expect(await r.isMentorOff("u1", "pure-snake")).toBe(false); + expect(await r.isMentorOff("u2", "sample")).toBe(false); + await r.saveUserKey("u1", { kind: "anthropic", apiKey: "k" }); + expect(await r.isMentorOff("u1", "sample")).toBe(true); + await r.setMentorOff("u1", "sample", false); + expect(await r.isMentorOff("u1", "sample")).toBe(false); + expect((await r.getUser("u1")).byok?.kind).toBe("anthropic"); +});