diff --git a/layers/base/plugins/posthog-production-gate.client.ts b/layers/base/plugins/posthog-production-gate.client.ts new file mode 100644 index 00000000..d138e993 --- /dev/null +++ b/layers/base/plugins/posthog-production-gate.client.ts @@ -0,0 +1,12 @@ +// Runs after nuxt-posthog's plugin, which creates $clientPosthog. Opting out here +// silences pageview, autocapture, pageleave and recording on every non-production host. +export default defineNuxtPlugin({ + name: 'posthog-production-gate', + dependsOn: ['posthog'], + setup() { + const { $clientPosthog } = useNuxtApp() + if (!$clientPosthog) return + const production = productionHostname(useRuntimeConfig().public.siteUrl) + if (!isAnalyticsHost(window.location.hostname, production)) $clientPosthog.opt_out_capturing() + }, +}) diff --git a/layers/base/utils/analyticsHost.ts b/layers/base/utils/analyticsHost.ts new file mode 100644 index 00000000..f2f4322c --- /dev/null +++ b/layers/base/utils/analyticsHost.ts @@ -0,0 +1,14 @@ +/** Production hostname. Undefined off production: only the production build sets siteUrl. */ +export function productionHostname(siteUrl: string | undefined): string | undefined { + if (!siteUrl) return undefined + try { + return new URL(siteUrl).hostname + } catch { + return undefined + } +} + +/** PostHog captures only on the production host; localhost and previews stay silent. */ +export function isAnalyticsHost(hostname: string, productionHost: string | undefined): boolean { + return !!productionHost && hostname === productionHost +} diff --git a/layers/consent/composables/useCookieConsent.ts b/layers/consent/composables/useCookieConsent.ts index b8941dd3..d44b2ea6 100644 --- a/layers/consent/composables/useCookieConsent.ts +++ b/layers/consent/composables/useCookieConsent.ts @@ -1,4 +1,4 @@ -import { computed, useNuxtApp, useState } from '#imports' +import { computed, useNuxtApp, useRuntimeConfig, useState } from '#imports' import type { ConsentState } from '../types' import { consentCookieString, readConsentCookie } from '../utils/consentCookie' import { analyticsAllowed, createConsentController, isBannerVisible } from '../utils/consentController' @@ -25,6 +25,9 @@ export function useCookieConsent() { document.cookie = consentCookieString(next, location.protocol === 'https:') }, applyAnalytics: (allowed) => applyPostHog(nuxtApp.$clientPosthog, allowed), + // Client only: the SSR render has no location, and never reaches applyAnalytics. + analyticsHost: import.meta.client + && isAnalyticsHost(location.hostname, productionHostname(useRuntimeConfig().public.siteUrl)), }) return { diff --git a/layers/consent/utils/consentController.ts b/layers/consent/utils/consentController.ts index 6094415f..b76cc3df 100644 --- a/layers/consent/utils/consentController.ts +++ b/layers/consent/utils/consentController.ts @@ -11,15 +11,19 @@ export interface ConsentPorts { setBannerOpen: (open: boolean) => void persist: (state: ConsentState) => void applyAnalytics: (allowed: boolean) => void + /** False off the production host: a stored opt-in must never switch capture on there. */ + analyticsHost: boolean } export function createConsentController(ports: ConsentPorts) { + const applyAnalytics = (allowed: boolean) => ports.applyAnalytics(allowed && ports.analyticsHost) + // A decision always closes the banner, including one reopened from the footer. function decide(analytics: boolean) { const state: ConsentState = { analytics, decidedAt: ports.now().toISOString() } ports.setState(state) ports.persist(state) - ports.applyAnalytics(analytics) + applyAnalytics(analytics) ports.setBannerOpen(false) } @@ -32,7 +36,7 @@ export function createConsentController(ports: ConsentPorts) { restore(stored: ConsentState | null) { if (!stored) return ports.setState(stored) - ports.applyAnalytics(stored.analytics) + applyAnalytics(stored.analytics) }, } } diff --git a/tests/analytics/analytics-host.spec.ts b/tests/analytics/analytics-host.spec.ts new file mode 100644 index 00000000..69bf5897 --- /dev/null +++ b/tests/analytics/analytics-host.spec.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from 'vitest' +import { isAnalyticsHost, productionHostname } from '../../layers/base/utils/analyticsHost' + +const PRODUCTION = 'onelitefeather.net' + +describe('analytics host gate', () => { + it('captures on the production host', () => { + expect(isAnalyticsHost('onelitefeather.net', PRODUCTION)).toBe(true) + }) + + it('stays silent on localhost', () => { + expect(isAnalyticsHost('localhost', PRODUCTION)).toBe(false) + }) + + it('stays silent on 127.0.0.1', () => { + expect(isAnalyticsHost('127.0.0.1', PRODUCTION)).toBe(false) + }) + + it('stays silent on a host with a port, such as the production preview on 8787', () => { + expect(isAnalyticsHost('localhost:8787', PRODUCTION)).toBe(false) + }) + + it('stays silent on a workers.dev preview alias', () => { + expect(isAnalyticsHost('launchpad.example.workers.dev', PRODUCTION)).toBe(false) + }) + + it('stays silent when the hostname is empty', () => { + expect(isAnalyticsHost('', PRODUCTION)).toBe(false) + }) + + it('stays silent when no production host is configured, as in dev and preview builds', () => { + expect(isAnalyticsHost('onelitefeather.net', undefined)).toBe(false) + }) + + it('stays silent when the configured production host is empty', () => { + expect(isAnalyticsHost('', '')).toBe(false) + }) + + it('reads the production hostname from the site URL', () => { + expect(productionHostname('https://onelitefeather.net')).toBe('onelitefeather.net') + }) + + it('has no production hostname when no site URL is configured', () => { + expect(productionHostname(undefined)).toBeUndefined() + }) + + it('has no production hostname when the site URL is not a URL', () => { + expect(productionHostname('onelitefeather.net')).toBeUndefined() + }) +}) diff --git a/tests/consent/controller.spec.ts b/tests/consent/controller.spec.ts index 98252c9a..a0a97398 100644 --- a/tests/consent/controller.spec.ts +++ b/tests/consent/controller.spec.ts @@ -6,8 +6,11 @@ const NOW = new Date('2026-10-10T12:00:00.000Z') const NOW_ISO = NOW.toISOString() const STORED_OFF: ConsentState = { analytics: false, decidedAt: '2026-01-01T00:00:00.000Z' } -/** A fresh controller with recording ports; every test builds its own. */ -function fixture(initial: ConsentState | null = null) { +/** + * A fresh controller with recording ports; every test builds its own. The + * production host is the default, so off-production tests say so explicitly. + */ +function fixture(initial: ConsentState | null = null, { analyticsHost = true } = {}) { const persisted: ConsentState[] = [] const analytics: boolean[] = [] const world = { state: initial, bannerOpen: false } @@ -17,6 +20,7 @@ function fixture(initial: ConsentState | null = null) { setBannerOpen: (open) => { world.bannerOpen = open }, persist: (state) => { persisted.push(state) }, applyAnalytics: (allowed) => { analytics.push(allowed) }, + analyticsHost, }) const bannerVisible = () => isBannerVisible(world.state !== null, world.bannerOpen) return { controller, world, persisted, analytics, bannerVisible } @@ -113,3 +117,24 @@ describe('consent controller', () => { expect(bannerVisible()).toBe(false) }) }) + +describe('consent off the production host', () => { + it('never opts PostHog in when all cookies are accepted', () => { + const { controller, analytics } = fixture(null, { analyticsHost: false }) + controller.acceptAll() + expect(analytics).toEqual([false]) + }) + + it('never opts PostHog in from a choice stored by an earlier visit', () => { + const { controller, analytics } = fixture(null, { analyticsHost: false }) + controller.restore({ analytics: true, decidedAt: '2026-01-01T00:00:00.000Z' }) + expect(analytics).toEqual([false]) + }) + + it('still stores the decision there, so the banner closes', () => { + const { controller, persisted, bannerVisible } = fixture(null, { analyticsHost: false }) + controller.acceptAll() + expect(persisted).toEqual([{ analytics: true, decidedAt: NOW_ISO }]) + expect(bannerVisible()).toBe(false) + }) +})