Skip to content
Merged
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
9 changes: 5 additions & 4 deletions src/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
61 changes: 59 additions & 2 deletions src/lib/registration-attribution.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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);
Expand Down Expand Up @@ -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);
Expand Down
70 changes: 66 additions & 4 deletions src/lib/registration-attribution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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;
Expand Down Expand Up @@ -72,6 +87,20 @@ const URL_PARAM_TO_FIELD: Record<string, keyof RegistrationAttribution> = {
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_';

/**
Expand Down Expand Up @@ -137,12 +166,35 @@ function writeStored(param: string, value: string): void {
}
}

/**
* The visitor token inside a `__hstc` value —
* `<domain hash>.<utk>.<first visit>.<previous visit>.<this visit>.<visit count>` — 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;
Expand All @@ -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.
Expand Down Expand Up @@ -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
Expand Down
Loading