diff --git a/src/app/page.tsx b/src/app/page.tsx index 14083aa8..f18b895d 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -18,10 +18,11 @@ export default function Home() { const target = () => `${getDefaultRedirectPath(isAuthenticated)}${window.location.search}${window.location.hash}`; router.replace(target()); - // The GTM PostHog tag strips `#distinct_id` with `history.replaceState({}, …)`. - // Landing mid-transition, that empty state wipes the router's tree and the - // navigation above is dropped: a blank page stuck on `/`. Fall back to a hard - // navigation if we are still here. + // The GTM PostHog tag used to strip `#distinct_id` with `history.replaceState({}, …)`; + // landing mid-transition, that empty state wiped the router's tree and the + // navigation above was dropped: a blank page stuck on `/`. The tag now passes + // `history.state` through (container fix, 2026-09-15). This hard fallback stays as + // insurance against the next tag that rewrites the URL. const fallback = window.setTimeout(() => { if (window.location.pathname === '/') window.location.replace(target()); }, 1500); diff --git a/src/lib/registration-attribution.test.mjs b/src/lib/registration-attribution.test.mjs index 21ac3b02..a3799ae2 100644 --- a/src/lib/registration-attribution.test.mjs +++ b/src/lib/registration-attribution.test.mjs @@ -2,7 +2,7 @@ // // The frontend repo has no test runner; these run on Node's built-in test module with its // native TypeScript stripping — `node --test src/lib/registration-attribution.test.mjs`, or -// `npm test`. They mock the browser globals the module touches (cookies, sessionStorage, the +// `npm test`. They mock the browser globals the module touches (cookies, localStorage, the // GTM dataLayer) and assert the observable payload, since the real cookies only exist in a // live browser. @@ -20,7 +20,7 @@ function resetBrowser() { // `hostname`/`protocol` matter only to the referral cookie writer; the cookie-scope rules // themselves are covered in referral-cookie.test.mjs, so this mock keeps `name=value` only. location: { search: '', hostname: 'auth.openframe.ai', protocol: 'https:' }, - sessionStorage: { + localStorage: { getItem: k => (k in store ? store[k] : null), setItem: (k, v) => { store[k] = String(v); @@ -119,6 +119,63 @@ test('the referral also rides the SSO continue URL', () => { assert.equal(params.get('ref'), 'partner-123'); }); +// HubSpot's cross-domain linker hands the marketing site's visitor token over in the URL; that +// token, not the local cookie, is what ties the registration to the visitor's sessions there. +const HANDOFF_UTK = 'f74d91effc2c2bf182c7210840fa38cb'; +const LOCAL_UTK = '0000000000000000000000000000aaaa'; +const HANDOFF_SEARCH = `?__hstc=221035399.${HANDOFF_UTK}.1757900000000.1757900000000.1757900000000.1&__hssc=221035399.1.1757900000000&__hsfp=7ce18d99cccb9504bc1eef62d4b5d5cd`; + +test('a HubSpot handoff in the URL beats the local hubspotutk cookie', () => { + cookies.push(`hubspotutk=${LOCAL_UTK}`); + window.location.search = HANDOFF_SEARCH; + A.captureAttributionFromUrl(); + + assert.equal(A.collectRegistrationAttribution().hutk, HANDOFF_UTK); +}); + +test('the handoff token survives the navigation from the landing page to signup', () => { + window.location.search = HANDOFF_SEARCH; + A.captureAttributionFromUrl(); // landing + + window.location.search = ''; // /auth — the params are gone from the address bar + cookies.push(`hubspotutk=${LOCAL_UTK}`); + assert.equal(A.collectRegistrationAttribution().hutk, HANDOFF_UTK); +}); + +test('a handoff straight to the signup page counts before the capture effect ran', () => { + cookies.push(`hubspotutk=${LOCAL_UTK}`); + window.location.search = HANDOFF_SEARCH; + assert.equal(A.collectRegistrationAttribution().hutk, HANDOFF_UTK); +}); + +test('last handoff wins: HubSpot replaces the visitor identity on every merge, so does this', () => { + window.location.search = HANDOFF_SEARCH; + A.captureAttributionFromUrl(); + + const later = '0123456789abcdef0123456789abcdef'; + window.location.search = HANDOFF_SEARCH.replace(HANDOFF_UTK, later); + A.captureAttributionFromUrl(); + + assert.equal(A.collectRegistrationAttribution().hutk, later); +}); + +test("a malformed __hstc, or one without the linker's __hsfp, falls back to the cookie", () => { + cookies.push(`hubspotutk=${LOCAL_UTK}`); + for (const search of ['?__hstc=not-a-hubspot-value&__hsfp=fp', `?__hstc=221035399.${HANDOFF_UTK}.1.1.1.1`]) { + window.location.search = search; + A.captureAttributionFromUrl(); + assert.equal(A.collectRegistrationAttribution().hutk, LOCAL_UTK, search); + } + assert.deepEqual(Object.keys(store), [], 'a rejected handoff stores nothing'); +}); + +test('hubspotUtkFromHstc reads the token slot and nothing else', () => { + assert.equal(A.hubspotUtkFromHstc(`221035399.${HANDOFF_UTK}.1.1.1.1`), HANDOFF_UTK); + assert.equal(A.hubspotUtkFromHstc(HANDOFF_UTK), undefined, 'a bare token is not a __hstc value'); + assert.equal(A.hubspotUtkFromHstc('221035399.short.1.1.1.1'), undefined); + assert.equal(A.hubspotUtkFromHstc(undefined), undefined); +}); + test('no signals present yields only the always-minted event id', () => { const got = A.collectRegistrationAttribution(); assert.equal(got.fbc, undefined); diff --git a/src/lib/registration-attribution.ts b/src/lib/registration-attribution.ts index 7c759bd0..072037a0 100644 --- a/src/lib/registration-attribution.ts +++ b/src/lib/registration-attribution.ts @@ -22,6 +22,20 @@ import { captureReferralFromUrl, REFERRAL_URL_PARAM, readReferralCode, sanitizeR * return visit days later, without attributing signups to clicks from another quarter. * Known limitation: Safari ITP caps script-writable storage at ~7 days; accepted as-is. * + * - **The HubSpot visitor token** is a cookie too (`hubspotutk`), but the token the contact + * should get is the *marketing site's*: the visitor's sessions and the ad click live on + * flamingo.run, under the token HubSpot's cross-domain linker writes into `__hstc` on the + * link to `/auth`. HubSpot merges that token into the local cookie only when a browser + * fingerprint it computes asynchronously is already there at tracker start-up — which it is + * not, so a returning visitor keeps the local token and the handoff is dropped (the GTM + * container seeds it for a first visit only). The incoming token is therefore captured into + * localStorage as well and preferred over the local cookie at submit: the outcome HubSpot's + * own merge produces when it wins. Last touch, unlike the ad parameters — `__hstc` names the + * visitor, not a click, and HubSpot itself replaces the local identity with every incoming + * one it accepts. The trust rule is the container's (a well-formed `__hstc` next to a + * `__hsfp`); neither verifies the fingerprint, so a forwarded handoff link would attribute + * its registrant to the sender's visitor — accepted, as it already is for first visits. + * * - **The partner referral** (`?ref=`) is different still. It is clicked on the marketing site * (`openframe.ai`) and redeemed on the signup app (`auth.openframe.ai`) — localStorage is * origin-scoped, so it cannot make that hop. It gets its own 90-day cookie on the shared @@ -36,8 +50,9 @@ export interface RegistrationAttribution { fbclid?: string; /** `_fbp` cookie value — the Meta browser id, sent to Meta as `fbp`. */ fbp?: string; - /** HubSpot visitor cookie (`hubspotutk`). The backend puts it in the registration form - * submission, which is what gives the contact its real traffic source. */ + /** HubSpot visitor token (utk): the marketing site's when the visitor came from there, else + * the local `hubspotutk` cookie (see the header). The backend puts it in the registration + * form submission, which is what gives the contact its real traffic source. */ hutk?: string; /** Google click id. */ gclid?: string; @@ -72,6 +87,20 @@ const URL_PARAM_TO_FIELD: Record = { utm_term: 'utmTerm', }; +/** + * What HubSpot's cross-domain linker appends to a link between the portal's domains: the + * visitor's `__hstc` cookie value, a session marker (`__hssc`) and the sender's browser + * fingerprint (`__hsfp`). + */ +const HUBSPOT_HSTC_PARAM = '__hstc'; +const HUBSPOT_HSFP_PARAM = '__hsfp'; + +/** localStorage entry (`of_attr_hutk`) for the visitor token a handoff carried. */ +const HUBSPOT_UTK_STORAGE_PARAM = 'hutk'; + +/** A HubSpot visitor token: 32 hex characters, the `hubspotutk` cookie value. */ +const HUBSPOT_UTK_PATTERN = /^[0-9a-f]{32}$/i; + const STORAGE_PREFIX = 'of_attr_'; /** @@ -137,12 +166,35 @@ function writeStored(param: string, value: string): void { } } +/** + * The visitor token inside a `__hstc` value — + * `.....` — which is + * the same string HubSpot keeps in the `hubspotutk` cookie. `undefined` for anything that does + * not carry a well-formed token in that slot, so a hand-edited parameter never reaches the + * form submission. + */ +export function hubspotUtkFromHstc(hstc: string | null | undefined): string | undefined { + const utk = hstc?.trim().split('.')[1]; + return utk && HUBSPOT_UTK_PATTERN.test(utk) ? utk : undefined; +} + +/** + * The visitor token on the current URL when it is a HubSpot cross-domain handoff. Same + * acceptance rule as the GTM container's first-visit seed — a well-formed `__hstc` next to a + * `__hsfp` — so both paths agree on which URLs count. + */ +function readHubspotHandoffUtk(): string | undefined { + const utk = hubspotUtkFromHstc(readUrlParam(HUBSPOT_HSTC_PARAM)); + return utk && readUrlParam(HUBSPOT_HSFP_PARAM) ? utk : undefined; +} + /** * Read every known attribution parameter out of the current URL and persist it for up to * 90 days. Safe to call on every page load: an existing unexpired value is never overwritten, * so the *first* touch wins — that is the ad click that brought the visitor, not whatever * internal navigation they made afterwards. An expired entry reads as absent, so the next - * visit that carries the parameter starts a fresh 90-day window. + * visit that carries the parameter starts a fresh 90-day window. The HubSpot handoff is the + * one last-touch entry (see the header). */ export function captureAttributionFromUrl(): void { if (!isBrowser()) return; @@ -154,6 +206,12 @@ export function captureAttributionFromUrl(): void { } } + // Last touch: the latest handoff is the identity HubSpot would be on. Usually idempotent — the + // marketing site's token is stable for a browser, so a repeat handoff rewrites the same value + // and only refreshes its 90 days. + const handoffUtk = readHubspotHandoffUtk(); + if (handoffUtk) writeStored(HUBSPOT_UTK_STORAGE_PARAM, handoffUtk); + // `?ref=` is deliberately NOT part of that loop: it is cookie-backed, cross-subdomain and // last-touch, none of which localStorage first-touch capture can express. Usually a no-op — // the cookie normally arrives from the marketing site, and an unchanged one is left alone. @@ -233,7 +291,11 @@ export function collectRegistrationAttribution(): RegistrationAttribution | unde const raw: RegistrationAttribution = { fbc: readCookie('_fbc'), fbp: readCookie('_fbp'), - hutk: readCookie('hubspotutk'), + // The marketing site's token first, the local cookie only for a visitor who never came + // through the handoff (see the header). The live URL sits between them for the same reason + // as the ad parameters below: a handoff straight to the signup page, submitted before the + // capture effect ran. + hutk: readStored(HUBSPOT_UTK_STORAGE_PARAM) ?? readHubspotHandoffUtk() ?? readCookie('hubspotutk'), eventId, // Cookie first — it is the one signal that can predate this visit entirely. The live URL is // the fallback for a partner link pointing straight at the signup page, submitted before the