refactor(runtime): prefer env-backed secrets at runtime
This commit is contained in:
@@ -50,6 +50,7 @@ The previously critical SSE and browser parser coverage issues were addressed du
|
|||||||
2. Secret handling is still application-database centric.
|
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).
|
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.
|
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.
|
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.
|
||||||
@@ -124,6 +125,7 @@ Goals:
|
|||||||
- 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.
|
- 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:
|
Definition of done:
|
||||||
|
|
||||||
|
|||||||
@@ -62,7 +62,9 @@ publicProcedure
|
|||||||
|
|
||||||
- Passwords: Argon2id hash (never stored in plaintext)
|
- Passwords: Argon2id hash (never stored in plaintext)
|
||||||
- TOTP secrets: stored in DB (encrypted at-rest via PostgreSQL TDE when available)
|
- 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
|
### Anonymization
|
||||||
|
|
||||||
@@ -102,7 +104,7 @@ publicProcedure
|
|||||||
|
|
||||||
- All OpenAI/Azure/Gemini API calls logged via `loggedAiCall()` wrapper
|
- All OpenAI/Azure/Gemini API calls logged via `loggedAiCall()` wrapper
|
||||||
- Structured Pino logs: `{ provider, model, promptLength, responseTimeMs }`
|
- 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
|
### tRPC Request Logging
|
||||||
|
|
||||||
@@ -136,13 +138,17 @@ Configured in `next.config.ts`:
|
|||||||
- **Pino** structured logging (JSON in production, pretty-print in development)
|
- **Pino** structured logging (JSON in production, pretty-print in development)
|
||||||
- tRPC errors mapped to appropriate HTTP status codes
|
- tRPC errors mapped to appropriate HTTP status codes
|
||||||
- AI API errors translated to human-readable messages via `parseAiError()` / `parseGeminiError()`
|
- 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
|
- Internal errors never leak stack traces to the client
|
||||||
|
|
||||||
## 10. Dependency Security
|
## 10. Dependency Security
|
||||||
|
|
||||||
- **Dependabot** configured for automated dependency updates
|
- **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
|
- 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
|
## 11. Network Architecture
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
import { describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
import { getAnonymizationDirectory } from "../lib/anonymization.js";
|
import { getAnonymizationConfig, getAnonymizationDirectory } from "../lib/anonymization.js";
|
||||||
|
|
||||||
describe("anonymization directory", () => {
|
describe("anonymization directory", () => {
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllEnvs();
|
||||||
|
});
|
||||||
|
|
||||||
it("persists aliases so existing resources keep the same identity when new resources appear", async () => {
|
it("persists aliases so existing resources keep the same identity when new resources appear", async () => {
|
||||||
let storedAliases: Record<string, { displayName: string; eid: string }> = {
|
let storedAliases: Record<string, { displayName: string; eid: string }> = {
|
||||||
resource_a: {
|
resource_a: {
|
||||||
@@ -126,4 +130,21 @@ describe("anonymization directory", () => {
|
|||||||
expect(alias?.eid).toMatch(/^[a-z]+(?:\.[a-z]+)*$/);
|
expect(alias?.eid).toMatch(/^[a-z]+(?:\.[a-z]+)*$/);
|
||||||
expect(db.systemSettings.update).toHaveBeenCalledTimes(1);
|
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");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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("<redacted-email>");
|
||||||
|
expect(JSON.stringify(diagnostic)).toContain("<redacted-host>");
|
||||||
|
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",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<string, unknown>) {
|
||||||
|
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("<redacted-url>");
|
||||||
|
expect(error).toContain("<redacted-secret>");
|
||||||
|
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("<redacted-url>");
|
||||||
|
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("<redacted-url>"),
|
||||||
|
}),
|
||||||
|
"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",
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import OpenAI, { AzureOpenAI } from "openai";
|
import OpenAI, { AzureOpenAI } from "openai";
|
||||||
import { logger } from "./lib/logger.js";
|
import { logger } from "./lib/logger.js";
|
||||||
|
import { resolveSystemSettingsRuntime } from "./lib/system-settings-runtime.js";
|
||||||
|
|
||||||
type AiSettings = {
|
type AiSettings = {
|
||||||
aiProvider?: string | null;
|
aiProvider?: string | null;
|
||||||
@@ -14,51 +15,70 @@ type AiSettings = {
|
|||||||
azureDalleApiKey?: string | null;
|
azureDalleApiKey?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function redactDiagnosticText(value: string): string {
|
||||||
|
return value
|
||||||
|
.replace(/https?:\/\/[^\s)\]}]+/gi, "<redacted-url>")
|
||||||
|
.replace(/\bsk-[A-Za-z0-9_-]+\b/g, "<redacted-secret>")
|
||||||
|
.replace(/\bAIza[0-9A-Za-z_-]+\b/g, "<redacted-secret>")
|
||||||
|
.replace(/(api[-_ ]?key\s*[=:]\s*)([^,\s]+)/gi, "$1<redacted-secret>")
|
||||||
|
.replace(/(Bearer\s+)([^\s]+)/gi, "$1<redacted-secret>")
|
||||||
|
.replace(/([?&](?:api-key|key)=)([^&\s]+)/gi, "$1<redacted-secret>");
|
||||||
|
}
|
||||||
|
|
||||||
|
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. */
|
/** Returns true if the settings have enough information to make an API call. */
|
||||||
export function isAiConfigured(settings: AiSettings | null | undefined): boolean {
|
export function isAiConfigured(settings: AiSettings | null | undefined): boolean {
|
||||||
if (!settings?.azureOpenAiApiKey || !settings.azureOpenAiDeployment) return false;
|
const runtimeSettings = resolveSystemSettingsRuntime(settings);
|
||||||
if (settings.aiProvider === "azure" && !settings.azureOpenAiEndpoint) return false;
|
if (!runtimeSettings.azureOpenAiApiKey || !runtimeSettings.azureOpenAiDeployment) return false;
|
||||||
|
if (runtimeSettings.aiProvider === "azure" && !runtimeSettings.azureOpenAiEndpoint) return false;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Instantiates the right OpenAI client based on the stored provider setting. */
|
/** Instantiates the right OpenAI client based on the stored provider setting. */
|
||||||
export function createAiClient(settings: AiSettings): OpenAI {
|
export function createAiClient(settings: AiSettings): OpenAI {
|
||||||
if (settings.aiProvider === "azure") {
|
const runtimeSettings = resolveSystemSettingsRuntime(settings);
|
||||||
|
if (runtimeSettings.aiProvider === "azure") {
|
||||||
return new AzureOpenAI({
|
return new AzureOpenAI({
|
||||||
endpoint: settings.azureOpenAiEndpoint!,
|
endpoint: runtimeSettings.azureOpenAiEndpoint!,
|
||||||
apiKey: settings.azureOpenAiApiKey!,
|
apiKey: runtimeSettings.azureOpenAiApiKey!,
|
||||||
apiVersion: settings.azureApiVersion ?? "2025-01-01-preview",
|
apiVersion: runtimeSettings.azureApiVersion ?? "2025-01-01-preview",
|
||||||
deployment: settings.azureOpenAiDeployment!,
|
deployment: runtimeSettings.azureOpenAiDeployment!,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
// Default: regular OpenAI (sk-... key)
|
// 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. */
|
/** Returns true if DALL-E image generation is configured. */
|
||||||
export function isDalleConfigured(settings: AiSettings | null | undefined): boolean {
|
export function isDalleConfigured(settings: AiSettings | null | undefined): boolean {
|
||||||
if (!settings) return false;
|
if (!settings) return false;
|
||||||
|
const runtimeSettings = resolveSystemSettingsRuntime(settings);
|
||||||
// DALL-E needs its own deployment (or a non-Azure key with model name)
|
// DALL-E needs its own deployment (or a non-Azure key with model name)
|
||||||
if (settings.aiProvider === "azure") {
|
if (runtimeSettings.aiProvider === "azure") {
|
||||||
return !!(settings.azureDalleDeployment && (settings.azureDalleEndpoint || settings.azureOpenAiEndpoint) && (settings.azureDalleApiKey || settings.azureOpenAiApiKey));
|
return !!(runtimeSettings.azureDalleDeployment && (runtimeSettings.azureDalleEndpoint || runtimeSettings.azureOpenAiEndpoint) && (runtimeSettings.azureDalleApiKey || runtimeSettings.azureOpenAiApiKey));
|
||||||
}
|
}
|
||||||
// For direct OpenAI, the chat API key works for DALL-E too
|
// 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. */
|
/** Creates an OpenAI client configured for DALL-E image generation. */
|
||||||
export function createDalleClient(settings: AiSettings): OpenAI {
|
export function createDalleClient(settings: AiSettings): OpenAI {
|
||||||
if (settings.aiProvider === "azure") {
|
const runtimeSettings = resolveSystemSettingsRuntime(settings);
|
||||||
const endpoint = settings.azureDalleEndpoint || settings.azureOpenAiEndpoint!;
|
if (runtimeSettings.aiProvider === "azure") {
|
||||||
const apiKey = settings.azureDalleApiKey || settings.azureOpenAiApiKey!;
|
const endpoint = runtimeSettings.azureDalleEndpoint || runtimeSettings.azureOpenAiEndpoint!;
|
||||||
|
const apiKey = runtimeSettings.azureDalleApiKey || runtimeSettings.azureOpenAiApiKey!;
|
||||||
return new AzureOpenAI({
|
return new AzureOpenAI({
|
||||||
endpoint,
|
endpoint,
|
||||||
apiKey,
|
apiKey,
|
||||||
apiVersion: settings.azureApiVersion ?? "2025-01-01-preview",
|
apiVersion: runtimeSettings.azureApiVersion ?? "2025-01-01-preview",
|
||||||
deployment: settings.azureDalleDeployment!,
|
deployment: runtimeSettings.azureDalleDeployment!,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return new OpenAI({ apiKey: settings.azureOpenAiApiKey! });
|
return new OpenAI({ apiKey: runtimeSettings.azureOpenAiApiKey! });
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -79,7 +99,7 @@ export async function loggedAiCall<T>(
|
|||||||
return result;
|
return result;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const responseTimeMs = Math.round(performance.now() - start);
|
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");
|
logger.warn({ provider, model, promptLength, responseTimeMs, errorMessage }, "External API call failed");
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
@@ -114,6 +134,5 @@ export function parseAiError(err: unknown): string {
|
|||||||
if (lower.includes("context_length_exceeded") || lower.includes("maximum context")) {
|
if (lower.includes("context_length_exceeded") || lower.includes("maximum context")) {
|
||||||
return "Request too large — the prompt exceeded the model's context limit.";
|
return "Request too large — the prompt exceeded the model's context limit.";
|
||||||
}
|
}
|
||||||
// Fall back to the raw message but strip noise
|
return sanitizeDiagnosticError(msg);
|
||||||
return msg.replace(/^Error: /, "").slice(0, 300);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { logger } from "./lib/logger.js";
|
import { logger } from "./lib/logger.js";
|
||||||
|
import { resolveSystemSettingsRuntime } from "./lib/system-settings-runtime.js";
|
||||||
|
|
||||||
type GeminiSettings = {
|
type GeminiSettings = {
|
||||||
geminiApiKey?: string | null;
|
geminiApiKey?: string | null;
|
||||||
@@ -7,7 +8,7 @@ type GeminiSettings = {
|
|||||||
|
|
||||||
/** Returns true if the settings have a Gemini API key configured. */
|
/** Returns true if the settings have a Gemini API key configured. */
|
||||||
export function isGeminiConfigured(settings: GeminiSettings | null | undefined): boolean {
|
export function isGeminiConfigured(settings: GeminiSettings | null | undefined): boolean {
|
||||||
return !!settings?.geminiApiKey;
|
return !!resolveSystemSettingsRuntime(settings).geminiApiKey;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { createHash } from "node:crypto";
|
import { createHash } from "node:crypto";
|
||||||
import type { PrismaClient } from "@capakraken/db";
|
import type { PrismaClient } from "@capakraken/db";
|
||||||
|
import { resolveSystemSettingsRuntime } from "./system-settings-runtime.js";
|
||||||
|
|
||||||
const DEFAULT_ANONYMIZATION_DOMAIN = "superhartmut.de";
|
const DEFAULT_ANONYMIZATION_DOMAIN = "superhartmut.de";
|
||||||
const DEFAULT_ANONYMIZATION_SEED = "capakraken-superhartmut-global";
|
const DEFAULT_ANONYMIZATION_SEED = "capakraken-superhartmut-global";
|
||||||
@@ -639,10 +640,12 @@ export async function getAnonymizationConfig(
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const runtimeSettings = resolveSystemSettingsRuntime(settings);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
enabled: settings?.anonymizationEnabled ?? false,
|
enabled: runtimeSettings.anonymizationEnabled ?? false,
|
||||||
domain: settings?.anonymizationDomain?.trim() || DEFAULT_ANONYMIZATION_DOMAIN,
|
domain: runtimeSettings.anonymizationDomain?.trim() || DEFAULT_ANONYMIZATION_DOMAIN,
|
||||||
seed: settings?.anonymizationSeed?.trim() || DEFAULT_ANONYMIZATION_SEED,
|
seed: runtimeSettings.anonymizationSeed?.trim() || DEFAULT_ANONYMIZATION_SEED,
|
||||||
mode: "global",
|
mode: "global",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -665,10 +668,12 @@ export async function getAnonymizationDirectory(
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const runtimeSettings = resolveSystemSettingsRuntime(settings);
|
||||||
|
|
||||||
const config: AnonymizationConfig = {
|
const config: AnonymizationConfig = {
|
||||||
enabled: settings?.anonymizationEnabled ?? false,
|
enabled: runtimeSettings.anonymizationEnabled ?? false,
|
||||||
domain: settings?.anonymizationDomain?.trim() || DEFAULT_ANONYMIZATION_DOMAIN,
|
domain: runtimeSettings.anonymizationDomain?.trim() || DEFAULT_ANONYMIZATION_DOMAIN,
|
||||||
seed: settings?.anonymizationSeed?.trim() || DEFAULT_ANONYMIZATION_SEED,
|
seed: runtimeSettings.anonymizationSeed?.trim() || DEFAULT_ANONYMIZATION_SEED,
|
||||||
mode: "global",
|
mode: "global",
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -680,7 +685,7 @@ export async function getAnonymizationDirectory(
|
|||||||
const usedSlugs = new Set<string>();
|
const usedSlugs = new Set<string>();
|
||||||
const byResourceId = new Map<string, ResourceAlias>();
|
const byResourceId = new Map<string, ResourceAlias>();
|
||||||
const byAliasEid = new Map<string, string>();
|
const byAliasEid = new Map<string, string>();
|
||||||
const storedAliases = parseStoredAliases(settings?.anonymizationAliases);
|
const storedAliases = parseStoredAliases(runtimeSettings.anonymizationAliases);
|
||||||
let aliasesChanged = false;
|
let aliasesChanged = false;
|
||||||
|
|
||||||
for (const [resourceId, storedAlias] of Object.entries(storedAliases)) {
|
for (const [resourceId, storedAlias] of Object.entries(storedAliases)) {
|
||||||
|
|||||||
@@ -4,6 +4,8 @@
|
|||||||
*/
|
*/
|
||||||
import nodemailer from "nodemailer";
|
import nodemailer from "nodemailer";
|
||||||
import { prisma as db } from "@capakraken/db";
|
import { prisma as db } from "@capakraken/db";
|
||||||
|
import { logger } from "./logger.js";
|
||||||
|
import { resolveSystemSettingsRuntime } from "./system-settings-runtime.js";
|
||||||
|
|
||||||
interface EmailPayload {
|
interface EmailPayload {
|
||||||
to: string | string[];
|
to: string | string[];
|
||||||
@@ -12,9 +14,43 @@ interface EmailPayload {
|
|||||||
html?: string;
|
html?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function sanitizeSmtpDiagnostic(err: unknown): string {
|
||||||
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
|
return message
|
||||||
|
.replace(/https?:\/\/[^\s)\]}]+/gi, "<redacted-url>")
|
||||||
|
.replace(/\b[^\s@]+@[^\s@]+\.[^\s@]+\b/g, "<redacted-email>")
|
||||||
|
.replace(/\b(?:[A-Za-z0-9-]+\.)+[A-Za-z]{2,}\b/g, "<redacted-host>")
|
||||||
|
.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() {
|
async function getSmtpConfig() {
|
||||||
const settings = await db.systemSettings.findUnique({ where: { id: "singleton" } });
|
const settings = resolveSystemSettingsRuntime(
|
||||||
if (!settings?.smtpHost) return null;
|
await db.systemSettings.findUnique({ where: { id: "singleton" } }),
|
||||||
|
);
|
||||||
|
if (!settings.smtpHost) return null;
|
||||||
return {
|
return {
|
||||||
host: settings.smtpHost,
|
host: settings.smtpHost,
|
||||||
port: settings.smtpPort ?? 587,
|
port: settings.smtpPort ?? 587,
|
||||||
@@ -53,7 +89,7 @@ export async function sendEmail(payload: EmailPayload): Promise<boolean> {
|
|||||||
|
|
||||||
return true;
|
return true;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("[email] Failed to send email:", err);
|
logger.warn({ diagnostic: sanitizeSmtpDiagnostic(err) }, "Email send failed");
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -76,6 +112,7 @@ export async function testSmtpConnection(): Promise<{ ok: boolean; error?: strin
|
|||||||
await transporter.verify();
|
await transporter.verify();
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
} catch (err) {
|
} 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) };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<T extends RuntimeAwareSystemSettings>(
|
||||||
|
settings: T | null | undefined,
|
||||||
|
): T & Required<Pick<RuntimeAwareSystemSettings, "azureOpenAiApiKey" | "azureDalleApiKey" | "geminiApiKey" | "smtpPassword" | "anonymizationSeed">> {
|
||||||
|
const resolved = { ...(settings ?? {}) } as T & Required<Pick<RuntimeAwareSystemSettings, "azureOpenAiApiKey" | "azureDalleApiKey" | "geminiApiKey" | "smtpPassword" | "anonymizationSeed">>;
|
||||||
|
|
||||||
|
resolved.azureOpenAiApiKey = resolvePrimaryAiApiKey(resolved.aiProvider) ?? settings?.azureOpenAiApiKey ?? null;
|
||||||
|
resolved.azureDalleApiKey = readEnvOverride("AZURE_DALLE_API_KEY") ?? settings?.azureDalleApiKey ?? null;
|
||||||
|
resolved.geminiApiKey = readEnvOverride("GEMINI_API_KEY") ?? settings?.geminiApiKey ?? null;
|
||||||
|
resolved.smtpPassword = readEnvOverride("SMTP_PASSWORD") ?? settings?.smtpPassword ?? null;
|
||||||
|
resolved.anonymizationSeed = readEnvOverride("ANONYMIZATION_SEED") ?? settings?.anonymizationSeed ?? null;
|
||||||
|
|
||||||
|
return resolved;
|
||||||
|
}
|
||||||
@@ -17,6 +17,7 @@ import { createDalleClient, isDalleConfigured, loggedAiCall, parseAiError } from
|
|||||||
import { generateGeminiImage, isGeminiConfigured, parseGeminiError } from "../gemini-client.js";
|
import { generateGeminiImage, isGeminiConfigured, parseGeminiError } from "../gemini-client.js";
|
||||||
import { invalidateDashboardCache } from "../lib/cache.js";
|
import { invalidateDashboardCache } from "../lib/cache.js";
|
||||||
import { logger } from "../lib/logger.js";
|
import { logger } from "../lib/logger.js";
|
||||||
|
import { resolveSystemSettingsRuntime } from "../lib/system-settings-runtime.js";
|
||||||
import { dispatchWebhooks } from "../lib/webhook-dispatcher.js";
|
import { dispatchWebhooks } from "../lib/webhook-dispatcher.js";
|
||||||
import { validateImageDataUrl } from "../lib/image-validation.js";
|
import { validateImageDataUrl } from "../lib/image-validation.js";
|
||||||
import type { TRPCContext } from "../trpc.js";
|
import type { TRPCContext } from "../trpc.js";
|
||||||
@@ -995,9 +996,10 @@ export const projectRouter = createTRPCRouter({
|
|||||||
where: { id: "singleton" },
|
where: { id: "singleton" },
|
||||||
});
|
});
|
||||||
|
|
||||||
const imageProvider = settings?.imageProvider ?? "dalle";
|
const runtimeSettings = resolveSystemSettingsRuntime(settings);
|
||||||
const useGemini = imageProvider === "gemini" && isGeminiConfigured(settings);
|
const imageProvider = runtimeSettings.imageProvider ?? "dalle";
|
||||||
const useDalle = imageProvider === "dalle" && isDalleConfigured(settings);
|
const useGemini = imageProvider === "gemini" && isGeminiConfigured(runtimeSettings);
|
||||||
|
const useDalle = imageProvider === "dalle" && isDalleConfigured(runtimeSettings);
|
||||||
|
|
||||||
if (!useGemini && !useDalle) {
|
if (!useGemini && !useDalle) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
@@ -1017,9 +1019,9 @@ export const projectRouter = createTRPCRouter({
|
|||||||
if (useGemini) {
|
if (useGemini) {
|
||||||
try {
|
try {
|
||||||
coverImageUrl = await generateGeminiImage(
|
coverImageUrl = await generateGeminiImage(
|
||||||
settings!.geminiApiKey!,
|
runtimeSettings.geminiApiKey!,
|
||||||
finalPrompt,
|
finalPrompt,
|
||||||
settings!.geminiModel ?? undefined,
|
runtimeSettings.geminiModel ?? undefined,
|
||||||
);
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
@@ -1028,8 +1030,8 @@ export const projectRouter = createTRPCRouter({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const dalleClient = createDalleClient(settings!);
|
const dalleClient = createDalleClient(runtimeSettings);
|
||||||
const model = settings!.aiProvider === "azure" ? settings!.azureDalleDeployment! : "dall-e-3";
|
const model = runtimeSettings.aiProvider === "azure" ? runtimeSettings.azureDalleDeployment! : "dall-e-3";
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
let response: any;
|
let response: any;
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { adminProcedure, createTRPCRouter } from "../trpc.js";
|
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 { DEFAULT_SUMMARY_PROMPT } from "./resource.js";
|
||||||
import { VALUE_SCORE_WEIGHTS } from "@capakraken/shared";
|
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 { 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([
|
||||||
@@ -20,6 +22,7 @@ export const settingsRouter = createTRPCRouter({
|
|||||||
const settings = await ctx.db.systemSettings.findUnique({
|
const settings = await ctx.db.systemSettings.findUnique({
|
||||||
where: { id: "singleton" },
|
where: { id: "singleton" },
|
||||||
});
|
});
|
||||||
|
const runtimeSettings = resolveSystemSettingsRuntime(settings);
|
||||||
|
|
||||||
const defaultWeights = {
|
const defaultWeights = {
|
||||||
skillDepth: VALUE_SCORE_WEIGHTS.SKILL_DEPTH,
|
skillDepth: VALUE_SCORE_WEIGHTS.SKILL_DEPTH,
|
||||||
@@ -38,7 +41,7 @@ export const settingsRouter = createTRPCRouter({
|
|||||||
aiTemperature: settings?.aiTemperature ?? 1,
|
aiTemperature: settings?.aiTemperature ?? 1,
|
||||||
aiSummaryPrompt: settings?.aiSummaryPrompt ?? null,
|
aiSummaryPrompt: settings?.aiSummaryPrompt ?? null,
|
||||||
defaultSummaryPrompt: DEFAULT_SUMMARY_PROMPT,
|
defaultSummaryPrompt: DEFAULT_SUMMARY_PROMPT,
|
||||||
hasApiKey: !!settings?.azureOpenAiApiKey,
|
hasApiKey: !!runtimeSettings.azureOpenAiApiKey,
|
||||||
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
|
||||||
@@ -47,7 +50,7 @@ export const settingsRouter = createTRPCRouter({
|
|||||||
smtpUser: settings?.smtpUser ?? null,
|
smtpUser: settings?.smtpUser ?? null,
|
||||||
smtpFrom: settings?.smtpFrom ?? null,
|
smtpFrom: settings?.smtpFrom ?? null,
|
||||||
smtpTls: settings?.smtpTls ?? true,
|
smtpTls: settings?.smtpTls ?? true,
|
||||||
hasSmtpPassword: !!settings?.smtpPassword,
|
hasSmtpPassword: !!runtimeSettings.smtpPassword,
|
||||||
// Global anonymization
|
// Global anonymization
|
||||||
anonymizationEnabled: settings?.anonymizationEnabled ?? false,
|
anonymizationEnabled: settings?.anonymizationEnabled ?? false,
|
||||||
anonymizationDomain: settings?.anonymizationDomain ?? "superhartmut.de",
|
anonymizationDomain: settings?.anonymizationDomain ?? "superhartmut.de",
|
||||||
@@ -55,10 +58,10 @@ export const settingsRouter = createTRPCRouter({
|
|||||||
// DALL-E
|
// DALL-E
|
||||||
azureDalleDeployment: settings?.azureDalleDeployment ?? null,
|
azureDalleDeployment: settings?.azureDalleDeployment ?? null,
|
||||||
azureDalleEndpoint: settings?.azureDalleEndpoint ?? null,
|
azureDalleEndpoint: settings?.azureDalleEndpoint ?? null,
|
||||||
hasDalleApiKey: !!settings?.azureDalleApiKey,
|
hasDalleApiKey: !!runtimeSettings.azureDalleApiKey,
|
||||||
// Gemini
|
// Gemini
|
||||||
geminiModel: settings?.geminiModel ?? "gemini-2.5-flash-image",
|
geminiModel: settings?.geminiModel ?? "gemini-2.5-flash-image",
|
||||||
hasGeminiApiKey: !!settings?.geminiApiKey,
|
hasGeminiApiKey: !!runtimeSettings.geminiApiKey,
|
||||||
// Image provider
|
// Image provider
|
||||||
imageProvider: settings?.imageProvider ?? "dalle",
|
imageProvider: settings?.imageProvider ?? "dalle",
|
||||||
// Vacation defaults
|
// Vacation defaults
|
||||||
@@ -216,9 +219,9 @@ export const settingsRouter = createTRPCRouter({
|
|||||||
}),
|
}),
|
||||||
|
|
||||||
testAiConnection: adminProcedure.mutation(async ({ ctx }) => {
|
testAiConnection: adminProcedure.mutation(async ({ ctx }) => {
|
||||||
const settings = await ctx.db.systemSettings.findUnique({
|
const settings = resolveSystemSettingsRuntime(await ctx.db.systemSettings.findUnique({
|
||||||
where: { id: "singleton" },
|
where: { id: "singleton" },
|
||||||
});
|
}));
|
||||||
|
|
||||||
if (!isAiConfigured(settings)) {
|
if (!isAiConfigured(settings)) {
|
||||||
const provider = settings?.aiProvider ?? "openai";
|
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." };
|
return { ok: false, error: "Missing required fields: model name and API key are required." };
|
||||||
}
|
}
|
||||||
|
|
||||||
const provider = settings!.aiProvider ?? "openai";
|
const provider = settings.aiProvider ?? "openai";
|
||||||
const apiKey = settings!.azureOpenAiApiKey!;
|
const apiKey = settings.azureOpenAiApiKey!;
|
||||||
|
|
||||||
let url: string;
|
let url: string;
|
||||||
let headers: Record<string, string>;
|
let headers: Record<string, string>;
|
||||||
|
|
||||||
if (provider === "azure") {
|
if (provider === "azure") {
|
||||||
const endpoint = settings!.azureOpenAiEndpoint!.replace(/\/$/, "");
|
const endpoint = settings.azureOpenAiEndpoint!.replace(/\/$/, "");
|
||||||
const deployment = settings!.azureOpenAiDeployment!;
|
const deployment = settings.azureOpenAiDeployment!;
|
||||||
const apiVersion = settings!.azureApiVersion ?? "2025-01-01-preview";
|
const apiVersion = settings.azureApiVersion ?? "2025-01-01-preview";
|
||||||
url = `${endpoint}/openai/deployments/${deployment}/chat/completions?api-version=${apiVersion}`;
|
url = `${endpoint}/openai/deployments/${deployment}/chat/completions?api-version=${apiVersion}`;
|
||||||
headers = { "Content-Type": "application/json", "api-key": apiKey };
|
headers = { "Content-Type": "application/json", "api-key": apiKey };
|
||||||
} else {
|
} else {
|
||||||
// Standard OpenAI API — deployment field holds the model name (e.g. "gpt-4o")
|
// 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";
|
url = "https://api.openai.com/v1/chat/completions";
|
||||||
headers = { "Content-Type": "application/json", Authorization: `Bearer ${apiKey}` };
|
headers = { "Content-Type": "application/json", Authorization: `Bearer ${apiKey}` };
|
||||||
// Override body to include model field for OpenAI
|
// Override body to include model field for OpenAI
|
||||||
@@ -257,17 +260,24 @@ export const settingsRouter = createTRPCRouter({
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
const body = await resp.text();
|
const body = await resp.text();
|
||||||
if (resp.ok) return { ok: true, raw: null };
|
if (resp.ok) return { ok: true };
|
||||||
let msg = body;
|
let msg = body;
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(body) as { error?: { message?: string } };
|
const parsed = JSON.parse(body) as { error?: { message?: string } };
|
||||||
if (parsed.error?.message) msg = parsed.error.message;
|
if (parsed.error?.message) msg = parsed.error.message;
|
||||||
} catch { /* keep raw */ }
|
} catch { /* keep raw */ }
|
||||||
const raw = `HTTP ${resp.status}: ${msg}`;
|
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) {
|
} catch (err) {
|
||||||
const raw = err instanceof Error ? err.message : String(err);
|
logger.warn(
|
||||||
return { ok: false, error: parseAiError(err), raw };
|
{ 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();
|
const body = await resp.text();
|
||||||
|
|
||||||
if (resp.ok) {
|
if (resp.ok) return { ok: true };
|
||||||
return { ok: true, raw: null };
|
|
||||||
}
|
|
||||||
|
|
||||||
let azureMessage = body;
|
let azureMessage = body;
|
||||||
try {
|
try {
|
||||||
@@ -294,10 +302,17 @@ export const settingsRouter = createTRPCRouter({
|
|||||||
} catch { /* leave as raw text */ }
|
} catch { /* leave as raw text */ }
|
||||||
|
|
||||||
const raw = `HTTP ${resp.status}: ${azureMessage}`;
|
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) {
|
} catch (err) {
|
||||||
const raw = err instanceof Error ? err.message : String(err);
|
logger.warn(
|
||||||
return { ok: false, error: parseAiError(err), raw };
|
{ 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 }) => {
|
testGeminiConnection: adminProcedure.mutation(async ({ ctx }) => {
|
||||||
const settings = await ctx.db.systemSettings.findUnique({
|
const settings = resolveSystemSettingsRuntime(await ctx.db.systemSettings.findUnique({
|
||||||
where: { id: "singleton" },
|
where: { id: "singleton" },
|
||||||
select: { geminiApiKey: true, geminiModel: true },
|
select: { geminiApiKey: true, geminiModel: true },
|
||||||
});
|
}));
|
||||||
|
|
||||||
if (!settings?.geminiApiKey) {
|
if (!settings?.geminiApiKey) {
|
||||||
return { ok: false, error: "Gemini API key is not configured." };
|
return { ok: false, error: "Gemini API key is not configured." };
|
||||||
@@ -362,7 +377,7 @@ export const settingsRouter = createTRPCRouter({
|
|||||||
}),
|
}),
|
||||||
|
|
||||||
getAiConfigured: adminProcedure.query(async ({ ctx }) => {
|
getAiConfigured: adminProcedure.query(async ({ ctx }) => {
|
||||||
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,
|
||||||
@@ -370,7 +385,7 @@ export const settingsRouter = createTRPCRouter({
|
|||||||
azureOpenAiDeployment: true,
|
azureOpenAiDeployment: true,
|
||||||
azureOpenAiApiKey: true,
|
azureOpenAiApiKey: true,
|
||||||
},
|
},
|
||||||
});
|
}));
|
||||||
return { configured: isAiConfigured(settings) };
|
return { configured: isAiConfigured(settings) };
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user