Skip to content
Draft
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
8 changes: 3 additions & 5 deletions src/app/actions/__tests__/invites-resolution.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,17 +31,15 @@ describe('validateInviteCode mixed-version resolution', () => {
})
)

// the wire still echoes the retired destination fields; the action's
// parser reduces the descriptor to the campaign identity (TASK-21226)
await expect(validateInviteCode('offramp')).resolves.toEqual({
data: {
success: true,
attributionResolved: false,
onboardingResolved: false,
username: '',
legacyAcquisition: {
campaignTag: 'offramp',
fallback: 'normal_app',
destination: 'offramp_migration',
},
legacyAcquisition: { campaignTag: 'offramp' },
},
})
})
Expand Down
57 changes: 19 additions & 38 deletions src/components/Invites/InvitesPage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,12 @@ let mockQueryResult: {
attributionResolved: boolean
onboardingResolved: boolean
username: string
// old-wire fields (fallback/destination) may appear in fixtures; the
// page reads only the campaign identity (TASK-21226)
legacyAcquisition?: {
campaignTag: string
fallback: 'normal_app'
destination: 'offramp_migration' | 'normal_app'
fallback?: 'normal_app'
destination?: 'offramp_migration' | 'normal_app'
}
}
isLoading: boolean
Expand Down Expand Up @@ -218,28 +220,17 @@ describe('invite and badge campaign routing boundaries', () => {
await waitFor(() => expect(mockPush).toHaveBeenCalledWith('/home'))
})

it.each([
['token-nation-2026', 'TOKEN_NATION_SP_2026'],
['nita', 'NITA'],
])('source-qualifies historic UTM alias %s and follows only the typed backend outcome', async (raw, badgeCode) => {
mockSearch = `utm_campaign=${raw}`
mockClaimBadgeCampaigns.mockResolvedValue({
transport: 'canonical',
pending: [],
claims: [
{
badgeCampaign: `utm:${raw}`,
badgeCode,
outcome: 'awarded',
},
],
})
it.each([['token-nation-2026'], ['nita'], ['offramp']])(
'treats a utm_campaign=%s-only link as a dead bare link (TASK-21226)',
async (raw) => {
mockSearch = `utm_campaign=${raw}`

render(<InvitesPage />)
render(<InvitesPage />)

await waitFor(() => expect(mockClaimBadgeCampaigns).toHaveBeenCalledWith([`utm:${raw}`]))
await waitFor(() => expect(mockPush).toHaveBeenCalledWith('/home'))
})
await waitFor(() => expect(mockReplace).toHaveBeenCalledWith('/'))
expect(mockClaimBadgeCampaigns).not.toHaveBeenCalled()
}
)

it.each([
['campaign=naija', 'naija', 'NAIJA'],
Expand All @@ -264,7 +255,6 @@ describe('invite and badge campaign routing boundaries', () => {
it.each([
['badge_campaign=offramp', 'offramp'],
['campaign=offramp', 'offramp'],
['utm_campaign=offramp', 'utm:offramp'],
])('uses confirmed acquisition navigation for badge-campaign-only %s', async (search, badgeCampaign) => {
mockSearch = search
mockClaimBadgeCampaigns.mockResolvedValue({
Expand Down Expand Up @@ -293,7 +283,6 @@ describe('invite and badge campaign routing boundaries', () => {
it.each([
['badge_campaign=offramp', 'offramp'],
['campaign=offramp', 'offramp'],
['utm_campaign=offramp', 'utm:offramp'],
['campaign=naija', 'naija'],
['campaign=terere', 'terere'],
])('queues signed-out %s for post-registration badge settlement', async (search, badgeCampaign) => {
Expand Down Expand Up @@ -532,37 +521,29 @@ describe('invite and badge campaign routing boundaries', () => {
fireEvent.click(screen.getByRole('button', { name: 'Claim your spot' }))

expect(mockSaveToCookie).toHaveBeenCalledWith('inviteCode', 'squirrelinvitesyou')
expect(mockQueuePendingBadgeCampaigns).toHaveBeenCalledWith(['utm:summer-analytics'])
// utm values stopped being badge identities (TASK-21226); nothing queues
expect(mockQueuePendingBadgeCampaigns).not.toHaveBeenCalled()
expect(mockPush).toHaveBeenCalledWith('/setup?step=signup')
})

it('consumes unknown analytics without blocking a typed system acquisition normal fallback', async () => {
it('ignores analytics UTM while a typed system acquisition follows the normal fallback', async () => {
mockSearch = 'code=SQUIRRELINVITESYOU&utm_campaign=summer-analytics'
mockQueryResult.data = {
success: true,
attributionResolved: true,
onboardingResolved: true,
username: 'peanut',
legacyAcquisition: {
campaignTag: 'arbiverseinvitesyou',
fallback: 'normal_app',
destination: 'normal_app',
},
legacyAcquisition: { campaignTag: 'arbiverseinvitesyou' },
}
mockClaimBadgeCampaigns.mockResolvedValue({
transport: 'canonical',
pending: [],
claims: [
{ badgeCampaign: 'utm:summer-analytics', outcome: 'unknown' },
{ badgeCampaign: 'arbiverseinvitesyou', outcome: 'already_owned' },
],
claims: [{ badgeCampaign: 'arbiverseinvitesyou', outcome: 'already_owned' }],
})

render(<InvitesPage />)

await waitFor(() =>
expect(mockClaimBadgeCampaigns).toHaveBeenCalledWith(['utm:summer-analytics', 'arbiverseinvitesyou'])
)
await waitFor(() => expect(mockClaimBadgeCampaigns).toHaveBeenCalledWith(['arbiverseinvitesyou']))
await waitFor(() => expect(mockPush).toHaveBeenCalledWith('/home'))
expect(mockPush).not.toHaveBeenCalledWith('/profile/peanut')
})
Expand Down
22 changes: 4 additions & 18 deletions src/components/Invites/InvitesPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,11 +30,9 @@ import {
} from './badge-campaign-context'
import {
claimAndSettlePendingBadgeCampaigns,
destinationForConfirmedBadgeCampaignAcquisition,
isConfirmedBadgeCampaignClaim,
isUnavailableBadgeCampaignClaim,
} from '@/services/badge-campaigns'
import { destinationForInviteAcquisition } from '@/services/invite-acquisition'

function InvitePageContent() {
const t = useTranslations('invites')
Expand Down Expand Up @@ -189,24 +187,12 @@ function InvitePageContent() {
}

// A validated caller continuation (notably a pending financial
// claim) outranks acquisition navigation. The small backend-owned
// destination enum is honored only after its matching claim is
// confirmed; all other outcomes fall through to the normal app.
const badgeCampaignDestination = destinationForConfirmedBadgeCampaignAcquisition(batch.claims)
const legacyDestination = legacyAcquisition
? destinationForInviteAcquisition(legacyAcquisition, batch.claims)
: '/home'
// claim) outranks everything. Bespoke campaign destinations
// retired with TASK-21226 — a campaign acquisition lands on
// /home, a plain valid invite on the inviter's profile.
const destination =
safeRedirectUri ||
(legacyDestination !== '/home'
? legacyDestination
: badgeCampaignDestination !== '/home'
? badgeCampaignDestination
: legacyAcquisition
? '/home'
: hasValidInvite
? profileUrl(inviteCodeData!.username!)
: '/home')
(legacyAcquisition ? '/home' : hasValidInvite ? profileUrl(inviteCodeData!.username!) : '/home')
router.push(destination)
})
.catch((error) => {
Expand Down
48 changes: 12 additions & 36 deletions src/components/Invites/badge-campaign-context.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { readFileSync, readdirSync } from 'node:fs'
import { join } from 'node:path'
import {
MAX_BADGE_CAMPAIGN_IDENTITY_LENGTH,
MAX_RAW_BADGE_CAMPAIGN_LENGTH,
MAX_BADGE_CAMPAIGNS,
LEGACY_PENDING_BADGE_CAMPAIGN_COOKIE,
Expand Down Expand Up @@ -87,28 +86,22 @@ describe('badge campaign identity transport', () => {
expect(badgeCampaignsFromSearchParams(new URLSearchParams(query))).toEqual([])
})

it('source-qualifies arbitrary UTM values without deciding whether they award', () => {
expect(badgeCampaignsFromSearchParams(new URLSearchParams('utm_campaign=Summer-Analytics'))).toEqual([
'utm:Summer-Analytics',
])
it('never turns a UTM value into a badge identity (TASK-21226)', () => {
expect(badgeCampaignsFromSearchParams(new URLSearchParams('utm_campaign=Summer-Analytics'))).toEqual([])
expect(badgeCampaignsFromSearchParams(new URLSearchParams('badge_campaign=arbitrum'))).toEqual(['arbitrum'])
expect(badgeCampaignsFromSearchParams(new URLSearchParams('utm_campaign=arbitrum'))).toEqual(['utm:arbitrum'])
expect(badgeCampaignsFromSearchParams(new URLSearchParams('utm_campaign=arbitrum'))).toEqual([])
expect(badgeCampaignsFromSearchParams(new URLSearchParams('badge_campaign=offramp'))).toEqual(['offramp'])
expect(badgeCampaignsFromSearchParams(new URLSearchParams('utm_campaign=offramp'))).toEqual(['utm:offramp'])
expect(badgeCampaignsFromSearchParams(new URLSearchParams('utm_campaign=offramp'))).toEqual([])
// an explicitly published utm: identity stays a valid canonical value
expect(badgeCampaignsFromSearchParams(new URLSearchParams('badge_campaign=utm:offramp'))).toEqual([
'utm:offramp',
])
expect(badgeCampaignsFromSearchParams(new URLSearchParams('utm_campaign=utm:offramp'))).toEqual([
'utm:utm:offramp',
])
expect(badgeCampaignsFromSearchParams(new URLSearchParams('badge_campaign=IRL_NOMADS'))).toEqual(['IRL_NOMADS'])
expect(badgeCampaignsFromSearchParams(new URLSearchParams('badge_campaign=irl_nomads'))).toEqual(['irl_nomads'])
expect(badgeCampaignsFromSearchParams(new URLSearchParams('utm_campaign=irl-nomads'))).toEqual([
'utm:irl-nomads',
])
expect(badgeCampaignsFromSearchParams(new URLSearchParams('utm_campaign=irl-nomads'))).toEqual([])
})

it('source-qualifies every published content UTM without a frontend allowlist', () => {
it('ignores every published content UTM as a badge identity', () => {
const publishedValues = new Set<string>()
for (const file of filesUnder(join(process.cwd(), 'src/content'))) {
const source = readFileSync(file, 'utf8')
Expand All @@ -118,31 +111,15 @@ describe('badge campaign identity transport', () => {
}

// Guard the corpus scan itself with the known collision that motivated
// source qualification, then apply the generic transport invariant to
// every current and future literal value discovered above.
// source qualification, then confirm no published UTM value can mint a
// badge identity now that the last alias retired (TASK-21226).
expect(publishedValues.has('arbitrum')).toBe(true)
expect(publishedValues.size).toBeGreaterThan(0)
for (const rawValue of publishedValues) {
expect(badgeCampaignsFromSearchParams(new URLSearchParams({ utm_campaign: rawValue }))).toEqual([
`utm:${rawValue}`,
])
expect(badgeCampaignsFromSearchParams(new URLSearchParams({ utm_campaign: rawValue }))).toEqual([])
}
})

it('accepts a 64-character raw UTM value within the 68-character qualified transport bound', () => {
const maximumRaw = 'x'.repeat(MAX_RAW_BADGE_CAMPAIGN_LENGTH)

const badgeCampaigns = badgeCampaignsFromSearchParams(new URLSearchParams(`utm_campaign=${maximumRaw}`))

expect(badgeCampaigns).toEqual([`utm:${maximumRaw}`])
expect(badgeCampaigns[0]).toHaveLength(MAX_BADGE_CAMPAIGN_IDENTITY_LENGTH)
expect(
badgeCampaignsFromSearchParams(
new URLSearchParams(`utm_campaign=${'x'.repeat(MAX_RAW_BADGE_CAMPAIGN_LENGTH + 1)}`)
)
).toEqual([])
})

it('never infers a campaign from code-only creator attribution', () => {
expect(badgeCampaignsFromSearchParams(new URLSearchParams('code=juanacervio'))).toEqual([])
})
Expand All @@ -164,9 +141,8 @@ describe('badge campaign identity transport', () => {
)
).toBe('canonical-first')
expect(badgeCampaignForLegacyWire(new URLSearchParams('campaignTag=published-legacy'))).toBe('published-legacy')
expect(badgeCampaignForLegacyWire(new URLSearchParams('utm_campaign=historic-alias'))).toBe(
'utm:historic-alias'
)
// utm values stopped being badge identities (TASK-21226)
expect(badgeCampaignForLegacyWire(new URLSearchParams('utm_campaign=historic-alias'))).toBeUndefined()
expect(badgeCampaignForLegacyWire(new URLSearchParams('code=creator'))).toBeUndefined()
})

Expand Down
24 changes: 10 additions & 14 deletions src/components/Invites/badge-campaign-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,12 +20,14 @@ export const LEGACY_PENDING_BADGE_CAMPAIGNS_V2_COOKIE = 'campaignTagsV2'
export const PENDING_BADGE_CAMPAIGN_INTENT_EPOCH_STORAGE_KEY = 'campaignIntentEpoch'
export const BADGE_CAMPAIGN_QUERY_PARAM = 'badge_campaign'
export const MAX_BADGE_CAMPAIGNS = 20
export const LEGACY_UTM_BADGE_CAMPAIGN_PREFIX = 'utm:'
/** Maximum raw length for explicit and UTM values before source qualification. */
/** Maximum raw length for explicit values before source qualification. */
export const MAX_RAW_BADGE_CAMPAIGN_LENGTH = 64
/** `utm:` plus the maximum 64-character raw UTM value. */
export const MAX_BADGE_CAMPAIGN_IDENTITY_LENGTH =
MAX_RAW_BADGE_CAMPAIGN_LENGTH + LEGACY_UTM_BADGE_CAMPAIGN_PREFIX.length
/**
* Deferred install payloads queued before the utm path retired (TASK-21226)
* can still carry 68-character `utm:`-prefixed identities; keep accepting
* that wire bound so in-flight queues settle instead of being rejected.
*/
export const MAX_BADGE_CAMPAIGN_IDENTITY_LENGTH = MAX_RAW_BADGE_CAMPAIGN_LENGTH + 'utm:'.length

// Explicit logout invalidates every settlement that began under the prior
// account. The durable epoch is shared by tabs; the process counter preserves
Expand Down Expand Up @@ -102,15 +104,9 @@ function badgeCampaignsFromSearchParamsWithExplicitBound(
return sanitizeBadgeCampaignIdentities(legacyExplicitValues, explicitMaxLength)
}

// utm_campaign is analytics-only. It is still source-qualified and forwarded
// so the backend can honour its own dated compatibility aliases (today: only
// `offramp`, which expires 2026-09-07). Everything else resolves `unknown`,
// so a marketing link can no longer mint a badge as a side effect.
const utmValues = sanitizeBadgeCampaignIdentities(
split(searchParams.getAll('utm_campaign')),
MAX_RAW_BADGE_CAMPAIGN_LENGTH
)
return sanitizeBadgeCampaignIdentities(utmValues.map((value) => `${LEGACY_UTM_BADGE_CAMPAIGN_PREFIX}${value}`))
// utm_campaign is analytics-only (TASK-21226 retired the last badge alias);
// it never becomes a badge identity.
return []
}

/** Parse raw public URL values. Canonical slugs and legacy raw aliases are at most 64 characters. */
Expand Down
4 changes: 0 additions & 4 deletions src/hooks/__tests__/useZeroDev-invite-onboarding.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -74,10 +74,6 @@ jest.mock('@/services/invites', () => ({
jest.mock('@/services/invite-acquisition', () => ({
settleAcceptedInviteAcquisition: (...args: unknown[]) => mockSettleAcceptedInviteAcquisition(...args),
}))
jest.mock('@/services/registration-acquisition', () => ({
persistRegistrationBadgeCampaignDestination: (...args: unknown[]) =>
mockPersistRegistrationBadgeCampaignDestination(...args),
}))
jest.mock('@/app/shhhhh/shhhhh-acquisition', () => ({
settleShhhhhCampaignContinuation: (...args: unknown[]) => mockSettleShhhhhCampaignContinuation(...args),
}))
Expand Down
12 changes: 5 additions & 7 deletions src/hooks/useZeroDev.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,6 @@ import {
isUnavailableBadgeCampaignClaim,
} from '@/services/badge-campaigns'
import { settleAcceptedInviteAcquisition } from '@/services/invite-acquisition'
import { persistRegistrationBadgeCampaignDestination } from '@/services/registration-acquisition'
import { getPendingBadgeCampaigns } from '@/components/Invites/badge-campaign-context'
import { settleShhhhhCampaignContinuation } from '@/app/shhhhh/shhhhh-acquisition'
import { signupConsentDocuments } from '@/services/consent'
Expand Down Expand Up @@ -210,12 +209,11 @@ export const useZeroDev = () => {
const confirmed = batch.claims.filter(isConfirmedBadgeCampaignClaim)
const unavailable = batch.claims.filter(isUnavailableBadgeCampaignClaim)

// Explicit/UTM badge campaigns do not pass through `/invites/accept`.
// Shhhhh owns one compatibility continuation: only a confirmed
// Skip Pass replaces its safe /home marker with /card. Every
// other entrypoint uses backend-owned acquisition navigation.
const shhhhhDestination = settleShhhhhCampaignContinuation(batch.claims)
if (shhhhhDestination === undefined) persistRegistrationBadgeCampaignDestination(batch.claims)
// Explicit badge campaigns do not pass through `/invites/accept`.
// Shhhhh owns the one remaining compatibility continuation: only a
// confirmed Skip Pass replaces its safe /home marker with /card.
// Bespoke campaign destinations retired with TASK-21226.
settleShhhhhCampaignContinuation(batch.claims)

if (confirmed.length > 0) {
posthog.capture(ANALYTICS_EVENTS.INVITE_ACCEPTED, {
Expand Down
23 changes: 1 addition & 22 deletions src/services/__tests__/badge-campaigns.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import {
claimAndSettlePendingBadgeCampaigns,
claimBadgeCampaigns,
destinationForConfirmedBadgeCampaignAcquisition,
pendingBadgeCampaignsAfterClaims,
} from '../badge-campaigns'
import {
Expand Down Expand Up @@ -445,7 +444,7 @@ describe('badge badge campaign claims contract', () => {
).toEqual(['Second'])
})

it('keeps validated acquisition navigation while dropping unknown policy and reward fields', async () => {
it('drops acquisition, unknown policy, and reward fields from the claim projection', async () => {
mockServerFetch.mockResolvedValue(
response(200, {
claims: [
Expand All @@ -468,30 +467,11 @@ describe('badge badge campaign claims contract', () => {
badgeCampaign: 'public-link',
badgeCode: 'PERMANENT_BADGE',
outcome: 'awarded',
acquisition: { fallback: 'normal_app', destination: 'offramp_migration' },
},
])
expect(result.pending).toEqual([])
})

it('routes only a confirmed, validated acquisition destination', () => {
const acquisition = { fallback: 'normal_app' as const, destination: 'offramp_migration' as const }

expect(
destinationForConfirmedBadgeCampaignAcquisition([
{ badgeCampaign: 'opaque', outcome: 'already_owned', acquisition },
])
).toBe('/home')
expect(
destinationForConfirmedBadgeCampaignAcquisition([
{ badgeCampaign: 'opaque', outcome: 'expired', acquisition },
])
).toBe('/home')
expect(
destinationForConfirmedBadgeCampaignAcquisition([{ badgeCampaign: 'offramp', outcome: 'awarded' }])
).toBe('/home')
})

it('drops a malformed destination while retaining the permanent award outcome', async () => {
mockServerFetch.mockResolvedValue(
response(200, {
Expand All @@ -509,7 +489,6 @@ describe('badge badge campaign claims contract', () => {
const result = await claimAndSettlePendingBadgeCampaigns(['offramp'])

expect(result.claims).toEqual([{ badgeCampaign: 'offramp', badgeCode: 'OFFRAMP_USER', outcome: 'awarded' }])
expect(destinationForConfirmedBadgeCampaignAcquisition(result.claims)).toBe('/home')
expect(result.pending).toEqual([])
})
})
Loading
Loading