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