allowlist에 u추가

This commit is contained in:
2026-08-03 20:19:59 +09:00
parent 8cb67d52c0
commit 02ce4bfb3f
6 changed files with 129 additions and 5 deletions
+21
View File
@@ -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<string, string | undefined>
): Partial<Record<ClickQueryParamKey, string>> {
const out: Partial<Record<ClickQueryParamKey, string>> = {};
for (const key of CLICK_QUERY_PARAM_ALLOWLIST) {
const value = query[key];
if (typeof value === 'string' && value.length > 0) {
out[key] = value;
}
}
return out;
}
+12
View File
@@ -129,6 +129,7 @@ export async function initializeDatabase(options: DatabaseOptions = {}) {
utm_medium VARCHAR(255), utm_medium VARCHAR(255),
utm_campaign VARCHAR(255), utm_campaign VARCHAR(255),
referrer TEXT, referrer TEXT,
click_query_params JSONB DEFAULT '{}',
is_bot BOOLEAN NOT NULL DEFAULT false, is_bot BOOLEAN NOT NULL DEFAULT false,
bot_reason VARCHAR(16) bot_reason VARCHAR(16)
) )
@@ -429,6 +430,17 @@ export async function initializeDatabase(options: DatabaseOptions = {}) {
END $$; 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 // Attribution metadata on install_events (SIT-296): how the install was
// attributed ('fingerprint' | 'none') and which fingerprint signals matched. // attributed ('fingerprint' | 'none') and which fingerprint signals matched.
// Makes attribution quality measurable. Backward compatible (NULL until set). // Makes attribution quality measurable. Backward compatible (NULL until set).
+51
View File
@@ -393,4 +393,55 @@ describe('recordInstallEvent', () => {
expect(params[15]).toBeNull(); // sdk_name '' -> null expect(params[15]).toBeNull(); // sdk_name '' -> null
expect(params[16]).toBeNull(); // sdk_version undefined -> 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' });
});
}); });
+35 -1
View File
@@ -24,6 +24,7 @@ export interface FingerprintMatch {
confidenceScore: number; confidenceScore: number;
matchedFactors: string[]; matchedFactors: string[];
clickedAt: Date; clickedAt: Date;
clickQueryParams?: Record<string, string>;
} }
type FingerprintFactorDebug = { type FingerprintFactorDebug = {
@@ -250,6 +251,27 @@ function logAttributionDebug(event: string, payload: Record<string, unknown>): v
console.info(`[linkforty][attribution] ${event} ${JSON.stringify(payload)}`); console.info(`[linkforty][attribution] ${event} ${JSON.stringify(payload)}`);
} }
function normalizeClickQueryParams(value: unknown): Record<string, string> {
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<string, string> = {};
for (const [k, v] of Object.entries(source as Record<string, unknown>)) {
if (typeof v === 'string') {
result[k] = v;
}
}
return result;
}
function scoreFingerprintMatch( function scoreFingerprintMatch(
fingerprint1: FingerprintData, fingerprint1: FingerprintData,
fingerprint2: FingerprintData fingerprint2: FingerprintData
@@ -396,6 +418,7 @@ export async function matchInstallToClick(
ce.id as click_id, ce.id as click_id,
ce.link_id, ce.link_id,
ce.clicked_at, ce.clicked_at,
ce.click_query_params,
l.attribution_window_hours, l.attribution_window_hours,
df.ip_address, df.ip_address,
df.user_agent, df.user_agent,
@@ -445,6 +468,7 @@ export async function matchInstallToClick(
platform: row.platform, platform: row.platform,
platformVersion: row.platform_version, platformVersion: row.platform_version,
}; };
const clickQueryParams = normalizeClickQueryParams(row.click_query_params);
const { score, matchedFactors, debug } = scoreFingerprintMatch( const { score, matchedFactors, debug } = scoreFingerprintMatch(
installFingerprint, installFingerprint,
@@ -476,6 +500,7 @@ export async function matchInstallToClick(
screenHeight: clickFingerprint.screenHeight || null, screenHeight: clickFingerprint.screenHeight || null,
}, },
factors: debug, factors: debug,
clickQueryParams,
}); });
// Track the best match // Track the best match
@@ -487,6 +512,7 @@ export async function matchInstallToClick(
confidenceScore: score, confidenceScore: score,
matchedFactors, matchedFactors,
clickedAt: new Date(row.clicked_at), clickedAt: new Date(row.clicked_at),
clickQueryParams,
}; };
} }
} }
@@ -510,6 +536,7 @@ export async function matchInstallToClick(
confidenceScore: bestMatch.confidenceScore, confidenceScore: bestMatch.confidenceScore,
matchedFactors: bestMatch.matchedFactors, matchedFactors: bestMatch.matchedFactors,
clickedAt: bestMatch.clickedAt.toISOString(), clickedAt: bestMatch.clickedAt.toISOString(),
clickQueryParams: bestMatch.clickQueryParams || {},
} }
: null, : null,
}); });
@@ -649,6 +676,12 @@ export async function recordInstallEvent(
if (linkResult.rows.length > 0) { if (linkResult.rows.length > 0) {
const link = linkResult.rows[0]; const link = linkResult.rows[0];
const clickQueryParams = match.clickQueryParams || {};
const deepLinkParameters = {
...(link.deep_link_parameters || {}),
...clickQueryParams,
};
deepLinkData = { deepLinkData = {
shortCode: link.short_code, shortCode: link.short_code,
originalUrl: link.original_url, originalUrl: link.original_url,
@@ -657,7 +690,8 @@ export async function recordInstallEvent(
webFallbackUrl: link.web_fallback_url, webFallbackUrl: link.web_fallback_url,
utmParameters: link.utm_parameters, utmParameters: link.utm_parameters,
targetingRules: link.targeting_rules, targetingRules: link.targeting_rules,
deepLinkParameters: link.deep_link_parameters, deepLinkParameters,
clickQueryParameters: clickQueryParams,
clickedAt: match.clickedAt, clickedAt: match.clickedAt,
confidenceScore: match.confidenceScore, confidenceScore: match.confidenceScore,
matchedFactors: match.matchedFactors, matchedFactors: match.matchedFactors,
+5 -2
View File
@@ -6,6 +6,7 @@ import { parseUserAgent, getLocationFromIP, buildRedirectUrl, detectDevice } fro
import { storeFingerprintForClick, type FingerprintData } from '../lib/fingerprint.js'; import { storeFingerprintForClick, type FingerprintData } from '../lib/fingerprint.js';
import { emitClickEvent } from '../lib/event-emitter.js'; import { emitClickEvent } from '../lib/event-emitter.js';
import { classifyBot, edgeBotSignal } from '../lib/bot-detection.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. * 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 utmSource = query?.utm_source;
const utmMedium = query?.utm_medium; const utmMedium = query?.utm_medium;
const utmCampaign = query?.utm_campaign; const utmCampaign = query?.utm_campaign;
const clickQueryParams = extractAllowedClickQueryParams(query);
// Extract fingerprint data from query params (sent by SDK/client) // Extract fingerprint data from query params (sent by SDK/client)
const fpTimezone = query?.fp_tz || timezone || undefined; const fpTimezone = query?.fp_tz || timezone || undefined;
@@ -296,8 +298,8 @@ export async function redirectRoutes(fastify: FastifyInstance) {
`INSERT INTO click_events ( `INSERT INTO click_events (
id, link_id, ip_address, user_agent, device_type, platform, id, link_id, ip_address, user_agent, device_type, platform,
country_code, country_name, region, city, latitude, longitude, timezone, country_code, country_name, region, city, latitude, longitude, timezone,
utm_source, utm_medium, utm_campaign, referrer, is_bot, bot_reason 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)`, ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20)`,
[ [
clickId, clickId,
link.id, link.id,
@@ -316,6 +318,7 @@ export async function redirectRoutes(fastify: FastifyInstance) {
utmMedium, utmMedium,
utmCampaign, utmCampaign,
referrer, referrer,
JSON.stringify(clickQueryParams),
isBot, isBot,
botReason, botReason,
] ]
+5 -2
View File
@@ -8,6 +8,7 @@ import {
storeFingerprintForClick, storeFingerprintForClick,
type FingerprintData, type FingerprintData,
} from '../lib/fingerprint.js'; } from '../lib/fingerprint.js';
import { extractAllowedClickQueryParams } from '../lib/click-query-params.js';
import { triggerWebhooks } from '../lib/webhook.js'; import { triggerWebhooks } from '../lib/webhook.js';
import { parseUserAgent, getLocationFromIP, detectDevice } from '../lib/utils.js'; import { parseUserAgent, getLocationFromIP, detectDevice } from '../lib/utils.js';
import { emitClickEvent } from '../lib/event-emitter.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 fpScreenHeight = query?.fp_sh ? parseInt(query.fp_sh, 10) : undefined;
const fpPlatform = query?.fp_platform || deviceType; const fpPlatform = query?.fp_platform || deviceType;
const fpPlatformVersion = query?.fp_pv || platformVersion; const fpPlatformVersion = query?.fp_pv || platformVersion;
const clickQueryParams = extractAllowedClickQueryParams(query);
// Insert click event // Insert click event
const clickResult = await db.query( const clickResult = await db.query(
`INSERT INTO click_events ( `INSERT INTO click_events (
link_id, ip_address, user_agent, device_type, platform, link_id, ip_address, user_agent, device_type, platform,
country_code, country_name, region, city, latitude, longitude, timezone, country_code, country_name, region, city, latitude, longitude, timezone,
utm_source, utm_medium, utm_campaign, referrer, is_bot, bot_reason 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) ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19)
RETURNING id`, RETURNING id`,
[ [
link.id, link.id,
@@ -637,6 +639,7 @@ export async function sdkRoutes(fastify: FastifyInstance) {
query?.utm_medium || null, query?.utm_medium || null,
query?.utm_campaign || null, query?.utm_campaign || null,
referrer, referrer,
JSON.stringify(clickQueryParams),
isBot, isBot,
botReason, botReason,
] ]