import { Api, GlobalSettings, type GlobalSettingsUpdate, mergeSettings, type SecretPurpose, type SettingsKey, } from "@umt/contracts"; import { ApiException } from "../../http/errors.ts"; import type { ModuleApp, ServerContext } from "../types.ts"; import { audit, endpoint } from "./endpoint.ts"; import { type CachedSettingsReader, SETTINGS_CHANGED_EVENT } from "./provided.ts"; // GET/PATCH /settings:按分组部分更新 → mergeSettings → GlobalSettings 整体校验 → 逐组 upsert; // TMDB 密钥(metadata.tmdbApiKey)与网页搜索 Key(agent.webSearch.apiKey)只写,存为 secret,设置里只留 secret 引用(Agent 上游密钥在 pi_upstream, // 旧 pi.apiKey 已弃用,PATCH 里会被 schema 丢弃);变更经 SSE 总线广播 settings.changed, // 各进程的 ctx.settings 缓存随之失效。自动化总开关 automation.enabled / 只读对账 automation.readOnlyReconcile 也走这里。 const isCached = (s: unknown): s is CachedSettingsReader => typeof (s as CachedSettingsReader)?.invalidate === "function"; export function registerSettingsRoutes(app: ModuleApp, ctx: ServerContext) { endpoint(app, { ...Api.settings.get, tags: ["settings"] }, async () => { if (isCached(ctx.settings)) return await ctx.settings.fresh(); return await ctx.settings.all(); }); endpoint(app, { ...Api.settings.update, tags: ["settings"] }, async ({ body, actor }) => { const patch = body as GlobalSettingsUpdate; const current = isCached(ctx.settings) ? await ctx.settings.fresh() : await ctx.settings.all(); const merged = mergeSettings(current, patch); // 只写密钥:存 secret,设置里只留 secret 引用 const slots = secretSlots(patch); for (const sl of slots) if (sl.plaintext === null) sl.set(merged, null); const parsed = GlobalSettings.safeParse(merged); if (!parsed.success) { const msg = parsed.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join("; "); throw new ApiException("validation_failed", msg); } const next = parsed.data; const created: string[] = []; const dropped: string[] = []; for (const sl of slots) { const oldRef = sl.get(current); if (sl.plaintext === null) { if (oldRef) dropped.push(oldRef); } else if (oldRef) { await ctx.secrets.replace(oldRef, sl.plaintext); sl.set(next, oldRef); } else { const ref = await ctx.secrets.create({ purpose: sl.purpose, plaintext: sl.plaintext, label: sl.label }); created.push(ref); sl.set(next, ref); } } const keys = (Object.keys(patch) as SettingsKey[]).filter((k) => patch[k] !== undefined); try { await ctx.db.transaction().execute(async (trx) => { for (const key of keys) { await trx .insertInto("settings") .values({ key, value: JSON.stringify(next[key]), updated_at: new Date() }) .onConflict((oc) => oc.column("key").doUpdateSet({ value: JSON.stringify(next[key]), updated_at: new Date() }), ) .execute(); } }); } catch (err) { for (const ref of created) await ctx.secrets.remove(ref).catch(() => {}); throw err; } for (const ref of dropped) await ctx.secrets.remove(ref).catch(() => {}); if (isCached(ctx.settings)) ctx.settings.invalidate(); void ctx.events.publish(SETTINGS_CHANGED_EVENT, { keys }); // 审计里不留明文密钥 const logged: Record = { ...patch }; if (patch.metadata && "tmdbApiKey" in patch.metadata) logged.metadata = { ...patch.metadata, tmdbApiKey: patch.metadata.tmdbApiKey === null ? "(cleared)" : "(set)" }; const ws = patch.agent?.webSearch; if (ws && "apiKey" in ws) logged.agent = { ...patch.agent, webSearch: { ...ws, apiKey: ws.apiKey === null ? "(cleared)" : "(set)" } }; await audit(ctx, actor, "settings.update", { type: "settings", id: keys.join(",") }, { patch: logged }); return next; }); } interface SecretSlot { plaintext: string | null; purpose: SecretPurpose; label: string; get(s: GlobalSettings): string | null; set(s: GlobalSettings, ref: string | null): void; } /** PATCH 里出现的只写密钥字段(undefined = 不改;null = 清除;字符串 = 设置) */ function secretSlots(patch: GlobalSettingsUpdate): SecretSlot[] { const out: SecretSlot[] = []; const tmdb = patch.metadata?.tmdbApiKey; if (tmdb !== undefined) out.push({ plaintext: tmdb, purpose: "metadata_api_key", label: "tmdb", get: (s) => s.metadata.tmdbApiKeySecretRef, set: (s, ref) => { s.metadata.tmdbApiKeySecretRef = ref; }, }); const ws = patch.agent?.webSearch?.apiKey; if (ws !== undefined) out.push({ plaintext: ws, purpose: "other", label: "websearch", get: (s) => s.agent.webSearch?.apiKeySecretRef ?? null, set: (s, ref) => { s.agent.webSearch.apiKeySecretRef = ref; }, }); return out; }