Skip to content
Draft
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
16 changes: 12 additions & 4 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
`<script>` carries `data-consent-mode` (read in `runtime-config.ts`). Until that
page grants it, `SurfaceStore` skips identify, the `surfaceLeadData` cache, the
journey cookies and forwards an empty cookie snapshot; `applyConsent()` starts
them on a grant and clears them on withdrawal. Without the attribute nothing
changes for existing installs.

### Key APIs

Expand Down
21 changes: 21 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,3 +68,24 @@ See [docs](https://docs.withsurface.com/docs/surface-tag/installation) for integ
3. Identity API call completes in <0.5s on slow 4G; no issues if blocked/failed
4. PostMessage to iframe: query params, prefilled email, cookies, URL/origin/referrer
5. Form loading speed on withsurface.com

## Cookie consent

Pages that run a consent banner load the tag with `data-consent-mode` and report
the visitor's answer, in full, on every load and every change:

```html
<script
src="https://cdn.jsdelivr.net/.../surface_tag.min.js"
site-id="your-environment-id"
data-consent-mode>
</script>
<script>
window.SurfaceSetConsent({ adTracking: true, surfaceAnalytics: true, cookieTracking: true });
</script>
```

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.
8 changes: 8 additions & 0 deletions src/consent/consent.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -34,6 +41,7 @@ describe("surface consent", () => {
expect(getSurfaceConsent()).toEqual({
adTracking: false,
surfaceAnalytics: true,
cookieTracking: false,
});
});

Expand Down
18 changes: 13 additions & 5 deletions src/consent/consent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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<SurfaceConsent>): void => {
consent = {
adTracking: granted?.adTracking === true,
surfaceAnalytics: granted?.surfaceAnalytics === true,
cookieTracking: granted?.cookieTracking === true,
};
onChange?.();
};
4 changes: 3 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
});
Expand Down
4 changes: 4 additions & 0 deletions src/lead/identify.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,10 @@ export function setLeadDataWithTTL(data: Omit<LeadData, "expiry">): 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;
Expand Down
10 changes: 9 additions & 1 deletion src/runtime-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,18 @@ import {
} from "./constants";

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

export interface SurfaceRuntimeConfig {
apiBaseUrl: string;
leadIdentifyApi: string;
userJourneyTrackingApi: string;
surfaceDomains: readonly string[];
customOrigin: string | null;
waitForCookieConsent: boolean;
}

export const DEFAULT_SURFACE_RUNTIME_CONFIG: SurfaceRuntimeConfig = {
Expand All @@ -21,6 +26,7 @@ export const DEFAULT_SURFACE_RUNTIME_CONFIG: SurfaceRuntimeConfig = {
userJourneyTrackingApi: USER_JOURNEY_TRACKING_API,
surfaceDomains: SURFACE_DOMAINS,
customOrigin: null,
waitForCookieConsent: false,
};

let runtimeConfig = DEFAULT_SURFACE_RUNTIME_CONFIG;
Expand Down Expand Up @@ -51,10 +57,11 @@ function normalizeCustomOrigin(value: string): string | null {
export function resolveSurfaceRuntimeConfig(
scriptElement: HTMLScriptElement | null
): SurfaceRuntimeConfig {
const waitForCookieConsent = scriptElement?.hasAttribute(CONSENT_MODE_ATTRIBUTE) ?? false;
const customOrigin = normalizeCustomOrigin(
scriptElement?.getAttribute(CUSTOM_DOMAIN_ATTRIBUTE) ?? ""
);
if (!customOrigin) return DEFAULT_SURFACE_RUNTIME_CONFIG;
if (!customOrigin) return { ...DEFAULT_SURFACE_RUNTIME_CONFIG, waitForCookieConsent };

const apiBaseUrl = `${customOrigin}/api/v1`;
return {
Expand All @@ -63,6 +70,7 @@ export function resolveSurfaceRuntimeConfig(
userJourneyTrackingApi: `${apiBaseUrl}/lead/track`,
surfaceDomains: Array.from(new Set([...SURFACE_DOMAINS, customOrigin])),
customOrigin,
waitForCookieConsent,
};
}

Expand Down
14 changes: 14 additions & 0 deletions src/store/message-listener.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ const makeStore = () =>
sendPayloadToIframes: vi.fn(),
sendConsentToIframes: vi.fn(),
clearUserJourney: vi.fn(),
cookieTrackingAllowed: vi.fn(() => true),
log: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
}) as unknown as SurfaceStore;

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

it("under consent mode without a cookie grant: pushes LEAD_DATA_UPDATE immediately, never identifies", () => {
vi.mocked(getEnvironmentId).mockReturnValue("env_123");
const store = makeStore();
vi.mocked(store.cookieTrackingAllowed).mockReturnValue(false);
initializeMessageListener(store);

dispatch({ type: "SEND_DATA", sender: "surface_form" });

// Listeners from earlier cases are still attached, so only this store's pushes are asserted.
const types = vi.mocked(store.sendPayloadToIframes).mock.calls.map((c) => c[0]);
expect(types).toEqual(["STORE_UPDATE", "LEAD_DATA_UPDATE"]);
});

it("without an environment id: pushes LEAD_DATA_UPDATE immediately, never identifies", () => {
vi.mocked(getEnvironmentId).mockReturnValue(null);
const store = makeStore();
Expand Down
2 changes: 1 addition & 1 deletion src/store/message-listener.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ export function initializeMessageListener(store: SurfaceStore): void {
store.sendConsentToIframes();

const envId = getEnvironmentId();
if (envId) {
if (envId && store.cookieTrackingAllowed()) {
const identify = store.config?.customOrigin
? identifyLead(envId, store.config)
: identifyLead(envId);
Expand Down
79 changes: 76 additions & 3 deletions src/store/store.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
import { describe, it, expect, vi, beforeEach, afterEach, type MockInstance } from "vitest";
import { SurfaceStore } from "./store";
import { identifyLead, getLeadDataWithTTL } from "../lead/identify";
import { initializeUserJourneyTracking, updateUserJourneyOnRouteChange } from "./user-journey";
import { identifyLead, getLeadDataWithTTL, clearLeadData } from "../lead/identify";
import {
initializeUserJourneyTracking,
updateUserJourneyOnRouteChange,
clearUserJourney,
} from "./user-journey";
import { DEFAULT_SURFACE_RUNTIME_CONFIG } from "../runtime-config";
import { onRouteChange } from "../utils/route-observer";
import type { LeadData } from "../types";
import { setSurfaceConsent } from "../consent/consent";
Expand All @@ -13,6 +18,7 @@ vi.mock("../lead/identify", () => ({
identifyLead: vi.fn(async () => null),
getLeadDataWithTTL: vi.fn((): LeadData | null => null),
isIdentifyInProgress: vi.fn(() => false),
clearLeadData: vi.fn(),
}));
vi.mock("./user-journey", () => ({
initializeUserJourneyTracking: vi.fn(),
Expand Down Expand Up @@ -203,10 +209,77 @@ describe("SurfaceStore postMessage protocol", () => {
{
type: "surface:consent",
sender: "surface_tag",
consent: { adTracking: true, surfaceAnalytics: false },
consent: { adTracking: true, surfaceAnalytics: false, cookieTracking: false },
},
"https://forms.withsurface.com"
);
expect(otherPost).not.toHaveBeenCalled();
});
});

// `data-consent-mode` on the script: the page's banner owns cookie consent, so
// the tag does no visitor recognition or journey work until it hears a grant.
describe("SurfaceStore under data-consent-mode", () => {
const consentModeConfig = { ...DEFAULT_SURFACE_RUNTIME_CONFIG, waitForCookieConsent: true };

beforeEach(() => {
vi.clearAllMocks();
vi.useFakeTimers();
document.body.innerHTML = "";
document.cookie = "hubspotutk=abc";
setSurfaceConsent({});
addIframe(SURFACE_IFRAME_SRC);
});

afterEach(() => {
vi.useRealTimers();
document.cookie = "hubspotutk=; max-age=0";
});

it("before a grant: no journey, no identify, no lead cache read, and an empty cookie snapshot", async () => {
const store = new SurfaceStore("env_123", consentModeConfig);
const pushes = vi.spyOn(store, "sendPayloadToIframes");

await vi.runAllTimersAsync();

expect(initializeUserJourneyTracking).not.toHaveBeenCalled();
expect(identifyLead).not.toHaveBeenCalled();
expect(getLeadDataWithTTL).not.toHaveBeenCalled();
// The frame still gets its handshake so it can identify without recognition.
expect(pushedTypes(pushes)).toEqual(["STORE_UPDATE", "LEAD_DATA_UPDATE"]);
expect(store.getPayload()).toMatchObject({ cookies: {}, surfaceLeadData: null, userJourneyId: null });
});

it("a cookie grant starts the journey, identifies and forwards cookies; withdrawal clears them again", async () => {
const store = new SurfaceStore("env_123", consentModeConfig);
await vi.runAllTimersAsync();

setSurfaceConsent({ cookieTracking: true });
store.applyConsent();
await vi.runAllTimersAsync();

expect(initializeUserJourneyTracking).toHaveBeenCalledTimes(1);
expect(identifyLead).toHaveBeenCalledWith("env_123");
expect(store.getPayload().cookies).toEqual({ hubspotutk: "abc" });

setSurfaceConsent({ cookieTracking: false });
store.applyConsent();

expect(clearUserJourney).toHaveBeenCalledTimes(1);
expect(clearLeadData).toHaveBeenCalledTimes(1);
expect(store.getPayload()).toMatchObject({ cookies: {}, surfaceLeadData: null });

// Route changes keep pushing the store but no longer touch the journey.
capturedRouteChangeCallback()("http://localhost:3000/next-page");
expect(updateUserJourneyOnRouteChange).not.toHaveBeenCalled();
});

it("without the attribute the tag behaves as before, whatever the page reports", async () => {
const store = new SurfaceStore("env_123");
await vi.runAllTimersAsync();

expect(initializeUserJourneyTracking).toHaveBeenCalledTimes(1);
expect(identifyLead).toHaveBeenCalledWith("env_123");
expect(store.getPayload().cookies).toEqual({ hubspotutk: "abc" });
});
});
Loading
Loading