diff --git a/lib/db.ts b/lib/db.ts index 9ddcdda..80ce274 100644 --- a/lib/db.ts +++ b/lib/db.ts @@ -62,10 +62,14 @@ export const getSettings = (userId: string) => one("SELECT * FROM settings WHERE user_id = ?", userId); export function saveSettings(userId: string, s: SettingsInput) { + // A blank secret means "keep what is stored" — enforced here, not only in the form layer, so no caller + // (password change, onboarding re-save, a future import) can blank a stored key by omitting it. db.prepare(`INSERT INTO settings (user_id, analyst_base_url, analyst_key, analyst_model, analyst_extra, jev_key, jev_model, onboarded, updated_at) VALUES (?,?,?,?,?,?,?,?,?) - ON CONFLICT(user_id) DO UPDATE SET analyst_base_url=excluded.analyst_base_url, analyst_key=excluded.analyst_key, - analyst_model=excluded.analyst_model, analyst_extra=excluded.analyst_extra, jev_key=excluded.jev_key, + ON CONFLICT(user_id) DO UPDATE SET analyst_base_url=excluded.analyst_base_url, + analyst_key=COALESCE(NULLIF(excluded.analyst_key, ''), settings.analyst_key), + analyst_model=excluded.analyst_model, analyst_extra=excluded.analyst_extra, + jev_key=COALESCE(NULLIF(excluded.jev_key, ''), settings.jev_key), jev_model=excluded.jev_model, onboarded=excluded.onboarded, updated_at=excluded.updated_at`) .run(userId, s.analyst_base_url, s.analyst_key, s.analyst_model, s.analyst_extra, s.jev_key, s.jev_model, s.onboarded, now()); diff --git a/lib/settings.ts b/lib/settings.ts new file mode 100644 index 0000000..5ffaa17 --- /dev/null +++ b/lib/settings.ts @@ -0,0 +1,45 @@ +import { getSettings, saveSettings, type SettingsInput } from "./db.ts"; +import { seal, open, mask } from "./vault.ts"; + +export type AnalystConfig = { baseUrl: string; apiKey: string; model: string; extra: Record }; +const str = (fd: FormData, k: string) => String(fd.get(k) ?? "").trim(); +const kept = (fd: FormData, k: string, existing: string) => { const v = str(fd, k); return v ? seal(v) : existing; }; + +export function saveSettingsForm(userId: string, fd: FormData, onboarded = 1) { + const cur = getSettings(userId); + let extra = str(fd, "analyst_extra") || "{}"; + try { JSON.parse(extra); } catch { extra = "{}"; } + saveSettings(userId, { + analyst_base_url: str(fd, "analyst_base_url").replace(/\/+$/, ""), + analyst_key: kept(fd, "analyst_key", cur?.analyst_key ?? ""), + analyst_model: str(fd, "analyst_model"), + analyst_extra: extra, + jev_key: kept(fd, "jev_key", cur?.jev_key ?? ""), + jev_model: str(fd, "jev_model") || "jev-latest", + onboarded: cur?.onboarded === 1 ? 1 : onboarded, + } satisfies SettingsInput); +} +export function analystConfig(userId: string): AnalystConfig | null { + const s = getSettings(userId); + const baseUrl = s?.analyst_base_url ?? ""; + const apiKey = open(s?.analyst_key ?? ""); + const model = s?.analyst_model ?? ""; + if (!baseUrl || !apiKey || !model) return null; + let extra: Record = {}; + try { extra = JSON.parse(s!.analyst_extra) as Record; } catch { /* bad JSON means no extra params */ } + return { baseUrl, apiKey, model, extra }; +} +export const jevKey = (userId: string) => open(getSettings(userId)?.jev_key ?? ""); +export const jevModel = (userId: string) => getSettings(userId)?.jev_model || "jev-latest"; +export function settingsView(userId: string) { + const s = getSettings(userId); + const analystKey = open(s?.analyst_key ?? ""); + const jev = open(s?.jev_key ?? ""); + return { + analyst_base_url: s?.analyst_base_url ?? "", analyst_model: s?.analyst_model ?? "", + analyst_extra: s?.analyst_extra ?? "{}", jev_model: s?.jev_model ?? "jev-latest", + analyst_key_masked: mask(analystKey), jev_key_masked: mask(jev), + has_analyst: !!(s?.analyst_base_url && s?.analyst_model && analystKey), has_jev: !!jev, + }; +} +export type SettingsView = ReturnType; diff --git a/lib/vault.ts b/lib/vault.ts new file mode 100644 index 0000000..7b34308 --- /dev/null +++ b/lib/vault.ts @@ -0,0 +1,24 @@ +import { createCipheriv, createDecipheriv, randomBytes, scryptSync } from "node:crypto"; + +function key() { + const s = process.env.APP_SECRET; + if (!s || s.length < 16) throw new Error("APP_SECRET is missing or shorter than 16 chars — set it in .env"); + return scryptSync(s, "qai-vault-v1", 32); +} +export function seal(plain: string): string { + if (!plain) return ""; + const iv = randomBytes(12); + const c = createCipheriv("aes-256-gcm", key(), iv); + const ct = Buffer.concat([c.update(plain, "utf8"), c.final()]); + return `${iv.toString("hex")}.${c.getAuthTag().toString("hex")}.${ct.toString("hex")}`; +} +export function open(sealed: string): string { + if (!sealed) return ""; + try { + const [iv, tag, ct] = sealed.split("."); + const d = createDecipheriv("aes-256-gcm", key(), Buffer.from(iv, "hex"), { authTagLength: 16 }); + d.setAuthTag(Buffer.from(tag, "hex")); + return Buffer.concat([d.update(Buffer.from(ct, "hex")), d.final()]).toString("utf8"); + } catch { return ""; } +} +export const mask = (v: string) => (v ? `••••${v.slice(-4)}` : ""); diff --git a/tests/vault.test.ts b/tests/vault.test.ts new file mode 100644 index 0000000..655a2a3 --- /dev/null +++ b/tests/vault.test.ts @@ -0,0 +1,48 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +process.env.APP_SECRET = "unit-test-secret-value"; +process.env.DATA_DIR = mkdtempSync(`${tmpdir()}/qai-vault-`); +const { seal, open, mask } = await import("../lib/vault.ts"); +const db = await import("../lib/db.ts"); +const { saveSettingsForm, analystConfig, settingsView, jevKey } = await import("../lib/settings.ts"); + +test("seal/open round-trips; junk and tamper yield empty", () => { + assert.equal(open(seal("sk-abc123")), "sk-abc123"); + assert.equal(open("deadbeef.00.zzz"), ""); + assert.equal(open(""), ""); + assert.equal(mask("sk-abcdefgh1234"), "••••1234"); + assert.equal(mask(""), ""); +}); + +test("blank secret keeps the stored key", () => { + const u = db.createUser("v@x.y", "h"); + const fd = (o: Record) => { const f = new FormData(); + Object.entries(o).forEach(([k, v]) => f.set(k, v)); return f; }; + saveSettingsForm(u, fd({ analyst_base_url: "https://api/x/v1/", analyst_key: "sk-1111", analyst_model: "gpt", + analyst_extra: '{"temperature":0.2}', jev_key: "ts-1", jev_model: "jev-latest" })); + assert.equal(analystConfig(u)?.apiKey, "sk-1111"); + + saveSettingsForm(u, fd({ analyst_base_url: "https://api/y/v1", analyst_key: "", analyst_model: "gpt", + analyst_extra: "not json", jev_key: "", jev_model: "" })); + const c = analystConfig(u)!; + assert.equal(c.baseUrl, "https://api/y/v1", "trailing slash trimmed"); + assert.equal(c.apiKey, "sk-1111", "blank must not wipe the key"); + assert.deepEqual(c.extra, {}, "bad JSON falls back to empty params"); + assert.equal(jevKey(u), "ts-1"); + assert.equal(settingsView(u).has_analyst, true); + assert.equal(settingsView(u).analyst_key_masked, "••••1111"); + assert.equal((settingsView(u) as Record).analyst_key, undefined, "no raw key to the client"); +}); + +test("saveSettings never blanks a stored secret, whoever calls it", () => { + const u = db.createUser("k@x.y", "h"); + const base = { analyst_base_url: "b", analyst_key: "sealed", analyst_model: "m", analyst_extra: "{}", + jev_key: "jevsealed", jev_model: "j", onboarded: 1 }; + db.saveSettings(u, base); + db.saveSettings(u, { ...base, analyst_key: "", jev_key: "" }); + assert.equal(db.getSettings(u)?.analyst_key, "sealed"); + assert.equal(db.getSettings(u)?.jev_key, "jevsealed"); + assert.equal(db.getSettings(u)?.analyst_base_url, "b", "non-secret fields still update"); +});