From df0d57079e37d82df1eb6bf84efe4ce54a4dd53c Mon Sep 17 00:00:00 2001 From: Brandon Estrella Date: Sat, 15 Aug 2026 16:24:20 -0700 Subject: [PATCH] feat(sdk): project install and resolve deep links through one shape (SIT-349) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two endpoints hand a deep link to an SDK: /api/sdk/v1/resolve when the app is already installed, and /api/sdk/v1/install when a new install is matched to an earlier click. Each built its own object literal, and the two had drifted. Resolve sent webUrl, customParameters, deepLinkPath, appScheme and linkId. Install sent webFallbackUrl and deepLinkParameters for the first two, and never sent the last three at all. Every SDK's DeepLinkData model matches the resolve spelling, so on the deferred path those fields decoded as null — including the deepLinkPath and appScheme an app needs to route a newly installed user, and the linkId the SDKs use to credit that user's first session to the link that acquired them. Both endpoints now project through toDeepLinkPayload, so the shapes cannot drift again, and a contract test fails if either side is edited alone. The change is additive. The install payload keeps emitting originalUrl, webFallbackUrl, targetingRules and deepLinkParameters with their exact previous values, nulls included: every SDK in the field reads those names and app updates take weeks to roll out. Version telemetry (sdkName/sdkVersion, sent on every install request) is what will eventually say when they can go. Organic installs now return null rather than {}. 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. --- src/lib/deep-link-payload.test.ts | 164 ++++++++++++++++++++++++++++++ src/lib/deep-link-payload.ts | 144 ++++++++++++++++++++++++++ src/lib/fingerprint.test.ts | 4 +- src/lib/fingerprint.ts | 27 ++--- src/routes/sdk.ts | 23 ++--- 5 files changed, 336 insertions(+), 26 deletions(-) create mode 100644 src/lib/deep-link-payload.test.ts create mode 100644 src/lib/deep-link-payload.ts 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) => {