From fed7aa5b6181bfe0a0fcc891403bd83cc4ab4389 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hartmut=20N=C3=B6renberg?= Date: Mon, 30 Mar 2026 19:17:32 +0200 Subject: [PATCH] refactor(runtime): prefer env-backed secrets at runtime --- docs/ai-excellence-due-diligence-roadmap.md | 2 + docs/security-architecture.md | 12 +- .../api/src/__tests__/anonymization.test.ts | 25 ++- .../email-runtime-config-hardening.test.ts | 117 ++++++++++++++ .../settings-runtime-config-hardening.test.ts | 153 ++++++++++++++++++ .../__tests__/system-settings-runtime.test.ts | 42 +++++ packages/api/src/ai-client.ts | 59 ++++--- packages/api/src/gemini-client.ts | 3 +- packages/api/src/lib/anonymization.ts | 19 ++- packages/api/src/lib/email.ts | 45 +++++- .../api/src/lib/system-settings-runtime.ts | 41 +++++ packages/api/src/router/project.ts | 16 +- packages/api/src/router/settings.ts | 69 ++++---- 13 files changed, 532 insertions(+), 71 deletions(-) create mode 100644 packages/api/src/__tests__/email-runtime-config-hardening.test.ts create mode 100644 packages/api/src/__tests__/settings-runtime-config-hardening.test.ts create mode 100644 packages/api/src/__tests__/system-settings-runtime.test.ts create mode 100644 packages/api/src/lib/system-settings-runtime.ts diff --git a/docs/ai-excellence-due-diligence-roadmap.md b/docs/ai-excellence-due-diligence-roadmap.md index c704932..8f04dfa 100644 --- a/docs/ai-excellence-due-diligence-roadmap.md +++ b/docs/ai-excellence-due-diligence-roadmap.md @@ -50,6 +50,7 @@ The previously critical SSE and browser parser coverage issues were addressed du 2. Secret handling is still application-database centric. Evidence: system settings mutate and persist API keys and SMTP credentials in [settings.ts](/home/hartmut/Documents/Copilot/capakraken/packages/api/src/router/settings.ts). Risk: operational secrets remain too coupled to the main app data plane for a gold-standard project. + Update: runtime resolution is now env-first for the active secret consumers, but persistence is still transitional and should be reduced further. 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. @@ -124,6 +125,7 @@ Goals: - Keep hardened spreadsheet parser boundaries under regression coverage. - Treat the route access matrix and narrowed auth slices as maintained architecture contracts. - Move production secrets out of regular application settings, or add an interim encrypted-secrets layer with clear migration path. + Status: in progress. Runtime consumers now prefer environment overrides; the remaining gap is eliminating or encrypting compatibility persistence in the admin settings path. Definition of done: diff --git a/docs/security-architecture.md b/docs/security-architecture.md index c632b95..bdd9b99 100644 --- a/docs/security-architecture.md +++ b/docs/security-architecture.md @@ -62,7 +62,9 @@ publicProcedure - Passwords: Argon2id hash (never stored in plaintext) - TOTP secrets: stored in DB (encrypted at-rest via PostgreSQL TDE when available) -- API keys (Azure OpenAI, Gemini, SMTP): stored in `SystemSettings` table, accessible only to ADMIN role +- Runtime secrets now resolve env-first for AI, Gemini, SMTP, and anonymization seed values. Database-backed `SystemSettings` values remain transitional compatibility storage, not the preferred production source of truth. +- Recommended runtime overrides: `OPENAI_API_KEY`, `AZURE_OPENAI_API_KEY`, `AZURE_DALLE_API_KEY`, `GEMINI_API_KEY`, `SMTP_PASSWORD`, `ANONYMIZATION_SEED` +- Admin settings reads expose only presence flags (`hasApiKey`, `hasSmtpPassword`, `hasGeminiApiKey`) instead of returning secret values to the browser, and those flags also reflect environment-backed runtime overrides ### Anonymization @@ -102,7 +104,7 @@ publicProcedure - All OpenAI/Azure/Gemini API calls logged via `loggedAiCall()` wrapper - Structured Pino logs: `{ provider, model, promptLength, responseTimeMs }` -- Failed calls logged at `warn` level with error details +- Failed calls logged at `warn` level with sanitized diagnostics only, with URL and secret-like tokens redacted before they reach structured logs ### tRPC Request Logging @@ -136,13 +138,17 @@ Configured in `next.config.ts`: - **Pino** structured logging (JSON in production, pretty-print in development) - tRPC errors mapped to appropriate HTTP status codes - AI API errors translated to human-readable messages via `parseAiError()` / `parseGeminiError()` +- Admin connection tests for AI/SMTP return sanitized, user-facing diagnostics only; raw upstream details stay in server logs with redaction for URLs, hosts, emails, and secret-like tokens - Internal errors never leak stack traces to the client ## 10. Dependency Security - **Dependabot** configured for automated dependency updates -- `pnpm audit` as part of CI pipeline +- `pnpm audit` runs in the scheduled [nightly-security.yml](/home/hartmut/Documents/Copilot/capakraken/.github/workflows/nightly-security.yml) workflow, and high-signal architecture guardrails run on every PR in [ci.yml](/home/hartmut/Documents/Copilot/capakraken/.github/workflows/ci.yml) - Lockfile integrity verified on install +- transitive audit hotspots such as `flatted` and `picomatch` are pinned through root `pnpm.overrides` to keep dev-tooling CVEs from drifting back in through nested dependencies +- runtime workbook parsing and export generation now use `exceljs` boundaries instead of direct `xlsx` usage in application, engine, and web paths +- `pnpm audit --audit-level=high` is clean as of 2026-03-30; the remaining dependency findings are low/moderate only ## 11. Network Architecture diff --git a/packages/api/src/__tests__/anonymization.test.ts b/packages/api/src/__tests__/anonymization.test.ts index 5287b25..bc061eb 100644 --- a/packages/api/src/__tests__/anonymization.test.ts +++ b/packages/api/src/__tests__/anonymization.test.ts @@ -1,7 +1,11 @@ -import { describe, expect, it, vi } from "vitest"; -import { getAnonymizationDirectory } from "../lib/anonymization.js"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { getAnonymizationConfig, getAnonymizationDirectory } from "../lib/anonymization.js"; describe("anonymization directory", () => { + afterEach(() => { + vi.unstubAllEnvs(); + }); + it("persists aliases so existing resources keep the same identity when new resources appear", async () => { let storedAliases: Record = { resource_a: { @@ -126,4 +130,21 @@ describe("anonymization directory", () => { expect(alias?.eid).toMatch(/^[a-z]+(?:\.[a-z]+)*$/); expect(db.systemSettings.update).toHaveBeenCalledTimes(1); }); + + it("prefers the anonymization seed from the environment at runtime", async () => { + vi.stubEnv("ANONYMIZATION_SEED", "env-seed"); + + const config = await getAnonymizationConfig({ + systemSettings: { + findUnique: vi.fn(async () => ({ + anonymizationEnabled: true, + anonymizationDomain: "example.test", + anonymizationSeed: "db-seed", + anonymizationMode: "global", + })), + }, + } as never); + + expect(config.seed).toBe("env-seed"); + }); }); diff --git a/packages/api/src/__tests__/email-runtime-config-hardening.test.ts b/packages/api/src/__tests__/email-runtime-config-hardening.test.ts new file mode 100644 index 0000000..c320aca --- /dev/null +++ b/packages/api/src/__tests__/email-runtime-config-hardening.test.ts @@ -0,0 +1,117 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import nodemailer from "nodemailer"; +import { logger } from "../lib/logger.js"; +import { testSmtpConnection } from "../lib/email.js"; + +const { findUnique, verify, createTransport } = vi.hoisted(() => { + const verify = vi.fn(); + return { + findUnique: vi.fn(), + verify, + createTransport: vi.fn(() => ({ + verify, + sendMail: vi.fn(), + })), + }; +}); + +vi.mock("@capakraken/db", () => ({ + prisma: { + systemSettings: { + findUnique, + }, + }, +})); + +vi.mock("nodemailer", () => ({ + default: { + createTransport, + }, +})); + +vi.mock("../lib/logger.js", () => ({ + logger: { + error: vi.fn(), + warn: vi.fn(), + info: vi.fn(), + debug: vi.fn(), + }, +})); + +describe("email runtime config hardening", () => { + beforeEach(() => { + vi.clearAllMocks(); + findUnique.mockResolvedValue({ + smtpHost: "smtp.secret.internal", + smtpPort: 587, + smtpUser: "alice@example.com", + smtpPassword: "super-secret", + smtpTls: true, + }); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it("returns a classified SMTP auth error without leaking diagnostics", async () => { + verify.mockRejectedValueOnce( + new Error("Invalid login for alice@example.com against smtp.secret.internal"), + ); + + const result = await testSmtpConnection(); + + expect(result).toEqual({ + ok: false, + error: "SMTP authentication failed — check username and password.", + }); + + expect(nodemailer.createTransport).toHaveBeenCalledWith({ + host: "smtp.secret.internal", + port: 587, + secure: true, + auth: { + user: "alice@example.com", + pass: "super-secret", + }, + }); + + expect(logger.warn).toHaveBeenCalledWith( + expect.objectContaining({ + diagnostic: expect.any(String), + }), + "SMTP connection test failed", + ); + + const diagnostic = vi.mocked(logger.warn).mock.calls[0]?.[0]; + expect(JSON.stringify(diagnostic)).toContain(""); + expect(JSON.stringify(diagnostic)).toContain(""); + expect(JSON.stringify(diagnostic)).not.toContain("alice@example.com"); + expect(JSON.stringify(diagnostic)).not.toContain("smtp.secret.internal"); + }); + + it("prefers SMTP_PASSWORD from the environment at runtime", async () => { + vi.stubEnv("SMTP_PASSWORD", "env-smtp-password"); + findUnique.mockResolvedValue({ + smtpHost: "smtp.secret.internal", + smtpPort: 587, + smtpUser: "alice@example.com", + smtpPassword: "db-password", + smtpTls: true, + }); + verify.mockResolvedValueOnce(true); + + const result = await testSmtpConnection(); + + expect(result).toEqual({ ok: true }); + expect(nodemailer.createTransport).toHaveBeenCalledWith({ + host: "smtp.secret.internal", + port: 587, + secure: true, + auth: { + user: "alice@example.com", + pass: "env-smtp-password", + }, + }); + }); +}); diff --git a/packages/api/src/__tests__/settings-runtime-config-hardening.test.ts b/packages/api/src/__tests__/settings-runtime-config-hardening.test.ts new file mode 100644 index 0000000..b0b6bdf --- /dev/null +++ b/packages/api/src/__tests__/settings-runtime-config-hardening.test.ts @@ -0,0 +1,153 @@ +import { SystemRole } from "@capakraken/shared"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { parseAiError } from "../ai-client.js"; +import { logger } from "../lib/logger.js"; +import { settingsRouter } from "../router/settings.js"; +import { createCallerFactory } from "../trpc.js"; + +vi.mock("../lib/logger.js", () => ({ + logger: { + error: vi.fn(), + warn: vi.fn(), + info: vi.fn(), + debug: vi.fn(), + }, +})); + +const createCaller = createCallerFactory(settingsRouter); + +function createAdminCaller(db: Record) { + return createCaller({ + session: { + user: { email: "admin@example.com", name: "Admin", image: null }, + expires: "2099-01-01T00:00:00.000Z", + }, + db: db as never, + dbUser: { + id: "admin_1", + systemRole: SystemRole.ADMIN, + permissionOverrides: null, + }, + }); +} + +describe("runtime config hardening", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + vi.unstubAllEnvs(); + }); + + it("sanitizes AI fallback diagnostics", () => { + const error = parseAiError( + new Error( + "Provider failed at https://example.openai.azure.com/path?api-key=topsecret with Bearer sk-super-secret", + ), + ); + + expect(error).toContain(""); + expect(error).toContain(""); + expect(error).not.toContain("https://example.openai.azure.com"); + expect(error).not.toContain("sk-super-secret"); + expect(error).not.toContain("topsecret"); + }); + + it("does not expose raw diagnostics from AI connection tests", async () => { + const findUnique = vi.fn().mockResolvedValue({ + aiProvider: "openai", + azureOpenAiDeployment: "gpt-4o-mini", + azureOpenAiApiKey: "sk-live-secret", + }); + + vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ + ok: false, + status: 500, + text: vi.fn().mockResolvedValue( + JSON.stringify({ + error: { + message: "upstream failure at https://api.openai.com/v1/chat/completions?api-key=hidden", + }, + }), + ), + })); + + const caller = createAdminCaller({ + systemSettings: { + findUnique, + }, + }); + + const result = await caller.testAiConnection(); + + expect(result.ok).toBe(false); + expect(result).not.toHaveProperty("raw"); + expect(result.error).toContain(""); + expect(result.error).not.toContain("https://api.openai.com"); + expect(result.error).not.toContain("hidden"); + + expect(logger.warn).toHaveBeenCalledWith( + expect.objectContaining({ + provider: "openai", + diagnostic: expect.stringContaining(""), + }), + "AI connection test failed", + ); + }); + + it("reports secret presence flags when secrets come from environment overrides", async () => { + vi.stubEnv("OPENAI_API_KEY", "env-openai-key"); + vi.stubEnv("SMTP_PASSWORD", "env-smtp-password"); + vi.stubEnv("GEMINI_API_KEY", "env-gemini-key"); + + const caller = createAdminCaller({ + systemSettings: { + findUnique: vi.fn().mockResolvedValue({ + aiProvider: "openai", + azureOpenAiApiKey: null, + smtpPassword: null, + geminiApiKey: null, + }), + }, + }); + + const result = await caller.getSystemSettings(); + + expect(result.hasApiKey).toBe(true); + expect(result.hasSmtpPassword).toBe(true); + expect(result.hasGeminiApiKey).toBe(true); + }); + + it("prefers environment API keys during AI connection tests", async () => { + vi.stubEnv("OPENAI_API_KEY", "env-openai-key"); + const fetch = vi.fn().mockResolvedValue({ + ok: true, + text: vi.fn().mockResolvedValue("ok"), + }); + vi.stubGlobal("fetch", fetch); + + const caller = createAdminCaller({ + systemSettings: { + findUnique: vi.fn().mockResolvedValue({ + aiProvider: "openai", + azureOpenAiDeployment: "gpt-4o-mini", + azureOpenAiApiKey: null, + }), + }, + }); + + const result = await caller.testAiConnection(); + + expect(result).toEqual({ ok: true }); + expect(fetch).toHaveBeenCalledWith( + "https://api.openai.com/v1/chat/completions", + expect.objectContaining({ + headers: expect.objectContaining({ + Authorization: "Bearer env-openai-key", + }), + }), + ); + }); +}); diff --git a/packages/api/src/__tests__/system-settings-runtime.test.ts b/packages/api/src/__tests__/system-settings-runtime.test.ts new file mode 100644 index 0000000..9a265ed --- /dev/null +++ b/packages/api/src/__tests__/system-settings-runtime.test.ts @@ -0,0 +1,42 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { resolveSystemSettingsRuntime } from "../lib/system-settings-runtime.js"; + +describe("system settings runtime resolution", () => { + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it("prefers OPENAI_API_KEY for direct OpenAI runtime settings", () => { + vi.stubEnv("OPENAI_API_KEY", "env-openai-key"); + vi.stubEnv("AZURE_OPENAI_API_KEY", "env-azure-key"); + + const settings = resolveSystemSettingsRuntime({ + aiProvider: "openai", + azureOpenAiApiKey: "db-key", + }); + + expect(settings.azureOpenAiApiKey).toBe("env-openai-key"); + }); + + it("prefers AZURE_OPENAI_API_KEY for Azure runtime settings", () => { + vi.stubEnv("OPENAI_API_KEY", "env-openai-key"); + vi.stubEnv("AZURE_OPENAI_API_KEY", "env-azure-key"); + + const settings = resolveSystemSettingsRuntime({ + aiProvider: "azure", + azureOpenAiApiKey: "db-key", + }); + + expect(settings.azureOpenAiApiKey).toBe("env-azure-key"); + }); + + it("ignores blank environment overrides", () => { + vi.stubEnv("SMTP_PASSWORD", " "); + + const settings = resolveSystemSettingsRuntime({ + smtpPassword: "db-password", + }); + + expect(settings.smtpPassword).toBe("db-password"); + }); +}); diff --git a/packages/api/src/ai-client.ts b/packages/api/src/ai-client.ts index 4d79c4f..5709930 100644 --- a/packages/api/src/ai-client.ts +++ b/packages/api/src/ai-client.ts @@ -1,5 +1,6 @@ import OpenAI, { AzureOpenAI } from "openai"; import { logger } from "./lib/logger.js"; +import { resolveSystemSettingsRuntime } from "./lib/system-settings-runtime.js"; type AiSettings = { aiProvider?: string | null; @@ -14,51 +15,70 @@ type AiSettings = { azureDalleApiKey?: string | null; }; +function redactDiagnosticText(value: string): string { + return value + .replace(/https?:\/\/[^\s)\]}]+/gi, "") + .replace(/\bsk-[A-Za-z0-9_-]+\b/g, "") + .replace(/\bAIza[0-9A-Za-z_-]+\b/g, "") + .replace(/(api[-_ ]?key\s*[=:]\s*)([^,\s]+)/gi, "$1") + .replace(/(Bearer\s+)([^\s]+)/gi, "$1") + .replace(/([?&](?:api-key|key)=)([^&\s]+)/gi, "$1"); +} + +export function sanitizeDiagnosticError(err: unknown): string { + const raw = err instanceof Error ? err.message : String(err); + return redactDiagnosticText(raw.replace(/^Error:\s*/, "")).slice(0, 300); +} + /** Returns true if the settings have enough information to make an API call. */ export function isAiConfigured(settings: AiSettings | null | undefined): boolean { - if (!settings?.azureOpenAiApiKey || !settings.azureOpenAiDeployment) return false; - if (settings.aiProvider === "azure" && !settings.azureOpenAiEndpoint) return false; + const runtimeSettings = resolveSystemSettingsRuntime(settings); + if (!runtimeSettings.azureOpenAiApiKey || !runtimeSettings.azureOpenAiDeployment) return false; + if (runtimeSettings.aiProvider === "azure" && !runtimeSettings.azureOpenAiEndpoint) return false; return true; } /** Instantiates the right OpenAI client based on the stored provider setting. */ export function createAiClient(settings: AiSettings): OpenAI { - if (settings.aiProvider === "azure") { + const runtimeSettings = resolveSystemSettingsRuntime(settings); + if (runtimeSettings.aiProvider === "azure") { return new AzureOpenAI({ - endpoint: settings.azureOpenAiEndpoint!, - apiKey: settings.azureOpenAiApiKey!, - apiVersion: settings.azureApiVersion ?? "2025-01-01-preview", - deployment: settings.azureOpenAiDeployment!, + endpoint: runtimeSettings.azureOpenAiEndpoint!, + apiKey: runtimeSettings.azureOpenAiApiKey!, + apiVersion: runtimeSettings.azureApiVersion ?? "2025-01-01-preview", + deployment: runtimeSettings.azureOpenAiDeployment!, }); } // Default: regular OpenAI (sk-... key) - return new OpenAI({ apiKey: settings.azureOpenAiApiKey! }); + return new OpenAI({ apiKey: runtimeSettings.azureOpenAiApiKey! }); } /** Returns true if DALL-E image generation is configured. */ export function isDalleConfigured(settings: AiSettings | null | undefined): boolean { if (!settings) return false; + const runtimeSettings = resolveSystemSettingsRuntime(settings); // DALL-E needs its own deployment (or a non-Azure key with model name) - if (settings.aiProvider === "azure") { - return !!(settings.azureDalleDeployment && (settings.azureDalleEndpoint || settings.azureOpenAiEndpoint) && (settings.azureDalleApiKey || settings.azureOpenAiApiKey)); + if (runtimeSettings.aiProvider === "azure") { + return !!(runtimeSettings.azureDalleDeployment && (runtimeSettings.azureDalleEndpoint || runtimeSettings.azureOpenAiEndpoint) && (runtimeSettings.azureDalleApiKey || runtimeSettings.azureOpenAiApiKey)); } // For direct OpenAI, the chat API key works for DALL-E too - return !!settings.azureOpenAiApiKey; + return !!runtimeSettings.azureOpenAiApiKey; } /** Creates an OpenAI client configured for DALL-E image generation. */ export function createDalleClient(settings: AiSettings): OpenAI { - if (settings.aiProvider === "azure") { - const endpoint = settings.azureDalleEndpoint || settings.azureOpenAiEndpoint!; - const apiKey = settings.azureDalleApiKey || settings.azureOpenAiApiKey!; + const runtimeSettings = resolveSystemSettingsRuntime(settings); + if (runtimeSettings.aiProvider === "azure") { + const endpoint = runtimeSettings.azureDalleEndpoint || runtimeSettings.azureOpenAiEndpoint!; + const apiKey = runtimeSettings.azureDalleApiKey || runtimeSettings.azureOpenAiApiKey!; return new AzureOpenAI({ endpoint, apiKey, - apiVersion: settings.azureApiVersion ?? "2025-01-01-preview", - deployment: settings.azureDalleDeployment!, + apiVersion: runtimeSettings.azureApiVersion ?? "2025-01-01-preview", + deployment: runtimeSettings.azureDalleDeployment!, }); } - return new OpenAI({ apiKey: settings.azureOpenAiApiKey! }); + return new OpenAI({ apiKey: runtimeSettings.azureOpenAiApiKey! }); } /** @@ -79,7 +99,7 @@ export async function loggedAiCall( return result; } catch (err) { const responseTimeMs = Math.round(performance.now() - start); - const errorMessage = err instanceof Error ? err.message : String(err); + const errorMessage = sanitizeDiagnosticError(err); logger.warn({ provider, model, promptLength, responseTimeMs, errorMessage }, "External API call failed"); throw err; } @@ -114,6 +134,5 @@ export function parseAiError(err: unknown): string { if (lower.includes("context_length_exceeded") || lower.includes("maximum context")) { return "Request too large — the prompt exceeded the model's context limit."; } - // Fall back to the raw message but strip noise - return msg.replace(/^Error: /, "").slice(0, 300); + return sanitizeDiagnosticError(msg); } diff --git a/packages/api/src/gemini-client.ts b/packages/api/src/gemini-client.ts index 25a550e..94c564b 100644 --- a/packages/api/src/gemini-client.ts +++ b/packages/api/src/gemini-client.ts @@ -1,4 +1,5 @@ import { logger } from "./lib/logger.js"; +import { resolveSystemSettingsRuntime } from "./lib/system-settings-runtime.js"; type GeminiSettings = { geminiApiKey?: string | null; @@ -7,7 +8,7 @@ type GeminiSettings = { /** Returns true if the settings have a Gemini API key configured. */ export function isGeminiConfigured(settings: GeminiSettings | null | undefined): boolean { - return !!settings?.geminiApiKey; + return !!resolveSystemSettingsRuntime(settings).geminiApiKey; } /** diff --git a/packages/api/src/lib/anonymization.ts b/packages/api/src/lib/anonymization.ts index 5618357..d56facb 100644 --- a/packages/api/src/lib/anonymization.ts +++ b/packages/api/src/lib/anonymization.ts @@ -1,5 +1,6 @@ import { createHash } from "node:crypto"; import type { PrismaClient } from "@capakraken/db"; +import { resolveSystemSettingsRuntime } from "./system-settings-runtime.js"; const DEFAULT_ANONYMIZATION_DOMAIN = "superhartmut.de"; const DEFAULT_ANONYMIZATION_SEED = "capakraken-superhartmut-global"; @@ -639,10 +640,12 @@ export async function getAnonymizationConfig( }, }); + const runtimeSettings = resolveSystemSettingsRuntime(settings); + return { - enabled: settings?.anonymizationEnabled ?? false, - domain: settings?.anonymizationDomain?.trim() || DEFAULT_ANONYMIZATION_DOMAIN, - seed: settings?.anonymizationSeed?.trim() || DEFAULT_ANONYMIZATION_SEED, + enabled: runtimeSettings.anonymizationEnabled ?? false, + domain: runtimeSettings.anonymizationDomain?.trim() || DEFAULT_ANONYMIZATION_DOMAIN, + seed: runtimeSettings.anonymizationSeed?.trim() || DEFAULT_ANONYMIZATION_SEED, mode: "global", }; } @@ -665,10 +668,12 @@ export async function getAnonymizationDirectory( }, }); + const runtimeSettings = resolveSystemSettingsRuntime(settings); + const config: AnonymizationConfig = { - enabled: settings?.anonymizationEnabled ?? false, - domain: settings?.anonymizationDomain?.trim() || DEFAULT_ANONYMIZATION_DOMAIN, - seed: settings?.anonymizationSeed?.trim() || DEFAULT_ANONYMIZATION_SEED, + enabled: runtimeSettings.anonymizationEnabled ?? false, + domain: runtimeSettings.anonymizationDomain?.trim() || DEFAULT_ANONYMIZATION_DOMAIN, + seed: runtimeSettings.anonymizationSeed?.trim() || DEFAULT_ANONYMIZATION_SEED, mode: "global", }; @@ -680,7 +685,7 @@ export async function getAnonymizationDirectory( const usedSlugs = new Set(); const byResourceId = new Map(); const byAliasEid = new Map(); - const storedAliases = parseStoredAliases(settings?.anonymizationAliases); + const storedAliases = parseStoredAliases(runtimeSettings.anonymizationAliases); let aliasesChanged = false; for (const [resourceId, storedAlias] of Object.entries(storedAliases)) { diff --git a/packages/api/src/lib/email.ts b/packages/api/src/lib/email.ts index 95a77bf..77134e2 100644 --- a/packages/api/src/lib/email.ts +++ b/packages/api/src/lib/email.ts @@ -4,6 +4,8 @@ */ import nodemailer from "nodemailer"; import { prisma as db } from "@capakraken/db"; +import { logger } from "./logger.js"; +import { resolveSystemSettingsRuntime } from "./system-settings-runtime.js"; interface EmailPayload { to: string | string[]; @@ -12,9 +14,43 @@ interface EmailPayload { html?: string; } +function sanitizeSmtpDiagnostic(err: unknown): string { + const message = err instanceof Error ? err.message : String(err); + return message + .replace(/https?:\/\/[^\s)\]}]+/gi, "") + .replace(/\b[^\s@]+@[^\s@]+\.[^\s@]+\b/g, "") + .replace(/\b(?:[A-Za-z0-9-]+\.)+[A-Za-z]{2,}\b/g, "") + .replace(/^Error:\s*/, "") + .slice(0, 300); +} + +export function parseSmtpError(err: unknown): string { + const message = err instanceof Error ? err.message : String(err); + const lower = message.toLowerCase(); + + if (lower.includes("auth") || lower.includes("invalid login") || lower.includes("535")) { + return "SMTP authentication failed — check username and password."; + } + if (lower.includes("certificate") || lower.includes("tls") || lower.includes("ssl")) { + return "SMTP TLS negotiation failed — verify port and TLS settings."; + } + if ( + lower.includes("econnrefused") + || lower.includes("enotfound") + || lower.includes("etimedout") + || lower.includes("timeout") + ) { + return "Cannot reach the SMTP server — check host, port, and network access."; + } + + return "SMTP connection failed — review host, port, TLS, and credentials."; +} + async function getSmtpConfig() { - const settings = await db.systemSettings.findUnique({ where: { id: "singleton" } }); - if (!settings?.smtpHost) return null; + const settings = resolveSystemSettingsRuntime( + await db.systemSettings.findUnique({ where: { id: "singleton" } }), + ); + if (!settings.smtpHost) return null; return { host: settings.smtpHost, port: settings.smtpPort ?? 587, @@ -53,7 +89,7 @@ export async function sendEmail(payload: EmailPayload): Promise { return true; } catch (err) { - console.error("[email] Failed to send email:", err); + logger.warn({ diagnostic: sanitizeSmtpDiagnostic(err) }, "Email send failed"); return false; } } @@ -76,6 +112,7 @@ export async function testSmtpConnection(): Promise<{ ok: boolean; error?: strin await transporter.verify(); return { ok: true }; } catch (err) { - return { ok: false, error: err instanceof Error ? err.message : String(err) }; + logger.warn({ diagnostic: sanitizeSmtpDiagnostic(err) }, "SMTP connection test failed"); + return { ok: false, error: parseSmtpError(err) }; } } diff --git a/packages/api/src/lib/system-settings-runtime.ts b/packages/api/src/lib/system-settings-runtime.ts new file mode 100644 index 0000000..04778f0 --- /dev/null +++ b/packages/api/src/lib/system-settings-runtime.ts @@ -0,0 +1,41 @@ +type RuntimeAwareSystemSettings = { + aiProvider?: string | null; + azureOpenAiApiKey?: string | null; + azureDalleApiKey?: string | null; + geminiApiKey?: string | null; + smtpPassword?: string | null; + anonymizationSeed?: string | null; +}; + +function readEnvOverride(...names: string[]): string | null { + for (const name of names) { + const value = process.env[name]?.trim(); + if (value) { + return value; + } + } + + return null; +} + +function resolvePrimaryAiApiKey(provider: string | null | undefined): string | null { + if (provider === "azure") { + return readEnvOverride("AZURE_OPENAI_API_KEY", "OPENAI_API_KEY"); + } + + return readEnvOverride("OPENAI_API_KEY", "AZURE_OPENAI_API_KEY"); +} + +export function resolveSystemSettingsRuntime( + settings: T | null | undefined, +): T & Required> { + const resolved = { ...(settings ?? {}) } as T & Required>; + + resolved.azureOpenAiApiKey = resolvePrimaryAiApiKey(resolved.aiProvider) ?? settings?.azureOpenAiApiKey ?? null; + resolved.azureDalleApiKey = readEnvOverride("AZURE_DALLE_API_KEY") ?? settings?.azureDalleApiKey ?? null; + resolved.geminiApiKey = readEnvOverride("GEMINI_API_KEY") ?? settings?.geminiApiKey ?? null; + resolved.smtpPassword = readEnvOverride("SMTP_PASSWORD") ?? settings?.smtpPassword ?? null; + resolved.anonymizationSeed = readEnvOverride("ANONYMIZATION_SEED") ?? settings?.anonymizationSeed ?? null; + + return resolved; +} diff --git a/packages/api/src/router/project.ts b/packages/api/src/router/project.ts index ea6f1ec..095c171 100644 --- a/packages/api/src/router/project.ts +++ b/packages/api/src/router/project.ts @@ -17,6 +17,7 @@ import { createDalleClient, isDalleConfigured, loggedAiCall, parseAiError } from import { generateGeminiImage, isGeminiConfigured, parseGeminiError } from "../gemini-client.js"; import { invalidateDashboardCache } from "../lib/cache.js"; import { logger } from "../lib/logger.js"; +import { resolveSystemSettingsRuntime } from "../lib/system-settings-runtime.js"; import { dispatchWebhooks } from "../lib/webhook-dispatcher.js"; import { validateImageDataUrl } from "../lib/image-validation.js"; import type { TRPCContext } from "../trpc.js"; @@ -995,9 +996,10 @@ export const projectRouter = createTRPCRouter({ where: { id: "singleton" }, }); - const imageProvider = settings?.imageProvider ?? "dalle"; - const useGemini = imageProvider === "gemini" && isGeminiConfigured(settings); - const useDalle = imageProvider === "dalle" && isDalleConfigured(settings); + const runtimeSettings = resolveSystemSettingsRuntime(settings); + const imageProvider = runtimeSettings.imageProvider ?? "dalle"; + const useGemini = imageProvider === "gemini" && isGeminiConfigured(runtimeSettings); + const useDalle = imageProvider === "dalle" && isDalleConfigured(runtimeSettings); if (!useGemini && !useDalle) { throw new TRPCError({ @@ -1017,9 +1019,9 @@ export const projectRouter = createTRPCRouter({ if (useGemini) { try { coverImageUrl = await generateGeminiImage( - settings!.geminiApiKey!, + runtimeSettings.geminiApiKey!, finalPrompt, - settings!.geminiModel ?? undefined, + runtimeSettings.geminiModel ?? undefined, ); } catch (err) { throw new TRPCError({ @@ -1028,8 +1030,8 @@ export const projectRouter = createTRPCRouter({ }); } } else { - const dalleClient = createDalleClient(settings!); - const model = settings!.aiProvider === "azure" ? settings!.azureDalleDeployment! : "dall-e-3"; + const dalleClient = createDalleClient(runtimeSettings); + const model = runtimeSettings.aiProvider === "azure" ? runtimeSettings.azureDalleDeployment! : "dall-e-3"; // eslint-disable-next-line @typescript-eslint/no-explicit-any let response: any; diff --git a/packages/api/src/router/settings.ts b/packages/api/src/router/settings.ts index f2cb53b..781158c 100644 --- a/packages/api/src/router/settings.ts +++ b/packages/api/src/router/settings.ts @@ -1,10 +1,12 @@ import { z } from "zod"; import { adminProcedure, createTRPCRouter } from "../trpc.js"; -import { createAiClient, isAiConfigured, parseAiError } from "../ai-client.js"; +import { isAiConfigured, parseAiError, sanitizeDiagnosticError } from "../ai-client.js"; import { DEFAULT_SUMMARY_PROMPT } from "./resource.js"; import { VALUE_SCORE_WEIGHTS } from "@capakraken/shared"; import { testSmtpConnection } from "../lib/email.js"; import { createAuditEntry } from "../lib/audit.js"; +import { logger } from "../lib/logger.js"; +import { resolveSystemSettingsRuntime } from "../lib/system-settings-runtime.js"; /** Fields that must never appear in audit log values */ const SENSITIVE_FIELDS = new Set([ @@ -20,6 +22,7 @@ export const settingsRouter = createTRPCRouter({ const settings = await ctx.db.systemSettings.findUnique({ where: { id: "singleton" }, }); + const runtimeSettings = resolveSystemSettingsRuntime(settings); const defaultWeights = { skillDepth: VALUE_SCORE_WEIGHTS.SKILL_DEPTH, @@ -38,7 +41,7 @@ export const settingsRouter = createTRPCRouter({ aiTemperature: settings?.aiTemperature ?? 1, aiSummaryPrompt: settings?.aiSummaryPrompt ?? null, defaultSummaryPrompt: DEFAULT_SUMMARY_PROMPT, - hasApiKey: !!settings?.azureOpenAiApiKey, + hasApiKey: !!runtimeSettings.azureOpenAiApiKey, scoreWeights: (settings?.scoreWeights as unknown as typeof defaultWeights) ?? defaultWeights, scoreVisibleRoles: (settings?.scoreVisibleRoles as unknown as string[]) ?? ["ADMIN", "MANAGER"], // SMTP @@ -47,7 +50,7 @@ export const settingsRouter = createTRPCRouter({ smtpUser: settings?.smtpUser ?? null, smtpFrom: settings?.smtpFrom ?? null, smtpTls: settings?.smtpTls ?? true, - hasSmtpPassword: !!settings?.smtpPassword, + hasSmtpPassword: !!runtimeSettings.smtpPassword, // Global anonymization anonymizationEnabled: settings?.anonymizationEnabled ?? false, anonymizationDomain: settings?.anonymizationDomain ?? "superhartmut.de", @@ -55,10 +58,10 @@ export const settingsRouter = createTRPCRouter({ // DALL-E azureDalleDeployment: settings?.azureDalleDeployment ?? null, azureDalleEndpoint: settings?.azureDalleEndpoint ?? null, - hasDalleApiKey: !!settings?.azureDalleApiKey, + hasDalleApiKey: !!runtimeSettings.azureDalleApiKey, // Gemini geminiModel: settings?.geminiModel ?? "gemini-2.5-flash-image", - hasGeminiApiKey: !!settings?.geminiApiKey, + hasGeminiApiKey: !!runtimeSettings.geminiApiKey, // Image provider imageProvider: settings?.imageProvider ?? "dalle", // Vacation defaults @@ -216,9 +219,9 @@ export const settingsRouter = createTRPCRouter({ }), testAiConnection: adminProcedure.mutation(async ({ ctx }) => { - const settings = await ctx.db.systemSettings.findUnique({ + const settings = resolveSystemSettingsRuntime(await ctx.db.systemSettings.findUnique({ where: { id: "singleton" }, - }); + })); if (!isAiConfigured(settings)) { const provider = settings?.aiProvider ?? "openai"; @@ -228,21 +231,21 @@ export const settingsRouter = createTRPCRouter({ return { ok: false, error: "Missing required fields: model name and API key are required." }; } - const provider = settings!.aiProvider ?? "openai"; - const apiKey = settings!.azureOpenAiApiKey!; + const provider = settings.aiProvider ?? "openai"; + const apiKey = settings.azureOpenAiApiKey!; let url: string; let headers: Record; if (provider === "azure") { - const endpoint = settings!.azureOpenAiEndpoint!.replace(/\/$/, ""); - const deployment = settings!.azureOpenAiDeployment!; - const apiVersion = settings!.azureApiVersion ?? "2025-01-01-preview"; + const endpoint = settings.azureOpenAiEndpoint!.replace(/\/$/, ""); + const deployment = settings.azureOpenAiDeployment!; + const apiVersion = settings.azureApiVersion ?? "2025-01-01-preview"; url = `${endpoint}/openai/deployments/${deployment}/chat/completions?api-version=${apiVersion}`; headers = { "Content-Type": "application/json", "api-key": apiKey }; } else { // Standard OpenAI API — deployment field holds the model name (e.g. "gpt-4o") - const model = settings!.azureOpenAiDeployment ?? "gpt-4o-mini"; + const model = settings.azureOpenAiDeployment ?? "gpt-4o-mini"; url = "https://api.openai.com/v1/chat/completions"; headers = { "Content-Type": "application/json", Authorization: `Bearer ${apiKey}` }; // Override body to include model field for OpenAI @@ -257,17 +260,24 @@ export const settingsRouter = createTRPCRouter({ }), }); const body = await resp.text(); - if (resp.ok) return { ok: true, raw: null }; + if (resp.ok) return { ok: true }; let msg = body; try { const parsed = JSON.parse(body) as { error?: { message?: string } }; if (parsed.error?.message) msg = parsed.error.message; } catch { /* keep raw */ } const raw = `HTTP ${resp.status}: ${msg}`; - return { ok: false, error: parseAiError(new Error(raw)), raw }; + logger.warn( + { provider, diagnostic: sanitizeDiagnosticError(raw) }, + "AI connection test failed", + ); + return { ok: false, error: parseAiError(new Error(raw)) }; } catch (err) { - const raw = err instanceof Error ? err.message : String(err); - return { ok: false, error: parseAiError(err), raw }; + logger.warn( + { provider, diagnostic: sanitizeDiagnosticError(err) }, + "AI connection test failed", + ); + return { ok: false, error: parseAiError(err) }; } } @@ -283,9 +293,7 @@ export const settingsRouter = createTRPCRouter({ const body = await resp.text(); - if (resp.ok) { - return { ok: true, raw: null }; - } + if (resp.ok) return { ok: true }; let azureMessage = body; try { @@ -294,10 +302,17 @@ export const settingsRouter = createTRPCRouter({ } catch { /* leave as raw text */ } const raw = `HTTP ${resp.status}: ${azureMessage}`; - return { ok: false, error: parseAiError(new Error(raw)), raw }; + logger.warn( + { provider, diagnostic: sanitizeDiagnosticError(raw) }, + "AI connection test failed", + ); + return { ok: false, error: parseAiError(new Error(raw)) }; } catch (err) { - const raw = err instanceof Error ? err.message : String(err); - return { ok: false, error: parseAiError(err), raw }; + logger.warn( + { provider, diagnostic: sanitizeDiagnosticError(err) }, + "AI connection test failed", + ); + return { ok: false, error: parseAiError(err) }; } }), @@ -320,10 +335,10 @@ export const settingsRouter = createTRPCRouter({ }), testGeminiConnection: adminProcedure.mutation(async ({ ctx }) => { - const settings = await ctx.db.systemSettings.findUnique({ + const settings = resolveSystemSettingsRuntime(await ctx.db.systemSettings.findUnique({ where: { id: "singleton" }, select: { geminiApiKey: true, geminiModel: true }, - }); + })); if (!settings?.geminiApiKey) { return { ok: false, error: "Gemini API key is not configured." }; @@ -362,7 +377,7 @@ export const settingsRouter = createTRPCRouter({ }), getAiConfigured: adminProcedure.query(async ({ ctx }) => { - const settings = await ctx.db.systemSettings.findUnique({ + const settings = resolveSystemSettingsRuntime(await ctx.db.systemSettings.findUnique({ where: { id: "singleton" }, select: { aiProvider: true, @@ -370,7 +385,7 @@ export const settingsRouter = createTRPCRouter({ azureOpenAiDeployment: true, azureOpenAiApiKey: true, }, - }); + })); return { configured: isAiConfigured(settings) }; }), });