From 02ce4bfb3f1b3e8dcd207cd43a23886df23a6ffc Mon Sep 17 00:00:00 2001 From: "Beomhee.Lee" Date: Mon, 3 Aug 2026 20:19:59 +0900 Subject: [PATCH] =?UTF-8?q?allowlist=EC=97=90=20u=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/lib/click-query-params.ts | 21 +++++++++++++++ src/lib/database.ts | 12 +++++++++ src/lib/fingerprint.test.ts | 51 +++++++++++++++++++++++++++++++++++ src/lib/fingerprint.ts | 36 ++++++++++++++++++++++++- src/routes/redirect.ts | 7 +++-- src/routes/sdk.ts | 7 +++-- 6 files changed, 129 insertions(+), 5 deletions(-) create mode 100644 src/lib/click-query-params.ts diff --git a/src/lib/click-query-params.ts b/src/lib/click-query-params.ts new file mode 100644 index 0000000..56d337f --- /dev/null +++ b/src/lib/click-query-params.ts @@ -0,0 +1,21 @@ +const CLICK_QUERY_PARAM_ALLOWLIST = ['u'] as const; + +type ClickQueryParamKey = typeof CLICK_QUERY_PARAM_ALLOWLIST[number]; + +/** + * Extract and sanitize click URL query params allowed to flow into deferred deep link payloads. + */ +export function extractAllowedClickQueryParams( + query: Record +): Partial> { + const out: Partial> = {}; + + for (const key of CLICK_QUERY_PARAM_ALLOWLIST) { + const value = query[key]; + if (typeof value === 'string' && value.length > 0) { + out[key] = value; + } + } + + return out; +} diff --git a/src/lib/database.ts b/src/lib/database.ts index a2ce8b2..85e3a56 100644 --- a/src/lib/database.ts +++ b/src/lib/database.ts @@ -129,6 +129,7 @@ export async function initializeDatabase(options: DatabaseOptions = {}) { utm_medium VARCHAR(255), utm_campaign VARCHAR(255), referrer TEXT, + click_query_params JSONB DEFAULT '{}', is_bot BOOLEAN NOT NULL DEFAULT false, bot_reason VARCHAR(16) ) @@ -429,6 +430,17 @@ export async function initializeDatabase(options: DatabaseOptions = {}) { END $$; `); + // Allowlisted click query parameters persisted for deferred deep linking. + // Backward compatible: legacy rows default to an empty object. + await client.query(` + DO $$ + BEGIN + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='click_events' AND column_name='click_query_params') THEN + ALTER TABLE click_events ADD COLUMN click_query_params JSONB DEFAULT '{}'; + END IF; + END $$; + `); + // Attribution metadata on install_events (SIT-296): how the install was // attributed ('fingerprint' | 'none') and which fingerprint signals matched. // Makes attribution quality measurable. Backward compatible (NULL until set). diff --git a/src/lib/fingerprint.test.ts b/src/lib/fingerprint.test.ts index 30e7c39..af3f75a 100644 --- a/src/lib/fingerprint.test.ts +++ b/src/lib/fingerprint.test.ts @@ -393,4 +393,55 @@ describe('recordInstallEvent', () => { expect(params[15]).toBeNull(); // sdk_name '' -> null expect(params[16]).toBeNull(); // sdk_version undefined -> null }); + + it('merges allowlisted click query params into deferred deepLinkParameters on attribution', async () => { + const clickTime = new Date().toISOString(); + + mockDbQuery.mockResolvedValueOnce({ + rows: [ + { + click_id: 'click-merge', + link_id: 'link-merge', + clicked_at: clickTime, + click_query_params: { u: 'abc' }, + attribution_window_hours: 24, + ip_address: '24.5.10.250', + user_agent: + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/116.0.0.0 Safari/537.36', + timezone: 'America/Los_Angeles', + language: 'en-US', + screen_width: 1080, + screen_height: 1920, + platform: 'Windows', + platform_version: '10', + }, + ], + }); + mockDbQuery.mockResolvedValueOnce({ rows: [{ id: 'install-merged', deep_link_data: {} }] }); + mockDbQuery.mockResolvedValueOnce({ + rows: [ + { + short_code: 'kLOepkou', + original_url: 'https://angkorlifes.com', + ios_app_store_url: null, + android_app_store_url: null, + web_fallback_url: null, + utm_parameters: {}, + targeting_rules: {}, + deep_link_parameters: { pid: 'recommend_user' }, + }, + ], + }); + mockDbQuery.mockResolvedValueOnce({ rows: [] }); // UPDATE install_events + mockDbQuery.mockResolvedValueOnce({ rows: [] }); // SELECT user_id FROM links + + const result = await fingerprint.recordInstallEvent(baseFingerprint, 'device-1'); + + expect(result.match).not.toBeNull(); + expect(result.deepLinkData.deepLinkParameters).toEqual({ + pid: 'recommend_user', + u: 'abc', + }); + expect(result.deepLinkData.clickQueryParameters).toEqual({ u: 'abc' }); + }); }); diff --git a/src/lib/fingerprint.ts b/src/lib/fingerprint.ts index 07ff665..b5a7055 100644 --- a/src/lib/fingerprint.ts +++ b/src/lib/fingerprint.ts @@ -24,6 +24,7 @@ export interface FingerprintMatch { confidenceScore: number; matchedFactors: string[]; clickedAt: Date; + clickQueryParams?: Record; } type FingerprintFactorDebug = { @@ -250,6 +251,27 @@ function logAttributionDebug(event: string, payload: Record): v console.info(`[linkforty][attribution] ${event} ${JSON.stringify(payload)}`); } +function normalizeClickQueryParams(value: unknown): Record { + if (value == null) return {}; + const source = typeof value === 'string' ? (() => { + try { + return JSON.parse(value); + } catch { + return {}; + } + })() : value; + + if (typeof source !== 'object' || Array.isArray(source)) return {}; + + const result: Record = {}; + for (const [k, v] of Object.entries(source as Record)) { + if (typeof v === 'string') { + result[k] = v; + } + } + return result; +} + function scoreFingerprintMatch( fingerprint1: FingerprintData, fingerprint2: FingerprintData @@ -396,6 +418,7 @@ export async function matchInstallToClick( ce.id as click_id, ce.link_id, ce.clicked_at, + ce.click_query_params, l.attribution_window_hours, df.ip_address, df.user_agent, @@ -445,6 +468,7 @@ export async function matchInstallToClick( platform: row.platform, platformVersion: row.platform_version, }; + const clickQueryParams = normalizeClickQueryParams(row.click_query_params); const { score, matchedFactors, debug } = scoreFingerprintMatch( installFingerprint, @@ -476,6 +500,7 @@ export async function matchInstallToClick( screenHeight: clickFingerprint.screenHeight || null, }, factors: debug, + clickQueryParams, }); // Track the best match @@ -487,6 +512,7 @@ export async function matchInstallToClick( confidenceScore: score, matchedFactors, clickedAt: new Date(row.clicked_at), + clickQueryParams, }; } } @@ -510,6 +536,7 @@ export async function matchInstallToClick( confidenceScore: bestMatch.confidenceScore, matchedFactors: bestMatch.matchedFactors, clickedAt: bestMatch.clickedAt.toISOString(), + clickQueryParams: bestMatch.clickQueryParams || {}, } : null, }); @@ -649,6 +676,12 @@ export async function recordInstallEvent( if (linkResult.rows.length > 0) { const link = linkResult.rows[0]; + const clickQueryParams = match.clickQueryParams || {}; + const deepLinkParameters = { + ...(link.deep_link_parameters || {}), + ...clickQueryParams, + }; + deepLinkData = { shortCode: link.short_code, originalUrl: link.original_url, @@ -657,7 +690,8 @@ export async function recordInstallEvent( webFallbackUrl: link.web_fallback_url, utmParameters: link.utm_parameters, targetingRules: link.targeting_rules, - deepLinkParameters: link.deep_link_parameters, + deepLinkParameters, + clickQueryParameters: clickQueryParams, clickedAt: match.clickedAt, confidenceScore: match.confidenceScore, matchedFactors: match.matchedFactors, diff --git a/src/routes/redirect.ts b/src/routes/redirect.ts index d4e556b..7e17947 100644 --- a/src/routes/redirect.ts +++ b/src/routes/redirect.ts @@ -6,6 +6,7 @@ import { parseUserAgent, getLocationFromIP, buildRedirectUrl, detectDevice } fro import { storeFingerprintForClick, type FingerprintData } from '../lib/fingerprint.js'; import { emitClickEvent } from '../lib/event-emitter.js'; import { classifyBot, edgeBotSignal } from '../lib/bot-detection.js'; +import { extractAllowedClickQueryParams } from '../lib/click-query-params.js'; /** * Detect iOS in-app browsers where Universal Links don't fire. @@ -281,6 +282,7 @@ export async function redirectRoutes(fastify: FastifyInstance) { const utmSource = query?.utm_source; const utmMedium = query?.utm_medium; const utmCampaign = query?.utm_campaign; + const clickQueryParams = extractAllowedClickQueryParams(query); // Extract fingerprint data from query params (sent by SDK/client) const fpTimezone = query?.fp_tz || timezone || undefined; @@ -296,8 +298,8 @@ export async function redirectRoutes(fastify: FastifyInstance) { `INSERT INTO click_events ( id, link_id, ip_address, user_agent, device_type, platform, country_code, country_name, region, city, latitude, longitude, timezone, - utm_source, utm_medium, utm_campaign, referrer, is_bot, bot_reason - ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19)`, + utm_source, utm_medium, utm_campaign, referrer, click_query_params, is_bot, bot_reason + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20)`, [ clickId, link.id, @@ -316,6 +318,7 @@ export async function redirectRoutes(fastify: FastifyInstance) { utmMedium, utmCampaign, referrer, + JSON.stringify(clickQueryParams), isBot, botReason, ] diff --git a/src/routes/sdk.ts b/src/routes/sdk.ts index 42b9f81..5fce998 100644 --- a/src/routes/sdk.ts +++ b/src/routes/sdk.ts @@ -8,6 +8,7 @@ import { storeFingerprintForClick, type FingerprintData, } from '../lib/fingerprint.js'; +import { extractAllowedClickQueryParams } from '../lib/click-query-params.js'; import { triggerWebhooks } from '../lib/webhook.js'; import { parseUserAgent, getLocationFromIP, detectDevice } from '../lib/utils.js'; import { emitClickEvent } from '../lib/event-emitter.js'; @@ -611,14 +612,15 @@ export async function sdkRoutes(fastify: FastifyInstance) { const fpScreenHeight = query?.fp_sh ? parseInt(query.fp_sh, 10) : undefined; const fpPlatform = query?.fp_platform || deviceType; const fpPlatformVersion = query?.fp_pv || platformVersion; + const clickQueryParams = extractAllowedClickQueryParams(query); // Insert click event const clickResult = await db.query( `INSERT INTO click_events ( link_id, ip_address, user_agent, device_type, platform, country_code, country_name, region, city, latitude, longitude, timezone, - utm_source, utm_medium, utm_campaign, referrer, is_bot, bot_reason - ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18) + utm_source, utm_medium, utm_campaign, referrer, click_query_params, is_bot, bot_reason + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19) RETURNING id`, [ link.id, @@ -637,6 +639,7 @@ export async function sdkRoutes(fastify: FastifyInstance) { query?.utm_medium || null, query?.utm_campaign || null, referrer, + JSON.stringify(clickQueryParams), isBot, botReason, ]