diff --git a/licensing-service/src/analytics.rs b/licensing-service/src/analytics.rs new file mode 100644 index 0000000..21aaf20 --- /dev/null +++ b/licensing-service/src/analytics.rs @@ -0,0 +1,223 @@ +//! Opt-in community analytics. +//! +//! Off by default. When the operator toggles it on (via the admin UI), +//! the daemon periodically POSTs a small anonymous heartbeat to a +//! configurable collector URL. The shape is designed to be useful +//! for "is Keysat being used and growing?" without exposing anything +//! that identifies a specific operator. +//! +//! ## What's sent (and what isn't) +//! +//! Sent: +//! - `install_uuid` — random UUIDv4 generated on first opt-in, +//! stored in the settings table. NOT derived from operator +//! identity, store id, or any user-supplied value. Resetting +//! analytics opt-in regenerates it. +//! - `daemon_version` — e.g. `"0.1.0:46"`. +//! - `tier` — `"unlicensed" | "creator" | "pro" | "patron"`. +//! - `counts` — rounded down to the nearest 5 to prevent +//! fingerprinting an operator by exact license count. +//! - `uptime_seconds` — bucketed to "<1d" / "1-7d" / "1-4w" / ">4w". +//! +//! Not sent: +//! - Operator name, public URL, BTCPay URL, store id. +//! - Any product or policy slug, name, or description. +//! - Any buyer email, license id, invoice id, or fingerprint. +//! - Admin API key, webhook secrets, or any other credential. +//! +//! The opt-in toggle lives in the admin UI (Overview page), with a +//! "what gets sent" disclosure and a one-click opt-out. The daemon +//! never starts the heartbeat task speculatively — the toggle has +//! to be on AND a collector URL has to be configured. + +use crate::api::AppState; +use crate::db::repo; +use serde::Serialize; +use std::time::Duration; +use uuid::Uuid; + +pub const SETTING_ENABLED: &str = "community_analytics_enabled"; +pub const SETTING_INSTALL_UUID: &str = "community_install_uuid"; +pub const SETTING_COLLECTOR_URL: &str = "community_collector_url"; + +/// Default upstream collector. v0.1.0:47 ships with this empty — +/// no URL means no requests, even if `enabled = true`. We'll set +/// the public collector URL on a future release once the +/// keysat.xyz/community endpoint is live. +const DEFAULT_COLLECTOR_URL: Option<&str> = None; + +#[derive(Debug, Serialize)] +pub struct Heartbeat { + pub install_uuid: String, + pub daemon_version: &'static str, + pub tier: &'static str, + pub counts: HeartbeatCounts, + pub uptime_bucket: &'static str, + pub schema_version: u32, +} + +#[derive(Debug, Serialize)] +pub struct HeartbeatCounts { + pub products: i64, + pub active_licenses: i64, + pub settled_invoices: i64, +} + +const HEARTBEAT_SCHEMA_VERSION: u32 = 1; + +/// Round down to the nearest `step`. `floor_to(23, 5) == 20`. Used +/// to prevent fingerprinting an operator by their exact license +/// count — a heartbeat that says "20-24 active licenses" is +/// sufficient signal without being unique. +fn floor_to(value: i64, step: i64) -> i64 { + if step <= 0 { + return value; + } + (value / step) * step +} + +fn uptime_bucket(secs: u64) -> &'static str { + let day = 86_400; + let week = 7 * day; + let four_weeks = 4 * week; + if secs < day { + "<1d" + } else if secs < week { + "1-7d" + } else if secs < four_weeks { + "1-4w" + } else { + ">4w" + } +} + +/// Build a heartbeat snapshot from current state. Always callable — +/// returns the snapshot synchronously without sending anything. +/// `started_at_secs_since_epoch` is the daemon's start time (used +/// to compute the uptime bucket). +pub async fn build_heartbeat( + state: &AppState, + install_uuid: &str, + started_at_secs: u64, +) -> anyhow::Result { + let products: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM products") + .fetch_one(&state.db) + .await?; + let active_licenses: i64 = + sqlx::query_scalar("SELECT COUNT(*) FROM licenses WHERE status = 'active'") + .fetch_one(&state.db) + .await?; + let settled_invoices: i64 = + sqlx::query_scalar("SELECT COUNT(*) FROM invoices WHERE status = 'settled'") + .fetch_one(&state.db) + .await?; + + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(started_at_secs); + let uptime = now.saturating_sub(started_at_secs); + + let tier_label = crate::api::tier::current(state).await.label; + + Ok(Heartbeat { + install_uuid: install_uuid.to_string(), + daemon_version: env!("CARGO_PKG_VERSION"), + tier: tier_label, + counts: HeartbeatCounts { + products: floor_to(products, 5), + active_licenses: floor_to(active_licenses, 5), + settled_invoices: floor_to(settled_invoices, 5), + }, + uptime_bucket: uptime_bucket(uptime), + schema_version: HEARTBEAT_SCHEMA_VERSION, + }) +} + +/// Read the opt-in flag. Returns Ok(false) on any storage error so +/// we always default to "off" — never accidentally beacon because +/// the settings table is unreachable. +pub async fn is_enabled(state: &AppState) -> bool { + match repo::settings_get(&state.db, SETTING_ENABLED).await { + Ok(Some(v)) => v == "1" || v.eq_ignore_ascii_case("true"), + _ => false, + } +} + +/// Get-or-create the install UUID. Idempotent; the first call after +/// opt-in writes a fresh UUIDv4 to the settings table, all later +/// calls read it back. +pub async fn ensure_install_uuid(state: &AppState) -> anyhow::Result { + if let Some(existing) = repo::settings_get(&state.db, SETTING_INSTALL_UUID).await? { + if !existing.is_empty() { + return Ok(existing); + } + } + let fresh = Uuid::new_v4().to_string(); + repo::settings_set(&state.db, SETTING_INSTALL_UUID, Some(&fresh)).await?; + Ok(fresh) +} + +/// Spawn the heartbeat-sending background task. No-op every tick if +/// the opt-in toggle is off OR no collector URL is configured. +/// +/// Tick cadence: every 24 hours, with a small initial delay so we +/// don't hit the collector during boot if many operators restart at +/// once. Aligned roughly to the day so heartbeats don't cluster. +pub fn spawn(state: AppState) { + let started_at_secs = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + + tokio::spawn(async move { + // 5-minute grace period after boot before the first + // heartbeat. Avoids beaconing during the warm-up window + // when caches are empty and counts might be misleading. + tokio::time::sleep(Duration::from_secs(300)).await; + loop { + if let Err(e) = tick(&state, started_at_secs).await { + tracing::warn!(error = %e, "community-analytics heartbeat failed"); + } + tokio::time::sleep(Duration::from_secs(86_400)).await; + } + }); +} + +async fn tick(state: &AppState, started_at_secs: u64) -> anyhow::Result<()> { + if !is_enabled(state).await { + return Ok(()); + } + let collector_url = match repo::settings_get(&state.db, SETTING_COLLECTOR_URL).await? { + Some(u) if !u.is_empty() => u, + _ => match DEFAULT_COLLECTOR_URL { + Some(u) => u.to_string(), + None => return Ok(()), // explicitly opted in but no URL configured — silent no-op + }, + }; + + let install_uuid = ensure_install_uuid(state).await?; + let payload = build_heartbeat(state, &install_uuid, started_at_secs).await?; + + let client = reqwest::Client::builder() + .timeout(Duration::from_secs(10)) + .build()?; + let resp = client + .post(&collector_url) + .json(&payload) + .send() + .await?; + if !resp.status().is_success() { + anyhow::bail!( + "collector responded with HTTP {}: {}", + resp.status(), + resp.text().await.unwrap_or_default() + ); + } + tracing::info!( + collector = %collector_url, + install_uuid = %install_uuid, + "community-analytics heartbeat sent" + ); + Ok(()) +} diff --git a/licensing-service/src/api/community.rs b/licensing-service/src/api/community.rs new file mode 100644 index 0000000..c303dc0 --- /dev/null +++ b/licensing-service/src/api/community.rs @@ -0,0 +1,173 @@ +//! Admin endpoints for the opt-in community analytics toggle. +//! +//! Three endpoints: +//! GET /v1/admin/community-analytics — current state + a +//! preview of what +//! would be sent +//! POST /v1/admin/community-analytics — set enabled + +//! collector_url +//! POST /v1/admin/community-analytics/reset — wipes the install +//! UUID (so a future +//! opt-in generates +//! a fresh anonymous +//! identifier) +//! +//! The toggle is intentionally a multi-step decision: enabling +//! requires the operator to also confirm a collector URL. The +//! daemon never beacons without both being set. + +use crate::analytics::{ + self, SETTING_COLLECTOR_URL, SETTING_ENABLED, SETTING_INSTALL_UUID, +}; +use crate::api::admin::{request_context, require_admin}; +use crate::api::AppState; +use crate::db::repo; +use crate::error::{AppError, AppResult}; +use axum::{extract::State, http::HeaderMap, Json}; +use serde::Deserialize; +use serde_json::{json, Value}; + +pub async fn get( + State(state): State, + headers: HeaderMap, +) -> AppResult> { + require_admin(&state, &headers)?; + let enabled = analytics::is_enabled(&state).await; + let collector_url = repo::settings_get(&state.db, SETTING_COLLECTOR_URL).await?; + let install_uuid = repo::settings_get(&state.db, SETTING_INSTALL_UUID).await?; + + // Preview: build a heartbeat snapshot RIGHT NOW so the operator + // sees exactly what would be sent. This is the privacy-by- + // demonstration move — nothing happens behind their back. + let preview = match install_uuid.as_deref() { + Some(uuid) if !uuid.is_empty() => { + // started_at = now-since-epoch; preview shows uptime "<1d" + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + let snap = analytics::build_heartbeat(&state, uuid, now).await?; + serde_json::to_value(snap).unwrap_or(serde_json::Value::Null) + } + _ => { + // Show what a heartbeat WOULD look like with a placeholder + // uuid so operators can see the shape before opting in. + let snap = analytics::build_heartbeat( + &state, + "00000000-0000-0000-0000-000000000000", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0), + ) + .await?; + serde_json::to_value(snap).unwrap_or(serde_json::Value::Null) + } + }; + + Ok(Json(json!({ + "enabled": enabled, + "collector_url": collector_url, + "install_uuid": install_uuid, + "preview_heartbeat": preview, + }))) +} + +#[derive(Debug, Deserialize)] +pub struct SetReq { + pub enabled: bool, + pub collector_url: Option, +} + +pub async fn set( + State(state): State, + headers: HeaderMap, + Json(req): Json, +) -> AppResult> { + let actor_hash = require_admin(&state, &headers)?; + let (ip, ua) = request_context(&headers); + + // Validate URL shape if one was supplied. We don't try to reach + // it — the heartbeat task does that on its own schedule. + let collector_url_clean: Option = req + .collector_url + .as_deref() + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()); + if let Some(url) = collector_url_clean.as_deref() { + if !(url.starts_with("http://") || url.starts_with("https://")) { + return Err(AppError::BadRequest( + "collector_url must start with http:// or https://".into(), + )); + } + } + + // Enabling without a collector URL is allowed (collector_url + // can be set later, OR the future built-in default URL will + // kick in once it ships). But surface the situation in the + // response so the SPA can show "enabled but not yet beaconing" + // state if relevant. + let enabled_str = if req.enabled { "1" } else { "0" }; + repo::settings_set(&state.db, SETTING_ENABLED, Some(enabled_str)).await?; + repo::settings_set( + &state.db, + SETTING_COLLECTOR_URL, + collector_url_clean.as_deref(), + ) + .await?; + + // Generate the install UUID on first opt-in. (No-op on + // subsequent toggles — the UUID persists across enable/disable + // cycles unless explicitly reset, so a flip-flop doesn't make + // the same install look like a new one.) + if req.enabled { + analytics::ensure_install_uuid(&state).await?; + } + + let _ = repo::insert_audit( + &state.db, + "admin_api_key", + Some(&actor_hash), + if req.enabled { "community_analytics.enable" } else { "community_analytics.disable" }, + Some("setting"), + Some(SETTING_ENABLED), + ip.as_deref(), + ua.as_deref(), + &json!({ + "enabled": req.enabled, + "collector_url_set": collector_url_clean.is_some(), + }), + ) + .await; + + Ok(Json(json!({ + "enabled": req.enabled, + "collector_url": collector_url_clean, + }))) +} + +/// Wipes the install UUID. After a reset, the next opt-in generates +/// a fresh UUID — useful for an operator who's been beaconing under +/// one identifier and wants to start over (e.g., after a DB restore +/// from a snapshot taken before they opted in originally). +pub async fn reset( + State(state): State, + headers: HeaderMap, +) -> AppResult> { + let actor_hash = require_admin(&state, &headers)?; + let (ip, ua) = request_context(&headers); + repo::settings_set(&state.db, SETTING_INSTALL_UUID, None).await?; + let _ = repo::insert_audit( + &state.db, + "admin_api_key", + Some(&actor_hash), + "community_analytics.reset", + Some("setting"), + Some(SETTING_INSTALL_UUID), + ip.as_deref(), + ua.as_deref(), + &json!({}), + ) + .await; + Ok(Json(json!({ "ok": true }))) +} diff --git a/licensing-service/src/api/mod.rs b/licensing-service/src/api/mod.rs index 7d3ce5e..9e50f39 100644 --- a/licensing-service/src/api/mod.rs +++ b/licensing-service/src/api/mod.rs @@ -69,6 +69,7 @@ pub mod self_license; pub mod session_layer; pub mod tier; pub mod validate; +pub mod community; pub mod db_info; pub mod recover; pub mod webhook; @@ -324,6 +325,16 @@ pub fn router(state: AppState) -> Router { // Database health snapshot — operator-facing sanity check // against the catastrophic-loss risk; see db_info.rs. .route("/v1/admin/db-info", get(db_info::get)) + // Opt-in community analytics. Off by default; toggling on + // requires the operator to confirm a collector URL. + .route( + "/v1/admin/community-analytics", + get(community::get).post(community::set), + ) + .route( + "/v1/admin/community-analytics/reset", + post(community::reset), + ) // Discount / referral codes. .route( "/v1/admin/discount-codes", diff --git a/licensing-service/src/lib.rs b/licensing-service/src/lib.rs index 471b792..dd194e0 100644 --- a/licensing-service/src/lib.rs +++ b/licensing-service/src/lib.rs @@ -7,6 +7,7 @@ //! background tasks. Tests bypass that wrapper and construct `AppState` //! programmatically. +pub mod analytics; pub mod api; pub mod btcpay; pub mod config; diff --git a/licensing-service/src/main.rs b/licensing-service/src/main.rs index 97c11be..c687fe8 100644 --- a/licensing-service/src/main.rs +++ b/licensing-service/src/main.rs @@ -6,7 +6,9 @@ //! changes between targets. use anyhow::Context; -use keysat::{api, btcpay, config, crypto, db, license_self, payment, reconcile, webhooks}; +use keysat::{ + analytics, api, btcpay, config, crypto, db, license_self, payment, reconcile, webhooks, +}; use std::sync::Arc; use tower_http::trace::TraceLayer; use tracing_subscriber::{fmt, prelude::*, EnvFilter}; @@ -80,6 +82,10 @@ async fn main() -> anyhow::Result<()> { // Spawn background loops before handing state to the router. reconcile::spawn(state.clone()); webhooks::spawn_delivery_worker(state.clone()); + // Opt-in community analytics — every tick checks the toggle + // and short-circuits if disabled (default), so spawning is safe + // unconditionally. + analytics::spawn(state.clone()); // Hourly session reaper — drops sessions whose expires_at < now. { diff --git a/licensing-service/tests/api.rs b/licensing-service/tests/api.rs index 6109904..136a5c8 100644 --- a/licensing-service/tests/api.rs +++ b/licensing-service/tests/api.rs @@ -1138,3 +1138,159 @@ async fn recover_returns_license_key_for_matching_pair() { .unwrap(); assert_eq!(audit_count, 1, "recovery must write an audit row"); } + +/// Community analytics: opt-in toggle + privacy contract. +/// +/// Locks in two invariants: +/// - Default state is OFF; no install_uuid generated. +/// - Enabling generates a fresh install_uuid; the heartbeat +/// preview's counts are floored to the nearest 5 (anti- +/// fingerprinting); no operator-identifying fields are present. +/// - Bad collector URL → 400 (must start with http:// or https://). +#[tokio::test] +async fn community_analytics_opt_in_and_privacy_contract() { + let (state, _tmp) = make_test_state().await; + let auth = format!("Bearer {}", TEST_ADMIN_KEY); + + // Default state: disabled, no install_uuid yet. + let req = build_request( + "GET", + "/v1/admin/community-analytics", + &[("authorization", &auth)], + None, + ); + let resp = send(&state, req).await; + assert_eq!(resp.status(), StatusCode::OK); + let body = body_json(resp).await; + assert_eq!(body["enabled"], false, "must default to off"); + assert!( + body["install_uuid"].is_null(), + "no UUID should exist before opt-in" + ); + assert!( + body["collector_url"].is_null(), + "no URL should exist before opt-in" + ); + + // Bad URL → 400. + let req = build_request( + "POST", + "/v1/admin/community-analytics", + &[("authorization", &auth)], + Some(json!({"enabled": true, "collector_url": "ftp://wrong"})), + ); + let resp = send(&state, req).await; + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); + + // Enabling without a URL is allowed (armed but silent). + let req = build_request( + "POST", + "/v1/admin/community-analytics", + &[("authorization", &auth)], + Some(json!({"enabled": true, "collector_url": null})), + ); + let resp = send(&state, req).await; + assert_eq!(resp.status(), StatusCode::OK); + + // Now an install_uuid exists. + let req = build_request( + "GET", + "/v1/admin/community-analytics", + &[("authorization", &auth)], + None, + ); + let resp = send(&state, req).await; + let body = body_json(resp).await; + assert_eq!(body["enabled"], true); + let uuid = body["install_uuid"] + .as_str() + .expect("install_uuid should be present after opt-in"); + assert_eq!(uuid.len(), 36, "install_uuid should be a UUIDv4 string"); + + // Privacy contract: the preview heartbeat MUST contain only + // anonymized fields. Specifically, no operator_name, no + // public_url, no store_id, no api keys, no buyer info. + let preview = &body["preview_heartbeat"]; + let preview_str = + serde_json::to_string(preview).expect("preview should serialize"); + for forbidden in &[ + "operator_name", + "public_url", + "store_id", + "api_key", + "buyer_email", + "btcpay_url", + ] { + assert!( + !preview_str.contains(forbidden), + "preview heartbeat must not contain '{forbidden}': {preview_str}" + ); + } + // Counts must be floored to the nearest 5. Seed 23 active + // licenses → counts.active_licenses must be 20. + let product = repo::create_product( + &state.db, + "ana-prod", + "Analytics Test", + "", + 100, + &json!({}), + ) + .await + .unwrap(); + for _ in 0..23 { + let lid = Uuid::new_v4().to_string(); + repo::create_license( + &state.db, + &lid, + &product.id, + None, + &Utc::now().to_rfc3339(), + &json!({}), + None, + None, + 0, + 1, + &[], + false, + None, + None, + ) + .await + .unwrap(); + } + let req = build_request( + "GET", + "/v1/admin/community-analytics", + &[("authorization", &auth)], + None, + ); + let resp = send(&state, req).await; + let body = body_json(resp).await; + let preview = &body["preview_heartbeat"]; + assert_eq!( + preview["counts"]["active_licenses"], 20, + "23 licenses must floor to 20 (anti-fingerprinting): {preview:?}" + ); + + // Reset wipes the UUID. + let req = build_request( + "POST", + "/v1/admin/community-analytics/reset", + &[("authorization", &auth)], + None, + ); + let resp = send(&state, req).await; + assert_eq!(resp.status(), StatusCode::OK); + let req = build_request( + "GET", + "/v1/admin/community-analytics", + &[("authorization", &auth)], + None, + ); + let body = body_json(send(&state, req).await).await; + assert!( + body["install_uuid"].is_null(), + "install_uuid must be wiped after reset: {body:?}" + ); +} diff --git a/licensing-service/web/index.html b/licensing-service/web/index.html index 7ecc9ac..a2ed615 100644 --- a/licensing-service/web/index.html +++ b/licensing-service/web/index.html @@ -892,6 +892,12 @@ The request will be refused if there are licenses or invoices tied to it — use sBtc.querySelector('.value').textContent = '?' } + // Community analytics opt-in card. Off by default; spelled out + // exactly what gets sent so the operator's choice is informed. + const analyticsCard = el('div', { class: 'card' }) + target.appendChild(analyticsCard) + renderAnalyticsCard(analyticsCard) + // Public key fetch — pulls PEM from /v1/issuer/public-key (no auth // required) and displays a short preview. Copy button copies the full // PEM, including BEGIN/END headers, ready to paste into source. @@ -924,6 +930,97 @@ The request will be refused if there are licenses or invoices tied to it — use ]) } + // Renders the "Help improve Keysat" card on Overview. Off by default; + // operators see exactly what gets sent before opting in. Toggling on + // requires confirming a collector URL — without one, the daemon + // doesn't beacon even with the toggle on. + async function renderAnalyticsCard(card) { + card.innerHTML = '' + let s + try { + s = await api('/v1/admin/community-analytics') + } catch (e) { + card.appendChild(el('p', { class: 'muted' }, 'Could not load analytics state: ' + e.message)) + return + } + + const headerLeft = el('div', null, [ + el('h3', { style: 'margin:0 0 4px' }, 'Help improve Keysat'), + el('p', { class: 'muted', style: 'margin:0; font-size:14px' }, + 'Send an anonymous daily heartbeat so we can show real adoption numbers on the public dashboard.'), + ]) + const toggle = el('label', { + style: 'display:inline-flex; align-items:center; gap:10px; font-weight:600; font-size:14px; cursor:pointer' + }, [ + el('input', { type: 'checkbox', checked: s.enabled ? 'checked' : null }), + s.enabled ? 'Enabled' : 'Disabled', + ]) + const toggleInput = toggle.querySelector('input') + card.appendChild(el('div', { + style: 'display:flex; justify-content:space-between; align-items:flex-start; gap:24px; margin-bottom:16px' + }, [headerLeft, toggle])) + + // Collector URL field. Required to actually send anything; the + // toggle being on without a URL is "armed but silent". + const urlInput = el('input', { + class: 'input', + type: 'url', + placeholder: 'https://keysat.xyz/community/v1/heartbeat (or your own collector)', + value: s.collector_url || '', + style: 'width:100%; box-sizing:border-box; margin-bottom:8px', + }) + card.appendChild(el('label', { style: 'display:block; font-weight:600; font-size:13px; margin-bottom:4px' }, 'Collector URL')) + card.appendChild(urlInput) + card.appendChild(el('p', { class: 'muted', style: 'margin:4px 0 16px; font-size:12px' }, + 'Leave blank to opt in but not send (useful while a public collector is being stood up). Once keysat.xyz/community is live, the default URL will populate here on upgrade.')) + + // Privacy disclosure — show the exact JSON shape that would be sent. + const disclosure = el('details', { class: 'disclosure' }, [ + el('summary', null, 'Show me exactly what gets sent'), + el('div', { class: 'body' }, [ + el('p', { class: 'muted', style: 'margin:0 0 12px' }, + 'Counts are floored to the nearest 5 to prevent fingerprinting an operator by exact license count. ' + + 'Uptime is bucketed (<1d / 1-7d / 1-4w / >4w). The install_uuid is a random UUIDv4 generated on first opt-in — ' + + 'NOT derived from your operator name, store id, or public URL. You can wipe it any time below.'), + el('pre', { style: 'background:#0e1f33; color:#f6f1e7; padding:12px; border-radius:6px; font-size:12px; overflow-x:auto' }, + JSON.stringify(s.preview_heartbeat, null, 2)), + s.install_uuid + ? el('p', { class: 'muted', style: 'margin:12px 0 8px; font-size:12px' }, + 'Your install_uuid: ' + s.install_uuid) + : null, + ].filter(Boolean)), + ]) + card.appendChild(disclosure) + + // Save button. + const saveBtn = el('button', { class: 'btn primary', style: 'margin-top:16px; margin-right:8px' }, 'Save') + saveBtn.addEventListener('click', async () => { + saveBtn.disabled = true + try { + await api('/v1/admin/community-analytics', { method: 'POST', body: { + enabled: toggleInput.checked, + collector_url: urlInput.value.trim() || null, + }}) + renderAnalyticsCard(card) + } catch (e) { + alert(e.message) + } finally { + saveBtn.disabled = false + } + }) + + const resetBtn = el('button', { class: 'btn sm secondary', style: 'margin-top:16px' }, 'Reset install_uuid') + resetBtn.addEventListener('click', async () => { + if (!confirm('This wipes your anonymous install_uuid. Future heartbeats (if you re-enable) will use a fresh UUID. Continue?')) return + try { + await api('/v1/admin/community-analytics/reset', { method: 'POST' }) + renderAnalyticsCard(card) + } catch (e) { alert(e.message) } + }) + card.appendChild(saveBtn) + if (s.install_uuid) card.appendChild(resetBtn) + } + async function copyPubkey() { const span = document.getElementById('pubkey-preview') const k = span.dataset.full