@affiliateo/web 4.5.0 → 4.6.1

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';
@@ -170,6 +171,23 @@ export function getAttribution() {
170
171
  // mount (host has the call sites) but we can shout in dev so the
171
172
  // mistake is visible.
172
173
  let providerMountCount = 0;
174
+ /**
175
+ * True only when we can SEE that this is a production build.
176
+ *
177
+ * Every bundler (webpack, Vite, Next, Rollup) replaces `process.env.NODE_ENV`
178
+ * with a literal before shipping, which is why reading it works for almost
179
+ * everyone. Someone importing the ESM directly has no `process` at all, and
180
+ * touching it there throws a ReferenceError rather than reading undefined.
181
+ *
182
+ * UNKNOWN COUNTS AS NOT-PRODUCTION, deliberately. The only caller is the
183
+ * double-mount warning, and a double mount double-fires every pageview — which
184
+ * corrupts the merchant's own numbers and the affiliate attribution built on
185
+ * them. One stray console line on an unbundled production page is a smaller
186
+ * cost than silently letting that run.
187
+ */
188
+ function isProductionBuild() {
189
+ return typeof process !== 'undefined' && process?.env?.NODE_ENV === 'production';
190
+ }
173
191
  export function AffiliateoProvider({ appId, campaignId, apiUrl, allowedHostnames, allowLocalhost, allowIframe, allowFileProtocol, debug, flushInterval, maxQueueSize, autoCapturePageviews, children, }) {
174
192
  // Normalize autoCapturePageviews. Three shapes:
175
193
  // - undefined / true / object → auto-capture ON (with object overrides)
@@ -208,10 +226,8 @@ export function AffiliateoProvider({ appId, campaignId, apiUrl, allowedHostnames
208
226
  const log = useCallback((msg, data) => {
209
227
  if (!debug)
210
228
  return;
211
- // eslint-disable-next-line no-console
212
229
  if (data !== undefined)
213
230
  console.log('[Affiliateo] ' + msg, data);
214
- // eslint-disable-next-line no-console
215
231
  else
216
232
  console.log('[Affiliateo] ' + msg);
217
233
  }, [debug]);
@@ -355,6 +371,12 @@ export function AffiliateoProvider({ appId, campaignId, apiUrl, allowedHostnames
355
371
  gclid: params.get('gclid') || '',
356
372
  screen_width: window.innerWidth,
357
373
  };
374
+ // Attach the (single-use) Turnstile token if one is still unconsumed.
375
+ // Matters for consent flows where the host disabled auto-capture and
376
+ // this manual call is the session-opening event.
377
+ const tsToken = consumeTurnstileToken();
378
+ if (tsToken)
379
+ payload.cf_turnstile_token = tsToken;
358
380
  queueRef.current?.enqueue(`${apiBase}/api/v1/web/event`, payload);
359
381
  log('pageview (manual)', { path: pagePath });
360
382
  }
@@ -436,7 +458,6 @@ export function AffiliateoProvider({ appId, campaignId, apiUrl, allowedHostnames
436
458
  // Neither appId nor the legacy campaignId alias was passed — nothing to
437
459
  // attribute events to, so stay dormant instead of firing junk.
438
460
  if (!appIdRef.current) {
439
- // eslint-disable-next-line no-console
440
461
  console.error('[Affiliateo] Missing appId — pass your app ID to <AffiliateoProvider appId="...">.');
441
462
  return;
442
463
  }
@@ -444,8 +465,7 @@ export function AffiliateoProvider({ appId, campaignId, apiUrl, allowedHostnames
444
465
  // ever mounted in the page lifetime. Dev-only; production swallows
445
466
  // the warning to avoid noise in real merchant consoles.
446
467
  providerMountCount += 1;
447
- if (providerMountCount > 1 && process.env.NODE_ENV !== 'production') {
448
- // eslint-disable-next-line no-console
468
+ if (providerMountCount > 1 && !isProductionBuild()) {
449
469
  console.warn('[Affiliateo] AffiliateoProvider mounted more than once. Mount it exactly once near the root of your app, or pageviews will double-fire.');
450
470
  }
451
471
  // Client-side bot detection. If the current browser context looks
@@ -674,6 +694,11 @@ export function AffiliateoProvider({ appId, campaignId, apiUrl, allowedHostnames
674
694
  ssSet(SS_PV_THROTTLE, JSON.stringify({ url: href, time: now }));
675
695
  lastSendTime = now;
676
696
  ended = false;
697
+ // Attach the (single-use) Turnstile token to the session-opening
698
+ // pageview. consumeTurnstileToken() returns null after the first
699
+ // consume, so SPA navigations go out bare — the server trusts them
700
+ // because the visitor row was created behind the verified token.
701
+ const tsToken = consumeTurnstileToken();
677
702
  queue.enqueue(endpoint, {
678
703
  type: 'pageview',
679
704
  campaign_id: appIdRef.current,
@@ -692,6 +717,7 @@ export function AffiliateoProvider({ appId, campaignId, apiUrl, allowedHostnames
692
717
  ttclid: params.get('ttclid') || '',
693
718
  gclid: params.get('gclid') || '',
694
719
  screen_width: window.innerWidth,
720
+ ...(tsToken ? { cf_turnstile_token: tsToken } : {}),
695
721
  });
696
722
  log('pageview', { path: window.location.pathname });
697
723
  }
@@ -761,13 +787,30 @@ export function AffiliateoProvider({ appId, campaignId, apiUrl, allowedHostnames
761
787
  }
762
788
  }
763
789
  }
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
- }
790
+ // Kick off the Turnstile mint regardless of auto-capture config so a
791
+ // consent flow's later manual trackPageview() finds a token waiting.
792
+ // Never rejects; resolves with or without a token (see turnstile.ts).
793
+ let cancelled = false;
794
+ void mintTurnstileToken().then(() => {
795
+ // Send pageview on mount, deferred until the Turnstile mint settles so
796
+ // the session-opening event carries the token (SPA navigations reuse
797
+ // the resolved state and don't wait). captureInitialPageview === false
798
+ // skips this for cookie-consent flows where the host wants to defer
799
+ // until the user accepts (then call trackPageview() manually).
800
+ //
801
+ // sentRef is set HERE (not before the mint) so a StrictMode
802
+ // double-mount can't mark the pageview as sent and then tear down the
803
+ // queue before the deferred send ever ran — the surviving mount's
804
+ // callback sees sentRef still false and fires.
805
+ if (cancelled)
806
+ return;
807
+ if (sentRef.current)
808
+ return;
809
+ if (autoCfg.captureInitialPageview && autoCfg.enabled) {
810
+ sentRef.current = true;
811
+ sendPageview();
812
+ }
813
+ });
771
814
  // Detect user leaving
772
815
  document.addEventListener('visibilitychange', onVisibilityChange);
773
816
  window.addEventListener('pagehide', sendEnd);
@@ -899,6 +942,7 @@ export function AffiliateoProvider({ appId, campaignId, apiUrl, allowedHostnames
899
942
  document.addEventListener('keydown', onKeydownForLinkDecoration, true);
900
943
  }
901
944
  return () => {
945
+ cancelled = true;
902
946
  document.removeEventListener('visibilitychange', onVisibilityChange);
903
947
  window.removeEventListener('pagehide', sendEnd);
904
948
  window.removeEventListener('popstate', onUrlChange);
@@ -921,6 +965,14 @@ export function AffiliateoProvider({ appId, campaignId, apiUrl, allowedHostnames
921
965
  queueRef.current = null;
922
966
  providerMountCount = Math.max(0, providerMountCount - 1);
923
967
  };
968
+ // flushInterval, maxQueueSize, allowFileProtocol and the autoCfg.* fields
969
+ // are BOOT configuration, read once when the queue and listeners are
970
+ // constructed. Listing them would tear the whole pipeline down and rebuild
971
+ // it whenever a host app re-renders with a new inline object, and
972
+ // shutdown() drops the in-memory queue — so honoring the rule here would
973
+ // cost real events. A host that needs different settings should remount
974
+ // the provider.
975
+ // eslint-disable-next-line react-hooks/exhaustive-deps
924
976
  }, [apiBase, allowedHostnames, allowLocalhost, allowIframe, log, track, identify, trackPageview, reset, optOut, optIn, flush]);
925
977
  return (_jsx(AffiliateoContext.Provider, { value: state, children: children }));
926
978
  }
@@ -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,7 +1,7 @@
1
1
  {
2
2
  "name": "@affiliateo/web",
3
- "version": "4.5.0",
4
- "description": "Affiliateo web SDK — affiliate attribution and tracking for web apps",
3
+ "version": "4.6.1",
4
+ "description": "Affiliateo web SDK \u2014 affiliate attribution and tracking for web apps",
5
5
  "main": "dist/index.js",
6
6
  "module": "dist/index.js",
7
7
  "types": "dist/index.d.ts",
@@ -14,7 +14,7 @@
14
14
  },
15
15
  "scripts": {
16
16
  "build": "tsc",
17
- "prepublishOnly": "npm run build"
17
+ "prepack": "npm run build"
18
18
  },
19
19
  "peerDependencies": {
20
20
  "react": ">=18.0.0"
@@ -28,7 +28,17 @@
28
28
  "@types/react": "^19.0.0",
29
29
  "typescript": "^5.0.0"
30
30
  },
31
- "keywords": ["affiliateo", "affiliate", "attribution", "stripe", "web", "session", "tracking"],
31
+ "keywords": [
32
+ "affiliateo",
33
+ "affiliate",
34
+ "attribution",
35
+ "stripe",
36
+ "web",
37
+ "session",
38
+ "tracking"
39
+ ],
32
40
  "license": "MIT",
33
- "files": ["dist"]
41
+ "files": [
42
+ "dist"
43
+ ]
34
44
  }