Skip to content

Commit b8a6501

Browse files
Sync public snapshot from freebuff-private
Source: CodebuffAI/freebuff-private@6f1b75415c9e7bddfa7d81be7515b7e27c2887f2
1 parent 3c07b68 commit b8a6501

3 files changed

Lines changed: 189 additions & 5 deletions

File tree

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

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { describe, expect, mock, test } from 'bun:test'
22
import {
33
buildPaidSocialRequest,
4+
hashPaidSocialEmail,
45
paidSocialId,
56
sendPaidSocialConversion,
67
xEventId,
@@ -43,7 +44,125 @@ const base: Omit<SendPaidSocialConversionParams, 'config'> = {
4344
campaign: { utm_campaign: 'internal-only-campaign' },
4445
},
4546
}
47+
const emailHash =
48+
'5806dc6b2f04fb728708a8f7b81c14edcd4fba36f77914cf0c9368d9a3a25f76'
4649
describe('paid social transport contracts', () => {
50+
test('normalizes a real email before unsalted SHA256 and rejects invalid input', () => {
51+
expect(hashPaidSocialEmail(' Test@X.com ')).toBe(emailHash)
52+
for (const value of [
53+
undefined,
54+
null,
55+
1,
56+
'',
57+
'not-email',
58+
'a@@b.com',
59+
'a b@c.com',
60+
`${'x'.repeat(255)}@x.com`,
61+
])
62+
expect(hashPaidSocialEmail(value)).toBeUndefined()
63+
})
64+
test.each([x, xPixelToken])(
65+
'X accepts genuine signup and native email matching without a click',
66+
(config) => {
67+
for (const eventName of [
68+
'CompleteRegistration',
69+
'CodingActivation',
70+
] as const) {
71+
const request = buildPaidSocialRequest({
72+
...base,
73+
config,
74+
eventName,
75+
attribution: { userAgent: 'Browser', hashedEmail: emailHash },
76+
})
77+
expect(request.body).toEqual({
78+
conversions: [
79+
{
80+
conversion_time: '2026-09-18T12:00:00.000Z',
81+
event_id: `${config.pixelToken ? 'tw-abc-' : ''}${eventName === 'CompleteRegistration' ? 'signup' : 'activation'}`,
82+
identifiers: [{ hashed_email: emailHash }],
83+
conversion_id: 'stable-occurrence',
84+
},
85+
],
86+
})
87+
expect(JSON.stringify(request.body)).not.toContain('internal-account')
88+
expect(JSON.stringify(request.body)).not.toContain('user_agent')
89+
expect(JSON.stringify(request.body)).not.toContain('url')
90+
}
91+
},
92+
)
93+
test('X includes both genuine matching identifiers when available', () => {
94+
const request = buildPaidSocialRequest({
95+
...base,
96+
config: xPixelToken,
97+
attribution: { ...base.attribution, hashedEmail: emailHash },
98+
})
99+
expect(request.body.conversions).toMatchObject([
100+
{ identifiers: [{ twclid: 'click-id', hashed_email: emailHash }] },
101+
])
102+
})
103+
test.each([
104+
{ clickId: undefined },
105+
{ clickId: undefined, hashedEmail: 'raw@example.com' },
106+
{ clickId: undefined, hashedEmail: emailHash.toUpperCase() },
107+
{ clickId: 'https://private/path', hashedEmail: emailHash },
108+
{ clickId: '', hashedEmail: emailHash },
109+
{ hashedEmail: 'not-a-hash' },
110+
{ userAgent: '' },
111+
{ userAgent: ' '.repeat(10) },
112+
{ userAgent: 'a'.repeat(513) },
113+
])(
114+
'X rejects missing or malformed matching data before network delivery',
115+
async (fields) => {
116+
const fetchImpl = mock(async () =>
117+
Response.json({ data: { conversions_processed: 1 } }),
118+
) as unknown as typeof fetch
119+
await expect(
120+
sendPaidSocialConversion({
121+
...base,
122+
config: xPixelToken,
123+
attribution: { ...base.attribution, ...fields },
124+
fetchImpl,
125+
}),
126+
).rejects.toThrow('Invalid paid social matching data')
127+
expect(fetchImpl).not.toHaveBeenCalled()
128+
},
129+
)
130+
test('TikTok organic registration omits the click and never forwards X email matching', () => {
131+
const request = buildPaidSocialRequest({
132+
...base,
133+
config: tiktok,
134+
attribution: {
135+
...base.attribution,
136+
clickId: undefined,
137+
hashedEmail: emailHash,
138+
},
139+
})
140+
expect(request.body.data).toMatchObject([
141+
{
142+
user: {
143+
external_id: paidSocialId('tiktok', 'internal-account'),
144+
user_agent: 'Browser',
145+
},
146+
},
147+
])
148+
const body = JSON.stringify(request.body)
149+
expect(body).not.toContain('ttclid')
150+
expect(body).not.toContain('email')
151+
expect(body).not.toContain(emailHash)
152+
})
153+
test.each([
154+
{ userAgent: '' },
155+
{ userAgent: 'x'.repeat(513) },
156+
{ clickId: 'malformed click' },
157+
])('TikTok still rejects invalid browser matching data', (fields) => {
158+
expect(() =>
159+
buildPaidSocialRequest({
160+
...base,
161+
config: tiktok,
162+
attribution: { ...base.attribution, ...fields },
163+
}),
164+
).toThrow()
165+
})
47166
test('X sends the configured event with stable occurrence, ISO time and only click matching', () => {
48167
const request = buildPaidSocialRequest({ ...base, config: x })
49168
expect(request.url).toBe(

common/src/paid-social-capi.ts

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { createHash, createHmac, randomBytes } from 'node:crypto'
22
import {
3+
normalizePaidSocialAttribution,
34
paidSocialSignupPath,
45
type PaidSocialAttribution,
56
type PaidSocialEvent,
@@ -26,6 +27,15 @@ export function paidSocialId(platform: PaidSocialPlatform, value: string) {
2627
return createHash('sha256').update(`${platform}-capi:${value}`).digest('hex')
2728
}
2829

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+
}
38+
2939
const encode = (value: string) =>
3040
encodeURIComponent(value).replace(
3141
/[!'()*]/g,
@@ -91,10 +101,15 @@ export function buildPaidSocialRequest(
91101
body: Record<string, unknown>
92102
} {
93103
const { config } = params
104+
const attribution = normalizePaidSocialAttribution(
105+
config.platform,
106+
params.attribution,
107+
)
108+
if (!attribution) throw new Error('Invalid paid social matching data')
94109
if (config.platform === 'tiktok') {
95110
if (params.eventName !== 'CompleteRegistration')
96111
throw new Error('Native coding activation is not a TikTok website event')
97-
const signupPath = paidSocialSignupPath(params.attribution.signupPath)
112+
const signupPath = paidSocialSignupPath(attribution.signupPath)
98113
if (!signupPath)
99114
throw new Error(
100115
'TikTok registration requires a known public auth callback',
@@ -114,9 +129,9 @@ export function buildPaidSocialRequest(
114129
event_time: Math.floor(params.eventAt.getTime() / 1000),
115130
event_id: params.eventId,
116131
user: {
117-
ttclid: params.attribution.clickId,
132+
...(attribution.clickId ? { ttclid: attribution.clickId } : {}),
118133
external_id: paidSocialId('tiktok', params.userId),
119-
user_agent: params.attribution.userAgent,
134+
user_agent: attribution.userAgent,
120135
},
121136
// Runtime-validated static public OAuth path, with no query or fragment.
122137
page: { url: `https://freebuff.com${signupPath}` },
@@ -149,7 +164,14 @@ export function buildPaidSocialRequest(
149164
config.pixelToken !== undefined
150165
? `tw-${config.pixelId}-${eventId}`
151166
: eventId,
152-
identifiers: [{ twclid: params.attribution.clickId }],
167+
identifiers: [
168+
{
169+
...(attribution.clickId ? { twclid: attribution.clickId } : {}),
170+
...(attribution.hashedEmail
171+
? { hashed_email: attribution.hashedEmail }
172+
: {}),
173+
},
174+
],
153175
conversion_id: params.eventId,
154176
},
155177
],

common/src/util/paid-social-conversions.ts

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,9 @@ export function paidSocialSignupPath(
1919
return PAID_SOCIAL_SIGNUP_PATHS.find((path) => path === value)
2020
}
2121
export type PaidSocialAttribution = {
22-
clickId: string
22+
clickId?: string
23+
/** X matching only. Never retain or send this field for TikTok. */
24+
hashedEmail?: string
2325
userAgent: string
2426
signupPath?: PaidSocialSignupPath
2527
/** First-party cohort dimensions only: never put these in a vendor payload. */
@@ -57,6 +59,47 @@ export function validPaidSocialClickId(value: unknown): string | undefined {
5759
: undefined
5860
}
5961

62+
export function validPaidSocialHashedEmail(value: unknown): string | undefined {
63+
return typeof value === 'string' && /^[a-f0-9]{64}$/.test(value)
64+
? value
65+
: undefined
66+
}
67+
68+
/** Validate both enrollment and stored data, retaining only approved fields. */
69+
export function normalizePaidSocialAttribution(
70+
platform: PaidSocialPlatform,
71+
value: unknown,
72+
): PaidSocialAttribution | undefined {
73+
if (!value || typeof value !== 'object' || Array.isArray(value))
74+
return undefined
75+
const input = value as Record<string, unknown>
76+
const clickId = validPaidSocialClickId(input.clickId)
77+
if (input.clickId !== undefined && !clickId) return undefined
78+
if (
79+
typeof input.userAgent !== 'string' ||
80+
!input.userAgent.trim() ||
81+
input.userAgent.length > 512
82+
)
83+
return undefined
84+
const signupPath = paidSocialSignupPath(input.signupPath)
85+
const hashedEmail =
86+
platform === 'x' ? validPaidSocialHashedEmail(input.hashedEmail) : undefined
87+
if (
88+
platform === 'x'
89+
? (!clickId && !hashedEmail) ||
90+
(input.hashedEmail !== undefined && !hashedEmail)
91+
: !signupPath
92+
)
93+
return undefined
94+
return {
95+
clickId,
96+
...(hashedEmail ? { hashedEmail } : {}),
97+
userAgent: input.userAgent,
98+
signupPath,
99+
campaign: paidSocialCampaign(input.campaign),
100+
}
101+
}
102+
60103
export function paidSocialOptedOut(headers: {
61104
get(name: string): string | null
62105
}) {

0 commit comments

Comments
 (0)