From 71ac4e6a1b57c92fd846af7e88bf8d2834533418 Mon Sep 17 00:00:00 2001 From: scttbnsn <80784472+scttbnsn@users.noreply.github.com> Date: Thu, 22 Oct 2026 13:43:17 -0400 Subject: [PATCH 01/11] fix(analytics): set $pathname on web vitals from the sanitized path --- frontend/lib/posthog-privacy.ts | 8 +++++++- frontend/test/posthog.test.ts | 24 ++++++++++++++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/frontend/lib/posthog-privacy.ts b/frontend/lib/posthog-privacy.ts index c2f8040..e8e9490 100644 --- a/frontend/lib/posthog-privacy.ts +++ b/frontend/lib/posthog-privacy.ts @@ -187,7 +187,13 @@ export function sanitizeEvent(input: unknown): SanitizedEvent | null { } } return Object.keys(vitalProperties).length > 0 - ? createSanitizedEvent(eventInput, event, { ...common, ...vitalProperties }) + ? createSanitizedEvent(eventInput, event, { + ...common, + // Same already-sanitized path the pageview branch uses, so Web + // analytics reports that key off $pathname resolve vitals too. + $pathname: common.path, + ...vitalProperties, + }) : null; } diff --git a/frontend/test/posthog.test.ts b/frontend/test/posthog.test.ts index 4c4d7a8..fb7b2b7 100644 --- a/frontend/test/posthog.test.ts +++ b/frontend/test/posthog.test.ts @@ -289,6 +289,7 @@ test("web vitals events keep only metric data", () => { site: "codeswhat", surface: "marketing", path: "/", + $pathname: "/", $web_vitals_LCP_value: 123.4, ...COOKIELESS_HASH_PROPERTIES, }, @@ -387,3 +388,26 @@ test("sanitizeEvent requires and forwards the cookieless server-hash fields", () ); } }); + +test("web vitals carry a $pathname taken from the sanitized path", () => { + const base = { + token: "phc_public-token_123", + $cookieless_mode: true, + $process_person_profile: false, + ...COOKIELESS_HASH_PROPERTIES, + }; + const other = sanitizeEvent({ + event: "$web_vitals", + properties: { ...base, path: "/private/page?secret=1", $web_vitals_LCP_value: 120 }, + }); + assert.ok(other); + assert.equal(other.properties.path, "/_other"); + assert.equal(other.properties.$pathname, "/_other"); + + const home = sanitizeEvent({ + event: "$web_vitals", + properties: { ...base, path: "/", $pathname: "/ignored", $web_vitals_CLS_value: 0.1 }, + }); + assert.ok(home); + assert.equal(home.properties.$pathname, "/"); +}); From 8dd4281d5871413bc1e6a5014264f7657e3f95d6 Mon Sep 17 00:00:00 2001 From: scttbnsn <80784472+scttbnsn@users.noreply.github.com> Date: Thu, 22 Oct 2026 13:43:44 -0400 Subject: [PATCH 02/11] feat(analytics): add project CTA, newsletter and client error events --- frontend/components/EmailSignupForm.tsx | 17 +- frontend/components/site/TrackedCtaLink.tsx | 13 +- .../components/site/sections/Projects.tsx | 7 +- frontend/instrumentation-client.ts | 27 ++- frontend/lib/posthog-privacy.ts | 74 ++++++- frontend/test/posthog-source.test.mjs | 31 +++ frontend/test/posthog.test.ts | 181 +++++++++++++++++- 7 files changed, 338 insertions(+), 12 deletions(-) diff --git a/frontend/components/EmailSignupForm.tsx b/frontend/components/EmailSignupForm.tsx index 19a7896..172d781 100644 --- a/frontend/components/EmailSignupForm.tsx +++ b/frontend/components/EmailSignupForm.tsx @@ -1,10 +1,19 @@ "use client"; import { Bell, Loader2 } from "lucide-react"; +import posthog from "posthog-js"; import { useState } from "react"; import { toast } from "sonner"; import { Button } from "@/components/ui/button"; +// Maps the /api/subscribe status to a fixed error_class. Never the email or the +// response text; the sanitizer drops anything outside this list. +function classifyNewsletterError(status: number) { + if (status === 429) return "rate_limited"; + if (status === 400) return "invalid_email"; + return "unavailable"; +} + export function EmailSignupForm() { const [email, setEmail] = useState(""); const [honeypot, setHoneypot] = useState(""); // Bot trap @@ -52,9 +61,10 @@ export function EmailSignupForm() { body: JSON.stringify({ email: trimmedEmail }), }); - const data = await response.json(); + const data = await response.json().catch(() => ({})); if (response.ok) { + posthog.capture("newsletter submitted", { result: "success" }); setStatus("success"); toast.success("What's next? Check your inbox to find out 👀", { icon: "🎯", @@ -67,6 +77,10 @@ export function EmailSignupForm() { setStatus("idle"); }, 10000); } else { + posthog.capture("newsletter submitted", { + result: "error", + error_class: classifyNewsletterError(response.status), + }); setStatus("error"); toast.error(data.error || "❌ Something went wrong", { duration: 5000, @@ -78,6 +92,7 @@ export function EmailSignupForm() { }, 5000); } } catch { + posthog.capture("newsletter submitted", { result: "error", error_class: "network" }); setStatus("error"); toast.error("🌐 Network error. Please check your connection and try again.", { duration: 5000, diff --git a/frontend/components/site/TrackedCtaLink.tsx b/frontend/components/site/TrackedCtaLink.tsx index d4ae1cd..e34c915 100644 --- a/frontend/components/site/TrackedCtaLink.tsx +++ b/frontend/components/site/TrackedCtaLink.tsx @@ -4,17 +4,24 @@ import posthog from "posthog-js"; import type { AnchorHTMLAttributes } from "react"; type TrackedCtaLinkProps = AnchorHTMLAttributes & { - placement: "header" | "hero" | "footer"; + placement: "header" | "hero" | "footer" | "projects"; + /** Must be in ALLOWED_CTA_IDS (lib/posthog-privacy.ts) or the sanitizer drops the event. */ + ctaId?: string; }; -export function TrackedCtaLink({ placement, onClick, ...props }: TrackedCtaLinkProps) { +export function TrackedCtaLink({ + placement, + ctaId = "github_org", + onClick, + ...props +}: TrackedCtaLinkProps) { return ( // biome-ignore lint/a11y/noStaticElementInteractions: this link records an allowlisted outbound CTA. { - posthog.capture("cta activated", { cta_id: "github_org", placement }); + posthog.capture("cta activated", { cta_id: ctaId, placement }); onClick?.(event); }} /> diff --git a/frontend/components/site/sections/Projects.tsx b/frontend/components/site/sections/Projects.tsx index 6df0de0..2acfcc2 100644 --- a/frontend/components/site/sections/Projects.tsx +++ b/frontend/components/site/sections/Projects.tsx @@ -1,4 +1,5 @@ import { ArrowUpRight } from "lucide-react"; +import { TrackedCtaLink } from "@/components/site/TrackedCtaLink"; import { PROJECTS } from "@/lib/site-config"; /** @@ -26,7 +27,9 @@ export function Projects() { diff --git a/frontend/instrumentation-client.ts b/frontend/instrumentation-client.ts index 6a8604f..d260604 100644 --- a/frontend/instrumentation-client.ts +++ b/frontend/instrumentation-client.ts @@ -1,5 +1,10 @@ import posthog from "posthog-js"; -import { getPostHogConfig, sanitizeEvent, sanitizeRoute } from "./lib/posthog-privacy"; +import { + classifyClientErrorName, + getPostHogConfig, + sanitizeEvent, + sanitizeRoute, +} from "./lib/posthog-privacy"; const posthogConfig = getPostHogConfig({ NEXT_PUBLIC_POSTHOG_PROJECT_TOKEN: process.env.NEXT_PUBLIC_POSTHOG_PROJECT_TOKEN, @@ -11,6 +16,21 @@ function capturePageview(url: string) { posthog.capture("$pageview", { path: sanitizeRoute(url) }); } +const MAX_CLIENT_ERRORS_PER_PAGE_LOAD = 5; +let clientErrorCount = 0; + +// Only a bounded kind, an allowlisted constructor name and the sanitized route +// leave the page. No message, stack, filename or URL is ever read. +function captureClientError(errorKind: "error" | "unhandled_rejection", cause: unknown) { + if (clientErrorCount >= MAX_CLIENT_ERRORS_PER_PAGE_LOAD) return; + clientErrorCount += 1; + posthog.capture("client error", { + error_kind: errorKind, + error_name: classifyClientErrorName(cause), + path: sanitizeRoute(window.location.pathname), + }); +} + if (typeof window !== "undefined" && posthogConfig) { posthog.init(posthogConfig.token, { api_host: posthogConfig.apiHost, @@ -56,6 +76,11 @@ if (typeof window !== "undefined" && posthogConfig) { before_send: (event) => sanitizeEvent(event) as typeof event | null, }); + window.addEventListener("error", (event) => captureClientError("error", event.error)); + window.addEventListener("unhandledrejection", (event) => + captureClientError("unhandled_rejection", event.reason), + ); + capturePageview(window.location.pathname); } diff --git a/frontend/lib/posthog-privacy.ts b/frontend/lib/posthog-privacy.ts index e8e9490..9b3d120 100644 --- a/frontend/lib/posthog-privacy.ts +++ b/frontend/lib/posthog-privacy.ts @@ -2,13 +2,38 @@ export const POSTHOG_API_HOST = "https://e.codeswhat.com"; export const POSTHOG_UI_HOST = "https://us.posthog.com"; export const PRODUCTION_ORIGIN = "https://codeswhat.com"; export const ALLOWED_ROUTES = ["/"] as const; -export const ALLOWED_CTA_IDS = ["github_org"] as const; +export const ALLOWED_CTA_IDS = [ + "github_org", + "project_sockguard", + "project_drydock", + "project_portwing", + "project_portkey-admin-mcp", +] as const; +export const NEWSLETTER_ERROR_CLASSES = [ + "invalid_email", + "rate_limited", + "unavailable", + "network", +] as const; +export const CLIENT_ERROR_KINDS = ["error", "unhandled_rejection"] as const; +export const CLIENT_ERROR_NAMES = [ + "TypeError", + "ReferenceError", + "SyntaxError", + "RangeError", + "ChunkLoadError", + "Error", + "other", +] as const; const OTHER_PATH = "/_other"; const PROJECT_TOKEN_PATTERN = /^phc_[A-Za-z0-9_-]+$/u; const allowedRoutes = new Set(ALLOWED_ROUTES); const allowedCtaIds = new Set(ALLOWED_CTA_IDS); -const allowedPlacements = new Set(["header", "hero", "footer"]); +const projectCtaPrefix = "project_"; +const allowedNewsletterErrorClasses = new Set(NEWSLETTER_ERROR_CLASSES); +const allowedClientErrorKinds = new Set(CLIENT_ERROR_KINDS); +const allowedClientErrorNames = new Set(CLIENT_ERROR_NAMES); const allowedVitalKeys = new Set([ "$web_vitals_CLS_value", "$web_vitals_FCP_value", @@ -51,7 +76,13 @@ type EventInput = { }; type SanitizedEvent = { - event: "$pageview" | "$pageleave" | "cta activated" | "$web_vitals"; + event: + | "$pageview" + | "$pageleave" + | "cta activated" + | "$web_vitals" + | "newsletter submitted" + | "client error"; properties: Record; timestamp?: Date; uuid?: string; @@ -81,6 +112,18 @@ function sanitizeReferringDomain(value: unknown): string | undefined { return value.toLowerCase(); } +export function classifyClientErrorName(value: unknown): string { + const name = value instanceof Error ? value.name : undefined; + return typeof name === "string" && allowedClientErrorNames.has(name) ? name : "other"; +} + +function isAllowedCtaPlacement(ctaId: string, placement: string): boolean { + if (ctaId === "github_org") { + return placement === "header" || placement === "hero" || placement === "footer"; + } + return ctaId.startsWith(projectCtaPrefix) && placement === "projects"; +} + function createCommonProperties(properties: Record) { const token = properties.token; // PostHog's cookieless server-hash ingestion step computes the anonymous @@ -173,7 +216,7 @@ export function sanitizeEvent(input: unknown): SanitizedEvent | null { 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) + return allowedCtaIds.has(ctaId) && isAllowedCtaPlacement(ctaId, placement) ? createSanitizedEvent(eventInput, event, { ...common, cta_id: ctaId, placement }) : null; } @@ -197,5 +240,28 @@ export function sanitizeEvent(input: unknown): SanitizedEvent | null { : null; } + if (event === "newsletter submitted") { + const result = values.result; + if (result === "success") { + return createSanitizedEvent(eventInput, event, { ...common, result }); + } + const errorClass = typeof values.error_class === "string" ? values.error_class : ""; + return result === "error" && allowedNewsletterErrorClasses.has(errorClass) + ? createSanitizedEvent(eventInput, event, { ...common, result, error_class: errorClass }) + : null; + } + + if (event === "client error") { + const errorKind = typeof values.error_kind === "string" ? values.error_kind : ""; + const errorName = typeof values.error_name === "string" ? values.error_name : ""; + return allowedClientErrorKinds.has(errorKind) && allowedClientErrorNames.has(errorName) + ? createSanitizedEvent(eventInput, event, { + ...common, + error_kind: errorKind, + error_name: errorName, + }) + : null; + } + return null; } diff --git a/frontend/test/posthog-source.test.mjs b/frontend/test/posthog-source.test.mjs index 3d9a8f7..d1346b9 100644 --- a/frontend/test/posthog-source.test.mjs +++ b/frontend/test/posthog-source.test.mjs @@ -35,6 +35,7 @@ test("privacy posture disables persistence, recording, autocapture, and automati "advanced_disable_flags: true", "disable_persistence: true", "save_referrer: true", + "capture_exceptions: false", "capture_performance:", ]) { assert.match(instrumentation, new RegExp(option.replace(/[.*+?^${}()|[\\]\\]/g, "\\$&"))); @@ -82,3 +83,33 @@ test("the cookieless envelope keeps the fields PostHog's server hash requires", assert.match(privacy, /\$host/u); assert.match(privacy, /cookieless_missing_user_agent|cookieless server-hash/u); }); + +test("every project card has an allowlisted project_ CTA id", async () => { + const config = await read("lib/site-config.ts"); + const privacy = await read("lib/posthog-privacy.ts"); + const projects = await read("components/site/sections/Projects.tsx"); + const slugs = [...config.matchAll(/^\s+slug: "([^"]+)",$/gmu)].map((match) => match[1]); + assert.ok(slugs.length > 0); + for (const slug of slugs) { + assert.ok(privacy.includes(`"project_${slug}"`), `project_${slug} is not in ALLOWED_CTA_IDS`); + } + assert.match(projects, /]*ctaId=\{`project_\$\{project\.slug\}`\}/u); + assert.match(projects, /placement="projects"/u); +}); + +test("client error reporting sends no message, stack, filename or URL", async () => { + const instrumentation = await read("instrumentation-client.ts"); + assert.match(instrumentation, /addEventListener\("error"/u); + assert.match(instrumentation, /addEventListener\("unhandledrejection"/u); + const call = instrumentation.match(/posthog\.capture\("client error", \{[^}]*\}\)/u)?.[0] ?? ""; + assert.match(call, /error_kind/u); + assert.match(call, /error_name/u); + assert.doesNotMatch(call, /message|stack|filename|href|url/iu); +}); + +test("newsletter submissions report a fixed result and error class only", async () => { + const form = await read("components/EmailSignupForm.tsx"); + const calls = [...form.matchAll(/posthog\.capture\("newsletter submitted", \{[^}]*\}\)/gu)]; + assert.equal(calls.length, 3); + for (const [call] of calls) assert.doesNotMatch(call, /email|message|data\./iu); +}); diff --git a/frontend/test/posthog.test.ts b/frontend/test/posthog.test.ts index fb7b2b7..08a9f68 100644 --- a/frontend/test/posthog.test.ts +++ b/frontend/test/posthog.test.ts @@ -52,6 +52,7 @@ import { test } from "node:test"; import { ALLOWED_CTA_IDS, ALLOWED_ROUTES, + classifyClientErrorName, getPostHogConfig, sanitizeEvent, sanitizeRoute, @@ -223,7 +224,13 @@ test("$pathname never diverges from the allowlisted path", () => { }); test("CTA events are limited to the initial GitHub placements", () => { - assert.deepEqual(ALLOWED_CTA_IDS, ["github_org"]); + assert.deepEqual(ALLOWED_CTA_IDS, [ + "github_org", + "project_sockguard", + "project_drydock", + "project_portwing", + "project_portkey-admin-mcp", + ]); assert.deepEqual( sanitizeEvent({ event: "cta activated", @@ -389,6 +396,14 @@ test("sanitizeEvent requires and forwards the cookieless server-hash fields", () } }); +const ENVELOPE = { + token: "phc_public-token_123", + $cookieless_mode: true, + $process_person_profile: false, + path: "/", + ...COOKIELESS_HASH_PROPERTIES, +}; + test("web vitals carry a $pathname taken from the sanitized path", () => { const base = { token: "phc_public-token_123", @@ -411,3 +426,167 @@ test("web vitals carry a $pathname taken from the sanitized path", () => { assert.ok(home); assert.equal(home.properties.$pathname, "/"); }); + +test("a CTA event shaped like the browser sends it (no path property) survives the pinned pipeline", () => { + // posthog-js never sets `path` on a cta capture; the route has to come from + // $pathname / $current_url, which carry the real query string. + const client = new PostHog(); + client.config.before_send = sanitizeEvent; + for (const [ctaId, placement] of [ + ["github_org", "header"], + ["project_drydock", "projects"], + ]) { + const result = client._runBeforeSend({ + event: "cta activated", + properties: { + token: "phc_public-token_123", + $cookieless_mode: true, + $process_person_profile: false, + $current_url: "https://codeswhat.com/?utm_source=github#projects", + $pathname: "/", + cta_id: ctaId, + placement, + ...COOKIELESS_HASH_PROPERTIES, + }, + }) as { event: string; properties: Record } | null; + assert.ok(result, `${ctaId} was dropped`); + assert.equal(result.event, "cta activated"); + assert.equal(result.properties.cta_id, ctaId); + assert.equal(result.properties.placement, placement); + assert.equal(result.properties.path, "/"); + } +}); + +test("project CTAs need a project_ id with the projects placement", () => { + const accepted = ALLOWED_CTA_IDS.filter((id) => id.startsWith("project_")); + assert.equal(accepted.length, 4); + for (const ctaId of accepted) { + assert.ok( + sanitizeEvent({ + event: "cta activated", + properties: { ...ENVELOPE, cta_id: ctaId, placement: "projects" }, + }), + ); + for (const placement of ["header", "hero", "footer", "elsewhere", ""]) { + assert.equal( + sanitizeEvent({ + event: "cta activated", + properties: { ...ENVELOPE, cta_id: ctaId, placement }, + }), + null, + ); + } + } + assert.equal( + sanitizeEvent({ + event: "cta activated", + properties: { ...ENVELOPE, cta_id: "github_org", placement: "projects" }, + }), + null, + ); + for (const ctaId of ["project_unknown", "project_", "project_drydock?x=1", 7, undefined]) { + assert.equal( + sanitizeEvent({ + event: "cta activated", + properties: { ...ENVELOPE, cta_id: ctaId, placement: "projects" }, + }), + null, + ); + } +}); + +test("newsletter submitted keeps only a result and a fixed error class", () => { + const success = sanitizeEvent({ + event: "newsletter submitted", + properties: { ...ENVELOPE, result: "success", email: "me@example.com", error_class: "network" }, + }); + assert.ok(success); + assert.equal(success.properties.result, "success"); + assert.equal("error_class" in success.properties, false); + assert.equal("email" in success.properties, false); + + for (const errorClass of ["invalid_email", "rate_limited", "unavailable", "network"]) { + const result = sanitizeEvent({ + event: "newsletter submitted", + properties: { + ...ENVELOPE, + result: "error", + error_class: errorClass, + email: "me@example.com", + }, + }); + assert.ok(result, errorClass); + assert.equal(result.properties.error_class, errorClass); + assert.equal("email" in result.properties, false); + } + + for (const bad of [ + { result: "error" }, + { result: "error", error_class: "me@example.com" }, + { result: "error", error_class: "Failed to subscribe" }, + { result: "pending" }, + { result: "SUCCESS" }, + {}, + ]) { + assert.equal( + sanitizeEvent({ event: "newsletter submitted", properties: { ...ENVELOPE, ...bad } }), + null, + JSON.stringify(bad), + ); + } +}); + +test("client error keeps only a bounded kind, an allowlisted name, and the route", () => { + for (const kind of ["error", "unhandled_rejection"]) { + const result = sanitizeEvent({ + event: "client error", + properties: { + ...ENVELOPE, + error_kind: kind, + error_name: "TypeError", + message: "Cannot read properties of undefined", + stack: "at https://codeswhat.com/_next/x.js:1:1", + filename: "https://codeswhat.com/_next/x.js", + }, + }); + assert.ok(result, kind); + assert.equal(result.properties.error_kind, kind); + assert.equal(result.properties.error_name, "TypeError"); + assert.equal(result.properties.path, "/"); + for (const leaked of ["message", "stack", "filename"]) { + assert.equal(leaked in result.properties, false, leaked); + } + } + for (const bad of [ + { error_kind: "exception", error_name: "TypeError" }, + { error_kind: "error", error_name: "CustomSecretError" }, + { error_kind: "error", error_name: "Cannot read properties of undefined" }, + { error_kind: "error" }, + { error_name: "Error" }, + ]) { + assert.equal( + sanitizeEvent({ event: "client error", properties: { ...ENVELOPE, ...bad } }), + null, + JSON.stringify(bad), + ); + } +}); + +test("classifyClientErrorName only returns allowlisted constructor names", () => { + class ChunkLoadError extends Error { + name = "ChunkLoadError"; + } + assert.equal(classifyClientErrorName(new TypeError("x")), "TypeError"); + assert.equal(classifyClientErrorName(new ReferenceError("x")), "ReferenceError"); + assert.equal(classifyClientErrorName(new SyntaxError("x")), "SyntaxError"); + assert.equal(classifyClientErrorName(new RangeError("x")), "RangeError"); + assert.equal(classifyClientErrorName(new ChunkLoadError("x")), "ChunkLoadError"); + assert.equal(classifyClientErrorName(new Error("x")), "Error"); + assert.equal(classifyClientErrorName(new URIError("x")), "other"); + const custom = new Error("x"); + custom.name = "me@example.com"; + assert.equal(classifyClientErrorName(custom), "other"); + for (const value of [null, undefined, "boom", 42, { name: "TypeError" }]) { + assert.equal(classifyClientErrorName(value), "other"); + } +}); From 98e10329f6e2b1635ef1fb0600b7d80cf3654161 Mon Sep 17 00:00:00 2001 From: scttbnsn <80784472+scttbnsn@users.noreply.github.com> Date: Thu, 22 Oct 2026 13:43:54 -0400 Subject: [PATCH 03/11] feat(analytics): record campaign labels through a bounded UTM vocabulary --- ROADMAP.md | 29 +++++++--- frontend/instrumentation-client.ts | 6 +- frontend/lib/posthog-privacy.ts | 34 +++++++++++ frontend/test/posthog-source.test.mjs | 2 + frontend/test/posthog.test.ts | 83 +++++++++++++++++++++++++++ 5 files changed, 145 insertions(+), 9 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index a315e29..5f36304 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -60,15 +60,28 @@ ships with the next promotion — the items below are what's left after that. (portwing's landed), so a Playwright canary's silence proves nothing either way. Verify with a real browser or by reading the deployed chunk for the property names. -- [ ] Acquisition data: decided org-wide in `CodesWhat/ops` +- [x] Acquisition data: decided org-wide in `CodesWhat/ops` `standards/analytics.md` ("Acquisition data and consent", as of - `c161ebc`) — that file is the authority. Shape: `save_campaign_params: - true` with `gclid`/`fbclid`/`msclkid` excluded; `save_referrer: true` - with the sanitizer forwarding `$referring_domain` only (drop it unless - it's a bare hostname, never copy `$referrer`); geo stays off (cookieless - strips the IP upstream, PostHog #48660). No banner needed. Implement - AFTER the `$pathname`/`$pageleave` promotion is verified in production, - so the two changes are separately attributable. + `c161ebc`) — that file is the authority. Landed: `save_campaign_params: + true` with `mask_personal_data_properties: true` (masks + `gclid`/`fbclid`/`msclkid` and the rest of the SDK's per-click ids); + `save_referrer: true` with the sanitizer forwarding `$referring_domain` + only (bare hostname, never `$referrer`). The sanitizer forwards only + `utm_source` (github, x, bluesky, linkedin, reddit, hackernews, + newsletter, producthunt), `utm_medium` (social, email, referral, + community) and `utm_campaign` (`/^[a-z0-9-]{1,40}$/`); everything else is + dropped. The vocabulary is pending Scott's approval. Geo stays off + (cookieless strips the IP upstream, PostHog #48660). No banner needed. +- [x] `$web_vitals` carries `$pathname` from the sanitized path +- [x] `cta activated` for the project cards: `project_` ids, placement + `projects`. `cta activated` had zero rows in 120 days on codeswhat.com; + reproduced in a browser and the capture, sanitizer and send path all work, + so it was low traffic (76 pageviews) rather than a bug. Check it fires after + deploy. +- [x] `newsletter submitted` (`result`, plus `error_class` from + invalid_email / rate_limited / unavailable / network on error) +- [x] `client error` (`error_kind`, allowlisted `error_name`, sanitized path; + max 5 per page load; `capture_exceptions` stays off) ## Telemetry and badge audit (2026-08-14) diff --git a/frontend/instrumentation-client.ts b/frontend/instrumentation-client.ts index d260604..e28cd7b 100644 --- a/frontend/instrumentation-client.ts +++ b/frontend/instrumentation-client.ts @@ -62,7 +62,11 @@ if (typeof window !== "undefined" && posthogConfig) { disable_persistence: true, respect_dnt: true, save_referrer: true, - save_campaign_params: false, + // Campaign labels only: the sanitizer forwards utm_source/medium/campaign + // through a bounded vocabulary, and masking drops the per-click ids + // (gclid, fbclid, msclkid and the rest of PERSONAL_DATA_CAMPAIGN_PARAMS). + save_campaign_params: true, + mask_personal_data_properties: true, disable_capture_url_hashes: true, disable_scroll_properties: true, mask_all_element_attributes: true, diff --git a/frontend/lib/posthog-privacy.ts b/frontend/lib/posthog-privacy.ts index 9b3d120..3949f39 100644 --- a/frontend/lib/posthog-privacy.ts +++ b/frontend/lib/posthog-privacy.ts @@ -25,6 +25,19 @@ export const CLIENT_ERROR_NAMES = [ "Error", "other", ] as const; +// Incoming UTM values are untrusted, so only this bounded vocabulary (plus a +// strict slug shape for utm_campaign) is forwarded. Everything else is dropped. +export const UTM_SOURCES = [ + "github", + "x", + "bluesky", + "linkedin", + "reddit", + "hackernews", + "newsletter", + "producthunt", +] as const; +export const UTM_MEDIUMS = ["social", "email", "referral", "community"] as const; const OTHER_PATH = "/_other"; const PROJECT_TOKEN_PATTERN = /^phc_[A-Za-z0-9_-]+$/u; @@ -34,6 +47,9 @@ const projectCtaPrefix = "project_"; const allowedNewsletterErrorClasses = new Set(NEWSLETTER_ERROR_CLASSES); const allowedClientErrorKinds = new Set(CLIENT_ERROR_KINDS); const allowedClientErrorNames = new Set(CLIENT_ERROR_NAMES); +const allowedUtmSources = new Set(UTM_SOURCES); +const allowedUtmMediums = new Set(UTM_MEDIUMS); +const UTM_CAMPAIGN_PATTERN = /^[a-z0-9-]{1,40}$/u; const allowedVitalKeys = new Set([ "$web_vitals_CLS_value", "$web_vitals_FCP_value", @@ -112,6 +128,18 @@ function sanitizeReferringDomain(value: unknown): string | undefined { return value.toLowerCase(); } +function sanitizeUtmValue(value: unknown, allowed: Set): string | undefined { + if (typeof value !== "string") return undefined; + const normalized = value.toLowerCase(); + return allowed.has(normalized) ? normalized : undefined; +} + +function sanitizeUtmCampaign(value: unknown): string | undefined { + if (typeof value !== "string") return undefined; + const normalized = value.toLowerCase(); + return UTM_CAMPAIGN_PATTERN.test(normalized) ? normalized : undefined; +} + export function classifyClientErrorName(value: unknown): string { const name = value instanceof Error ? value.name : undefined; return typeof name === "string" && allowedClientErrorNames.has(name) ? name : "other"; @@ -171,6 +199,12 @@ function createCommonProperties(properties: Record) { } const referringDomain = sanitizeReferringDomain(properties.$referring_domain); if (referringDomain !== undefined) common.$referring_domain = referringDomain; + const utmSource = sanitizeUtmValue(properties.utm_source, allowedUtmSources); + if (utmSource !== undefined) common.utm_source = utmSource; + const utmMedium = sanitizeUtmValue(properties.utm_medium, allowedUtmMediums); + if (utmMedium !== undefined) common.utm_medium = utmMedium; + const utmCampaign = sanitizeUtmCampaign(properties.utm_campaign); + if (utmCampaign !== undefined) common.utm_campaign = utmCampaign; return common; } diff --git a/frontend/test/posthog-source.test.mjs b/frontend/test/posthog-source.test.mjs index d1346b9..97b1eb4 100644 --- a/frontend/test/posthog-source.test.mjs +++ b/frontend/test/posthog-source.test.mjs @@ -35,6 +35,8 @@ test("privacy posture disables persistence, recording, autocapture, and automati "advanced_disable_flags: true", "disable_persistence: true", "save_referrer: true", + "save_campaign_params: true", + "mask_personal_data_properties: true", "capture_exceptions: false", "capture_performance:", ]) { diff --git a/frontend/test/posthog.test.ts b/frontend/test/posthog.test.ts index 08a9f68..896e0df 100644 --- a/frontend/test/posthog.test.ts +++ b/frontend/test/posthog.test.ts @@ -590,3 +590,86 @@ test("classifyClientErrorName only returns allowlisted constructor names", () => assert.equal(classifyClientErrorName(value), "other"); } }); + +test("UTM labels pass only through the bounded vocabulary", () => { + const forward = (utm: Record, event = "$pageview") => { + const result = sanitizeEvent({ + event, + properties: { ...ENVELOPE, $web_vitals_LCP_value: 1, ...utm }, + }); + assert.ok(result); + return result.properties; + }; + + for (const event of ["$pageview", "$pageleave", "$web_vitals"]) { + const props = forward( + { utm_source: "GitHub", utm_medium: "social", utm_campaign: "launch-2026" }, + event, + ); + assert.equal(props.utm_source, "github"); + assert.equal(props.utm_medium, "social"); + assert.equal(props.utm_campaign, "launch-2026"); + } + for (const source of [ + "github", + "x", + "bluesky", + "linkedin", + "reddit", + "hackernews", + "newsletter", + "producthunt", + ]) { + assert.equal(forward({ utm_source: source }).utm_source, source); + } + for (const medium of ["social", "email", "referral", "community"]) { + assert.equal(forward({ utm_medium: medium }).utm_medium, medium); + } + + for (const bad of [ + "me@example.com", + "user-12345", + "github.com", + "twitter", + "", + "", + 42, + null, + ]) { + assert.equal("utm_source" in forward({ utm_source: bad }), false, String(bad)); + assert.equal("utm_medium" in forward({ utm_medium: bad }), false, String(bad)); + } + for (const bad of [ + "me@example.com", + "Has Space", + "a".repeat(41), + "under_score", + "", + 7, + "", + ]) { + assert.equal("utm_campaign" in forward({ utm_campaign: bad }), false, String(bad)); + } + assert.equal(forward({ utm_campaign: "a".repeat(40) }).utm_campaign, "a".repeat(40)); + + const props = forward({ + utm_content: "x", + utm_term: "y", + gclid: "abc", + fbclid: "abc", + msclkid: "abc", + gad_source: "1", + mc_cid: "abc", + }); + for (const dropped of [ + "utm_content", + "utm_term", + "gclid", + "fbclid", + "msclkid", + "gad_source", + "mc_cid", + ]) { + assert.equal(dropped in props, false, dropped); + } +}); From c007f591a899e565eca26bfa9430b90a7f41239f Mon Sep 17 00:00:00 2001 From: scttbnsn <80784472+scttbnsn@users.noreply.github.com> Date: Thu, 22 Oct 2026 15:12:46 -0400 Subject: [PATCH 04/11] feat(site): add /support page with GitHub Sponsors, Stripe tiers and supporters list Six tiers mirror GitHub Sponsors and Stripe, with links read only from CODESWHAT_SUPPORT_LINKS. Support clicks go through the existing PostHog sanitizer under the support placement, and /support joins the route allowlist. --- frontend/README-VERCEL.md | 17 ++ frontend/app/sitemap.ts | 13 +- frontend/app/support/page.tsx | 242 ++++++++++++++++++++ frontend/components/site/SiteFooter.tsx | 7 + frontend/components/site/SiteHeader.tsx | 9 +- frontend/components/site/SponsorsStrip.tsx | 27 +++ frontend/components/site/SupportLink.tsx | 18 ++ frontend/components/site/Supporters.tsx | 67 ++++++ frontend/components/site/TrackedCtaLink.tsx | 2 +- frontend/lib/posthog-privacy.ts | 11 +- frontend/lib/support.ts | 192 ++++++++++++++++ frontend/lib/supporters.ts | 20 ++ frontend/package.json | 1 + frontend/test/posthog.test.ts | 67 +++++- frontend/test/support.test.ts | 127 ++++++++++ 15 files changed, 806 insertions(+), 14 deletions(-) create mode 100644 frontend/app/support/page.tsx create mode 100644 frontend/components/site/SponsorsStrip.tsx create mode 100644 frontend/components/site/SupportLink.tsx create mode 100644 frontend/components/site/Supporters.tsx create mode 100644 frontend/lib/support.ts create mode 100644 frontend/lib/supporters.ts create mode 100644 frontend/test/support.test.ts diff --git a/frontend/README-VERCEL.md b/frontend/README-VERCEL.md index 17e647c..b115f3c 100644 --- a/frontend/README-VERCEL.md +++ b/frontend/README-VERCEL.md @@ -55,6 +55,23 @@ unset in Preview and Development so those deployments emit no analytics: | `NEXT_PUBLIC_POSTHOG_HOST` | `https://e.codeswhat.com` | | `NEXT_PUBLIC_POSTHOG_UI_HOST` | `https://us.posthog.com` | +### Support page + +`/support` reads `CODESWHAT_SUPPORT_LINKS`, a server-side JSON object mapping tier +id to a Stripe Payment Link. The six ids are `monthly-5`, `monthly-10`, +`monthly-25`, `monthly-100`, `once-10` and `once-50`: + +``` +CODESWHAT_SUPPORT_LINKS={"monthly-5":"https://buy.stripe.com/...","monthly-10":"https://buy.stripe.com/...","monthly-25":"https://buy.stripe.com/...","monthly-100":"https://buy.stripe.com/...","once-10":"https://buy.stripe.com/...","once-50":"https://buy.stripe.com/..."} +``` + +Only `https://buy.stripe.com/...` and `https://donate.stripe.com/...` links with a +path and no credentials are accepted. Unknown ids and invalid links are dropped +with a server warning, and any tier without a link renders as "Opening soon". +The page is rendered per request, so changing the variable needs a redeploy but +no rebuild of page code. Costs marked PLACEHOLDER in `lib/support.ts` are for the +maintainer to fill in. + ## Domain Configuration 1. Go to your Vercel project settings diff --git a/frontend/app/sitemap.ts b/frontend/app/sitemap.ts index 36dfa68..dee15ce 100644 --- a/frontend/app/sitemap.ts +++ b/frontend/app/sitemap.ts @@ -10,13 +10,12 @@ export default function sitemap(): MetadataRoute.Sitemap { changeFrequency: "weekly" as const, priority: 1, }, - // Example of additional pages (uncomment and modify as needed): - // { - // url: `${BASE_URL}/about`, - // lastModified: new Date(), - // changeFrequency: 'monthly' as const, - // priority: 0.8, - // }, + { + url: `${BASE_URL}/support`, + lastModified: new Date(), + changeFrequency: "monthly" as const, + priority: 0.5, + }, ]; return routes; diff --git a/frontend/app/support/page.tsx b/frontend/app/support/page.tsx new file mode 100644 index 0000000..36ff5b2 --- /dev/null +++ b/frontend/app/support/page.tsx @@ -0,0 +1,242 @@ +import type { Metadata } from "next"; +import { SiteBackground } from "@/components/site/SiteBackground"; +import { SiteFooter } from "@/components/site/SiteFooter"; +import { SiteHeader } from "@/components/site/SiteHeader"; +import { Supporters } from "@/components/site/Supporters"; +import { SupportLink } from "@/components/site/SupportLink"; +import { BASE_URL, PROJECTS, SITE_CONFIG } from "@/lib/site-config"; +import { + GITHUB_SPONSORS_URL, + PROJECT_COSTS, + SHARED_COSTS, + type SupportProjectId, + SYSTEMA, + supportCtaId, + supportLinksFrom, + tiersWith, +} from "@/lib/support"; + +const description = + "Back CodesWhat through GitHub Sponsors or a Stripe payment link, see what each project costs to run, and what happens to a project that can't cover its costs."; + +export const metadata: Metadata = { + title: `Support ${SITE_CONFIG.name}`, + description, + alternates: { canonical: "/support" }, + openGraph: { + title: `Support ${SITE_CONFIG.name}`, + description, + url: `${BASE_URL}/support`, + siteName: SITE_CONFIG.name, + type: "website", + }, +}; + +// read per request so the links can change without a rebuild +export const dynamic = "force-dynamic"; + +const linkCn = + "inline-flex min-h-11 items-center justify-center rounded-full border border-line-strong px-4 py-2 text-sm font-semibold text-fg transition-colors hover:border-fg-muted focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand"; + +const primaryCn = + "inline-flex min-h-11 items-center justify-center rounded-full bg-brand px-5 py-2 text-sm font-semibold text-black transition-colors hover:bg-brand-hover focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand"; + +const disabledCn = + "inline-flex min-h-11 cursor-not-allowed items-center justify-center rounded-full border border-dashed border-line-strong px-4 py-2 text-sm font-semibold text-fg-faint"; + +const textLinkCn = + "text-sm font-medium text-fg-muted underline decoration-brand/60 underline-offset-2 hover:text-brand-hover"; + +export default function SupportPage() { + const tiers = tiersWith(supportLinksFrom(process.env.CODESWHAT_SUPPORT_LINKS, console.warn)); + + return ( + <> + +
+ +
+
+

+ Support +

+

+ Back CodesWhat +

+

+ These projects are free and open source, and they cost real money and time to keep + going. Everything below goes into one pool that covers whichever project needs it + most. Each project's running cost is listed further down. +

+ +
+

+ GitHub Sponsors +

+

+ The easiest way to give if you're on GitHub. Same tiers, pay through GitHub. +

+
+ + Sponsor CodesWhat on GitHub + +
+
+ +
+

+ Not on GitHub? +

+

+ Pay by card through Stripe instead. The tiers match the ones on GitHub. +

+
    + {tiers.map((tier) => ( +
  • +
    +

    {tier.name}

    +

    + ${tier.price} + {tier.cadence === "monthly" ? " / month" : " once"} +

    +
    +

    {tier.buys}

    +
    + {tier.link ? ( + + {tier.cadence === "monthly" ? "Support monthly" : "Give once"} + + ) : ( + + Opening soon + + )} +
    +
  • + ))} +
+
+ +
+

+ Supporters +

+
+ +
+
+ +
+

+ Where the money goes +

+

Shared costs

+
    + {SHARED_COSTS.map((item) => ( +
  • + {item.label} + {item.cost} +
  • + ))} +
+

By project

+
    + {PROJECTS.map((project) => { + const cost = PROJECT_COSTS[project.slug as SupportProjectId]; + return ( +
  • +
    +

    {project.name}

    + + Repo + +
    +

    + + Running cost + {" "} + {cost.placeholder ? ( + + {cost.line} + + ) : ( + cost.line + )} +

    +
  • + ); + })} +
  • +
    +

    {SYSTEMA.name}

    + + Support Systema + +
    +

    + + Running cost + {" "} + {SYSTEMA.cost} +

    +

    {SYSTEMA.note}

    +
  • +
+
+ +
+

Fund it or wind it down

+ {/* wording pending Scott's approval */} +

+ A project that can't cover its running costs gets wound down. It goes read-only or + archived, the last release stays published, and the repo stays public. +

+
+ +

+ Stripe handles card payments on its own pages. This site loads nothing from Stripe, + and a link only opens when you click it. Supporting a project buys no say in its + roadmap. +

+
+
+ +
+ + ); +} diff --git a/frontend/components/site/SiteFooter.tsx b/frontend/components/site/SiteFooter.tsx index ed19c85..81b2a71 100644 --- a/frontend/components/site/SiteFooter.tsx +++ b/frontend/components/site/SiteFooter.tsx @@ -1,5 +1,7 @@ import { ArrowUpRight, Github } from "lucide-react"; +import Link from "next/link"; import type { CSSProperties } from "react"; +import { SponsorsStrip } from "@/components/site/SponsorsStrip"; import { TrackedCtaLink } from "@/components/site/TrackedCtaLink"; import { Wordmark } from "@/components/site/Wordmark"; import { GITHUB_URL, PROJECTS, SITE_CONFIG } from "@/lib/site-config"; @@ -43,6 +45,9 @@ export function SiteFooter() { ))} + + Support + + +

© {year} {SITE_CONFIG.name}. All rights reserved.

diff --git a/frontend/components/site/SiteHeader.tsx b/frontend/components/site/SiteHeader.tsx index 1f3c1e3..f7d8c3c 100644 --- a/frontend/components/site/SiteHeader.tsx +++ b/frontend/components/site/SiteHeader.tsx @@ -20,15 +20,18 @@ export function SiteHeader() {