From 41c8252c53b8f1040d3fee0c6e7a3be21b913141 Mon Sep 17 00:00:00 2001 From: hieu-w Date: Tue, 15 Sep 2026 18:18:59 +0700 Subject: [PATCH 1/4] Capture Agent Wallet campaign tags in the shared mm_attribution cookie --- docusaurus.config.js | 5 +- src/client/attribution-cookie.js | 17 +++ src/lib/attribution-cookie.js | 207 +++++++++++++++++++++++++++++++ 3 files changed, 228 insertions(+), 1 deletion(-) create mode 100644 src/client/attribution-cookie.js create mode 100644 src/lib/attribution-cookie.js diff --git a/docusaurus.config.js b/docusaurus.config.js index f7840361584..65d9899322e 100644 --- a/docusaurus.config.js +++ b/docusaurus.config.js @@ -369,7 +369,10 @@ const config = { // content-negotiation rewrite to the `.md` siblings emitted here. ['./src/plugins/llms-html-injector', llmsPluginOptions], ], - clientModules: [require.resolve('./src/client/scroll-fix.js')], + clientModules: [ + require.resolve('./src/client/scroll-fix.js'), + require.resolve('./src/client/attribution-cookie.js'), + ], themeConfig: /** @type {import('@docusaurus/preset-classic').ThemeConfig} */ ({ diff --git a/src/client/attribution-cookie.js b/src/client/attribution-cookie.js new file mode 100644 index 00000000000..31b5802704b --- /dev/null +++ b/src/client/attribution-cookie.js @@ -0,0 +1,17 @@ +import { writeAttributionCookie } from '@site/src/lib/attribution-cookie' + +/** + * Parks campaign tags from /agent-wallet landings (e.g. /agent-wallet/quickstart) + * into the shared `mm_attribution` cookie. Same last-touch rules as metamask.io. + */ +export function onRouteDidUpdate({ location, previousLocation }) { + if ( + previousLocation && + previousLocation.pathname === location.pathname && + previousLocation.search === location.search + ) { + return + } + + writeAttributionCookie(location.pathname, location.search) +} diff --git a/src/lib/attribution-cookie.js b/src/lib/attribution-cookie.js new file mode 100644 index 00000000000..c12963d1fde --- /dev/null +++ b/src/lib/attribution-cookie.js @@ -0,0 +1,207 @@ +/** + * Writes the `mm_attribution` cookie so developer.metamask.io can read + * campaign tags from a docs.metamask.io landing. + * + * Keep this in sync with metamask-website `src/lib/hooks/use-attribution-cookie.js`. + * Format matches the dashboard `readCookieTouch()` contract: + * `{ "utm": { "utm_source": "…" }, "click": { "gclid": "…" }, "at": "" }` + */ + +const COOKIE_NAME = 'mm_attribution' +const COOKIE_MAX_AGE_DAYS = 90 + +/** + * Ad-network click IDs to capture from the landing URL. + * Matches the whitelist in the developer-dashboard attribution reader. + */ +const CLICK_ID_KEYS = ['gclid', 'twclid', 'fbclid', 'msclkid', 'ttclid', 'li_fat_id'] + +/** + * UTM parameter keys to capture from the landing URL. + */ +const UTM_KEYS = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content'] + +/** + * Bounds mirror the server-side limits in backend/pkg/auth/attribution.go + * and the developer-dashboard reader. A stuffed cookie must not be able to + * occupy every slot so a last-touch URL parameter is dropped. + */ +const MAX_UTM_PARAMS = 10 +const MAX_CLICK_PARAMS = 10 +const MAX_KEY_LENGTH = 34 +const MAX_VALUE_LENGTH = 255 +const UTM_KEY_PATTERN = /^utm_[a-z0-9_]{1,30}$/ +const CLICK_KEY_PATTERN = /^[a-z0-9_]{1,40}$/ + +/** + * Sanitises a parameter value: strips markup-significant / control characters + * and truncates to MAX_VALUE_LENGTH. Returns null for empty/invalid input. + * + * @param {string} raw + * @returns {string | null} + */ +function clean(raw) { + if (!raw || typeof raw !== 'string') return null + + /* eslint-disable-next-line no-control-regex */ + const stripped = raw.replace(/[<>"'`\\]|[\u0000-\u001f\u007f]/g, '').trim() + + if (!stripped) return null + + return stripped.length > MAX_VALUE_LENGTH ? stripped.slice(0, MAX_VALUE_LENGTH) : stripped +} + +/** + * @param {string} key + * @returns {string | undefined} + */ +function normalizeUtmKey(key) { + const lowered = key.trim().toLowerCase() + + if (!lowered || lowered.length > MAX_KEY_LENGTH) return undefined + + return UTM_KEY_PATTERN.test(lowered) ? lowered : undefined +} + +/** + * @param {string} key + * @returns {string | undefined} + */ +function normalizeClickKey(key) { + const lowered = key.trim().toLowerCase() + + if (!lowered || lowered.length > MAX_KEY_LENGTH) return undefined + + return CLICK_KEY_PATTERN.test(lowered) && CLICK_ID_KEYS.includes(lowered) ? lowered : undefined +} + +/** + * @param {unknown} input + * @param {(key: string) => string | undefined} allowKey + * @param {number} max + * @returns {Record} + */ +function takeAllowedMap(input, allowKey, max) { + const out = {} + + if (!input || typeof input !== 'object') return out + + Object.entries(input).forEach(([rawKey, rawValue]) => { + if (typeof rawValue !== 'string' || Object.keys(out).length >= max) return + + const key = allowKey(rawKey) + const val = clean(rawValue) + + if (key && val) out[key] = val + }) + + return out +} + +/** + * Last-touch (`incoming`) keys take the budget first; leftover slots are + * filled from `existing`. Cookie-first insertion would let a full cookie + * occupy every slot so a new URL parameter is discarded. + * + * @param {Record} incoming + * @param {Record} existing + * @param {number} max + * @returns {Record} + */ +function mergePreferIncoming(incoming, existing, max) { + const out = { ...incoming } + + Object.entries(existing).forEach(([key, value]) => { + if (key in out || Object.keys(out).length >= max) return + + out[key] = value + }) + + return out +} + +/** + * Reads and parses the existing `mm_attribution` cookie, returning its `utm` + * and `click` maps. Returns empty maps when the cookie is absent or malformed, + * so a corrupt value never blocks a fresh write. + * + * @returns {{ utm: Record, click: Record }} + */ +function readExistingCookie() { + const empty = { utm: {}, click: {} } + + const entry = document.cookie.split('; ').find(c => c.startsWith(`${COOKIE_NAME}=`)) + + if (!entry) return empty + + try { + const parsed = JSON.parse(decodeURIComponent(entry.slice(COOKIE_NAME.length + 1))) + + if (!parsed || typeof parsed !== 'object') return empty + + return { + utm: takeAllowedMap(parsed.utm, normalizeUtmKey, MAX_UTM_PARAMS), + click: takeAllowedMap(parsed.click, normalizeClickKey, MAX_CLICK_PARAMS), + } + } catch { + return empty + } +} + +/** + * Writes the `mm_attribution` cookie with `Domain=.metamask.io` so the + * developer-dashboard (developer.metamask.io) can read it. + * + * Only fires on /agent-wallet paths when the URL carries at least one + * UTM or click-ID parameter. + * + * @param {string} pathname + * @param {string} [search] + */ +export function writeAttributionCookie(pathname, search) { + if (typeof document === 'undefined') return + if (!pathname || !pathname.includes('/agent-wallet')) return + + const searchParams = new URLSearchParams(search || '') + const utm = {} + const click = {} + + UTM_KEYS.forEach(key => { + const val = clean(searchParams.get(key)) + + if (val) utm[key] = val + }) + + CLICK_ID_KEYS.forEach(key => { + const val = clean(searchParams.get(key)) + + if (val) click[key] = val + }) + + const hasUtm = Object.keys(utm).length > 0 + const hasClick = Object.keys(click).length > 0 + + if (!hasUtm && !hasClick) return + + // Last-touch: URL keys take the 10-key budget first so a stuffed cookie + // cannot evict a new campaign tag. Remaining slots keep earlier click IDs + // / UTMs so a UTM-only visit does not drop a prior gclid. + const existing = readExistingCookie() + + const mergedUtm = mergePreferIncoming(utm, existing.utm, MAX_UTM_PARAMS) + const mergedClick = mergePreferIncoming(click, existing.click, MAX_CLICK_PARAMS) + + const payload = { + ...(Object.keys(mergedUtm).length > 0 ? { utm: mergedUtm } : {}), + ...(Object.keys(mergedClick).length > 0 ? { click: mergedClick } : {}), + at: new Date().toISOString(), + } + + const encoded = encodeURIComponent(JSON.stringify(payload)) + + const d = new Date() + + d.setTime(d.getTime() + COOKIE_MAX_AGE_DAYS * 24 * 60 * 60 * 1000) + + document.cookie = `${COOKIE_NAME}=${encoded}; expires=${d.toUTCString()}; path=/; domain=.metamask.io; secure; sameSite=lax` +} From 355983adb988db0052aeddce586cc4adacf9325d Mon Sep 17 00:00:00 2001 From: hieu-w Date: Thu, 24 Sep 2026 16:44:56 +0700 Subject: [PATCH 2/4] fix: gate mm_attribution cookie on Osano marketing consent --- src/lib/attribution-cookie.js | 113 +++++++++++++++++++++++++++++----- 1 file changed, 97 insertions(+), 16 deletions(-) diff --git a/src/lib/attribution-cookie.js b/src/lib/attribution-cookie.js index c12963d1fde..69cdb897054 100644 --- a/src/lib/attribution-cookie.js +++ b/src/lib/attribution-cookie.js @@ -33,6 +33,73 @@ const MAX_VALUE_LENGTH = 255 const UTM_KEY_PATTERN = /^utm_[a-z0-9_]{1,30}$/ const CLICK_KEY_PATTERN = /^[a-z0-9_]{1,40}$/ +/** + * Osano consent category that gates marketing-attribution storage. + * Attribution (UTM + ad click IDs) is marketing tracking, so it must not be + * persisted unless the visitor has accepted this category. + */ +const MARKETING_CATEGORY = 'MARKETING' + +/** + * Osano event fired when the visitor saves their cookie preferences. + */ +const CONSENT_SAVED_EVENT = 'osano-cm-consent-saved' + +/** + * Tracks the single pending consent listener so repeated /agent-wallet + * landings don't stack duplicate Osano listeners; the latest landing wins. + * + * @type {(() => void) | null} + */ +let pendingConsentListener = null + +/** + * Returns true when the visitor has granted Osano MARKETING consent. + * + * Osano exposes the current decision via `cm.getConsent()`. When Osano is + * absent (e.g. local dev / non-production, where the CMP script is not + * injected) consent is treated as not granted, so the attribution cookie is + * never written without a consent signal. + * + * @returns {boolean} + */ +function hasMarketingConsent() { + try { + return window.Osano?.cm?.getConsent?.()?.[MARKETING_CATEGORY] === 'ACCEPT' + } catch { + return false + } +} + +/** + * Defers `commit` until the visitor saves Osano preferences accepting + * marketing cookies. No-ops when Osano is unavailable, so nothing is written + * without a consent signal. Only one landing stays pending at a time. + * + * @param {() => void} commit + */ +function deferUntilConsent(commit) { + const osanoCm = window.Osano?.cm + + if (!osanoCm?.addEventListener) return + + // Replace any earlier pending listener so only the latest landing commits. + if (pendingConsentListener) { + osanoCm.removeEventListener?.(CONSENT_SAVED_EVENT, pendingConsentListener) + } + + const listener = () => { + if (!hasMarketingConsent()) return + + osanoCm.removeEventListener?.(CONSENT_SAVED_EVENT, listener) + pendingConsentListener = null + commit() + } + + pendingConsentListener = listener + osanoCm.addEventListener(CONSENT_SAVED_EVENT, listener) +} + /** * Sanitises a parameter value: strips markup-significant / control characters * and truncates to MAX_VALUE_LENGTH. Returns null for empty/invalid input. @@ -153,7 +220,8 @@ function readExistingCookie() { * developer-dashboard (developer.metamask.io) can read it. * * Only fires on /agent-wallet paths when the URL carries at least one - * UTM or click-ID parameter. + * UTM or click-ID parameter, and only once the visitor has granted Osano + * MARKETING consent (either already on load, or later via the consent banner). * * @param {string} pathname * @param {string} [search] @@ -183,25 +251,38 @@ export function writeAttributionCookie(pathname, search) { if (!hasUtm && !hasClick) return - // Last-touch: URL keys take the 10-key budget first so a stuffed cookie - // cannot evict a new campaign tag. Remaining slots keep earlier click IDs - // / UTMs so a UTM-only visit does not drop a prior gclid. - const existing = readExistingCookie() + const commit = () => { + // Last-touch: URL keys take the 10-key budget first so a stuffed cookie + // cannot evict a new campaign tag. Remaining slots keep earlier click IDs + // / UTMs so a UTM-only visit does not drop a prior gclid. + const existing = readExistingCookie() - const mergedUtm = mergePreferIncoming(utm, existing.utm, MAX_UTM_PARAMS) - const mergedClick = mergePreferIncoming(click, existing.click, MAX_CLICK_PARAMS) + const mergedUtm = mergePreferIncoming(utm, existing.utm, MAX_UTM_PARAMS) + const mergedClick = mergePreferIncoming(click, existing.click, MAX_CLICK_PARAMS) - const payload = { - ...(Object.keys(mergedUtm).length > 0 ? { utm: mergedUtm } : {}), - ...(Object.keys(mergedClick).length > 0 ? { click: mergedClick } : {}), - at: new Date().toISOString(), - } + const payload = { + ...(Object.keys(mergedUtm).length > 0 ? { utm: mergedUtm } : {}), + ...(Object.keys(mergedClick).length > 0 ? { click: mergedClick } : {}), + at: new Date().toISOString(), + } + + const encoded = encodeURIComponent(JSON.stringify(payload)) - const encoded = encodeURIComponent(JSON.stringify(payload)) + const d = new Date() - const d = new Date() + d.setTime(d.getTime() + COOKIE_MAX_AGE_DAYS * 24 * 60 * 60 * 1000) - d.setTime(d.getTime() + COOKIE_MAX_AGE_DAYS * 24 * 60 * 60 * 1000) + document.cookie = `${COOKIE_NAME}=${encoded}; expires=${d.toUTCString()}; path=/; domain=.metamask.io; secure; sameSite=lax` + } + + // Respect the visitor's cookie-consent choice: attribution is marketing + // tracking, so only persist it once MARKETING consent has been granted. If + // consent isn't granted yet, wait for the visitor to save their preferences. + if (hasMarketingConsent()) { + commit() + + return + } - document.cookie = `${COOKIE_NAME}=${encoded}; expires=${d.toUTCString()}; path=/; domain=.metamask.io; secure; sameSite=lax` + deferUntilConsent(commit) } From 79eb6236ee53597c1830e28abf6c494d63d136ce Mon Sep 17 00:00:00 2001 From: hieu-w Date: Thu, 24 Sep 2026 17:22:23 +0700 Subject: [PATCH 3/4] fix: comment --- src/lib/attribution-cookie.js | 56 +++++++++++++++++++++++------------ 1 file changed, 37 insertions(+), 19 deletions(-) diff --git a/src/lib/attribution-cookie.js b/src/lib/attribution-cookie.js index 69cdb897054..8370a0e553e 100644 --- a/src/lib/attribution-cookie.js +++ b/src/lib/attribution-cookie.js @@ -40,18 +40,27 @@ const CLICK_KEY_PATTERN = /^[a-z0-9_]{1,40}$/ */ const MARKETING_CATEGORY = 'MARKETING' +/** + * Osano event fired once the CMP script has loaded and resolved stored consent. + * For returning visitors whose marketing cookies are already accepted this is + * the only lifecycle event that fires — `osano-cm-consent-saved` requires the + * visitor to actively interact with the consent banner. + */ +const CONSENT_INITIALIZED_EVENT = 'osano-cm-initialized' + /** * Osano event fired when the visitor saves their cookie preferences. */ const CONSENT_SAVED_EVENT = 'osano-cm-consent-saved' /** - * Tracks the single pending consent listener so repeated /agent-wallet - * landings don't stack duplicate Osano listeners; the latest landing wins. + * Cleanup function for the single pending consent listener pair so repeated + * /agent-wallet landings don't stack duplicate listeners; the latest landing + * wins. * * @type {(() => void) | null} */ -let pendingConsentListener = null +let pendingConsentCleanup = null /** * Returns true when the visitor has granted Osano MARKETING consent. @@ -72,32 +81,41 @@ function hasMarketingConsent() { } /** - * Defers `commit` until the visitor saves Osano preferences accepting - * marketing cookies. No-ops when Osano is unavailable, so nothing is written - * without a consent signal. Only one landing stays pending at a time. + * Defers `commit` until the visitor's Osano MARKETING consent is confirmed. + * + * Listens on `document` for both `osano-cm-initialized` (covers returning + * visitors whose stored consent resolves after the script loads) and + * `osano-cm-consent-saved` (covers new visitors who interact with the banner). + * Using `document` instead of `Osano.cm` means the listeners work even when + * the Osano script hasn't loaded yet. Only one landing stays pending at a time. * * @param {() => void} commit */ function deferUntilConsent(commit) { - const osanoCm = window.Osano?.cm - - if (!osanoCm?.addEventListener) return - - // Replace any earlier pending listener so only the latest landing commits. - if (pendingConsentListener) { - osanoCm.removeEventListener?.(CONSENT_SAVED_EVENT, pendingConsentListener) + // Replace any earlier pending listener pair so only the latest landing commits. + if (pendingConsentCleanup) { + pendingConsentCleanup() } - const listener = () => { - if (!hasMarketingConsent()) return + let committed = false + + const tryCommit = () => { + if (committed || !hasMarketingConsent()) return - osanoCm.removeEventListener?.(CONSENT_SAVED_EVENT, listener) - pendingConsentListener = null + committed = true + cleanup() commit() } - pendingConsentListener = listener - osanoCm.addEventListener(CONSENT_SAVED_EVENT, listener) + const cleanup = () => { + document.removeEventListener(CONSENT_INITIALIZED_EVENT, tryCommit) + document.removeEventListener(CONSENT_SAVED_EVENT, tryCommit) + pendingConsentCleanup = null + } + + pendingConsentCleanup = cleanup + document.addEventListener(CONSENT_INITIALIZED_EVENT, tryCommit) + document.addEventListener(CONSENT_SAVED_EVENT, tryCommit) } /** From da7aa6e090fc7ac8265d7c42ee5f0d219e526e7c Mon Sep 17 00:00:00 2001 From: hieu-w Date: Thu, 24 Sep 2026 17:26:01 +0700 Subject: [PATCH 4/4] refactor: rename consent handling functions and improve cookie deletion logic - Renamed `deferUntilConsent` to `watchConsent` for clarity. - Updated the consent listener cleanup mechanism to prevent duplicate listeners. - Added a new `deleteCookie` function to handle cookie deletion when consent is withdrawn. - Enhanced comments for better understanding of consent lifecycle events. --- src/lib/attribution-cookie.js | 71 ++++++++++++++++++++--------------- 1 file changed, 41 insertions(+), 30 deletions(-) diff --git a/src/lib/attribution-cookie.js b/src/lib/attribution-cookie.js index 8370a0e553e..f9b5e29b347 100644 --- a/src/lib/attribution-cookie.js +++ b/src/lib/attribution-cookie.js @@ -54,13 +54,13 @@ const CONSENT_INITIALIZED_EVENT = 'osano-cm-initialized' const CONSENT_SAVED_EVENT = 'osano-cm-consent-saved' /** - * Cleanup function for the single pending consent listener pair so repeated + * Cleanup function for the active consent listener pair so repeated * /agent-wallet landings don't stack duplicate listeners; the latest landing * wins. * * @type {(() => void) | null} */ -let pendingConsentCleanup = null +let activeConsentCleanup = null /** * Returns true when the visitor has granted Osano MARKETING consent. @@ -81,41 +81,54 @@ function hasMarketingConsent() { } /** - * Defers `commit` until the visitor's Osano MARKETING consent is confirmed. + * Deletes the `mm_attribution` cookie by setting it in the past with the same + * domain/path attributes so the browser removes it immediately. + */ +function deleteCookie() { + document.cookie = `${COOKIE_NAME}=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/; domain=.metamask.io; secure; sameSite=lax` +} + +/** + * Listens for Osano consent lifecycle events and reacts to both grants and + * withdrawals for the lifetime of the current landing. + * + * Uses `document` rather than `Osano.cm` so the listeners work even when the + * Osano script hasn't loaded yet. Only one landing stays active at a time. * - * Listens on `document` for both `osano-cm-initialized` (covers returning - * visitors whose stored consent resolves after the script loads) and - * `osano-cm-consent-saved` (covers new visitors who interact with the banner). - * Using `document` instead of `Osano.cm` means the listeners work even when - * the Osano script hasn't loaded yet. Only one landing stays pending at a time. + * - `osano-cm-initialized` — covers returning visitors whose stored consent + * resolves after the script loads. + * - `osano-cm-consent-saved` — fires each time the visitor saves prefs; the + * listener stays active so a later withdrawal deletes the cookie. * * @param {() => void} commit */ -function deferUntilConsent(commit) { - // Replace any earlier pending listener pair so only the latest landing commits. - if (pendingConsentCleanup) { - pendingConsentCleanup() +function watchConsent(commit) { + // Replace any earlier listener pair so only the latest landing is active. + if (activeConsentCleanup) { + activeConsentCleanup() } - let committed = false - - const tryCommit = () => { - if (committed || !hasMarketingConsent()) return + const onConsentInitialized = () => { + if (hasMarketingConsent()) commit() + } - committed = true - cleanup() - commit() + const onConsentSaved = () => { + if (hasMarketingConsent()) { + commit() + } else { + deleteCookie() + } } const cleanup = () => { - document.removeEventListener(CONSENT_INITIALIZED_EVENT, tryCommit) - document.removeEventListener(CONSENT_SAVED_EVENT, tryCommit) - pendingConsentCleanup = null + document.removeEventListener(CONSENT_INITIALIZED_EVENT, onConsentInitialized) + document.removeEventListener(CONSENT_SAVED_EVENT, onConsentSaved) + activeConsentCleanup = null } - pendingConsentCleanup = cleanup - document.addEventListener(CONSENT_INITIALIZED_EVENT, tryCommit) - document.addEventListener(CONSENT_SAVED_EVENT, tryCommit) + activeConsentCleanup = cleanup + document.addEventListener(CONSENT_INITIALIZED_EVENT, onConsentInitialized) + document.addEventListener(CONSENT_SAVED_EVENT, onConsentSaved) } /** @@ -294,13 +307,11 @@ export function writeAttributionCookie(pathname, search) { } // Respect the visitor's cookie-consent choice: attribution is marketing - // tracking, so only persist it once MARKETING consent has been granted. If - // consent isn't granted yet, wait for the visitor to save their preferences. + // tracking, so only persist it once MARKETING consent has been granted. + // If the visitor later withdraws consent, the cookie is deleted. if (hasMarketingConsent()) { commit() - - return } - deferUntilConsent(commit) + watchConsent(commit) }