Opt-in community analytics + admin UI surface
Closes the last T2 plan item. Off by default; toggling on requires the operator to confirm a collector URL (an empty URL is "armed but silent"). The toggle lives on the admin Overview page next to the public-key card — the right place for a privacy-affecting choice since it's where operators actually live. What's sent (per the in-card "Show me exactly what gets sent" disclosure, and pinned by the test): - install_uuid: random UUIDv4 generated on first opt-in. NOT derived from operator_name, store id, public URL, or any other identifier. Wipeable via the Reset button. - daemon_version (CARGO_PKG_VERSION). - tier (creator/pro/patron/unlicensed) — the same string the admin tier endpoint already exposes. - counts: products, active_licenses, settled_invoices — each floored to the nearest 5 (anti-fingerprinting; an exact license count uniquely identifies an operator over time). - uptime_bucket: <1d / 1-7d / 1-4w / >4w (bucketed, not exact). What's NOT sent (test asserts none of these strings appear in the preview heartbeat): operator_name, public_url, store_id, api_key, buyer_email, btcpay_url. Also no product/policy slugs or names, no license/invoice ids, no fingerprints, no webhook secrets. Backend: - src/analytics.rs — heartbeat builder, opt-in check, daily background tick (5min initial grace period after boot). - src/api/community.rs — GET / POST / reset admin endpoints. - main.rs spawns the background tick unconditionally; the tick is a no-op if disabled OR no collector URL configured. Frontend (web/index.html, Overview page): - Toggle + collector URL input + privacy disclosure showing the EXACT JSON shape that would be sent (renders the live preview heartbeat from /v1/admin/community-analytics). - "Reset install_uuid" button so an operator who's been beaconing under one identifier can start fresh. Also includes the configureBtcpay.ts idempotency change from v0.1.0:46 (already committed; touched again here only because the diff includes the .ts file in the same dirty-tree push). Test count: 32 (was 31; +1 community_analytics_opt_in_and_privacy_contract which seeds 23 licenses and verifies the heartbeat reports 20 — proves the floor-to-5 anti-fingerprinting is in effect).
This commit is contained in:
@@ -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<Heartbeat> {
|
||||||
|
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<String> {
|
||||||
|
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(())
|
||||||
|
}
|
||||||
@@ -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<AppState>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
) -> AppResult<Json<Value>> {
|
||||||
|
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<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn set(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
Json(req): Json<SetReq>,
|
||||||
|
) -> AppResult<Json<Value>> {
|
||||||
|
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<String> = 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<AppState>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
) -> AppResult<Json<Value>> {
|
||||||
|
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 })))
|
||||||
|
}
|
||||||
@@ -69,6 +69,7 @@ pub mod self_license;
|
|||||||
pub mod session_layer;
|
pub mod session_layer;
|
||||||
pub mod tier;
|
pub mod tier;
|
||||||
pub mod validate;
|
pub mod validate;
|
||||||
|
pub mod community;
|
||||||
pub mod db_info;
|
pub mod db_info;
|
||||||
pub mod recover;
|
pub mod recover;
|
||||||
pub mod webhook;
|
pub mod webhook;
|
||||||
@@ -324,6 +325,16 @@ pub fn router(state: AppState) -> Router {
|
|||||||
// Database health snapshot — operator-facing sanity check
|
// Database health snapshot — operator-facing sanity check
|
||||||
// against the catastrophic-loss risk; see db_info.rs.
|
// against the catastrophic-loss risk; see db_info.rs.
|
||||||
.route("/v1/admin/db-info", get(db_info::get))
|
.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.
|
// Discount / referral codes.
|
||||||
.route(
|
.route(
|
||||||
"/v1/admin/discount-codes",
|
"/v1/admin/discount-codes",
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
//! background tasks. Tests bypass that wrapper and construct `AppState`
|
//! background tasks. Tests bypass that wrapper and construct `AppState`
|
||||||
//! programmatically.
|
//! programmatically.
|
||||||
|
|
||||||
|
pub mod analytics;
|
||||||
pub mod api;
|
pub mod api;
|
||||||
pub mod btcpay;
|
pub mod btcpay;
|
||||||
pub mod config;
|
pub mod config;
|
||||||
|
|||||||
@@ -6,7 +6,9 @@
|
|||||||
//! changes between targets.
|
//! changes between targets.
|
||||||
|
|
||||||
use anyhow::Context;
|
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 std::sync::Arc;
|
||||||
use tower_http::trace::TraceLayer;
|
use tower_http::trace::TraceLayer;
|
||||||
use tracing_subscriber::{fmt, prelude::*, EnvFilter};
|
use tracing_subscriber::{fmt, prelude::*, EnvFilter};
|
||||||
@@ -80,6 +82,10 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
// Spawn background loops before handing state to the router.
|
// Spawn background loops before handing state to the router.
|
||||||
reconcile::spawn(state.clone());
|
reconcile::spawn(state.clone());
|
||||||
webhooks::spawn_delivery_worker(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.
|
// Hourly session reaper — drops sessions whose expires_at < now.
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1138,3 +1138,159 @@ async fn recover_returns_license_key_for_matching_pair() {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(audit_count, 1, "recovery must write an audit row");
|
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:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -892,6 +892,12 @@ The request will be refused if there are licenses or invoices tied to it — use
|
|||||||
sBtc.querySelector('.value').textContent = '?'
|
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
|
// Public key fetch — pulls PEM from /v1/issuer/public-key (no auth
|
||||||
// required) and displays a short preview. Copy button copies the full
|
// required) and displays a short preview. Copy button copies the full
|
||||||
// PEM, including BEGIN/END headers, ready to paste into source.
|
// 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() {
|
async function copyPubkey() {
|
||||||
const span = document.getElementById('pubkey-preview')
|
const span = document.getElementById('pubkey-preview')
|
||||||
const k = span.dataset.full
|
const k = span.dataset.full
|
||||||
|
|||||||
Reference in New Issue
Block a user