Skip to content
Open
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
5 changes: 4 additions & 1 deletion docusaurus.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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} */
({
Expand Down
17 changes: 17 additions & 0 deletions src/client/attribution-cookie.js
Original file line number Diff line number Diff line change
@@ -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)
}
207 changes: 207 additions & 0 deletions src/lib/attribution-cookie.js
Original file line number Diff line number Diff line change
@@ -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": "<ISO>" }`
*/

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<string, string>}
*/
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<string, string>} incoming
* @param {Record<string, string>} existing
* @param {number} max
* @returns {Record<string, string>}
*/
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<string, string>, click: Record<string, string> }}
*/
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`
}
Loading