diff --git a/README.md b/README.md index bef2fd4..3b39985 100644 --- a/README.md +++ b/README.md @@ -223,6 +223,18 @@ LinkForty.onDeferredDeepLink((data: DeepLinkData | null) => { |--------------------|------|-------------| | `data` | `DeepLinkData \| null` | Attributed link data, or `null` for organic installs | +The deferred link also carries how certain the match is: `confidenceScore`, `matchedFactors`, `attributionMethod` and `competingLinks` (see [`DeepLinkData`](#deeplinkdata)). Before acting on a sensitive link, check `competingLinks`: + +```typescript +LinkForty.onDeferredDeepLink((data) => { + if (data?.customParameters?.route === 'SIGN_IN' && (data.competingLinks ?? 0) > 0) { + // Another link was also in contention for this install: confirm with the user first + return askUserToConfirm(data); + } + // ... +}); +``` + --- ### `createLink(options)` @@ -381,9 +393,18 @@ interface DeepLinkData { appScheme?: string; clickedAt?: string; linkId?: string; + // Deferred deep links only (onDeferredDeepLink / getInstallData): + confidenceScore?: number; // 0-100 + matchedFactors?: string[]; // e.g. ['ip', 'user_agent', 'timezone'] + attributionMethod?: 'fingerprint' | 'none' | string; + competingLinks?: number; } ``` +`confidenceScore`, `matchedFactors`, `attributionMethod` and `competingLinks` are set only on the deferred deep link; direct opens leave them undefined, and servers that do not report `attributionMethod` or `competingLinks` leave those undefined too. + +`competingLinks` is how many other links had a click from the install's network that also reached the confidence threshold. `0` means no other link was in contention; above `0` means another device's tap may be what led to this install, so an app acting on a sensitive link (sign-in, gift) can treat the match as unverified, for example by asking the user to confirm. + ### `LinkFortyConfig` ```typescript diff --git a/__tests__/DeferredDeepLink.test.ts b/__tests__/DeferredDeepLink.test.ts index a0a6aa8..dbe66a1 100644 --- a/__tests__/DeferredDeepLink.test.ts +++ b/__tests__/DeferredDeepLink.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, vi } from 'vitest'; -import { DeferredDeepLink } from '../src/DeferredDeepLink'; -import type { DeepLinkData } from '../src/types'; +import { DeferredDeepLink, deferredDeepLinkFromInstall } from '../src/DeferredDeepLink'; +import type { DeepLinkData, InstallAttributionResponse } from '../src/types'; const data = { shortCode: 'abc123', linkId: 'link-1' } as DeepLinkData; @@ -41,3 +41,137 @@ describe('DeferredDeepLink', () => { expect(callback).not.toHaveBeenCalled(); }); }); + +describe('deferredDeepLinkFromInstall', () => { + const matched: InstallAttributionResponse = { + installId: 'install-1', + attributed: true, + confidenceScore: 85, + matchedFactors: ['ip', 'user_agent', 'timezone'], + attributionMethod: 'fingerprint', + competingLinks: 2, + deepLinkData: { + shortCode: 'abc123', + linkId: 'link-1', + confidenceScore: 85, + matchedFactors: ['ip', 'user_agent', 'timezone'], + attributionMethod: 'fingerprint', + competingLinks: 2, + }, + }; + + it('passes the match certainty fields through to the deferred deep link', () => { + const link = deferredDeepLinkFromInstall(matched); + + expect(link).toMatchObject({ + shortCode: 'abc123', + linkId: 'link-1', + confidenceScore: 85, + matchedFactors: ['ip', 'user_agent', 'timezone'], + attributionMethod: 'fingerprint', + competingLinks: 2, + }); + }); + + it('delivers the match certainty fields to the onDeferredDeepLink callback', () => { + const deferred = new DeferredDeepLink(); + const callback = vi.fn(); + deferred.register(callback); + deferred.deliver(deferredDeepLinkFromInstall(matched)); + + const delivered = callback.mock.calls[0][0] as DeepLinkData; + expect(delivered.confidenceScore).toBe(85); + expect(delivered.matchedFactors).toEqual(['ip', 'user_agent', 'timezone']); + expect(delivered.attributionMethod).toBe('fingerprint'); + expect(delivered.competingLinks).toBe(2); + }); + + it('keeps the fields through the JSON round trip used by getInstallData', () => { + const stored = JSON.parse(JSON.stringify(deferredDeepLinkFromInstall(matched))); + + expect(stored.attributionMethod).toBe('fingerprint'); + expect(stored.competingLinks).toBe(2); + expect(stored.confidenceScore).toBe(85); + expect(stored.matchedFactors).toEqual(['ip', 'user_agent', 'timezone']); + }); + + it('keeps competingLinks of 0', () => { + const link = deferredDeepLinkFromInstall({ + ...matched, + competingLinks: 0, + deepLinkData: { shortCode: 'abc123', attributionMethod: 'fingerprint', competingLinks: 0 }, + }); + + expect(link?.competingLinks).toBe(0); + }); + + it('leaves attributionMethod and competingLinks undefined when the server omits them', () => { + const link = deferredDeepLinkFromInstall({ + installId: 'install-1', + attributed: true, + confidenceScore: 85, + matchedFactors: ['ip'], + deepLinkData: { shortCode: 'abc123', confidenceScore: 85, matchedFactors: ['ip'] }, + }); + + expect(link?.attributionMethod).toBeUndefined(); + expect(link?.competingLinks).toBeUndefined(); + expect(link?.confidenceScore).toBe(85); + expect(link?.matchedFactors).toEqual(['ip']); + }); + + it('leaves all four fields undefined when the server sends none of them', () => { + const response = { + installId: 'install-1', + attributed: true, + deepLinkData: { shortCode: 'abc123' }, + } as unknown as InstallAttributionResponse; + const link = deferredDeepLinkFromInstall(response); + + expect(link?.shortCode).toBe('abc123'); + expect(link?.confidenceScore).toBeUndefined(); + expect(link?.matchedFactors).toBeUndefined(); + expect(link?.attributionMethod).toBeUndefined(); + expect(link?.competingLinks).toBeUndefined(); + }); + + it('falls back to the top-level fields when the deep link data lacks them', () => { + const link = deferredDeepLinkFromInstall({ + ...matched, + deepLinkData: { shortCode: 'abc123' }, + }); + + expect(link).toMatchObject({ + confidenceScore: 85, + matchedFactors: ['ip', 'user_agent', 'timezone'], + attributionMethod: 'fingerprint', + competingLinks: 2, + }); + }); + + it('maps deepLinkParameters to customParameters', () => { + const link = deferredDeepLinkFromInstall({ + ...matched, + deepLinkData: { + shortCode: 'abc123', + deepLinkParameters: { route: 'PRODUCT', id: '42' }, + } as unknown as DeepLinkData, + }); + + expect(link?.customParameters).toEqual({ route: 'PRODUCT', id: '42' }); + }); + + it('returns null for an organic install', () => { + const organic: InstallAttributionResponse = { + installId: 'install-2', + attributed: false, + confidenceScore: 0, + matchedFactors: [], + attributionMethod: 'none', + competingLinks: 0, + deepLinkData: {}, + }; + + expect(deferredDeepLinkFromInstall(organic)).toBeNull(); + }); +}); diff --git a/llms.txt b/llms.txt index bad0599..8fb74a5 100644 --- a/llms.txt +++ b/llms.txt @@ -60,6 +60,20 @@ interface DeepLinkData { appScheme?: string; clickedAt?: string; linkId?: string; + /** Deferred deep links only: attribution confidence score (0-100) */ + confidenceScore?: number; + /** Deferred deep links only: fingerprint factors that matched the click */ + matchedFactors?: string[]; + /** Deferred deep links only: 'fingerprint' when matched to a click (newer values may appear) */ + attributionMethod?: 'fingerprint' | 'none' | string; + /** + * Deferred deep links only: how many OTHER links had a click from the + * install's network that also reached the confidence threshold. 0 = no other + * link was in contention. Above 0 = another device's tap may be what led to + * this install; treat a sensitive link (sign-in, gift) as unverified. + * Undefined when the server does not report it. + */ + competingLinks?: number; } interface CreateLinkOptions { diff --git a/src/DeferredDeepLink.ts b/src/DeferredDeepLink.ts index 09effe9..f0d39c2 100644 --- a/src/DeferredDeepLink.ts +++ b/src/DeferredDeepLink.ts @@ -1,4 +1,38 @@ -import type { DeepLinkData, DeferredDeepLinkCallback } from './types'; +import type { + DeepLinkData, + DeferredDeepLinkCallback, + InstallAttributionResponse, +} from './types'; + +/** + * The deferred deep link carried by an install response, or null when the + * install is not attributed. + * + * `customParameters` is filled from the link's `deepLinkParameters`, so apps + * read the same field on deferred and direct deep links. The match certainty + * fields (`confidenceScore`, `matchedFactors`, `attributionMethod`, + * `competingLinks`) are taken from the deep link data, or from the top level of + * the response when the deep link data lacks them; a server that sends neither + * leaves them undefined. + */ +export function deferredDeepLinkFromInstall( + response: InstallAttributionResponse, +): DeepLinkData | null { + if (!response.attributed || !response.deepLinkData) return null; + + const link = response.deepLinkData as DeepLinkData & { + deepLinkParameters?: Record; + }; + + return { + ...link, + customParameters: link.deepLinkParameters || link.customParameters, + confidenceScore: link.confidenceScore ?? response.confidenceScore, + matchedFactors: link.matchedFactors ?? response.matchedFactors, + attributionMethod: link.attributionMethod ?? response.attributionMethod, + competingLinks: link.competingLinks ?? response.competingLinks, + }; +} /** * Delivers the deferred deep link once, on the launch that reports the install: diff --git a/src/LinkFortySDK.ts b/src/LinkFortySDK.ts index 503a179..c21cb2e 100644 --- a/src/LinkFortySDK.ts +++ b/src/LinkFortySDK.ts @@ -8,7 +8,7 @@ import { DeepLinkHandler } from './DeepLinkHandler'; import { AttributionContext } from './AttributionContext'; import { NavigationTracker } from './NavigationTracker'; import { LinkDomains } from './LinkDomains'; -import { DeferredDeepLink } from './DeferredDeepLink'; +import { DeferredDeepLink, deferredDeepLinkFromInstall } from './DeferredDeepLink'; import { SDK_NAME, SDK_VERSION } from './version'; import type { LinkFortyConfig, @@ -509,16 +509,8 @@ export class LinkFortySDK { } // If attributed, store deep link data - if (response.attributed && response.deepLinkData) { - // Normalize backend response to match DeepLinkData type - // Populate customParameters from deepLinkParameters so app developers - // get a consistent field regardless of direct vs deferred deep link path - const deepLinkData: DeepLinkData = { - ...response.deepLinkData as DeepLinkData, - customParameters: (response.deepLinkData as any).deepLinkParameters - || (response.deepLinkData as DeepLinkData).customParameters, - }; - + const deepLinkData = deferredDeepLinkFromInstall(response); + if (deepLinkData) { await AsyncStorage.setItem( STORAGE_KEYS.INSTALL_DATA, JSON.stringify(deepLinkData) @@ -536,6 +528,7 @@ export class LinkFortySDK { if (this.config.debug) { console.log('[LinkForty] Install attributed with confidence:', response.confidenceScore); console.log('[LinkForty] Matched factors:', response.matchedFactors); + console.log('[LinkForty] Competing links:', deepLinkData.competingLinks); } } else { if (this.config.debug) { diff --git a/src/index.ts b/src/index.ts index 306f553..eabe811 100644 --- a/src/index.ts +++ b/src/index.ts @@ -23,6 +23,7 @@ export type { DeviceFingerprint, DeepLinkData, InstallAttributionResponse, + AttributionMethod, EventData, ActiveAttribution, AttributionStamp, diff --git a/src/types.ts b/src/types.ts index 1f2e9dd..0a0520c 100644 --- a/src/types.ts +++ b/src/types.ts @@ -149,8 +149,39 @@ export interface DeepLinkData { clickedAt?: string; /** Link ID */ linkId?: string; + /** + * Install attribution confidence score (0-100). Set on deferred deep links + * only; direct opens leave it undefined. + */ + confidenceScore?: number; + /** + * Fingerprint factors that matched the click (e.g. `['ip', 'user_agent', 'timezone']`). + * Set on deferred deep links only. + */ + matchedFactors?: string[]; + /** + * How the install was matched to the click. Set on deferred deep links only, + * and only by servers that report it. + */ + attributionMethod?: AttributionMethod; + /** + * How many other links had a click from the install's network that also + * reached the confidence threshold. 0 means no other link was in contention; + * above 0 means another device's tap may be what led to this install, so an + * app acting on a sensitive link (sign-in, gift) can treat the match as + * unverified and ask the user to confirm. Set on deferred deep links only, + * and only by servers that report it. + */ + competingLinks?: number; } +/** + * How an install was attributed: `'fingerprint'` when it was matched to a + * click, `'none'` for an organic install. Other values may be added by newer + * servers. + */ +export type AttributionMethod = 'fingerprint' | 'none' | (string & {}); + /** * Response from install attribution endpoint * Matches backend contract from /api/sdk/v1/install @@ -164,6 +195,18 @@ export interface InstallAttributionResponse { confidenceScore: number; /** Array of matched fingerprint factors (e.g., ['ip', 'user_agent', 'timezone', 'language', 'screen']) */ matchedFactors: string[]; + /** + * `'fingerprint'` when the install was matched to a click, `'none'` when it + * is organic. Undefined when the server does not report it. + */ + attributionMethod?: AttributionMethod; + /** + * How many other links had a click from the install's network that also + * reached the confidence threshold (0 when organic). Above 0 means another + * device's tap may be what led to this install. Undefined when the server + * does not report it. + */ + competingLinks?: number; /** Deep link data if attributed (contains shortCode, URLs, UTM params, confidence, etc.) */ deepLinkData: DeepLinkData | Record; }