feat(analytics): in-repo publishable token — source builds get the same consent-gated analytics (#1193)

Owner-sanctioned reversal: the publishable write-only PostHog client key is
committed as the in-repo default in backend/core/analytics.py and
frontend/src/utils/analytics.ts (env / baked release token still wins), so
source builds show the same first-run consent ask as installers — skip = off,
nothing is ever sent without an explicit yes. Adds an install_channel property
(installer / docker / source) to lifecycle events, stamped by the desktop shell
via OMNIVOICE_INSTALL_CHANNEL and by the Docker image's existing
OMNIVOICE_SERVER_MODE marker. Guard tests now pin the two-canonical-files
allowlist + same-token invariant, and the uninstall-ping info file works on the
default token.

Fixes #1193

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
debpalash
2026-07-20 11:51:40 +05:30
co-authored by Claude Fable 5
parent 367ed1e8e0
commit 23f1767e3c
14 changed files with 277 additions and 101 deletions
+2 -1
View File
@@ -29,7 +29,8 @@ The bundled TTS model package (`pyproject.toml`) is versioned independently.
- Settings → Permissions + wizard System Check: live mic/Accessibility grant state, per-OS guidance, Open Settings deep-links; dictation pre-flights the mic grant (#1175) - Settings → Permissions + wizard System Check: live mic/Accessibility grant state, per-OS guidance, Open Settings deep-links; dictation pre-flights the mic grant (#1175)
- `parakeet-mlx` engine: Parakeet TDT v3 on Apple Silicon — 25 EU languages, word timestamps, ~2 GB, opt-in from Settings → Models, never auto-downloads (#1175) - `parakeet-mlx` engine: Parakeet TDT v3 on Apple Silicon — 25 EU languages, word timestamps, ~2 GB, opt-in from Settings → Models, never auto-downloads (#1175)
- First-run downloads race the direct GitHub path against the mirror and use whichever answers fastest (#1179) - First-run downloads race the direct GitHub path against the mirror and use whichever answers fastest (#1179)
- First-run consent question for the existing opt-in analytics (two equal buttons, skip = no; source builds never ask) - First-run consent question for the existing opt-in analytics (two equal buttons, skip = no)
- Source builds carry the publishable analytics token and get the same first-run consent ask as installers; opt-in events now note the install channel (installer / docker / source) — thanks @agudmund! (#1193)
- Official Google Colab notebook (`notebooks/OmniVoice_Studio_Colab.ipynb`) — full app + API feature tour on a free T4 - Official Google Colab notebook (`notebooks/OmniVoice_Studio_Colab.ipynb`) — full app + API feature tour on a free T4
- ROCm Docker image `ghcr.io/debpalash/omnivoice-studio:rocm` (+ `:stable-rocm`, `:X.Y.Z-rocm`) (#1165) - ROCm Docker image `ghcr.io/debpalash/omnivoice-studio:rocm` (+ `:stable-rocm`, `:X.Y.Z-rocm`) (#1165)
- `OMNIVOICE_TRUSTED_NETWORKS` — comma-separated CIDRs exempted from the consumption auth gates (share PIN / API key / dictation WS); admin routes stay loopback-only (#1170) - `OMNIVOICE_TRUSTED_NETWORKS` — comma-separated CIDRs exempted from the consumption auth gates (share PIN / API key / dictation WS); admin routes stay loopback-only (#1170)
+2 -2
View File
@@ -15,7 +15,7 @@ Everything else (new engines, fancy features) is downstream of "the thing instal
- **Cross-platform parity**: Every fix must work on macOS (Apple Silicon + Intel), Windows (x64), and Linux (AppImage + deb). No platform-only regressions; the cross-platform bug bash (PR #51) is the baseline. - **Cross-platform parity**: Every fix must work on macOS (Apple Silicon + Intel), Windows (x64), and Linux (AppImage + deb). No platform-only regressions; the cross-platform bug bash (PR #51) is the baseline.
- **Default features must work on every platform (strict rule, 2026-05-20):** A feature that ships in default mode — out-of-the-box, no user customization, no opt-in toggle — must behave identically on macOS, Windows, and Linux. Platform-specific *implementation code* is allowed for OS APIs / shells / packaging, but the user-visible *default behavior* cannot diverge. Platform-only features (e.g., a macOS-only global shortcut, a Windows-only path picker) must go behind explicit user opt-in: Settings toggle, env var, or CLI flag. When a default doesn't work on a platform, that's a P0 bug — either fix it on the missing platform or move it behind opt-in. No third option. - **Default features must work on every platform (strict rule, 2026-05-20):** A feature that ships in default mode — out-of-the-box, no user customization, no opt-in toggle — must behave identically on macOS, Windows, and Linux. Platform-specific *implementation code* is allowed for OS APIs / shells / packaging, but the user-visible *default behavior* cannot diverge. Platform-only features (e.g., a macOS-only global shortcut, a Windows-only path picker) must go behind explicit user opt-in: Settings toggle, env var, or CLI flag. When a default doesn't work on a platform, that's a P0 bug — either fix it on the missing platform or move it behind opt-in. No third option.
- **Backward-compatible project data**: Existing `omnivoice_data/` (user voices, projects, settings) must keep working without manual migration. Any DB schema change goes through alembic with a tested upgrade path. - **Backward-compatible project data**: Existing `omnivoice_data/` (user voices, projects, settings) must keep working without manual migration. Any DB schema change goes through alembic with a tested upgrade path.
- **Local-first guarantee preserved**: nothing leaves the machine without the user's **explicit yes**, and the app must remain fully functional with everything declined. Auto bug reporting is opt-in and submits only to GitHub Issues (prefilled-URL, from the user's own browser). Product analytics (owner-sanctioned 2026-07-16) is opt-in PostHog EU with a **first-run consent prompt** — two equal-weight Yes/No buttons, never default-on, skipping = off; consent-gated, allowlisted content-free metadata only (`backend/core/analytics.py`); source builds have no token and never even ask. No required cloud calls, accounts, or API keys. - **Local-first guarantee preserved**: nothing leaves the machine without the user's **explicit yes**, and the app must remain fully functional with everything declined. Auto bug reporting is opt-in and submits only to GitHub Issues (prefilled-URL, from the user's own browser). Product analytics (owner-sanctioned 2026-07-16) is opt-in PostHog EU with a **first-run consent prompt** — two equal-weight Yes/No buttons, never default-on, skipping = off; consent-gated, allowlisted content-free metadata only (`backend/core/analytics.py`); every build — installer, Docker, and source alike (owner reversal 2026-07-20, #1193) — carries the in-repo publishable write-only token and shows the same consent ask, with env/baked token overriding it. No required cloud calls, accounts, or API keys.
- **Beta release cadence (no RC, no ceremony — strict rule, 2026-05-20):** the v0.3.x line has **no release candidates, no 48h soak, no formal release ceremony**. Every fix goes continuous-to-main; the owner tags a patch (`v0.3.Z`) from main whenever the current state is worth cutting. No `-rc` tags. No phased release. No `v0.4` deferrals while the v0.3.x line is open — every open issue and every open community PR gets absorbed into the v0.3.x line or explicitly declined. Users follow `main` for previews; users wanting stable stay on the latest tagged release. ROADMAP.md's Phase 6 "Release/Verify/Retro" entries are obsolete unless the user revives them. - **Beta release cadence (no RC, no ceremony — strict rule, 2026-05-20):** the v0.3.x line has **no release candidates, no 48h soak, no formal release ceremony**. Every fix goes continuous-to-main; the owner tags a patch (`v0.3.Z`) from main whenever the current state is worth cutting. No `-rc` tags. No phased release. No `v0.4` deferrals while the v0.3.x line is open — every open issue and every open community PR gets absorbed into the v0.3.x line or explicitly declined. Users follow `main` for previews; users wanting stable stay on the latest tagged release. ROADMAP.md's Phase 6 "Release/Verify/Retro" entries are obsolete unless the user revives them.
<!-- GSD:project-end --> <!-- GSD:project-end -->
@@ -24,7 +24,7 @@ Everything else (new engines, fancy features) is downstream of "the thing instal
The May-2026 stack research that used to live here served five capabilities that have all since shipped (HF-token Settings panel, prefilled-URL bug reporting, uv mirror fallback for restricted networks, the Supertonic-3 engine, in-repo Markdown docs). Follow the patterns in the code itself; the durable *don'ts* that research established: The May-2026 stack research that used to live here served five capabilities that have all since shipped (HF-token Settings panel, prefilled-URL bug reporting, uv mirror fallback for restricted networks, the Supertonic-3 engine, in-repo Markdown docs). Follow the patterns in the code itself; the durable *don'ts* that research established:
- **No third-party endpoints for bug reporting or crash dumps** (`sentry-tauri` was evaluated and rejected) — bug reporting stays opt-in via prefilled GitHub-issue URLs, submitted from the user's own browser. The one sanctioned third-party endpoint is the opt-in PostHog EU product analytics (owner-set 2026-07-16), which is consent-gated behind the first-run prompt, ships allowlisted content-free metadata only, and must never grow exception/DOM autocapture. - **No third-party endpoints for bug reporting or crash dumps** (`sentry-tauri` was evaluated and rejected) — bug reporting stays opt-in via prefilled GitHub-issue URLs, submitted from the user's own browser. The one sanctioned third-party endpoint is the opt-in PostHog EU product analytics (owner-set 2026-07-16), which is consent-gated behind the first-run prompt, ships allowlisted content-free metadata only, and must never grow exception/DOM autocapture. Its publishable write-only project token is committed in-repo (owner reversal 2026-07-20, #1193 — source builds get the same consent-gated analytics as installers; env/baked token overrides), allowed by `tests/test_no_committed_analytics_token.py` in exactly `backend/core/analytics.py` + `frontend/src/utils/analytics.ts`.
- **No PAT/token-based GitHub posting from the app** — the user submits from their own browser. - **No PAT/token-based GitHub posting from the app** — the user submits from their own browser.
- **Don't recommend `setx` for env vars on Windows** (silent truncation, no current-shell propagation) — use the in-app Settings panel or PowerShell `[Environment]::SetEnvironmentVariable`. - **Don't recommend `setx` for env vars on Windows** (silent truncation, no current-shell propagation) — use the in-app Settings panel or PowerShell `[Environment]::SetEnvironmentVariable`.
- **Don't adopt Material for MkDocs** for any future docs site (maintenance mode since Nov 2025) — Astro Starlight is the precedent if docs ever outgrow the repo. - **Don't adopt Material for MkDocs** for any future docs site (maintenance mode since Nov 2025) — Astro Starlight is the precedent if docs ever outgrow the repo.
+1 -1
View File
@@ -586,7 +586,7 @@ Yes. Subclass <code>TTSBackend</code> in <code>backend/services/tts_backend.py</
<br/> <br/>
<b>Not unless you explicitly say yes.</b> On first run the app <i>asks</i> — one screen, two equal-weight buttons, no pre-ticked box — and until you answer yes, OmniVoice sends nothing: no analytics, no telemetry, no accounts, no phone-home. Skipping the question means no. Your text, audio, voices, and projects never leave your machine either way. <b>Not unless you explicitly say yes.</b> On first run the app <i>asks</i> — one screen, two equal-weight buttons, no pre-ticked box — and until you answer yes, OmniVoice sends nothing: no analytics, no telemetry, no accounts, no phone-home. Skipping the question means no. Your text, audio, voices, and projects never leave your machine either way.
If you do opt in (also togglable anytime under <b>Settings → Privacy → "Help improve OmniVoice"</b>), what's sent is anonymous, content-free usage stats: generations (engine, language, generation time, character <i>count</i>, error <i>type</i>), plus app lifecycle — an install ping, updates (version-to-version), crashes (error class and a <i>bucketed</i> uptime, never logs), error <i>types</i> (capped, deduplicated), and a single uninstall ping if you remove it. Never your text, audio, file names, or anything identifying — enforced in code by a property allowlist (<code>backend/core/analytics.py</code>), not just a promise. Source builds have no analytics destination at all, so they never even ask. Your own numbers live in <b>Settings → Usage</b>, computed locally, sent nowhere. If you do opt in (also togglable anytime under <b>Settings → Privacy → "Help improve OmniVoice"</b>), what's sent is anonymous, content-free usage stats: generations (engine, language, generation time, character <i>count</i>, error <i>type</i>), plus app lifecycle — an install ping, updates (version-to-version), crashes (error class and a <i>bucketed</i> uptime, never logs), error <i>types</i> (capped, deduplicated), and a single uninstall ping if you remove it. Never your text, audio, file names, or anything identifying — enforced in code by a property allowlist (<code>backend/core/analytics.py</code>), not just a promise. Every build — installer, Docker, or built from source — asks the same first-run question and stays off unless you say yes (the destination is PostHog's publishable write-only client key; skipping the question means off). Your own numbers live in <b>Settings → Usage</b>, computed locally, sent nowhere.
</details> </details>
<details> <details>
+3 -2
View File
@@ -1020,8 +1020,9 @@ def get_analytics():
return { return {
"enabled": analytics.enabled(), "enabled": analytics.enabled(),
"opted_in": analytics.user_opted_in(), "opted_in": analytics.user_opted_in(),
# False for source builds / any build with no token: analytics can never # True for source builds too since #1193 (in-repo default token; env/baked
# run, so the UI can say so instead of offering a toggle that does nothing. # overrides). False only for a destination-less build, where the UI can
# say so instead of offering a toggle that does nothing.
"available": analytics.token_configured(), "available": analytics.token_configured(),
# Whether the user has ever been explicitly asked (first-run consent step # Whether the user has ever been explicitly asked (first-run consent step
# or the one-time banner). The UI uses this to ask exactly once — it never # or the one-time banner). The UI uses this to ask exactly once — it never
+60 -13
View File
@@ -4,10 +4,12 @@ OmniVoice is local-first, so analytics here is held to a higher bar than the
usual SDK drop-in. Three rules, each enforced in code below and pinned by tests: usual SDK drop-in. Three rules, each enforced in code below and pinned by tests:
1. **Off unless the user says yes.** Two independent gates must BOTH be true: 1. **Off unless the user says yes.** Two independent gates must BOTH be true:
a build-provided ``POSTHOG_PROJECT_TOKEN`` *and* the user's explicit a configured destination token (the in-repo publishable default, overridden
``analytics_enabled`` preference, which defaults to **False**. A default by ``POSTHOG_PROJECT_TOKEN`` when set see ``_PUBLIC_PROJECT_TOKEN``) *and*
install transmits nothing, so the product's promise holds out of the box. the user's explicit ``analytics_enabled`` preference, which defaults to
``OMNIVOICE_ANALYTICS_DISABLED=1`` is a hard kill switch that outranks both. **False**. A default install transmits nothing, so the product's promise
holds out of the box. ``OMNIVOICE_ANALYTICS_DISABLED=1`` is a hard kill
switch that outranks both.
2. **No exception autocapture, ever.** The obvious SDK default 2. **No exception autocapture, ever.** The obvious SDK default
(``enable_exception_autocapture=True``) ships raw tracebacks which carry (``enable_exception_autocapture=True``) ships raw tracebacks which carry
@@ -42,6 +44,19 @@ _client_key: Optional[str] = None # the (token, host) the live client was built
_KILL_SWITCH = "OMNIVOICE_ANALYTICS_DISABLED" _KILL_SWITCH = "OMNIVOICE_ANALYTICS_DISABLED"
_OFF_VALUES = {"1", "true", "yes", "on"} _OFF_VALUES = {"1", "true", "yes", "on"}
#: In-repo default analytics destination (owner-sanctioned reversal, #1193):
#: source builds get the SAME consent-gated analytics as installers. This is a
#: PostHog *publishable* client key — write-only event ingestion, no data
#: access; PostHog's own FAQ says these are designed to ship in client code —
#: NOT a secret. It only names a destination: not one event leaves the machine
#: without the user's explicit opt-in (see `enabled()`).
#: A `POSTHOG_PROJECT_TOKEN` env var (release builds bake one in via the
#: desktop shell; developers can point at their own project) always wins.
#: Committed-token guard: tests/test_no_committed_analytics_token.py allows a
#: `phc_` literal in exactly this file and frontend/src/utils/analytics.ts.
_PUBLIC_PROJECT_TOKEN = "phc_v5wMjnYMPMaEcRNLRKQsTYCzPaYWh7wcHPhXNkNajVf9"
_DEFAULT_HOST = "https://eu.i.posthog.com"
#: The ONLY property keys that may leave this machine. Anything else is dropped. #: The ONLY property keys that may leave this machine. Anything else is dropped.
#: Deliberately conservative: no free text, no paths, no names, no ids of user #: Deliberately conservative: no free text, no paths, no names, no ids of user
#: content. Add here only after asking "could this ever hold something the user #: content. Add here only after asking "could this ever hold something the user
@@ -70,6 +85,7 @@ _ALLOWED_PROPS: frozenset[str] = frozenset({
"uptime_bucket", # app_crashed: BUCKETED prior-run uptime, never raw seconds "uptime_bucket", # app_crashed: BUCKETED prior-run uptime, never raw seconds
"error_class", # error_occurred/app_crashed: locked taxonomy key (GPU_OOM, …) "error_class", # error_occurred/app_crashed: locked taxonomy key (GPU_OOM, …)
"stage", # error_occurred: coarse pipeline stage / route head only "stage", # error_occurred: coarse pipeline stage / route head only
"install_channel", # installer | docker | source — closed set, never a path
}) })
#: A string property longer than this is refused outright — a belt-and-braces #: A string property longer than this is refused outright — a belt-and-braces
@@ -130,11 +146,23 @@ def user_prompted() -> bool:
return False return False
def _resolved_token() -> str:
"""The destination token: env (baked builds / developer override) wins,
the committed publishable default (#1193) is the fallback. Empty only when
both are blank a destination-less build can never run analytics."""
return (os.environ.get("POSTHOG_PROJECT_TOKEN", "") or "").strip() or _PUBLIC_PROJECT_TOKEN
def _resolved_host() -> str:
return (os.environ.get("POSTHOG_HOST") or _DEFAULT_HOST).strip()
def token_configured() -> bool: def token_configured() -> bool:
"""Whether this BUILD ships an analytics destination at all. When false, """Whether this build has an analytics destination at all. Since #1193 the
analytics can never run no matter what the user chooses which is the case in-repo default means source builds have one too so they get the same
for anyone building from source.""" first-run consent ask as installers. False only when both the env var and
return bool((os.environ.get("POSTHOG_PROJECT_TOKEN", "") or "").strip()) the committed default are blank; consent stays the real gate regardless."""
return bool(_resolved_token())
def enabled() -> bool: def enabled() -> bool:
@@ -152,8 +180,8 @@ def _get_client():
shutdown() shutdown()
return None return None
token = os.environ["POSTHOG_PROJECT_TOKEN"].strip() token = _resolved_token()
host = (os.environ.get("POSTHOG_HOST") or "https://eu.i.posthog.com").strip() host = _resolved_host()
key = f"{token}@{host}" key = f"{token}@{host}"
if _client is not None and _client_key == key: if _client is not None and _client_key == key:
return _client return _client
@@ -274,8 +302,27 @@ def _platform() -> str:
return {"darwin": "macos"}.get(_pl.system().lower(), _pl.system().lower() or "unknown") return {"darwin": "macos"}.get(_pl.system().lower(), _pl.system().lower() or "unknown")
def install_channel() -> str:
"""How this backend was distributed — a closed set, never derived from
paths or hostnames. "installer": the desktop shell sets
``OMNIVOICE_INSTALL_CHANNEL=installer`` (backend.rs analytics_env()).
"docker": the image sets ``OMNIVOICE_SERVER_MODE=1`` (see
api/dependencies.py the pre-existing Docker marker). Else "source"."""
ch = (os.environ.get("OMNIVOICE_INSTALL_CHANNEL", "") or "").strip().lower()
if ch in {"installer", "docker", "source"}:
return ch
# _OFF_VALUES doubles as the repo's canonical truthy-string set.
if (os.environ.get("OMNIVOICE_SERVER_MODE", "") or "").strip().lower() in _OFF_VALUES:
return "docker"
return "source"
def _common_props() -> dict: def _common_props() -> dict:
return {"app_version": _app_version(), "platform": _platform()} return {
"app_version": _app_version(),
"platform": _platform(),
"install_channel": install_channel(),
}
def uptime_bucket(seconds: Optional[float]) -> str: def uptime_bucket(seconds: Optional[float]) -> str:
@@ -414,8 +461,8 @@ def sync_uninstall_ping_info() -> None:
pass pass
return return
payload = { payload = {
"token": os.environ["POSTHOG_PROJECT_TOKEN"].strip(), "token": _resolved_token(),
"host": (os.environ.get("POSTHOG_HOST") or "https://eu.i.posthog.com").strip(), "host": _resolved_host(),
"distinct_id": installation_id(), "distinct_id": installation_id(),
"app_version": _app_version(), "app_version": _app_version(),
"platform": _platform(), "platform": _platform(),
+46 -25
View File
@@ -281,27 +281,33 @@ fn spawn_failure_diagnostic(python: &Path, err: &std::io::Error) -> String {
// ── Spawn the backend via the bootstrapped venv Python ──────────────────── // ── Spawn the backend via the bootstrapped venv Python ────────────────────
/// Env the spawned backend needs in order to have an analytics destination at all. /// Analytics env for the spawned backend: destination override + install channel.
/// ///
/// `core/analytics.py` reads `POSTHOG_PROJECT_TOKEN` from its own environment at /// `core/analytics.py` ships an in-repo publishable default token (#1193), and
/// RUNTIME — but the backend runs on the *user's* machine, where nothing sets it. /// reads `POSTHOG_PROJECT_TOKEN` from its environment as the OVERRIDE. release.yml
/// Without this, `token_configured()` is false forever and every backend event is /// passes the `POSTHOG_PROJECT_TOKEN` secret to the tauri-action step as
/// dead code in every shipped build, no matter what secret CI holds. /// `VITE_POSTHOG_KEY`, and that step compiles this binary as well as the frontend
/// /// bundle — so `option_env!` bakes it in on the builds that ship it, and we hand
/// The token is really a *build* input. release.yml passes the /// it to the child process here so a baked release token wins over the in-repo
/// `POSTHOG_PROJECT_TOKEN` secret to the tauri-action step as `VITE_POSTHOG_KEY`, /// default.
/// and that step compiles this binary as well as the frontend bundle — so
/// `option_env!` bakes it in on exactly the builds that ship it, and we hand it to
/// the child process here.
/// ///
/// Two properties this preserves, both load-bearing: /// Two properties this preserves, both load-bearing:
/// * **No token baked in (every source build) => nothing is passed** => the /// * **Since #1193 there is always a destination**: `core/analytics.py` now
/// backend has no destination and analytics can never run. Correct default. /// carries the in-repo publishable default token, so even when nothing is
/// * **A real process env var wins**, so a developer can point a local run at /// baked in here (a source-built shell) the backend can run its
/// their own PostHog project without recompiling. /// consent-gated analytics. What this function adds on top is *override*
/// precedence — a baked release token replaces the in-repo default.
/// * **A real process env var wins over both**, so a developer can point a
/// local run at their own PostHog project without recompiling.
/// ///
/// This only supplies a *destination*. Consent is a separate gate the backend /// It also stamps `OMNIVOICE_INSTALL_CHANNEL=installer` (#1193): anyone running
/// checks in prefs (default off) — a token alone never causes a single event. /// through this desktop shell is on the "installer" channel — the backend's
/// `install_channel()` reads it (docker is detected via its own marker; bare
/// `uvicorn` runs report "source").
///
/// This only supplies a *destination* and a channel label. Consent is a separate
/// gate the backend checks in prefs (default off) — a token alone never causes a
/// single event.
fn analytics_env(baked_token: Option<&str>, baked_host: Option<&str>) -> Vec<(String, String)> { fn analytics_env(baked_token: Option<&str>, baked_host: Option<&str>) -> Vec<(String, String)> {
let mut out = Vec::new(); let mut out = Vec::new();
let mut pass = |name: &str, baked: Option<&str>| { let mut pass = |name: &str, baked: Option<&str>| {
@@ -314,6 +320,7 @@ fn analytics_env(baked_token: Option<&str>, baked_host: Option<&str>) -> Vec<(St
}; };
pass("POSTHOG_PROJECT_TOKEN", baked_token); pass("POSTHOG_PROJECT_TOKEN", baked_token);
pass("POSTHOG_HOST", baked_host); pass("POSTHOG_HOST", baked_host);
pass("OMNIVOICE_INSTALL_CHANNEL", Some("installer"));
out out
} }
@@ -500,8 +507,10 @@ mod tests {
// #1123 shipped backend analytics that could never run: core/analytics.py reads // #1123 shipped backend analytics that could never run: core/analytics.py reads
// POSTHOG_PROJECT_TOKEN from the runtime environment, and nothing on the user's // POSTHOG_PROJECT_TOKEN from the runtime environment, and nothing on the user's
// machine ever set it. These pin the wiring that fixes it — and, just as // machine ever set it. These pin the wiring that fixes it. Since #1193 the
// importantly, pin that a build with no token stays silent. // backend also carries an in-repo default token, so what's pinned here is the
// OVERRIDE precedence (baked > in-repo default, process env > baked) plus the
// "installer" channel marker this shell stamps on the child.
/// The env-var tests below mutate process-global state; keep them off each /// The env-var tests below mutate process-global state; keep them off each
/// other's toes (cargo runs tests in threads by default). /// other's toes (cargo runs tests in threads by default).
@@ -512,35 +521,47 @@ mod tests {
let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
std::env::remove_var("POSTHOG_PROJECT_TOKEN"); std::env::remove_var("POSTHOG_PROJECT_TOKEN");
std::env::remove_var("POSTHOG_HOST"); std::env::remove_var("POSTHOG_HOST");
std::env::remove_var("OMNIVOICE_INSTALL_CHANNEL");
let env = analytics_env(Some("phc_baked"), Some("https://eu.i.posthog.com")); let env = analytics_env(Some("phc_baked"), Some("https://eu.i.posthog.com"));
// Without this the backend has no destination and every event is dropped. // The baked release token must reach the child, where it overrides the
// backend's in-repo default destination (#1193).
assert!(env.contains(&("POSTHOG_PROJECT_TOKEN".into(), "phc_baked".into()))); assert!(env.contains(&("POSTHOG_PROJECT_TOKEN".into(), "phc_baked".into())));
assert!(env assert!(env
.contains(&("POSTHOG_HOST".into(), "https://eu.i.posthog.com".into()))); .contains(&("POSTHOG_HOST".into(), "https://eu.i.posthog.com".into())));
} }
#[test] #[test]
fn a_source_build_passes_no_destination_at_all() { fn a_source_build_passes_no_destination_but_still_marks_the_channel() {
let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
std::env::remove_var("POSTHOG_PROJECT_TOKEN"); std::env::remove_var("POSTHOG_PROJECT_TOKEN");
std::env::remove_var("POSTHOG_HOST"); std::env::remove_var("POSTHOG_HOST");
std::env::remove_var("OMNIVOICE_INSTALL_CHANNEL");
// No secret at compile time (anyone building from source), and the empty // No secret at compile time (anyone building the shell from source), and
// string CI hands over when the secret is simply absent. // the empty string CI hands over when the secret is simply absent: no
assert!(analytics_env(None, None).is_empty()); // POSTHOG_* is passed (the backend falls back to its in-repo default,
assert!(analytics_env(Some(""), Some(" ")).is_empty()); // #1193) — but running under this shell is still the "installer" channel.
for env in [analytics_env(None, None), analytics_env(Some(""), Some(" "))] {
assert_eq!(
env,
vec![("OMNIVOICE_INSTALL_CHANNEL".to_string(), "installer".to_string())]
);
}
} }
#[test] #[test]
fn the_process_environment_beats_the_baked_token() { fn the_process_environment_beats_the_baked_token() {
let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
std::env::set_var("POSTHOG_PROJECT_TOKEN", "phc_developers_own_project"); std::env::set_var("POSTHOG_PROJECT_TOKEN", "phc_developers_own_project");
std::env::set_var("OMNIVOICE_INSTALL_CHANNEL", "source");
let env = analytics_env(Some("phc_baked"), None); let env = analytics_env(Some("phc_baked"), None);
// Don't override what the caller deliberately set — the child inherits it. // Don't override what the caller deliberately set — the child inherits it.
assert!(env.iter().all(|(k, _)| k != "POSTHOG_PROJECT_TOKEN")); assert!(env.iter().all(|(k, _)| k != "POSTHOG_PROJECT_TOKEN"));
assert!(env.iter().all(|(k, _)| k != "OMNIVOICE_INSTALL_CHANNEL"));
std::env::remove_var("POSTHOG_PROJECT_TOKEN"); std::env::remove_var("POSTHOG_PROJECT_TOKEN");
std::env::remove_var("OMNIVOICE_INSTALL_CHANNEL");
} }
#[test] #[test]
+2 -2
View File
@@ -1374,8 +1374,8 @@ function App() {
<BackendCrashNotice /> <BackendCrashNotice />
{/* One-time analytics consent ask for installs that predate the {/* One-time analytics consent ask for installs that predate the
first-run consent step. Renders nothing once any choice was made first-run consent step. Renders nothing once any choice was made.
(or in source builds, which have no analytics destination). */} Source builds get it too since #1193 (in-repo default token). */}
<AnalyticsConsentBanner /> <AnalyticsConsentBanner />
{/* #567's visible half: while the shell auto-restarts a dead backend {/* #567's visible half: while the shell auto-restarts a dead backend
@@ -12,8 +12,9 @@
* - Dismiss (X) treated as No (analytics stays/goes off, `prompted` set); * - Dismiss (X) treated as No (analytics stays/goes off, `prompted` set);
* never shown again. Dismissal is a choice, not a snooze nagging users * never shown again. Dismissal is a choice, not a snooze nagging users
* into consent would be its own kind of dark pattern. * into consent would be its own kind of dark pattern.
* - Backend unreachable / source build (no token) / already prompted / * - Backend unreachable / destination-less build / already prompted /
* already opted in renders nothing. * already opted in renders nothing. (Source builds have a destination
* since #1193, so they get this same one-time ask.)
*/ */
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
@@ -10,8 +10,9 @@
* voice names, and any identity), * voice names, and any identity),
* - and that it can be turned off again at any time. * - and that it can be turned off again at any time.
* *
* When the build ships no analytics destination (any source build), the toggle * When the build ships no analytics destination (rare since #1193 the in-repo
* is not offered at all an inert switch would be a lie. See * default token covers source builds too), the toggle is not offered at all
* an inert switch would be a lie. See
* backend/core/analytics.py for the enforcement (allowlist + no exception * backend/core/analytics.py for the enforcement (allowlist + no exception
* autocapture) that makes the promises above true rather than aspirational. * autocapture) that makes the promises above true rather than aspirational.
*/ */
+3 -2
View File
@@ -228,8 +228,9 @@ export default function SetupWizard({ onReady }) {
// Whether to insert the analytics consent step. Resolved once at mount // Whether to insert the analytics consent step. Resolved once at mount
// (the user is on step 0 when this lands, so indices never shift underfoot): // (the user is on step 0 when this lands, so indices never shift underfoot):
// only when the build CAN send (token baked in) and the user was never // only when the build CAN send and the user was never asked. Since #1193
// asked. Source builds have no destination asking would be dishonest. // every build has a destination (in-repo default token), so source builds
// get this same ask; skipping the wizard still means analytics stays off.
const [askConsent, setAskConsent] = useState(false); const [askConsent, setAskConsent] = useState(false);
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
+16 -9
View File
@@ -30,15 +30,20 @@
import type { PostHog } from 'posthog-js'; import type { PostHog } from 'posthog-js';
/** /**
* Supplied at BUILD time, not committed. PostHog client tokens are publishable * In-repo default destination (owner-sanctioned reversal, #1193): source builds
* (write-only event ingestion), but a token-shaped literal in the repo trips the * get the SAME consent-gated analytics as installers. This is a PostHog
* secret scanner and is a bad habit regardless so the release build injects it, * *publishable* client key write-only event ingestion, no data access;
* exactly as the backend takes POSTHOG_PROJECT_TOKEN from the environment. * PostHog's own FAQ says these are designed to ship in client code NOT a
* * secret. It only names a destination: nothing is ever sent without the user's
* No token => no destination => the Privacy toggle isn't even offered and nothing * explicit opt-in (init happens only after consent, see enableAnalytics()).
* can ever be sent. That is the correct default for a source build. * A build-time VITE_POSTHOG_KEY (release builds; developers pointing at their
* own project) always wins over it mirrors backend/core/analytics.py.
* tests/test_no_committed_analytics_token.py pins that a `phc_` literal may
* live in exactly this file and backend/core/analytics.py.
*/ */
const POSTHOG_TOKEN: string = (import.meta.env?.VITE_POSTHOG_KEY as string) || ''; const PUBLIC_PROJECT_TOKEN = 'phc_v5wMjnYMPMaEcRNLRKQsTYCzPaYWh7wcHPhXNkNajVf9';
const POSTHOG_TOKEN: string =
(import.meta.env?.VITE_POSTHOG_KEY as string) || PUBLIC_PROJECT_TOKEN;
const POSTHOG_HOST: string = const POSTHOG_HOST: string =
(import.meta.env?.VITE_POSTHOG_HOST as string) || 'https://eu.i.posthog.com'; (import.meta.env?.VITE_POSTHOG_HOST as string) || 'https://eu.i.posthog.com';
@@ -73,6 +78,7 @@ const ALLOWED_PROPS = new Set([
'uptime_bucket', 'uptime_bucket',
'error_class', 'error_class',
'stage', 'stage',
'install_channel', // installer | docker | source — closed set, never a path
]); ]);
/** A string longer than this is refused outright, so free text can't ride in on /** A string longer than this is refused outright, so free text can't ride in on
@@ -151,7 +157,8 @@ export function capture(event: string, props?: Record<string, unknown>): void {
} }
/** On app start: turn analytics on ONLY if the backend says the user opted in. /** On app start: turn analytics on ONLY if the backend says the user opted in.
* Anything else backend down, no consent, source build leaves it off. */ * Anything else backend down, no consent, destination-less build leaves it
* off. */
export async function initAnalyticsFromConsent( export async function initAnalyticsFromConsent(
fetchState: () => Promise<{ opted_in?: boolean; available?: boolean }>, fetchState: () => Promise<{ opted_in?: boolean; available?: boolean }>,
): Promise<boolean> { ): Promise<boolean> {
+24 -5
View File
@@ -38,6 +38,8 @@ def sent(monkeypatch, tmp_path):
monkeypatch.setattr(config, "DATA_DIR", str(tmp_path)) monkeypatch.setattr(config, "DATA_DIR", str(tmp_path))
monkeypatch.delenv("POSTHOG_PROJECT_TOKEN", raising=False) monkeypatch.delenv("POSTHOG_PROJECT_TOKEN", raising=False)
monkeypatch.delenv("OMNIVOICE_ANALYTICS_DISABLED", raising=False) monkeypatch.delenv("OMNIVOICE_ANALYTICS_DISABLED", raising=False)
monkeypatch.delenv("OMNIVOICE_INSTALL_CHANNEL", raising=False)
monkeypatch.delenv("OMNIVOICE_SERVER_MODE", raising=False)
analytics.shutdown() analytics.shutdown()
analytics._reset_error_events_for_tests() analytics._reset_error_events_for_tests()
@@ -88,7 +90,8 @@ def test_installed_fires_once_and_only_once(sent, monkeypatch):
analytics.record_startup_lifecycle(None) analytics.record_startup_lifecycle(None)
assert _names(sent).count("app_installed") == 1 assert _names(sent).count("app_installed") == 1
props = dict(sent[0][1]) props = dict(sent[0][1])
assert set(props) <= {"app_version", "platform"} assert set(props) <= {"app_version", "platform", "install_channel"}
assert props["install_channel"] == "source" # no shell/docker marker in tests
def test_installed_never_fires_without_consent_but_is_not_swallowed(sent, monkeypatch): def test_installed_never_fires_without_consent_but_is_not_swallowed(sent, monkeypatch):
@@ -105,8 +108,10 @@ def test_installed_never_fires_without_consent_but_is_not_swallowed(sent, monkey
assert _names(sent).count("app_installed") == 1 assert _names(sent).count("app_installed") == 1
def test_installed_never_fires_without_a_token(sent, monkeypatch): def test_installed_never_fires_without_any_token(sent, monkeypatch):
analytics.set_opted_in(True) # source build: consent but no destination # No destination at all: env absent AND the in-repo default (#1193) blanked.
monkeypatch.setattr(analytics, "_PUBLIC_PROJECT_TOKEN", "")
analytics.set_opted_in(True) # consent but no destination
analytics.record_startup_lifecycle(None) analytics.record_startup_lifecycle(None)
assert sent == [] assert sent == []
@@ -334,6 +339,20 @@ def test_uninstall_ping_info_written_when_enabled_and_removed_when_not(
assert not info.exists() assert not info.exists()
def test_uninstall_ping_info_never_written_for_a_source_build(sent, monkeypatch, tmp_path): def test_uninstall_ping_info_written_for_a_source_build_with_the_default_token(
analytics.set_opted_in(True) # no token sent, monkeypatch, tmp_path
):
"""#1193: a consented source build has a destination (the in-repo default),
so the uninstall ping must work there too."""
analytics.set_opted_in(True) # no env token → in-repo default
info = tmp_path / analytics.UNINSTALL_PING_INFO_BASENAME
assert info.exists()
payload = json.loads(info.read_text())
assert payload["token"] == analytics._PUBLIC_PROJECT_TOKEN
assert payload["host"] == "https://eu.i.posthog.com"
def test_uninstall_ping_info_never_written_without_any_token(sent, monkeypatch, tmp_path):
monkeypatch.setattr(analytics, "_PUBLIC_PROJECT_TOKEN", "") # destination-less build
analytics.set_opted_in(True)
assert not (tmp_path / analytics.UNINSTALL_PING_INFO_BASENAME).exists() assert not (tmp_path / analytics.UNINSTALL_PING_INFO_BASENAME).exists()
+48 -2
View File
@@ -40,6 +40,8 @@ def _isolate(monkeypatch, tmp_path):
monkeypatch.setattr(config, "DATA_DIR", str(tmp_path)) monkeypatch.setattr(config, "DATA_DIR", str(tmp_path))
monkeypatch.delenv("POSTHOG_PROJECT_TOKEN", raising=False) monkeypatch.delenv("POSTHOG_PROJECT_TOKEN", raising=False)
monkeypatch.delenv("OMNIVOICE_ANALYTICS_DISABLED", raising=False) monkeypatch.delenv("OMNIVOICE_ANALYTICS_DISABLED", raising=False)
monkeypatch.delenv("OMNIVOICE_INSTALL_CHANNEL", raising=False)
monkeypatch.delenv("OMNIVOICE_SERVER_MODE", raising=False)
class _InertPosthog: class _InertPosthog:
def __init__(self, *a, **k): def __init__(self, *a, **k):
@@ -70,8 +72,29 @@ def test_off_by_default_even_when_the_build_ships_a_token(monkeypatch):
assert analytics.enabled() is False assert analytics.enabled() is False
def test_opting_in_without_a_token_still_cannot_transmit(monkeypatch): def test_source_builds_have_a_destination_via_the_in_repo_default(monkeypatch):
"""A source build has no destination — the toggle must not pretend otherwise.""" """#1193: with no env/baked token, the committed publishable key is the
fallback so source builds get the SAME consent flow as installers. Consent
is still the gate: available enabled."""
assert analytics.token_configured() is True # no env token set by _isolate
assert analytics.enabled() is False # …but silence is still not consent
analytics.set_opted_in(True)
assert analytics.enabled() is True
def test_the_env_token_beats_the_in_repo_default(monkeypatch):
"""Release builds bake a token through the shell env; developers set their
own. Either must override the committed default."""
monkeypatch.setenv("POSTHOG_PROJECT_TOKEN", " phc_env_wins ")
assert analytics._resolved_token() == "phc_env_wins"
monkeypatch.delenv("POSTHOG_PROJECT_TOKEN")
assert analytics._resolved_token() == analytics._PUBLIC_PROJECT_TOKEN
def test_opting_in_without_any_token_still_cannot_transmit(monkeypatch):
"""A build with no destination at all (env absent AND in-repo default
blanked) the toggle must not pretend otherwise."""
monkeypatch.setattr(analytics, "_PUBLIC_PROJECT_TOKEN", "")
analytics.set_opted_in(True) analytics.set_opted_in(True)
assert analytics.user_opted_in() is True assert analytics.user_opted_in() is True
assert analytics.token_configured() is False assert analytics.token_configured() is False
@@ -281,3 +304,26 @@ def test_installation_id_is_random_not_derived_from_the_machine():
assert socket.gethostname() not in iid assert socket.gethostname() not in iid
assert os.environ.get("USER", "nope") not in iid assert os.environ.get("USER", "nope") not in iid
# ── install_channel: closed set, driven by env markers (#1193) ───────────────
def test_install_channel_resolves_installer_docker_then_source(monkeypatch):
assert analytics.install_channel() == "source" # bare source run
monkeypatch.setenv("OMNIVOICE_SERVER_MODE", "1") # the Docker image's marker
assert analytics.install_channel() == "docker"
monkeypatch.setenv("OMNIVOICE_INSTALL_CHANNEL", "installer") # desktop shell marker
assert analytics.install_channel() == "installer"
# A value outside the closed set falls through to the other markers.
monkeypatch.setenv("OMNIVOICE_INSTALL_CHANNEL", "franken-build")
monkeypatch.delenv("OMNIVOICE_SERVER_MODE")
assert analytics.install_channel() == "source"
def test_install_channel_rides_wherever_app_version_does(monkeypatch):
"""Attached via _common_props (the same place app_version is), and
allowlisted so the sanitizer doesn't strip it."""
props = analytics._common_props()
assert props["install_channel"] == "source"
assert analytics.sanitize_properties(props)["install_channel"] == "source"
+64 -33
View File
@@ -1,14 +1,20 @@
"""No analytics token may be committed to the repo. """The analytics token lives in exactly two canonical files — nowhere else.
gitleaks caught a hardcoded PostHog key in frontend/src/utils/analytics.ts once. History: gitleaks once caught a hardcoded PostHog key in analytics.ts, and the
PostHog client tokens are *publishable* (write-only event ingestion, not data repo banned token-shaped literals outright (build-time injection only). Owner
access), so it was never a credential leak but a token-shaped literal in the reversal #1193: source builds now ship the same consent-gated analytics as
source is a bad habit, and this project already bans them. The destination is installers, so the *publishable* project token (write-only event ingestion, not
supplied at BUILD time instead (VITE_POSTHOG_KEY / POSTHOG_PROJECT_TOKEN). data access PostHog's client keys are designed to ship in client code) is
committed as an in-repo default in the two files that implement analytics:
This guard makes the rule ours rather than relying on the scanner to catch it, backend/core/analytics.py (_PUBLIC_PROJECT_TOKEN)
and it fails BEFORE a push rather than after. Same file-scanning idiom as frontend/src/utils/analytics.ts (PUBLIC_PROJECT_TOKEN)
test_no_hardcoded_cjk / test_no_literal_borders.
This guard pins the new contract: a `phc_` literal may exist ONLY there, both
files must actually carry one, and the two must be the SAME token (one PostHog
project a drifted pair would silently split the event stream). The build-time
override chain (env / baked secret beats the in-repo default) stays pinned
below. Same file-scanning idiom as test_no_hardcoded_cjk / test_no_literal_borders.
""" """
from __future__ import annotations from __future__ import annotations
@@ -19,9 +25,15 @@ from pathlib import Path
_REPO = Path(__file__).resolve().parents[1] _REPO = Path(__file__).resolve().parents[1]
# A PostHog project key. Deliberately matched by SHAPE, not by the specific value, # A PostHog project key. Deliberately matched by SHAPE, not by the specific value,
# so a *different* key can't slip through. # so a key can't slip into a *different* file unnoticed.
_POSTHOG_KEY_RE = re.compile(r"phc_[A-Za-z0-9]{20,}") _POSTHOG_KEY_RE = re.compile(r"phc_[A-Za-z0-9]{20,}")
# The ONLY tracked files allowed to contain a `phc_` literal (#1193).
_CANONICAL_TOKEN_FILES = (
"backend/core/analytics.py",
"frontend/src/utils/analytics.ts",
)
_SKIP_DIRS = {"node_modules", ".git", "target", "dist", "build", ".venv", "zig-out"} _SKIP_DIRS = {"node_modules", ".git", "target", "dist", "build", ".venv", "zig-out"}
_SCAN_EXT = {".ts", ".tsx", ".js", ".jsx", ".py", ".rs", ".json", ".yml", ".yaml", ".md", ".env"} _SCAN_EXT = {".ts", ".tsx", ".js", ".jsx", ".py", ".rs", ".json", ".yml", ".yaml", ".md", ".env"}
@@ -38,12 +50,14 @@ def _tracked_files():
yield name, _REPO / p yield name, _REPO / p
def test_no_posthog_token_is_committed(): def test_posthog_token_literals_only_in_the_two_canonical_files():
offenders = [] offenders = []
for name, path in _tracked_files(): for name, path in _tracked_files():
# This guard describes the pattern it forbids, so exempt itself. # This guard describes the pattern it forbids, so exempt itself.
if name == "tests/test_no_committed_analytics_token.py": if name == "tests/test_no_committed_analytics_token.py":
continue continue
if name in _CANONICAL_TOKEN_FILES:
continue
try: try:
text = path.read_text(encoding="utf-8", errors="ignore") text = path.read_text(encoding="utf-8", errors="ignore")
except OSError: except OSError:
@@ -52,43 +66,60 @@ def test_no_posthog_token_is_committed():
offenders.append(name) offenders.append(name)
assert not offenders, ( assert not offenders, (
"A PostHog token literal is committed in: " "A PostHog token literal is committed outside the canonical files ("
+ ", ".join(_CANONICAL_TOKEN_FILES)
+ "): "
+ ", ".join(offenders) + ", ".join(offenders)
+ ". Supply it at build time instead (VITE_POSTHOG_KEY for the frontend, " + ". The in-repo default lives ONLY there (#1193); everything else takes "
"POSTHOG_PROJECT_TOKEN for the backend) — see frontend/src/utils/analytics.ts." "the token via VITE_POSTHOG_KEY / POSTHOG_PROJECT_TOKEN at build/run time."
) )
def test_the_frontend_reads_its_token_from_the_build_env(): def test_both_canonical_files_carry_the_same_default_token():
"""The mechanism that replaces the literal must stay in place.""" """#1193's whole point: source builds have a destination. Both halves must
ship the in-repo default, and it must be ONE token a mismatched pair would
split installs across PostHog projects with no error anywhere."""
tokens = {}
for name in _CANONICAL_TOKEN_FILES:
found = _POSTHOG_KEY_RE.findall((_REPO / name).read_text(encoding="utf-8"))
assert found, f"{name} no longer carries the in-repo default token (#1193)"
assert len(set(found)) == 1, f"{name} contains multiple distinct phc_ literals"
tokens[name] = found[0]
assert len(set(tokens.values())) == 1, f"canonical token files disagree: {tokens}"
# ── the OVERRIDE chain must stay wired ───────────────────────────────────────
#
# The in-repo default is the fallback; release builds override it with the repo
# secret, and a developer's process env overrides everything:
#
# repo secret -> release.yml -> tauri-action -> option_env! in backend.rs
# -> spawned backend process env -> analytics._resolved_token()
#
# Every link is invisible when it breaks (events just land in the default
# project instead of the release one). These pin the links that live in files a
# future change could quietly drop.
def test_the_frontend_reads_its_token_override_from_the_build_env():
"""The build-time override mechanism must stay in place."""
src = (_REPO / "frontend/src/utils/analytics.ts").read_text(encoding="utf-8") src = (_REPO / "frontend/src/utils/analytics.ts").read_text(encoding="utf-8")
assert "VITE_POSTHOG_KEY" in src assert "VITE_POSTHOG_KEY" in src
# ── the token has to actually REACH both halves ──────────────────────────────
#
# The backend reads POSTHOG_PROJECT_TOKEN from its own environment at runtime,
# on the user's machine, where nothing sets it. So the chain
#
# repo secret -> release.yml -> tauri-action -> option_env! in backend.rs
# -> spawned backend process env -> analytics.token_configured()
#
# has to hold end to end, and every link is invisible when it breaks: analytics
# just silently never fires. These pin the two links that live in files a future
# change could quietly drop.
def test_release_workflow_still_passes_the_secret_to_the_build(): def test_release_workflow_still_passes_the_secret_to_the_build():
wf = (_REPO / ".github/workflows/release.yml").read_text(encoding="utf-8") wf = (_REPO / ".github/workflows/release.yml").read_text(encoding="utf-8")
assert "VITE_POSTHOG_KEY" in wf, "the build no longer receives the analytics token" assert "VITE_POSTHOG_KEY" in wf, "the build no longer receives the analytics token override"
assert "secrets.POSTHOG_PROJECT_TOKEN" in wf, "the token must come from the repo secret" assert "secrets.POSTHOG_PROJECT_TOKEN" in wf, "the override must come from the repo secret"
def test_the_shell_hands_the_token_to_the_backend_it_spawns(): def test_the_shell_hands_the_token_to_the_backend_it_spawns():
"""Without this the backend's analytics is dead code in every shipped build.""" """Without this a baked release token could never beat the in-repo default."""
src = (_REPO / "frontend/src-tauri/src/backend.rs").read_text(encoding="utf-8") src = (_REPO / "frontend/src-tauri/src/backend.rs").read_text(encoding="utf-8")
assert 'option_env!("VITE_POSTHOG_KEY")' in src, "the shell no longer bakes in the token" assert 'option_env!("VITE_POSTHOG_KEY")' in src, "the shell no longer bakes in the token"
assert "POSTHOG_PROJECT_TOKEN" in src, "the backend process is no longer given a destination" assert "POSTHOG_PROJECT_TOKEN" in src, "the backend process is no longer given the override"
# #1193: the shell marks everything it spawns as the "installer" channel.
assert "OMNIVOICE_INSTALL_CHANNEL" in src, "the shell no longer stamps the install channel"
# option_env! is resolved at COMPILE time, so cargo must rebuild when the # option_env! is resolved at COMPILE time, so cargo must rebuild when the
# secret changes — otherwise a cached build keeps the token it first saw. # secret changes — otherwise a cached build keeps the token it first saw.