Skip to content

Commit 921b8df

Browse files
Sync public snapshot from freebuff-private
Source: CodebuffAI/freebuff-private@d1a3e09ac6158143365eb8174549ea2ed3bee93e
1 parent bb40c65 commit 921b8df

9 files changed

Lines changed: 170 additions & 37 deletions

‎bun.lock‎

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎common/src/__tests__/meta-capi.test.ts‎

Lines changed: 46 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,20 @@
11
import { createHash } from 'node:crypto'
22
import { describe, expect, test } from 'bun:test'
33

4+
import { hashMatchingEmail } from '../matching-hash'
45
import {
56
buildMetaConversionBody,
67
metaConversionId,
78
sendMetaConversion,
89
type SendMetaConversionParams,
910
} from '../meta-capi'
1011
import {
12+
metaClickCookieValue,
1113
metaTrackingOptedOut,
1214
validMetaBrowserId,
1315
} from '../util/meta-conversions'
1416

17+
const hashedEmail = hashMatchingEmail(' Person@Example.com ')!
1518
const params: SendMetaConversionParams = {
1619
pixelId: '123456789',
1720
accessToken: 'secret-never-in-body',
@@ -23,6 +26,8 @@ const params: SendMetaConversionParams = {
2326
attribution: {
2427
fbc: 'fb.1.1790000000000.click',
2528
fbp: 'fb.1.1790000000000.1234',
29+
hashedEmail,
30+
ipAddress: '203.0.113.9',
2631
userAgent: 'Browser',
2732
},
2833
}
@@ -59,28 +64,47 @@ describe('Meta conversion payload and attribution', () => {
5964
).toThrow('positive confirmed USD payment')
6065
}
6166
})
62-
test('uses seconds and hashed canonical identity, without email, URL parameters, or token', () => {
67+
test('uses seconds, hashed identity and every enrollment match key, never the raw email, URL parameters or token', () => {
6368
const body = buildMetaConversionBody(params)
6469
expect(body.data[0]?.event_time).toBe(1789907420)
65-
expect(body.data[0]?.user_data.external_id).toEqual([
66-
createHash('sha256').update('canonical-user').digest('hex'),
67-
])
68-
expect(body.data[0]?.user_data.fbc).toBe(params.attribution.fbc)
70+
expect(body.data[0]?.user_data).toEqual({
71+
external_id: [
72+
createHash('sha256').update('canonical-user').digest('hex'),
73+
],
74+
em: [hashedEmail],
75+
fbc: params.attribution.fbc,
76+
fbp: params.attribution.fbp,
77+
client_ip_address: '203.0.113.9',
78+
client_user_agent: 'Browser',
79+
})
6980
expect(body.data[0]?.action_source).toBe('website')
7081
expect(body.data[0]?.event_source_url).toBe('https://freebuff.com/')
71-
expect(JSON.stringify(body)).not.toContain('canonical-user')
72-
expect(JSON.stringify(body)).not.toContain('secret-never-in-body')
73-
expect(body.data[0]?.user_data).not.toHaveProperty('em')
82+
const serialized = JSON.stringify(body)
83+
expect(serialized).not.toContain('canonical-user')
84+
expect(serialized).not.toContain('secret-never-in-body')
85+
expect(serialized.toLowerCase()).not.toContain('person@example.com')
86+
})
87+
test('omits match keys it does not have rather than sending empty values', () => {
88+
const userData = buildMetaConversionBody({
89+
...params,
90+
attribution: { fbp: params.attribution.fbp, userAgent: 'Browser' },
91+
}).data[0]?.user_data
92+
expect(userData).not.toHaveProperty('em')
93+
expect(userData).not.toHaveProperty('fbc')
94+
expect(userData).not.toHaveProperty('client_ip_address')
95+
expect(userData?.client_user_agent).toBe('Browser')
7496
})
75-
test('reports native coding as other, without a fictional website event or browser user agent', () => {
97+
test('reports native coding as other, without a fictional website event, but with the same person match keys', () => {
7698
const event = buildMetaConversionBody({
7799
...params,
78100
eventName: 'CodingActivation',
79101
surface: 'desktop',
80102
}).data[0]
81103
expect(event?.action_source).toBe('other')
82104
expect(event).not.toHaveProperty('event_source_url')
83-
expect(event?.user_data).not.toHaveProperty('client_user_agent')
105+
expect(event?.user_data.client_user_agent).toBe('Browser')
106+
expect(event?.user_data.client_ip_address).toBe('203.0.113.9')
107+
expect(event?.user_data.em).toEqual([hashedEmail])
84108
expect(metaConversionId('CompleteRegistration', params.userId)).not.toBe(
85109
metaConversionId('CodingActivation', params.userId),
86110
)
@@ -100,6 +124,18 @@ describe('Meta conversion payload and attribution', () => {
100124
expect(metaTrackingOptedOut(new Headers({ DNT: '1' }))).toBe(true)
101125
expect(metaTrackingOptedOut(new Headers())).toBe(false)
102126
})
127+
test("builds a first-party _fbc in Meta's own shape that the server-side validator accepts", () => {
128+
const value = metaClickCookieValue('IwAR0abc_-123', 1790000000000.7)
129+
expect(value).toBe('fb.1.1790000000000.IwAR0abc_-123')
130+
expect(validMetaBrowserId(value)).toBe(value)
131+
for (const clickId of ['', 'not a click', 'a'.repeat(401), undefined])
132+
expect(metaClickCookieValue(clickId, 1790000000000)).toBeUndefined()
133+
})
134+
test('normalizes the email before hashing and refuses anything that is not one', () => {
135+
expect(hashMatchingEmail('person@example.com')).toBe(hashedEmail)
136+
for (const value of [undefined, null, 1, '', 'not-email', 'a b@c.com'])
137+
expect(hashMatchingEmail(value)).toBeUndefined()
138+
})
103139
})
104140

105141
describe('Meta conversion transport', () => {

‎common/src/__tests__/paid-social-capi.test.ts‎

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,8 @@ const base: Omit<SendPaidSocialConversionParams, 'config'> = {
4040
attribution: {
4141
clickId: 'click-id',
4242
userAgent: 'Browser',
43+
ipAddress: '203.0.113.9',
44+
ttp: 'CiXyZ.tt.1',
4345
signupPath: '/api/auth/callback/github',
4446
campaign: { utm_campaign: 'internal-only-campaign' },
4547
},
@@ -107,6 +109,7 @@ describe('paid social transport contracts', () => {
107109
{ clickId: 'https://private/path', hashedEmail: emailHash },
108110
{ clickId: '', hashedEmail: emailHash },
109111
{ hashedEmail: 'not-a-hash' },
112+
{ ipAddress: '999.1.1.1' },
110113
{ userAgent: '' },
111114
{ userAgent: ' '.repeat(10) },
112115
{ userAgent: 'a'.repeat(513) },
@@ -127,7 +130,7 @@ describe('paid social transport contracts', () => {
127130
expect(fetchImpl).not.toHaveBeenCalled()
128131
},
129132
)
130-
test('TikTok organic registration omits the click and never forwards X email matching', () => {
133+
test('TikTok organic registration omits the click but forwards the hashed email, address and _ttp', () => {
131134
const request = buildPaidSocialRequest({
132135
...base,
133136
config: tiktok,
@@ -140,20 +143,24 @@ describe('paid social transport contracts', () => {
140143
expect(request.body.data).toMatchObject([
141144
{
142145
user: {
146+
ttp: 'CiXyZ.tt.1',
143147
external_id: paidSocialId('tiktok', 'internal-account'),
148+
email: emailHash,
149+
ip: '203.0.113.9',
144150
user_agent: 'Browser',
145151
},
146152
},
147153
])
148154
const body = JSON.stringify(request.body)
149155
expect(body).not.toContain('ttclid')
150-
expect(body).not.toContain('email')
151-
expect(body).not.toContain(emailHash)
156+
expect(body.toLowerCase()).not.toContain('test@x.com')
152157
})
153158
test.each([
154159
{ userAgent: '' },
155160
{ userAgent: 'x'.repeat(513) },
156161
{ clickId: 'malformed click' },
162+
{ ttp: 'bad ttp' },
163+
{ ipAddress: 'not-an-ip' },
157164
])('TikTok still rejects invalid browser matching data', (fields) => {
158165
expect(() =>
159166
buildPaidSocialRequest({
@@ -249,7 +256,9 @@ describe('paid social transport contracts', () => {
249256
event_id: 'stable-occurrence',
250257
user: {
251258
ttclid: 'click-id',
259+
ttp: 'CiXyZ.tt.1',
252260
external_id: paidSocialId('tiktok', 'internal-account'),
261+
ip: '203.0.113.9',
253262
user_agent: 'Browser',
254263
},
255264
page: { url: 'https://freebuff.com/api/auth/callback/github' },

‎common/src/matching-hash.ts‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import { createHash } from 'node:crypto'
2+
3+
/**
4+
* The one email normalization every vendor documents for its hashed match key
5+
* (Meta `em`, TikTok `email`, X `hashed_email`): trim, lowercase, unsalted
6+
* SHA-256. Server use only; the raw address is never stored or logged.
7+
*/
8+
export function hashMatchingEmail(value: unknown): string | undefined {
9+
if (typeof value !== 'string') return undefined
10+
const email = value.trim().toLowerCase()
11+
if (email.length > 254 || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email))
12+
return undefined
13+
return createHash('sha256').update(email).digest('hex')
14+
}

‎common/src/meta-capi.ts‎

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,11 @@ export const META_GRAPH_API_VERSION = 'v26.0'
1212
export type MetaConversionAttribution = {
1313
fbc?: string
1414
fbp?: string
15-
/** Original signup browser agent, used only for the website signup event. */
15+
/** Unsalted SHA-256 of the trimmed, lowercased account email (Meta's `em`). */
16+
hashedEmail?: string
17+
/** Client address resolved at enrollment, sent as `client_ip_address`. */
18+
ipAddress?: string
19+
/** Enrollment browser agent, sent as `client_user_agent` on every event. */
1620
userAgent: string
1721
}
1822

@@ -52,6 +56,7 @@ export function buildMetaConversionBody(params: SendMetaConversionParams) {
5256
) {
5357
throw new Error('Subscribe requires a positive confirmed USD payment')
5458
}
59+
const { attribution } = params
5560
return {
5661
data: [
5762
{
@@ -64,13 +69,19 @@ export function buildMetaConversionBody(params: SendMetaConversionParams) {
6469
...(params.surface === 'web'
6570
? { event_source_url: 'https://freebuff.com/' }
6671
: {}),
72+
// Every key below identifies the PERSON, not the event's own network
73+
// hop, so the enrollment browser's agent and address ride on native
74+
// events too. Match quality is what decides whether Meta can tie a
75+
// conversion back to the ad; measured 4.7/10 with fbp+external_id only.
6776
user_data: {
6877
external_id: [metaExternalId(params.userId)],
69-
...(params.attribution.fbc ? { fbc: params.attribution.fbc } : {}),
70-
...(params.attribution.fbp ? { fbp: params.attribution.fbp } : {}),
71-
...(params.surface === 'web'
72-
? { client_user_agent: params.attribution.userAgent }
78+
...(attribution.hashedEmail ? { em: [attribution.hashedEmail] } : {}),
79+
...(attribution.fbc ? { fbc: attribution.fbc } : {}),
80+
...(attribution.fbp ? { fbp: attribution.fbp } : {}),
81+
...(attribution.ipAddress
82+
? { client_ip_address: attribution.ipAddress }
7383
: {}),
84+
client_user_agent: attribution.userAgent,
7485
},
7586
custom_data: {
7687
surface: params.surface,

‎common/src/paid-social-capi.ts‎

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { createHash, createHmac, randomBytes } from 'node:crypto'
2+
import { hashMatchingEmail } from './matching-hash'
23
import {
34
normalizePaidSocialAttribution,
45
paidSocialSignupPath,
@@ -27,14 +28,8 @@ export function paidSocialId(platform: PaidSocialPlatform, value: string) {
2728
return createHash('sha256').update(`${platform}-capi:${value}`).digest('hex')
2829
}
2930

30-
/** X's matching contract: normalized email, unsalted SHA-256; server use only. */
31-
export function hashPaidSocialEmail(value: unknown): string | undefined {
32-
if (typeof value !== 'string') return undefined
33-
const email = value.trim().toLowerCase()
34-
if (email.length > 254 || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email))
35-
return undefined
36-
return createHash('sha256').update(email).digest('hex')
37-
}
31+
/** X `hashed_email` and TikTok `email` share one normalization; server use only. */
32+
export const hashPaidSocialEmail = hashMatchingEmail
3833

3934
const encode = (value: string) =>
4035
encodeURIComponent(value).replace(
@@ -130,7 +125,12 @@ export function buildPaidSocialRequest(
130125
event_id: params.eventId,
131126
user: {
132127
...(attribution.clickId ? { ttclid: attribution.clickId } : {}),
128+
...(attribution.ttp ? { ttp: attribution.ttp } : {}),
133129
external_id: paidSocialId('tiktok', params.userId),
130+
...(attribution.hashedEmail
131+
? { email: attribution.hashedEmail }
132+
: {}),
133+
...(attribution.ipAddress ? { ip: attribution.ipAddress } : {}),
134134
user_agent: attribution.userAgent,
135135
},
136136
// Runtime-validated static public OAuth path, with no query or fragment.
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
/**
2+
* Shape checks for the identity match keys the acquisition trackers store and
3+
* forward to Meta, TikTok and X. Browser-safe on purpose: the pixel helpers
4+
* import from here, so nothing in this file may touch node:crypto or node:net.
5+
*/
6+
7+
/** Unsalted SHA-256 hex, as every vendor's `em`/`email`/`hashed_email` expects. */
8+
export function validHashedEmailHex(value: unknown): string | undefined {
9+
return typeof value === 'string' && /^[a-f0-9]{64}$/.test(value)
10+
? value
11+
: undefined
12+
}
13+
14+
const IPV4 =
15+
/^(?:(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)\.){3}(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)$/
16+
// Bounded rather than exhaustive: the resolver already validated syntax with
17+
// node's isIP before anything was stored. This only refuses junk on the way out.
18+
const IPV6 = /^(?=.*:)[0-9a-f:.]{2,45}$/i
19+
20+
export function validMatchingIpAddress(value: unknown): string | undefined {
21+
if (typeof value !== 'string') return undefined
22+
return IPV4.test(value) || IPV6.test(value) ? value : undefined
23+
}

‎common/src/util/meta-conversions.ts‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
/** Shared browser/server contract; this marker is eligibility, not a consent UI. */
22
export const META_TRACKING_PERMISSION_COOKIE = 'freebuff_meta_allowed'
3+
/** Meta's own first-party click cookie, which the pixel and our fallback both write. */
4+
export const META_CLICK_COOKIE = '_fbc'
35
export const META_CONVERSION_EVENT_NAMES = [
46
'CompleteRegistration',
57
'CodingActivation',
@@ -39,3 +41,24 @@ export function validMetaBrowserId(value: unknown): string | undefined {
3941
? value
4042
: undefined
4143
}
44+
45+
/** A landing `fbclid`, as Meta's own pixel would accept it into `_fbc`. */
46+
export function validMetaClickId(value: unknown): string | undefined {
47+
return typeof value === 'string' && /^[A-Za-z0-9_-]{1,400}$/.test(value)
48+
? value
49+
: undefined
50+
}
51+
52+
/**
53+
* Meta's documented first-party `_fbc` value, `fb.1.<creation ms>.<fbclid>`,
54+
* built by us so a click still reaches the server when the pixel script is
55+
* blocked — which on a developer audience is the common case, not the edge.
56+
* `1` is the subdomain index for a cookie on the registrable domain.
57+
*/
58+
export function metaClickCookieValue(
59+
fbclid: unknown,
60+
now: number,
61+
): string | undefined {
62+
const clickId = validMetaClickId(fbclid)
63+
return clickId ? `fb.1.${Math.floor(now)}.${clickId}` : undefined
64+
}

0 commit comments

Comments
 (0)