From dcd278f173378b8ff90ab43af9c5587d61f340ba Mon Sep 17 00:00:00 2001 From: Sam Kim Date: Mon, 27 Apr 2026 22:30:24 -0700 Subject: [PATCH 1/2] feat: add shared consent and privacy compliance utilities Add a portable consent module for reading cookie-based privacy preferences across AuthZed properties. Update analytics initialization to respect visitor consent decisions and regional privacy requirements. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/App.tsx | 9 ++++- src/lib/consent/debug.ts | 22 +++++++++++ src/lib/consent/eu-detection.ts | 70 +++++++++++++++++++++++++++++++++ src/lib/consent/index.ts | 19 +++++++++ src/lib/consent/storage.ts | 60 ++++++++++++++++++++++++++++ src/lib/consent/types.ts | 8 ++++ 6 files changed, 187 insertions(+), 1 deletion(-) create mode 100644 src/lib/consent/debug.ts create mode 100644 src/lib/consent/eu-detection.ts create mode 100644 src/lib/consent/index.ts create mode 100644 src/lib/consent/storage.ts create mode 100644 src/lib/consent/types.ts diff --git a/src/App.tsx b/src/App.tsx index f5edafb..2c8931d 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -9,6 +9,7 @@ import { import { TanStackRouterDevtools } from "@tanstack/react-router-devtools"; import posthog from "posthog-js"; import { PropsWithChildren, useEffect } from "react"; +import { isEUVisitor, shouldOptOutCapturing } from "@/lib/consent"; import { CookiesProvider } from "react-cookie"; import "react-reflex/styles.css"; import "typeface-roboto-mono/index.css"; // Import the Roboto Mono font. @@ -60,10 +61,16 @@ const config = AppConfig(); function PHProvider({ children }: PropsWithChildren) { useEffect(() => { if (config.posthog.apiKey && config.posthog.host) { + const optOut = shouldOptOutCapturing(isEUVisitor()); + posthog.init(config.posthog.apiKey, { api_host: config.posthog.host, person_profiles: "identified_only", - defaults: "2025-11-30", // Enables automatic SPA pageview tracking via history API + cross_subdomain_cookie: true, + defaults: "2025-11-30", + opt_out_capturing_by_default: optOut, + opt_out_persistence_by_default: optOut, + cookieless_mode: "on_reject", }); } }, []); diff --git a/src/lib/consent/debug.ts b/src/lib/consent/debug.ts new file mode 100644 index 0000000..fe36ee7 --- /dev/null +++ b/src/lib/consent/debug.ts @@ -0,0 +1,22 @@ +/** Debug cookie names used by ConsentProvider to override detection. */ +export const DEBUG_EU_COOKIE = '__consent_debug_eu'; +export const DEBUG_GPC_COOKIE = '__consent_debug_gpc'; + +/** + * Read a debug override cookie by name. Returns null if not set or + * if running in production (Vercel production environment). + */ +export function getDebugCookie(name: string): string | null { + if (typeof document === 'undefined') return null; + if (typeof process !== 'undefined') { + const env = + (process.env as Record).VERCEL_ENV ?? + (process.env as Record) + .NEXT_PUBLIC_VERCEL_ENV; + if (env === 'production') return null; + } + const match = document.cookie + .split('; ') + .find((row) => row.startsWith(`${name}=`)); + return match ? match.split('=')[1] : null; +} diff --git a/src/lib/consent/eu-detection.ts b/src/lib/consent/eu-detection.ts new file mode 100644 index 0000000..ae617b4 --- /dev/null +++ b/src/lib/consent/eu-detection.ts @@ -0,0 +1,70 @@ +import { DEBUG_EU_COOKIE, getDebugCookie } from './debug'; + +const EU_TIMEZONES = new Set([ + // EU member states + 'Europe/Vienna', + 'Europe/Brussels', + 'Europe/Sofia', + 'Europe/Zagreb', + 'Asia/Famagusta', + 'Asia/Nicosia', + 'Europe/Prague', + 'Europe/Copenhagen', + 'Europe/Tallinn', + 'Europe/Helsinki', + 'Europe/Paris', + 'Europe/Berlin', + 'Europe/Busingen', + 'Europe/Athens', + 'Europe/Budapest', + 'Europe/Dublin', + 'Europe/Rome', + 'Europe/Riga', + 'Europe/Vilnius', + 'Europe/Luxembourg', + 'Europe/Malta', + 'Europe/Amsterdam', + 'Europe/Warsaw', + 'Europe/Lisbon', + 'Atlantic/Azores', + 'Atlantic/Madeira', + 'Europe/Bucharest', + 'Europe/Bratislava', + 'Europe/Ljubljana', + 'Europe/Madrid', + 'Africa/Ceuta', + 'Atlantic/Canary', + 'Europe/Stockholm', + // EEA (non-EU) + 'Europe/Oslo', + 'Arctic/Longyearbyen', + 'Atlantic/Reykjavik', + 'Europe/Vaduz', + // UK (still applies GDPR-equivalent) + 'Europe/London', + 'Europe/Belfast', + 'Europe/Guernsey', + 'Europe/Isle_of_Man', + 'Europe/Jersey', +]); + +let cached: boolean | undefined; + +export function isEUVisitor(): boolean { + if (typeof window === 'undefined') return true; + if (cached !== undefined) return cached; + + const debugOverride = getDebugCookie(DEBUG_EU_COOKIE); + if (debugOverride !== null) { + cached = debugOverride === 'true'; + return cached; + } + + try { + const tz = Intl.DateTimeFormat().resolvedOptions().timeZone; + cached = EU_TIMEZONES.has(tz); + } catch { + cached = true; + } + return cached; +} diff --git a/src/lib/consent/index.ts b/src/lib/consent/index.ts new file mode 100644 index 0000000..ce4ff49 --- /dev/null +++ b/src/lib/consent/index.ts @@ -0,0 +1,19 @@ +/** + * @authzed/consent core (read-only consumer) + * + * Copied from authzed/web src/consent/core/. + * Portable consent utilities with zero external dependencies. + * + * This is a read-only consumer: it reads the `az-consent` cookie set by + * the marketing site (authzed.com) but does not write it or show a banner. + * Consent decisions are made on authzed.com and shared via the cookie. + */ +export { + readConsentCookie, + parseConsentCookie, + consentedIdentify, + shouldOptOutCapturing, + CONSENT_COOKIE_NAME, +} from './storage'; +export { isEUVisitor } from './eu-detection'; +export type { ConsentPreferences } from './types'; diff --git a/src/lib/consent/storage.ts b/src/lib/consent/storage.ts new file mode 100644 index 0000000..c60a143 --- /dev/null +++ b/src/lib/consent/storage.ts @@ -0,0 +1,60 @@ +import type { ConsentPreferences } from './types'; + +const COOKIE_NAME = 'az-consent'; +const COOKIE_VERSION = 1; + +export function readConsentCookie(): ConsentPreferences | null { + if (typeof document === 'undefined') return null; + + const raw = getCookieValue(COOKIE_NAME); + if (!raw) return null; + + try { + const parsed = JSON.parse(raw); + if (parsed.version === COOKIE_VERSION) + return parsed as ConsentPreferences; + } catch { + // invalid cookie + } + return null; +} + +export function parseConsentCookie( + rawValue: string | undefined | null +): ConsentPreferences | null { + if (!rawValue) return null; + try { + const parsed = JSON.parse(decodeURIComponent(rawValue)); + if (parsed.version === COOKIE_VERSION) return parsed as ConsentPreferences; + } catch { + // invalid cookie + } + return null; +} + +export const CONSENT_COOKIE_NAME = COOKIE_NAME; + +export function consentedIdentify( + ph: { identify: (id: string, props?: Record) => void }, + distinctId: string, + properties?: Record +): void { + const consent = readConsentCookie(); + if (consent?.statistics) { + ph.identify(distinctId, properties); + } +} + +export function shouldOptOutCapturing(isEU: boolean): boolean { + const consent = readConsentCookie(); + if (consent !== null) return !consent.statistics; + return isEU; +} + +function getCookieValue(name: string): string | null { + if (typeof document === 'undefined') return null; + const match = document.cookie + .split('; ') + .find((row) => row.startsWith(`${name}=`)); + return match ? decodeURIComponent(match.split('=').slice(1).join('=')) : null; +} diff --git a/src/lib/consent/types.ts b/src/lib/consent/types.ts new file mode 100644 index 0000000..99f8717 --- /dev/null +++ b/src/lib/consent/types.ts @@ -0,0 +1,8 @@ +export interface ConsentPreferences { + version: 1; + necessary: true; + preferences: boolean; + statistics: boolean; + marketing: boolean; + updatedAt: string; // ISO-8601 +} From 41e0a1bf0d7c502fcf78d11cb2933393e2285a8d Mon Sep 17 00:00:00 2001 From: Sam Kim Date: Mon, 27 Apr 2026 22:51:07 -0700 Subject: [PATCH 2/2] style: format with oxfmt Co-Authored-By: Claude Opus 4.6 (1M context) --- src/App.tsx | 2 +- src/lib/consent/debug.ts | 19 +++---- src/lib/consent/eu-detection.ts | 90 ++++++++++++++++----------------- src/lib/consent/index.ts | 6 +-- src/lib/consent/storage.ts | 23 ++++----- 5 files changed, 66 insertions(+), 74 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 2c8931d..e0d4d8f 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -9,12 +9,12 @@ import { import { TanStackRouterDevtools } from "@tanstack/react-router-devtools"; import posthog from "posthog-js"; import { PropsWithChildren, useEffect } from "react"; -import { isEUVisitor, shouldOptOutCapturing } from "@/lib/consent"; import { CookiesProvider } from "react-cookie"; import "react-reflex/styles.css"; import "typeface-roboto-mono/index.css"; // Import the Roboto Mono font. import { ThemeProvider } from "@/components/ThemeProvider"; +import { isEUVisitor, shouldOptOutCapturing } from "@/lib/consent"; import "./App.css"; import { EmbeddedPlayground } from "./components/EmbeddedPlayground"; diff --git a/src/lib/consent/debug.ts b/src/lib/consent/debug.ts index fe36ee7..d83ca02 100644 --- a/src/lib/consent/debug.ts +++ b/src/lib/consent/debug.ts @@ -1,22 +1,19 @@ /** Debug cookie names used by ConsentProvider to override detection. */ -export const DEBUG_EU_COOKIE = '__consent_debug_eu'; -export const DEBUG_GPC_COOKIE = '__consent_debug_gpc'; +export const DEBUG_EU_COOKIE = "__consent_debug_eu"; +export const DEBUG_GPC_COOKIE = "__consent_debug_gpc"; /** * Read a debug override cookie by name. Returns null if not set or * if running in production (Vercel production environment). */ export function getDebugCookie(name: string): string | null { - if (typeof document === 'undefined') return null; - if (typeof process !== 'undefined') { + if (typeof document === "undefined") return null; + if (typeof process !== "undefined") { const env = (process.env as Record).VERCEL_ENV ?? - (process.env as Record) - .NEXT_PUBLIC_VERCEL_ENV; - if (env === 'production') return null; + (process.env as Record).NEXT_PUBLIC_VERCEL_ENV; + if (env === "production") return null; } - const match = document.cookie - .split('; ') - .find((row) => row.startsWith(`${name}=`)); - return match ? match.split('=')[1] : null; + const match = document.cookie.split("; ").find((row) => row.startsWith(`${name}=`)); + return match ? match.split("=")[1] : null; } diff --git a/src/lib/consent/eu-detection.ts b/src/lib/consent/eu-detection.ts index ae617b4..fad4f88 100644 --- a/src/lib/consent/eu-detection.ts +++ b/src/lib/consent/eu-detection.ts @@ -1,62 +1,62 @@ -import { DEBUG_EU_COOKIE, getDebugCookie } from './debug'; +import { DEBUG_EU_COOKIE, getDebugCookie } from "./debug"; const EU_TIMEZONES = new Set([ // EU member states - 'Europe/Vienna', - 'Europe/Brussels', - 'Europe/Sofia', - 'Europe/Zagreb', - 'Asia/Famagusta', - 'Asia/Nicosia', - 'Europe/Prague', - 'Europe/Copenhagen', - 'Europe/Tallinn', - 'Europe/Helsinki', - 'Europe/Paris', - 'Europe/Berlin', - 'Europe/Busingen', - 'Europe/Athens', - 'Europe/Budapest', - 'Europe/Dublin', - 'Europe/Rome', - 'Europe/Riga', - 'Europe/Vilnius', - 'Europe/Luxembourg', - 'Europe/Malta', - 'Europe/Amsterdam', - 'Europe/Warsaw', - 'Europe/Lisbon', - 'Atlantic/Azores', - 'Atlantic/Madeira', - 'Europe/Bucharest', - 'Europe/Bratislava', - 'Europe/Ljubljana', - 'Europe/Madrid', - 'Africa/Ceuta', - 'Atlantic/Canary', - 'Europe/Stockholm', + "Europe/Vienna", + "Europe/Brussels", + "Europe/Sofia", + "Europe/Zagreb", + "Asia/Famagusta", + "Asia/Nicosia", + "Europe/Prague", + "Europe/Copenhagen", + "Europe/Tallinn", + "Europe/Helsinki", + "Europe/Paris", + "Europe/Berlin", + "Europe/Busingen", + "Europe/Athens", + "Europe/Budapest", + "Europe/Dublin", + "Europe/Rome", + "Europe/Riga", + "Europe/Vilnius", + "Europe/Luxembourg", + "Europe/Malta", + "Europe/Amsterdam", + "Europe/Warsaw", + "Europe/Lisbon", + "Atlantic/Azores", + "Atlantic/Madeira", + "Europe/Bucharest", + "Europe/Bratislava", + "Europe/Ljubljana", + "Europe/Madrid", + "Africa/Ceuta", + "Atlantic/Canary", + "Europe/Stockholm", // EEA (non-EU) - 'Europe/Oslo', - 'Arctic/Longyearbyen', - 'Atlantic/Reykjavik', - 'Europe/Vaduz', + "Europe/Oslo", + "Arctic/Longyearbyen", + "Atlantic/Reykjavik", + "Europe/Vaduz", // UK (still applies GDPR-equivalent) - 'Europe/London', - 'Europe/Belfast', - 'Europe/Guernsey', - 'Europe/Isle_of_Man', - 'Europe/Jersey', + "Europe/London", + "Europe/Belfast", + "Europe/Guernsey", + "Europe/Isle_of_Man", + "Europe/Jersey", ]); let cached: boolean | undefined; export function isEUVisitor(): boolean { - if (typeof window === 'undefined') return true; + if (typeof window === "undefined") return true; if (cached !== undefined) return cached; const debugOverride = getDebugCookie(DEBUG_EU_COOKIE); if (debugOverride !== null) { - cached = debugOverride === 'true'; + cached = debugOverride === "true"; return cached; } diff --git a/src/lib/consent/index.ts b/src/lib/consent/index.ts index ce4ff49..44799a4 100644 --- a/src/lib/consent/index.ts +++ b/src/lib/consent/index.ts @@ -14,6 +14,6 @@ export { consentedIdentify, shouldOptOutCapturing, CONSENT_COOKIE_NAME, -} from './storage'; -export { isEUVisitor } from './eu-detection'; -export type { ConsentPreferences } from './types'; +} from "./storage"; +export { isEUVisitor } from "./eu-detection"; +export type { ConsentPreferences } from "./types"; diff --git a/src/lib/consent/storage.ts b/src/lib/consent/storage.ts index c60a143..b95a223 100644 --- a/src/lib/consent/storage.ts +++ b/src/lib/consent/storage.ts @@ -1,27 +1,24 @@ -import type { ConsentPreferences } from './types'; +import type { ConsentPreferences } from "./types"; -const COOKIE_NAME = 'az-consent'; +const COOKIE_NAME = "az-consent"; const COOKIE_VERSION = 1; export function readConsentCookie(): ConsentPreferences | null { - if (typeof document === 'undefined') return null; + if (typeof document === "undefined") return null; const raw = getCookieValue(COOKIE_NAME); if (!raw) return null; try { const parsed = JSON.parse(raw); - if (parsed.version === COOKIE_VERSION) - return parsed as ConsentPreferences; + if (parsed.version === COOKIE_VERSION) return parsed as ConsentPreferences; } catch { // invalid cookie } return null; } -export function parseConsentCookie( - rawValue: string | undefined | null -): ConsentPreferences | null { +export function parseConsentCookie(rawValue: string | undefined | null): ConsentPreferences | null { if (!rawValue) return null; try { const parsed = JSON.parse(decodeURIComponent(rawValue)); @@ -37,7 +34,7 @@ export const CONSENT_COOKIE_NAME = COOKIE_NAME; export function consentedIdentify( ph: { identify: (id: string, props?: Record) => void }, distinctId: string, - properties?: Record + properties?: Record, ): void { const consent = readConsentCookie(); if (consent?.statistics) { @@ -52,9 +49,7 @@ export function shouldOptOutCapturing(isEU: boolean): boolean { } function getCookieValue(name: string): string | null { - if (typeof document === 'undefined') return null; - const match = document.cookie - .split('; ') - .find((row) => row.startsWith(`${name}=`)); - return match ? decodeURIComponent(match.split('=').slice(1).join('=')) : null; + if (typeof document === "undefined") return null; + const match = document.cookie.split("; ").find((row) => row.startsWith(`${name}=`)); + return match ? decodeURIComponent(match.split("=").slice(1).join("=")) : null; }