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:
Grant
2026-05-08 11:35:50 -05:00
parent 763a44bbdd
commit d827b1aaab
7 changed files with 668 additions and 1 deletions
+97
View File
@@ -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