diff --git a/frontend/lib/posthog-privacy.ts b/frontend/lib/posthog-privacy.ts index 2af4856..1254d76 100644 --- a/frontend/lib/posthog-privacy.ts +++ b/frontend/lib/posthog-privacy.ts @@ -46,11 +46,15 @@ export function sanitizeRoute(input: unknown): string { type EventInput = { event?: unknown; properties?: unknown; + timestamp?: unknown; + uuid?: unknown; }; type SanitizedEvent = { event: "$pageview" | "cta activated" | "$web_vitals"; - properties: Record; + properties: Record; + timestamp?: Date; + uuid?: string; }; function getRawPath(properties: Record): unknown { @@ -65,29 +69,66 @@ function getRawPath(properties: Record): unknown { } function createCommonProperties(properties: Record) { + const token = properties.token; + if ( + typeof token !== "string" || + !PROJECT_TOKEN_PATTERN.test(token) || + properties.$cookieless_mode !== true || + properties.$process_person_profile !== false + ) { + return null; + } + const path = sanitizeRoute(getRawPath(properties)); - return { schema_version: 1, site: "codeswhat", surface: "marketing", path } as Record< - string, - string | number - >; + const common: Record = { + token, + $cookieless_mode: true, + $process_person_profile: false, + schema_version: 1, + site: "codeswhat", + surface: "marketing", + path, + }; + if (properties.distinct_id === "$posthog_cookieless") { + common.distinct_id = "$posthog_cookieless"; + } + return common; +} + +function createSanitizedEvent( + input: EventInput, + event: SanitizedEvent["event"], + properties: SanitizedEvent["properties"], +): SanitizedEvent { + const result: SanitizedEvent = { event, properties }; + if (typeof input.uuid === "string") result.uuid = input.uuid; + if (input.timestamp instanceof Date && Number.isFinite(input.timestamp.getTime())) { + result.timestamp = input.timestamp; + } + return result; } export function sanitizeEvent(input: unknown): SanitizedEvent | null { if (!input || typeof input !== "object") return null; - const { event, properties } = input as EventInput; + const eventInput = input as EventInput; + const { event, properties } = eventInput; if (typeof event !== "string" || !properties || typeof properties !== "object") return null; const values = properties as Record; const common = createCommonProperties(values); + if (common === null) return null; if (event === "$pageview") { - return { event, properties: { ...common, $current_url: `${PRODUCTION_ORIGIN}${common.path}` } }; + return createSanitizedEvent(eventInput, event, { + ...common, + $current_url: `${PRODUCTION_ORIGIN}${common.path}`, + }); } if (event === "cta activated") { const ctaId = typeof values.cta_id === "string" ? values.cta_id : ""; const placement = typeof values.placement === "string" ? values.placement : ""; return allowedCtaIds.has(ctaId) && allowedPlacements.has(placement) - ? { event, properties: { ...common, cta_id: ctaId, placement } } + ? createSanitizedEvent(eventInput, event, { ...common, cta_id: ctaId, placement }) : null; } @@ -99,15 +140,8 @@ export function sanitizeEvent(input: unknown): SanitizedEvent | null { vitalProperties[key] = value; } } - if (Object.keys(vitalProperties).length === 0) { - const metricName = typeof values.metric_name === "string" ? values.metric_name : ""; - const value = typeof values.value === "number" ? values.value : Number.NaN; - if (Number.isFinite(value) && ["CLS", "FCP", "INP", "LCP"].includes(metricName)) { - vitalProperties[`$web_vitals_${metricName}_value`] = value; - } - } return Object.keys(vitalProperties).length > 0 - ? { event, properties: { ...common, ...vitalProperties } } + ? createSanitizedEvent(eventInput, event, { ...common, ...vitalProperties }) : null; } diff --git a/frontend/test/posthog-browser.test.mjs b/frontend/test/posthog-browser.test.mjs index e73cb2a..ead35da 100644 --- a/frontend/test/posthog-browser.test.mjs +++ b/frontend/test/posthog-browser.test.mjs @@ -3,7 +3,9 @@ import { test } from "node:test"; const baseUrl = process.env.POSTHOG_BROWSER_BASE_URL; -test("production browser response keeps PostHog on the exact proxy", { skip: !baseUrl }, async () => { +test("production browser response keeps PostHog on the exact proxy", { + skip: !baseUrl, +}, async () => { const response = await fetch(`${baseUrl}/?query-secret=should-not-leave`); const body = await response.text(); const csp = response.headers.get("content-security-policy") ?? ""; diff --git a/frontend/test/posthog-source.test.mjs b/frontend/test/posthog-source.test.mjs index add1640..deb1d0e 100644 --- a/frontend/test/posthog-source.test.mjs +++ b/frontend/test/posthog-source.test.mjs @@ -26,9 +26,9 @@ test("privacy posture disables persistence, recording, autocapture, and automati "NEXT_PUBLIC_POSTHOG_PROJECT_TOKEN", "NEXT_PUBLIC_POSTHOG_HOST", "NEXT_PUBLIC_POSTHOG_UI_HOST", - 'capture_pageview: false', - 'autocapture: false', - 'disable_session_recording: true', + "capture_pageview: false", + "autocapture: false", + "disable_session_recording: true", 'persistence: "memory"', 'cookieless_mode: "always"', "advanced_disable_flags: true", diff --git a/frontend/test/posthog.test.ts b/frontend/test/posthog.test.ts index ad923ee..e894f1f 100644 --- a/frontend/test/posthog.test.ts +++ b/frontend/test/posthog.test.ts @@ -1,4 +1,5 @@ import assert from "node:assert/strict"; +import { createRequire } from "node:module"; import { test } from "node:test"; import { ALLOWED_CTA_IDS, @@ -8,6 +9,14 @@ import { sanitizeRoute, } from "../lib/posthog-privacy.ts"; +const require = createRequire(import.meta.url); +const { PostHog } = require("../node_modules/posthog-js/lib/src/posthog-core.js") as { + PostHog: new () => { + config: { before_send?: (input: unknown) => unknown }; + _runBeforeSend: (input: unknown) => unknown; + }; +}; + test("route sanitization only returns the finite public route manifest", () => { assert.deepEqual(ALLOWED_ROUTES, ["/"]); assert.equal(sanitizeRoute("/?utm_source=secret#private"), "/"); @@ -32,12 +41,19 @@ test("pageview events keep only the sanitized pathname", () => { properties: { path: "/?secret=1#fragment", $current_url: "https://codeswhat.com/?secret=1#fragment", - token: "secret", + token: "phc_public-token_123", + distinct_id: "$posthog_cookieless", + $cookieless_mode: true, + $process_person_profile: false, }, }), { event: "$pageview", properties: { + token: "phc_public-token_123", + distinct_id: "$posthog_cookieless", + $cookieless_mode: true, + $process_person_profile: false, schema_version: 1, site: "codeswhat", surface: "marketing", @@ -53,11 +69,21 @@ test("CTA events are limited to the initial GitHub placements", () => { assert.deepEqual( sanitizeEvent({ event: "cta activated", - properties: { path: "/", cta_id: "github_org", placement: "hero", token: "secret" }, + properties: { + token: "phc_public-token_123", + $cookieless_mode: true, + $process_person_profile: false, + path: "/", + cta_id: "github_org", + placement: "hero", + }, }), { event: "cta activated", properties: { + token: "phc_public-token_123", + $cookieless_mode: true, + $process_person_profile: false, schema_version: 1, site: "codeswhat", surface: "marketing", @@ -81,9 +107,13 @@ test("web vitals events keep only metric data", () => { sanitizeEvent({ event: "$web_vitals", properties: { + token: "phc_public-token_123", + $cookieless_mode: true, + $process_person_profile: false, path: "/?private=1", - metric_name: "LCP", - value: 123.4, + $web_vitals_LCP_value: 123.4, + $web_vitals_CLS_value: -1, + $web_vitals_LCP_event: { attribution: "private" }, rating: "good", $current_url: "https://codeswhat.com/?private=1", }, @@ -91,6 +121,9 @@ test("web vitals events keep only metric data", () => { { event: "$web_vitals", properties: { + token: "phc_public-token_123", + $cookieless_mode: true, + $process_person_profile: false, schema_version: 1, site: "codeswhat", surface: "marketing", @@ -99,8 +132,62 @@ test("web vitals events keep only metric data", () => { }, }, ); + assert.equal( + sanitizeEvent({ + event: "$web_vitals", + properties: { + token: "phc_public-token_123", + $cookieless_mode: true, + $process_person_profile: false, + path: "/", + metric_name: "LCP", + value: 123.4, + }, + }), + null, + ); }); test("unknown events fail closed", () => { assert.equal(sanitizeEvent({ event: "$identify", properties: {} }), null); }); + +test("the pinned PostHog before_send pipeline keeps the required cookieless envelope", () => { + const timestamp = new Date("2026-08-14T12:00:00.000Z"); + const input = { + uuid: "0189f47a-2f44-7dcb-bf7b-3bf1b8bd5d61", + timestamp, + event: "$pageview", + properties: { + token: "phc_public-token_123", + distinct_id: "$posthog_cookieless", + $cookieless_mode: true, + $process_person_profile: false, + path: "/?secret=1#fragment", + $set: { email: "private@example.com" }, + $set_once: { referrer: "private" }, + }, + $set: { email: "private@example.com" }, + $set_once: { referrer: "private" }, + }; + + const client = new PostHog(); + client.config.before_send = sanitizeEvent; + + assert.deepEqual(client._runBeforeSend(input), { + uuid: input.uuid, + timestamp, + event: "$pageview", + properties: { + token: "phc_public-token_123", + distinct_id: "$posthog_cookieless", + $cookieless_mode: true, + $process_person_profile: false, + schema_version: 1, + site: "codeswhat", + surface: "marketing", + path: "/", + $current_url: "https://codeswhat.com/", + }, + }); +});