From 330b800fca3759e33a650e57a4208de9be471b58 Mon Sep 17 00:00:00 2001 From: Parag More Date: Tue, 25 Aug 2026 16:51:09 +0530 Subject: [PATCH 1/2] feat(consent): relay host consent to Surface form iframes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- CLAUDE.md | 20 ++++++++++-- src/consent/consent.test.ts | 49 ++++++++++++++++++++++++++++++ src/consent/consent.ts | 41 +++++++++++++++++++++++++ src/index.ts | 10 ++++++ src/store/message-listener.test.ts | 10 ++++++ src/store/message-listener.ts | 2 ++ src/store/store.test.ts | 30 ++++++++++++++++++ src/store/store.ts | 33 +++++++++++++++++--- surface_embed_v1.js | 48 ++++++++++++++++++++++++++--- surface_tag.js | 48 ++++++++++++++++++++++++++--- 10 files changed, 277 insertions(+), 14 deletions(-) create mode 100644 src/consent/consent.test.ts create mode 100644 src/consent/consent.ts diff --git a/CLAUDE.md b/CLAUDE.md index 3adb5d1..cdc3614 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -78,8 +78,24 @@ No automated tests, linter, or CI pipeline. Testing is manual and browser-based. ### PostMessage Protocol -- **To iframe:** `STORE_UPDATE` (cookies, URL params, partial fill data), `LEAD_DATA_UPDATE` (leadId, sessionId, fingerprint) -- **From iframe:** `SEND_DATA` (iframe requests current store data) +- **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) +- **From iframe:** `SEND_DATA` (iframe requests current store data), `surface:conversion` (iframe asks the parent to fire an ad pixel first-party) + +### Consent (`src/consent/`) + +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 }); +``` + +`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`. ### Key APIs diff --git a/src/consent/consent.test.ts b/src/consent/consent.test.ts new file mode 100644 index 0000000..579d449 --- /dev/null +++ b/src/consent/consent.test.ts @@ -0,0 +1,49 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { + getSurfaceConsent, + onSurfaceConsentChange, + setSurfaceConsent, +} from "./consent"; + +describe("surface consent", () => { + beforeEach(() => { + onSurfaceConsentChange(() => {}); + }); + + it("reports nothing granted until the page answers", () => { + // Module state, so this only holds before the first setSurfaceConsent call. + expect(getSurfaceConsent()).toBe(null); + }); + + it("normalises a partial answer — omitted categories are not granted", () => { + setSurfaceConsent({ adTracking: true }); + expect(getSurfaceConsent()).toEqual({ + adTracking: true, + surfaceAnalytics: false, + }); + }); + + it("ignores non-boolean values", () => { + setSurfaceConsent({ adTracking: "yes" as unknown as boolean }); + expect(getSurfaceConsent()?.adTracking).toBe(false); + }); + + it("lets a later answer withdraw consent", () => { + setSurfaceConsent({ adTracking: true, surfaceAnalytics: true }); + setSurfaceConsent({ adTracking: false, surfaceAnalytics: true }); + expect(getSurfaceConsent()).toEqual({ + adTracking: false, + surfaceAnalytics: true, + }); + }); + + it("notifies the relay on every answer", () => { + const onChange = vi.fn(); + onSurfaceConsentChange(onChange); + + setSurfaceConsent({ adTracking: true }); + setSurfaceConsent({ adTracking: false }); + + expect(onChange).toHaveBeenCalledTimes(2); + }); +}); diff --git a/src/consent/consent.ts b/src/consent/consent.ts new file mode 100644 index 0000000..9c8f39c --- /dev/null +++ b/src/consent/consent.ts @@ -0,0 +1,41 @@ +// Wire contract with the iframe (surface_forms form-render `hostConsent.ts`). +// Keep in sync. +export const SURFACE_CONSENT_MESSAGE_TYPE = "surface:consent"; + +/** + * Categories of third-party calls 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. + */ +export interface SurfaceConsent { + adTracking: boolean; + surfaceAnalytics: boolean; +} + +let consent: SurfaceConsent | null = null; +let onChange: (() => void) | null = null; + +/** Null until the page has answered — forms treat that as nothing granted. */ +export const getSurfaceConsent = (): SurfaceConsent | null => consent; + +export const onSurfaceConsentChange = (callback: () => void): void => { + onChange = callback; +}; + +/** + * Public API — call from a consent banner once the visitor answers: + * + * ```js + * window.SurfaceSetConsent({ adTracking: true, surfaceAnalytics: true }); + * ``` + * + * 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, + }; + onChange?.(); +}; diff --git a/src/index.ts b/src/index.ts index c330749..521ecf2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,6 +6,7 @@ import { setEnvironmentId, } from "./lead/identify"; import { SurfaceStore } from "./store/store"; +import { onSurfaceConsentChange, setSurfaceConsent } from "./consent/consent"; import { SurfaceExternalForm } from "./external-form/external-form"; import { SurfaceEmbed } from "./embed/embed"; import { resolveOpenTriggersOnLoad } from "./open-triggers/open-triggers"; @@ -29,6 +30,15 @@ w.SurfaceIdentifyLead = identifyLead; w.SurfaceSetLeadDataWithTTL = setLeadDataWithTTL; w.SurfaceGetLeadDataWithTTL = getLeadDataWithTTL; w.SurfaceGetSiteIdFromScript = getSiteIdFromScript; +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. +onSurfaceConsentChange(() => { + SurfaceTagStore.sendConsentToIframes(); + SurfaceTagStore.sendPayloadToIframes("STORE_UPDATE"); +}); // Auto-open a form when the host URL carries a configured `?=true` param. // Fire-and-forget; only touches the network when params are present. diff --git a/src/store/message-listener.test.ts b/src/store/message-listener.test.ts index 1060e3a..657387e 100644 --- a/src/store/message-listener.test.ts +++ b/src/store/message-listener.test.ts @@ -16,6 +16,7 @@ const FORMS_ORIGIN = "https://forms.withsurface.com"; const makeStore = () => ({ sendPayloadToIframes: vi.fn(), + sendConsentToIframes: vi.fn(), clearUserJourney: vi.fn(), log: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, }) as unknown as SurfaceStore; @@ -48,6 +49,15 @@ describe("initializeMessageListener", () => { expect(store.sendPayloadToIframes).toHaveBeenCalledWith("STORE_UPDATE"); }); + it("re-sends consent on the handshake, so a late-mounting form learns the page's answer", () => { + const store = makeStore(); + initializeMessageListener(store); + + dispatch({ type: "SEND_DATA", sender: "surface_form" }); + + expect(store.sendConsentToIframes).toHaveBeenCalledTimes(1); + }); + it("with an environment id: pushes STORE_UPDATE, identifies, then pushes LEAD_DATA_UPDATE", async () => { vi.mocked(getEnvironmentId).mockReturnValue("env_123"); const store = makeStore(); diff --git a/src/store/message-listener.ts b/src/store/message-listener.ts index 560ca78..a28a51e 100644 --- a/src/store/message-listener.ts +++ b/src/store/message-listener.ts @@ -17,6 +17,8 @@ export function initializeMessageListener(store: SurfaceStore): void { if (event.data.type === "SEND_DATA") { store.sendPayloadToIframes("STORE_UPDATE"); + // A form that booted after the banner was answered learns consent here. + store.sendConsentToIframes(); const envId = getEnvironmentId(); if (envId) { diff --git a/src/store/store.test.ts b/src/store/store.test.ts index d5a7243..c53032a 100644 --- a/src/store/store.test.ts +++ b/src/store/store.test.ts @@ -4,6 +4,7 @@ import { identifyLead, getLeadDataWithTTL } from "../lead/identify"; import { initializeUserJourneyTracking, updateUserJourneyOnRouteChange } from "./user-journey"; import { onRouteChange } from "../utils/route-observer"; import type { LeadData } from "../types"; +import { setSurfaceConsent } from "../consent/consent"; vi.mock("./message-listener", () => ({ initializeMessageListener: vi.fn(), @@ -179,4 +180,33 @@ describe("SurfaceStore postMessage protocol", () => { ); expect(otherPost).not.toHaveBeenCalled(); }); + + it("relays consent only to Surface iframes, and only once the page has answered", () => { + const surfaceIframe = addIframe(SURFACE_IFRAME_SRC); + const otherIframe = addIframe("https://example.com/embed"); + const store = new SurfaceStore(null); + + const surfacePost = vi + .spyOn(surfaceIframe.contentWindow as Window, "postMessage") + .mockImplementation(() => {}); + const otherPost = vi + .spyOn(otherIframe.contentWindow as Window, "postMessage") + .mockImplementation(() => {}); + + store.sendConsentToIframes(); + expect(surfacePost).not.toHaveBeenCalled(); + + setSurfaceConsent({ adTracking: true }); + store.sendConsentToIframes(); + + expect(surfacePost).toHaveBeenCalledWith( + { + type: "surface:consent", + sender: "surface_tag", + consent: { adTracking: true, surfaceAnalytics: false }, + }, + "https://forms.withsurface.com" + ); + expect(otherPost).not.toHaveBeenCalled(); + }); }); diff --git a/src/store/store.ts b/src/store/store.ts index c8f7c9f..eb9a3dd 100644 --- a/src/store/store.ts +++ b/src/store/store.ts @@ -1,4 +1,8 @@ import { VALID_EMBED_TYPES } from "../constants"; +import { + getSurfaceConsent, + SURFACE_CONSENT_MESSAGE_TYPE, +} from "../consent/consent"; import { isDebugMode } from "../utils/debug"; import { createLogger } from "../utils/logger"; import { parseCookies } from "../utils/cookies"; @@ -163,19 +167,40 @@ export class SurfaceStore { const target = iframe || document.querySelector("#surface-iframe"); if (!target) return; + this.postToSurfaceIframe(target, { + type, + payload: this.getPayload(), + sender: "surface_tag", + }); + } + + private postToSurfaceIframe(target: HTMLIFrameElement, message: unknown): void { try { const targetOrigin = new URL(target.src).origin; if (!this.surfaceDomains.includes(targetOrigin)) return; - target.contentWindow?.postMessage( - { type, payload: this.getPayload(), sender: "surface_tag" }, - targetOrigin - ); + target.contentWindow?.postMessage(message, targetOrigin); } catch { // Ignore invalid iframe URLs. } } + // Relays the page's consent answer to every Surface form on it. Forms with a + // category set to "On consent" stay dark until this arrives, so it is also + // re-sent on each SEND_DATA handshake for frames that mount later. + sendConsentToIframes(): void { + const consent = getSurfaceConsent(); + if (!consent) return; + + document.querySelectorAll("iframe").forEach((iframe) => + this.postToSurfaceIframe(iframe, { + type: SURFACE_CONSENT_MESSAGE_TYPE, + sender: "surface_tag", + consent, + }) + ); + } + getUrlParams(): Record { return getUrlParams(); } diff --git a/surface_embed_v1.js b/surface_embed_v1.js index 076e3ac..9d14ceb 100644 --- a/surface_embed_v1.js +++ b/surface_embed_v1.js @@ -216,6 +216,22 @@ return null; } + // src/consent/consent.ts + var SURFACE_CONSENT_MESSAGE_TYPE = "surface:consent"; + var consent = null; + var onChange = null; + var getSurfaceConsent = () => consent; + var onSurfaceConsentChange = (callback) => { + onChange = callback; + }; + var setSurfaceConsent = (granted) => { + consent = { + adTracking: granted?.adTracking === true, + surfaceAnalytics: granted?.surfaceAnalytics === true + }; + onChange?.(); + }; + // src/utils/debug.ts var cached = null; function isDebugMode() { @@ -496,6 +512,7 @@ } if (event.data.type === "SEND_DATA") { store.sendPayloadToIframes("STORE_UPDATE"); + store.sendConsentToIframes(); const envId = getEnvironmentId(); if (envId) { const identify = store.config?.customOrigin ? identifyLead(envId, store.config) : identifyLead(envId); @@ -756,16 +773,34 @@ notifyIframe(iframe, type) { const target = iframe || document.querySelector("#surface-iframe"); if (!target) return; + this.postToSurfaceIframe(target, { + type, + payload: this.getPayload(), + sender: "surface_tag" + }); + } + postToSurfaceIframe(target, message) { try { const targetOrigin = new URL(target.src).origin; if (!this.surfaceDomains.includes(targetOrigin)) return; - target.contentWindow?.postMessage( - { type, payload: this.getPayload(), sender: "surface_tag" }, - targetOrigin - ); + target.contentWindow?.postMessage(message, targetOrigin); } catch { } } + // Relays the page's consent answer to every Surface form on it. Forms with a + // category set to "On consent" stay dark until this arrives, so it is also + // re-sent on each SEND_DATA handshake for frames that mount later. + sendConsentToIframes() { + const consent2 = getSurfaceConsent(); + if (!consent2) return; + document.querySelectorAll("iframe").forEach( + (iframe) => this.postToSurfaceIframe(iframe, { + type: SURFACE_CONSENT_MESSAGE_TYPE, + sender: "surface_tag", + consent: consent2 + }) + ); + } getUrlParams() { return getUrlParams(); } @@ -2473,6 +2508,11 @@ w2.SurfaceSetLeadDataWithTTL = setLeadDataWithTTL; w2.SurfaceGetLeadDataWithTTL = getLeadDataWithTTL; w2.SurfaceGetSiteIdFromScript = getSiteIdFromScript; + w2.SurfaceSetConsent = setSurfaceConsent; + onSurfaceConsentChange(() => { + SurfaceTagStore.sendConsentToIframes(); + SurfaceTagStore.sendPayloadToIframes("STORE_UPDATE"); + }); void resolveOpenTriggersOnLoad(environmentId2, runtimeConfig2); initReview(); })(); diff --git a/surface_tag.js b/surface_tag.js index 076e3ac..9d14ceb 100644 --- a/surface_tag.js +++ b/surface_tag.js @@ -216,6 +216,22 @@ return null; } + // src/consent/consent.ts + var SURFACE_CONSENT_MESSAGE_TYPE = "surface:consent"; + var consent = null; + var onChange = null; + var getSurfaceConsent = () => consent; + var onSurfaceConsentChange = (callback) => { + onChange = callback; + }; + var setSurfaceConsent = (granted) => { + consent = { + adTracking: granted?.adTracking === true, + surfaceAnalytics: granted?.surfaceAnalytics === true + }; + onChange?.(); + }; + // src/utils/debug.ts var cached = null; function isDebugMode() { @@ -496,6 +512,7 @@ } if (event.data.type === "SEND_DATA") { store.sendPayloadToIframes("STORE_UPDATE"); + store.sendConsentToIframes(); const envId = getEnvironmentId(); if (envId) { const identify = store.config?.customOrigin ? identifyLead(envId, store.config) : identifyLead(envId); @@ -756,16 +773,34 @@ notifyIframe(iframe, type) { const target = iframe || document.querySelector("#surface-iframe"); if (!target) return; + this.postToSurfaceIframe(target, { + type, + payload: this.getPayload(), + sender: "surface_tag" + }); + } + postToSurfaceIframe(target, message) { try { const targetOrigin = new URL(target.src).origin; if (!this.surfaceDomains.includes(targetOrigin)) return; - target.contentWindow?.postMessage( - { type, payload: this.getPayload(), sender: "surface_tag" }, - targetOrigin - ); + target.contentWindow?.postMessage(message, targetOrigin); } catch { } } + // Relays the page's consent answer to every Surface form on it. Forms with a + // category set to "On consent" stay dark until this arrives, so it is also + // re-sent on each SEND_DATA handshake for frames that mount later. + sendConsentToIframes() { + const consent2 = getSurfaceConsent(); + if (!consent2) return; + document.querySelectorAll("iframe").forEach( + (iframe) => this.postToSurfaceIframe(iframe, { + type: SURFACE_CONSENT_MESSAGE_TYPE, + sender: "surface_tag", + consent: consent2 + }) + ); + } getUrlParams() { return getUrlParams(); } @@ -2473,6 +2508,11 @@ w2.SurfaceSetLeadDataWithTTL = setLeadDataWithTTL; w2.SurfaceGetLeadDataWithTTL = getLeadDataWithTTL; w2.SurfaceGetSiteIdFromScript = getSiteIdFromScript; + w2.SurfaceSetConsent = setSurfaceConsent; + onSurfaceConsentChange(() => { + SurfaceTagStore.sendConsentToIframes(); + SurfaceTagStore.sendPayloadToIframes("STORE_UPDATE"); + }); void resolveOpenTriggersOnLoad(environmentId2, runtimeConfig2); initReview(); })(); From 66432639ccfdfba5b7a3013d18e079ba24918dbb Mon Sep 17 00:00:00 2001 From: Parag More Date: Tue, 25 Aug 2026 17:22:07 +0530 Subject: [PATCH 2/2] test(consent): add an end-to-end consent banner test page A stand-in cookie banner over a real embedded form, so the whole path can be exercised in a browser: form renders dark, banner answered, vendor scripts start with no reload. - `?mode=tag` (default) delivers the answer through window.SurfaceSetConsent and the tag relays it; `?mode=iframe` loads no tag at all and posts `surface:consent` straight to the frame, the way a customer embedding a plain + + + + + + + + + + + + diff --git a/test/consent.js b/test/consent.js new file mode 100644 index 0000000..474b329 --- /dev/null +++ b/test/consent.js @@ -0,0 +1,117 @@ +// Drives the consent-mode test page: renders the config panel, wires the +// stand-in cookie banner, and delivers the answer the way the chosen embed +// shape would — window.SurfaceSetConsent (tag relays it) or a postMessage +// straight to the form frame (no tag on the page). + +const config = window.consentTestConfig; +const iframe = document.getElementById("consentIframe"); +const banner = document.getElementById("cookieBanner"); +const prefs = document.getElementById("cookiePrefs"); +const status = document.getElementById("consentStatus"); +const statusText = document.getElementById("consentStatusText"); +const adTrackingPref = document.getElementById("prefAdTracking"); +const surfaceAnalyticsPref = document.getElementById("prefSurfaceAnalytics"); + +const text = (id, value) => { + document.getElementById(id).textContent = value; +}; + +const describe = (consent) => + Object.entries(consent) + .map(([category, granted]) => `${category}: ${granted ? "granted" : "denied"}`) + .join(", "); + +function renderConfig() { + document.getElementById("formUrlInput").value = config.formSrc; + text("modeLabel", config.mode === "tag" ? "Surface tag" : "Direct iframe (no tag)"); + text("siteIdLabel", config.siteId); + text("customDomainLabel", config.customDomain || "none (production origin)"); + text("tagStatus", config.tagStatus); + text("debugMode", String(window.location.search.includes("surfaceDebug=true"))); + + if (!config.formOrigin) { + text("tagStatus", "form URL is not a valid absolute URL"); + return; + } + iframe.src = config.formSrc; +} + +function reloadWithFormUrl() { + const params = new URLSearchParams(window.location.search); + params.set("formSrc", document.getElementById("formUrlInput").value.trim()); + window.location.search = params.toString(); +} + +// The one call a real cookie banner would make. +function deliverConsent(consent) { + if (config.mode === "tag") { + if (typeof window.SurfaceSetConsent !== "function") { + logEvent( + { type: "CONSENT_NOT_DELIVERED", sender: "consent_banner", payload: { reason: "surface_tag.js did not load — run `pnpm run build` and serve from the repo root" } }, + "sent" + ); + return; + } + window.SurfaceSetConsent(consent); + logEvent({ type: "SurfaceSetConsent", sender: "consent_banner", payload: consent }, "sent"); + return; + } + + iframe.contentWindow.postMessage({ type: "surface:consent", consent }, config.formOrigin); + logEvent( + { type: "surface:consent", sender: "consent_banner", payload: { consent, targetOrigin: config.formOrigin } }, + "sent" + ); +} + +function answer(consent) { + deliverConsent(consent); + adTrackingPref.checked = consent.adTracking; + surfaceAnalyticsPref.checked = consent.surfaceAnalytics; + banner.hidden = true; + status.hidden = false; + statusText.textContent = `Consent — ${describe(consent)}`; +} + +function showBanner() { + status.hidden = true; + banner.hidden = false; + prefs.hidden = false; +} + +document.getElementById("reloadWithForm").addEventListener("click", reloadWithFormUrl); +document.getElementById("managePrefs").addEventListener("click", () => { + prefs.hidden = !prefs.hidden; +}); +document.getElementById("acceptAll").addEventListener("click", () => + answer({ adTracking: true, surfaceAnalytics: true }) +); +document.getElementById("rejectAll").addEventListener("click", () => + answer({ adTracking: false, surfaceAnalytics: false }) +); +document.getElementById("savePrefs").addEventListener("click", () => + answer({ adTracking: adTrackingPref.checked, surfaceAnalytics: surfaceAnalyticsPref.checked }) +); +document.getElementById("changePrefs").addEventListener("click", showBanner); +document.getElementById("copyFilter").addEventListener("click", (event) => { + navigator.clipboard.writeText(document.getElementById("vendorFilter").textContent.trim()); + event.target.textContent = "Copied"; + setTimeout(() => (event.target.textContent = "Copy"), 1500); +}); + +// event-monitor.js only logs messages from production Surface origins, so log +// the form frame's own traffic here — it may be a preview deploy or localhost. +window.addEventListener( + "message", + (event) => { + if (event.origin !== config.formOrigin || !event.data) return; + logEvent( + { type: event.data.type || "UNKNOWN", payload: event.data.payload || event.data, sender: event.data.sender || "iframe" }, + "received" + ); + }, + true +); + +initializeEventMonitoring(); +renderConfig(); diff --git a/test/index.html b/test/index.html index 80726b7..1a64fca 100644 --- a/test/index.html +++ b/test/index.html @@ -45,6 +45,12 @@

4. Input Trigger Embed

Test Input Trigger → + +