refactor(settings): adopt environment-only runtime secret flow
This commit is contained in:
@@ -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";
|
||||
|
||||
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;
|
||||
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() {
|
||||
const [provider, setProvider] = useState<Provider>("openai");
|
||||
const [endpoint, setEndpoint] = useState("");
|
||||
const [model, setModel] = useState("");
|
||||
const [apiVersion, setApiVersion] = useState("2025-01-01-preview");
|
||||
const [apiKey, setApiKey] = useState("");
|
||||
const [maxTokens, setMaxTokens] = useState(2000);
|
||||
const [temperature, setTemperature] = useState(1);
|
||||
const [summaryPrompt, setSummaryPrompt] = useState("");
|
||||
@@ -73,7 +160,6 @@ export function SystemSettingsClient() {
|
||||
const [testResult, setTestResult] = useState<{
|
||||
ok: boolean;
|
||||
error?: string;
|
||||
raw?: string | null;
|
||||
} | null>(null);
|
||||
const [urlPasteValue, setUrlPasteValue] = useState("");
|
||||
const [urlParseError, setUrlParseError] = useState(false);
|
||||
@@ -94,12 +180,10 @@ export function SystemSettingsClient() {
|
||||
// DALL-E settings
|
||||
const [dalleDeployment, setDalleDeployment] = useState("");
|
||||
const [dalleEndpoint, setDalleEndpoint] = useState("");
|
||||
const [dalleApiKey, setDalleApiKey] = useState("");
|
||||
|
||||
// Gemini / Image generation settings
|
||||
type ImageProvider = "dalle" | "gemini";
|
||||
const [imageProvider, setImageProvider] = useState<ImageProvider>("dalle");
|
||||
const [geminiApiKey, setGeminiApiKey] = useState("");
|
||||
const [geminiModel, setGeminiModel] = useState("");
|
||||
const [imageSaved, setImageSaved] = useState(false);
|
||||
|
||||
@@ -107,7 +191,6 @@ export function SystemSettingsClient() {
|
||||
const [smtpHost, setSmtpHost] = useState("");
|
||||
const [smtpPort, setSmtpPort] = useState(587);
|
||||
const [smtpUser, setSmtpUser] = useState("");
|
||||
const [smtpPassword, setSmtpPassword] = useState("");
|
||||
const [smtpFrom, setSmtpFrom] = useState("");
|
||||
const [smtpTls, setSmtpTls] = useState(true);
|
||||
const [smtpSaved, setSmtpSaved] = useState(false);
|
||||
@@ -118,7 +201,6 @@ export function SystemSettingsClient() {
|
||||
// Global anonymization
|
||||
const [anonymizationEnabled, setAnonymizationEnabled] = useState(false);
|
||||
const [anonymizationDomain, setAnonymizationDomain] = useState("superhartmut.de");
|
||||
const [anonymizationSeed, setAnonymizationSeed] = useState("");
|
||||
const [anonymizationSaved, setAnonymizationSaved] = useState(false);
|
||||
|
||||
// Vacation defaults
|
||||
@@ -128,7 +210,9 @@ export function SystemSettingsClient() {
|
||||
// Timeline
|
||||
const [undoMaxSteps, setUndoMaxSteps] = useState(50);
|
||||
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, {
|
||||
staleTime: 0,
|
||||
});
|
||||
@@ -163,7 +247,6 @@ export function SystemSettingsClient() {
|
||||
// Global anonymization
|
||||
setAnonymizationEnabled(settings.anonymizationEnabled ?? false);
|
||||
setAnonymizationDomain(settings.anonymizationDomain ?? "superhartmut.de");
|
||||
setAnonymizationSeed("");
|
||||
// Vacation
|
||||
setVacationDefaultDays(settings.vacationDefaultDays ?? 28);
|
||||
// Timeline
|
||||
@@ -171,6 +254,10 @@ export function SystemSettingsClient() {
|
||||
}
|
||||
}, [settings]);
|
||||
|
||||
function invalidateSystemSettings() {
|
||||
void utils.settings.getSystemSettings.invalidate();
|
||||
}
|
||||
|
||||
function handleUrlPaste(raw: string) {
|
||||
setUrlPasteValue(raw);
|
||||
if (!raw) {
|
||||
@@ -196,6 +283,8 @@ export function SystemSettingsClient() {
|
||||
onSuccess: () => {
|
||||
setSaved(true);
|
||||
setTestResult(null);
|
||||
setLegacyCleanupResult(null);
|
||||
invalidateSystemSettings();
|
||||
setTimeout(() => setSaved(false), 3000);
|
||||
},
|
||||
});
|
||||
@@ -208,6 +297,7 @@ export function SystemSettingsClient() {
|
||||
const saveScoreMutation = trpc.settings.updateSystemSettings.useMutation({
|
||||
onSuccess: () => {
|
||||
setScoreSaved(true);
|
||||
invalidateSystemSettings();
|
||||
setTimeout(() => setScoreSaved(false), 3000);
|
||||
},
|
||||
});
|
||||
@@ -220,6 +310,8 @@ export function SystemSettingsClient() {
|
||||
onSuccess: () => {
|
||||
setSmtpSaved(true);
|
||||
setSmtpTestResult(null);
|
||||
setLegacyCleanupResult(null);
|
||||
invalidateSystemSettings();
|
||||
setTimeout(() => setSmtpSaved(false), 3000);
|
||||
},
|
||||
});
|
||||
@@ -232,6 +324,8 @@ export function SystemSettingsClient() {
|
||||
const saveAnonymizationMutation = trpc.settings.updateSystemSettings.useMutation({
|
||||
onSuccess: () => {
|
||||
setAnonymizationSaved(true);
|
||||
setLegacyCleanupResult(null);
|
||||
invalidateSystemSettings();
|
||||
setTimeout(() => setAnonymizationSaved(false), 3000);
|
||||
},
|
||||
});
|
||||
@@ -239,6 +333,7 @@ export function SystemSettingsClient() {
|
||||
const saveVacationMutation = trpc.settings.updateSystemSettings.useMutation({
|
||||
onSuccess: () => {
|
||||
setVacationSaved(true);
|
||||
invalidateSystemSettings();
|
||||
setTimeout(() => setVacationSaved(false), 3000);
|
||||
},
|
||||
});
|
||||
@@ -246,6 +341,7 @@ export function SystemSettingsClient() {
|
||||
const saveTimelineMutation = trpc.settings.updateSystemSettings.useMutation({
|
||||
onSuccess: () => {
|
||||
setTimelineSaved(true);
|
||||
invalidateSystemSettings();
|
||||
setTimeout(() => setTimelineSaved(false), 3000);
|
||||
},
|
||||
});
|
||||
@@ -253,10 +349,26 @@ export function SystemSettingsClient() {
|
||||
const saveImageMutation = trpc.settings.updateSystemSettings.useMutation({
|
||||
onSuccess: () => {
|
||||
setImageSaved(true);
|
||||
setLegacyCleanupResult(null);
|
||||
invalidateSystemSettings();
|
||||
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 testGeminiMut = trpc.settings.testGeminiConnection.useMutation({
|
||||
onSuccess: (data) => setGeminiTestResult(data as any),
|
||||
@@ -268,7 +380,6 @@ export function SystemSettingsClient() {
|
||||
smtpHost: smtpHost || undefined,
|
||||
smtpPort,
|
||||
smtpUser: smtpUser || undefined,
|
||||
...(smtpPassword ? { smtpPassword } : {}),
|
||||
smtpFrom: smtpFrom || undefined,
|
||||
smtpTls,
|
||||
});
|
||||
@@ -288,9 +399,7 @@ export function SystemSettingsClient() {
|
||||
// DALL-E fields
|
||||
azureDalleDeployment: dalleDeployment || undefined,
|
||||
azureDalleEndpoint: provider === "azure" && dalleEndpoint ? dalleEndpoint : undefined,
|
||||
...(dalleApiKey ? { azureDalleApiKey: dalleApiKey } : {}),
|
||||
// Gemini fields
|
||||
...(geminiApiKey ? { geminiApiKey } : {}),
|
||||
geminiModel: geminiModel || undefined,
|
||||
});
|
||||
}
|
||||
@@ -299,7 +408,6 @@ export function SystemSettingsClient() {
|
||||
saveAnonymizationMutation.mutate({
|
||||
anonymizationEnabled,
|
||||
anonymizationDomain: anonymizationDomain.trim() || "superhartmut.de",
|
||||
...(anonymizationSeed.trim() ? { anonymizationSeed: anonymizationSeed.trim() } : {}),
|
||||
anonymizationMode: "global",
|
||||
});
|
||||
}
|
||||
@@ -330,10 +438,22 @@ export function SystemSettingsClient() {
|
||||
aiMaxCompletionTokens: maxTokens,
|
||||
aiTemperature: temperature,
|
||||
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) {
|
||||
return (
|
||||
<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 (
|
||||
<div className="app-page space-y-6">
|
||||
<div className="app-page-header gap-4">
|
||||
@@ -353,6 +483,46 @@ export function SystemSettingsClient() {
|
||||
</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={PANEL_CLASS}>
|
||||
<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>
|
||||
)}
|
||||
|
||||
{/* API key */}
|
||||
<div>
|
||||
<label className={LABEL_CLASS} htmlFor="ai-key">
|
||||
API Key
|
||||
</label>
|
||||
<input
|
||||
id="ai-key"
|
||||
type="password"
|
||||
className={INPUT_CLASS}
|
||||
placeholder={
|
||||
settings?.hasApiKey
|
||||
? "●●●●●●●●●●●● (already set — enter new value to replace)"
|
||||
: 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>
|
||||
<RuntimeSecretCard
|
||||
title="Primary AI API Key"
|
||||
description={
|
||||
provider === "openai"
|
||||
? "The runtime reads the OpenAI key directly from deployment secrets. Saving this form does not store or rotate secrets."
|
||||
: "The runtime reads the Azure OpenAI key directly from deployment secrets. Saving this form only updates non-secret metadata."
|
||||
}
|
||||
secret={settings.runtimeSecrets.azureOpenAiApiKey}
|
||||
optionalNote={
|
||||
provider === "openai"
|
||||
? "Expected source: OPENAI_API_KEY. AZURE_OPENAI_API_KEY is also accepted as a fallback."
|
||||
: "Expected source: AZURE_OPENAI_API_KEY. OPENAI_API_KEY is also accepted as a fallback."
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Test result */}
|
||||
{testResult && (
|
||||
@@ -541,16 +698,6 @@ export function SystemSettingsClient() {
|
||||
<p>
|
||||
<span className="font-medium">Connection failed:</span> {testResult.error}
|
||||
</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>
|
||||
@@ -1052,9 +1199,9 @@ export function SystemSettingsClient() {
|
||||
</div>
|
||||
|
||||
{/* ── Image Generation ────────────────────────────────── */}
|
||||
<div className={PANEL_CLASS}>
|
||||
<div>
|
||||
<h2 className="text-base font-semibold text-gray-900 dark:text-gray-100 flex items-center">
|
||||
<div className={PANEL_CLASS}>
|
||||
<div>
|
||||
<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." />
|
||||
</h2>
|
||||
<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"
|
||||
/>
|
||||
</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>
|
||||
{settings?.hasDalleApiKey && (
|
||||
<p className="text-xs text-green-600 dark:text-green-400">A separate DALL-E API key is stored.</p>
|
||||
)}
|
||||
<RuntimeSecretCard
|
||||
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>
|
||||
)}
|
||||
|
||||
@@ -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">
|
||||
<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>
|
||||
<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>
|
||||
<label className={LABEL_CLASS}>
|
||||
<span className="flex items-center">
|
||||
@@ -1194,6 +1308,12 @@ export function SystemSettingsClient() {
|
||||
</select>
|
||||
</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>
|
||||
)}
|
||||
|
||||
@@ -1277,24 +1397,6 @@ export function SystemSettingsClient() {
|
||||
autoComplete="off"
|
||||
/>
|
||||
</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>
|
||||
<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
|
||||
@@ -1322,6 +1424,13 @@ export function SystemSettingsClient() {
|
||||
</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">
|
||||
<button
|
||||
type="button"
|
||||
@@ -1476,20 +1585,20 @@ export function SystemSettingsClient() {
|
||||
</div>
|
||||
<div>
|
||||
<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">
|
||||
Changing the seed intentionally reshuffles aliases. Leave blank to preserve the
|
||||
existing mapping.
|
||||
The optional seed is now managed as a deployment secret instead of an in-app value.
|
||||
Changing it intentionally reshuffles aliases.
|
||||
</p>
|
||||
</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">
|
||||
High-LCR resources receive more iconic characters first. Real EIDs and emails are never
|
||||
rewritten in Prisma.
|
||||
|
||||
@@ -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
|
||||
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
|
||||
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.
|
||||
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.
|
||||
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).
|
||||
Risk: operational secrets remain too coupled to the main app data plane for a gold-standard project.
|
||||
Update: runtime resolution is now env-first for the active secret consumers, but persistence is still transitional and should be reduced further.
|
||||
2. Runtime secret policy is mostly corrected, but deploy standardization still has to catch up.
|
||||
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: 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: 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.
|
||||
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
|
||||
|
||||
`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
|
||||
|
||||
@@ -124,8 +124,8 @@ Goals:
|
||||
- Keep SSE audience scoping under test and CI guardrails.
|
||||
- Keep hardened spreadsheet parser boundaries under regression coverage.
|
||||
- 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.
|
||||
Status: in progress. Runtime consumers now prefer environment overrides; the remaining gap is eliminating or encrypting compatibility persistence in the admin settings path.
|
||||
- Enforce the environment-only runtime secret policy operationally and clear remaining legacy database secret residue.
|
||||
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:
|
||||
|
||||
@@ -222,12 +222,11 @@ Artifacts to add:
|
||||
|
||||
## Suggested Order Of Execution
|
||||
|
||||
1. secrets policy
|
||||
2. router/component decomposition
|
||||
3. architecture fitness checks in CI
|
||||
4. full operational standardization
|
||||
5. production-grade rate limiting
|
||||
6. performance hotspot reduction
|
||||
1. router/component decomposition
|
||||
2. architecture fitness checks in CI
|
||||
3. full operational standardization
|
||||
4. production-grade rate limiting
|
||||
5. performance hotspot reduction
|
||||
|
||||
## 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
|
||||
@@ -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
|
||||
- `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
|
||||
- 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
|
||||
|
||||
|
||||
@@ -52,9 +52,9 @@ These files already have unrelated local edits. Audience parity work that would
|
||||
|
||||
## Next Major Themes
|
||||
|
||||
1. convert the still-open runtime secret model away from application-database centric storage
|
||||
2. add broader authorization regression coverage and long-lived guardrails around the narrowed route audiences
|
||||
3. reduce oversized routers and UI ownership surfaces so audience rules stay reviewable
|
||||
1. 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. keep runtime secret policy and role/audience boundaries aligned as adjacent architecture guardrails
|
||||
|
||||
## Slice Definition
|
||||
|
||||
|
||||
+16
-2
@@ -154,6 +154,11 @@ SMTP_PORT=587
|
||||
SMTP_USER=notifications@example.com
|
||||
SMTP_PASSWORD=<password>
|
||||
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`:
|
||||
@@ -162,6 +167,12 @@ Generate a secure `NEXTAUTH_SECRET`:
|
||||
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
|
||||
@@ -169,13 +180,13 @@ openssl rand -base64 32
|
||||
### docker-compose (simplest)
|
||||
|
||||
```bash
|
||||
# On your server
|
||||
# On your server, after updating the host-side env/secret source
|
||||
git pull
|
||||
docker compose -f docker-compose.prod.yml up -d --build
|
||||
|
||||
# Run database migrations
|
||||
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)
|
||||
docker compose -f docker-compose.prod.yml exec app \
|
||||
@@ -193,6 +204,7 @@ git pull origin main
|
||||
pnpm install
|
||||
pnpm db:generate
|
||||
pnpm db:validate
|
||||
pnpm --filter @capakraken/db db:migrate:deploy
|
||||
pnpm --filter @capakraken/web exec next build
|
||||
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.
|
||||
|
||||
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
|
||||
|
||||
The existing nginx reverse proxy should forward to port 3100:
|
||||
|
||||
@@ -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:
|
||||
|
||||
- architecture guardrails for SSE audience scoping
|
||||
- typecheck
|
||||
- lint
|
||||
- unit tests
|
||||
@@ -38,6 +39,12 @@ The existing `CI` workflow continues to validate:
|
||||
|
||||
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
|
||||
|
||||
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.
|
||||
|
||||
### 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
|
||||
|
||||
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.
|
||||
|
||||
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
|
||||
|
||||
The repository now contains the CI/CD scaffolding, but the existing manual production setup remains untouched:
|
||||
|
||||
+2
-1
@@ -46,7 +46,8 @@ See `.github/PULL_REQUEST_TEMPLATE.md` for the security checklist that must be c
|
||||
|
||||
- No secrets in source code
|
||||
- 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`
|
||||
|
||||
## Incident Response
|
||||
|
||||
@@ -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.
|
||||
- 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
|
||||
- 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
|
||||
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -6,8 +6,13 @@ NEXTAUTH_SECRET=replace-with-a-long-random-secret
|
||||
|
||||
# Optional but commonly needed application settings.
|
||||
SENTRY_DSN=
|
||||
OPENAI_API_KEY=
|
||||
AZURE_OPENAI_API_KEY=
|
||||
AZURE_DALLE_API_KEY=
|
||||
GEMINI_API_KEY=
|
||||
SMTP_HOST=
|
||||
SMTP_PORT=587
|
||||
SMTP_USER=
|
||||
SMTP_PASSWORD=
|
||||
SMTP_FROM=CapaKraken <notifications@example.com>
|
||||
ANONYMIZATION_SEED=
|
||||
|
||||
@@ -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`.
|
||||
2. Fill in the required secrets and URLs.
|
||||
3. Ensure Docker Engine and Docker Compose v2 are installed.
|
||||
4. Ensure the target host can pull from `ghcr.io`.
|
||||
5. Run the image release workflow, then the staging or production deploy workflow with the same image tag.
|
||||
3. Provision runtime AI/SMTP/anonymization secrets on the host through `.env.production` or the platform's secret facility.
|
||||
4. Keep admin settings for status/verification only; do not use them to enter or rotate operational secrets.
|
||||
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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user