From 41c8252c53b8f1040d3fee0c6e7a3be21b913141 Mon Sep 17 00:00:00 2001 From: hieu-w Date: Tue, 15 Sep 2026 18:18:59 +0700 Subject: [PATCH] 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` +}