From 0dea85166c10edcb2823b418d03b1d233dd49295 Mon Sep 17 00:00:00 2001 From: Gautam Ahuja Date: Mon, 7 Sep 2026 01:56:21 +0530 Subject: [PATCH 1/2] feat(consent): gate host-side visitor recognition on cookieTracking consent Adds `cookieTracking` as a third category of `SurfaceSetConsent`, mirroring the new Cookie Tracking privacy control in Forms (trysurface/surface_forms#5811). Every call is a complete snapshot, so an older two-field call denies cookies. Pages that load the tag with `data-consent-mode` get a tag that does no visitor recognition until that category is granted: no identify or fingerprint, no `surfaceLeadData` cache read/write, no journey cookies or page-view beacons, and an empty cookie snapshot in STORE_UPDATE. A grant starts all of it; withdrawal clears the journey cookies and lead cache. Frames still receive their handshake so forms render and submit as before. Without the attribute nothing changes for existing installs. Co-Authored-By: Claude Fable 5.1 --- CLAUDE.md | 16 +++- README.md | 21 +++++ src/consent/consent.test.ts | 8 ++ src/consent/consent.ts | 18 +++-- src/index.ts | 4 +- src/lead/identify.ts | 4 + src/runtime-config.ts | 10 ++- src/store/message-listener.test.ts | 14 ++++ src/store/message-listener.ts | 2 +- src/store/store.test.ts | 79 +++++++++++++++++- src/store/store.ts | 123 +++++++++++++++++++---------- surface_embed_v1.js | 116 +++++++++++++++++++-------- surface_tag.js | 116 +++++++++++++++++++-------- test/consent.html | 4 + test/consent.js | 12 ++- 15 files changed, 419 insertions(+), 128 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index cdc3614..85a63f7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -87,15 +87,23 @@ Forms whose Privacy settings put a category on "On consent" load no scripts for it until the host page reports the visitor's answer: ```js -window.SurfaceSetConsent({ adTracking: true, surfaceAnalytics: true }); +window.SurfaceSetConsent({ adTracking: true, surfaceAnalytics: true, cookieTracking: true }); ``` `consent.ts` holds the answer in module state and notifies `src/index.ts`, which relays `surface:consent` to every Surface iframe (and re-sends it on each `SEND_DATA` handshake, for forms that mount after the banner was answered). -Omitted categories count as not granted. The categories mirror the form-render -gate in `surface_forms` (`lib/client/thirdParty/`) — keep the message shape in -sync with its `hostConsent.ts`. +Every call is a complete snapshot: omitted categories count as not granted. The +categories mirror the form-render gate in `surface_forms` +(`lib/client/thirdParty/`) — keep the message shape in sync with its +`hostConsent.ts`. + +`cookieTracking` also gates the tag's own host-side work, but only when the +` + +``` + +With the attribute, the tag does no visitor recognition, sets no journey cookies +and forwards no page cookies to Surface forms until `cookieTracking` is granted. +Form rendering and submission work regardless. Without the attribute the tag +behaves exactly as before. See `CLAUDE.md` for the message contract. diff --git a/src/consent/consent.test.ts b/src/consent/consent.test.ts index 579d449..acd4bb6 100644 --- a/src/consent/consent.test.ts +++ b/src/consent/consent.test.ts @@ -20,9 +20,16 @@ describe("surface consent", () => { expect(getSurfaceConsent()).toEqual({ adTracking: true, surfaceAnalytics: false, + cookieTracking: false, }); }); + it("treats each answer as a complete snapshot, so an older two-field call denies cookies", () => { + setSurfaceConsent({ adTracking: true, surfaceAnalytics: true, cookieTracking: true }); + setSurfaceConsent({ adTracking: true, surfaceAnalytics: true }); + expect(getSurfaceConsent()?.cookieTracking).toBe(false); + }); + it("ignores non-boolean values", () => { setSurfaceConsent({ adTracking: "yes" as unknown as boolean }); expect(getSurfaceConsent()?.adTracking).toBe(false); @@ -34,6 +41,7 @@ describe("surface consent", () => { expect(getSurfaceConsent()).toEqual({ adTracking: false, surfaceAnalytics: true, + cookieTracking: false, }); }); diff --git a/src/consent/consent.ts b/src/consent/consent.ts index 9c8f39c..2c537c8 100644 --- a/src/consent/consent.ts +++ b/src/consent/consent.ts @@ -3,13 +3,18 @@ export const SURFACE_CONSENT_MESSAGE_TYPE = "surface:consent"; /** - * Categories of third-party calls a Surface form can be told to wait for. They + * Categories of optional tracking a Surface form can be told to wait for. They * mirror the form's Privacy settings: a category set to "On consent" there stays * off until this page reports it as granted. + * + * `cookieTracking` also gates this tag's own host-side work — visitor + * recognition, the journey cookies and forwarding the page's cookies — when the + * script is loaded with `data-consent-mode`. */ export interface SurfaceConsent { adTracking: boolean; surfaceAnalytics: boolean; + cookieTracking: boolean; } let consent: SurfaceConsent | null = null; @@ -23,19 +28,22 @@ export const onSurfaceConsentChange = (callback: () => void): void => { }; /** - * Public API — call from a consent banner once the visitor answers: + * Public API — call from a consent banner once the visitor answers, and again + * whenever the answer changes: * * ```js - * window.SurfaceSetConsent({ adTracking: true, surfaceAnalytics: true }); + * window.SurfaceSetConsent({ adTracking: true, surfaceAnalytics: true, cookieTracking: true }); * ``` * - * Omitted categories count as not granted. Calling again with `false` stops - * further tracking, but cannot unload vendor scripts a form already started. + * Every call is a complete snapshot: omitted categories count as not granted. + * Calling again with `false` stops further tracking, but cannot unload vendor + * scripts a form already started. */ export const setSurfaceConsent = (granted: Partial): void => { consent = { adTracking: granted?.adTracking === true, surfaceAnalytics: granted?.surfaceAnalytics === true, + cookieTracking: granted?.cookieTracking === true, }; onChange?.(); }; diff --git a/src/index.ts b/src/index.ts index 521ecf2..0d1fca2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -34,8 +34,10 @@ w.SurfaceSetConsent = setSurfaceConsent; // Relay a consent answer to the forms on the page. The store push goes with it // so a form that was blocked until now still gets the parent URL params it -// needs to fire conversions in first-party context. +// needs to fire conversions in first-party context. Under data-consent-mode the +// tag's own recognition and journey work start or stop here too. onSurfaceConsentChange(() => { + SurfaceTagStore.applyConsent(); SurfaceTagStore.sendConsentToIframes(); SurfaceTagStore.sendPayloadToIframes("STORE_UPDATE"); }); diff --git a/src/lead/identify.ts b/src/lead/identify.ts index b2b4c28..eb1278e 100644 --- a/src/lead/identify.ts +++ b/src/lead/identify.ts @@ -29,6 +29,10 @@ export function setLeadDataWithTTL(data: Omit): void { localStorage.setItem("surfaceLeadData", JSON.stringify(item)); } +export function clearLeadData(): void { + localStorage.removeItem("surfaceLeadData"); +} + export function getLeadDataWithTTL(): LeadData | null { const itemStr = localStorage.getItem("surfaceLeadData"); if (!itemStr) return null; diff --git a/src/runtime-config.ts b/src/runtime-config.ts index 2c36beb..dacfc05 100644 --- a/src/runtime-config.ts +++ b/src/runtime-config.ts @@ -6,6 +6,10 @@ import { } from "./constants"; export const CUSTOM_DOMAIN_ATTRIBUTE = "data-custom-domain"; +// Present on the