Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 18 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
49 changes: 49 additions & 0 deletions src/consent/consent.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
41 changes: 41 additions & 0 deletions src/consent/consent.ts
Original file line number Diff line number Diff line change
@@ -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<SurfaceConsent>): void => {
consent = {
adTracking: granted?.adTracking === true,
surfaceAnalytics: granted?.surfaceAnalytics === true,
};
onChange?.();
};
10 changes: 10 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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 `?<slug>=true` param.
// Fire-and-forget; only touches the network when params are present.
Expand Down
10 changes: 10 additions & 0 deletions src/store/message-listener.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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();
Expand Down
2 changes: 2 additions & 0 deletions src/store/message-listener.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
30 changes: 30 additions & 0 deletions src/store/store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down Expand Up @@ -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();
});
});
33 changes: 29 additions & 4 deletions src/store/store.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -163,19 +167,40 @@ export class SurfaceStore {
const target = iframe || document.querySelector<HTMLIFrameElement>("#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<string, string> {
return getUrlParams();
}
Expand Down
48 changes: 44 additions & 4 deletions surface_embed_v1.js
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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();
}
Expand Down Expand Up @@ -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();
})();
Loading
Loading