diff --git a/src/lib/deep-link-payload.test.ts b/src/lib/deep-link-payload.test.ts new file mode 100644 index 0000000..6b70266 --- /dev/null +++ b/src/lib/deep-link-payload.test.ts @@ -0,0 +1,164 @@ +import { describe, it, expect } from 'vitest'; +import { + toDeepLinkPayload, + CANONICAL_DEEP_LINK_KEYS, + type DeepLinkLinkRow, +} from './deep-link-payload.js'; + +const link: DeepLinkLinkRow = { + id: 'link-123', + short_code: 'abc123', + original_url: 'https://example.com/product/456', + deep_link_path: '/product/456', + app_scheme: 'myapp', + ios_app_store_url: 'https://apps.apple.com/app/id123', + android_app_store_url: 'https://play.google.com/store/apps/details?id=com.example', + web_fallback_url: 'https://example.com', + utm_parameters: { source: 'facebook', campaign: 'summer' }, + targeting_rules: { countries: ['US'] }, + deep_link_parameters: { productId: '456' }, +}; + +const clickedAt = new Date('2026-01-15T10:30:00.000Z'); + +/** How the install endpoint projects a matched link. */ +function installPayload(row: DeepLinkLinkRow = link) { + return toDeepLinkPayload(row, { + clickedAt, + isDeferred: true, + confidenceScore: 85, + matchedFactors: ['userAgent', 'timezone'], + legacyInstallKeys: true, + }); +} + +/** How the resolve endpoint projects a directly opened link. */ +function resolvePayload(row: DeepLinkLinkRow = link) { + return toDeepLinkPayload(row, { + clickedAt: clickedAt.toISOString(), + isDeferred: false, + }); +} + +describe('toDeepLinkPayload', () => { + it('projects a link into the shape the SDK DeepLinkData model expects', () => { + expect(resolvePayload()).toEqual({ + shortCode: 'abc123', + linkId: 'link-123', + deepLinkPath: '/product/456', + appScheme: 'myapp', + iosUrl: 'https://apps.apple.com/app/id123', + androidUrl: 'https://play.google.com/store/apps/details?id=com.example', + webUrl: 'https://example.com', + utmParameters: { source: 'facebook', campaign: 'summer' }, + customParameters: { productId: '456' }, + clickedAt: '2026-01-15T10:30:00.000Z', + isDeferred: false, + }); + }); + + it('normalizes a Date clickedAt to an ISO string', () => { + expect(installPayload().clickedAt).toBe('2026-01-15T10:30:00.000Z'); + }); + + it('omits empty canonical fields rather than sending null', () => { + const payload = resolvePayload({ short_code: 'bare' }); + + expect(payload.shortCode).toBe('bare'); + expect('deepLinkPath' in payload).toBe(false); + expect('appScheme' in payload).toBe(false); + expect('webUrl' in payload).toBe(false); + expect('linkId' in payload).toBe(false); + }); + + it('carries attribution metadata on deferred opens only', () => { + const deferred = installPayload(); + expect(deferred.isDeferred).toBe(true); + expect(deferred.confidenceScore).toBe(85); + expect(deferred.matchedFactors).toEqual(['userAgent', 'timezone']); + + const direct = resolvePayload(); + expect(direct.isDeferred).toBe(false); + expect('confidenceScore' in direct).toBe(false); + expect('matchedFactors' in direct).toBe(false); + }); + + it('reports a zero confidence score rather than dropping it', () => { + const payload = toDeepLinkPayload(link, { + clickedAt, + isDeferred: true, + confidenceScore: 0, + matchedFactors: [], + }); + + expect(payload.confidenceScore).toBe(0); + expect(payload.matchedFactors).toEqual([]); + }); +}); + +/** + * The contract that keeps the install and resolve payloads from drifting apart + * again. Both endpoints describe the same link to the same SDK model, so every + * canonical field must carry the same value on both paths. + */ +describe('install and resolve payload contract', () => { + it('agrees field for field on every canonical key', () => { + const install = installPayload() as unknown as Record; + const resolve = resolvePayload() as unknown as Record; + + for (const key of CANONICAL_DEEP_LINK_KEYS) { + if (key === 'isDeferred') continue; // The one field that must differ. + expect(resolve[key], `resolve.${key}`).toEqual(install[key]); + } + }); + + it('gives the deferred payload every field an app needs to route', () => { + const payload = installPayload(); + + // The whole point of a deferred deep link: a newly installed user lands on + // the right screen, and the first session is credited to the link. + expect(payload.deepLinkPath).toBe('/product/456'); + expect(payload.appScheme).toBe('myapp'); + expect(payload.customParameters).toEqual({ productId: '456' }); + expect(payload.linkId).toBe('link-123'); + }); +}); + +/** + * Every SDK already in the field reads the pre-1.22 install keys. App updates + * take weeks to roll out, so these must keep their exact previous values — + * including nulls, which is why they are asserted with `toBe`/`in` rather than + * a loose truthiness check. + */ +describe('legacy install keys', () => { + it('emits the old aliases alongside the canonical ones', () => { + const payload = installPayload(); + + expect(payload.originalUrl).toBe('https://example.com/product/456'); + expect(payload.webFallbackUrl).toBe('https://example.com'); + expect(payload.targetingRules).toEqual({ countries: ['US'] }); + expect(payload.deepLinkParameters).toEqual({ productId: '456' }); + + // The canonical spellings of the same values ship in the same payload. + expect(payload.webUrl).toBe(payload.webFallbackUrl); + expect(payload.customParameters).toEqual(payload.deepLinkParameters); + }); + + it('keeps null legacy values present rather than omitting the key', () => { + const payload = installPayload({ short_code: 'bare' }); + + expect(payload.originalUrl).toBeNull(); + expect(payload.webFallbackUrl).toBeNull(); + expect(payload.targetingRules).toBeNull(); + expect(payload.deepLinkParameters).toBeNull(); + }); + + it('does not leak legacy keys into the resolve payload', () => { + const payload = resolvePayload() as unknown as Record; + + expect('originalUrl' in payload).toBe(false); + expect('webFallbackUrl' in payload).toBe(false); + expect('targetingRules' in payload).toBe(false); + expect('deepLinkParameters' in payload).toBe(false); + }); +}); diff --git a/src/lib/deep-link-payload.ts b/src/lib/deep-link-payload.ts new file mode 100644 index 0000000..b7bbe32 --- /dev/null +++ b/src/lib/deep-link-payload.ts @@ -0,0 +1,144 @@ +/** + * The single place a link row becomes an SDK deep link payload. + * + * Two endpoints hand a deep link to an SDK: `/api/sdk/v1/resolve/:shortCode` + * when the app is already installed, and `/api/sdk/v1/install` when a new + * install is matched to an earlier click. Both describe the same thing to the + * same SDK model, so both project through this function. + * + * They did not always. Each built its own object literal, and the two drifted: + * the install payload spelled `webUrl` as `webFallbackUrl` and `customParameters` + * as `deepLinkParameters`, and omitted `deepLinkPath`, `appScheme` and `linkId` + * entirely — the fields an app needs to route a newly installed user, and the one + * the SDKs use to credit that user's first session to the link that acquired + * them. Renaming the keys without also collapsing the two call sites would only + * reset the clock, so the shape lives here and the callers pass context. + */ + +/** The columns of `links` this projection reads. */ +export interface DeepLinkLinkRow { + id?: string | null; + short_code: string; + deep_link_path?: string | null; + app_scheme?: string | null; + ios_app_store_url?: string | null; + android_app_store_url?: string | null; + web_fallback_url?: string | null; + utm_parameters?: unknown; + deep_link_parameters?: unknown; + original_url?: string | null; + targeting_rules?: unknown; +} + +export interface DeepLinkPayloadOptions { + /** When the click happened (direct opens pass the resolution time). */ + clickedAt: Date | string; + /** True when this link is being delivered to a deferred (post-install) open. */ + isDeferred: boolean; + /** Attribution confidence, deferred opens only. */ + confidenceScore?: number | null; + /** Which fingerprint factors matched, deferred opens only. */ + matchedFactors?: string[] | null; + /** + * Emit the pre-1.22 install keys (`originalUrl`, `webFallbackUrl`, + * `targetingRules`, `deepLinkParameters`) alongside the canonical ones. + * + * Every SDK in the field reads the old names, and app updates take weeks to + * roll out, so the install payload keeps emitting them verbatim — same values, + * nulls included — until version telemetry shows the old readers are gone. + */ + legacyInstallKeys?: boolean; +} + +export interface DeepLinkPayload { + shortCode: string; + linkId?: string; + deepLinkPath?: string; + appScheme?: string; + iosUrl?: string; + androidUrl?: string; + webUrl?: string; + utmParameters?: unknown; + customParameters?: unknown; + clickedAt: string; + isDeferred: boolean; + confidenceScore?: number; + matchedFactors?: string[]; + // Legacy install-only aliases; see `legacyInstallKeys`. + originalUrl?: string | null; + webFallbackUrl?: string | null; + targetingRules?: unknown; + deepLinkParameters?: unknown; +} + +/** The keys both endpoints must agree on. Exported so tests can assert it. */ +export const CANONICAL_DEEP_LINK_KEYS = [ + 'shortCode', + 'linkId', + 'deepLinkPath', + 'appScheme', + 'iosUrl', + 'androidUrl', + 'webUrl', + 'utmParameters', + 'customParameters', + 'clickedAt', + 'isDeferred', +] as const; + +function toIsoString(value: Date | string): string { + return value instanceof Date ? value.toISOString() : value; +} + +/** + * Projects a link row into the payload an SDK's `DeepLinkData` model expects. + * + * Empty canonical fields are omitted rather than sent as null, matching what + * `/resolve` has always done; the legacy aliases keep their raw values so their + * serialization does not change at all. + */ +export function toDeepLinkPayload( + link: DeepLinkLinkRow, + options: DeepLinkPayloadOptions +): DeepLinkPayload { + const payload: DeepLinkPayload = { + shortCode: link.short_code, + linkId: link.id || undefined, + deepLinkPath: link.deep_link_path || undefined, + appScheme: link.app_scheme || undefined, + iosUrl: link.ios_app_store_url || undefined, + androidUrl: link.android_app_store_url || undefined, + webUrl: link.web_fallback_url || undefined, + utmParameters: link.utm_parameters || undefined, + customParameters: link.deep_link_parameters || undefined, + clickedAt: toIsoString(options.clickedAt), + isDeferred: options.isDeferred, + }; + + // Drop the empty ones outright rather than leaving `key: undefined` behind. + // JSON serialization would omit them anyway; deleting keeps the in-memory + // object identical to what goes over the wire and into `deep_link_data`, so + // anything enumerating keys sees the same shape a client does. + for (const key of Object.keys(payload) as (keyof DeepLinkPayload)[]) { + if (payload[key] === undefined) delete payload[key]; + } + + // Attribution metadata is meaningful only for a probabilistic (deferred) + // match. Handing it to the app is deliberate: it can gate how confidently it + // routes, which is not possible when a provider hides the match quality. + if (options.confidenceScore != null) { + payload.confidenceScore = options.confidenceScore; + } + if (options.matchedFactors != null) { + payload.matchedFactors = options.matchedFactors; + } + + if (options.legacyInstallKeys) { + payload.originalUrl = (link.original_url ?? null) as string | null; + payload.webFallbackUrl = (link.web_fallback_url ?? null) as string | null; + payload.targetingRules = link.targeting_rules ?? null; + payload.deepLinkParameters = link.deep_link_parameters ?? null; + } + + return payload; +} diff --git a/src/lib/fingerprint.test.ts b/src/lib/fingerprint.test.ts index 89c8133..52087dd 100644 --- a/src/lib/fingerprint.test.ts +++ b/src/lib/fingerprint.test.ts @@ -321,7 +321,9 @@ describe('recordInstallEvent', () => { expect(result.installId).toBe('install-123'); expect(result.match).toBeNull(); - expect(result.deepLinkData).toEqual({}); + // Null, not `{}` — an SDK decodes this field into a model with a required + // short code, so an empty object fails to decode the whole response. + expect(result.deepLinkData).toBeNull(); expect(mockDbQuery).toHaveBeenCalledTimes(2); expect(mockDbQuery).toHaveBeenLastCalledWith( diff --git a/src/lib/fingerprint.ts b/src/lib/fingerprint.ts index 4704513..bff58c6 100644 --- a/src/lib/fingerprint.ts +++ b/src/lib/fingerprint.ts @@ -1,5 +1,6 @@ import crypto from 'crypto'; import { db } from './database.js'; +import { toDeepLinkPayload, type DeepLinkPayload } from './deep-link-payload.js'; /** * Device fingerprint data structure @@ -393,7 +394,7 @@ export async function recordInstallEvent( ): Promise<{ installId: string; match: FingerprintMatch | null; - deepLinkData: any; + deepLinkData: DeepLinkPayload | null; }> { const fingerprintHash = generateFingerprintHash(fingerprintData); @@ -456,14 +457,22 @@ export async function recordInstallEvent( ); const installId = installResult.rows[0].id; - let deepLinkData = {}; + + // Organic installs carry no deep link. This is null rather than `{}` because + // an empty object is not a deep link: SDKs decode this field into a model with + // a required short code, so `{}` fails to decode and takes the whole install + // response — and with it SDK initialization — down with it. + let deepLinkData: DeepLinkPayload | null = null; // If we have a match, retrieve the deep link data from the original link if (match) { const linkResult = await db.query( `SELECT + id, short_code, original_url, + deep_link_path, + app_scheme, ios_app_store_url, android_app_store_url, web_fallback_url, @@ -477,19 +486,13 @@ export async function recordInstallEvent( if (linkResult.rows.length > 0) { const link = linkResult.rows[0]; - deepLinkData = { - shortCode: link.short_code, - originalUrl: link.original_url, - iosUrl: link.ios_app_store_url, - androidUrl: link.android_app_store_url, - webFallbackUrl: link.web_fallback_url, - utmParameters: link.utm_parameters, - targetingRules: link.targeting_rules, - deepLinkParameters: link.deep_link_parameters, + deepLinkData = toDeepLinkPayload(link, { clickedAt: match.clickedAt, + isDeferred: true, confidenceScore: match.confidenceScore, matchedFactors: match.matchedFactors, - }; + legacyInstallKeys: true, + }); // Update the install event with deep link data await db.query( diff --git a/src/routes/sdk.ts b/src/routes/sdk.ts index 7457a9d..be51bfc 100644 --- a/src/routes/sdk.ts +++ b/src/routes/sdk.ts @@ -8,6 +8,7 @@ import { storeFingerprintForClick, type FingerprintData, } from '../lib/fingerprint.js'; +import { toDeepLinkPayload } from '../lib/deep-link-payload.js'; import { triggerWebhooks } from '../lib/webhook.js'; import { evaluateLinkSafety, createOwnerSuspensionSelect } from '../lib/link-safety.js'; import { parseUserAgent, getLocationFromIP, detectDevice } from '../lib/utils.js'; @@ -679,19 +680,15 @@ export async function sdkRoutes(fastify: FastifyInstance) { } }); - // Return JSON response with deep link data - return reply.status(200).send({ - shortCode: link.short_code, - linkId: link.id, - deepLinkPath: link.deep_link_path || undefined, - appScheme: link.app_scheme || undefined, - iosUrl: link.ios_app_store_url || undefined, - androidUrl: link.android_app_store_url || undefined, - webUrl: link.web_fallback_url || undefined, - utmParameters: link.utm_parameters || undefined, - customParameters: link.deep_link_parameters || undefined, - clickedAt: new Date().toISOString(), - }); + // Return JSON response with deep link data. Same projection the install + // endpoint uses, so a direct open and a deferred one describe a link to the + // SDK identically. + return reply.status(200).send( + toDeepLinkPayload(link, { + clickedAt: new Date().toISOString(), + isDeferred: false, + }) + ); } fastify.get('/api/sdk/v1/resolve/:shortCode', async (request, reply) => {