/** * The env-backed secret store (security.md §Secrets, issue #80): secrets the * setup wizard collects in the browser (SMTP credentials) are persisted as a * mode-600 dotenv-style file on a volume — never as database rows. The file * extends the environment: `overlayEnv` fills only variables the process * environment does not set. These pure format/merge helpers live in shared * because two services read the store the api writes: the api itself and the * backup sidecar (issue #83), which needs the wizard's SMTP relay for its * failure mail. File I/O stays with each service. */ /** Parses the dotenv-style store content. Ignores blank lines and comments. */ export function parseSecretsFile(content: string): Record { const secrets: Record = {}; for (const line of content.split('\n')) { const trimmed = line.trim(); if (!trimmed || trimmed.startsWith('#')) continue; const eq = trimmed.indexOf('='); if (eq <= 0) continue; const key = trimmed.slice(0, eq).trim(); let value = trimmed.slice(eq + 1).trim(); if (value.startsWith('"') && value.endsWith('"') && value.length >= 2) { value = value.slice(1, -1).replace(/\\n/g, '\n').replace(/\\"/g, '"').replace(/\\\\/g, '\\'); } secrets[key] = value; } return secrets; } /** Serializes secrets with double-quoted, escaped values (dotenv-compatible). */ export function serializeSecrets(secrets: Record): string { const lines = [ '# Managed by Dorfteich (setup wizard). Values here fill environment', '# variables that the container environment does not set explicitly.', ]; for (const [key, value] of Object.entries(secrets)) { const escaped = value.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n'); lines.push(`${key}="${escaped}"`); } return lines.join('\n') + '\n'; } /** * Merges the store under the real environment: explicit env vars win, store * values fill the gaps (and Zod defaults fill whatever remains at parse * time). Empty strings count as unset on both sides — compose passes * `${SMTP_HOST:-}` as `""` for variables the stage `.env` does not define, * and those must not shadow wizard-written store values or schema defaults. */ export function overlayEnv( env: Record, secrets: Record, ): Record { const merged: Record = {}; for (const [key, value] of Object.entries(secrets)) { if (value !== '') merged[key] = value; } for (const [key, value] of Object.entries(env)) { if (value !== undefined && value !== '') merged[key] = value; } return merged; }