refactor(settings): adopt environment-only runtime secret flow

This commit is contained in:
2026-03-30 19:55:06 +02:00
parent fed7aa5b61
commit a19d2cbae0
19 changed files with 757 additions and 172 deletions
@@ -16,6 +16,13 @@ const CHECKBOX_ROW_CLASS =
"flex items-center gap-2 rounded-xl border border-gray-200 bg-gray-50 px-3 py-2 text-sm text-gray-700 dark:border-gray-700 dark:bg-gray-900/70 dark:text-gray-300"; "flex items-center gap-2 rounded-xl border border-gray-200 bg-gray-50 px-3 py-2 text-sm text-gray-700 dark:border-gray-700 dark:bg-gray-900/70 dark:text-gray-300";
type Provider = "openai" | "azure"; type Provider = "openai" | "azure";
type RuntimeSecretSource = "environment" | "database" | "none";
type RuntimeSecretStatus = {
configured: boolean;
activeSource: RuntimeSecretSource;
hasStoredValue: boolean;
envVarNames: string[];
};
const ALL_ROLES = ["ADMIN", "MANAGER", "CONTROLLER", "USER", "VIEWER"] as const; const ALL_ROLES = ["ADMIN", "MANAGER", "CONTROLLER", "USER", "VIEWER"] as const;
type SystemRole = (typeof ALL_ROLES)[number]; type SystemRole = (typeof ALL_ROLES)[number];
@@ -60,12 +67,92 @@ function parseAzureUrl(raw: string): ParsedAzureUrl | null {
} }
} }
function getSecretStatusTone(source: RuntimeSecretSource): string {
if (source === "environment") {
return "border-green-200 bg-green-50 text-green-800 dark:border-green-800 dark:bg-green-950/30 dark:text-green-300";
}
if (source === "database") {
return "border-amber-200 bg-amber-50 text-amber-800 dark:border-amber-800 dark:bg-amber-950/30 dark:text-amber-300";
}
return "border-gray-200 bg-gray-50 text-gray-700 dark:border-gray-700 dark:bg-gray-900/60 dark:text-gray-300";
}
function getSecretStatusLabel(source: RuntimeSecretSource): string {
if (source === "environment") return "Environment";
if (source === "database") return "Legacy DB";
return "Missing";
}
function RuntimeSecretCard({
title,
description,
secret,
optionalNote,
}: {
title: string;
description: string;
secret: RuntimeSecretStatus;
optionalNote?: string;
}) {
return (
<div className="rounded-2xl border border-gray-200 bg-gray-50/80 p-4 dark:border-gray-700 dark:bg-gray-900/50">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<h3 className="text-sm font-semibold text-gray-900 dark:text-gray-100">{title}</h3>
<p className="mt-1 text-xs leading-relaxed text-gray-500 dark:text-gray-400">
{description}
</p>
</div>
<span
className={`rounded-full border px-2.5 py-1 text-[11px] font-semibold uppercase tracking-[0.12em] ${getSecretStatusTone(secret.activeSource)}`}
>
{getSecretStatusLabel(secret.activeSource)}
</span>
</div>
<div className="mt-3 space-y-2 text-xs text-gray-600 dark:text-gray-300">
<p>
Runtime status:{" "}
<span className="font-medium">
{secret.configured ? "configured" : "not configured"}
</span>
</p>
<p>
Provision via{" "}
{secret.envVarNames.map((name) => (
<code key={name} className="mr-1 font-mono">
{name}
</code>
))}
</p>
{optionalNote ? <p>{optionalNote}</p> : null}
{secret.activeSource === "environment" && secret.hasStoredValue ? (
<p className="text-amber-700 dark:text-amber-400">
An older database value still exists, but the environment value currently overrides it.
</p>
) : null}
{secret.activeSource === "database" ? (
<p className="text-amber-700 dark:text-amber-400">
Runtime currently still depends on a legacy database secret. Migrate it to deployment
secrets and clear the stored value afterwards.
</p>
) : null}
{secret.activeSource === "none" ? (
<p className="text-gray-500 dark:text-gray-400">
No runtime secret is available yet. The related integration will stay disabled or fail
connectivity checks until the deployment secret is set.
</p>
) : null}
</div>
</div>
);
}
export function SystemSettingsClient() { export function SystemSettingsClient() {
const [provider, setProvider] = useState<Provider>("openai"); const [provider, setProvider] = useState<Provider>("openai");
const [endpoint, setEndpoint] = useState(""); const [endpoint, setEndpoint] = useState("");
const [model, setModel] = useState(""); const [model, setModel] = useState("");
const [apiVersion, setApiVersion] = useState("2025-01-01-preview"); const [apiVersion, setApiVersion] = useState("2025-01-01-preview");
const [apiKey, setApiKey] = useState("");
const [maxTokens, setMaxTokens] = useState(2000); const [maxTokens, setMaxTokens] = useState(2000);
const [temperature, setTemperature] = useState(1); const [temperature, setTemperature] = useState(1);
const [summaryPrompt, setSummaryPrompt] = useState(""); const [summaryPrompt, setSummaryPrompt] = useState("");
@@ -73,7 +160,6 @@ export function SystemSettingsClient() {
const [testResult, setTestResult] = useState<{ const [testResult, setTestResult] = useState<{
ok: boolean; ok: boolean;
error?: string; error?: string;
raw?: string | null;
} | null>(null); } | null>(null);
const [urlPasteValue, setUrlPasteValue] = useState(""); const [urlPasteValue, setUrlPasteValue] = useState("");
const [urlParseError, setUrlParseError] = useState(false); const [urlParseError, setUrlParseError] = useState(false);
@@ -94,12 +180,10 @@ export function SystemSettingsClient() {
// DALL-E settings // DALL-E settings
const [dalleDeployment, setDalleDeployment] = useState(""); const [dalleDeployment, setDalleDeployment] = useState("");
const [dalleEndpoint, setDalleEndpoint] = useState(""); const [dalleEndpoint, setDalleEndpoint] = useState("");
const [dalleApiKey, setDalleApiKey] = useState("");
// Gemini / Image generation settings // Gemini / Image generation settings
type ImageProvider = "dalle" | "gemini"; type ImageProvider = "dalle" | "gemini";
const [imageProvider, setImageProvider] = useState<ImageProvider>("dalle"); const [imageProvider, setImageProvider] = useState<ImageProvider>("dalle");
const [geminiApiKey, setGeminiApiKey] = useState("");
const [geminiModel, setGeminiModel] = useState(""); const [geminiModel, setGeminiModel] = useState("");
const [imageSaved, setImageSaved] = useState(false); const [imageSaved, setImageSaved] = useState(false);
@@ -107,7 +191,6 @@ export function SystemSettingsClient() {
const [smtpHost, setSmtpHost] = useState(""); const [smtpHost, setSmtpHost] = useState("");
const [smtpPort, setSmtpPort] = useState(587); const [smtpPort, setSmtpPort] = useState(587);
const [smtpUser, setSmtpUser] = useState(""); const [smtpUser, setSmtpUser] = useState("");
const [smtpPassword, setSmtpPassword] = useState("");
const [smtpFrom, setSmtpFrom] = useState(""); const [smtpFrom, setSmtpFrom] = useState("");
const [smtpTls, setSmtpTls] = useState(true); const [smtpTls, setSmtpTls] = useState(true);
const [smtpSaved, setSmtpSaved] = useState(false); const [smtpSaved, setSmtpSaved] = useState(false);
@@ -118,7 +201,6 @@ export function SystemSettingsClient() {
// Global anonymization // Global anonymization
const [anonymizationEnabled, setAnonymizationEnabled] = useState(false); const [anonymizationEnabled, setAnonymizationEnabled] = useState(false);
const [anonymizationDomain, setAnonymizationDomain] = useState("superhartmut.de"); const [anonymizationDomain, setAnonymizationDomain] = useState("superhartmut.de");
const [anonymizationSeed, setAnonymizationSeed] = useState("");
const [anonymizationSaved, setAnonymizationSaved] = useState(false); const [anonymizationSaved, setAnonymizationSaved] = useState(false);
// Vacation defaults // Vacation defaults
@@ -128,7 +210,9 @@ export function SystemSettingsClient() {
// Timeline // Timeline
const [undoMaxSteps, setUndoMaxSteps] = useState(50); const [undoMaxSteps, setUndoMaxSteps] = useState(50);
const [timelineSaved, setTimelineSaved] = useState(false); const [timelineSaved, setTimelineSaved] = useState(false);
const [legacyCleanupResult, setLegacyCleanupResult] = useState<string | null>(null);
const utils = trpc.useUtils();
const { data: settings, isLoading } = trpc.settings.getSystemSettings.useQuery(undefined, { const { data: settings, isLoading } = trpc.settings.getSystemSettings.useQuery(undefined, {
staleTime: 0, staleTime: 0,
}); });
@@ -163,7 +247,6 @@ export function SystemSettingsClient() {
// Global anonymization // Global anonymization
setAnonymizationEnabled(settings.anonymizationEnabled ?? false); setAnonymizationEnabled(settings.anonymizationEnabled ?? false);
setAnonymizationDomain(settings.anonymizationDomain ?? "superhartmut.de"); setAnonymizationDomain(settings.anonymizationDomain ?? "superhartmut.de");
setAnonymizationSeed("");
// Vacation // Vacation
setVacationDefaultDays(settings.vacationDefaultDays ?? 28); setVacationDefaultDays(settings.vacationDefaultDays ?? 28);
// Timeline // Timeline
@@ -171,6 +254,10 @@ export function SystemSettingsClient() {
} }
}, [settings]); }, [settings]);
function invalidateSystemSettings() {
void utils.settings.getSystemSettings.invalidate();
}
function handleUrlPaste(raw: string) { function handleUrlPaste(raw: string) {
setUrlPasteValue(raw); setUrlPasteValue(raw);
if (!raw) { if (!raw) {
@@ -196,6 +283,8 @@ export function SystemSettingsClient() {
onSuccess: () => { onSuccess: () => {
setSaved(true); setSaved(true);
setTestResult(null); setTestResult(null);
setLegacyCleanupResult(null);
invalidateSystemSettings();
setTimeout(() => setSaved(false), 3000); setTimeout(() => setSaved(false), 3000);
}, },
}); });
@@ -208,6 +297,7 @@ export function SystemSettingsClient() {
const saveScoreMutation = trpc.settings.updateSystemSettings.useMutation({ const saveScoreMutation = trpc.settings.updateSystemSettings.useMutation({
onSuccess: () => { onSuccess: () => {
setScoreSaved(true); setScoreSaved(true);
invalidateSystemSettings();
setTimeout(() => setScoreSaved(false), 3000); setTimeout(() => setScoreSaved(false), 3000);
}, },
}); });
@@ -220,6 +310,8 @@ export function SystemSettingsClient() {
onSuccess: () => { onSuccess: () => {
setSmtpSaved(true); setSmtpSaved(true);
setSmtpTestResult(null); setSmtpTestResult(null);
setLegacyCleanupResult(null);
invalidateSystemSettings();
setTimeout(() => setSmtpSaved(false), 3000); setTimeout(() => setSmtpSaved(false), 3000);
}, },
}); });
@@ -232,6 +324,8 @@ export function SystemSettingsClient() {
const saveAnonymizationMutation = trpc.settings.updateSystemSettings.useMutation({ const saveAnonymizationMutation = trpc.settings.updateSystemSettings.useMutation({
onSuccess: () => { onSuccess: () => {
setAnonymizationSaved(true); setAnonymizationSaved(true);
setLegacyCleanupResult(null);
invalidateSystemSettings();
setTimeout(() => setAnonymizationSaved(false), 3000); setTimeout(() => setAnonymizationSaved(false), 3000);
}, },
}); });
@@ -239,6 +333,7 @@ export function SystemSettingsClient() {
const saveVacationMutation = trpc.settings.updateSystemSettings.useMutation({ const saveVacationMutation = trpc.settings.updateSystemSettings.useMutation({
onSuccess: () => { onSuccess: () => {
setVacationSaved(true); setVacationSaved(true);
invalidateSystemSettings();
setTimeout(() => setVacationSaved(false), 3000); setTimeout(() => setVacationSaved(false), 3000);
}, },
}); });
@@ -246,6 +341,7 @@ export function SystemSettingsClient() {
const saveTimelineMutation = trpc.settings.updateSystemSettings.useMutation({ const saveTimelineMutation = trpc.settings.updateSystemSettings.useMutation({
onSuccess: () => { onSuccess: () => {
setTimelineSaved(true); setTimelineSaved(true);
invalidateSystemSettings();
setTimeout(() => setTimelineSaved(false), 3000); setTimeout(() => setTimelineSaved(false), 3000);
}, },
}); });
@@ -253,10 +349,26 @@ export function SystemSettingsClient() {
const saveImageMutation = trpc.settings.updateSystemSettings.useMutation({ const saveImageMutation = trpc.settings.updateSystemSettings.useMutation({
onSuccess: () => { onSuccess: () => {
setImageSaved(true); setImageSaved(true);
setLegacyCleanupResult(null);
invalidateSystemSettings();
setTimeout(() => setImageSaved(false), 3000); setTimeout(() => setImageSaved(false), 3000);
}, },
}); });
const clearRuntimeSecretsMutation = trpc.settings.clearStoredRuntimeSecrets.useMutation({
onSuccess: (data) => {
setLegacyCleanupResult(
data.clearedFields.length > 0
? `Cleared ${data.clearedFields.length} legacy database secret field${data.clearedFields.length === 1 ? "" : "s"}.`
: "No legacy database secrets were left to clear.",
);
invalidateSystemSettings();
},
onError: (error) => {
setLegacyCleanupResult(error.message);
},
});
const [geminiTestResult, setGeminiTestResult] = useState<{ ok: boolean; model?: string; error?: string } | null>(null); const [geminiTestResult, setGeminiTestResult] = useState<{ ok: boolean; model?: string; error?: string } | null>(null);
const testGeminiMut = trpc.settings.testGeminiConnection.useMutation({ const testGeminiMut = trpc.settings.testGeminiConnection.useMutation({
onSuccess: (data) => setGeminiTestResult(data as any), onSuccess: (data) => setGeminiTestResult(data as any),
@@ -268,7 +380,6 @@ export function SystemSettingsClient() {
smtpHost: smtpHost || undefined, smtpHost: smtpHost || undefined,
smtpPort, smtpPort,
smtpUser: smtpUser || undefined, smtpUser: smtpUser || undefined,
...(smtpPassword ? { smtpPassword } : {}),
smtpFrom: smtpFrom || undefined, smtpFrom: smtpFrom || undefined,
smtpTls, smtpTls,
}); });
@@ -288,9 +399,7 @@ export function SystemSettingsClient() {
// DALL-E fields // DALL-E fields
azureDalleDeployment: dalleDeployment || undefined, azureDalleDeployment: dalleDeployment || undefined,
azureDalleEndpoint: provider === "azure" && dalleEndpoint ? dalleEndpoint : undefined, azureDalleEndpoint: provider === "azure" && dalleEndpoint ? dalleEndpoint : undefined,
...(dalleApiKey ? { azureDalleApiKey: dalleApiKey } : {}),
// Gemini fields // Gemini fields
...(geminiApiKey ? { geminiApiKey } : {}),
geminiModel: geminiModel || undefined, geminiModel: geminiModel || undefined,
}); });
} }
@@ -299,7 +408,6 @@ export function SystemSettingsClient() {
saveAnonymizationMutation.mutate({ saveAnonymizationMutation.mutate({
anonymizationEnabled, anonymizationEnabled,
anonymizationDomain: anonymizationDomain.trim() || "superhartmut.de", anonymizationDomain: anonymizationDomain.trim() || "superhartmut.de",
...(anonymizationSeed.trim() ? { anonymizationSeed: anonymizationSeed.trim() } : {}),
anonymizationMode: "global", anonymizationMode: "global",
}); });
} }
@@ -330,10 +438,22 @@ export function SystemSettingsClient() {
aiMaxCompletionTokens: maxTokens, aiMaxCompletionTokens: maxTokens,
aiTemperature: temperature, aiTemperature: temperature,
aiSummaryPrompt: summaryPrompt || undefined, aiSummaryPrompt: summaryPrompt || undefined,
...(apiKey ? { azureOpenAiApiKey: apiKey } : {}),
}); });
} }
function handleClearLegacyRuntimeSecrets() {
if (
typeof window !== "undefined"
&& !window.confirm(
"Clear all legacy runtime secrets from database storage? Environment-based deployment secrets must already be configured.",
)
) {
return;
}
clearRuntimeSecretsMutation.mutate();
}
if (isLoading) { if (isLoading) {
return ( return (
<div className="app-page"> <div className="app-page">
@@ -342,6 +462,16 @@ export function SystemSettingsClient() {
); );
} }
if (!settings) {
return (
<div className="app-page">
<div className="rounded-2xl border border-red-200 bg-red-50 px-5 py-4 text-sm text-red-700 dark:border-red-800 dark:bg-red-950/30 dark:text-red-300">
System settings could not be loaded.
</div>
</div>
);
}
return ( return (
<div className="app-page space-y-6"> <div className="app-page space-y-6">
<div className="app-page-header gap-4"> <div className="app-page-header gap-4">
@@ -353,6 +483,46 @@ export function SystemSettingsClient() {
</div> </div>
</div> </div>
{settings.legacyStoredSecretFields.length ? (
<div className="rounded-2xl border border-amber-200 bg-amber-50 px-5 py-4 dark:border-amber-800 dark:bg-amber-950/30">
<div className="flex flex-wrap items-start justify-between gap-4">
<div className="min-w-0 flex-1">
<h2 className="text-sm font-semibold uppercase tracking-[0.16em] text-amber-900 dark:text-amber-200">
Legacy Runtime Secrets Detected
</h2>
<p className="mt-2 text-sm text-amber-900/90 dark:text-amber-100/90">
This installation still has database-stored runtime secrets. New secrets are no
longer persisted in the application. Move them to deployment-level secret
management first, then clear the legacy residue here.
</p>
<p className="mt-2 text-xs text-amber-800 dark:text-amber-300">
Affected fields:{" "}
{settings.legacyStoredSecretFields.map((field) => (
<code key={field} className="mr-1 font-mono">
{field}
</code>
))}
</p>
{legacyCleanupResult ? (
<p className="mt-2 text-xs text-amber-900 dark:text-amber-200">
{legacyCleanupResult}
</p>
) : null}
</div>
<button
type="button"
onClick={handleClearLegacyRuntimeSecrets}
disabled={clearRuntimeSecretsMutation.isPending}
className={SECONDARY_BUTTON_CLASS}
>
{clearRuntimeSecretsMutation.isPending
? "Clearing…"
: "Clear Legacy DB Secrets"}
</button>
</div>
</div>
) : null}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 items-start"> <div className="grid grid-cols-1 lg:grid-cols-2 gap-6 items-start">
<div className={PANEL_CLASS}> <div className={PANEL_CLASS}>
<h2 className="text-sm font-semibold uppercase tracking-[0.18em] text-gray-700 dark:text-gray-200 flex items-center"> <h2 className="text-sm font-semibold uppercase tracking-[0.18em] text-gray-700 dark:text-gray-200 flex items-center">
@@ -495,33 +665,20 @@ export function SystemSettingsClient() {
</div> </div>
)} )}
{/* API key */} <RuntimeSecretCard
<div> title="Primary AI API Key"
<label className={LABEL_CLASS} htmlFor="ai-key"> description={
API Key provider === "openai"
</label> ? "The runtime reads the OpenAI key directly from deployment secrets. Saving this form does not store or rotate secrets."
<input : "The runtime reads the Azure OpenAI key directly from deployment secrets. Saving this form only updates non-secret metadata."
id="ai-key" }
type="password" secret={settings.runtimeSecrets.azureOpenAiApiKey}
className={INPUT_CLASS} optionalNote={
placeholder={ provider === "openai"
settings?.hasApiKey ? "Expected source: OPENAI_API_KEY. AZURE_OPENAI_API_KEY is also accepted as a fallback."
? "●●●●●●●●●●●● (already set — enter new value to replace)" : "Expected source: AZURE_OPENAI_API_KEY. OPENAI_API_KEY is also accepted as a fallback."
: provider === "openai" }
? "sk-..." />
: "Enter Azure API key"
}
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
autoComplete="new-password"
/>
<p className="text-xs text-gray-400 dark:text-gray-500 mt-1">
{provider === "openai"
? "Your secret key from platform.openai.com → API keys. Starts with sk-."
: "One of the two keys from Azure Portal → your resource → Keys and Endpoint."}
{settings?.hasApiKey && " Leave blank to keep the existing key."}
</p>
</div>
{/* Test result */} {/* Test result */}
{testResult && ( {testResult && (
@@ -541,16 +698,6 @@ export function SystemSettingsClient() {
<p> <p>
<span className="font-medium">Connection failed:</span> {testResult.error} <span className="font-medium">Connection failed:</span> {testResult.error}
</p> </p>
{testResult.raw && (
<details className="text-xs">
<summary className="cursor-pointer opacity-70 hover:opacity-100">
Show raw error
</summary>
<pre className="mt-1 p-2 bg-red-100 dark:bg-red-950 rounded text-red-800 dark:text-red-200 whitespace-pre-wrap break-all font-mono">
{testResult.raw}
</pre>
</details>
)}
</div> </div>
)} )}
</div> </div>
@@ -1052,9 +1199,9 @@ export function SystemSettingsClient() {
</div> </div>
{/* ── Image Generation ────────────────────────────────── */} {/* ── Image Generation ────────────────────────────────── */}
<div className={PANEL_CLASS}> <div className={PANEL_CLASS}>
<div> <div>
<h2 className="text-base font-semibold text-gray-900 dark:text-gray-100 flex items-center"> <h2 className="text-base font-semibold text-gray-900 dark:text-gray-100 flex items-center">
Image Generation <InfoTooltip content="Configure the image generation provider used for AI-generated project cover art." /> Image Generation <InfoTooltip content="Configure the image generation provider used for AI-generated project cover art." />
</h2> </h2>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1"> <p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
@@ -1127,30 +1274,15 @@ export function SystemSettingsClient() {
placeholder="Leave empty to use same endpoint as chat" placeholder="Leave empty to use same endpoint as chat"
/> />
</div> </div>
<div>
<label className={LABEL_CLASS}>
<span className="flex items-center">
API Key{" "}
<InfoTooltip content="API key for the DALL-E endpoint. Leave empty to use the same API key as the chat model." />
<span className="ml-1 text-xs font-normal text-gray-400">(optional)</span>
</span>
</label>
<input
type="password"
className={INPUT_CLASS}
value={dalleApiKey}
onChange={(e) => setDalleApiKey(e.target.value)}
placeholder="Leave empty to use same API key as chat"
autoComplete="new-password"
/>
</div>
</> </>
)} )}
</div> </div>
{settings?.hasDalleApiKey && ( <RuntimeSecretCard
<p className="text-xs text-green-600 dark:text-green-400">A separate DALL-E API key is stored.</p> title="Dedicated DALL-E Key"
)} description="Optional override for image generation. If unset, runtime falls back to the primary AI key when possible."
secret={settings.runtimeSecrets.azureDalleApiKey}
optionalNote="Use AZURE_DALLE_API_KEY only when image generation should be isolated from the primary AI credential."
/>
</div> </div>
)} )}
@@ -1159,24 +1291,6 @@ export function SystemSettingsClient() {
<div className="space-y-4 rounded-xl border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-900/50"> <div className="space-y-4 rounded-xl border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-900/50">
<h3 className="text-sm font-semibold text-gray-700 dark:text-gray-300">Google Gemini Configuration</h3> <h3 className="text-sm font-semibold text-gray-700 dark:text-gray-300">Google Gemini Configuration</h3>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2"> <div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div>
<label className={LABEL_CLASS}>
<span className="flex items-center">
API Key <InfoTooltip content="Google Gemini API key from Google AI Studio (aistudio.google.com)." />
</span>
</label>
<input
type="password"
className={INPUT_CLASS}
value={geminiApiKey}
onChange={(e) => setGeminiApiKey(e.target.value)}
placeholder={settings?.hasGeminiApiKey ? "•••••••• (key is stored)" : "Enter Gemini API key"}
autoComplete="new-password"
/>
{settings?.hasGeminiApiKey && !geminiApiKey && (
<p className="text-xs text-green-600 dark:text-green-400 mt-1">API key is stored.</p>
)}
</div>
<div> <div>
<label className={LABEL_CLASS}> <label className={LABEL_CLASS}>
<span className="flex items-center"> <span className="flex items-center">
@@ -1194,6 +1308,12 @@ export function SystemSettingsClient() {
</select> </select>
</div> </div>
</div> </div>
<RuntimeSecretCard
title="Gemini API Key"
description="Gemini credentials are resolved from deployment secrets only."
secret={settings.runtimeSecrets.geminiApiKey}
optionalNote="Provision GEMINI_API_KEY in the target environment before using Gemini image generation."
/>
</div> </div>
)} )}
@@ -1277,24 +1397,6 @@ export function SystemSettingsClient() {
autoComplete="off" autoComplete="off"
/> />
</div> </div>
<div>
<label className={LABEL_CLASS}><span className="flex items-center">
SMTP Password <InfoTooltip content="The SMTP authentication password. Stored encrypted. Leave blank to keep the existing password." />{" "}</span>
{settings?.hasSmtpPassword && (
<span className="text-gray-400 font-normal text-xs">
(set leave blank to keep)
</span>
)}
</label>
<input
type="password"
className={INPUT_CLASS}
value={smtpPassword}
onChange={(e) => setSmtpPassword(e.target.value)}
placeholder="••••••••"
autoComplete="new-password"
/>
</div>
<div> <div>
<label className={LABEL_CLASS}><span className="flex items-center">From Address <InfoTooltip content="The sender email address shown in notification emails (e.g. noreply@capakraken.app)." /></span></label> <label className={LABEL_CLASS}><span className="flex items-center">From Address <InfoTooltip content="The sender email address shown in notification emails (e.g. noreply@capakraken.app)." /></span></label>
<input <input
@@ -1322,6 +1424,13 @@ export function SystemSettingsClient() {
</div> </div>
</div> </div>
<RuntimeSecretCard
title="SMTP Password"
description="SMTP credentials are provisioned outside the application and injected at runtime."
secret={settings.runtimeSecrets.smtpPassword}
optionalNote="Provision SMTP_PASSWORD in the deployment target used by the API service."
/>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<button <button
type="button" type="button"
@@ -1476,20 +1585,20 @@ export function SystemSettingsClient() {
</div> </div>
<div> <div>
<label className={LABEL_CLASS}>Optional Seed Override</label> <label className={LABEL_CLASS}>Optional Seed Override</label>
<input
type="text"
className={INPUT_CLASS}
value={anonymizationSeed}
onChange={(e) => setAnonymizationSeed(e.target.value)}
placeholder="Leave blank to keep the current stable mapping"
/>
<p className="text-xs text-gray-400 dark:text-gray-500 mt-1"> <p className="text-xs text-gray-400 dark:text-gray-500 mt-1">
Changing the seed intentionally reshuffles aliases. Leave blank to preserve the The optional seed is now managed as a deployment secret instead of an in-app value.
existing mapping. Changing it intentionally reshuffles aliases.
</p> </p>
</div> </div>
</div> </div>
<RuntimeSecretCard
title="Anonymization Seed"
description="The stable anonymization seed is resolved from runtime secret management."
secret={settings.runtimeSecrets.anonymizationSeed}
optionalNote="Provision ANONYMIZATION_SEED only when you need a non-default, deployment-specific alias mapping."
/>
<div className="rounded-2xl border border-amber-200 bg-amber-50 px-4 py-3 text-xs text-amber-800 dark:border-amber-800 dark:bg-amber-950/30 dark:text-amber-300"> <div className="rounded-2xl border border-amber-200 bg-amber-50 px-4 py-3 text-xs text-amber-800 dark:border-amber-800 dark:bg-amber-950/30 dark:text-amber-300">
High-LCR resources receive more iconic characters first. Real EIDs and emails are never High-LCR resources receive more iconic characters first. Real EIDs and emails are never
rewritten in Prisma. rewritten in Prisma.
+14 -15
View File
@@ -11,7 +11,7 @@ At the same time, the codebase still carries several risks that are typical of f
1. some critical cross-cutting concerns are only partially productized 1. some critical cross-cutting concerns are only partially productized
2. several files and routers have grown beyond comfortable ownership size 2. several files and routers have grown beyond comfortable ownership size
3. runtime configuration and secret handling are still too application-database centric 3. runtime secret handling is now materially cleaner, but the repo still needs to standardize the operational source of truth around that model
4. the current operational model is improving, but not yet fully standardized 4. the current operational model is improving, but not yet fully standardized
5. production-grade multi-instance safeguards are not complete yet 5. production-grade multi-instance safeguards are not complete yet
@@ -47,10 +47,10 @@ The previously critical SSE and browser parser coverage issues were addressed du
Evidence: [assistant-tools.ts](/home/hartmut/Documents/Copilot/capakraken/packages/api/src/router/assistant-tools.ts), [resource.ts](/home/hartmut/Documents/Copilot/capakraken/packages/api/src/router/resource.ts), [allocation.ts](/home/hartmut/Documents/Copilot/capakraken/packages/api/src/router/allocation.ts), [timeline.ts](/home/hartmut/Documents/Copilot/capakraken/packages/api/src/router/timeline.ts), [vacation.ts](/home/hartmut/Documents/Copilot/capakraken/packages/api/src/router/vacation.ts), and large frontend files such as [SystemSettingsClient.tsx](/home/hartmut/Documents/Copilot/capakraken/apps/web/src/components/admin/SystemSettingsClient.tsx) and [TimelineProjectPanel.tsx](/home/hartmut/Documents/Copilot/capakraken/apps/web/src/components/timeline/TimelineProjectPanel.tsx) are each well past the size where safe ownership stays easy. Evidence: [assistant-tools.ts](/home/hartmut/Documents/Copilot/capakraken/packages/api/src/router/assistant-tools.ts), [resource.ts](/home/hartmut/Documents/Copilot/capakraken/packages/api/src/router/resource.ts), [allocation.ts](/home/hartmut/Documents/Copilot/capakraken/packages/api/src/router/allocation.ts), [timeline.ts](/home/hartmut/Documents/Copilot/capakraken/packages/api/src/router/timeline.ts), [vacation.ts](/home/hartmut/Documents/Copilot/capakraken/packages/api/src/router/vacation.ts), and large frontend files such as [SystemSettingsClient.tsx](/home/hartmut/Documents/Copilot/capakraken/apps/web/src/components/admin/SystemSettingsClient.tsx) and [TimelineProjectPanel.tsx](/home/hartmut/Documents/Copilot/capakraken/apps/web/src/components/timeline/TimelineProjectPanel.tsx) are each well past the size where safe ownership stays easy.
Risk: AI-generated changes become harder to review, humans lose local reasoning context, and regressions become more likely. Risk: AI-generated changes become harder to review, humans lose local reasoning context, and regressions become more likely.
2. Secret handling is still application-database centric. 2. Runtime secret policy is mostly corrected, but deploy standardization still has to catch up.
Evidence: system settings mutate and persist API keys and SMTP credentials in [settings.ts](/home/hartmut/Documents/Copilot/capakraken/packages/api/src/router/settings.ts). Evidence: runtime resolution and admin flows now treat environment-backed secrets as the preferred source in [settings.ts](/home/hartmut/Documents/Copilot/capakraken/packages/api/src/router/settings.ts), [system-settings-runtime.ts](/home/hartmut/Documents/Copilot/capakraken/packages/api/src/lib/system-settings-runtime.ts), and [SystemSettingsClient.tsx](/home/hartmut/Documents/Copilot/capakraken/apps/web/src/components/admin/SystemSettingsClient.tsx).
Risk: operational secrets remain too coupled to the main app data plane for a gold-standard project. Risk: a strong secret policy is only fully effective once staging and production provisioning use one canonical deployment path and operators clear remaining legacy database copies.
Update: runtime resolution is now env-first for the active secret consumers, but persistence is still transitional and should be reduced further. Update: the application no longer persists new operational secret values through admin settings; the remaining work is rollout discipline and cleanup completion.
3. Least-privilege is materially better documented now, but it still needs long-lived enforcement rather than relying mainly on one hardening batch. 3. Least-privilege is materially better documented now, but it still needs long-lived enforcement rather than relying mainly on one hardening batch.
Evidence: the route audience model is now explicit in [route-access-matrix.md](/home/hartmut/Documents/Copilot/capakraken/docs/route-access-matrix.md) and backed by multiple focused auth tests, but the remaining guarantee still depends on continuing test coverage and architecture guardrails as new routes evolve. Evidence: the route audience model is now explicit in [route-access-matrix.md](/home/hartmut/Documents/Copilot/capakraken/docs/route-access-matrix.md) and backed by multiple focused auth tests, but the remaining guarantee still depends on continuing test coverage and architecture guardrails as new routes evolve.
@@ -80,9 +80,9 @@ This is materially better than a typical startup CRUD app and already has the bo
### Security Posture ### Security Posture
`7/10` `7.5/10`
There are good foundations, and the most obvious real-time and comment-visibility gaps were closed, but secrets policy and long-lived least-privilege enforcement still need structural work. There are good foundations, and the most obvious real-time, comment-visibility, and runtime-secret-policy gaps were closed, but long-lived least-privilege enforcement and operational standardization still need structural work.
### Maintainability ### Maintainability
@@ -124,8 +124,8 @@ Goals:
- Keep SSE audience scoping under test and CI guardrails. - Keep SSE audience scoping under test and CI guardrails.
- Keep hardened spreadsheet parser boundaries under regression coverage. - Keep hardened spreadsheet parser boundaries under regression coverage.
- Treat the route access matrix and narrowed auth slices as maintained architecture contracts. - Treat the route access matrix and narrowed auth slices as maintained architecture contracts.
- Move production secrets out of regular application settings, or add an interim encrypted-secrets layer with clear migration path. - Enforce the environment-only runtime secret policy operationally and clear remaining legacy database secret residue.
Status: in progress. Runtime consumers now prefer environment overrides; the remaining gap is eliminating or encrypting compatibility persistence in the admin settings path. Status: mostly completed in code. Runtime consumers prefer environment values, admin updates no longer store new secret material, and operators now need to finish rollout/bootstrap documentation plus cleanup of old database copies.
Definition of done: Definition of done:
@@ -222,12 +222,11 @@ Artifacts to add:
## Suggested Order Of Execution ## Suggested Order Of Execution
1. secrets policy 1. router/component decomposition
2. router/component decomposition 2. architecture fitness checks in CI
3. architecture fitness checks in CI 3. full operational standardization
4. full operational standardization 4. production-grade rate limiting
5. production-grade rate limiting 5. performance hotspot reduction
6. performance hotspot reduction
## Success Criteria For The Next 60 Days ## Success Criteria For The Next 60 Days
@@ -0,0 +1,89 @@
# ADR 0001: Runtime Secret Provisioning
**Status:** Accepted
**Date:** 2026-03-30
## Context
CapaKraken historically allowed some operational runtime secrets to be persisted through `SystemSettings`.
That included values such as:
- primary AI API credentials
- dedicated DALL-E credentials
- Gemini credentials
- SMTP password
- anonymization seed
This was convenient for fast iteration, but it coupled operational secret material to the main application data plane and blurred the line between configuration metadata and deployment secrets.
The project is moving toward a production model where the running artifact should be immutable and environment-driven. That model is weakened if operators can still rotate runtime secrets through normal application writes.
## Decision
Operational runtime secrets must be provisioned outside the application database.
Allowed sources:
- deployment environment variables
- host-level secret files such as `.env.production` on self-managed infrastructure
- platform secret managers or encrypted environment facilities
Disallowed source for new secret values:
- admin updates that write runtime secrets into `SystemSettings`
`SystemSettings` remains valid for non-secret runtime metadata such as:
- provider selection
- endpoints
- model names
- SMTP host/user/from settings
- anonymization mode and domain
Legacy secret values that already exist in `SystemSettings` may still be read during migration for compatibility, but they are not the target state and should be cleared after equivalent deployment secrets are provisioned.
## Consequences
Positive:
- production updates become more predictable because images and runtime secrets are managed as separate deployment concerns
- operational secrets stop depending on ordinary application write paths
- admin tooling can expose status and diagnostics without pretending to be the system of record for secrets
- secret rotation becomes an infrastructure operation rather than a product mutation
Tradeoffs:
- smaller self-managed installs need a disciplined host bootstrap process
- operators must understand that updating app settings is no longer sufficient for secret rotation
- migration requires visibility into which secrets are still backed by database residue
## Implementation Notes
The implementation should follow these rules:
1. runtime consumers resolve supported secret values from environment first
2. admin settings reads expose presence and source status, not secret values
3. admin settings updates ignore incoming secret payloads
4. the UI explains the expected environment variables for each runtime secret
5. a dedicated cleanup action removes legacy database-stored secret values after migration
## Operational Guidance
For staging and production:
1. provision runtime secrets on the host or platform before starting a new release
2. deploy the already-built application image
3. restart the application so the new process reads the current secret source
4. verify runtime status in admin settings
5. clear any leftover legacy database secret values once the environment-backed source is confirmed
Secret rotation should follow the same model. In most cases, no application data mutation is needed. The operator updates the deployment secret source and restarts or redeploys the app.
## Follow-up
Still required after this decision:
- complete the canonical image-based staging/production rollout
- ensure staging and production hosts both use the same secret provisioning rules
- periodically verify that legacy database secret fields remain empty
+1
View File
@@ -20,6 +20,7 @@
- comment entity support is now centralized across shared constants, API registry policy, assistant tool metadata, and the web comment target API without pretending a second consumer exists - comment entity support is now centralized across shared constants, API registry policy, assistant tool metadata, and the web comment target API without pretending a second consumer exists
- `resource` is now onboarded as the second real comment entity, reusing the same ownership and staff-visibility rules as the resource detail route - `resource` is now onboarded as the second real comment entity, reusing the same ownership and staff-visibility rules as the resource detail route
- comment mention autocomplete now uses a dedicated entity-scoped API route instead of inheriting the narrower `user.listAssignable` audience - comment mention autocomplete now uses a dedicated entity-scoped API route instead of inheriting the narrower `user.listAssignable` audience
- runtime secret handling is now environment-first end to end: admin updates no longer persist new operational secrets, runtime status is surfaced explicitly, and legacy database secret copies can be cleared through a dedicated cleanup path
## Next Up ## Next Up
+3 -3
View File
@@ -52,9 +52,9 @@ These files already have unrelated local edits. Audience parity work that would
## Next Major Themes ## Next Major Themes
1. convert the still-open runtime secret model away from application-database centric storage 1. add broader authorization regression coverage and long-lived guardrails around the narrowed route audiences
2. add broader authorization regression coverage and long-lived guardrails around the narrowed route audiences 2. reduce oversized routers and UI ownership surfaces so audience rules stay reviewable
3. reduce oversized routers and UI ownership surfaces so audience rules stay reviewable 3. keep runtime secret policy and role/audience boundaries aligned as adjacent architecture guardrails
## Slice Definition ## Slice Definition
+16 -2
View File
@@ -154,6 +154,11 @@ SMTP_PORT=587
SMTP_USER=notifications@example.com SMTP_USER=notifications@example.com
SMTP_PASSWORD=<password> SMTP_PASSWORD=<password>
SMTP_FROM=CapaKraken <notifications@example.com> SMTP_FROM=CapaKraken <notifications@example.com>
OPENAI_API_KEY=<optional-if-openai-used>
AZURE_OPENAI_API_KEY=<optional-if-azure-chat-used>
AZURE_DALLE_API_KEY=<optional-if-azure-image-gen-used>
GEMINI_API_KEY=<optional-if-gemini-used>
ANONYMIZATION_SEED=<required-if-deterministic-anonymization-enabled>
``` ```
Generate a secure `NEXTAUTH_SECRET`: Generate a secure `NEXTAUTH_SECRET`:
@@ -162,6 +167,12 @@ Generate a secure `NEXTAUTH_SECRET`:
openssl rand -base64 32 openssl rand -base64 32
``` ```
Runtime secret policy:
- production secrets are injected through the deployment environment or host secret store
- admin settings must not be used to enter or rotate AI, SMTP, or anonymization secrets
- the admin UI is only for status checks and cleanup of legacy database-stored secret values
--- ---
## 5. Deployment ## 5. Deployment
@@ -169,13 +180,13 @@ openssl rand -base64 32
### docker-compose (simplest) ### docker-compose (simplest)
```bash ```bash
# On your server # On your server, after updating the host-side env/secret source
git pull git pull
docker compose -f docker-compose.prod.yml up -d --build docker compose -f docker-compose.prod.yml up -d --build
# Run database migrations # Run database migrations
docker compose -f docker-compose.prod.yml exec app \ docker compose -f docker-compose.prod.yml exec app \
pnpm db:push pnpm --filter @capakraken/db db:migrate:deploy
# Seed initial data (first deployment only) # Seed initial data (first deployment only)
docker compose -f docker-compose.prod.yml exec app \ docker compose -f docker-compose.prod.yml exec app \
@@ -193,6 +204,7 @@ git pull origin main
pnpm install pnpm install
pnpm db:generate pnpm db:generate
pnpm db:validate pnpm db:validate
pnpm --filter @capakraken/db db:migrate:deploy
pnpm --filter @capakraken/web exec next build pnpm --filter @capakraken/web exec next build
rm -rf apps/web/.next/cache # clear stale cache rm -rf apps/web/.next/cache # clear stale cache
@@ -203,6 +215,8 @@ PORT=3100 pnpm --filter @capakraken/web start &
Use the repo-level `pnpm db:*` commands for Prisma/database operations. They load `.env`, `.env.local`, `.env.$NODE_ENV`, and `.env.$NODE_ENV.local` automatically before invoking Prisma. Use the repo-level `pnpm db:*` commands for Prisma/database operations. They load `.env`, `.env.local`, `.env.$NODE_ENV`, and `.env.$NODE_ENV.local` automatically before invoking Prisma.
If you rotate runtime secrets during a manual deploy, update the host-side environment source first, then restart the app so the new process reads the updated values. Do not patch those values through admin settings.
### nginx configuration ### nginx configuration
The existing nginx reverse proxy should forward to port 3100: The existing nginx reverse proxy should forward to port 3100:
+31
View File
@@ -30,6 +30,7 @@ That removes "works on the server but not in CI" drift and makes rollbacks much
The existing `CI` workflow continues to validate: The existing `CI` workflow continues to validate:
- architecture guardrails for SSE audience scoping
- typecheck - typecheck
- lint - lint
- unit tests - unit tests
@@ -38,6 +39,12 @@ The existing `CI` workflow continues to validate:
This remains the quality gate before merge. This remains the quality gate before merge.
The guardrail step currently enforces three invariants:
- no role-based SSE audience fan-out in [event-bus.ts](/home/hartmut/Documents/Copilot/capakraken/packages/api/src/sse/event-bus.ts)
- no role-derived subscription audiences in [subscription-policy.ts](/home/hartmut/Documents/Copilot/capakraken/packages/api/src/sse/subscription-policy.ts)
- no client-provided audience parsing in [route.ts](/home/hartmut/Documents/Copilot/capakraken/apps/web/src/app/api/sse/timeline/route.ts)
### 2. Image Build ### 2. Image Build
The new manual workflow [release-image.yml](/home/hartmut/Documents/Copilot/capakraken/.github/workflows/release-image.yml) builds two images from [Dockerfile.prod](/home/hartmut/Documents/Copilot/capakraken/Dockerfile.prod): The new manual workflow [release-image.yml](/home/hartmut/Documents/Copilot/capakraken/.github/workflows/release-image.yml) builds two images from [Dockerfile.prod](/home/hartmut/Documents/Copilot/capakraken/Dockerfile.prod):
@@ -149,6 +156,28 @@ NEXTAUTH_SECRET=<long-random-secret>
GitHub Actions only injects the short-lived image references through `deploy.env`. The deploy script then loads both files before calling Docker Compose, so compose interpolation and container runtime env use the same source of truth. GitHub Actions only injects the short-lived image references through `deploy.env`. The deploy script then loads both files before calling Docker Compose, so compose interpolation and container runtime env use the same source of truth.
### Runtime Secret Provisioning Policy
Production and staging secrets should be provisioned at the host or platform-secret layer, not through admin mutations and not through application database writes.
That includes at least:
```env
OPENAI_API_KEY=<optional-if-openai-used>
AZURE_OPENAI_API_KEY=<optional-if-azure-chat-used>
AZURE_DALLE_API_KEY=<optional-if-azure-image-gen-used>
GEMINI_API_KEY=<optional-if-gemini-used>
SMTP_PASSWORD=<required-if-smtp-auth-used>
ANONYMIZATION_SEED=<required-if-deterministic-anonymization-enabled>
```
Operational rule:
- keep these values in `.env.production` only for smaller self-managed hosts, or preferably in the host's secret manager / encrypted environment facility
- do not rotate or patch these values through `SystemSettings`
- use the admin settings page only to verify runtime source/status and to clear leftover legacy database copies
- after migration, legacy database secret fields should be empty in both staging and production
## Database Policy ## Database Policy
For release environments, use: For release environments, use:
@@ -183,6 +212,8 @@ The intended production update path is:
That means the production host no longer builds from Git. It only receives a versioned image and starts it after migrations complete. That means the production host no longer builds from Git. It only receives a versioned image and starts it after migrations complete.
The same principle applies to secrets: the running container reads them from the deployment environment at start time, so an update only needs a new image tag unless secret material itself is being rotated.
## Current Status ## Current Status
The repository now contains the CI/CD scaffolding, but the existing manual production setup remains untouched: The repository now contains the CI/CD scaffolding, but the existing manual production setup remains untouched:
+2 -1
View File
@@ -46,7 +46,8 @@ See `.github/PULL_REQUEST_TEMPLATE.md` for the security checklist that must be c
- No secrets in source code - No secrets in source code
- Environment variables for all credentials (`DATABASE_URL`, API keys) - Environment variables for all credentials (`DATABASE_URL`, API keys)
- `SystemSettings` table for runtime-configurable secrets (AI keys, SMTP credentials) - Runtime application secrets are provisioned outside the application data plane through environment variables or a deployment-time secret manager
- `SystemSettings` may still contain legacy secret residue during migration, but new secret values must not be written there
- `.env` files excluded from version control via `.gitignore` - `.env` files excluded from version control via `.gitignore`
## Incident Response ## Incident Response
+2
View File
@@ -65,6 +65,8 @@ publicProcedure
- Runtime secrets now resolve env-first for AI, Gemini, SMTP, and anonymization seed values. Database-backed `SystemSettings` values remain transitional compatibility storage, not the preferred production source of truth. - Runtime secrets now resolve env-first for AI, Gemini, SMTP, and anonymization seed values. Database-backed `SystemSettings` values remain transitional compatibility storage, not the preferred production source of truth.
- Recommended runtime overrides: `OPENAI_API_KEY`, `AZURE_OPENAI_API_KEY`, `AZURE_DALLE_API_KEY`, `GEMINI_API_KEY`, `SMTP_PASSWORD`, `ANONYMIZATION_SEED` - Recommended runtime overrides: `OPENAI_API_KEY`, `AZURE_OPENAI_API_KEY`, `AZURE_DALLE_API_KEY`, `GEMINI_API_KEY`, `SMTP_PASSWORD`, `ANONYMIZATION_SEED`
- Admin settings reads expose only presence flags (`hasApiKey`, `hasSmtpPassword`, `hasGeminiApiKey`) instead of returning secret values to the browser, and those flags also reflect environment-backed runtime overrides - Admin settings reads expose only presence flags (`hasApiKey`, `hasSmtpPassword`, `hasGeminiApiKey`) instead of returning secret values to the browser, and those flags also reflect environment-backed runtime overrides
- The admin settings mutation no longer persists new secret values into `SystemSettings`; secret inputs must be provisioned through environment or a deployment-time secret manager, and legacy database copies can be cleared explicitly
- The admin UI now exposes runtime secret source/status plus an explicit "clear legacy DB secrets" cleanup path so operators can complete the migration without direct database writes
### Anonymization ### Anonymization
@@ -618,10 +618,12 @@ describe("assistant router tool gating", () => {
const adminNames = getToolNames([], SystemRole.ADMIN); const adminNames = getToolNames([], SystemRole.ADMIN);
const userNames = getToolNames([], SystemRole.USER); const userNames = getToolNames([], SystemRole.USER);
const managerNames = getToolNames([], SystemRole.MANAGER);
expect(adminNames).toContain("get_system_settings"); expect(adminNames).toContain("get_system_settings");
expect(adminNames).toContain("update_system_settings"); expect(adminNames).toContain("update_system_settings");
expect(adminNames).toContain("test_ai_connection"); expect(adminNames).toContain("test_ai_connection");
expect(adminNames).toContain("test_smtp_connection"); expect(adminNames).toContain("test_smtp_connection");
expect(adminNames).toContain("clear_stored_runtime_secrets");
expect(adminNames).toContain("test_gemini_connection"); expect(adminNames).toContain("test_gemini_connection");
expect(adminNames).toContain("list_system_role_configs"); expect(adminNames).toContain("list_system_role_configs");
expect(adminNames).toContain("update_system_role_config"); expect(adminNames).toContain("update_system_role_config");
@@ -632,12 +634,22 @@ describe("assistant router tool gating", () => {
expect(adminNames).toContain("delete_webhook"); expect(adminNames).toContain("delete_webhook");
expect(adminNames).toContain("test_webhook"); expect(adminNames).toContain("test_webhook");
expect(adminNames).toContain("get_ai_configured"); 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("get_system_settings");
expect(userNames).not.toContain("update_system_settings"); expect(userNames).not.toContain("update_system_settings");
expect(userNames).not.toContain("test_ai_connection"); expect(userNames).not.toContain("test_ai_connection");
expect(userNames).not.toContain("get_ai_configured"); 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("list_system_role_configs");
expect(userNames).not.toContain("update_system_role_config"); expect(userNames).not.toContain("update_system_role_config");
expect(userNames).not.toContain("list_webhooks"); 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("update_system_settings")).toContain("Always confirm first");
expect(toolDescriptions.get("get_ai_configured")).toContain("Admin role"); expect(toolDescriptions.get("get_ai_configured")).toContain("Admin role");
expect(toolDescriptions.get("list_system_role_configs")).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("update_system_role_config")).toContain("Admin role");
expect(toolDescriptions.get("list_webhooks")).toContain("Secrets are masked"); expect(toolDescriptions.get("list_webhooks")).toContain("Secrets are masked");
expect(toolDescriptions.get("create_webhook")).toContain("Always confirm first"); expect(toolDescriptions.get("create_webhook")).toContain("Always confirm first");
@@ -150,6 +150,7 @@ function createToolContext(
describe("assistant import/export and dispo tools", () => { describe("assistant import/export and dispo tools", () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
vi.unstubAllEnvs();
apiRateLimiter.reset(); apiRateLimiter.reset();
totpValidateMock.mockReset(); totpValidateMock.mockReset();
vi.mocked(approveEstimateVersion).mockReset(); vi.mocked(approveEstimateVersion).mockReset();
@@ -288,6 +289,67 @@ describe("assistant import/export and dispo tools", () => {
expect(JSON.parse(result.content)).toEqual({ configured: true }); 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 () => { it("masks webhook secrets in assistant responses", async () => {
const ctx = createToolContext( const ctx = createToolContext(
{ {
@@ -118,6 +118,9 @@ describe("runtime config hardening", () => {
expect(result.hasApiKey).toBe(true); expect(result.hasApiKey).toBe(true);
expect(result.hasSmtpPassword).toBe(true); expect(result.hasSmtpPassword).toBe(true);
expect(result.hasGeminiApiKey).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 () => { 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 { 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", () => { describe("system settings runtime resolution", () => {
afterEach(() => { afterEach(() => {
@@ -39,4 +39,27 @@ describe("system settings runtime resolution", () => {
expect(settings.smtpPassword).toBe("db-password"); 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; 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 { function readEnvOverride(...names: string[]): string | null {
for (const name of names) { for (const name of names) {
const value = process.env[name]?.trim(); 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"); 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>( export function resolveSystemSettingsRuntime<T extends RuntimeAwareSystemSettings>(
settings: T | null | undefined, settings: T | null | undefined,
): T & Required<Pick<RuntimeAwareSystemSettings, "azureOpenAiApiKey" | "azureDalleApiKey" | "geminiApiKey" | "smtpPassword" | "anonymizationSeed">> { ): T & Required<Pick<RuntimeAwareSystemSettings, "azureOpenAiApiKey" | "azureDalleApiKey" | "geminiApiKey" | "smtpPassword" | "anonymizationSeed">> {
const resolved = { ...(settings ?? {}) } as 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.azureOpenAiApiKey = resolveSecretEnvOverride("azureOpenAiApiKey", resolved.aiProvider) ?? settings?.azureOpenAiApiKey ?? null;
resolved.azureDalleApiKey = readEnvOverride("AZURE_DALLE_API_KEY") ?? settings?.azureDalleApiKey ?? null; resolved.azureDalleApiKey = resolveSecretEnvOverride("azureDalleApiKey", resolved.aiProvider) ?? settings?.azureDalleApiKey ?? null;
resolved.geminiApiKey = readEnvOverride("GEMINI_API_KEY") ?? settings?.geminiApiKey ?? null; resolved.geminiApiKey = resolveSecretEnvOverride("geminiApiKey", resolved.aiProvider) ?? settings?.geminiApiKey ?? null;
resolved.smtpPassword = readEnvOverride("SMTP_PASSWORD") ?? settings?.smtpPassword ?? null; resolved.smtpPassword = resolveSecretEnvOverride("smtpPassword", resolved.aiProvider) ?? settings?.smtpPassword ?? null;
resolved.anonymizationSeed = readEnvOverride("ANONYMIZATION_SEED") ?? settings?.anonymizationSeed ?? null; resolved.anonymizationSeed = resolveSecretEnvOverride("anonymizationSeed", resolved.aiProvider) ?? settings?.anonymizationSeed ?? null;
return resolved; return resolved;
} }
+21 -13
View File
@@ -76,12 +76,14 @@ import { insightsRouter } from "./insights.js";
import { scenarioRouter } from "./scenario.js"; import { scenarioRouter } from "./scenario.js";
import { allocationRouter } from "./allocation.js"; import { allocationRouter } from "./allocation.js";
import { staffingRouter } from "./staffing.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) ────── // ─── Mutation tool set for audit logging (EGAI 4.1.3.1 / IAAI 3.6.26) ──────
export const MUTATION_TOOLS = new Set([ export const MUTATION_TOOLS = new Set([
"import_csv_data", "import_csv_data",
"update_system_settings", "update_system_settings",
"clear_stored_runtime_secrets",
"test_ai_connection", "test_ai_connection",
"test_smtp_connection", "test_smtp_connection",
"test_gemini_connection", "test_gemini_connection",
@@ -4772,14 +4774,13 @@ export const TOOL_DEFINITIONS: ToolDef[] = [
type: "function", type: "function",
function: { function: {
name: "update_system_settings", 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: { parameters: {
type: "object", type: "object",
properties: { properties: {
aiProvider: { type: "string", enum: ["openai", "azure"] }, aiProvider: { type: "string", enum: ["openai", "azure"] },
azureOpenAiEndpoint: { type: "string" }, azureOpenAiEndpoint: { type: "string" },
azureOpenAiDeployment: { type: "string" }, azureOpenAiDeployment: { type: "string" },
azureOpenAiApiKey: { type: "string" },
azureApiVersion: { type: "string" }, azureApiVersion: { type: "string" },
aiMaxCompletionTokens: { type: "integer" }, aiMaxCompletionTokens: { type: "integer" },
aiTemperature: { type: "number" }, aiTemperature: { type: "number" },
@@ -4789,17 +4790,13 @@ export const TOOL_DEFINITIONS: ToolDef[] = [
smtpHost: { type: "string" }, smtpHost: { type: "string" },
smtpPort: { type: "integer" }, smtpPort: { type: "integer" },
smtpUser: { type: "string" }, smtpUser: { type: "string" },
smtpPassword: { type: "string" },
smtpFrom: { type: "string" }, smtpFrom: { type: "string" },
smtpTls: { type: "boolean" }, smtpTls: { type: "boolean" },
anonymizationEnabled: { type: "boolean" }, anonymizationEnabled: { type: "boolean" },
anonymizationDomain: { type: "string" }, anonymizationDomain: { type: "string" },
anonymizationSeed: { type: "string" },
anonymizationMode: { type: "string", enum: ["global"] }, anonymizationMode: { type: "string", enum: ["global"] },
azureDalleDeployment: { type: "string" }, azureDalleDeployment: { type: "string" },
azureDalleEndpoint: { type: "string" }, azureDalleEndpoint: { type: "string" },
azureDalleApiKey: { type: "string" },
geminiApiKey: { type: "string" },
geminiModel: { type: "string" }, geminiModel: { type: "string" },
imageProvider: { type: "string", enum: ["dalle", "gemini"] }, imageProvider: { type: "string", enum: ["dalle", "gemini"] },
vacationDefaultDays: { type: "integer" }, 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", type: "function",
function: { function: {
name: "test_ai_connection", name: "test_ai_connection",
@@ -9306,7 +9314,6 @@ const executors = {
aiProvider?: "openai" | "azure"; aiProvider?: "openai" | "azure";
azureOpenAiEndpoint?: string; azureOpenAiEndpoint?: string;
azureOpenAiDeployment?: string; azureOpenAiDeployment?: string;
azureOpenAiApiKey?: string;
azureApiVersion?: string; azureApiVersion?: string;
aiMaxCompletionTokens?: number; aiMaxCompletionTokens?: number;
aiTemperature?: number; aiTemperature?: number;
@@ -9322,17 +9329,13 @@ const executors = {
smtpHost?: string; smtpHost?: string;
smtpPort?: number; smtpPort?: number;
smtpUser?: string; smtpUser?: string;
smtpPassword?: string;
smtpFrom?: string; smtpFrom?: string;
smtpTls?: boolean; smtpTls?: boolean;
anonymizationEnabled?: boolean; anonymizationEnabled?: boolean;
anonymizationDomain?: string; anonymizationDomain?: string;
anonymizationSeed?: string;
anonymizationMode?: "global"; anonymizationMode?: "global";
azureDalleDeployment?: string; azureDalleDeployment?: string;
azureDalleEndpoint?: string; azureDalleEndpoint?: string;
azureDalleApiKey?: string;
geminiApiKey?: string;
geminiModel?: string; geminiModel?: string;
imageProvider?: "dalle" | "gemini"; imageProvider?: "dalle" | "gemini";
vacationDefaultDays?: number; vacationDefaultDays?: number;
@@ -9342,6 +9345,11 @@ const executors = {
return caller.updateSystemSettings(params); 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) { async test_ai_connection(_params: Record<string, never>, ctx: ToolContext) {
const caller = createSettingsCaller(createScopedCallerContext(ctx)); const caller = createSettingsCaller(createScopedCallerContext(ctx));
return caller.testAiConnection(); return caller.testAiConnection();
@@ -9358,7 +9366,7 @@ const executors = {
}, },
async get_ai_configured(_params: Record<string, never>, ctx: ToolContext) { 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" }, where: { id: "singleton" },
select: { select: {
aiProvider: true, aiProvider: true,
@@ -9366,7 +9374,7 @@ const executors = {
azureOpenAiDeployment: true, azureOpenAiDeployment: true,
azureOpenAiApiKey: true, azureOpenAiApiKey: true,
}, },
}); }));
return { configured: isAiConfigured(settings) }; return { configured: isAiConfigured(settings) };
}, },
+1
View File
@@ -349,6 +349,7 @@ const ADMIN_ONLY_TOOLS = new Set([
"commit_dispo_import_batch", "commit_dispo_import_batch",
"get_system_settings", "get_system_settings",
"update_system_settings", "update_system_settings",
"clear_stored_runtime_secrets",
"get_ai_configured", "get_ai_configured",
"test_ai_connection", "test_ai_connection",
"test_smtp_connection", "test_smtp_connection",
+60 -10
View File
@@ -6,7 +6,7 @@ import { VALUE_SCORE_WEIGHTS } from "@capakraken/shared";
import { testSmtpConnection } from "../lib/email.js"; import { testSmtpConnection } from "../lib/email.js";
import { createAuditEntry } from "../lib/audit.js"; import { createAuditEntry } from "../lib/audit.js";
import { logger } from "../lib/logger.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 */ /** Fields that must never appear in audit log values */
const SENSITIVE_FIELDS = new Set([ const SENSITIVE_FIELDS = new Set([
@@ -23,6 +23,10 @@ export const settingsRouter = createTRPCRouter({
where: { id: "singleton" }, where: { id: "singleton" },
}); });
const runtimeSettings = resolveSystemSettingsRuntime(settings); const runtimeSettings = resolveSystemSettingsRuntime(settings);
const runtimeSecrets = getRuntimeSecretStatuses(settings);
const legacyStoredSecretFields = RUNTIME_SECRET_FIELDS.filter(
(field) => runtimeSecrets[field].hasStoredValue,
);
const defaultWeights = { const defaultWeights = {
skillDepth: VALUE_SCORE_WEIGHTS.SKILL_DEPTH, skillDepth: VALUE_SCORE_WEIGHTS.SKILL_DEPTH,
@@ -42,6 +46,8 @@ export const settingsRouter = createTRPCRouter({
aiSummaryPrompt: settings?.aiSummaryPrompt ?? null, aiSummaryPrompt: settings?.aiSummaryPrompt ?? null,
defaultSummaryPrompt: DEFAULT_SUMMARY_PROMPT, defaultSummaryPrompt: DEFAULT_SUMMARY_PROMPT,
hasApiKey: !!runtimeSettings.azureOpenAiApiKey, hasApiKey: !!runtimeSettings.azureOpenAiApiKey,
runtimeSecrets,
legacyStoredSecretFields,
scoreWeights: (settings?.scoreWeights as unknown as typeof defaultWeights) ?? defaultWeights, scoreWeights: (settings?.scoreWeights as unknown as typeof defaultWeights) ?? defaultWeights,
scoreVisibleRoles: (settings?.scoreVisibleRoles as unknown as string[]) ?? ["ADMIN", "MANAGER"], scoreVisibleRoles: (settings?.scoreVisibleRoles as unknown as string[]) ?? ["ADMIN", "MANAGER"],
// SMTP // SMTP
@@ -125,13 +131,14 @@ export const settingsRouter = createTRPCRouter({
) )
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
const data: Record<string, unknown> = {}; const data: Record<string, unknown> = {};
const ignoredSecretFields: string[] = [];
if (input.aiProvider !== undefined) data.aiProvider = input.aiProvider; if (input.aiProvider !== undefined) data.aiProvider = input.aiProvider;
if (input.azureOpenAiEndpoint !== undefined) if (input.azureOpenAiEndpoint !== undefined)
data.azureOpenAiEndpoint = input.azureOpenAiEndpoint || null; data.azureOpenAiEndpoint = input.azureOpenAiEndpoint || null;
if (input.azureOpenAiDeployment !== undefined) if (input.azureOpenAiDeployment !== undefined)
data.azureOpenAiDeployment = input.azureOpenAiDeployment || null; data.azureOpenAiDeployment = input.azureOpenAiDeployment || null;
if (input.azureOpenAiApiKey !== undefined) if (input.azureOpenAiApiKey !== undefined)
data.azureOpenAiApiKey = input.azureOpenAiApiKey || null; ignoredSecretFields.push("azureOpenAiApiKey");
if (input.azureApiVersion !== undefined) if (input.azureApiVersion !== undefined)
data.azureApiVersion = input.azureApiVersion || null; data.azureApiVersion = input.azureApiVersion || null;
if (input.aiMaxCompletionTokens !== undefined) if (input.aiMaxCompletionTokens !== undefined)
@@ -148,16 +155,13 @@ export const settingsRouter = createTRPCRouter({
if (input.smtpHost !== undefined) data.smtpHost = input.smtpHost || null; if (input.smtpHost !== undefined) data.smtpHost = input.smtpHost || null;
if (input.smtpPort !== undefined) data.smtpPort = input.smtpPort; if (input.smtpPort !== undefined) data.smtpPort = input.smtpPort;
if (input.smtpUser !== undefined) data.smtpUser = input.smtpUser || null; 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.smtpFrom !== undefined) data.smtpFrom = input.smtpFrom || null;
if (input.smtpTls !== undefined) data.smtpTls = input.smtpTls; if (input.smtpTls !== undefined) data.smtpTls = input.smtpTls;
// Global anonymization // Global anonymization
if (input.anonymizationEnabled !== undefined) data.anonymizationEnabled = input.anonymizationEnabled; if (input.anonymizationEnabled !== undefined) data.anonymizationEnabled = input.anonymizationEnabled;
if (input.anonymizationDomain !== undefined) data.anonymizationDomain = input.anonymizationDomain || "superhartmut.de"; if (input.anonymizationDomain !== undefined) data.anonymizationDomain = input.anonymizationDomain || "superhartmut.de";
if (input.anonymizationSeed !== undefined) { if (input.anonymizationSeed !== undefined) ignoredSecretFields.push("anonymizationSeed");
data.anonymizationSeed = input.anonymizationSeed || null;
data.anonymizationAliases = null;
}
if (input.anonymizationMode !== undefined) { if (input.anonymizationMode !== undefined) {
data.anonymizationMode = input.anonymizationMode; data.anonymizationMode = input.anonymizationMode;
data.anonymizationAliases = null; data.anonymizationAliases = null;
@@ -168,10 +172,10 @@ export const settingsRouter = createTRPCRouter({
if (input.azureDalleEndpoint !== undefined) if (input.azureDalleEndpoint !== undefined)
data.azureDalleEndpoint = input.azureDalleEndpoint || null; data.azureDalleEndpoint = input.azureDalleEndpoint || null;
if (input.azureDalleApiKey !== undefined) if (input.azureDalleApiKey !== undefined)
data.azureDalleApiKey = input.azureDalleApiKey || null; ignoredSecretFields.push("azureDalleApiKey");
// Gemini // Gemini
if (input.geminiApiKey !== undefined) if (input.geminiApiKey !== undefined)
data.geminiApiKey = input.geminiApiKey || null; ignoredSecretFields.push("geminiApiKey");
if (input.geminiModel !== undefined) if (input.geminiModel !== undefined)
data.geminiModel = input.geminiModel || null; data.geminiModel = input.geminiModel || null;
// Image provider // Image provider
@@ -182,6 +186,14 @@ export const settingsRouter = createTRPCRouter({
// Timeline // Timeline
if (input.timelineUndoMaxSteps !== undefined) data.timelineUndoMaxSteps = input.timelineUndoMaxSteps; 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 // Fetch current settings for before-snapshot
const before = await ctx.db.systemSettings.findUnique({ where: { id: "singleton" } }); const before = await ctx.db.systemSettings.findUnique({ where: { id: "singleton" } });
@@ -215,9 +227,47 @@ export const settingsRouter = createTRPCRouter({
source: "ui", 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 }) => { testAiConnection: adminProcedure.mutation(async ({ ctx }) => {
const settings = resolveSystemSettingsRuntime(await ctx.db.systemSettings.findUnique({ const settings = resolveSystemSettingsRuntime(await ctx.db.systemSettings.findUnique({
where: { id: "singleton" }, where: { id: "singleton" },
+5
View File
@@ -6,8 +6,13 @@ NEXTAUTH_SECRET=replace-with-a-long-random-secret
# Optional but commonly needed application settings. # Optional but commonly needed application settings.
SENTRY_DSN= SENTRY_DSN=
OPENAI_API_KEY=
AZURE_OPENAI_API_KEY=
AZURE_DALLE_API_KEY=
GEMINI_API_KEY=
SMTP_HOST= SMTP_HOST=
SMTP_PORT=587 SMTP_PORT=587
SMTP_USER= SMTP_USER=
SMTP_PASSWORD= SMTP_PASSWORD=
SMTP_FROM=CapaKraken <notifications@example.com> SMTP_FROM=CapaKraken <notifications@example.com>
ANONYMIZATION_SEED=
+6 -3
View File
@@ -25,9 +25,12 @@ On the target host, the deploy directory should contain:
1. Copy `tooling/deploy/.env.production.example` to the target host as `.env.production`. 1. Copy `tooling/deploy/.env.production.example` to the target host as `.env.production`.
2. Fill in the required secrets and URLs. 2. Fill in the required secrets and URLs.
3. Ensure Docker Engine and Docker Compose v2 are installed. 3. Provision runtime AI/SMTP/anonymization secrets on the host through `.env.production` or the platform's secret facility.
4. Ensure the target host can pull from `ghcr.io`. 4. Keep admin settings for status/verification only; do not use them to enter or rotate operational secrets.
5. Run the image release workflow, then the staging or production deploy workflow with the same image tag. 5. After migration, use the admin cleanup action to remove any legacy database-stored runtime secrets.
6. Ensure Docker Engine and Docker Compose v2 are installed.
7. Ensure the target host can pull from `ghcr.io`.
8. Run the image release workflow, then the staging or production deploy workflow with the same image tag.
## Manual Host Test ## Manual Host Test