← all branches

feat/ph-consent

blockedviewing older commit
5fb4d78 · fullpre-PRreviewed 2026-07-31 22:11 UTC4H · 8M · 9L · 7I
The branch
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
The changes (whole branch)
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+1130packages/analytics/src/providers/PostHogProvider.tsx+657apps/web/src/components/ConsentBanner.tsx+790apps/web/src/app/(marketing)/legal/privacidad/page.tsx+300apps/web/src/app/layout.tsx+61packages/analytics/src/index.ts+245packages/analytics/src/providers/__tests__/session-replay.test.ts+370
Blast
7 files, +354/−13. Shared analytics package affects all apps; apps/web root layout affects all public pages. apps/platform structurally unaffected.
CI· No gh auth on runnerCodeRabbit· No .coderabbit.yaml in repotypecheck· Not run in this review passcorrectness· CRITICAL: consent gate not wired into posthog.init()security· Event capture ungated + no revocation UIconventions· ConsentBanner location + function cohesiontests· localStorage layer + banner component untestedimprovement· Misleading API param + a11y focus gapseo· Privacy page description too short

Findings · 30

correctness3

critical

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.

medium

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.

low

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

high

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.

high

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.

medium

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.

medium

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.

low

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.

info

maskAllInputs:true correctly applies to consent-gated replay

conventions4

medium

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.

medium

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.

low

grantAnalyticsConsent sessionReplay param leaks internal gate logic into the public consent API

packages/analytics/src/providers/PostHogProvider.tsx

low

Several JSDoc comments explain WHAT rather than WHY

packages/analytics/src/providers/session-replay.ts

tests5

high

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.

high

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.

medium

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.

medium

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.

low

ANALYTICS_CONSENT_STORAGE_KEY constant not pin-tested

packages/analytics/src/providers/session-replay.ts

improvement5

medium

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.

medium

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.

low

No focus management after banner dismiss — keyboard/screen-reader UX gap

apps/web/src/components/ConsentBanner.tsx

low

accept/reject are unnecessary arrow wrapper functions

apps/web/src/components/ConsentBanner.tsx

info

shouldShowConsentBannerOnServer literal return type :false is intentional and correct

seo7

low

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.

low

metadataBase env-var fallback chain should be confirmed for production

apps/web/src/app/layout.tsx

info

SSR/CSR split is correct — no CLS or crawler impact

info

Heading hierarchy on privacidad page is valid

info

Internal link from ConsentBanner to /legal/privacidad is good for users

info

sitemap.ts already includes /legal/privacidad — no update needed

info

lang='es' correctly set on <html>

History · 6 commits

  1. 8c748c7safeincremental0H · 0M · 2L2026-08-01 00:40
  2. 22fc175safeincremental0H · 0M · 4L2026-08-01 00:30
  3. f26dee1needs attentionincremental2H · 2M · 9L2026-08-01 00:24
  4. bf92308needs attentionincremental2H · 8M · 8L2026-07-31 23:56
  5. cf3e330needs attentionincremental0H · 6M · 5L2026-07-31 23:17
  6. 5fb4d78blockedfull4H · 8M · 9L2026-07-31 22:11current