feat/ph-consent
blockedviewing older commit5fb4d78 · fullpre-PRreviewed 2026-07-31 22:11 UTC4H · 8M · 9L · 7I- Purpose
- Gate PostHog session replay on the public marketing site (apps/web) behind explicit visitor consent, as required by LFPDPPP before enabling recording of anonymous public visitors.
- Goal
- Implement a consent banner with accept/reject, persist the decision in localStorage, and wire the gate into the PostHog provider so replay only starts after explicit opt-in.
- Sub-goals
- Pure consent primitives in session-replay.ts (storage, pub/sub, gate logic)
- PostHogProvider requireConsent prop and consent action exports
- ConsentBanner UI component with useSyncExternalStore for SSR-safe rendering
- Privacy policy disclosure section for session recording
- Unit tests for the isReplayAllowedWithConsent gate matrix
- What
- New session-replay.ts module with all consent primitives; PostHogProvider gains requireConsent prop and exports grantAnalyticsConsent/denyAnalyticsConsent; ConsentBanner renders a fixed bottom bar; apps/web layout wires it all together; privacy page adds PostHog/recording disclosure.
- Why
- apps/web is a public site with no prior consent agreement from visitors — enabling session replay without consent would be a LFPDPPP violation.
- Areas
- packages/analytics/src/providers/session-replay.ts+113−0packages/analytics/src/providers/PostHogProvider.tsx+65−7apps/web/src/components/ConsentBanner.tsx+79−0apps/web/src/app/(marketing)/legal/privacidad/page.tsx+30−0apps/web/src/app/layout.tsx+6−1packages/analytics/src/index.ts+24−5packages/analytics/src/providers/__tests__/session-replay.test.ts+37−0
- Blast
- 7 files, +354/−13. Shared analytics package affects all apps; apps/web root layout affects all public pages. apps/platform structurally unaffected.
Findings · 30
correctness3
Consent gate computed but not wired into posthog.init() — replay starts before consent on production
packages/analytics/src/providers/PostHogProvider.tsx:150
replayEnabled correctly returns false when consent=unset, but posthog.init() receives sessionReplayInitOptions(sessionReplay, SESSION_REPLAY_ENABLED_FOR_ENV) which ignores consent. On apps/web production (sessionReplay=true, env=true, consent=unset), disable_session_recording=false and recording starts immediately. Fix: sessionReplayInitOptions(sessionReplay && replayEnabled, SESSION_REPLAY_ENABLED_FOR_ENV) or set disable_session_recording: !replayEnabled directly.
grantAnalyticsConsent(sessionReplay=true) default decoupled from provider's sessionReplay prop
packages/analytics/src/providers/PostHogProvider.tsx:58
Default param hardcodes true; ConsentBanner calls with no args. If another app renders ConsentBanner without sessionReplay on the provider, the grant call will attempt startSessionRecording() gated only by env var.
storeConsent comment says 'banner must close' but banner stays open on write failure
packages/analytics/src/providers/session-replay.ts:93
When localStorage.setItem throws, listeners are still notified but readStoredConsent() returns 'unset', so banner stays visible. Behavior is correct but comment is misleading.
security6
PostHog event capture (pageview, autocapture) not gated on consent
packages/analytics/src/providers/PostHogProvider.tsx
The consent gate only controls session replay. Standard PostHog $pageview, $pageleave, and autocapture fire unconditionally after posthog.init() regardless of consent. Under LFPDPPP, behavioral tracking linkable to an individual requires prior informed consent.
No consent revocation mechanism — LFPDPPP Article 16 compliance gap
apps/web/src/components/ConsentBanner.tsx
Once accepted, batu.analytics.consent=granted persists indefinitely with no expiry, re-prompt, or accessible UI to withdraw. LFPDPPP Article 16: revocation must be as easy as granting. A cookie/privacy settings link in the footer calling denyAnalyticsConsent() is required.
posthog.__loaded is an internal unstable API
packages/analytics/src/providers/PostHogProvider.tsx
Double-underscore prefix signals internal property. A future posthog-js release could change __loaded semantics silently, causing consent-based start/stop calls to be skipped.
apps/platform doesn't inherit web's denied consent — document the decision
packages/analytics/src/providers/PostHogProvider.tsx
If apps share an origin, a visitor's rejection on the public site does NOT disable replay in the platform (requireConsent=false ignores stored denial). This may be intentional but should be explicitly documented.
Replay starts on return visit without re-displaying banner
packages/analytics/src/providers/PostHogProvider.tsx
Stored grant means posthog.startSessionRecording() fires at init on every return visit. Combined with no revocation UI, this creates a practical dead-end for visitors who want to opt out.
maskAllInputs:true correctly applies to consent-gated replay
conventions4
ConsentBanner placed in src/components/ — should be _components/ (route-scoped)
apps/web/src/components/ConsentBanner.tsx
Single-consumer component used only in root layout. Canonical location: apps/web/src/app/_components/ConsentBanner.tsx.
grantAnalyticsConsent/denyAnalyticsConsent mishoused in PostHogProvider.tsx
packages/analytics/src/providers/PostHogProvider.tsx:58
session-replay.ts already owns all consent primitives. Move to session-replay.ts or rename the file to consent-controls.ts.
grantAnalyticsConsent sessionReplay param leaks internal gate logic into the public consent API
packages/analytics/src/providers/PostHogProvider.tsx
Several JSDoc comments explain WHAT rather than WHY
packages/analytics/src/providers/session-replay.ts
tests5
readStoredConsent, storeConsent, subscribeToConsent have no tests
packages/analytics/src/providers/session-replay.ts
These are the localStorage persistence layer — the legal guarantee of fail-closed consent. readStoredConsent() SSR guard, corruption handling, and fail-closed contract are all untested. Tests require @vitest-environment jsdom per-file annotation.
shouldShowConsentBanner is untested — the visibility gate for the banner
packages/analytics/src/providers/session-replay.ts:102
A one-character inversion (=== 'granted' instead of === 'unset') would mean the banner never appears and no visitor can grant consent.
ConsentBanner component has no tests despite its privacy-critical role
apps/web/src/components/ConsentBanner.tsx
Minimum: test banner doesn't render when consent is stored, Accept calls grantAnalyticsConsent, Reject calls denyAnalyticsConsent. Without these, a handler swap passes all tests.
grantAnalyticsConsent and denyAnalyticsConsent are untested
packages/analytics/src/providers/PostHogProvider.tsx
Guard conditions (window undefined, !posthog.__loaded) and isSessionReplayEnabled check in grantAnalyticsConsent are testable with mocks.
ANALYTICS_CONSENT_STORAGE_KEY constant not pin-tested
packages/analytics/src/providers/session-replay.ts
improvement5
grantAnalyticsConsent sessionReplay default param is misleading dead weight
packages/analytics/src/providers/PostHogProvider.tsx
The param implies callers control session replay, but the only callsite (banner) always uses the default. Remove the param.
Three-level nesting at init obscures the gate logic
packages/analytics/src/providers/PostHogProvider.tsx
isReplayAllowedWithConsent(isSessionReplayEnabled(sessionReplay, SESSION_REPLAY_ENABLED_FOR_ENV), requireConsent, readStoredConsent()) — name the intermediate result.
No focus management after banner dismiss — keyboard/screen-reader UX gap
apps/web/src/components/ConsentBanner.tsx
accept/reject are unnecessary arrow wrapper functions
apps/web/src/components/ConsentBanner.tsx
shouldShowConsentBannerOnServer literal return type :false is intentional and correct
seo7
Privacy page metadata description is too short (71 chars vs 150–160 target)
apps/web/src/app/(marketing)/legal/privacidad/page.tsx:9
Expand to ~155 chars mentioning PostHog/session recording.
metadataBase env-var fallback chain should be confirmed for production
apps/web/src/app/layout.tsx
SSR/CSR split is correct — no CLS or crawler impact
Heading hierarchy on privacidad page is valid
Internal link from ConsentBanner to /legal/privacidad is good for users
sitemap.ts already includes /legal/privacidad — no update needed
lang='es' correctly set on <html>
History · 6 commits
- 8c748c7safeincremental0H · 0M · 2L2026-08-01 00:40
- 22fc175safeincremental0H · 0M · 4L2026-08-01 00:30
- f26dee1needs attentionincremental2H · 2M · 9L2026-08-01 00:24
- bf92308needs attentionincremental2H · 8M · 8L2026-07-31 23:56
- cf3e330needs attentionincremental0H · 6M · 5L2026-07-31 23:17
- 5fb4d78blockedfull4H · 8M · 9L2026-07-31 22:11current