From c3c9f0d69a0c83266844f1321e1975db9cddab33 Mon Sep 17 00:00:00 2001 From: Max Pengilly <182439403+MP2EZ@users.noreply.github.com> Date: Sat, 30 May 2026 14:02:00 -0700 Subject: [PATCH] feat(analytics): GPC-gated PostHog with pageviews + waitlist events (#42) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(analytics): GPC-gated PostHog with pageviews + waitlist events Adds PostHog product analytics to being.fyi as the first analytics ever on the site. Output of a 5-step agent review sequence (compliance → product → architect → product priority → compliance) — see plan file. Compliance gave conditional sign-off pending three items (all addressed: upstream PR, Notion disclosure, /privacy/multi-state link). # Scope (Tier B-trimmed) Three events only: - $pageview (PostHog default) - waitlist_signup_submitted { source } - waitlist_signup_failed { source, reason } No autocapture. No session replay. No heatmaps. No surveys. No email hash identify (deferred to PR #2). EU data residency (Frankfurt). # GPC structural kill AnalyticsGate (client component, useSyncExternalStore) returns null when ANY of: - being_gpc=1 cookie set by middleware on Sec-GPC: 1 - navigator.globalPrivacyControl === true (browsers exposing the JS API without the HTTP header, e.g. older Brave) - NEXT_PUBLIC_POSTHOG_KEY missing (local dev) - SSR (getServerSnapshot=true, safe default) Null → PosthogProvider never mounts → dynamic import('posthog-js') never fires → posthog-js chunk never fetched → no init, no ph_* cookie, no beacon. Structurally enforced; not "suppressed after load." Diverged from architect's server-component design because cookies() in next/headers would force the parent layout into dynamic rendering, undoing force-static on legal pages and re-introducing the per-request marked() parse that PR #33 fixed. Client-side gate preserves both force-static AND the no-load guarantee. Confirmed by compliance re-review. # CI structural-regression gate tests/analytics-gpc-gate.test.tsx test #6 is a grep gate: if any file under app/, components/, lib/ statically imports posthog-js (outside PosthogProvider.tsx's dynamic import), the build fails. This is the regression vector that would bypass the gate; locking it in CI. # CSP additions - script-src += eu-assets.i.posthog.com - connect-src += eu.i.posthog.com eu-assets.i.posthog.com - img-src += eu.i.posthog.com (PostHog pixel fallback) - worker-src 'self' blob: (PostHog web worker for batching) # /cookies page rewrite - Removed "No PostHog" denial (was false the moment this PR ships) - Headline: "Two Functional Cookies + Privacy-Respecting Analytics" - New ph_* cookie disclosure with EU residency, kill conditions, scope - "What We Don't Use" updated: no GA/Mixpanel/Amplitude/Segment, no autocapture, no session replay (explicit even for PostHog) - GPC section: structurally enforced kill described accurately # Hard dependency This PR depends on mp2ez/being#106 landing first (privacy policy §5.2 split into in-app vs web subsections + Notion added to §5.1 service providers). CI sync pulls from being@development; until #106 merges, the website ships analytics with a stale policy = FTC §5 issue. # Out of scope (PR #2+) - cta_clicked, crisis_resource_clicked, ab_variant_assigned events - Email-hash identify (server-side SHA-256 + identifyByEmailHash helper) - PostHog feature flags / experiments (we have being_ab_variant) - Session replay / heatmaps / surveys (categorically off) Co-Authored-By: Claude Opus 4.7 (1M context) * feat(analytics): register surface: 'web' super-property Tags every PostHog event from the marketing site with surface: 'web' so the website's data stays distinguishable from the being-app's data in the shared PostHog project (free tier = 1 project per account; sharing is the right call pre-launch and a deliberate design choice). The app mirrors this with posthog.register({ surface: 'app' }) in a parallel change to ~/dev/being's PostHogProvider. Co-Authored-By: Claude Opus 4.7 (1M context) --------- Co-authored-by: Claude Opus 4.7 (1M context) --- .github/workflows/deploy.yml | 4 + app/(main)/cookies/page.tsx | 57 ++- app/(main)/download/page.tsx | 2 +- app/(main)/layout.tsx | 4 + app/(standalone)/layout.tsx | 9 +- app/(standalone)/page.tsx | 2 +- components/analytics/AnalyticsGate.tsx | 64 ++++ components/analytics/PosthogProvider.tsx | 51 +++ components/shared/WaitlistSignupForm.tsx | 22 +- lib/posthog/config.ts | 20 ++ lib/posthog/events.ts | 49 +++ next.config.ts | 11 +- package-lock.json | 431 +++++++++++++++++++++++ package.json | 1 + tests/analytics-gpc-gate.test.tsx | 149 ++++++++ 15 files changed, 851 insertions(+), 25 deletions(-) create mode 100644 components/analytics/AnalyticsGate.tsx create mode 100644 components/analytics/PosthogProvider.tsx create mode 100644 lib/posthog/config.ts create mode 100644 lib/posthog/events.ts create mode 100644 tests/analytics-gpc-gate.test.tsx diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 2540605..01e5c32 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -54,6 +54,10 @@ jobs: # an emergency-fallback option but the runtime redirect carries # / → /home. /download serves as the pre-launch waitlist page. NEXT_PUBLIC_SHOW_FULL_SITE: 'true' + # PostHog product analytics (EU cloud). When this secret is missing + # or empty, AnalyticsGate renders nothing — graceful degradation. + # GPC kill happens client-side regardless. See lib/posthog/config.ts. + NEXT_PUBLIC_POSTHOG_KEY: ${{ secrets.POSTHOG_KEY }} - name: Deploy to Cloudflare Workers uses: cloudflare/wrangler-action@v4 diff --git a/app/(main)/cookies/page.tsx b/app/(main)/cookies/page.tsx index bea2864..7b08207 100644 --- a/app/(main)/cookies/page.tsx +++ b/app/(main)/cookies/page.tsx @@ -20,7 +20,7 @@ export default function CookiesPage() { Cookie Policy

- Last Updated: May 25, 2026 + Last Updated: May 30, 2026

@@ -28,16 +28,18 @@ export default function CookiesPage() { {/* Summary */}

- First-Party Functional Cookies Only + Two Functional Cookies + Privacy-Respecting Analytics

Being’s website (www.being.fyi) uses two small first-party - cookies for site functionality. We don’t use third-party advertising or analytics - cookies, tracking pixels, or cross-site trackers. + cookies for site functionality, plus PostHog product analytics (EU cloud) scoped + tightly to pageviews and waitlist signup events. No advertising trackers, no + session replay, no autocapture, no cross-site profiling.

- We honor the Global Privacy Control (Sec-GPC) signal automatically — no - banner, no preference center. + We honor the Global Privacy Control (Sec-GPC) signal automatically — when + your browser sends it, PostHog does not load, no analytics cookie is set, and no + event is transmitted. No banner, no preference center.

@@ -61,13 +63,26 @@ export default function CookiesPage() {

being_gpc

-
    +
    • Purpose: Caches the Global Privacy Control signal so the page can display an acknowledgement.
    • Type: First-party functional.
    • Value: 1 when your browser sends Sec-GPC: 1; otherwise the cookie is cleared.
    • Lifetime: 24 hours; automatically cleared on any subsequent request that doesn’t carry the header.
    • Shared with third parties: No.
    + +

    + ph_* (PostHog) +

    +
      +
    • Purpose: Anonymous product analytics — pageviews and waitlist signup events only. Helps us understand which marketing pages drive waitlist signups.
    • +
    • Type: Third-party analytics (PostHog Inc., EU data residency — Frankfurt).
    • +
    • Value: A random distinct identifier (no email, no name, no PII).
    • +
    • Lifetime: 365 days (PostHog default).
    • +
    • Shared with third parties: Yes, with PostHog. No further onward sharing or sale.
    • +
    • Not set when GPC is detected. If your browser sends Sec-GPC: 1 or exposes navigator.globalPrivacyControl === true, PostHog does not load and no ph_* cookie is set. See Global Privacy Control section below.
    • +
    • What we do NOT capture: No autocapture (no recording of all clicks/forms), no session replay, no heatmaps, no raw email or other PII. See our Privacy Policy §5.2 for the full web-analytics scope.
    • +
    {/* What We Don't Use */} @@ -82,7 +97,7 @@ export default function CookiesPage() {
  • - No Analytics Cookies: No Google Analytics, PostHog, Mixpanel, or similar. + No Google Analytics, Mixpanel, Amplitude, or Segment. We use PostHog (disclosed above) for pageviews + waitlist conversion events only — no other analytics vendors.
  • @@ -90,11 +105,15 @@ export default function CookiesPage() {
  • - No Third-Party Cookies: No external services set cookies on our pages. + No Cross-Site Tracking Cookies: No advertising network cookies that follow you across the web. +
  • +
  • + + No Fingerprinting or Session Replay: No FullStory, LogRocket, Hotjar, or similar. PostHog session replay is explicitly disabled in our configuration.
  • - No Fingerprinting or Session Replay: No FullStory, LogRocket, Hotjar, or similar. + No Autocapture: PostHog supports auto-recording all clicks, form fields, and DOM interactions — we have this turned off. Only the named events (pageviews + waitlist signup success/failure) are sent.
@@ -110,10 +129,13 @@ export default function CookiesPage() { personal information under CCPA, TDPSA, CPA, and CTDPA.

- In practice, because we don’t use third-party analytics or advertising trackers, - there is no sale or sharing to opt out of — but the signal is recorded and acknowledged - with a visible notice on our privacy pages, and an X-GPC-Honored: 1 response - header confirms detection to anyone inspecting the request. + When the signal is present, PostHog does not load — no script is fetched, no + ph_* cookie is set, and no event is sent. The detection is structurally + enforced by our AnalyticsGate component, which checks both the + being_gpc cookie (set by middleware when Sec-GPC: 1 is + received) and the browser’s navigator.globalPrivacyControl JS API. + An X-GPC-Honored: 1 response header confirms detection to anyone + inspecting the request.

See the{' '} @@ -194,10 +216,13 @@ export default function CookiesPage() { Your Choices

- You can clear either cookie at any time via your browser’s site-data settings. + You can clear any of the cookies at any time via your browser’s site-data settings. Clearing being_ab_variant just reassigns a variant on your next visit; clearing being_gpc is harmless because the cookie reflects the header, - not a stored preference. + not a stored preference; clearing ph_* opts you out of any PostHog + session continuity (you’ll appear as a new anonymous visitor next time). + Enabling Global Privacy Control in your browser is the simplest way to suppress + PostHog entirely.

For questions about server logs, mobile app data storage, or anything else, see our{' '} diff --git a/app/(main)/download/page.tsx b/app/(main)/download/page.tsx index 2f62885..bd7fd4d 100644 --- a/app/(main)/download/page.tsx +++ b/app/(main)/download/page.tsx @@ -32,7 +32,7 @@ export default function DownloadPage() { Get early access + a free month trial when we launch. We’ll email you the moment Being is in the App Store and Google Play.

- + diff --git a/app/(main)/layout.tsx b/app/(main)/layout.tsx index 8c5027c..266387e 100644 --- a/app/(main)/layout.tsx +++ b/app/(main)/layout.tsx @@ -7,6 +7,7 @@ import DesktopNav from "@/components/navigation/DesktopNav"; import MobileHeader from "@/components/navigation/MobileHeader"; import MobileBottomTabs from "@/components/navigation/MobileBottomTabs"; import Footer from "@/components/navigation/Footer"; +import { AnalyticsGate } from "@/components/analytics/AnalyticsGate"; export default function MainLayout({ children, @@ -39,6 +40,9 @@ export default function MainLayout({ {/* Mobile Bottom Tabs (hidden on desktop) */} + + {/* PostHog analytics — gated by GPC / config / SSR-safe-default */} + ); } diff --git a/app/(standalone)/layout.tsx b/app/(standalone)/layout.tsx index 6d65e36..de5f378 100644 --- a/app/(standalone)/layout.tsx +++ b/app/(standalone)/layout.tsx @@ -9,6 +9,7 @@ */ import { redirect } from 'next/navigation'; +import { AnalyticsGate } from '@/components/analytics/AnalyticsGate'; export const metadata = { title: 'Being - Launching Soon', @@ -23,5 +24,11 @@ export default function StandaloneLayout({ if (process.env.NEXT_PUBLIC_SHOW_FULL_SITE === 'true') { redirect('/home'); } - return <>{children}; + return ( + <> + {children} + {/* PostHog analytics — gated by GPC / config / SSR-safe-default */} + + + ); } diff --git a/app/(standalone)/page.tsx b/app/(standalone)/page.tsx index 921fae2..5595766 100644 --- a/app/(standalone)/page.tsx +++ b/app/(standalone)/page.tsx @@ -39,7 +39,7 @@ export default function ComingSoon() { {/* Email Capture Form */} - + {/* Trust Signals */}
diff --git a/components/analytics/AnalyticsGate.tsx b/components/analytics/AnalyticsGate.tsx new file mode 100644 index 0000000..2b20a45 --- /dev/null +++ b/components/analytics/AnalyticsGate.tsx @@ -0,0 +1,64 @@ +'use client'; + +/** + * AnalyticsGate — the structural GPC kill for PostHog. + * + * Returns null (no PosthogProvider mounted, no posthog-js dynamic import + * triggered, no script loaded, no cookie set, no beacon sent) when ANY + * of these is true: + * - `being_gpc=1` cookie present (set by middleware when Sec-GPC: 1) + * - `navigator.globalPrivacyControl === true` (JS API; some browsers + * expose this without sending the HTTP header, e.g. older Brave) + * - PostHog key is not configured (local dev, env unset) + * - Component hasn't mounted yet (SSR default = safe = null) + * + * Why client-side, not server-side: + * - A server-component gate would force its parent layout into dynamic + * rendering (via `cookies()` from `next/headers`), which would undo + * the `force-static` rendering for legal pages and re-introduce the + * per-request marked() parsing that PR #33 fixed. + * - Client-side gate keeps pages prerendered AND preserves the + * "PostHog doesn't load on GPC" guarantee: the dynamic import inside + * PosthogProvider only fires when this gate renders the provider. + * If the gate returns null, posthog-js is never fetched, init never + * runs, no cookie is set, no beacon is sent. + * + * Compliance: see being repo PR #106 (§5.2 update) — this gate is the + * implementation of the "Global Privacy Control (GPC) hard kill" + * commitment in the web-side analytics disclosure. + */ + +import { useSyncExternalStore } from 'react'; +import { PosthogProvider } from './PosthogProvider'; +import { isPosthogConfigured } from '@/lib/posthog/config'; + +function subscribe(): () => void { + return () => {}; +} + +function getClientSnapshot(): boolean { + // Returns TRUE when analytics should be suppressed. + if (typeof document !== 'undefined') { + if (document.cookie.split(';').some((c) => c.trim() === 'being_gpc=1')) { + return true; + } + } + if (typeof navigator !== 'undefined') { + const gpc = (navigator as Navigator & { globalPrivacyControl?: boolean }).globalPrivacyControl; + if (gpc === true) return true; + } + return false; +} + +function getServerSnapshot(): boolean { + return true; // SSR default = suppress = safe +} + +export function AnalyticsGate() { + const suppressed = useSyncExternalStore(subscribe, getClientSnapshot, getServerSnapshot); + + if (suppressed) return null; + if (!isPosthogConfigured()) return null; + + return ; +} diff --git a/components/analytics/PosthogProvider.tsx b/components/analytics/PosthogProvider.tsx new file mode 100644 index 0000000..5f7f3e6 --- /dev/null +++ b/components/analytics/PosthogProvider.tsx @@ -0,0 +1,51 @@ +'use client'; + +/** + * PostHog initializer — dynamic-imports posthog-js and runs init() once. + * + * This component is rendered ONLY by AnalyticsGate, which has already + * verified that GPC is not set and the PostHog key is configured. + * Mounting this component is what kicks off the dynamic import + init. + * + * Init options chosen to match privacy-policy.md §5.2 web-side commitments: + * - api_host: EU cloud only (no US fallback) + * - autocapture: false (we capture only the named events) + * - disable_session_recording: true (categorically off per /cookies) + * - capture_pageview: true (the one default we DO want) + * - persistence: cookie (per /cookies disclosure) + * - opt_out_capturing_by_default: false (opt-out posture per product agent + * + scoped to non-GPC visitors per §5.2) + * + * @see lib/posthog/events.ts for the event-emit helpers + */ + +import { useEffect } from 'react'; +import { POSTHOG_KEY, POSTHOG_HOST } from '@/lib/posthog/config'; + +export function PosthogProvider() { + useEffect(() => { + const key = POSTHOG_KEY; + if (!key) return; + if (window.__posthog) return; // already initialized in this isolate + + void import('posthog-js').then(({ default: posthog }) => { + posthog.init(key, { + api_host: POSTHOG_HOST, + autocapture: false, + disable_session_recording: true, + capture_pageview: true, + persistence: 'cookie', + opt_out_capturing_by_default: false, + loaded: (ph) => { + // Tag every event with the surface so the website's data stays + // distinguishable from the being-app's data in the shared PostHog + // project. The app mirrors this with posthog.register({ surface: 'app' }). + ph.register({ surface: 'web' }); + window.__posthog = ph as unknown as NonNullable; + }, + }); + }); + }, []); + + return null; +} diff --git a/components/shared/WaitlistSignupForm.tsx b/components/shared/WaitlistSignupForm.tsx index 875ab3a..bbc3b04 100644 --- a/components/shared/WaitlistSignupForm.tsx +++ b/components/shared/WaitlistSignupForm.tsx @@ -4,17 +4,30 @@ * Waitlist signup form. * * Used on: - * - / (splash) when NEXT_PUBLIC_SHOW_FULL_SITE !== 'true' - * - /download (pre-launch state until the app is in the stores) + * - / (splash) when NEXT_PUBLIC_SHOW_FULL_SITE !== 'true' (source='splash') + * - /download (pre-launch state until the app is in the stores) (source='download') * * POSTs to the existing /api/waitlist endpoint (Notion-backed). * Self-contained: idle / submitting / success / error states are * managed internally. + * + * Fires PostHog events on success/failure (no-op when PostHog isn't loaded + * — e.g., GPC kill, missing key). See lib/posthog/events.ts. */ import { useState, FormEvent } from 'react'; +import { + trackWaitlistSubmitted, + trackWaitlistFailed, + type WaitlistSource, +} from '@/lib/posthog/events'; + +interface WaitlistSignupFormProps { + /** Where on the site this form instance lives — flows through to PostHog events. */ + source: WaitlistSource; +} -export function WaitlistSignupForm() { +export function WaitlistSignupForm({ source }: WaitlistSignupFormProps) { const [email, setEmail] = useState(''); const [status, setStatus] = useState<'idle' | 'submitting' | 'success' | 'error'>('idle'); const [errorMessage, setErrorMessage] = useState(''); @@ -32,12 +45,15 @@ export function WaitlistSignupForm() { }); if (!response.ok) { + trackWaitlistFailed({ source, reason: `http_${response.status}` }); throw new Error('Failed to join waitlist'); } + trackWaitlistSubmitted({ source }); setStatus('success'); setEmail(''); } catch { + trackWaitlistFailed({ source, reason: 'network_error' }); setStatus('error'); setErrorMessage('Something went wrong. Please try again.'); } diff --git a/lib/posthog/config.ts b/lib/posthog/config.ts new file mode 100644 index 0000000..5af639a --- /dev/null +++ b/lib/posthog/config.ts @@ -0,0 +1,20 @@ +/** + * PostHog configuration — single source of truth for analytics setup. + * + * EU data residency is required per privacy-policy.md §5.1/§5.2. + * + * If the key is missing (e.g., local dev), `isPosthogConfigured()` returns + * false and the AnalyticsGate renders nothing → graceful degradation, + * never a hard failure. + * + * @see INFRA-website-posthog-disclosure (being repo PR #106) + */ + +export const POSTHOG_HOST = 'https://eu.i.posthog.com'; +export const POSTHOG_ASSETS_HOST = 'https://eu-assets.i.posthog.com'; + +export const POSTHOG_KEY: string | undefined = process.env.NEXT_PUBLIC_POSTHOG_KEY; + +export function isPosthogConfigured(): boolean { + return typeof POSTHOG_KEY === 'string' && POSTHOG_KEY.length > 0; +} diff --git a/lib/posthog/events.ts b/lib/posthog/events.ts new file mode 100644 index 0000000..b0d1221 --- /dev/null +++ b/lib/posthog/events.ts @@ -0,0 +1,49 @@ +/** + * PostHog event helpers — type-safe wrappers for the PR #1 event set. + * + * Design notes: + * - No `posthog-js` import at module scope — these helpers reach the + * PostHog instance via `window.__posthog`, which is only set after + * PosthogProvider has dynamic-imported the library AND init'd it. + * - If PostHog never loaded (GPC kill, missing key, SSR), every helper + * is a silent no-op. No throws, no console noise. + * - PR #1 event set: $pageview (PostHog default — fires automatically + * on init via capture_pageview: true), trackWaitlistSubmitted, + * trackWaitlistFailed. + * - Future helpers (cta_clicked, crisis_resource_clicked, ab_variant_assigned, + * identifyByEmailHash) ship in PR #2+. + */ + +declare global { + interface Window { + __posthog?: { + capture: (event: string, props?: Record) => void; + }; + } +} + +export type WaitlistSource = 'home' | 'features' | 'download' | 'splash'; + +export interface WaitlistSubmittedProps { + source: WaitlistSource; +} + +export interface WaitlistFailedProps { + source: WaitlistSource; + reason: string; +} + +function capture(event: string, props?: Record): void { + if (typeof window === 'undefined') return; + const ph = window.__posthog; + if (!ph) return; + ph.capture(event, props); +} + +export function trackWaitlistSubmitted(props: WaitlistSubmittedProps): void { + capture('waitlist_signup_submitted', { source: props.source }); +} + +export function trackWaitlistFailed(props: WaitlistFailedProps): void { + capture('waitlist_signup_failed', { source: props.source, reason: props.reason }); +} diff --git a/next.config.ts b/next.config.ts index 2b4fca3..ca35472 100644 --- a/next.config.ts +++ b/next.config.ts @@ -16,12 +16,17 @@ const securityHeaders = [ key: "Content-Security-Policy", value: [ "default-src 'self'", - "script-src 'self' 'unsafe-inline' https://challenges.cloudflare.com", + // PostHog hosts its JS at eu-assets.i.posthog.com (EU region) + "script-src 'self' 'unsafe-inline' https://challenges.cloudflare.com https://eu-assets.i.posthog.com", "style-src 'self' 'unsafe-inline'", - "img-src 'self' data: blob:", + // PostHog uses pixel-style image beacons as a fallback + "img-src 'self' data: blob: https://eu.i.posthog.com", "font-src 'self' data:", - "connect-src 'self' https://api.notion.com https://challenges.cloudflare.com", + // PostHog event ingestion + asset CDN endpoints (EU region only) + "connect-src 'self' https://api.notion.com https://challenges.cloudflare.com https://eu.i.posthog.com https://eu-assets.i.posthog.com", "frame-src https://challenges.cloudflare.com", + // PostHog spawns a web worker for batching even with session replay off + "worker-src 'self' blob:", "base-uri 'self'", "form-action 'self'", ].join("; "), diff --git a/package-lock.json b/package-lock.json index 0cd1b74..71d3cd7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "@mp2ez/being-design-system": "^1.8.0", "marked": "^17.0.1", "next": "^16.0.7", + "posthog-js": "^1.376.4", "raw-loader": "^4.0.2", "react": "^19.2.0", "react-dom": "^19.2.0", @@ -11141,6 +11142,252 @@ "wrangler": "^4.49.1" } }, + "node_modules/@opentelemetry/api": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.1.tgz", + "integrity": "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==", + "license": "Apache-2.0", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@opentelemetry/api-logs": { + "version": "0.208.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.208.0.tgz", + "integrity": "sha512-CjruKY9V6NMssL/T1kAFgzosF1v9o6oeN+aX5JB/C/xPNtmgIJqcXHG7fA82Ou1zCpWGl4lROQUKwUNE1pMCyg==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api": "^1.3.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@opentelemetry/core": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.2.0.tgz", + "integrity": "sha512-FuabnnUm8LflnieVxs6eP7Z383hgQU4W1e3KJS6aOG3RxWxcHyBxH8fDMHNgu/gFx/M2jvTOW/4/PHhLz6bjWw==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/exporter-logs-otlp-http": { + "version": "0.208.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-logs-otlp-http/-/exporter-logs-otlp-http-0.208.0.tgz", + "integrity": "sha512-jOv40Bs9jy9bZVLo/i8FwUiuCvbjWDI+ZW13wimJm4LjnlwJxGgB+N/VWOZUTpM+ah/awXeQqKdNlpLf2EjvYg==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api-logs": "0.208.0", + "@opentelemetry/core": "2.2.0", + "@opentelemetry/otlp-exporter-base": "0.208.0", + "@opentelemetry/otlp-transformer": "0.208.0", + "@opentelemetry/sdk-logs": "0.208.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/otlp-exporter-base": { + "version": "0.208.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/otlp-exporter-base/-/otlp-exporter-base-0.208.0.tgz", + "integrity": "sha512-gMd39gIfVb2OgxldxUtOwGJYSH8P1kVFFlJLuut32L6KgUC4gl1dMhn+YC2mGn0bDOiQYSk/uHOdSjuKp58vvA==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.2.0", + "@opentelemetry/otlp-transformer": "0.208.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/otlp-transformer": { + "version": "0.208.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/otlp-transformer/-/otlp-transformer-0.208.0.tgz", + "integrity": "sha512-DCFPY8C6lAQHUNkzcNT9R+qYExvsk6C5Bto2pbNxgicpcSWbe2WHShLxkOxIdNcBiYPdVHv/e7vH7K6TI+C+fQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api-logs": "0.208.0", + "@opentelemetry/core": "2.2.0", + "@opentelemetry/resources": "2.2.0", + "@opentelemetry/sdk-logs": "0.208.0", + "@opentelemetry/sdk-metrics": "2.2.0", + "@opentelemetry/sdk-trace-base": "2.2.0", + "protobufjs": "^7.3.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/otlp-transformer/node_modules/@opentelemetry/resources": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.2.0.tgz", + "integrity": "sha512-1pNQf/JazQTMA0BiO5NINUzH0cbLbbl7mntLa4aJNmCCXSj0q03T5ZXXL0zw4G55TjdL9Tz32cznGClf+8zr5A==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.2.0", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/resources": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.7.1.tgz", + "integrity": "sha512-DeT6KKolmC4e/dRQvMQ/RwlnzhaqeiFOXY5ngoOPJ07GgVVKxZOg9EcrNZb5aTzUn+iCrJldAgOfQm1O/QfPAQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.7.1", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/resources/node_modules/@opentelemetry/core": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.7.1.tgz", + "integrity": "sha512-QAqIj32AtK6+pEVNG7EOVxHdE06RP+FM5qpiEJ4RtDcFIqKUZHYhl7/7UY5efhwmwNAg7j8QbJVBLxMerc0+gw==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-logs": { + "version": "0.208.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-logs/-/sdk-logs-0.208.0.tgz", + "integrity": "sha512-QlAyL1jRpOeaqx7/leG1vJMp84g0xKP6gJmfELBpnI4O/9xPX+Hu5m1POk9Kl+veNkyth5t19hRlN6tNY1sjbA==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api-logs": "0.208.0", + "@opentelemetry/core": "2.2.0", + "@opentelemetry/resources": "2.2.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.4.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-logs/node_modules/@opentelemetry/resources": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.2.0.tgz", + "integrity": "sha512-1pNQf/JazQTMA0BiO5NINUzH0cbLbbl7mntLa4aJNmCCXSj0q03T5ZXXL0zw4G55TjdL9Tz32cznGClf+8zr5A==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.2.0", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-metrics": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-2.2.0.tgz", + "integrity": "sha512-G5KYP6+VJMZzpGipQw7Giif48h6SGQ2PFKEYCybeXJsOCB4fp8azqMAAzE5lnnHK3ZVwYQrgmFbsUJO/zOnwGw==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.2.0", + "@opentelemetry/resources": "2.2.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.9.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-metrics/node_modules/@opentelemetry/resources": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.2.0.tgz", + "integrity": "sha512-1pNQf/JazQTMA0BiO5NINUzH0cbLbbl7mntLa4aJNmCCXSj0q03T5ZXXL0zw4G55TjdL9Tz32cznGClf+8zr5A==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.2.0", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-trace-base": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.2.0.tgz", + "integrity": "sha512-xWQgL0Bmctsalg6PaXExmzdedSp3gyKV8mQBwK/j9VGdCDu2fmXIb2gAehBKbkXCpJ4HPkgv3QfoJWRT4dHWbw==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.2.0", + "@opentelemetry/resources": "2.2.0", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-trace-base/node_modules/@opentelemetry/resources": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.2.0.tgz", + "integrity": "sha512-1pNQf/JazQTMA0BiO5NINUzH0cbLbbl7mntLa4aJNmCCXSj0q03T5ZXXL0zw4G55TjdL9Tz32cznGClf+8zr5A==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.2.0", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/semantic-conventions": { + "version": "1.41.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.41.1.tgz", + "integrity": "sha512-/UhIkaZgPutTFmQ7RnIJGgDXZmtEJ7Dvi86xNTFWcnRxVRNk/aotsqDJYeEvDP+FSMB2SdW+pQzNMcWP0rwuNA==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, "node_modules/@oxc-project/types": { "version": "0.132.0", "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.132.0.tgz", @@ -11197,6 +11444,84 @@ "license": "MIT", "peer": true }, + "node_modules/@posthog/core": { + "version": "1.29.13", + "resolved": "https://registry.npmjs.org/@posthog/core/-/core-1.29.13.tgz", + "integrity": "sha512-7Me5zaeAue/wmA364Go8ChYbsVAfNAHbtDxXopWu3D6hq9PVScUcauRgjD1njgvP8NzN91SrIllE+pri3XvJVw==", + "license": "MIT", + "dependencies": { + "@posthog/types": "1.376.4" + } + }, + "node_modules/@posthog/types": { + "version": "1.376.4", + "resolved": "https://registry.npmjs.org/@posthog/types/-/types-1.376.4.tgz", + "integrity": "sha512-EoDEvA925lf6yxPpbP4wozlXgu4b9WEqxZlFBUDd4k2akP5R/RWyHpvQT8aYyfY6BtSLn8TnVwxPQOM4b90isA==", + "license": "MIT" + }, + "node_modules/@protobufjs/aspromise": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", + "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/codegen": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.5.tgz", + "integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/eventemitter": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.1.tgz", + "integrity": "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/fetch": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.1.tgz", + "integrity": "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==", + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.1" + } + }, + "node_modules/@protobufjs/float": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", + "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/inquire": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.2.tgz", + "integrity": "sha512-pa0vFRuws4wkvaXKK1uXZMAwAX4/t8ANaJo45iw/oQHNQ9q5xUzwgFmVJGXiga2BeN+zpX7Vf9vmsiIa2J+MUw==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/path": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", + "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/pool": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", + "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/utf8": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.1.tgz", + "integrity": "sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg==", + "license": "BSD-3-Clause" + }, "node_modules/@rolldown/binding-android-arm64": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.2.tgz", @@ -13490,6 +13815,13 @@ "@types/react": "^19.2.0" } }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "license": "MIT", + "optional": true + }, "node_modules/@types/whatwg-mimetype": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/@types/whatwg-mimetype/-/whatwg-mimetype-3.0.2.tgz", @@ -15329,6 +15661,17 @@ "node": ">=6.6.0" } }, + "node_modules/core-js": { + "version": "3.49.0", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.49.0.tgz", + "integrity": "sha512-es1U2+YTtzpwkxVLwAFdSpaIMyQaq0PBgm3YD1W3Qpsn1NAmO3KSgZfu+oGSWVu6NvLHoHCV/aYcsE5wiB7ALg==", + "hasInstallScript": true, + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/core-js" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -15535,6 +15878,15 @@ "license": "MIT", "peer": true }, + "node_modules/dompurify": { + "version": "3.4.7", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.7.tgz", + "integrity": "sha512-2jBxDJY4RR06tQNy4w5FlFH7kfxsQZlufd0sbv+chfHCxeJwrFw2baUDsSwvBISD4K4RDbd0PTfy3uNXsR6siA==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, "node_modules/dotenv": { "version": "16.6.1", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", @@ -16605,6 +16957,12 @@ "reusify": "^1.0.4" } }, + "node_modules/fflate": { + "version": "0.4.8", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.4.8.tgz", + "integrity": "sha512-FJqqoDBR00Mdj9ppamLa/Y7vxm+PRmNWA67N846RvsoYVMKB4q3y/de5PA7gUmRMYK/8CMz2GDZQmCRN1wBcWA==", + "license": "MIT" + }, "node_modules/file-entry-cache": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", @@ -18413,6 +18771,12 @@ "dev": true, "license": "MIT" }, + "node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "license": "Apache-2.0" + }, "node_modules/loose-envify": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", @@ -19766,6 +20130,37 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/posthog-js": { + "version": "1.376.4", + "resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.376.4.tgz", + "integrity": "sha512-SGhZWMBpd9GyV1+Klhx/vRwyy/reRRpJGYc1n1rYG9+LAML/YUEIrfl3Y2CLvuwmhKbPVY5W98Z7pAVQ88Qf1A==", + "license": "SEE LICENSE IN LICENSE", + "dependencies": { + "@opentelemetry/api": "^1.9.0", + "@opentelemetry/api-logs": "^0.208.0", + "@opentelemetry/exporter-logs-otlp-http": "^0.208.0", + "@opentelemetry/resources": "^2.2.0", + "@opentelemetry/sdk-logs": "^0.208.0", + "@posthog/core": "1.29.13", + "@posthog/types": "1.376.4", + "core-js": "^3.38.1", + "dompurify": "^3.3.2", + "fflate": "^0.4.8", + "preact": "^10.28.2", + "query-selector-shadow-dom": "^1.0.1", + "web-vitals": "^5.1.0" + } + }, + "node_modules/preact": { + "version": "10.29.2", + "resolved": "https://registry.npmjs.org/preact/-/preact-10.29.2.tgz", + "integrity": "sha512-7tNmwg/7mzzAoB/8kSg6Hl37JraAZw3Z3A0JSY7VXlZwo82Xn0G7wKbNNs2qoF4ZEEsQGTwDAroNdqKs1ofJxQ==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/preact" + } + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -19826,6 +20221,30 @@ "react-is": "^16.13.1" } }, + "node_modules/protobufjs": { + "version": "7.6.1", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.1.tgz", + "integrity": "sha512-4K0myLaWL5EteuSAro91EGFgcfVgxb64Jx+7oDAY6GOkXD4M69yuSEljNcInGVCA5sOPxmZ/EqDLj2x0Q0+Ygg==", + "hasInstallScript": true, + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.5", + "@protobufjs/eventemitter": "^1.1.1", + "@protobufjs/fetch": "^1.1.1", + "@protobufjs/float": "^1.0.2", + "@protobufjs/inquire": "^1.1.2", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.1", + "@types/node": ">=13.7.0", + "long": "^5.3.2" + }, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", @@ -19865,6 +20284,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/query-selector-shadow-dom": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/query-selector-shadow-dom/-/query-selector-shadow-dom-1.0.1.tgz", + "integrity": "sha512-lT5yCqEBgfoMYpf3F2xQRK7zEr1rhIIZuceDK6+xRkJQ4NMbHTwXqk4NkwDwQMNqXgG9r9fyHnzwNVs6zV5KRw==", + "license": "MIT" + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -22096,6 +22521,12 @@ "node": ">= 14" } }, + "node_modules/web-vitals": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-5.3.0.tgz", + "integrity": "sha512-q6LWsLatGYZp5VGBIOvbTj6JBV2nOmC8KvWztXBmwJcfFAzhwKwbOxhUH306XY3CcaZDUlSmSuNPBsCn0bFu+g==", + "license": "Apache-2.0" + }, "node_modules/webidl-conversions": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", diff --git a/package.json b/package.json index db1594c..3d457fe 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "@mp2ez/being-design-system": "^1.8.0", "marked": "^17.0.1", "next": "^16.0.7", + "posthog-js": "^1.376.4", "raw-loader": "^4.0.2", "react": "^19.2.0", "react-dom": "^19.2.0", diff --git a/tests/analytics-gpc-gate.test.tsx b/tests/analytics-gpc-gate.test.tsx new file mode 100644 index 0000000..3de95a0 --- /dev/null +++ b/tests/analytics-gpc-gate.test.tsx @@ -0,0 +1,149 @@ +// @vitest-environment happy-dom + +/** + * Analytics GPC gate — the compliance-critical test surface. + * + * Failure modes this file MUST catch: + * + * 1. AnalyticsGate forgets to suppress when GPC is set + * → PostHog loads on a user who explicitly opted out → CCPA "sharing" + * violation + FTC §5 deceptive practice (we publicly promise GPC + * kill on /cookies and in privacy-policy §5.2). + * + * 2. AnalyticsGate forgets to no-op when key is missing + * → posthog.init() called with undefined → noisy console errors or + * worse, accidentally captures with no project = data loss + bug. + * + * 3. Someone accidentally adds `import posthog from 'posthog-js'` to a + * component that always renders → posthog-js loads on every page even + * with GPC set. This is the regression vector that defeats the gate. + * + * 4. Event helpers throw when window.__posthog is undefined → breaks pages + * for users whose PostHog didn't load (GPC, missing key, SSR). + */ + +import { execSync } from 'node:child_process'; +import { readFileSync } from 'node:fs'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render } from '@testing-library/react'; +import { trackWaitlistSubmitted, trackWaitlistFailed } from '@/lib/posthog/events'; + +const ORIGINAL_ENV = process.env.NEXT_PUBLIC_POSTHOG_KEY; + +describe('analytics GPC gate', () => { + beforeEach(() => { + document.cookie = ''; + delete (window as unknown as { __posthog?: unknown }).__posthog; + }); + + afterEach(() => { + if (ORIGINAL_ENV === undefined) { + delete process.env.NEXT_PUBLIC_POSTHOG_KEY; + } else { + process.env.NEXT_PUBLIC_POSTHOG_KEY = ORIGINAL_ENV; + } + vi.restoreAllMocks(); + }); + + it('renders null when being_gpc=1 cookie is set', async () => { + process.env.NEXT_PUBLIC_POSTHOG_KEY = 'phc_test_key'; + document.cookie = 'being_gpc=1'; + + vi.resetModules(); + const { AnalyticsGate } = await import('@/components/analytics/AnalyticsGate'); + const { container } = render(); + + expect(container.firstChild).toBeNull(); + }); + + it('renders null when PostHog key is missing', async () => { + delete process.env.NEXT_PUBLIC_POSTHOG_KEY; + + vi.resetModules(); + const { AnalyticsGate } = await import('@/components/analytics/AnalyticsGate'); + const { container } = render(); + + expect(container.firstChild).toBeNull(); + }); + + it('renders null when navigator.globalPrivacyControl === true', async () => { + process.env.NEXT_PUBLIC_POSTHOG_KEY = 'phc_test_key'; + Object.defineProperty(navigator, 'globalPrivacyControl', { + value: true, + configurable: true, + }); + + vi.resetModules(); + const { AnalyticsGate } = await import('@/components/analytics/AnalyticsGate'); + const { container } = render(); + + expect(container.firstChild).toBeNull(); + + Object.defineProperty(navigator, 'globalPrivacyControl', { + value: undefined, + configurable: true, + }); + }); + + it('event helpers are silent no-ops when window.__posthog is undefined', () => { + // No throws, no console noise. + expect(() => trackWaitlistSubmitted({ source: 'home' })).not.toThrow(); + expect(() => trackWaitlistFailed({ source: 'splash', reason: 'http_500' })).not.toThrow(); + }); + + it('event helpers call window.__posthog.capture when PostHog is loaded', () => { + const captureMock = vi.fn(); + (window as unknown as { __posthog: { capture: typeof captureMock } }).__posthog = { + capture: captureMock, + }; + + trackWaitlistSubmitted({ source: 'download' }); + trackWaitlistFailed({ source: 'splash', reason: 'network_error' }); + + expect(captureMock).toHaveBeenCalledTimes(2); + expect(captureMock).toHaveBeenNthCalledWith(1, 'waitlist_signup_submitted', { + source: 'download', + }); + expect(captureMock).toHaveBeenNthCalledWith(2, 'waitlist_signup_failed', { + source: 'splash', + reason: 'network_error', + }); + }); + + /** + * The structural regression gate: posthog-js must only be statically + * imported from `components/analytics/PosthogProvider.tsx`, and even + * there only inside a dynamic `import()` expression. + * + * If anyone adds `import posthog from 'posthog-js'` to an always-rendered + * component, posthog-js ships in the page bundle and loads regardless of + * the GPC gate. This test runs grep to enforce. + */ + it('only PosthogProvider may import posthog-js, and only dynamically', () => { + const matches = execSync( + "grep -rln \"['\\\"]posthog-js['\\\"]\" app components lib --include='*.ts' --include='*.tsx' || true", + { encoding: 'utf-8' }, + ) + .trim() + .split('\n') + .filter((line) => line.length > 0); + + const allowedFile = 'components/analytics/PosthogProvider.tsx'; + const unauthorized = matches.filter((file) => !file.endsWith(allowedFile)); + expect( + unauthorized, + `Only ${allowedFile} may reference posthog-js. Found in: ${unauthorized.join(', ')}`, + ).toEqual([]); + + // And the one allowed reference must be inside a dynamic import(). + const providerSource = readFileSync(allowedFile, 'utf-8'); + expect( + providerSource, + "PosthogProvider.tsx must use dynamic import('posthog-js'), not a static import", + ).toMatch(/import\(['"]posthog-js['"]\)/); + expect( + providerSource, + "PosthogProvider.tsx must NOT use a static `import ... from 'posthog-js'`", + ).not.toMatch(/^import .* from ['"]posthog-js['"]/m); + }); +});