refactor(settings): adopt environment-only runtime secret flow
This commit is contained in:
@@ -618,10 +618,12 @@ describe("assistant router tool gating", () => {
|
||||
const adminNames = getToolNames([], SystemRole.ADMIN);
|
||||
const userNames = getToolNames([], SystemRole.USER);
|
||||
|
||||
const managerNames = getToolNames([], SystemRole.MANAGER);
|
||||
expect(adminNames).toContain("get_system_settings");
|
||||
expect(adminNames).toContain("update_system_settings");
|
||||
expect(adminNames).toContain("test_ai_connection");
|
||||
expect(adminNames).toContain("test_smtp_connection");
|
||||
expect(adminNames).toContain("clear_stored_runtime_secrets");
|
||||
expect(adminNames).toContain("test_gemini_connection");
|
||||
expect(adminNames).toContain("list_system_role_configs");
|
||||
expect(adminNames).toContain("update_system_role_config");
|
||||
@@ -632,12 +634,22 @@ describe("assistant router tool gating", () => {
|
||||
expect(adminNames).toContain("delete_webhook");
|
||||
expect(adminNames).toContain("test_webhook");
|
||||
expect(adminNames).toContain("get_ai_configured");
|
||||
expect(adminNames).toContain("list_system_role_configs");
|
||||
|
||||
expect(managerNames).not.toContain("get_system_settings");
|
||||
expect(managerNames).not.toContain("update_system_settings");
|
||||
expect(managerNames).not.toContain("clear_stored_runtime_secrets");
|
||||
expect(managerNames).not.toContain("test_ai_connection");
|
||||
expect(managerNames).not.toContain("get_ai_configured");
|
||||
expect(managerNames).not.toContain("list_system_role_configs");
|
||||
expect(managerNames).not.toContain("update_system_role_config");
|
||||
expect(managerNames).not.toContain("list_webhooks");
|
||||
expect(managerNames).not.toContain("create_webhook");
|
||||
|
||||
expect(userNames).not.toContain("get_system_settings");
|
||||
expect(userNames).not.toContain("update_system_settings");
|
||||
expect(userNames).not.toContain("test_ai_connection");
|
||||
expect(userNames).not.toContain("get_ai_configured");
|
||||
expect(userNames).not.toContain("clear_stored_runtime_secrets");
|
||||
expect(userNames).not.toContain("list_system_role_configs");
|
||||
expect(userNames).not.toContain("update_system_role_config");
|
||||
expect(userNames).not.toContain("list_webhooks");
|
||||
@@ -996,6 +1008,8 @@ describe("assistant router tool gating", () => {
|
||||
expect(toolDescriptions.get("update_system_settings")).toContain("Always confirm first");
|
||||
expect(toolDescriptions.get("get_ai_configured")).toContain("Admin role");
|
||||
expect(toolDescriptions.get("list_system_role_configs")).toContain("Admin role");
|
||||
expect(toolDescriptions.get("update_system_settings")).toContain("Runtime secrets must be provisioned");
|
||||
expect(toolDescriptions.get("clear_stored_runtime_secrets")).toContain("deployment secret management");
|
||||
expect(toolDescriptions.get("update_system_role_config")).toContain("Admin role");
|
||||
expect(toolDescriptions.get("list_webhooks")).toContain("Secrets are masked");
|
||||
expect(toolDescriptions.get("create_webhook")).toContain("Always confirm first");
|
||||
|
||||
@@ -150,6 +150,7 @@ function createToolContext(
|
||||
describe("assistant import/export and dispo tools", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.unstubAllEnvs();
|
||||
apiRateLimiter.reset();
|
||||
totpValidateMock.mockReset();
|
||||
vi.mocked(approveEstimateVersion).mockReset();
|
||||
@@ -288,6 +289,67 @@ describe("assistant import/export and dispo tools", () => {
|
||||
expect(JSON.parse(result.content)).toEqual({ configured: true });
|
||||
});
|
||||
|
||||
it("treats environment-backed AI configuration as configured for assistant checks", async () => {
|
||||
vi.stubEnv("OPENAI_API_KEY", "env-secret");
|
||||
|
||||
const ctx = createToolContext(
|
||||
{
|
||||
systemSettings: {
|
||||
findUnique: vi.fn().mockResolvedValue({
|
||||
aiProvider: "openai",
|
||||
azureOpenAiDeployment: "gpt-4o-mini",
|
||||
azureOpenAiApiKey: null,
|
||||
}),
|
||||
},
|
||||
},
|
||||
{ userRole: SystemRole.USER },
|
||||
);
|
||||
|
||||
const result = await executeTool("get_ai_configured", "{}", ctx);
|
||||
|
||||
expect(JSON.parse(result.content)).toEqual({ configured: true });
|
||||
});
|
||||
|
||||
it("clears legacy runtime secrets through the real settings router path", async () => {
|
||||
const findUnique = vi.fn().mockResolvedValue({
|
||||
azureOpenAiApiKey: "db-openai",
|
||||
azureDalleApiKey: null,
|
||||
geminiApiKey: "db-gemini",
|
||||
smtpPassword: null,
|
||||
anonymizationSeed: "db-seed",
|
||||
});
|
||||
const update = vi.fn().mockResolvedValue({ id: "singleton" });
|
||||
const auditCreate = vi.fn().mockResolvedValue(undefined);
|
||||
const ctx = createToolContext(
|
||||
{
|
||||
systemSettings: {
|
||||
findUnique,
|
||||
update,
|
||||
},
|
||||
auditLog: {
|
||||
create: auditCreate,
|
||||
},
|
||||
},
|
||||
{ userRole: SystemRole.ADMIN },
|
||||
);
|
||||
|
||||
const result = await executeTool("clear_stored_runtime_secrets", "{}", ctx);
|
||||
|
||||
expect(JSON.parse(result.content)).toEqual({
|
||||
ok: true,
|
||||
clearedFields: ["azureOpenAiApiKey", "geminiApiKey", "anonymizationSeed"],
|
||||
});
|
||||
expect(update).toHaveBeenCalledWith({
|
||||
where: { id: "singleton" },
|
||||
data: {
|
||||
azureOpenAiApiKey: null,
|
||||
geminiApiKey: null,
|
||||
anonymizationSeed: null,
|
||||
},
|
||||
});
|
||||
expect(auditCreate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("masks webhook secrets in assistant responses", async () => {
|
||||
const ctx = createToolContext(
|
||||
{
|
||||
|
||||
@@ -118,6 +118,9 @@ describe("runtime config hardening", () => {
|
||||
expect(result.hasApiKey).toBe(true);
|
||||
expect(result.hasSmtpPassword).toBe(true);
|
||||
expect(result.hasGeminiApiKey).toBe(true);
|
||||
expect(result.runtimeSecrets.azureOpenAiApiKey.activeSource).toBe("environment");
|
||||
expect(result.runtimeSecrets.smtpPassword.activeSource).toBe("environment");
|
||||
expect(result.legacyStoredSecretFields).toEqual([]);
|
||||
});
|
||||
|
||||
it("prefers environment API keys during AI connection tests", async () => {
|
||||
@@ -150,4 +153,81 @@ describe("runtime config hardening", () => {
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not persist incoming secret fields through updateSystemSettings", async () => {
|
||||
const findUnique = vi.fn().mockResolvedValue(null);
|
||||
const upsert = vi.fn().mockResolvedValue({});
|
||||
const caller = createAdminCaller({
|
||||
systemSettings: {
|
||||
findUnique,
|
||||
upsert,
|
||||
},
|
||||
});
|
||||
|
||||
const result = await caller.updateSystemSettings({
|
||||
azureOpenAiApiKey: "sk-should-not-store",
|
||||
smtpPassword: "smtp-should-not-store",
|
||||
geminiApiKey: "gemini-should-not-store",
|
||||
azureDalleApiKey: "dalle-should-not-store",
|
||||
anonymizationSeed: "seed-should-not-store",
|
||||
aiProvider: "openai",
|
||||
azureOpenAiDeployment: "gpt-4o-mini",
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
ignoredSecretFields: [
|
||||
"azureOpenAiApiKey",
|
||||
"smtpPassword",
|
||||
"anonymizationSeed",
|
||||
"azureDalleApiKey",
|
||||
"geminiApiKey",
|
||||
],
|
||||
secretStorageMode: "environment-only",
|
||||
});
|
||||
expect(upsert).toHaveBeenCalledWith({
|
||||
where: { id: "singleton" },
|
||||
create: {
|
||||
id: "singleton",
|
||||
aiProvider: "openai",
|
||||
azureOpenAiDeployment: "gpt-4o-mini",
|
||||
},
|
||||
update: {
|
||||
aiProvider: "openai",
|
||||
azureOpenAiDeployment: "gpt-4o-mini",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("can clear legacy runtime secrets from database storage", async () => {
|
||||
const findUnique = vi.fn().mockResolvedValue({
|
||||
azureOpenAiApiKey: "db-key",
|
||||
azureDalleApiKey: null,
|
||||
geminiApiKey: "db-gemini",
|
||||
smtpPassword: "db-smtp",
|
||||
anonymizationSeed: null,
|
||||
});
|
||||
const update = vi.fn().mockResolvedValue({});
|
||||
const caller = createAdminCaller({
|
||||
systemSettings: {
|
||||
findUnique,
|
||||
update,
|
||||
},
|
||||
});
|
||||
|
||||
const result = await caller.clearStoredRuntimeSecrets();
|
||||
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
clearedFields: ["azureOpenAiApiKey", "geminiApiKey", "smtpPassword"],
|
||||
});
|
||||
expect(update).toHaveBeenCalledWith({
|
||||
where: { id: "singleton" },
|
||||
data: {
|
||||
azureOpenAiApiKey: null,
|
||||
geminiApiKey: null,
|
||||
smtpPassword: null,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { resolveSystemSettingsRuntime } from "../lib/system-settings-runtime.js";
|
||||
import { getRuntimeSecretStatuses, resolveSystemSettingsRuntime } from "../lib/system-settings-runtime.js";
|
||||
|
||||
describe("system settings runtime resolution", () => {
|
||||
afterEach(() => {
|
||||
@@ -39,4 +39,27 @@ describe("system settings runtime resolution", () => {
|
||||
|
||||
expect(settings.smtpPassword).toBe("db-password");
|
||||
});
|
||||
|
||||
it("reports active source and legacy DB presence separately", () => {
|
||||
vi.stubEnv("OPENAI_API_KEY", "env-openai-key");
|
||||
|
||||
const statuses = getRuntimeSecretStatuses({
|
||||
aiProvider: "openai",
|
||||
azureOpenAiApiKey: "db-key",
|
||||
smtpPassword: "db-password",
|
||||
});
|
||||
|
||||
expect(statuses.azureOpenAiApiKey).toEqual({
|
||||
configured: true,
|
||||
activeSource: "environment",
|
||||
hasStoredValue: true,
|
||||
envVarNames: ["OPENAI_API_KEY", "AZURE_OPENAI_API_KEY"],
|
||||
});
|
||||
expect(statuses.smtpPassword).toEqual({
|
||||
configured: true,
|
||||
activeSource: "database",
|
||||
hasStoredValue: true,
|
||||
envVarNames: ["SMTP_PASSWORD"],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,6 +7,23 @@ type RuntimeAwareSystemSettings = {
|
||||
anonymizationSeed?: string | null;
|
||||
};
|
||||
|
||||
export const RUNTIME_SECRET_FIELDS = [
|
||||
"azureOpenAiApiKey",
|
||||
"azureDalleApiKey",
|
||||
"geminiApiKey",
|
||||
"smtpPassword",
|
||||
"anonymizationSeed",
|
||||
] as const;
|
||||
|
||||
export type RuntimeSecretField = (typeof RUNTIME_SECRET_FIELDS)[number];
|
||||
|
||||
export type RuntimeSecretStatus = {
|
||||
configured: boolean;
|
||||
activeSource: "environment" | "database" | "none";
|
||||
hasStoredValue: boolean;
|
||||
envVarNames: string[];
|
||||
};
|
||||
|
||||
function readEnvOverride(...names: string[]): string | null {
|
||||
for (const name of names) {
|
||||
const value = process.env[name]?.trim();
|
||||
@@ -26,16 +43,92 @@ function resolvePrimaryAiApiKey(provider: string | null | undefined): string | n
|
||||
return readEnvOverride("OPENAI_API_KEY", "AZURE_OPENAI_API_KEY");
|
||||
}
|
||||
|
||||
function getPrimaryAiEnvVarNames(provider: string | null | undefined): string[] {
|
||||
if (provider === "azure") {
|
||||
return ["AZURE_OPENAI_API_KEY", "OPENAI_API_KEY"];
|
||||
}
|
||||
|
||||
return ["OPENAI_API_KEY", "AZURE_OPENAI_API_KEY"];
|
||||
}
|
||||
|
||||
function resolveSecretEnvOverride(
|
||||
field: RuntimeSecretField,
|
||||
provider: string | null | undefined,
|
||||
): string | null {
|
||||
if (field === "azureOpenAiApiKey") {
|
||||
return resolvePrimaryAiApiKey(provider);
|
||||
}
|
||||
if (field === "azureDalleApiKey") {
|
||||
return readEnvOverride("AZURE_DALLE_API_KEY");
|
||||
}
|
||||
if (field === "geminiApiKey") {
|
||||
return readEnvOverride("GEMINI_API_KEY");
|
||||
}
|
||||
if (field === "smtpPassword") {
|
||||
return readEnvOverride("SMTP_PASSWORD");
|
||||
}
|
||||
|
||||
return readEnvOverride("ANONYMIZATION_SEED");
|
||||
}
|
||||
|
||||
function getSecretEnvVarNames(
|
||||
field: RuntimeSecretField,
|
||||
provider: string | null | undefined,
|
||||
): string[] {
|
||||
if (field === "azureOpenAiApiKey") {
|
||||
return getPrimaryAiEnvVarNames(provider);
|
||||
}
|
||||
if (field === "azureDalleApiKey") {
|
||||
return ["AZURE_DALLE_API_KEY"];
|
||||
}
|
||||
if (field === "geminiApiKey") {
|
||||
return ["GEMINI_API_KEY"];
|
||||
}
|
||||
if (field === "smtpPassword") {
|
||||
return ["SMTP_PASSWORD"];
|
||||
}
|
||||
|
||||
return ["ANONYMIZATION_SEED"];
|
||||
}
|
||||
|
||||
export function getRuntimeSecretStatuses(
|
||||
settings: RuntimeAwareSystemSettings | null | undefined,
|
||||
): Record<RuntimeSecretField, RuntimeSecretStatus> {
|
||||
const provider = settings?.aiProvider;
|
||||
|
||||
return Object.fromEntries(
|
||||
RUNTIME_SECRET_FIELDS.map((field) => {
|
||||
const envValue = resolveSecretEnvOverride(field, provider);
|
||||
const storedValue = settings?.[field]?.trim() || null;
|
||||
const activeSource = envValue
|
||||
? "environment"
|
||||
: storedValue
|
||||
? "database"
|
||||
: "none";
|
||||
|
||||
return [
|
||||
field,
|
||||
{
|
||||
configured: !!(envValue || storedValue),
|
||||
activeSource,
|
||||
hasStoredValue: !!storedValue,
|
||||
envVarNames: getSecretEnvVarNames(field, provider),
|
||||
} satisfies RuntimeSecretStatus,
|
||||
];
|
||||
}),
|
||||
) as Record<RuntimeSecretField, RuntimeSecretStatus>;
|
||||
}
|
||||
|
||||
export function resolveSystemSettingsRuntime<T extends RuntimeAwareSystemSettings>(
|
||||
settings: T | null | undefined,
|
||||
): T & Required<Pick<RuntimeAwareSystemSettings, "azureOpenAiApiKey" | "azureDalleApiKey" | "geminiApiKey" | "smtpPassword" | "anonymizationSeed">> {
|
||||
const resolved = { ...(settings ?? {}) } as T & Required<Pick<RuntimeAwareSystemSettings, "azureOpenAiApiKey" | "azureDalleApiKey" | "geminiApiKey" | "smtpPassword" | "anonymizationSeed">>;
|
||||
|
||||
resolved.azureOpenAiApiKey = resolvePrimaryAiApiKey(resolved.aiProvider) ?? settings?.azureOpenAiApiKey ?? null;
|
||||
resolved.azureDalleApiKey = readEnvOverride("AZURE_DALLE_API_KEY") ?? settings?.azureDalleApiKey ?? null;
|
||||
resolved.geminiApiKey = readEnvOverride("GEMINI_API_KEY") ?? settings?.geminiApiKey ?? null;
|
||||
resolved.smtpPassword = readEnvOverride("SMTP_PASSWORD") ?? settings?.smtpPassword ?? null;
|
||||
resolved.anonymizationSeed = readEnvOverride("ANONYMIZATION_SEED") ?? settings?.anonymizationSeed ?? null;
|
||||
resolved.azureOpenAiApiKey = resolveSecretEnvOverride("azureOpenAiApiKey", resolved.aiProvider) ?? settings?.azureOpenAiApiKey ?? null;
|
||||
resolved.azureDalleApiKey = resolveSecretEnvOverride("azureDalleApiKey", resolved.aiProvider) ?? settings?.azureDalleApiKey ?? null;
|
||||
resolved.geminiApiKey = resolveSecretEnvOverride("geminiApiKey", resolved.aiProvider) ?? settings?.geminiApiKey ?? null;
|
||||
resolved.smtpPassword = resolveSecretEnvOverride("smtpPassword", resolved.aiProvider) ?? settings?.smtpPassword ?? null;
|
||||
resolved.anonymizationSeed = resolveSecretEnvOverride("anonymizationSeed", resolved.aiProvider) ?? settings?.anonymizationSeed ?? null;
|
||||
|
||||
return resolved;
|
||||
}
|
||||
|
||||
@@ -76,12 +76,14 @@ import { insightsRouter } from "./insights.js";
|
||||
import { scenarioRouter } from "./scenario.js";
|
||||
import { allocationRouter } from "./allocation.js";
|
||||
import { staffingRouter } from "./staffing.js";
|
||||
import { resolveSystemSettingsRuntime } from "../lib/system-settings-runtime.js";
|
||||
|
||||
// ─── Mutation tool set for audit logging (EGAI 4.1.3.1 / IAAI 3.6.26) ──────
|
||||
|
||||
export const MUTATION_TOOLS = new Set([
|
||||
"import_csv_data",
|
||||
"update_system_settings",
|
||||
"clear_stored_runtime_secrets",
|
||||
"test_ai_connection",
|
||||
"test_smtp_connection",
|
||||
"test_gemini_connection",
|
||||
@@ -4772,14 +4774,13 @@ export const TOOL_DEFINITIONS: ToolDef[] = [
|
||||
type: "function",
|
||||
function: {
|
||||
name: "update_system_settings",
|
||||
description: "Update system settings through the real settings router. Admin role required. Always confirm first.",
|
||||
description: "Update non-secret system settings through the real settings router. Runtime secrets must be provisioned via deployment environment or secret manager. Admin role required. Always confirm first.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
aiProvider: { type: "string", enum: ["openai", "azure"] },
|
||||
azureOpenAiEndpoint: { type: "string" },
|
||||
azureOpenAiDeployment: { type: "string" },
|
||||
azureOpenAiApiKey: { type: "string" },
|
||||
azureApiVersion: { type: "string" },
|
||||
aiMaxCompletionTokens: { type: "integer" },
|
||||
aiTemperature: { type: "number" },
|
||||
@@ -4789,17 +4790,13 @@ export const TOOL_DEFINITIONS: ToolDef[] = [
|
||||
smtpHost: { type: "string" },
|
||||
smtpPort: { type: "integer" },
|
||||
smtpUser: { type: "string" },
|
||||
smtpPassword: { type: "string" },
|
||||
smtpFrom: { type: "string" },
|
||||
smtpTls: { type: "boolean" },
|
||||
anonymizationEnabled: { type: "boolean" },
|
||||
anonymizationDomain: { type: "string" },
|
||||
anonymizationSeed: { type: "string" },
|
||||
anonymizationMode: { type: "string", enum: ["global"] },
|
||||
azureDalleDeployment: { type: "string" },
|
||||
azureDalleEndpoint: { type: "string" },
|
||||
azureDalleApiKey: { type: "string" },
|
||||
geminiApiKey: { type: "string" },
|
||||
geminiModel: { type: "string" },
|
||||
imageProvider: { type: "string", enum: ["dalle", "gemini"] },
|
||||
vacationDefaultDays: { type: "integer" },
|
||||
@@ -4809,6 +4806,17 @@ export const TOOL_DEFINITIONS: ToolDef[] = [
|
||||
},
|
||||
},
|
||||
{
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "clear_stored_runtime_secrets",
|
||||
description: "Clear legacy database-stored runtime secrets after they have been migrated to deployment secret management. Admin role required. Always confirm first.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
type: "function",
|
||||
function: {
|
||||
name: "test_ai_connection",
|
||||
@@ -9306,7 +9314,6 @@ const executors = {
|
||||
aiProvider?: "openai" | "azure";
|
||||
azureOpenAiEndpoint?: string;
|
||||
azureOpenAiDeployment?: string;
|
||||
azureOpenAiApiKey?: string;
|
||||
azureApiVersion?: string;
|
||||
aiMaxCompletionTokens?: number;
|
||||
aiTemperature?: number;
|
||||
@@ -9322,17 +9329,13 @@ const executors = {
|
||||
smtpHost?: string;
|
||||
smtpPort?: number;
|
||||
smtpUser?: string;
|
||||
smtpPassword?: string;
|
||||
smtpFrom?: string;
|
||||
smtpTls?: boolean;
|
||||
anonymizationEnabled?: boolean;
|
||||
anonymizationDomain?: string;
|
||||
anonymizationSeed?: string;
|
||||
anonymizationMode?: "global";
|
||||
azureDalleDeployment?: string;
|
||||
azureDalleEndpoint?: string;
|
||||
azureDalleApiKey?: string;
|
||||
geminiApiKey?: string;
|
||||
geminiModel?: string;
|
||||
imageProvider?: "dalle" | "gemini";
|
||||
vacationDefaultDays?: number;
|
||||
@@ -9342,6 +9345,11 @@ const executors = {
|
||||
return caller.updateSystemSettings(params);
|
||||
},
|
||||
|
||||
async clear_stored_runtime_secrets(_params: Record<string, never>, ctx: ToolContext) {
|
||||
const caller = createSettingsCaller(createScopedCallerContext(ctx));
|
||||
return caller.clearStoredRuntimeSecrets();
|
||||
},
|
||||
|
||||
async test_ai_connection(_params: Record<string, never>, ctx: ToolContext) {
|
||||
const caller = createSettingsCaller(createScopedCallerContext(ctx));
|
||||
return caller.testAiConnection();
|
||||
@@ -9358,7 +9366,7 @@ const executors = {
|
||||
},
|
||||
|
||||
async get_ai_configured(_params: Record<string, never>, ctx: ToolContext) {
|
||||
const settings = await ctx.db.systemSettings.findUnique({
|
||||
const settings = resolveSystemSettingsRuntime(await ctx.db.systemSettings.findUnique({
|
||||
where: { id: "singleton" },
|
||||
select: {
|
||||
aiProvider: true,
|
||||
@@ -9366,7 +9374,7 @@ const executors = {
|
||||
azureOpenAiDeployment: true,
|
||||
azureOpenAiApiKey: true,
|
||||
},
|
||||
});
|
||||
}));
|
||||
return { configured: isAiConfigured(settings) };
|
||||
},
|
||||
|
||||
|
||||
@@ -349,6 +349,7 @@ const ADMIN_ONLY_TOOLS = new Set([
|
||||
"commit_dispo_import_batch",
|
||||
"get_system_settings",
|
||||
"update_system_settings",
|
||||
"clear_stored_runtime_secrets",
|
||||
"get_ai_configured",
|
||||
"test_ai_connection",
|
||||
"test_smtp_connection",
|
||||
|
||||
@@ -6,7 +6,7 @@ import { VALUE_SCORE_WEIGHTS } from "@capakraken/shared";
|
||||
import { testSmtpConnection } from "../lib/email.js";
|
||||
import { createAuditEntry } from "../lib/audit.js";
|
||||
import { logger } from "../lib/logger.js";
|
||||
import { resolveSystemSettingsRuntime } from "../lib/system-settings-runtime.js";
|
||||
import { getRuntimeSecretStatuses, RUNTIME_SECRET_FIELDS, resolveSystemSettingsRuntime } from "../lib/system-settings-runtime.js";
|
||||
|
||||
/** Fields that must never appear in audit log values */
|
||||
const SENSITIVE_FIELDS = new Set([
|
||||
@@ -23,6 +23,10 @@ export const settingsRouter = createTRPCRouter({
|
||||
where: { id: "singleton" },
|
||||
});
|
||||
const runtimeSettings = resolveSystemSettingsRuntime(settings);
|
||||
const runtimeSecrets = getRuntimeSecretStatuses(settings);
|
||||
const legacyStoredSecretFields = RUNTIME_SECRET_FIELDS.filter(
|
||||
(field) => runtimeSecrets[field].hasStoredValue,
|
||||
);
|
||||
|
||||
const defaultWeights = {
|
||||
skillDepth: VALUE_SCORE_WEIGHTS.SKILL_DEPTH,
|
||||
@@ -42,6 +46,8 @@ export const settingsRouter = createTRPCRouter({
|
||||
aiSummaryPrompt: settings?.aiSummaryPrompt ?? null,
|
||||
defaultSummaryPrompt: DEFAULT_SUMMARY_PROMPT,
|
||||
hasApiKey: !!runtimeSettings.azureOpenAiApiKey,
|
||||
runtimeSecrets,
|
||||
legacyStoredSecretFields,
|
||||
scoreWeights: (settings?.scoreWeights as unknown as typeof defaultWeights) ?? defaultWeights,
|
||||
scoreVisibleRoles: (settings?.scoreVisibleRoles as unknown as string[]) ?? ["ADMIN", "MANAGER"],
|
||||
// SMTP
|
||||
@@ -125,13 +131,14 @@ export const settingsRouter = createTRPCRouter({
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const data: Record<string, unknown> = {};
|
||||
const ignoredSecretFields: string[] = [];
|
||||
if (input.aiProvider !== undefined) data.aiProvider = input.aiProvider;
|
||||
if (input.azureOpenAiEndpoint !== undefined)
|
||||
data.azureOpenAiEndpoint = input.azureOpenAiEndpoint || null;
|
||||
if (input.azureOpenAiDeployment !== undefined)
|
||||
data.azureOpenAiDeployment = input.azureOpenAiDeployment || null;
|
||||
if (input.azureOpenAiApiKey !== undefined)
|
||||
data.azureOpenAiApiKey = input.azureOpenAiApiKey || null;
|
||||
ignoredSecretFields.push("azureOpenAiApiKey");
|
||||
if (input.azureApiVersion !== undefined)
|
||||
data.azureApiVersion = input.azureApiVersion || null;
|
||||
if (input.aiMaxCompletionTokens !== undefined)
|
||||
@@ -148,16 +155,13 @@ export const settingsRouter = createTRPCRouter({
|
||||
if (input.smtpHost !== undefined) data.smtpHost = input.smtpHost || null;
|
||||
if (input.smtpPort !== undefined) data.smtpPort = input.smtpPort;
|
||||
if (input.smtpUser !== undefined) data.smtpUser = input.smtpUser || null;
|
||||
if (input.smtpPassword !== undefined) data.smtpPassword = input.smtpPassword || null;
|
||||
if (input.smtpPassword !== undefined) ignoredSecretFields.push("smtpPassword");
|
||||
if (input.smtpFrom !== undefined) data.smtpFrom = input.smtpFrom || null;
|
||||
if (input.smtpTls !== undefined) data.smtpTls = input.smtpTls;
|
||||
// Global anonymization
|
||||
if (input.anonymizationEnabled !== undefined) data.anonymizationEnabled = input.anonymizationEnabled;
|
||||
if (input.anonymizationDomain !== undefined) data.anonymizationDomain = input.anonymizationDomain || "superhartmut.de";
|
||||
if (input.anonymizationSeed !== undefined) {
|
||||
data.anonymizationSeed = input.anonymizationSeed || null;
|
||||
data.anonymizationAliases = null;
|
||||
}
|
||||
if (input.anonymizationSeed !== undefined) ignoredSecretFields.push("anonymizationSeed");
|
||||
if (input.anonymizationMode !== undefined) {
|
||||
data.anonymizationMode = input.anonymizationMode;
|
||||
data.anonymizationAliases = null;
|
||||
@@ -168,10 +172,10 @@ export const settingsRouter = createTRPCRouter({
|
||||
if (input.azureDalleEndpoint !== undefined)
|
||||
data.azureDalleEndpoint = input.azureDalleEndpoint || null;
|
||||
if (input.azureDalleApiKey !== undefined)
|
||||
data.azureDalleApiKey = input.azureDalleApiKey || null;
|
||||
ignoredSecretFields.push("azureDalleApiKey");
|
||||
// Gemini
|
||||
if (input.geminiApiKey !== undefined)
|
||||
data.geminiApiKey = input.geminiApiKey || null;
|
||||
ignoredSecretFields.push("geminiApiKey");
|
||||
if (input.geminiModel !== undefined)
|
||||
data.geminiModel = input.geminiModel || null;
|
||||
// Image provider
|
||||
@@ -182,6 +186,14 @@ export const settingsRouter = createTRPCRouter({
|
||||
// Timeline
|
||||
if (input.timelineUndoMaxSteps !== undefined) data.timelineUndoMaxSteps = input.timelineUndoMaxSteps;
|
||||
|
||||
if (Object.keys(data).length === 0) {
|
||||
return {
|
||||
ok: true,
|
||||
ignoredSecretFields,
|
||||
secretStorageMode: "environment-only" as const,
|
||||
};
|
||||
}
|
||||
|
||||
// Fetch current settings for before-snapshot
|
||||
const before = await ctx.db.systemSettings.findUnique({ where: { id: "singleton" } });
|
||||
|
||||
@@ -215,9 +227,47 @@ export const settingsRouter = createTRPCRouter({
|
||||
source: "ui",
|
||||
});
|
||||
|
||||
return { ok: true };
|
||||
return {
|
||||
ok: true,
|
||||
ignoredSecretFields,
|
||||
secretStorageMode: "environment-only" as const,
|
||||
};
|
||||
}),
|
||||
|
||||
clearStoredRuntimeSecrets: adminProcedure.mutation(async ({ ctx }) => {
|
||||
const existing = await ctx.db.systemSettings.findUnique({
|
||||
where: { id: "singleton" },
|
||||
select: Object.fromEntries(
|
||||
RUNTIME_SECRET_FIELDS.map((field) => [field, true]),
|
||||
) as Record<(typeof RUNTIME_SECRET_FIELDS)[number], true>,
|
||||
});
|
||||
|
||||
const clearedFields = RUNTIME_SECRET_FIELDS.filter((field) => !!existing?.[field]);
|
||||
|
||||
if (clearedFields.length === 0) {
|
||||
return { ok: true, clearedFields: [] as string[] };
|
||||
}
|
||||
|
||||
await ctx.db.systemSettings.update({
|
||||
where: { id: "singleton" },
|
||||
data: Object.fromEntries(clearedFields.map((field) => [field, null])),
|
||||
});
|
||||
|
||||
void createAuditEntry({
|
||||
db: ctx.db,
|
||||
entityType: "SystemSettings",
|
||||
entityId: "singleton",
|
||||
entityName: "Runtime Secrets",
|
||||
action: "UPDATE",
|
||||
userId: ctx.dbUser?.id,
|
||||
after: { clearedFields },
|
||||
source: "ui",
|
||||
summary: `Cleared ${clearedFields.length} legacy runtime secret field(s) from database storage`,
|
||||
});
|
||||
|
||||
return { ok: true, clearedFields };
|
||||
}),
|
||||
|
||||
testAiConnection: adminProcedure.mutation(async ({ ctx }) => {
|
||||
const settings = resolveSystemSettingsRuntime(await ctx.db.systemSettings.findUnique({
|
||||
where: { id: "singleton" },
|
||||
|
||||
Reference in New Issue
Block a user