diff --git a/src/app/actions/__tests__/invites-resolution.test.ts b/src/app/actions/__tests__/invites-resolution.test.ts
index a27042ba87..13ee286a75 100644
--- a/src/app/actions/__tests__/invites-resolution.test.ts
+++ b/src/app/actions/__tests__/invites-resolution.test.ts
@@ -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' },
},
})
})
diff --git a/src/components/Invites/InvitesPage.test.tsx b/src/components/Invites/InvitesPage.test.tsx
index a1befa5669..b391693ba9 100644
--- a/src/components/Invites/InvitesPage.test.tsx
+++ b/src/components/Invites/InvitesPage.test.tsx
@@ -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
@@ -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()
+ render()
- 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'],
@@ -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({
@@ -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) => {
@@ -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()
- 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')
})
diff --git a/src/components/Invites/InvitesPage.tsx b/src/components/Invites/InvitesPage.tsx
index 3ad5de550c..ebdfdf8c03 100644
--- a/src/components/Invites/InvitesPage.tsx
+++ b/src/components/Invites/InvitesPage.tsx
@@ -30,11 +30,9 @@ import {
} from './badge-campaign-context'
import {
claimAndSettlePendingBadgeCampaigns,
- destinationForConfirmedBadgeCampaignAcquisition,
isConfirmedBadgeCampaignClaim,
isUnavailableBadgeCampaignClaim,
} from '@/services/badge-campaigns'
-import { destinationForInviteAcquisition } from '@/services/invite-acquisition'
import { getPasskeyErrorSetupKey } from '@/utils/webauthn.utils'
function InvitePageContent() {
@@ -190,24 +188,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) => {
diff --git a/src/components/Invites/badge-campaign-context.test.ts b/src/components/Invites/badge-campaign-context.test.ts
index 1338db9c3a..354070fc8d 100644
--- a/src/components/Invites/badge-campaign-context.test.ts
+++ b/src/components/Invites/badge-campaign-context.test.ts
@@ -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,
@@ -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()
for (const file of filesUnder(join(process.cwd(), 'src/content'))) {
const source = readFileSync(file, 'utf8')
@@ -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([])
})
@@ -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()
})
diff --git a/src/components/Invites/badge-campaign-context.ts b/src/components/Invites/badge-campaign-context.ts
index a0344ad304..9caea8dc02 100644
--- a/src/components/Invites/badge-campaign-context.ts
+++ b/src/components/Invites/badge-campaign-context.ts
@@ -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
@@ -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. */
diff --git a/src/hooks/__tests__/useZeroDev-invite-onboarding.test.tsx b/src/hooks/__tests__/useZeroDev-invite-onboarding.test.tsx
index f849000674..3efa37a222 100644
--- a/src/hooks/__tests__/useZeroDev-invite-onboarding.test.tsx
+++ b/src/hooks/__tests__/useZeroDev-invite-onboarding.test.tsx
@@ -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),
}))
diff --git a/src/hooks/__tests__/useZeroDev-login-failure.test.tsx b/src/hooks/__tests__/useZeroDev-login-failure.test.tsx
index 83af2a90f2..8a1c49d9e2 100644
--- a/src/hooks/__tests__/useZeroDev-login-failure.test.tsx
+++ b/src/hooks/__tests__/useZeroDev-login-failure.test.tsx
@@ -55,7 +55,6 @@ jest.mock('@zerodev/passkey-validator', () => ({
}))
jest.mock('@/services/invites', () => ({ invitesApi: { acceptInvite: jest.fn() } }))
jest.mock('@/services/invite-acquisition', () => ({ settleAcceptedInviteAcquisition: jest.fn() }))
-jest.mock('@/services/registration-acquisition', () => ({ persistRegistrationBadgeCampaignDestination: jest.fn() }))
jest.mock('@/app/shhhhh/shhhhh-acquisition', () => ({ settleShhhhhCampaignContinuation: jest.fn() }))
jest.mock('@/components/Invites/badge-campaign-context', () => ({ getPendingBadgeCampaigns: () => [] }))
jest.mock('@/services/badge-campaigns', () => ({
diff --git a/src/hooks/useZeroDev.ts b/src/hooks/useZeroDev.ts
index ca82a197b9..eab01970ba 100644
--- a/src/hooks/useZeroDev.ts
+++ b/src/hooks/useZeroDev.ts
@@ -30,7 +30,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'
@@ -209,12 +208,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, {
diff --git a/src/services/__tests__/badge-campaigns.test.ts b/src/services/__tests__/badge-campaigns.test.ts
index a0c4b7ee4d..e23e74f293 100644
--- a/src/services/__tests__/badge-campaigns.test.ts
+++ b/src/services/__tests__/badge-campaigns.test.ts
@@ -1,7 +1,6 @@
import {
claimAndSettlePendingBadgeCampaigns,
claimBadgeCampaigns,
- destinationForConfirmedBadgeCampaignAcquisition,
pendingBadgeCampaignsAfterClaims,
} from '../badge-campaigns'
import {
@@ -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: [
@@ -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, {
@@ -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([])
})
})
diff --git a/src/services/__tests__/invite-response.test.ts b/src/services/__tests__/invite-response.test.ts
index 95d3c4d918..7858554868 100644
--- a/src/services/__tests__/invite-response.test.ts
+++ b/src/services/__tests__/invite-response.test.ts
@@ -38,11 +38,18 @@ describe('invite response rollout helpers', () => {
expect(isTypedCampaignOnlyInviteResponse(typed)).toBe(true)
expect(isTypedCampaignOnlyInviteResponse({ ...typed, onboardingResolved: true })).toBe(false)
expect(isTypedCampaignOnlyInviteResponse({ ...typed, legacyAcquisition: undefined })).toBe(false)
+ // retired destination fields are ignored, not validated (TASK-21226)
expect(
isTypedCampaignOnlyInviteResponse({
...typed,
legacyAcquisition: { ...typed.legacyAcquisition, destination: 'future_product_flow' },
})
+ ).toBe(true)
+ expect(
+ isTypedCampaignOnlyInviteResponse({
+ ...typed,
+ legacyAcquisition: { ...typed.legacyAcquisition, campaignTag: ' ' },
+ })
).toBe(false)
})
})
diff --git a/src/services/__tests__/invites-attribution.test.ts b/src/services/__tests__/invites-attribution.test.ts
index ef1bd1b33b..4b562258d3 100644
--- a/src/services/__tests__/invites-attribution.test.ts
+++ b/src/services/__tests__/invites-attribution.test.ts
@@ -2,7 +2,7 @@ import { invitesApi } from '../invites'
import { EInviteType } from '../services.types'
import { serverFetch } from '@/utils/api-fetch'
import { validateInviteCode } from '@/app/actions/invites'
-import { destinationForInviteAcquisition, settleAcceptedInviteAcquisition } from '../invite-acquisition'
+import { settleAcceptedInviteAcquisition } from '../invite-acquisition'
import { clearPendingBadgeCampaigns, getPendingBadgeCampaigns } from '@/components/Invites/badge-campaign-context'
jest.mock('@/utils/api-fetch', () => ({ serverFetch: jest.fn() }))
@@ -95,11 +95,7 @@ describe('invite attribution contract', () => {
success: true,
attributionResolved: false,
onboardingResolved: false,
- legacyAcquisition: {
- campaignTag: 'offramp',
- fallback: 'normal_app',
- destination: 'offramp_migration',
- },
+ legacyAcquisition: { campaignTag: 'offramp' },
claims: [{ badgeCampaign: 'offramp', outcome: 'already_owned' }],
})
expect(settleAcceptedInviteAcquisition(result.legacyAcquisition!, result.claims)).toEqual({
@@ -149,7 +145,6 @@ describe('invite attribution contract', () => {
const result = await invitesApi.acceptInvite('offramp', EInviteType.PAYMENT_LINK)
expect(result.claims).toEqual([{ badgeCampaign: 'offramp', outcome: 'retryable_error' }])
- expect(destinationForInviteAcquisition(result.legacyAcquisition!, result.claims)).toBe('/home')
expect(settleAcceptedInviteAcquisition(result.legacyAcquisition!, result.claims)).toEqual({
destination: '/home',
pending: ['offramp'],
@@ -157,18 +152,16 @@ describe('invite attribution contract', () => {
expect(getPendingBadgeCampaigns()).toEqual(['offramp'])
})
- it('preserves the typed legacy campaign identity and destination from invite validation', async () => {
+ it('preserves the typed legacy campaign identity from invite validation', async () => {
+ // the server action is the parse boundary; it already reduced the wire
+ // descriptor to the campaign identity (destinations retired, TASK-21226)
mockValidateInviteCode.mockResolvedValue({
data: {
success: true,
attributionResolved: false,
onboardingResolved: false,
username: 'peanut',
- legacyAcquisition: {
- campaignTag: 'offramp',
- fallback: 'normal_app',
- destination: 'offramp_migration',
- },
+ legacyAcquisition: { campaignTag: 'offramp' },
},
})
@@ -177,11 +170,7 @@ describe('invite attribution contract', () => {
attributionResolved: false,
onboardingResolved: false,
username: 'peanut',
- legacyAcquisition: {
- campaignTag: 'offramp',
- fallback: 'normal_app',
- destination: 'offramp_migration',
- },
+ legacyAcquisition: { campaignTag: 'offramp' },
})
})
diff --git a/src/services/__tests__/registration-acquisition.test.ts b/src/services/__tests__/registration-acquisition.test.ts
deleted file mode 100644
index 52d3a9ca60..0000000000
--- a/src/services/__tests__/registration-acquisition.test.ts
+++ /dev/null
@@ -1,40 +0,0 @@
-import { saveToLocalStorage } from '@/utils/general.utils'
-import { persistRegistrationBadgeCampaignDestination } from '../registration-acquisition'
-
-jest.mock('@/utils/general.utils', () => ({ saveToLocalStorage: jest.fn() }))
-
-const mockSaveToLocalStorage = saveToLocalStorage as jest.MockedFunction
-const acquisition = { fallback: 'normal_app' as const, destination: 'offramp_migration' as const }
-
-describe('new-registration campaign navigation', () => {
- beforeEach(() => jest.clearAllMocks())
-
- // every destination currently routes to /home (the offramp migration
- // surface is gone), so a confirmed claim persists nothing
- it.each(['awarded', 'already_owned'] as const)(
- 'does not persist a redirect for a confirmed %s canonical claim',
- (outcome) => {
- expect(
- persistRegistrationBadgeCampaignDestination([{ badgeCampaign: 'offramp', outcome, acquisition }])
- ).toBe('/home')
- expect(mockSaveToLocalStorage).not.toHaveBeenCalled()
- }
- )
-
- it.each(['inactive', 'expired', 'unknown', 'definition_missing', 'retryable_error'] as const)(
- 'keeps the normal app destination after %s',
- (outcome) => {
- expect(
- persistRegistrationBadgeCampaignDestination([{ badgeCampaign: 'offramp', outcome, acquisition }])
- ).toBe('/home')
- expect(mockSaveToLocalStorage).not.toHaveBeenCalled()
- }
- )
-
- it('does not invent a destination when a confirmed claim omits acquisition metadata', () => {
- expect(persistRegistrationBadgeCampaignDestination([{ badgeCampaign: 'offramp', outcome: 'awarded' }])).toBe(
- '/home'
- )
- expect(mockSaveToLocalStorage).not.toHaveBeenCalled()
- })
-})
diff --git a/src/services/acquisition-navigation.ts b/src/services/acquisition-navigation.ts
deleted file mode 100644
index e8ca8acf06..0000000000
--- a/src/services/acquisition-navigation.ts
+++ /dev/null
@@ -1,30 +0,0 @@
-export type AcquisitionDestination = 'offramp_migration' | 'normal_app'
-
-export type AcquisitionNavigation = {
- fallback: 'normal_app'
- destination: AcquisitionDestination
-}
-
-// the offramp migration surface is gone (TASK-20535); the enum member survives
-// only because the backend can still emit it until its registry flip deploys.
-const DESTINATION_ROUTES: Readonly> = {
- offramp_migration: '/home',
- normal_app: '/home',
-}
-
-export function parseAcquisitionNavigation(value: unknown): AcquisitionNavigation | undefined {
- if (!value || typeof value !== 'object') return undefined
- const candidate = value as Partial
- if (
- candidate.fallback !== 'normal_app' ||
- (candidate.destination !== 'offramp_migration' && candidate.destination !== 'normal_app')
- ) {
- return undefined
- }
- return { fallback: candidate.fallback, destination: candidate.destination }
-}
-
-/** Translate a backend-owned destination enum into a same-origin application path. */
-export function acquisitionDestinationRoute(destination: AcquisitionDestination): string {
- return DESTINATION_ROUTES[destination]
-}
diff --git a/src/services/badge-campaigns.ts b/src/services/badge-campaigns.ts
index bf8fd1d97d..226496bdf1 100644
--- a/src/services/badge-campaigns.ts
+++ b/src/services/badge-campaigns.ts
@@ -5,7 +5,6 @@ import {
savePendingBadgeCampaigns,
} from '@/components/Invites/badge-campaign-context'
import { serverFetch } from '@/utils/api-fetch'
-import { acquisitionDestinationRoute, parseAcquisitionNavigation } from './acquisition-navigation'
import type { paths } from '@/types/api.generated'
type GeneratedBadgeCampaignClaimRequest = paths['/badge/claims']['post']['requestBody']['content']['application/json']
@@ -140,7 +139,6 @@ function parseBackendBadgeCampaignClaim(value: unknown): BackendBadgeCampaignCla
badgeCode?: unknown
badge?: unknown
outcome?: unknown
- acquisition?: unknown
}
// During rolling deploys, accept the published legacy echo only when the
// canonical field is absent. Presence of a malformed canonical value is a
@@ -148,7 +146,6 @@ function parseBackendBadgeCampaignClaim(value: unknown): BackendBadgeCampaignCla
const hasCanonicalBadgeCampaign = Object.prototype.hasOwnProperty.call(claim, 'badgeCampaign')
const badgeCampaign = hasCanonicalBadgeCampaign ? claim.badgeCampaign : claim.campaignTag
const badge = claim.badge === undefined ? undefined : parseBadgePresentation(claim.badge)
- const acquisition = claim.acquisition === undefined ? undefined : parseAcquisitionNavigation(claim.acquisition)
if (
typeof badgeCampaign !== 'string' ||
badgeCampaign.length === 0 ||
@@ -165,7 +162,6 @@ function parseBackendBadgeCampaignClaim(value: unknown): BackendBadgeCampaignCla
outcome: claim.outcome as BackendBadgeCampaignClaim['outcome'],
...(typeof claim.badgeCode === 'string' ? { badgeCode: claim.badgeCode } : {}),
...(badge ? { badge } : {}),
- ...(acquisition ? { acquisition } : {}),
}
}
@@ -347,24 +343,6 @@ export function isConfirmedBadgeCampaignClaim(claim: BadgeCampaignClaim): boolea
return claim.outcome === 'awarded' || claim.outcome === 'already_owned'
}
-/**
- * Badge campaign navigation comes only from a confirmed canonical claim. Missing,
- * malformed, unavailable, and retryable claims use the declared normal-app
- * fallback. Multiple different bespoke destinations fail closed as well.
- */
-export function destinationForConfirmedBadgeCampaignAcquisition(claims: readonly BadgeCampaignClaim[]): string {
- const nonDefaultDestinations = new Set(
- claims.flatMap((claim) => {
- if (!isConfirmedBadgeCampaignClaim(claim) || !claim.acquisition) return []
- const { destination, fallback } = claim.acquisition
- return destination === fallback ? [] : [destination]
- })
- )
-
- if (nonDefaultDestinations.size !== 1) return acquisitionDestinationRoute('normal_app')
- return acquisitionDestinationRoute([...nonDefaultDestinations][0])
-}
-
export function isUnavailableBadgeCampaignClaim(claim: BadgeCampaignClaim): boolean {
return claim.outcome === 'inactive' || claim.outcome === 'expired' || claim.outcome === 'unknown'
}
diff --git a/src/services/invite-acquisition.ts b/src/services/invite-acquisition.ts
index 7783572a74..8d1d78c789 100644
--- a/src/services/invite-acquisition.ts
+++ b/src/services/invite-acquisition.ts
@@ -1,45 +1,20 @@
-import { isConfirmedBadgeCampaignClaim, settlePendingBadgeCampaigns, type BadgeCampaignClaim } from './badge-campaigns'
-import {
- acquisitionDestinationRoute,
- parseAcquisitionNavigation,
- type AcquisitionNavigation,
-} from './acquisition-navigation'
+import { settlePendingBadgeCampaigns, type BadgeCampaignClaim } from './badge-campaigns'
-export type LegacyInviteAcquisition = AcquisitionNavigation & {
+export type LegacyInviteAcquisition = {
campaignTag: string
}
/**
* Compatibility adapter for already-published legacy invite links. The backend
- * owns the opaque campaign identity and destination enum; this client never
- * infers either from the invite code or from badge provenance.
+ * owns the opaque campaign identity; this client never infers it from the
+ * invite code or from badge provenance. Bespoke destinations retired with
+ * TASK-21226 — every acquisition lands in the normal app.
*/
export function parseLegacyInviteAcquisition(value: unknown): LegacyInviteAcquisition | undefined {
if (!value || typeof value !== 'object') return undefined
const candidate = value as { campaignTag?: unknown }
- const navigation = parseAcquisitionNavigation(value)
- if (typeof candidate.campaignTag !== 'string' || candidate.campaignTag.trim().length === 0 || !navigation)
- return undefined
- return {
- campaignTag: candidate.campaignTag.trim(),
- ...navigation,
- }
-}
-
-/**
- * A bespoke compatibility destination is usable only after the matching badge
- * claim is confirmed. Every other outcome follows the descriptor's safe fallback.
- */
-export function destinationForInviteAcquisition(
- acquisition: LegacyInviteAcquisition,
- claims: readonly BadgeCampaignClaim[]
-): string {
- const matchingClaim = claims.find(
- (claim) => claim.badgeCampaign.toLowerCase() === acquisition.campaignTag.toLowerCase()
- )
- return acquisitionDestinationRoute(
- matchingClaim && isConfirmedBadgeCampaignClaim(matchingClaim) ? acquisition.destination : acquisition.fallback
- )
+ if (typeof candidate.campaignTag !== 'string' || candidate.campaignTag.trim().length === 0) return undefined
+ return { campaignTag: candidate.campaignTag.trim() }
}
/** Consume the claim batch returned by signed-out registration's invite accept. */
@@ -48,7 +23,7 @@ export function settleAcceptedInviteAcquisition(
claims: readonly BadgeCampaignClaim[]
): { destination: string; pending: string[] } {
return {
- destination: destinationForInviteAcquisition(acquisition, claims),
+ destination: '/home',
pending: settlePendingBadgeCampaigns([acquisition.campaignTag], claims),
}
}
diff --git a/src/services/registration-acquisition.ts b/src/services/registration-acquisition.ts
deleted file mode 100644
index 6174f57ced..0000000000
--- a/src/services/registration-acquisition.ts
+++ /dev/null
@@ -1,13 +0,0 @@
-import { saveToLocalStorage } from '@/utils/general.utils'
-import { destinationForConfirmedBadgeCampaignAcquisition, type BadgeCampaignClaim } from './badge-campaigns'
-
-/**
- * Registration settles queued URL badge campaigns after authentication. Preserve a
- * bespoke destination for the remaining setup flow only when the canonical
- * claim is confirmed; every other outcome leaves normal navigation untouched.
- */
-export function persistRegistrationBadgeCampaignDestination(claims: readonly BadgeCampaignClaim[]): string {
- const destination = destinationForConfirmedBadgeCampaignAcquisition(claims)
- if (destination !== '/home') saveToLocalStorage('redirect', destination)
- return destination
-}
diff --git a/src/utils/__tests__/deferred-link.test.ts b/src/utils/__tests__/deferred-link.test.ts
index 3e7334be08..4a1a0f3739 100644
--- a/src/utils/__tests__/deferred-link.test.ts
+++ b/src/utils/__tests__/deferred-link.test.ts
@@ -120,9 +120,12 @@ describe('buildDeferredPayload / parseDeferredPayload round-trip', () => {
).toEqual({ badgeCampaigns: ['canonical-first', 'canonical-second'] })
})
- it('keeps a marked historical UTM source-qualified for backend allowlist resolution', () => {
+ it('ignores a bare historical UTM in an old deferred payload (TASK-21226)', () => {
expect(parseDeferredPayload('pnutdl=1&utm_campaign=token-nation-2026')).toEqual({
- badgeCampaigns: ['utm:token-nation-2026'],
+ badgeCampaigns: undefined,
+ dest: undefined,
+ invite: undefined,
+ lang: undefined,
})
})