Skip to content

Commit 330b800

Browse files
committed
feat(consent): relay host consent to Surface form iframes
Forms whose Privacy settings put a category on "On consent" load no vendor scripts until the embedding page reports the visitor's answer. This adds the page-side half of that handshake. - `window.SurfaceSetConsent({ adTracking, surfaceAnalytics })` — call it from a consent banner. Omitted categories count as not granted; a later call can withdraw. - The answer is relayed as `surface:consent` to every Surface iframe (origin allowlist unchanged) and re-sent on each SEND_DATA handshake, so a form that mounts after the banner was answered still learns about it. - A store push rides along, so a form unblocked mid-session still gets the parent URL params it needs to fire conversions in first-party context.
1 parent 1ce8e5e commit 330b800

10 files changed

Lines changed: 277 additions & 14 deletions

File tree

‎CLAUDE.md‎

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -78,8 +78,24 @@ No automated tests, linter, or CI pipeline. Testing is manual and browser-based.
7878

7979
### PostMessage Protocol
8080

81-
- **To iframe:** `STORE_UPDATE` (cookies, URL params, partial fill data), `LEAD_DATA_UPDATE` (leadId, sessionId, fingerprint)
82-
- **From iframe:** `SEND_DATA` (iframe requests current store data)
81+
- **To iframe:** `STORE_UPDATE` (cookies, URL params, partial fill data), `LEAD_DATA_UPDATE` (leadId, sessionId, fingerprint), `surface:consent` (which third-party categories the visitor consented to)
82+
- **From iframe:** `SEND_DATA` (iframe requests current store data), `surface:conversion` (iframe asks the parent to fire an ad pixel first-party)
83+
84+
### Consent (`src/consent/`)
85+
86+
Forms whose Privacy settings put a category on "On consent" load no scripts for
87+
it until the host page reports the visitor's answer:
88+
89+
```js
90+
window.SurfaceSetConsent({ adTracking: true, surfaceAnalytics: true });
91+
```
92+
93+
`consent.ts` holds the answer in module state and notifies `src/index.ts`, which
94+
relays `surface:consent` to every Surface iframe (and re-sends it on each
95+
`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`.
8399

84100
### Key APIs
85101

‎src/consent/consent.test.ts‎

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import { describe, it, expect, vi, beforeEach } from "vitest";
2+
import {
3+
getSurfaceConsent,
4+
onSurfaceConsentChange,
5+
setSurfaceConsent,
6+
} from "./consent";
7+
8+
describe("surface consent", () => {
9+
beforeEach(() => {
10+
onSurfaceConsentChange(() => {});
11+
});
12+
13+
it("reports nothing granted until the page answers", () => {
14+
// Module state, so this only holds before the first setSurfaceConsent call.
15+
expect(getSurfaceConsent()).toBe(null);
16+
});
17+
18+
it("normalises a partial answer — omitted categories are not granted", () => {
19+
setSurfaceConsent({ adTracking: true });
20+
expect(getSurfaceConsent()).toEqual({
21+
adTracking: true,
22+
surfaceAnalytics: false,
23+
});
24+
});
25+
26+
it("ignores non-boolean values", () => {
27+
setSurfaceConsent({ adTracking: "yes" as unknown as boolean });
28+
expect(getSurfaceConsent()?.adTracking).toBe(false);
29+
});
30+
31+
it("lets a later answer withdraw consent", () => {
32+
setSurfaceConsent({ adTracking: true, surfaceAnalytics: true });
33+
setSurfaceConsent({ adTracking: false, surfaceAnalytics: true });
34+
expect(getSurfaceConsent()).toEqual({
35+
adTracking: false,
36+
surfaceAnalytics: true,
37+
});
38+
});
39+
40+
it("notifies the relay on every answer", () => {
41+
const onChange = vi.fn();
42+
onSurfaceConsentChange(onChange);
43+
44+
setSurfaceConsent({ adTracking: true });
45+
setSurfaceConsent({ adTracking: false });
46+
47+
expect(onChange).toHaveBeenCalledTimes(2);
48+
});
49+
});

‎src/consent/consent.ts‎

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
// Wire contract with the iframe (surface_forms form-render `hostConsent.ts`).
2+
// Keep in sync.
3+
export const SURFACE_CONSENT_MESSAGE_TYPE = "surface:consent";
4+
5+
/**
6+
* Categories of third-party calls a Surface form can be told to wait for. They
7+
* mirror the form's Privacy settings: a category set to "On consent" there stays
8+
* off until this page reports it as granted.
9+
*/
10+
export interface SurfaceConsent {
11+
adTracking: boolean;
12+
surfaceAnalytics: boolean;
13+
}
14+
15+
let consent: SurfaceConsent | null = null;
16+
let onChange: (() => void) | null = null;
17+
18+
/** Null until the page has answered — forms treat that as nothing granted. */
19+
export const getSurfaceConsent = (): SurfaceConsent | null => consent;
20+
21+
export const onSurfaceConsentChange = (callback: () => void): void => {
22+
onChange = callback;
23+
};
24+
25+
/**
26+
* Public API — call from a consent banner once the visitor answers:
27+
*
28+
* ```js
29+
* window.SurfaceSetConsent({ adTracking: true, surfaceAnalytics: true });
30+
* ```
31+
*
32+
* Omitted categories count as not granted. Calling again with `false` stops
33+
* further tracking, but cannot unload vendor scripts a form already started.
34+
*/
35+
export const setSurfaceConsent = (granted: Partial<SurfaceConsent>): void => {
36+
consent = {
37+
adTracking: granted?.adTracking === true,
38+
surfaceAnalytics: granted?.surfaceAnalytics === true,
39+
};
40+
onChange?.();
41+
};

‎src/index.ts‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
setEnvironmentId,
77
} from "./lead/identify";
88
import { SurfaceStore } from "./store/store";
9+
import { onSurfaceConsentChange, setSurfaceConsent } from "./consent/consent";
910
import { SurfaceExternalForm } from "./external-form/external-form";
1011
import { SurfaceEmbed } from "./embed/embed";
1112
import { resolveOpenTriggersOnLoad } from "./open-triggers/open-triggers";
@@ -29,6 +30,15 @@ w.SurfaceIdentifyLead = identifyLead;
2930
w.SurfaceSetLeadDataWithTTL = setLeadDataWithTTL;
3031
w.SurfaceGetLeadDataWithTTL = getLeadDataWithTTL;
3132
w.SurfaceGetSiteIdFromScript = getSiteIdFromScript;
33+
w.SurfaceSetConsent = setSurfaceConsent;
34+
35+
// Relay a consent answer to the forms on the page. The store push goes with it
36+
// so a form that was blocked until now still gets the parent URL params it
37+
// needs to fire conversions in first-party context.
38+
onSurfaceConsentChange(() => {
39+
SurfaceTagStore.sendConsentToIframes();
40+
SurfaceTagStore.sendPayloadToIframes("STORE_UPDATE");
41+
});
3242

3343
// Auto-open a form when the host URL carries a configured `?<slug>=true` param.
3444
// Fire-and-forget; only touches the network when params are present.

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

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ const FORMS_ORIGIN = "https://forms.withsurface.com";
1616
const makeStore = () =>
1717
({
1818
sendPayloadToIframes: vi.fn(),
19+
sendConsentToIframes: vi.fn(),
1920
clearUserJourney: vi.fn(),
2021
log: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
2122
}) as unknown as SurfaceStore;
@@ -48,6 +49,15 @@ describe("initializeMessageListener", () => {
4849
expect(store.sendPayloadToIframes).toHaveBeenCalledWith("STORE_UPDATE");
4950
});
5051

52+
it("re-sends consent on the handshake, so a late-mounting form learns the page's answer", () => {
53+
const store = makeStore();
54+
initializeMessageListener(store);
55+
56+
dispatch({ type: "SEND_DATA", sender: "surface_form" });
57+
58+
expect(store.sendConsentToIframes).toHaveBeenCalledTimes(1);
59+
});
60+
5161
it("with an environment id: pushes STORE_UPDATE, identifies, then pushes LEAD_DATA_UPDATE", async () => {
5262
vi.mocked(getEnvironmentId).mockReturnValue("env_123");
5363
const store = makeStore();

‎src/store/message-listener.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ export function initializeMessageListener(store: SurfaceStore): void {
1717

1818
if (event.data.type === "SEND_DATA") {
1919
store.sendPayloadToIframes("STORE_UPDATE");
20+
// A form that booted after the banner was answered learns consent here.
21+
store.sendConsentToIframes();
2022

2123
const envId = getEnvironmentId();
2224
if (envId) {

‎src/store/store.test.ts‎

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { identifyLead, getLeadDataWithTTL } from "../lead/identify";
44
import { initializeUserJourneyTracking, updateUserJourneyOnRouteChange } from "./user-journey";
55
import { onRouteChange } from "../utils/route-observer";
66
import type { LeadData } from "../types";
7+
import { setSurfaceConsent } from "../consent/consent";
78

89
vi.mock("./message-listener", () => ({
910
initializeMessageListener: vi.fn(),
@@ -179,4 +180,33 @@ describe("SurfaceStore postMessage protocol", () => {
179180
);
180181
expect(otherPost).not.toHaveBeenCalled();
181182
});
183+
184+
it("relays consent only to Surface iframes, and only once the page has answered", () => {
185+
const surfaceIframe = addIframe(SURFACE_IFRAME_SRC);
186+
const otherIframe = addIframe("https://example.com/embed");
187+
const store = new SurfaceStore(null);
188+
189+
const surfacePost = vi
190+
.spyOn(surfaceIframe.contentWindow as Window, "postMessage")
191+
.mockImplementation(() => {});
192+
const otherPost = vi
193+
.spyOn(otherIframe.contentWindow as Window, "postMessage")
194+
.mockImplementation(() => {});
195+
196+
store.sendConsentToIframes();
197+
expect(surfacePost).not.toHaveBeenCalled();
198+
199+
setSurfaceConsent({ adTracking: true });
200+
store.sendConsentToIframes();
201+
202+
expect(surfacePost).toHaveBeenCalledWith(
203+
{
204+
type: "surface:consent",
205+
sender: "surface_tag",
206+
consent: { adTracking: true, surfaceAnalytics: false },
207+
},
208+
"https://forms.withsurface.com"
209+
);
210+
expect(otherPost).not.toHaveBeenCalled();
211+
});
182212
});

‎src/store/store.ts‎

Lines changed: 29 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,8 @@
11
import { VALID_EMBED_TYPES } from "../constants";
2+
import {
3+
getSurfaceConsent,
4+
SURFACE_CONSENT_MESSAGE_TYPE,
5+
} from "../consent/consent";
26
import { isDebugMode } from "../utils/debug";
37
import { createLogger } from "../utils/logger";
48
import { parseCookies } from "../utils/cookies";
@@ -163,19 +167,40 @@ export class SurfaceStore {
163167
const target = iframe || document.querySelector<HTMLIFrameElement>("#surface-iframe");
164168
if (!target) return;
165169

170+
this.postToSurfaceIframe(target, {
171+
type,
172+
payload: this.getPayload(),
173+
sender: "surface_tag",
174+
});
175+
}
176+
177+
private postToSurfaceIframe(target: HTMLIFrameElement, message: unknown): void {
166178
try {
167179
const targetOrigin = new URL(target.src).origin;
168180
if (!this.surfaceDomains.includes(targetOrigin)) return;
169181

170-
target.contentWindow?.postMessage(
171-
{ type, payload: this.getPayload(), sender: "surface_tag" },
172-
targetOrigin
173-
);
182+
target.contentWindow?.postMessage(message, targetOrigin);
174183
} catch {
175184
// Ignore invalid iframe URLs.
176185
}
177186
}
178187

188+
// Relays the page's consent answer to every Surface form on it. Forms with a
189+
// category set to "On consent" stay dark until this arrives, so it is also
190+
// re-sent on each SEND_DATA handshake for frames that mount later.
191+
sendConsentToIframes(): void {
192+
const consent = getSurfaceConsent();
193+
if (!consent) return;
194+
195+
document.querySelectorAll("iframe").forEach((iframe) =>
196+
this.postToSurfaceIframe(iframe, {
197+
type: SURFACE_CONSENT_MESSAGE_TYPE,
198+
sender: "surface_tag",
199+
consent,
200+
})
201+
);
202+
}
203+
179204
getUrlParams(): Record<string, string> {
180205
return getUrlParams();
181206
}

‎surface_embed_v1.js‎

Lines changed: 44 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -216,6 +216,22 @@
216216
return null;
217217
}
218218

219+
// src/consent/consent.ts
220+
var SURFACE_CONSENT_MESSAGE_TYPE = "surface:consent";
221+
var consent = null;
222+
var onChange = null;
223+
var getSurfaceConsent = () => consent;
224+
var onSurfaceConsentChange = (callback) => {
225+
onChange = callback;
226+
};
227+
var setSurfaceConsent = (granted) => {
228+
consent = {
229+
adTracking: granted?.adTracking === true,
230+
surfaceAnalytics: granted?.surfaceAnalytics === true
231+
};
232+
onChange?.();
233+
};
234+
219235
// src/utils/debug.ts
220236
var cached = null;
221237
function isDebugMode() {
@@ -496,6 +512,7 @@
496512
}
497513
if (event.data.type === "SEND_DATA") {
498514
store.sendPayloadToIframes("STORE_UPDATE");
515+
store.sendConsentToIframes();
499516
const envId = getEnvironmentId();
500517
if (envId) {
501518
const identify = store.config?.customOrigin ? identifyLead(envId, store.config) : identifyLead(envId);
@@ -756,16 +773,34 @@
756773
notifyIframe(iframe, type) {
757774
const target = iframe || document.querySelector("#surface-iframe");
758775
if (!target) return;
776+
this.postToSurfaceIframe(target, {
777+
type,
778+
payload: this.getPayload(),
779+
sender: "surface_tag"
780+
});
781+
}
782+
postToSurfaceIframe(target, message) {
759783
try {
760784
const targetOrigin = new URL(target.src).origin;
761785
if (!this.surfaceDomains.includes(targetOrigin)) return;
762-
target.contentWindow?.postMessage(
763-
{ type, payload: this.getPayload(), sender: "surface_tag" },
764-
targetOrigin
765-
);
786+
target.contentWindow?.postMessage(message, targetOrigin);
766787
} catch {
767788
}
768789
}
790+
// Relays the page's consent answer to every Surface form on it. Forms with a
791+
// category set to "On consent" stay dark until this arrives, so it is also
792+
// re-sent on each SEND_DATA handshake for frames that mount later.
793+
sendConsentToIframes() {
794+
const consent2 = getSurfaceConsent();
795+
if (!consent2) return;
796+
document.querySelectorAll("iframe").forEach(
797+
(iframe) => this.postToSurfaceIframe(iframe, {
798+
type: SURFACE_CONSENT_MESSAGE_TYPE,
799+
sender: "surface_tag",
800+
consent: consent2
801+
})
802+
);
803+
}
769804
getUrlParams() {
770805
return getUrlParams();
771806
}
@@ -2473,6 +2508,11 @@
24732508
w2.SurfaceSetLeadDataWithTTL = setLeadDataWithTTL;
24742509
w2.SurfaceGetLeadDataWithTTL = getLeadDataWithTTL;
24752510
w2.SurfaceGetSiteIdFromScript = getSiteIdFromScript;
2511+
w2.SurfaceSetConsent = setSurfaceConsent;
2512+
onSurfaceConsentChange(() => {
2513+
SurfaceTagStore.sendConsentToIframes();
2514+
SurfaceTagStore.sendPayloadToIframes("STORE_UPDATE");
2515+
});
24762516
void resolveOpenTriggersOnLoad(environmentId2, runtimeConfig2);
24772517
initReview();
24782518
})();

0 commit comments

Comments
 (0)