Skip to content

Commit 0dea851

Browse files
0xgautamclaude
andcommitted
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 <noreply@anthropic.com>
1 parent 0dfbb41 commit 0dea851

15 files changed

Lines changed: 419 additions & 128 deletions

‎CLAUDE.md‎

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -87,15 +87,23 @@ Forms whose Privacy settings put a category on "On consent" load no scripts for
8787
it until the host page reports the visitor's answer:
8888

8989
```js
90-
window.SurfaceSetConsent({ adTracking: true, surfaceAnalytics: true });
90+
window.SurfaceSetConsent({ adTracking: true, surfaceAnalytics: true, cookieTracking: true });
9191
```
9292

9393
`consent.ts` holds the answer in module state and notifies `src/index.ts`, which
9494
relays `surface:consent` to every Surface iframe (and re-sends it on each
9595
`SEND_DATA` handshake, for forms that mount after the banner was answered).
96-
Omitted categories count as not granted. The categories mirror the form-render
97-
gate in `surface_forms` (`lib/client/thirdParty/`) — keep the message shape in
98-
sync with its `hostConsent.ts`.
96+
Every call is a complete snapshot: omitted categories count as not granted. The
97+
categories mirror the form-render gate in `surface_forms`
98+
(`lib/client/thirdParty/`) — keep the message shape in sync with its
99+
`hostConsent.ts`.
100+
101+
`cookieTracking` also gates the tag's own host-side work, but only when the
102+
`<script>` carries `data-consent-mode` (read in `runtime-config.ts`). Until that
103+
page grants it, `SurfaceStore` skips identify, the `surfaceLeadData` cache, the
104+
journey cookies and forwards an empty cookie snapshot; `applyConsent()` starts
105+
them on a grant and clears them on withdrawal. Without the attribute nothing
106+
changes for existing installs.
99107

100108
### Key APIs
101109

‎README.md‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,3 +68,24 @@ See [docs](https://docs.withsurface.com/docs/surface-tag/installation) for integ
6868
3. Identity API call completes in <0.5s on slow 4G; no issues if blocked/failed
6969
4. PostMessage to iframe: query params, prefilled email, cookies, URL/origin/referrer
7070
5. Form loading speed on withsurface.com
71+
72+
## Cookie consent
73+
74+
Pages that run a consent banner load the tag with `data-consent-mode` and report
75+
the visitor's answer, in full, on every load and every change:
76+
77+
```html
78+
<script
79+
src="https://cdn.jsdelivr.net/.../surface_tag.min.js"
80+
site-id="your-environment-id"
81+
data-consent-mode>
82+
</script>
83+
<script>
84+
window.SurfaceSetConsent({ adTracking: true, surfaceAnalytics: true, cookieTracking: true });
85+
</script>
86+
```
87+
88+
With the attribute, the tag does no visitor recognition, sets no journey cookies
89+
and forwards no page cookies to Surface forms until `cookieTracking` is granted.
90+
Form rendering and submission work regardless. Without the attribute the tag
91+
behaves exactly as before. See `CLAUDE.md` for the message contract.

‎src/consent/consent.test.ts‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,9 +20,16 @@ describe("surface consent", () => {
2020
expect(getSurfaceConsent()).toEqual({
2121
adTracking: true,
2222
surfaceAnalytics: false,
23+
cookieTracking: false,
2324
});
2425
});
2526

27+
it("treats each answer as a complete snapshot, so an older two-field call denies cookies", () => {
28+
setSurfaceConsent({ adTracking: true, surfaceAnalytics: true, cookieTracking: true });
29+
setSurfaceConsent({ adTracking: true, surfaceAnalytics: true });
30+
expect(getSurfaceConsent()?.cookieTracking).toBe(false);
31+
});
32+
2633
it("ignores non-boolean values", () => {
2734
setSurfaceConsent({ adTracking: "yes" as unknown as boolean });
2835
expect(getSurfaceConsent()?.adTracking).toBe(false);
@@ -34,6 +41,7 @@ describe("surface consent", () => {
3441
expect(getSurfaceConsent()).toEqual({
3542
adTracking: false,
3643
surfaceAnalytics: true,
44+
cookieTracking: false,
3745
});
3846
});
3947

‎src/consent/consent.ts‎

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,18 @@
33
export const SURFACE_CONSENT_MESSAGE_TYPE = "surface:consent";
44

55
/**
6-
* Categories of third-party calls a Surface form can be told to wait for. They
6+
* Categories of optional tracking a Surface form can be told to wait for. They
77
* mirror the form's Privacy settings: a category set to "On consent" there stays
88
* off until this page reports it as granted.
9+
*
10+
* `cookieTracking` also gates this tag's own host-side work — visitor
11+
* recognition, the journey cookies and forwarding the page's cookies — when the
12+
* script is loaded with `data-consent-mode`.
913
*/
1014
export interface SurfaceConsent {
1115
adTracking: boolean;
1216
surfaceAnalytics: boolean;
17+
cookieTracking: boolean;
1318
}
1419

1520
let consent: SurfaceConsent | null = null;
@@ -23,19 +28,22 @@ export const onSurfaceConsentChange = (callback: () => void): void => {
2328
};
2429

2530
/**
26-
* Public API — call from a consent banner once the visitor answers:
31+
* Public API — call from a consent banner once the visitor answers, and again
32+
* whenever the answer changes:
2733
*
2834
* ```js
29-
* window.SurfaceSetConsent({ adTracking: true, surfaceAnalytics: true });
35+
* window.SurfaceSetConsent({ adTracking: true, surfaceAnalytics: true, cookieTracking: true });
3036
* ```
3137
*
32-
* Omitted categories count as not granted. Calling again with `false` stops
33-
* further tracking, but cannot unload vendor scripts a form already started.
38+
* Every call is a complete snapshot: omitted categories count as not granted.
39+
* Calling again with `false` stops further tracking, but cannot unload vendor
40+
* scripts a form already started.
3441
*/
3542
export const setSurfaceConsent = (granted: Partial<SurfaceConsent>): void => {
3643
consent = {
3744
adTracking: granted?.adTracking === true,
3845
surfaceAnalytics: granted?.surfaceAnalytics === true,
46+
cookieTracking: granted?.cookieTracking === true,
3947
};
4048
onChange?.();
4149
};

‎src/index.ts‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,8 +34,10 @@ w.SurfaceSetConsent = setSurfaceConsent;
3434

3535
// Relay a consent answer to the forms on the page. The store push goes with it
3636
// so a form that was blocked until now still gets the parent URL params it
37-
// needs to fire conversions in first-party context.
37+
// needs to fire conversions in first-party context. Under data-consent-mode the
38+
// tag's own recognition and journey work start or stop here too.
3839
onSurfaceConsentChange(() => {
40+
SurfaceTagStore.applyConsent();
3941
SurfaceTagStore.sendConsentToIframes();
4042
SurfaceTagStore.sendPayloadToIframes("STORE_UPDATE");
4143
});

‎src/lead/identify.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,10 @@ export function setLeadDataWithTTL(data: Omit<LeadData, "expiry">): void {
2929
localStorage.setItem("surfaceLeadData", JSON.stringify(item));
3030
}
3131

32+
export function clearLeadData(): void {
33+
localStorage.removeItem("surfaceLeadData");
34+
}
35+
3236
export function getLeadDataWithTTL(): LeadData | null {
3337
const itemStr = localStorage.getItem("surfaceLeadData");
3438
if (!itemStr) return null;

‎src/runtime-config.ts‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,18 @@ import {
66
} from "./constants";
77

88
export const CUSTOM_DOMAIN_ATTRIBUTE = "data-custom-domain";
9+
// Present on the <script> when the page's cookie banner will call
10+
// SurfaceSetConsent: the tag then does no visitor recognition, journey cookies
11+
// or cookie forwarding until `cookieTracking` is granted.
12+
export const CONSENT_MODE_ATTRIBUTE = "data-consent-mode";
913

1014
export interface SurfaceRuntimeConfig {
1115
apiBaseUrl: string;
1216
leadIdentifyApi: string;
1317
userJourneyTrackingApi: string;
1418
surfaceDomains: readonly string[];
1519
customOrigin: string | null;
20+
waitForCookieConsent: boolean;
1621
}
1722

1823
export const DEFAULT_SURFACE_RUNTIME_CONFIG: SurfaceRuntimeConfig = {
@@ -21,6 +26,7 @@ export const DEFAULT_SURFACE_RUNTIME_CONFIG: SurfaceRuntimeConfig = {
2126
userJourneyTrackingApi: USER_JOURNEY_TRACKING_API,
2227
surfaceDomains: SURFACE_DOMAINS,
2328
customOrigin: null,
29+
waitForCookieConsent: false,
2430
};
2531

2632
let runtimeConfig = DEFAULT_SURFACE_RUNTIME_CONFIG;
@@ -51,10 +57,11 @@ function normalizeCustomOrigin(value: string): string | null {
5157
export function resolveSurfaceRuntimeConfig(
5258
scriptElement: HTMLScriptElement | null
5359
): SurfaceRuntimeConfig {
60+
const waitForCookieConsent = scriptElement?.hasAttribute(CONSENT_MODE_ATTRIBUTE) ?? false;
5461
const customOrigin = normalizeCustomOrigin(
5562
scriptElement?.getAttribute(CUSTOM_DOMAIN_ATTRIBUTE) ?? ""
5663
);
57-
if (!customOrigin) return DEFAULT_SURFACE_RUNTIME_CONFIG;
64+
if (!customOrigin) return { ...DEFAULT_SURFACE_RUNTIME_CONFIG, waitForCookieConsent };
5865

5966
const apiBaseUrl = `${customOrigin}/api/v1`;
6067
return {
@@ -63,6 +70,7 @@ export function resolveSurfaceRuntimeConfig(
6370
userJourneyTrackingApi: `${apiBaseUrl}/lead/track`,
6471
surfaceDomains: Array.from(new Set([...SURFACE_DOMAINS, customOrigin])),
6572
customOrigin,
73+
waitForCookieConsent,
6674
};
6775
}
6876

‎src/store/message-listener.test.ts‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ const makeStore = () =>
1818
sendPayloadToIframes: vi.fn(),
1919
sendConsentToIframes: vi.fn(),
2020
clearUserJourney: vi.fn(),
21+
cookieTrackingAllowed: vi.fn(() => true),
2122
log: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
2223
}) as unknown as SurfaceStore;
2324

@@ -73,6 +74,19 @@ describe("initializeMessageListener", () => {
7374
expect(store.sendPayloadToIframes).toHaveBeenLastCalledWith("LEAD_DATA_UPDATE");
7475
});
7576

77+
it("under consent mode without a cookie grant: pushes LEAD_DATA_UPDATE immediately, never identifies", () => {
78+
vi.mocked(getEnvironmentId).mockReturnValue("env_123");
79+
const store = makeStore();
80+
vi.mocked(store.cookieTrackingAllowed).mockReturnValue(false);
81+
initializeMessageListener(store);
82+
83+
dispatch({ type: "SEND_DATA", sender: "surface_form" });
84+
85+
// Listeners from earlier cases are still attached, so only this store's pushes are asserted.
86+
const types = vi.mocked(store.sendPayloadToIframes).mock.calls.map((c) => c[0]);
87+
expect(types).toEqual(["STORE_UPDATE", "LEAD_DATA_UPDATE"]);
88+
});
89+
7690
it("without an environment id: pushes LEAD_DATA_UPDATE immediately, never identifies", () => {
7791
vi.mocked(getEnvironmentId).mockReturnValue(null);
7892
const store = makeStore();

‎src/store/message-listener.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ export function initializeMessageListener(store: SurfaceStore): void {
2121
store.sendConsentToIframes();
2222

2323
const envId = getEnvironmentId();
24-
if (envId) {
24+
if (envId && store.cookieTrackingAllowed()) {
2525
const identify = store.config?.customOrigin
2626
? identifyLead(envId, store.config)
2727
: identifyLead(envId);

‎src/store/store.test.ts‎

Lines changed: 76 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,12 @@
11
import { describe, it, expect, vi, beforeEach, afterEach, type MockInstance } from "vitest";
22
import { SurfaceStore } from "./store";
3-
import { identifyLead, getLeadDataWithTTL } from "../lead/identify";
4-
import { initializeUserJourneyTracking, updateUserJourneyOnRouteChange } from "./user-journey";
3+
import { identifyLead, getLeadDataWithTTL, clearLeadData } from "../lead/identify";
4+
import {
5+
initializeUserJourneyTracking,
6+
updateUserJourneyOnRouteChange,
7+
clearUserJourney,
8+
} from "./user-journey";
9+
import { DEFAULT_SURFACE_RUNTIME_CONFIG } from "../runtime-config";
510
import { onRouteChange } from "../utils/route-observer";
611
import type { LeadData } from "../types";
712
import { setSurfaceConsent } from "../consent/consent";
@@ -13,6 +18,7 @@ vi.mock("../lead/identify", () => ({
1318
identifyLead: vi.fn(async () => null),
1419
getLeadDataWithTTL: vi.fn((): LeadData | null => null),
1520
isIdentifyInProgress: vi.fn(() => false),
21+
clearLeadData: vi.fn(),
1622
}));
1723
vi.mock("./user-journey", () => ({
1824
initializeUserJourneyTracking: vi.fn(),
@@ -203,10 +209,77 @@ describe("SurfaceStore postMessage protocol", () => {
203209
{
204210
type: "surface:consent",
205211
sender: "surface_tag",
206-
consent: { adTracking: true, surfaceAnalytics: false },
212+
consent: { adTracking: true, surfaceAnalytics: false, cookieTracking: false },
207213
},
208214
"https://forms.withsurface.com"
209215
);
210216
expect(otherPost).not.toHaveBeenCalled();
211217
});
212218
});
219+
220+
// `data-consent-mode` on the script: the page's banner owns cookie consent, so
221+
// the tag does no visitor recognition or journey work until it hears a grant.
222+
describe("SurfaceStore under data-consent-mode", () => {
223+
const consentModeConfig = { ...DEFAULT_SURFACE_RUNTIME_CONFIG, waitForCookieConsent: true };
224+
225+
beforeEach(() => {
226+
vi.clearAllMocks();
227+
vi.useFakeTimers();
228+
document.body.innerHTML = "";
229+
document.cookie = "hubspotutk=abc";
230+
setSurfaceConsent({});
231+
addIframe(SURFACE_IFRAME_SRC);
232+
});
233+
234+
afterEach(() => {
235+
vi.useRealTimers();
236+
document.cookie = "hubspotutk=; max-age=0";
237+
});
238+
239+
it("before a grant: no journey, no identify, no lead cache read, and an empty cookie snapshot", async () => {
240+
const store = new SurfaceStore("env_123", consentModeConfig);
241+
const pushes = vi.spyOn(store, "sendPayloadToIframes");
242+
243+
await vi.runAllTimersAsync();
244+
245+
expect(initializeUserJourneyTracking).not.toHaveBeenCalled();
246+
expect(identifyLead).not.toHaveBeenCalled();
247+
expect(getLeadDataWithTTL).not.toHaveBeenCalled();
248+
// The frame still gets its handshake so it can identify without recognition.
249+
expect(pushedTypes(pushes)).toEqual(["STORE_UPDATE", "LEAD_DATA_UPDATE"]);
250+
expect(store.getPayload()).toMatchObject({ cookies: {}, surfaceLeadData: null, userJourneyId: null });
251+
});
252+
253+
it("a cookie grant starts the journey, identifies and forwards cookies; withdrawal clears them again", async () => {
254+
const store = new SurfaceStore("env_123", consentModeConfig);
255+
await vi.runAllTimersAsync();
256+
257+
setSurfaceConsent({ cookieTracking: true });
258+
store.applyConsent();
259+
await vi.runAllTimersAsync();
260+
261+
expect(initializeUserJourneyTracking).toHaveBeenCalledTimes(1);
262+
expect(identifyLead).toHaveBeenCalledWith("env_123");
263+
expect(store.getPayload().cookies).toEqual({ hubspotutk: "abc" });
264+
265+
setSurfaceConsent({ cookieTracking: false });
266+
store.applyConsent();
267+
268+
expect(clearUserJourney).toHaveBeenCalledTimes(1);
269+
expect(clearLeadData).toHaveBeenCalledTimes(1);
270+
expect(store.getPayload()).toMatchObject({ cookies: {}, surfaceLeadData: null });
271+
272+
// Route changes keep pushing the store but no longer touch the journey.
273+
capturedRouteChangeCallback()("http://localhost:3000/next-page");
274+
expect(updateUserJourneyOnRouteChange).not.toHaveBeenCalled();
275+
});
276+
277+
it("without the attribute the tag behaves as before, whatever the page reports", async () => {
278+
const store = new SurfaceStore("env_123");
279+
await vi.runAllTimersAsync();
280+
281+
expect(initializeUserJourneyTracking).toHaveBeenCalledTimes(1);
282+
expect(identifyLead).toHaveBeenCalledWith("env_123");
283+
expect(store.getPayload().cookies).toEqual({ hubspotutk: "abc" });
284+
});
285+
});

0 commit comments

Comments
 (0)