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";
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.
+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
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
+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
- `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
+3 -3
View File
@@ -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
View File
@@ -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:
+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:
- 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
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
- 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
+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.
- 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;
}
+21 -13
View File
@@ -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) };
},
+1
View File
@@ -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",
+60 -10
View File
@@ -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" },
+5
View File
@@ -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=
+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`.
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