import { readFile, writeFile, mkdir, rename } from 'node:fs/promises'; import { existsSync } from 'node:fs'; import path from 'node:path'; import { randomUUID } from 'node:crypto'; import { logger } from '@/lib/logger'; import type { TelemetryStateFile, ConsentState } from './types'; import { DEFAULT_ENDPOINT } from './types'; function getDir(): string { return process.env.TELEMETRY_DATA_DIR || path.join(process.cwd(), 'data', 'telemetry'); } function statePath(): string { return path.join(getDir(), 'state.json'); } function idPath(): string { return path.join(getDir(), '.telemetry-id'); } function envOverride(): ConsentState | null { const v = (process.env.BULWARK_TELEMETRY ?? '').toLowerCase(); if (v === 'off' || v === 'false' || v === '0' || v === 'no') return 'off'; if (v === 'on' || v === 'true' || v === '1' || v === 'yes') return 'on'; if (process.env.BULWARK_TELEMETRY_DISABLED) { const d = process.env.BULWARK_TELEMETRY_DISABLED.toLowerCase(); if (d === '1' || d === 'true' || d === 'yes') return 'off'; } return null; } export async function ensureDir(): Promise { if (!existsSync(getDir())) await mkdir(getDir(), { recursive: true }); } export async function getInstanceId(): Promise { await ensureDir(); try { const id = (await readFile(idPath(), 'utf8')).trim(); if (/^[0-9a-f-]{36}$/i.test(id)) return id; } catch { /* generate fresh */ } const fresh = randomUUID(); const tmp = idPath() + '.tmp'; await writeFile(tmp, fresh, 'utf8'); await rename(tmp, idPath()); return fresh; } // Default consent is 'off' - telemetry is opt-in. Admins can enable it during // install (BULWARK_TELEMETRY=on in .env.local), via the BULWARK_TELEMETRY env // var, or with one click in the admin UI. Heartbeats are anonymous: no PII, // just version/platform/feature toggles. Enabling helps us improve the product. // See https://bulwarkmail.org/docs/legal/privacy/telemetry. const DEFAULTS: TelemetryStateFile = { consent: 'off', endpoint: DEFAULT_ENDPOINT, consentedAt: null, lastSentAt: null, nextScheduledAt: null, }; export async function loadState(): Promise { await ensureDir(); try { const raw = await readFile(statePath(), 'utf8'); const parsed = JSON.parse(raw) as Partial; return { ...DEFAULTS, ...parsed }; } catch (err) { if ((err as NodeJS.ErrnoException).code !== 'ENOENT') { logger.warn('telemetry: state read failed', { error: err instanceof Error ? err.message : String(err), }); } // First-ever load on a fresh install: persist the default-off state so the // instance id and (lack of) consent are stable across restarts. The admin // can opt in later via the UI or the BULWARK_TELEMETRY env var. const fresh: TelemetryStateFile = { ...DEFAULTS }; await saveState(fresh); return fresh; } } export async function saveState(state: TelemetryStateFile): Promise { await ensureDir(); const tmp = statePath() + '.tmp'; await writeFile(tmp, JSON.stringify(state, null, 2), 'utf8'); await rename(tmp, statePath()); } // Effective consent: env var wins over file. UI changes are blocked // when env override is active so the user knows where it's coming from. export async function effectiveConsent(): Promise<{ consent: ConsentState; source: 'env' | 'file'; state: TelemetryStateFile; }> { const envState = envOverride(); const state = await loadState(); if (envState) return { consent: envState, source: 'env', state }; return { consent: state.consent, source: 'file', state }; } export function endpointEnabled(endpoint: string | undefined): boolean { return !!endpoint && endpoint.trim().length > 0; }