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, }) })