thermograph/frontend/static/consent.js
Emi Griffith 35b58300cb UI events v2: consented session tier, failed-search capture, privacy rewrite
Three operator decisions change v1's premises, so the design is now two tiers
with separate flags:

Tier A (THERMOGRAPH_EVENTS) is v1 unchanged -- anonymous hourly aggregates, no
identifier, nothing stored on the device, no consent needed, runs for everyone.
It stays the primary signal precisely so a poor consent rate cannot take the
numbers down with it, and so Tier B's rates can be calibrated against it to
measure the consent bias rather than ignoring it.

Tier B (THERMOGRAPH_EVENT_SESSIONS) adds an ephemeral per-tab id -- 16 random
bytes in sessionStorage, rotating on a 30-minute idle and a 2-hour absolute cap,
capped at 200 events, never linked to another session, device, or to the account
(api_event does not read the auth cookie and there is no user column). Rows land
in a 30-day hypertable with minute-granularity timestamps and an in-session
sequence number instead of precise clock times. Storing an identifier engages
ePrivacy Art. 5(3) and analytics is not strictly necessary, so it is gated on
opt-in consent: an equal-weight banner that mints nothing before "Allow",
one-click withdrawal in every footer that drops the live id immediately, and
GPC/DNT treated as a refusal already given.

THERMOGRAPH_SEARCH_MISS captures zero-result search text server-side in
api_suggest only -- normalised, rejected outright on any personal-data smell,
stored as a per-day count, and pruned below a three-person floor after a week.

The privacy page is rewritten rather than deferred: "no per-visitor identifier"
becomes false the moment Tier B ships. Tests assert the load-bearing promises so
the copy and the code cannot drift apart silently.

Raw-IP truncation is NOT implemented here (the logging pipeline owns it) but
UI-EVENTS.md states what the app side must do, and records that Caddy's default
JSON log already stores full request URIs -- so every search query is in Loki
with the client IP today, which the search-miss mitigations depend on fixing.

UI-EVENTS.md carries the v1-to-v2 diff, the consent reasoning including why
legitimate interest is not available, and an explicit argument that the session
identifier is the wrong trade at this traffic volume.
2026-07-23 16:11:06 -07:00

145 lines
6.1 KiB
JavaScript

// Analytics consent: the opt-in gate in front of the session-scoped tier.
//
// WHY THIS EXISTS. The anonymous tier (hourly counts, no identifier, nothing
// written to your device) needs no permission. The session tier does: it mints
// an ephemeral id and stores it in sessionStorage, and storing an identifier on
// someone's device engages ePrivacy Art. 5(3). Product analytics is not
// "strictly necessary to provide a service the user explicitly requested", so
// the exemption does not apply and the lawful basis is opt-in consent.
//
// RULES THIS FILE ENFORCES, all of them load-bearing:
// * Nothing is stored and no id is minted before an explicit "Allow". Not on
// first paint, not "pending a choice" — refusing by ignoring the banner must
// leave exactly as little behind as refusing explicitly.
// * Allow and No are the same size, same weight, same prominence. No pre-tick,
// no "legitimate interest" second layer, no cookie-wall.
// * Withdrawal is as easy as granting, reachable at any time from the footer
// and the privacy page, and it deletes the live session id immediately.
// * GPC / DNT are treated as a refusal already given: no banner is shown and
// nothing is stored. Stricter than the letter of the law, and it means the
// most privacy-conscious visitors are never nagged.
//
// The consent RECORD itself lives in localStorage. Storing a user's own choice
// is strictly necessary to honour it, so it is exempt from the consent it
// records — but it is deliberately the only thing kept across sessions.
const KEY = "thermograph:analytics-consent";
//: Bump to re-ask everyone — only when what is collected materially changes.
const POLICY_VERSION = 1;
//: Re-ask a granter after this long. Never re-ask a refuser inside it: a banner
//: that keeps reappearing after "no" is a dark pattern, not a question.
const GRANT_TTL_MS = 183 * 24 * 3600 * 1000; // ~6 months
const DENY_TTL_MS = 365 * 24 * 3600 * 1000; // ~12 months
export const signalledOptOut =
navigator.globalPrivacyControl === true ||
navigator.doNotTrack === "1" ||
window.doNotTrack === "1";
const listeners = [];
let state = null; // "granted" | "denied" | null (not asked)
function read() {
try {
const raw = localStorage.getItem(KEY);
if (!raw) return null;
const rec = JSON.parse(raw);
if (rec.v !== POLICY_VERSION) return null;
const ttl = rec.choice === "granted" ? GRANT_TTL_MS : DENY_TTL_MS;
if (!rec.ts || Date.now() - rec.ts > ttl) return null;
return rec.choice === "granted" ? "granted" : "denied";
} catch { return null; }
}
function write(choice) {
try {
localStorage.setItem(KEY, JSON.stringify({ v: POLICY_VERSION, choice, ts: Date.now() }));
} catch { /* private mode: the choice just won't persist */ }
}
/** "granted" | "denied" | null. GPC/DNT short-circuits to "denied". */
export function consentState() {
if (signalledOptOut) return "denied";
if (state === null) state = read();
return state;
}
export const hasConsent = () => consentState() === "granted";
/** Called with the new state whenever it changes — track.js uses this to drop
* the live session id the instant consent is withdrawn. */
export function onConsentChange(cb) { listeners.push(cb); }
export function setConsent(choice) {
state = choice === "granted" ? "granted" : "denied";
write(state);
hideBanner();
paintControls();
for (const cb of listeners) { try { cb(state); } catch { /* never break */ } }
}
// --- the banner ---------------------------------------------------------------
// Rendered only when instrumentation is enabled AND no choice has been recorded
// AND no opt-out signal is present. It is a bar, not a modal: it never blocks
// reading the page, which is the whole product.
let banner = null;
function hideBanner() {
if (banner) { banner.remove(); banner = null; }
}
function showBanner() {
if (banner) return;
banner = document.createElement("div");
banner.className = "consent-bar";
banner.setAttribute("role", "dialog");
banner.setAttribute("aria-label", "Analytics");
banner.innerHTML = `
<p>Can we count how this page gets used? Anonymous, kept for 30 days, never
shared, and never linked to you or to an account.
<a class="consent-more" href="">What this collects</a></p>
<div class="consent-actions">
<button type="button" class="consent-no">No thanks</button>
<button type="button" class="consent-yes">Allow</button>
</div>`;
// The privacy link is resolved against the page, not hardcoded, so it is right
// under both the root-served and /thermograph-prefixed deployments.
const more = banner.querySelector(".consent-more");
more.href = new URL("privacy", document.baseURI).pathname;
banner.querySelector(".consent-yes").addEventListener("click", () => setConsent("granted"));
banner.querySelector(".consent-no").addEventListener("click", () => setConsent("denied"));
document.body.appendChild(banner);
}
// --- the withdrawal control ---------------------------------------------------
// Any [data-consent-control] element becomes a live "Analytics: on/off — change"
// toggle. The footer carries one on every page, so withdrawing is never more
// than one click from wherever the visitor is.
function paintControls() {
const s = consentState();
for (const el of document.querySelectorAll("[data-consent-control]")) {
if (signalledOptOut) {
el.textContent = "Analytics: off (your browser asked us not to)";
continue;
}
const on = s === "granted";
el.innerHTML =
`Analytics: ${on ? "on" : "off"} <button type="button" class="consent-toggle">`
+ `${on ? "Turn off" : "Turn on"}</button>`;
el.querySelector(".consent-toggle").addEventListener(
"click", () => setConsent(on ? "denied" : "granted"));
}
}
export function initConsent() {
// Instrumentation disabled for this environment: no banner, no control, no
// storage. The flag is the outermost gate on everything.
if (document.documentElement.dataset.tgEvents !== "1") return;
paintControls();
if (signalledOptOut) return; // already refused, at the browser level
if (consentState() === null) showBanner();
}
initConsent();