Skip to content

feat(consent): cookie consent banner and cookie policy - #6832

Merged
waleedlatif1 merged 5 commits into
stagingfrom
feat/cookie-consent-banner
Aug 19, 2026
Merged

feat(consent): cookie consent banner and cookie policy#6832
waleedlatif1 merged 5 commits into
stagingfrom
feat/cookie-consent-banner

Conversation

@waleedlatif1

Copy link
Copy Markdown
Collaborator

Summary

Adds a cookie consent banner (c15t, hosted mode) and a /cookie-policy page. Both are hosted-only: the root layout renders the runtime behind isHosted, alongside the existing GTM/GA gates, so a self-hosted deployment never fetches the chunk, never reaches Sim's consent backend, and never sees the banner.

Banner

A non-modal card docked bottom-left, opposite the toast stack and wearing the same chrome. It never dims, blocks, or reflows the page; "Customize" expands the same card into per-category switches rather than opening a dialog over the app. Built from Chip/Switch/Label — the runtime is imported from @c15t/nextjs/headless, which leaves c15t's own components and stylesheet out of the bundle entirely.

Visibility and the available actions come from the jurisdiction policy the runtime resolves, so the banner is absent where no consent is required (verified: a US visitor gets ui.mode: 'none', a DE/GB visitor gets the banner). Accept and reject render with identical weight on the first layer, which GDPR requires.

The CSP bug this uncovered

Our own CSP blocked connect-src to the consent backend, and c15t's failure mode for a blocked request is silent: it falls back to an offline policy that shows the banner to every visitor worldwide and records nothing. The backend origin now lives in lib/consent/constants and csp.ts allows it from that single source, so the two cannot drift. Verified end to end: 200 GET /init on load, 200 POST /subjects on accept.

Cookie policy

/cookie-policy is a third consumer of the shared ProsePage primitive alongside Terms and Privacy, so the three share a layout and cannot drift. The cookie inventory is rendered as tables via a new table LegalBlock kind. The inventory was enumerated empirically from a real page load rather than written from memory.

Cross-referenced from the Privacy Policy (whose lastUpdated is bumped), and added to the sitemap, llms.txt, and llms-full.txt. Not added to the footer, per request.

Type of Change

  • New feature
  • Documentation

Testing

  • bun run type-check, bun run lint:check, and all 29 check:audits pass. New unit test covers the runtime's configuration.
  • Driven in a real browser against the live consent instance: banner shown for DE, absent for US, /init and /subjects both 200, expand/save/reject/reopen all exercised.
  • Reviewers should focus on the copy in cookie-policy-content.tsx (it is legal text — check the retention periods and provider attributions against what we actually set) and on the light token-layer decision in consent-banner.tsx.

Known gaps (deliberately not in this PR)

  1. Prior blocking is not implemented. GTM, GA, HubSpot, X, and Ahrefs are hardcoded <Script> tags that still load before consent — measured on the landing page, _ga and five X cookies are set with the banner still on screen. This is the most-enforced ePrivacy requirement and needs its own PR, because fixing it changes production analytics collection.
  2. The inth instance declares only necessary as a purpose. scopeMode is permissive so our three categories still render, but the audit records won't carry measurement/marketing until they're added in the dashboard.
  3. No footer or settings entry point for cookie preferences — the Cookie Policy has one, but that page is intentionally not linked from the footer.

Checklist

  • Code follows project style guidelines
  • Self-reviewed my changes
  • Tests added/updated and passing
  • No new warnings introduced
  • I confirm that I have read and agree to the terms outlined in the Contributor License Agreement (CLA)

Adds a c15t-backed consent runtime and a Sim-styled banner, mounted from
the root layout only when `isHosted` is true. A self-hosted deployment
never mounts the runtime, so it makes no request to Sim's consent backend
and never sees the banner.

The banner is a non-modal card docked bottom-left, opposite the toast
stack, built from the same chrome (border, --bg, --shadow-overlay) and
from Chip/Switch/Label rather than c15t's own components — the runtime is
imported from `@c15t/nextjs/headless`, which ships no UI or stylesheet.
"Customize" expands the same card into per-category switches instead of
opening a dialog over the app. Visibility and the available actions come
from the jurisdiction policy the runtime resolves, and accept and reject
are rendered with identical weight.
…lignment

The consent backend was blocked by our own CSP, so the runtime silently fell
back to an offline policy that showed the banner to every visitor worldwide and
recorded nothing. The backend origin now lives in lib/consent/constants and the
CSP builder allows it from that single source.

Banner: mount the runtime beside the app rather than wrapping it, behind a
dynamic() boundary, so consent state cannot re-render the page tree and a
self-hosted build never fetches the chunk. Align chrome with the toast card
(z token, font scale, --text-body/--text-muted pairing) and mirror the light
token layer the public shells pin, which a dark-theme visitor on a landing
route outside ThemeProvider's forced list would otherwise miss. Read the
category list from the store's own getDisplayedConsents() — the shipped
defaults mark every category except necessary as display:false, so the
hand-rolled filter rendered a one-row list.

Docs: add /cookie-policy as a third ProsePage consumer with the cookie
inventory in tables (a new table block kind on the shared primitive),
cross-reference it from the Privacy Policy, and wire it into the sitemap and
llms.txt. The policy promises consent can be changed at any time, so the
banner can be reopened from it.
- Drop the .light DOM probe: it matched the banner's own element, so once set
  it could never flip back, and it went stale on a theme toggle with no
  navigation. The card now pins the light layer unconditionally, as every other
  public surface does.
- Hoist the motion/style objects to module scope.
- Move a chip's mr-auto into the row layout; chips carry no outer margin.
- Use the shadow-overlay utility and --border rather than the legacy alias.
- Render <caption> before <colgroup>, which the HTML spec requires.
- Make the code formatting of a table column a renderer concern (codeColumns)
  instead of JSX smuggled into the row content.
- Raise the table caption above body weight, and tighten comments.
The consent runtime installs a childList+subtree MutationObserver on
document.body for its iframe blocker, for the life of every hosted page —
including the workflow canvas — and re-scans each added subtree. Sim gates no
iframes by consent, so disableAutomaticBlocking turns it off.

Also: collapse the ConsentProvider passthrough into the dynamic() export; move
ConsentPreferencesLink under (landing)/cookie-policy so a shell module no
longer imports landing chrome; build the three cookie tables from one shape;
move the table column widths into the prose chrome layer; only compute the
category list when the card is expanded; drop the ConsentCategory cast; express
the card width in Tailwind rather than an inline style.

Comment corrections: the sibling mount is forced by ssr:false, not by
re-render concerns; lib/consent/constants must stay dependency-free because
next.config loads it and the browser bundles it; codeColumns exists for
biome's useJsxKeyInIterable, not for React; the headless entry omits the
components but the provider still injects an inert --c15t-* style block.
@waleedlatif1
waleedlatif1 requested a review from a team as a code owner August 18, 2026 23:55
@vercel

vercel Bot commented Aug 18, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
docs Skipped Skipped Aug 19, 2026 12:03am

Request Review

@cursor

cursor Bot commented Aug 18, 2026

Copy link
Copy Markdown

PR Summary

Medium Risk
Touches global layout, CSP connect-src, and consent/geo behavior on all hosted traffic; legal copy and retention tables need accuracy review. Analytics blocking is not implemented yet, so compliance gap remains until a follow-up.

Overview
Adds hosted-only cookie consent via @c15t/nextjs (headless): a bottom-left non-modal banner with equal-weight accept/reject, expandable per-category switches, and a window event so the Cookie Policy can reopen preferences. The runtime is client-only (ssr: false) and gated by isHosted in the root layout; iframe auto-blocking is disabled to avoid a global MutationObserver on the canvas.

Introduces /cookie-policy as another ProsePage consumer, with a new table LegalBlock kind (shared prose tokens + fixed column widths for aligned cookie inventory tables). Privacy Policy is cross-linked and lastUpdated bumped; sitemap, llms.txt, and landing route/COEP exemptions are updated.

CSP now allows connect-src to the consent backend from a single constant in lib/consent/constants so a blocked init call cannot silently show the banner worldwide.

Note: GTM/GA and other tags still load before consent in this PR; blocking is explicitly deferred.

Reviewed by Cursor Bugbot for commit 982474b. Configure here.

@greptile-apps

greptile-apps Bot commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

Adds a hosted-only cookie-consent runtime and banner, a cookie-policy page backed by the shared legal-page components, and the CSP/configuration needed to reach the consent backend.

  • Adds customizable consent controls and a hosted-only provider gate.
  • Adds cookie inventory tables, policy content, metadata, sitemap, and LLM-text entries.
  • Updates the privacy policy to reference the new cookie policy.
  • Centralizes the consent backend origin for runtime and CSP configuration.

Confidence Score: 5/5

The PR appears safe to merge.

No blocking failure remains; both previously reported issues are resolved in the current code.

Important Files Changed

Filename Overview
apps/sim/app/_shell/consent/consent-banner.tsx Implements the headless, jurisdiction-aware consent banner and preference controls.
apps/sim/app/_shell/consent/consent-runtime.tsx Configures and mounts the hosted consent runtime.
apps/sim/app/layout.tsx Gates the consent provider behind the existing hosted-deployment condition.
apps/sim/app/(landing)/cookie-policy/cookie-policy-content.tsx Defines the cookie-policy copy and conditionally exposes preference controls only where the runtime exists.
apps/sim/app/(landing)/cookie-policy/consent-preferences-link.tsx Dispatches the preference-opening event and now follows the required named-props-interface convention.
apps/sim/lib/core/security/csp.ts Allows connections to the centralized consent backend origin.
apps/sim/app/(landing)/components/prose-page/components/legal-block-group/components/legal-block/legal-block.tsx Extends the shared legal-page renderer with accessible reference tables.

Sequence Diagram

sequenceDiagram
  participant Browser
  participant Layout as Root Layout
  participant Runtime as Consent Runtime
  participant Backend as Consent Backend
  participant Banner as Consent Banner
  Browser->>Layout: Load hosted Sim deployment
  Layout->>Runtime: Mount ConsentProvider when isHosted
  Runtime->>Backend: Resolve jurisdiction policy
  Backend-->>Runtime: UI mode and allowed actions
  Runtime-->>Banner: Show or suppress consent UI
  Browser->>Banner: Accept, reject, or customize
  Banner->>Runtime: Perform consent action
  Runtime->>Backend: Record subject preferences
Loading

Reviews (2): Last reviewed commit: "fix(consent): address the first review r..." | Re-trigger Greptile

Comment thread apps/sim/app/(landing)/cookie-policy/consent-preferences-link.tsx
Comment thread apps/sim/app/(landing)/cookie-policy/consent-preferences-link.tsx Outdated
Comment thread apps/sim/app/(landing)/cookie-policy/page.tsx
- Add /cookie-policy to LANDING_ROUTES. It is an app/(landing) route, and
  every one of those must be exempt from COEP: the header is inherited across
  soft navigations, so an isolated landing page navigating into /demo leaves
  the Cal.com booker loading uncredentialed.
- Render the withdrawal control as plain text on a self-hosted deployment,
  where the consent runtime is never mounted and the button had no listener.
- Give ConsentPreferencesLink a named props interface.
@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@greptile

@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@cursor review

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Bugbot reviewed your changes and found no new issues!

Comment @cursor review or bugbot run to trigger another review on this PR

Reviewed by Cursor Bugbot for commit 982474b. Configure here.

@waleedlatif1
waleedlatif1 merged commit b446b42 into staging Aug 19, 2026
30 checks passed
@waleedlatif1
waleedlatif1 deleted the feat/cookie-consent-banner branch August 19, 2026 00:08
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant