@affiliateo/web 4.5.0 → 4.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/provider.js CHANGED
@@ -4,6 +4,7 @@ import { createContext, useCallback, useContext, useEffect, useRef, useState } f
4
4
  import { EventQueue } from './queue';
5
5
  import { isLikelyBot } from './bot-detect';
6
6
  import { isDevHost } from './dev-host';
7
+ import { mintTurnstileToken, consumeTurnstileToken } from './turnstile';
7
8
  const DEFAULT_API_URL = 'https://affiliateo.com';
8
9
  const LS_VISITOR_ID = 'affiliateo_visitor_id';
9
10
  const LS_REF = 'affiliateo_ref';
@@ -355,6 +356,12 @@ export function AffiliateoProvider({ appId, campaignId, apiUrl, allowedHostnames
355
356
  gclid: params.get('gclid') || '',
356
357
  screen_width: window.innerWidth,
357
358
  };
359
+ // Attach the (single-use) Turnstile token if one is still unconsumed.
360
+ // Matters for consent flows where the host disabled auto-capture and
361
+ // this manual call is the session-opening event.
362
+ const tsToken = consumeTurnstileToken();
363
+ if (tsToken)
364
+ payload.cf_turnstile_token = tsToken;
358
365
  queueRef.current?.enqueue(`${apiBase}/api/v1/web/event`, payload);
359
366
  log('pageview (manual)', { path: pagePath });
360
367
  }
@@ -674,6 +681,11 @@ export function AffiliateoProvider({ appId, campaignId, apiUrl, allowedHostnames
674
681
  ssSet(SS_PV_THROTTLE, JSON.stringify({ url: href, time: now }));
675
682
  lastSendTime = now;
676
683
  ended = false;
684
+ // Attach the (single-use) Turnstile token to the session-opening
685
+ // pageview. consumeTurnstileToken() returns null after the first
686
+ // consume, so SPA navigations go out bare — the server trusts them
687
+ // because the visitor row was created behind the verified token.
688
+ const tsToken = consumeTurnstileToken();
677
689
  queue.enqueue(endpoint, {
678
690
  type: 'pageview',
679
691
  campaign_id: appIdRef.current,
@@ -692,6 +704,7 @@ export function AffiliateoProvider({ appId, campaignId, apiUrl, allowedHostnames
692
704
  ttclid: params.get('ttclid') || '',
693
705
  gclid: params.get('gclid') || '',
694
706
  screen_width: window.innerWidth,
707
+ ...(tsToken ? { cf_turnstile_token: tsToken } : {}),
695
708
  });
696
709
  log('pageview', { path: window.location.pathname });
697
710
  }
@@ -761,13 +774,30 @@ export function AffiliateoProvider({ appId, campaignId, apiUrl, allowedHostnames
761
774
  }
762
775
  }
763
776
  }
764
- // Send pageview on mount. captureInitialPageview === false skips this
765
- // for cookie-consent flows where the host wants to defer until the
766
- // user accepts (then call trackPageview() manually).
767
- if (!sentRef.current && autoCfg.captureInitialPageview && autoCfg.enabled) {
768
- sentRef.current = true;
769
- sendPageview();
770
- }
777
+ // Kick off the Turnstile mint regardless of auto-capture config so a
778
+ // consent flow's later manual trackPageview() finds a token waiting.
779
+ // Never rejects; resolves with or without a token (see turnstile.ts).
780
+ let cancelled = false;
781
+ void mintTurnstileToken().then(() => {
782
+ // Send pageview on mount, deferred until the Turnstile mint settles so
783
+ // the session-opening event carries the token (SPA navigations reuse
784
+ // the resolved state and don't wait). captureInitialPageview === false
785
+ // skips this for cookie-consent flows where the host wants to defer
786
+ // until the user accepts (then call trackPageview() manually).
787
+ //
788
+ // sentRef is set HERE (not before the mint) so a StrictMode
789
+ // double-mount can't mark the pageview as sent and then tear down the
790
+ // queue before the deferred send ever ran — the surviving mount's
791
+ // callback sees sentRef still false and fires.
792
+ if (cancelled)
793
+ return;
794
+ if (sentRef.current)
795
+ return;
796
+ if (autoCfg.captureInitialPageview && autoCfg.enabled) {
797
+ sentRef.current = true;
798
+ sendPageview();
799
+ }
800
+ });
771
801
  // Detect user leaving
772
802
  document.addEventListener('visibilitychange', onVisibilityChange);
773
803
  window.addEventListener('pagehide', sendEnd);
@@ -899,6 +929,7 @@ export function AffiliateoProvider({ appId, campaignId, apiUrl, allowedHostnames
899
929
  document.addEventListener('keydown', onKeydownForLinkDecoration, true);
900
930
  }
901
931
  return () => {
932
+ cancelled = true;
902
933
  document.removeEventListener('visibilitychange', onVisibilityChange);
903
934
  window.removeEventListener('pagehide', sendEnd);
904
935
  window.removeEventListener('popstate', onUrlChange);
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Cloudflare Turnstile token minting. Mirrors the hosted t.js tracker:
3
+ * mint ONE invisible token at SDK init and attach it to the first pageview.
4
+ * The server verifies the token before creating the visitor row (and before
5
+ * counting a pay-per-click web_traffic click — the billable path REQUIRES a
6
+ * genuine pass when TURNSTILE_SECRET is configured). Subsequent events from
7
+ * the same visitor_id are trusted because the row was created behind a
8
+ * verified token, so the cost is one mint + one verify per session.
9
+ *
10
+ * Tokens are single-use at Cloudflare's siteverify, so consumeToken()
11
+ * hands the token out exactly once — re-sending a used token would get the
12
+ * event rejected server-side, not warn-allowed.
13
+ *
14
+ * Failure modes: if the Turnstile script is blocked (ad blockers, strict
15
+ * CSP), errors, or times out, minting resolves with no token and events go
16
+ * out bare. The server's warn-allow mode (TURNSTILE_REQUIRE unset) lets
17
+ * those through; commission-on-sale (web_affiliate) tracking never depends
18
+ * on the token at all.
19
+ */
20
+ interface TurnstileApi {
21
+ render: (el: HTMLElement, opts: Record<string, unknown>) => unknown;
22
+ }
23
+ declare global {
24
+ interface Window {
25
+ turnstile?: TurnstileApi;
26
+ __affiliateoTSLoaded?: () => void;
27
+ }
28
+ }
29
+ /**
30
+ * Load the Turnstile script (once) and run the invisible challenge. Resolves
31
+ * when a token is available OR the mint failed/timed out — never rejects, so
32
+ * callers can unconditionally `.then(sendPageview)`.
33
+ */
34
+ export declare function mintTurnstileToken(): Promise<void>;
35
+ /**
36
+ * Hand out the minted token exactly once (single-use at siteverify).
37
+ * Returns null when no token was minted or it was already consumed.
38
+ */
39
+ export declare function consumeTurnstileToken(): string | null;
40
+ export {};
@@ -0,0 +1,105 @@
1
+ /**
2
+ * Cloudflare Turnstile token minting. Mirrors the hosted t.js tracker:
3
+ * mint ONE invisible token at SDK init and attach it to the first pageview.
4
+ * The server verifies the token before creating the visitor row (and before
5
+ * counting a pay-per-click web_traffic click — the billable path REQUIRES a
6
+ * genuine pass when TURNSTILE_SECRET is configured). Subsequent events from
7
+ * the same visitor_id are trusted because the row was created behind a
8
+ * verified token, so the cost is one mint + one verify per session.
9
+ *
10
+ * Tokens are single-use at Cloudflare's siteverify, so consumeToken()
11
+ * hands the token out exactly once — re-sending a used token would get the
12
+ * event rejected server-side, not warn-allowed.
13
+ *
14
+ * Failure modes: if the Turnstile script is blocked (ad blockers, strict
15
+ * CSP), errors, or times out, minting resolves with no token and events go
16
+ * out bare. The server's warn-allow mode (TURNSTILE_REQUIRE unset) lets
17
+ * those through; commission-on-sale (web_affiliate) tracking never depends
18
+ * on the token at all.
19
+ */
20
+ // Public sitekey (safe to expose) — the same one public/t.js ships. The
21
+ // matching secret lives in TURNSTILE_SECRET on the server.
22
+ const TURNSTILE_SITEKEY = '0x4AAAAAADR5UW2x6axFd13p';
23
+ const TURNSTILE_SCRIPT_SRC = 'https://challenges.cloudflare.com/turnstile/v0/api.js?onload=__affiliateoTSLoaded&render=explicit';
24
+ // Hard deadline for the whole mint (script load + challenge). t.js relies on
25
+ // Turnstile's own timeout-callback, but a script tag that never loads never
26
+ // fires ANY callback — this timer makes sure the first pageview can't be
27
+ // held hostage by a blocked challenges.cloudflare.com.
28
+ const MINT_TIMEOUT_MS = 3000;
29
+ let token = null;
30
+ let minting = null;
31
+ /**
32
+ * Load the Turnstile script (once) and run the invisible challenge. Resolves
33
+ * when a token is available OR the mint failed/timed out — never rejects, so
34
+ * callers can unconditionally `.then(sendPageview)`.
35
+ */
36
+ export function mintTurnstileToken() {
37
+ if (typeof window === 'undefined' || typeof document === 'undefined') {
38
+ return Promise.resolve();
39
+ }
40
+ if (minting)
41
+ return minting;
42
+ minting = new Promise((resolve) => {
43
+ let settled = false;
44
+ const settle = () => {
45
+ if (settled)
46
+ return;
47
+ settled = true;
48
+ clearTimeout(deadline);
49
+ resolve();
50
+ };
51
+ const deadline = setTimeout(settle, MINT_TIMEOUT_MS);
52
+ const render = () => {
53
+ if (!window.turnstile) {
54
+ settle();
55
+ return;
56
+ }
57
+ try {
58
+ const holder = document.createElement('div');
59
+ holder.style.display = 'none';
60
+ document.body.appendChild(holder);
61
+ window.turnstile.render(holder, {
62
+ sitekey: TURNSTILE_SITEKEY,
63
+ // Invisible widget — never shows UI. Real browsers pass the
64
+ // challenge in 50-200ms with zero user interaction.
65
+ size: 'invisible',
66
+ callback: (tok) => {
67
+ token = tok;
68
+ settle();
69
+ },
70
+ 'error-callback': settle,
71
+ 'timeout-callback': settle,
72
+ });
73
+ }
74
+ catch {
75
+ settle();
76
+ }
77
+ };
78
+ if (window.turnstile) {
79
+ render();
80
+ return;
81
+ }
82
+ try {
83
+ const script = document.createElement('script');
84
+ script.src = TURNSTILE_SCRIPT_SRC;
85
+ script.async = true;
86
+ script.defer = true;
87
+ script.onerror = settle;
88
+ window.__affiliateoTSLoaded = render;
89
+ document.head.appendChild(script);
90
+ }
91
+ catch {
92
+ settle();
93
+ }
94
+ });
95
+ return minting;
96
+ }
97
+ /**
98
+ * Hand out the minted token exactly once (single-use at siteverify).
99
+ * Returns null when no token was minted or it was already consumed.
100
+ */
101
+ export function consumeTurnstileToken() {
102
+ const t = token;
103
+ token = null;
104
+ return t;
105
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@affiliateo/web",
3
- "version": "4.5.0",
3
+ "version": "4.6.0",
4
4
  "description": "Affiliateo web SDK — affiliate attribution and tracking for web apps",
5
5
  "main": "dist/index.js",
6
6
  "module": "dist/index.js",