Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions layers/base/plugins/posthog-production-gate.client.ts
Original file line number Diff line number Diff line change
@@ -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()
},
})
14 changes: 14 additions & 0 deletions layers/base/utils/analyticsHost.ts
Original file line number Diff line number Diff line change
@@ -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
}
5 changes: 4 additions & 1 deletion layers/consent/composables/useCookieConsent.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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 {
Expand Down
8 changes: 6 additions & 2 deletions layers/consent/utils/consentController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

Expand All @@ -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)
},
}
}
Expand Down
50 changes: 50 additions & 0 deletions tests/analytics/analytics-host.spec.ts
Original file line number Diff line number Diff line change
@@ -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()
})
})
29 changes: 27 additions & 2 deletions tests/consent/controller.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand All @@ -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 }
Expand Down Expand Up @@ -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)
})
})
Loading