Skip to content

Commit 42d492a

Browse files
committed
verify click limit before universal link return
1 parent 7841249 commit 42d492a

3 files changed

Lines changed: 112 additions & 0 deletions

File tree

packages/react-native-detour/src/expo-router/nativeIntent.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { OPENED_VIA_UNIVERSAL_LINK } from "../analytics/const/definedEvents";
22
import { analyticsEmitter } from "../analytics/utils/analyticsEmitter";
3+
import { checkClickLimit } from "../links/api/checkClickLimit";
34
import { resolveShortLink } from "../links/api/resolveShortLink";
45
import type { Config } from "../links/types";
56
import { getRouteFromDeepLink } from "../links/utils/urlHelpers";
@@ -297,6 +298,23 @@ export const createDetourNativeIntentHandler = (
297298
return fallbackPath;
298299
}
299300

301+
const clickLimitStatus = await checkClickLimit({
302+
apiKey: options.config.apiKey,
303+
appID: options.config.appID,
304+
});
305+
306+
if (!clickLimitStatus.allowed) {
307+
console.error("🔗[Detour:CLICK_LIMIT_ERROR] Native-intent routing blocked:", {
308+
path,
309+
status: clickLimitStatus.status,
310+
error: clickLimitStatus.error,
311+
code: clickLimitStatus.code,
312+
clicksInPeriod: clickLimitStatus.clicksInPeriod,
313+
effectiveLimit: clickLimitStatus.effectiveLimit,
314+
});
315+
return fallbackPath;
316+
}
317+
300318
let resolvedUrl = url;
301319

302320
if (isShortLinkCandidate(url)) {
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import { SDK_HEADER_VALUE } from "../../version";
2+
import type { RequiredConfig } from "../types";
3+
4+
const API_URL = "https://godetour.dev/api/link/click-limit";
5+
6+
type ClickLimitResponseBody = {
7+
allowed?: boolean;
8+
error?: string;
9+
code?: string;
10+
clicksInPeriod?: number;
11+
effectiveLimit?: number;
12+
};
13+
14+
export type ClickLimitCheckResult =
15+
| {
16+
allowed: true;
17+
}
18+
| {
19+
allowed: false;
20+
status: number;
21+
error: string;
22+
code?: string;
23+
clicksInPeriod?: number;
24+
effectiveLimit?: number;
25+
};
26+
27+
const getFallbackErrorMessage = (status: number) => {
28+
if (status === 402) {
29+
return "Click limit exceeded";
30+
}
31+
return "Click limit check failed";
32+
};
33+
34+
export const checkClickLimit = async ({
35+
apiKey: API_KEY,
36+
appID,
37+
}: Pick<RequiredConfig, "apiKey" | "appID">): Promise<ClickLimitCheckResult> => {
38+
try {
39+
const response = await fetch(API_URL, {
40+
method: "POST",
41+
headers: {
42+
"Content-Type": "application/json",
43+
Authorization: `Bearer ${API_KEY}`,
44+
"X-App-ID": appID,
45+
"X-SDK": SDK_HEADER_VALUE,
46+
},
47+
});
48+
49+
let body: ClickLimitResponseBody | null = null;
50+
try {
51+
body = (await response.json()) as ClickLimitResponseBody;
52+
} catch {
53+
body = null;
54+
}
55+
56+
const isExplicitDeny = body?.allowed === false || response.status === 402;
57+
if (isExplicitDeny) {
58+
return {
59+
allowed: false,
60+
status: response.status,
61+
error: body?.error || getFallbackErrorMessage(response.status),
62+
code: body?.code,
63+
clicksInPeriod: body?.clicksInPeriod,
64+
effectiveLimit: body?.effectiveLimit,
65+
};
66+
}
67+
68+
if (!response.ok) {
69+
// Fail-open for temporary backend/network issues so apps keep working.
70+
return { allowed: true };
71+
}
72+
73+
return { allowed: true };
74+
} catch {
75+
// Fail-open on transport errors; limit enforcement only happens on explicit deny.
76+
return { allowed: true };
77+
}
78+
};

packages/react-native-detour/src/links/hooks/useDetour.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { Linking } from "react-native";
44

55
import { OPENED_VIA_UNIVERSAL_LINK } from "../../analytics/const/definedEvents";
66
import { analyticsEmitter } from "../../analytics/utils/analyticsEmitter";
7+
import { checkClickLimit } from "../api/checkClickLimit";
78
import { getDeferredLink } from "../api/getDeferredLink";
89
import { resolveShortLink } from "../api/resolveShortLink";
910
import type { DetourContextType, DetourLink, LinkType, RequiredConfig } from "../types";
@@ -76,6 +77,21 @@ export const useDetour = ({
7677
const detectedType: LinkType = isWeb ? "verified" : "scheme";
7778
const type = typeOverride ?? detectedType;
7879

80+
if (isWeb && type !== "deferred") {
81+
const clickLimitStatus = await checkClickLimit({ apiKey, appID });
82+
if (!clickLimitStatus.allowed) {
83+
console.error("🔗[Detour:CLICK_LIMIT_ERROR] Universal/App link blocked:", {
84+
url: rawLink,
85+
status: clickLimitStatus.status,
86+
error: clickLimitStatus.error,
87+
code: clickLimitStatus.code,
88+
clicksInPeriod: clickLimitStatus.clicksInPeriod,
89+
effectiveLimit: clickLimitStatus.effectiveLimit,
90+
});
91+
return null;
92+
}
93+
}
94+
7995
if (isWeb) {
8096
const pathSegments = urlObj.pathname.split("/").filter(Boolean);
8197
const isSingleSegmentPath =

0 commit comments

Comments
 (0)