diff --git a/packages/feature-ai/migrations/0002_ai_mentor_prefs.sql b/packages/feature-ai/migrations/0002_ai_mentor_prefs.sql new file mode 100644 index 0000000..13da201 --- /dev/null +++ b/packages/feature-ai/migrations/0002_ai_mentor_prefs.sql @@ -0,0 +1,7 @@ +-- v1.1 (contract request 4): per-course mentor toggles get their own table. A row exists only while the learner +-- has switched the course's mentor rules off. v1 kept them in ai_settings.config_json.mentorOff; reading that JSON +-- is not portable SQL, so upgradeAiData (src/upgrade.ts) copies it on first start. +CREATE TABLE ai_mentor_prefs ( + user_id TEXT NOT NULL, course_id TEXT NOT NULL, off INTEGER NOT NULL, updated_at TEXT NOT NULL, + PRIMARY KEY (user_id, course_id) +); diff --git a/packages/feature-ai/src/feature.ts b/packages/feature-ai/src/feature.ts index e283fc1..3582386 100644 --- a/packages/feature-ai/src/feature.ts +++ b/packages/feature-ai/src/feature.ts @@ -145,6 +145,8 @@ export function aiFeature(ctx: ServerContext, opts: AiFeatureOptions = {}): Feat const progress = progressRepo(ctx.db); const sink = opts.draftSink ?? createFsDraftSink(ctx.config.dataDir); const log = opts.log ?? ((m: string) => console.error(`[ai] ${m}`)); + // The v1 data upgrade (mentor toggles, MCP secrets) starts now; every settings call also waits for it. + settings.ready().catch((e) => log(`v1.1 data upgrade failed (retried on next use): ${(e as Error).message}`)); // AppConfig.aiAllowedBaseUrls (STC_AI_ALLOWED_BASE_URLS) plus the admin's list in the server AI settings. const configOrigins = parseAllowedOrigins((ctx.config.aiAllowedBaseUrls ?? []).join(",")); const allowedOrigins = async () => [...new Set([...configOrigins, ...(await settings.getServer()).allowedBaseUrls])]; diff --git a/packages/feature-ai/src/index.ts b/packages/feature-ai/src/index.ts index 66c642d..2582896 100644 --- a/packages/feature-ai/src/index.ts +++ b/packages/feature-ai/src/index.ts @@ -2,6 +2,7 @@ export { createSecretBox, type SecretBox } from "./crypto"; export { migrationsDirs } from "./migrations"; export * from "./settings"; +export { upgradeAiData, AI_DATA_UPGRADE } from "./upgrade"; export * from "./providers"; export * from "./retrieval/course-index"; export { Bm25, tokenize } from "./retrieval/bm25"; diff --git a/packages/feature-ai/src/settings.ts b/packages/feature-ai/src/settings.ts index ecc9776..b0dfa04 100644 --- a/packages/feature-ai/src/settings.ts +++ b/packages/feature-ai/src/settings.ts @@ -1,5 +1,6 @@ import type { Db } from "@stc/db"; import type { SecretBox } from "./crypto"; +import { upgradeAiData } from "./upgrade"; export type ProviderKind = "anthropic" | "openai"; export type Effort = "low" | "medium" | "high" | "xhigh" | "max"; @@ -30,7 +31,7 @@ export interface ServerSettingsInput { export interface UserSettings { byok: PublicProviderConfig | null; mentorOff: Record } type ServerRow = { provider: ProviderConfig | null; apiKeyCipher: string | null; mcpServers: McpServerConfig[]; allowedBaseUrls?: string[] }; -type UserRow = { byok: ProviderConfig | null; mentorOff: Record }; +type UserRow = { byok: ProviderConfig | null }; const pickProvider = (p: ProviderConfig): ProviderConfig => { const out: ProviderConfig = { kind: p.kind }; @@ -46,14 +47,18 @@ const parse = (s: string | undefined, fallback: T): T => { }; /** - * AI settings in the ai_settings and user_api_keys tables of 0001_init.sql. + * 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. - * - ('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). + * - ('user', ) 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. + * 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) { + let upgraded: Promise | null = null; + const ready = () => + (upgraded ??= upgradeAiData(db, box).catch((e) => { upgraded = null; throw e; })); + 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; @@ -68,16 +73,23 @@ export function aiSettingsRepo(db: Db, box: SecretBox) { }); }; 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 userRow = async (userId: string): Promise => { + const { mentorOff: _legacy, ...row } = parse(await read("user", userId), { byok: null } as UserRow & { mentorOff?: unknown }); + return row; + }; 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 { + /** Resolves once the v1 data upgrade is done (aiFeature starts it at construction). */ + ready: async (): Promise => { await ready(); }, async getServer(): Promise { + await ready(); const r = await serverRow(); return { provider: r.provider ? { ...r.provider, hasApiKey: !!r.apiKeyCipher } : null, mcpServers: r.mcpServers ?? [], allowedBaseUrls: r.allowedBaseUrls ?? [] }; }, async saveServer(input: ServerSettingsInput): Promise { + await ready(); const prev = await serverRow(); let apiKeyCipher: string | null = null; if (input.provider) { @@ -92,15 +104,19 @@ export function aiSettingsRepo(db: Db, box: SecretBox) { }); }, async serverApiKey(): Promise { + await ready(); const r = await serverRow(); return r.provider && r.apiKeyCipher ? box.decrypt(r.apiKeyCipher) : null; }, async getUser(userId: string): Promise { + await ready(); const r = await userRow(userId); const k = await keyRow(userId); - return { byok: r.byok && k ? { ...r.byok, hasApiKey: true } : null, mentorOff: r.mentorOff ?? {} }; + const prefs = (await db.sql`SELECT course_id FROM ai_mentor_prefs WHERE user_id = ${userId} AND off = 1`) as { course_id: string }[]; + return { byok: r.byok && k ? { ...r.byok, hasApiKey: true } : null, mentorOff: Object.fromEntries(prefs.map((p) => [p.course_id, true])) }; }, async saveUserKey(userId: string, cfg: ProviderConfig & { apiKey: string }): Promise { + await ready(); const ciphertext = await box.encrypt(cfg.apiKey); const now = new Date().toISOString(); await db.sql.begin(async (tx) => { @@ -111,23 +127,26 @@ export function aiSettingsRepo(db: Db, box: SecretBox) { await write("user", userId, { ...r, byok: pickProvider(cfg) }); }, async removeUserKey(userId: string): Promise { + await ready(); 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 { + await ready(); 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 }); + await ready(); + await db.sql.begin(async (tx) => { + await tx`DELETE FROM ai_mentor_prefs WHERE user_id = ${userId} AND course_id = ${courseId}`; + if (off) await tx`INSERT INTO ai_mentor_prefs (user_id, course_id, off, updated_at) VALUES (${userId}, ${courseId}, 1, ${new Date().toISOString()})`; + }); }, async isMentorOff(userId: string, courseId: string): Promise { - return (await userRow(userId)).mentorOff?.[courseId] === true; + await ready(); + return (await db.sql`SELECT 1 AS x FROM ai_mentor_prefs WHERE user_id = ${userId} AND course_id = ${courseId} AND off = 1`).length > 0; }, }; } diff --git a/packages/feature-ai/src/upgrade.ts b/packages/feature-ai/src/upgrade.ts new file mode 100644 index 0000000..26767c2 --- /dev/null +++ b/packages/feature-ai/src/upgrade.ts @@ -0,0 +1,45 @@ +import type { Db } from "@stc/db"; +import type { SecretBox } from "./crypto"; + +/** 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"; + +type Tx = Bun.TransactionSQL; + +const parseObject = (s: string): Record | null => { + try { + const v = JSON.parse(s); + return v && typeof v === "object" && !Array.isArray(v) ? v : null; + } catch { return null; } +}; + +/** v1 kept per-course toggles in the user row's config_json.mentorOff; move them to ai_mentor_prefs. */ +async function moveMentorToggles(tx: Tx, now: string) { + const rows = (await tx`SELECT owner_id, config_json FROM ai_settings WHERE scope = 'user'`) as { owner_id: string; config_json: string }[]; + for (const r of rows) { + const cfg = parseObject(r.config_json); + if (!cfg || !("mentorOff" in cfg)) continue; + const off = cfg.mentorOff && typeof cfg.mentorOff === "object" ? (cfg.mentorOff as Record) : {}; + for (const [courseId, v] of Object.entries(off)) { + if (v !== true || !courseId) continue; + const exists = await tx`SELECT 1 AS x FROM ai_mentor_prefs WHERE user_id = ${r.owner_id} AND course_id = ${courseId}`; + if (!exists.length) await tx`INSERT INTO ai_mentor_prefs (user_id, course_id, off, updated_at) VALUES (${r.owner_id}, ${courseId}, 1, ${now})`; + } + const { mentorOff: _drop, ...rest } = cfg; + await tx`UPDATE ai_settings SET config_json = ${JSON.stringify(rest)}, updated_at = ${now} WHERE scope = 'user' AND owner_id = ${r.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 { + 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 tx`INSERT INTO schema_migrations (name, applied_at) VALUES (${AI_DATA_UPGRADE}, ${now})`; + return true; + }); +} diff --git a/packages/feature-ai/test/settings.test.ts b/packages/feature-ai/test/settings.test.ts index 3febe6c..6edf375 100644 --- a/packages/feature-ai/test/settings.test.ts +++ b/packages/feature-ai/test/settings.test.ts @@ -61,3 +61,17 @@ forEachDb("mentor mode is on by default and toggles per course", async (db) => { expect(await r.isMentorOff("u1", "sample")).toBe(false); expect((await r.getUser("u1")).byok?.kind).toBe("anthropic"); }); + +forEachDb("mentor toggles are rows in ai_mentor_prefs, not config_json", async (db) => { + const r = aiSettingsRepo(db, box); + await r.saveUserKey("u1", { kind: "anthropic", apiKey: "k" }); + await r.setMentorOff("u1", "sample", true); + await r.setMentorOff("u1", "sample", true); // twice is fine + const rows = await db.sql`SELECT user_id, course_id, off FROM ai_mentor_prefs`; + expect(rows.map((x: { user_id: string; course_id: string; off: number }) => [x.user_id, x.course_id, Number(x.off)])).toEqual([["u1", "sample", 1]]); + const cfg = (await db.sql`SELECT config_json FROM ai_settings WHERE scope = 'user' AND owner_id = 'u1'`)[0].config_json; + expect(cfg).not.toContain("mentorOff"); + expect((await r.getUser("u1")).mentorOff).toEqual({ sample: true }); + await r.setMentorOff("u1", "sample", false); + expect(await db.sql`SELECT user_id FROM ai_mentor_prefs`).toHaveLength(0); +}); diff --git a/packages/feature-ai/test/upgrade.test.ts b/packages/feature-ai/test/upgrade.test.ts index 56fe1a4..dd8ba37 100644 --- a/packages/feature-ai/test/upgrade.test.ts +++ b/packages/feature-ai/test/upgrade.test.ts @@ -2,9 +2,13 @@ import { expect } from "bun:test"; import { migrate, type Db } from "@stc/db"; // Core migrations only: the ai_* tables exactly as v1 shipped them. import { forEachDb as forEachV1Db } from "../../db/test/helpers"; +import { createSecretBox } from "../src/crypto"; import { migrationsDirs } from "../src/migrations"; +import { aiSettingsRepo } from "../src/settings"; +import { upgradeAiData } from "../src/upgrade"; const T = "2026-10-01T00:00:00.000Z"; +const box = createSecretBox("test-secret"); const aiMigrations = { "feature-ai": migrationsDirs }; async function seedMessage(db: Db, id: string, conv: string, role: string, content: string) { @@ -39,3 +43,42 @@ forEachV1Db("v1 upgrade: sources rows fold into citations_json on the assistant expect(byId["0001-a"]).toBeNull(); expect(await migrate(db, aiMigrations)).toEqual([]); }); + +async function seedSettings(db: Db, scope: "server" | "user", owner: string, config: unknown) { + const json = typeof config === "string" ? config : JSON.stringify(config); + await db.sql`INSERT INTO ai_settings (scope, owner_id, mentor_off, config_json, updated_at) VALUES (${scope}, ${owner}, 0, ${json}, ${T})`; +} + +forEachV1Db("v1 upgrade: per-course mentor toggles move from config_json to ai_mentor_prefs", async (db) => { + await seedSettings(db, "user", "u1", { byok: { kind: "anthropic" }, mentorOff: { sample: true, other: false } }); + await seedSettings(db, "user", "u2", { byok: null, mentorOff: {} }); + await seedSettings(db, "user", "u3", "{not json"); + await db.sql`INSERT INTO user_api_keys (user_id, provider, ciphertext, created_at) VALUES ('u1', 'anthropic', ${await box.encrypt("sk-u1")}, ${T})`; + + expect(await migrate(db, aiMigrations)).toContain("0002_ai_mentor_prefs.sql"); + expect(await upgradeAiData(db, box)).toBe(true); + + const prefs = await db.sql`SELECT user_id, course_id, off FROM ai_mentor_prefs ORDER BY user_id, course_id`; + expect(prefs.map((r: { user_id: string; course_id: string; off: number }) => [r.user_id, r.course_id, Number(r.off)])).toEqual([["u1", "sample", 1]]); + const cfg = async (u: string) => (await db.sql`SELECT config_json FROM ai_settings WHERE scope = 'user' AND owner_id = ${u}`)[0].config_json as string; + expect(JSON.parse(await cfg("u1"))).toEqual({ byok: { kind: "anthropic" } }); + expect(JSON.parse(await cfg("u2"))).toEqual({ byok: null }); + expect(await cfg("u3")).toBe("{not json"); + + const repo = aiSettingsRepo(db, box); + expect(await repo.isMentorOff("u1", "sample")).toBe(true); + expect(await repo.isMentorOff("u1", "other")).toBe(false); + expect(await repo.getUser("u1")).toEqual({ byok: { kind: "anthropic", hasApiKey: true }, mentorOff: { sample: true } }); + + // idempotent: the marker stops a second run + expect(await upgradeAiData(db, box)).toBe(false); + expect((await db.sql`SELECT COUNT(*) AS n FROM ai_mentor_prefs`)[0].n.toString()).toBe("1"); + expect((await db.sql`SELECT name FROM schema_migrations WHERE name = 'ai_data_v11'`)).toHaveLength(1); +}); + +forEachV1Db("v1 upgrade: the settings repo upgrades on first use", async (db) => { + await seedSettings(db, "user", "u1", { byok: null, mentorOff: { sample: true } }); + await migrate(db, aiMigrations); + 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 }); +});