From ade2c42d515ef3427f061b804bf049fb35927d3a Mon Sep 17 00:00:00 2001 From: jjbrunton Date: Sun, 6 Sep 2026 12:45:28 +0100 Subject: [PATCH] fix(web): redact invite analytics URLs --- apps/web/app/PostHogPageView.test.ts | 24 ++++++++++++++++++++++++ apps/web/app/PostHogPageView.tsx | 12 ++++++++---- apps/web/app/providers.tsx | 25 ++++++++++++++++++------- 3 files changed, 50 insertions(+), 11 deletions(-) create mode 100644 apps/web/app/PostHogPageView.test.ts diff --git a/apps/web/app/PostHogPageView.test.ts b/apps/web/app/PostHogPageView.test.ts new file mode 100644 index 0000000..4e866ac --- /dev/null +++ b/apps/web/app/PostHogPageView.test.ts @@ -0,0 +1,24 @@ +import { readFile } from 'node:fs/promises' +import { describe, expect, it } from 'vitest' + +const pageViewPath = new URL('./PostHogPageView.tsx', import.meta.url) +const providerPath = new URL('./providers.tsx', import.meta.url) + +describe('PostHog invite privacy boundary', () => { + it('does not send a global page view for invite routes', async () => { + const source = await readFile(pageViewPath, 'utf8') + + expect(source).toContain("pathname?.startsWith('/join/')") + expect(source.indexOf("pathname?.startsWith('/join/')")).toBeLessThan(source.indexOf("posthog.capture('$pageview'")) + }) + + it('disables automatic page-leave capture and redacts join URLs', async () => { + const source = await readFile(providerPath, 'utf8') + + expect(source).toContain('capture_pageview: false') + expect(source).toContain('capture_pageleave: false') + expect(source).toContain('get_current_url: redactInviteUrl') + expect(source).toContain("url.pathname.startsWith('/join/')") + expect(source).toContain('/join/[invite]') + }) +}) diff --git a/apps/web/app/PostHogPageView.tsx b/apps/web/app/PostHogPageView.tsx index 10bfd8b..19fed20 100644 --- a/apps/web/app/PostHogPageView.tsx +++ b/apps/web/app/PostHogPageView.tsx @@ -7,10 +7,14 @@ import { usePostHog } from 'posthog-js/react' function PostHogPageViewInner() { const pathname = usePathname() const searchParams = useSearchParams() - const posthog = usePostHog() - - useEffect(() => { - if (pathname && posthog) { + const posthog = usePostHog() + + useEffect(() => { + // Invite codes are bearer-like tokens. The join route emits its own + // aggregate-only event, so it must never produce a URL-bearing page view. + if (pathname?.startsWith('/join/')) return + + if (pathname && posthog) { let url = window.origin + pathname if (searchParams.toString()) { url = url + '?' + searchParams.toString() diff --git a/apps/web/app/providers.tsx b/apps/web/app/providers.tsx index c159ad6..52529a2 100644 --- a/apps/web/app/providers.tsx +++ b/apps/web/app/providers.tsx @@ -2,16 +2,27 @@ import posthog from 'posthog-js' import { PostHogProvider as PHProvider } from 'posthog-js/react' -import { useEffect } from 'react' - -export function PostHogProvider({ children }: { children: React.ReactNode }) { +import { useEffect } from 'react' + +function redactInviteUrl(defaultUrl: string): string { + const url = new URL(defaultUrl) + + if (url.pathname.startsWith('/join/')) { + return `${url.origin}/join/[invite]` + } + + return defaultUrl +} + +export function PostHogProvider({ children }: { children: React.ReactNode }) { useEffect(() => { if (typeof window !== 'undefined' && process.env.NEXT_PUBLIC_POSTHOG_KEY) { posthog.init(process.env.NEXT_PUBLIC_POSTHOG_KEY, { - api_host: process.env.NEXT_PUBLIC_POSTHOG_HOST || 'https://eu.i.posthog.com', - person_profiles: 'identified_only', - capture_pageview: false, // We'll capture pageviews manually for better control - capture_pageleave: true, + api_host: process.env.NEXT_PUBLIC_POSTHOG_HOST || 'https://eu.i.posthog.com', + person_profiles: 'identified_only', + capture_pageview: false, + capture_pageleave: false, + get_current_url: redactInviteUrl, }) } }, [])