diff --git a/CHANGELOG.md b/CHANGELOG.md index 1f7e11d1..e4373ed1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,8 +4,8 @@ ### Features -- **Plugins**: New `composer-sidebar` slot and `ui:composer-sidebar` permission — plugins can now render a panel on either side of the New Message dialog. See `repos/subway-surfers` for an example -- **Plugins**: Manifests can declare `frameOrigins` — a strictly-validated list of `https://host` origins the plugin needs to embed. The proxy reads the union from enabled plugins and merges it into the host CSP `frame-src`, so the host CSP no longer needs to know about specific embed providers +- **Plugins**: New `composer-sidebar` slot and `ui:composer-sidebar` permission - plugins can now render a panel on either side of the New Message dialog. See `repos/subway-surfers` for an example +- **Plugins**: Manifests can declare `frameOrigins` - a strictly-validated list of `https://host` origins the plugin needs to embed. The proxy reads the union from enabled plugins and merges it into the host CSP `frame-src`, so the host CSP no longer needs to know about specific embed providers - **Calendar/Contacts**: JMAP sharing for calendars and address books - **i18n**: Czech language support diff --git a/app/admin/telemetry/page.tsx b/app/admin/telemetry/page.tsx new file mode 100644 index 00000000..7921aee4 --- /dev/null +++ b/app/admin/telemetry/page.tsx @@ -0,0 +1,234 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { Loader2, Send, Save, CheckCircle2, XCircle, ExternalLink } from 'lucide-react'; +import { apiFetch } from '@/lib/browser-navigation'; + +interface TelemetryStatus { + consent: 'pending' | 'on' | 'off'; + consentSource: 'env' | 'file'; + endpoint: string; + defaultEndpoint: string; + consentedAt: string | null; + lastSentAt: string | null; + nextScheduledAt: string | null; + payloadPreview: Record; +} + +function timeAgo(iso: string | null): string { + if (!iso) return 'never'; + const d = Date.now() - new Date(iso).getTime(); + if (d < 0) return new Date(iso).toLocaleString(); + const m = Math.floor(d / 60000); + if (m < 1) return 'just now'; + if (m < 60) return `${m} min ago`; + const h = Math.floor(m / 60); + if (h < 48) return `${h} hours ago`; + const days = Math.floor(h / 24); + return `${days} days ago`; +} + +export default function AdminTelemetryPage() { + const [status, setStatus] = useState(null); + const [loading, setLoading] = useState(true); + const [busy, setBusy] = useState(null); + const [endpointDraft, setEndpointDraft] = useState(''); + const [sendResult, setSendResult] = useState<{ ok: boolean; msg: string } | null>(null); + + async function refresh(): Promise { + setLoading(true); + try { + const r = await apiFetch('/api/admin/telemetry'); + if (!r.ok) throw new Error('failed to load'); + const data = (await r.json()) as TelemetryStatus; + setStatus(data); + setEndpointDraft(data.endpoint); + } catch (err) { + console.error(err); + } finally { + setLoading(false); + } + } + useEffect(() => { void refresh(); }, []); + + async function setConsent(consent: 'on' | 'off'): Promise { + setBusy('consent'); + try { + const r = await apiFetch('/api/admin/telemetry', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ action: 'set-consent', consent }), + }); + if (!r.ok) { + const j = (await r.json().catch(() => ({}))) as { error?: string }; + alert(j.error ?? 'failed'); + } + await refresh(); + } finally { setBusy(null); } + } + + async function saveEndpoint(): Promise { + setBusy('endpoint'); + try { + const r = await apiFetch('/api/admin/telemetry', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ action: 'set-endpoint', endpoint: endpointDraft }), + }); + if (!r.ok) { + const j = (await r.json().catch(() => ({}))) as { error?: string }; + alert(j.error ?? 'failed'); + } + await refresh(); + } finally { setBusy(null); } + } + + async function sendNow(): Promise { + setBusy('send'); + setSendResult(null); + try { + const r = await apiFetch('/api/admin/telemetry', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ action: 'send-now' }), + }); + const j = (await r.json().catch(() => ({}))) as { ok?: boolean; status?: number; error?: string }; + setSendResult({ + ok: !!j.ok, + msg: j.ok ? `sent (HTTP ${j.status ?? '?'})` : `failed: ${j.error ?? 'unknown'}`, + }); + await refresh(); + } finally { setBusy(null); } + } + + if (loading || !status) { + return ( +
+ loading… +
+ ); + } + + const envOverridden = status.consentSource === 'env'; + const isOn = status.consent === 'on'; + + return ( +
+
+

Anonymous Usage Stats

+

+ Bulwark sends one anonymous heartbeat per day so we can see how many instances are + running, on what platforms, and which features they use. Enabled by default; + one click below disables it. No email addresses, no hostnames, no IPs are sent.{' '} + + Full schema and policy + +

+
+ +
+
+
+
Status
+
+ {status.consent === 'pending' && 'Initialising — no heartbeats sent yet.'} + {status.consent === 'on' && 'Heartbeats are enabled (default).'} + {status.consent === 'off' && 'Heartbeats are off.'} + {envOverridden && ( + <> Locked by BULWARK_TELEMETRY env var. + )} +
+
+
+ + +
+
+
+
Last sent
+
{timeAgo(status.lastSentAt)}
+
Next scheduled
+
{timeAgo(status.nextScheduledAt)}
+
Consented at
+
{status.consentedAt ? new Date(status.consentedAt).toLocaleString() : '-'}
+
+
+ +
+
Endpoint
+

+ Where heartbeats are sent. Defaults to the project's collector. Point at your own collector + (open source at bulwarkmail/dashboard) or clear this field to disable sending. +

+
+ setEndpointDraft(e.target.value)} + placeholder={status.defaultEndpoint} + className="flex-1 px-3 py-1.5 rounded-md border bg-background" + /> + +
+
+ +
+
+
+
Payload preview
+
+ Exactly what the next heartbeat would send from this install, right now. +
+
+ +
+ {sendResult && ( +
+ {sendResult.ok ? : } + {sendResult.msg} +
+ )} +
+          {JSON.stringify(status.payloadPreview, null, 2)}
+        
+
+
+ ); +} diff --git a/app/api/admin/marketplace/route.ts b/app/api/admin/marketplace/route.ts index fdf25e61..021996f6 100644 --- a/app/api/admin/marketplace/route.ts +++ b/app/api/admin/marketplace/route.ts @@ -232,7 +232,7 @@ export async function POST(request: NextRequest) { // Plugins may declare iframe origins they need for embedded content. // Anything that doesn't pass strict origin validation is silently - // dropped — the plugin still installs, but those origins are not + // dropped - the plugin still installs, but those origins are not // added to the host CSP. const declaredFrameOrigins = sanitizeFrameOrigins(manifest.frameOrigins); const droppedFrameOrigins = Array.isArray(manifest.frameOrigins) diff --git a/app/api/admin/oauth/setup/route.ts b/app/api/admin/oauth/setup/route.ts index b1c63851..0182e197 100644 --- a/app/api/admin/oauth/setup/route.ts +++ b/app/api/admin/oauth/setup/route.ts @@ -139,7 +139,7 @@ export async function POST(request: NextRequest) { const queryEntry = queryRes.methodResponses?.[0]; if (!queryEntry || queryEntry[0] === 'error') { return NextResponse.json({ - error: 'Stalwart denied OAuthClient/query — your Stalwart account likely lacks admin permissions.', + error: 'Stalwart denied OAuthClient/query - your Stalwart account likely lacks admin permissions.', detail: queryEntry?.[1], }, { status: 403 }); } @@ -187,7 +187,7 @@ export async function POST(request: NextRequest) { const setEntry = setRes.methodResponses?.[0]; if (!setEntry || setEntry[0] === 'error') { return NextResponse.json({ - error: 'Stalwart denied OAuthClient/set — admin permissions required.', + error: 'Stalwart denied OAuthClient/set - admin permissions required.', detail: setEntry?.[1], }, { status: 403 }); } diff --git a/app/api/admin/telemetry/route.ts b/app/api/admin/telemetry/route.ts new file mode 100644 index 00000000..a3de8eb1 --- /dev/null +++ b/app/api/admin/telemetry/route.ts @@ -0,0 +1,126 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { requireAdminAuth, getClientIP } from '@/lib/admin/session'; +import { auditLog } from '@/lib/admin/audit'; +import { logger } from '@/lib/logger'; +import { + effectiveConsent, + loadState, + saveState, + buildPayload, + sendOnce, + reschedule, + DEFAULT_ENDPOINT, +} from '@/lib/telemetry'; + +/** + * GET /api/admin/telemetry + * Returns current consent + endpoint + next/last send + a live preview + * of exactly what the next heartbeat would contain. + */ +export async function GET() { + try { + const auth = await requireAdminAuth(); + if ('error' in auth) return auth.error; + + const { consent, source, state } = await effectiveConsent(); + const payload = await buildPayload(); + + return NextResponse.json( + { + consent, + consentSource: source, + endpoint: state.endpoint || DEFAULT_ENDPOINT, + consentedAt: state.consentedAt, + lastSentAt: state.lastSentAt, + nextScheduledAt: state.nextScheduledAt, + defaultEndpoint: DEFAULT_ENDPOINT, + payloadPreview: payload, + }, + { headers: { 'Cache-Control': 'no-store' } }, + ); + } catch (err) { + logger.error('telemetry GET error', { + error: err instanceof Error ? err.message : 'unknown', + }); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} + +/** + * POST /api/admin/telemetry + * Body: { action: 'set-consent' | 'set-endpoint' | 'send-now', ... } + * set-consent : { action, consent: 'on' | 'off' } + * set-endpoint : { action, endpoint: string } + * send-now : { action } + */ +export async function POST(request: NextRequest) { + try { + const auth = await requireAdminAuth(); + if ('error' in auth) return auth.error; + const ip = getClientIP(request); + + const body = (await request.json().catch(() => null)) as + | { action?: string; consent?: string; endpoint?: string } + | null; + if (!body || typeof body.action !== 'string') { + return NextResponse.json({ error: 'action required' }, { status: 400 }); + } + + const { source } = await effectiveConsent(); + + if (body.action === 'set-consent') { + if (source === 'env') { + return NextResponse.json( + { error: 'consent is overridden by BULWARK_TELEMETRY env var' }, + { status: 409 }, + ); + } + if (body.consent !== 'on' && body.consent !== 'off') { + return NextResponse.json({ error: 'consent must be "on" or "off"' }, { status: 400 }); + } + const state = await loadState(); + const before = state.consent; + state.consent = body.consent; + if (body.consent === 'on' && !state.consentedAt) { + state.consentedAt = new Date().toISOString(); + } + await saveState(state); + await reschedule(); + await auditLog('telemetry.set-consent', { from: before, to: body.consent }, ip); + return NextResponse.json({ ok: true }); + } + + if (body.action === 'set-endpoint') { + if (typeof body.endpoint !== 'string') { + return NextResponse.json({ error: 'endpoint required' }, { status: 400 }); + } + const trimmed = body.endpoint.trim(); + if (trimmed && !/^https?:\/\//i.test(trimmed)) { + return NextResponse.json({ error: 'endpoint must be http(s)://' }, { status: 400 }); + } + const state = await loadState(); + const before = state.endpoint; + state.endpoint = trimmed || DEFAULT_ENDPOINT; + await saveState(state); + await auditLog('telemetry.set-endpoint', { from: before, to: state.endpoint }, ip); + return NextResponse.json({ ok: true, endpoint: state.endpoint }); + } + + if (body.action === 'send-now') { + const result = await sendOnce({ reason: 'admin-manual' }); + await auditLog( + 'telemetry.send-now', + { ok: result.ok, status: result.status ?? null, error: result.error ?? null }, + ip, + ); + return NextResponse.json(result, { status: result.ok ? 200 : 502 }); + } + + return NextResponse.json({ error: 'unknown action' }, { status: 400 }); + } catch (err) { + logger.error('telemetry POST error', { + error: err instanceof Error ? err.message : 'unknown', + }); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} diff --git a/components/layout/navigation-rail.tsx b/components/layout/navigation-rail.tsx index 4bdccb05..8c9b1163 100644 --- a/components/layout/navigation-rail.tsx +++ b/components/layout/navigation-rail.tsx @@ -335,7 +335,7 @@ export function NavigationRail({ ); })} - {/* Admin (Stalwart admins) — hard nav because /admin lives outside the [locale] tree */} + {/* Admin (Stalwart admins) - hard nav because /admin lives outside the [locale] tree */} {isStalwartAdmin && ( { console.info("Admin dashboard initialized"); }) + .then(async () => { + // Anonymous telemetry - opt-in, off until admin consents. + // See https://bulwarkmail.org/docs/legal/privacy/telemetry + const { startScheduler, markProcessStart } = await import("./lib/telemetry"); + markProcessStart(); + await startScheduler(); + }) .catch((err) => { console.warn("Admin dashboard init skipped:", err instanceof Error ? err.message : err); }); diff --git a/lib/admin/csp-frame-origins.ts b/lib/admin/csp-frame-origins.ts index 9fa211ea..910aca17 100644 --- a/lib/admin/csp-frame-origins.ts +++ b/lib/admin/csp-frame-origins.ts @@ -5,7 +5,7 @@ * domains in the host CSP. * * Origins are validated at install time and re-validated here as defense in - * depth — any malformed value is dropped so a corrupted registry can never + * depth - any malformed value is dropped so a corrupted registry can never * inject arbitrary CSP fragments. */ @@ -65,7 +65,7 @@ const CACHE_TTL_MS = 5_000; * the server-side registry, deduped and validated. * * Returns an empty array on any failure (missing file, parse error, …) so - * a broken registry only ever shrinks the CSP — never widens it. + * a broken registry only ever shrinks the CSP - never widens it. */ export async function getEnabledPluginFrameOrigins(): Promise { const now = Date.now(); diff --git a/lib/telemetry/index.ts b/lib/telemetry/index.ts new file mode 100644 index 00000000..cec835b8 --- /dev/null +++ b/lib/telemetry/index.ts @@ -0,0 +1,10 @@ +export { startScheduler, stopScheduler, reschedule, sendOnce } from './sender'; +export { buildPayload, markProcessStart } from './payload'; +export { + loadState, saveState, getInstanceId, effectiveConsent, +} from './state'; +export type { + TelemetryPayload, TelemetryStateFile, ConsentState, + Platform, OsFamily, CountBucket, TelemetryFeatures, +} from './types'; +export { DEFAULT_ENDPOINT } from './types'; diff --git a/lib/telemetry/payload.ts b/lib/telemetry/payload.ts new file mode 100644 index 00000000..c6d4d8f8 --- /dev/null +++ b/lib/telemetry/payload.ts @@ -0,0 +1,146 @@ +import { readFileSync } from 'node:fs'; +import path from 'node:path'; +import { configManager } from '@/lib/admin/config-manager'; +import { logger } from '@/lib/logger'; +import { getInstanceId } from './state'; +import type { + TelemetryPayload, + TelemetryFeatures, + Platform, + OsFamily, + CountBucket, +} from './types'; + +let processStartedAt = Date.now(); +export function markProcessStart(): void { + processStartedAt = Date.now(); +} + +function readPackage(): { version: string; build: string | null } { + try { + const pkg = JSON.parse( + readFileSync(path.join(process.cwd(), 'package.json'), 'utf8'), + ) as { version?: string }; + return { version: pkg.version ?? '0.0.0', build: process.env.BULWARK_BUILD ?? 'release' }; + } catch { + return { version: '0.0.0', build: null }; + } +} + +function detectPlatform(): Platform { + if (process.env.KUBERNETES_SERVICE_HOST) return 'k8s'; + // /.dockerenv is the standard Docker container marker. + try { + readFileSync('/.dockerenv'); + return 'docker'; + } catch { /* not in docker */ } + return 'bare'; +} + +function detectOs(): OsFamily { + switch (process.platform) { + case 'linux': return 'linux'; + case 'darwin': return 'darwin'; + case 'win32': return 'windows'; + default: return 'unknown'; + } +} + +export function bucketCount(n: number): CountBucket { + if (n <= 0) return '0'; + if (n === 1) return '1'; + if (n <= 5) return '2-5'; + if (n <= 10) return '6-10'; + if (n <= 50) return '11-50'; + if (n <= 200) return '51-200'; + return '201+'; +} + +async function readFeatures(): Promise { + await configManager.ensureLoaded(); + const policy = configManager.getPolicy(); + const gates = policy.features ?? {}; + const cfg = configManager.getAll(); + return { + // Booleans only. We read whether a feature is enabled - never any + // config value beyond a presence check. + calendar: gates.calendarTasksEnabled !== false, + contacts: true, + files: gates.filesEnabled === true, + extensions: gates.pluginsEnabled !== false, + push_relay: !!cfg['pushRelayUrl'], + oauth_enabled: !!cfg['oauthClientId'], + smime_enabled: gates.smimeEnabled === true, + webdav_enabled: gates.filesEnabled === true, + }; +} + +async function countAccounts(): Promise<{ total: number; active7d: number }> { + // Best-effort. If Stalwart's admin endpoint isn't reachable from here we + // return 0 / 0 - the heartbeat still fires. + try { + const adminUrl = process.env.STALWART_MGMT_URL || process.env.STALWART_ADMIN_URL; + const adminUser = process.env.STALWART_ADMIN_USER; + const adminPass = process.env.STALWART_ADMIN_PASSWORD; + if (!adminUrl || !adminUser || !adminPass) return { total: 0, active7d: 0 }; + const auth = Buffer.from(`${adminUser}:${adminPass}`).toString('base64'); + const res = await fetch(`${adminUrl.replace(/\/$/, '')}/api/principal?type=individual`, { + headers: { authorization: `Basic ${auth}` }, + signal: AbortSignal.timeout(2000), + }); + if (!res.ok) return { total: 0, active7d: 0 }; + const body = await res.json() as { data?: { total?: number } }; + const total = Number(body?.data?.total ?? 0); + return { total, active7d: total }; + } catch (err) { + logger.debug?.('telemetry: account count probe failed', { + error: err instanceof Error ? err.message : String(err), + }); + return { total: 0, active7d: 0 }; + } +} + +async function countExtensions(): Promise<{ extensions: number; themes: number }> { + try { + const { getPluginRegistry, getThemeRegistry } = await import('@/lib/admin/plugin-registry'); + const [plugins, themes] = await Promise.all([getPluginRegistry(), getThemeRegistry()]); + return { + extensions: plugins.plugins.length, + themes: themes.themes.length, + }; + } catch { + return { extensions: 0, themes: 0 }; + } +} + +export async function buildPayload(): Promise { + const instance_id = await getInstanceId(); + const { version, build } = readPackage(); + const features = await readFeatures(); + const accounts = await countAccounts(); + const exts = await countExtensions(); + const uptime_days = Math.min( + 365, + Math.floor((Date.now() - processStartedAt) / 86_400_000), + ); + + return { + schema: '1', + instance_id, + ts: new Date().toISOString(), + version, + build, + platform: detectPlatform(), + node_version: process.versions.node, + os_family: detectOs(), + stalwart_version: process.env.STALWART_VERSION ?? null, + features, + counts: { + accounts: bucketCount(accounts.total), + accounts_active_7d: bucketCount(accounts.active7d), + extensions_installed: exts.extensions, + themes_installed: exts.themes, + }, + uptime_days, + }; +} diff --git a/lib/telemetry/sender.ts b/lib/telemetry/sender.ts new file mode 100644 index 00000000..675b3e93 --- /dev/null +++ b/lib/telemetry/sender.ts @@ -0,0 +1,99 @@ +import { logger } from '@/lib/logger'; +import { effectiveConsent, endpointEnabled, loadState, saveState } from './state'; +import { buildPayload } from './payload'; +import { DEFAULT_ENDPOINT } from './types'; + +const DAY_MS = 24 * 60 * 60 * 1000; +const JITTER_MS = 2 * 60 * 60 * 1000; // ± 2 hours +const FIRST_DELAY_MS = 60 * 60 * 1000; // 1 hour after consent + +let currentTimer: NodeJS.Timeout | null = null; + +function jitteredDelay(base: number): number { + const j = (Math.random() * 2 - 1) * JITTER_MS; + return Math.max(60_000, base + j); +} + +export async function sendOnce(opts?: { reason?: string }): Promise<{ + ok: boolean; + status?: number; + error?: string; +}> { + const { consent, source, state } = await effectiveConsent(); + if (consent !== 'on') return { ok: false, error: `consent ${consent} (source ${source})` }; + const endpoint = state.endpoint || DEFAULT_ENDPOINT; + if (!endpointEnabled(endpoint)) return { ok: false, error: 'endpoint blank' }; + + const payload = await buildPayload(); + try { + const res = await fetch(endpoint, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(payload), + signal: AbortSignal.timeout(5000), + }); + const ok = res.ok; + if (ok) { + const next = await loadState(); + next.lastSentAt = new Date().toISOString(); + await saveState(next); + } + logger.info('telemetry: heartbeat', { + ok, status: res.status, reason: opts?.reason ?? 'scheduled', + }); + return { ok, status: res.status }; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + logger.warn('telemetry: heartbeat failed', { error: msg }); + return { ok: false, error: msg }; + } +} + +async function scheduleNext(delayMs: number): Promise { + if (currentTimer) clearTimeout(currentTimer); + const at = new Date(Date.now() + delayMs).toISOString(); + const state = await loadState(); + state.nextScheduledAt = at; + await saveState(state); + currentTimer = setTimeout(() => { void tick(); }, delayMs); + // Don't keep the process alive just for this. + currentTimer.unref?.(); +} + +async function tick(): Promise { + await sendOnce({ reason: 'scheduled' }); + await scheduleNext(jitteredDelay(DAY_MS)); +} + +// Called from instrumentation. Idempotent. +export async function startScheduler(): Promise { + const { consent } = await effectiveConsent(); + if (consent !== 'on') { + logger.info('telemetry: scheduler not started', { consent }); + return; + } + const state = await loadState(); + // If we have a next-scheduled time in the future use it; otherwise schedule + // FIRST_DELAY_MS out. This means after a restart we don't fire immediately. + let delay = FIRST_DELAY_MS; + if (state.nextScheduledAt) { + const remaining = new Date(state.nextScheduledAt).getTime() - Date.now(); + if (remaining > 0) delay = Math.min(remaining, DAY_MS + JITTER_MS); + } + await scheduleNext(delay); + logger.info('telemetry: scheduler started', { + nextInMs: delay, + endpoint: state.endpoint, + }); +} + +export async function stopScheduler(): Promise { + if (currentTimer) clearTimeout(currentTimer); + currentTimer = null; +} + +// Called when consent flips on/off via the UI. +export async function reschedule(): Promise { + await stopScheduler(); + await startScheduler(); +} diff --git a/lib/telemetry/state.ts b/lib/telemetry/state.ts new file mode 100644 index 00000000..ff94802e --- /dev/null +++ b/lib/telemetry/state.ts @@ -0,0 +1,101 @@ +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 (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 'on' — telemetry is anonymous and enabled by default. +// Admins can disable via the UI, the BULWARK_TELEMETRY env var, or by clearing +// the endpoint. See https://bulwarkmail.org/docs/legal/privacy/telemetry. +const DEFAULTS: TelemetryStateFile = { + consent: 'on', + 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-on state with + // an autoEnabledAt stamp so the admin UI can show "telemetry was + // auto-enabled at