feat(ai): settings repository for server config, BYOK keys and mentor toggles
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
e1938b5be8
commit
bccbc692e9
@@ -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";
|
||||
|
||||
@@ -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<string, string>;
|
||||
url?: string;
|
||||
headers?: Record<string, string>;
|
||||
/** 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<string, boolean> }
|
||||
|
||||
type ServerRow = { provider: ProviderConfig | null; apiKeyCipher: string | null; mcpServers: McpServerConfig[] };
|
||||
type UserRow = { byok: ProviderConfig | null; mentorOff: Record<string, boolean> };
|
||||
|
||||
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 = <T>(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', <userId>) 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<string | undefined> => {
|
||||
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<ServerRow> => parse(await read("server", ""), { provider: null, apiKeyCipher: null, mcpServers: [] });
|
||||
const userRow = async (userId: string): Promise<UserRow> => 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<ServerSettings> {
|
||||
const r = await serverRow();
|
||||
return { provider: r.provider ? { ...r.provider, hasApiKey: !!r.apiKeyCipher } : null, mcpServers: r.mcpServers ?? [] };
|
||||
},
|
||||
async saveServer(input: ServerSettingsInput): Promise<void> {
|
||||
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<string | null> {
|
||||
const r = await serverRow();
|
||||
return r.provider && r.apiKeyCipher ? box.decrypt(r.apiKeyCipher) : null;
|
||||
},
|
||||
async getUser(userId: string): Promise<UserSettings> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<string | null> {
|
||||
const k = await keyRow(userId);
|
||||
return k ? box.decrypt(k.ciphertext) : null;
|
||||
},
|
||||
async setMentorOff(userId: string, courseId: string, off: boolean): Promise<void> {
|
||||
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<boolean> {
|
||||
return (await userRow(userId)).mentorOff?.[courseId] === true;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export type AiSettingsRepo = ReturnType<typeof aiSettingsRepo>;
|
||||
@@ -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");
|
||||
});
|
||||
Reference in New Issue
Block a user