dorfteich/packages/shared/src/secret-store.ts
Claude Fable 5 8dbff86537
All checks were successful
CI / Lint, typecheck, test (push) Successful in 3m9s
CI / Build container images (push) Has been skipped
CD / Build and push images (push) Successful in 3m47s
CD / Deploy to Test (push) Successful in 8s
CD / Smoke tests against Test (push) Successful in 1m10s
CD / Promote to Int (push) Successful in 9s
CI / Auth e2e pack (push) Successful in 5m25s
CI / Import/export fidelity gate (push) Successful in 45s
Add backup sidecar: nightly dump, volume archive, prune, status, restore (#83)
New apps/backup service (ADR 0015): nightly pg_dump -Fc plus one tar of the
uploads/plugins volumes as a consistent restore set on a new backups volume,
retention prune that never removes the newest complete set, atomic
status.json for the readiness/admin consumers (#85/#86), and a failure mail
sent directly via nodemailer (the api may be the broken part) with de/en
texts in the shared mails catalog. BACKUP_RUN_ONCE=1 gives the on-demand
path; deploy/backup/restore.sh automates the documented restore runbook.
The pure secret-store helpers moved to @dorfteich/shared so the sidecar
resolves the wizard-written SMTP relay exactly like the api.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EwZ4jR4KFAPvpjWevfUGX1
2026-07-11 17:29:15 +02:00

63 lines
2.6 KiB
TypeScript

/**
* 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<string, string> {
const secrets: Record<string, string> = {};
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, string>): 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<string, string | undefined>,
secrets: Record<string, string>,
): Record<string, string | undefined> {
const merged: Record<string, string | undefined> = {};
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;
}