Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
164 changes: 164 additions & 0 deletions src/lib/deep-link-payload.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;
const resolve = resolvePayload() as unknown as Record<string, unknown>;

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<string, unknown>;

expect('originalUrl' in payload).toBe(false);
expect('webFallbackUrl' in payload).toBe(false);
expect('targetingRules' in payload).toBe(false);
expect('deepLinkParameters' in payload).toBe(false);
});
});
144 changes: 144 additions & 0 deletions src/lib/deep-link-payload.ts
Original file line number Diff line number Diff line change
@@ -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;
}
4 changes: 3 additions & 1 deletion src/lib/fingerprint.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
27 changes: 15 additions & 12 deletions src/lib/fingerprint.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -393,7 +394,7 @@ export async function recordInstallEvent(
): Promise<{
installId: string;
match: FingerprintMatch | null;
deepLinkData: any;
deepLinkData: DeepLinkPayload | null;
}> {
const fingerprintHash = generateFingerprintHash(fingerprintData);

Expand Down Expand Up @@ -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,
Expand All @@ -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(
Expand Down
Loading
Loading