@drip-apex/sdk 0.1.0 → 0.3.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.
Files changed (246) hide show
  1. package/README.md +5 -0
  2. package/dist/bundle-report.json +584 -226
  3. package/dist/define.cjs +123 -0
  4. package/dist/define.cjs.map +7 -0
  5. package/dist/define.mjs +102 -0
  6. package/dist/define.mjs.map +7 -0
  7. package/dist/drip.cjs +19283 -0
  8. package/dist/drip.cjs.map +7 -0
  9. package/dist/drip.debug.js +1 -1
  10. package/dist/drip.debug.js.map +1 -1
  11. package/dist/drip.heatmap.js +1 -1
  12. package/dist/drip.heatmap.js.map +1 -1
  13. package/dist/drip.js +1 -1
  14. package/dist/drip.js.map +1 -1
  15. package/dist/drip.live.js +1 -1
  16. package/dist/drip.live.js.map +1 -1
  17. package/dist/drip.mjs +19267 -0
  18. package/dist/drip.mjs.map +7 -0
  19. package/dist/drip.replay-recorder.js +2 -0
  20. package/dist/drip.replay-recorder.js.map +1 -0
  21. package/dist/editor.js +147 -142
  22. package/dist/editor.js.map +4 -4
  23. package/dist/flags.cjs +1788 -0
  24. package/dist/flags.cjs.map +7 -0
  25. package/dist/flags.mjs +1765 -0
  26. package/dist/flags.mjs.map +7 -0
  27. package/dist/index.mjs +12 -0
  28. package/dist/index.mjs.map +2 -2
  29. package/dist/metafiles/define.cjs.meta.json +28 -0
  30. package/dist/metafiles/define.mjs.meta.json +32 -0
  31. package/dist/metafiles/drip.cjs.meta.json +3193 -0
  32. package/dist/metafiles/drip.debug.meta.json +2859 -574
  33. package/dist/metafiles/drip.heatmap.meta.json +2732 -460
  34. package/dist/metafiles/drip.live.meta.json +2732 -460
  35. package/dist/metafiles/drip.meta.json +2859 -574
  36. package/dist/metafiles/drip.mjs.meta.json +3235 -0
  37. package/dist/metafiles/drip.replay-recorder.meta.json +82 -0
  38. package/dist/metafiles/editor.meta.json +67 -27
  39. package/dist/metafiles/flags.cjs.meta.json +220 -0
  40. package/dist/metafiles/flags.mjs.meta.json +225 -0
  41. package/dist/metafiles/index.meta.json +2717 -491
  42. package/dist/metafiles/index.mjs.meta.json +14 -4
  43. package/dist/react/index.js +16636 -13533
  44. package/dist/react/index.js.map +4 -4
  45. package/dist/sri.json +20 -15
  46. package/dist/types/activation-runtime.d.ts +91 -0
  47. package/dist/types/analytics-runtime.d.ts +166 -0
  48. package/dist/types/antiflicker.d.ts +9 -3
  49. package/dist/types/assignment-cookie-sync.d.ts +31 -0
  50. package/dist/types/assignment-runtime.d.ts +257 -0
  51. package/dist/types/assignment-store.d.ts +4 -8
  52. package/dist/types/bucket.d.ts +1 -2
  53. package/dist/types/compact-store.d.ts +1 -0
  54. package/dist/types/config-runtime.d.ts +313 -0
  55. package/dist/types/consent-evaluation.d.ts +28 -0
  56. package/dist/types/css.d.ts +5 -3
  57. package/dist/types/debug-loader.d.ts +2 -0
  58. package/dist/types/decide-assignments.d.ts +31 -0
  59. package/dist/types/delivery-timing.d.ts +8 -0
  60. package/dist/types/early-interaction-capture.d.ts +79 -0
  61. package/dist/types/early-reassert.d.ts +32 -0
  62. package/dist/types/edge-runtime.d.ts +11 -0
  63. package/dist/types/editor/api.d.ts +3 -0
  64. package/dist/types/editor/controller.d.ts +3 -1
  65. package/dist/types/editor/publication-result.d.ts +11 -0
  66. package/dist/types/editor/types.d.ts +2 -1
  67. package/dist/types/editor/view-kit.d.ts +1 -1
  68. package/dist/types/endpoint.d.ts +8 -0
  69. package/dist/types/exclusion-groups.d.ts +4 -7
  70. package/dist/types/flags.d.ts +2 -0
  71. package/dist/types/goal-eligibility.d.ts +4 -0
  72. package/dist/types/goals.d.ts +10 -1
  73. package/dist/types/guards.d.ts +7 -7
  74. package/dist/types/handover.d.ts +12 -1
  75. package/dist/types/hash.d.ts +8 -4
  76. package/dist/types/hydration.d.ts +2 -1
  77. package/dist/types/index.d.ts +24 -128
  78. package/dist/types/init-test-helpers.d.ts +48 -0
  79. package/dist/types/inline-bootstrap.d.ts +26 -0
  80. package/dist/types/js.d.ts +3 -1
  81. package/dist/types/money.d.ts +12 -0
  82. package/dist/types/mutate.d.ts +45 -6
  83. package/dist/types/performance.d.ts +2 -0
  84. package/dist/types/persistence-decoders.d.ts +12 -0
  85. package/dist/types/public-api.d.ts +209 -0
  86. package/dist/types/qa-assistant-state.d.ts +34 -0
  87. package/dist/types/react/index.d.ts +28 -3
  88. package/dist/types/reassert-sentinel.d.ts +6 -0
  89. package/dist/types/redirect.d.ts +48 -0
  90. package/dist/types/replay-recorder-entry.d.ts +1 -0
  91. package/dist/types/replay-signal-state.d.ts +6 -0
  92. package/dist/types/replay-signals.d.ts +13 -0
  93. package/dist/types/replay.d.ts +110 -0
  94. package/dist/types/router.d.ts +12 -2
  95. package/dist/types/runtime-state.d.ts +166 -0
  96. package/dist/types/sanitize.d.ts +1 -0
  97. package/dist/types/shadow.d.ts +3 -0
  98. package/dist/types/shopify-cart.d.ts +25 -1
  99. package/dist/types/storefront-vitals.d.ts +24 -0
  100. package/dist/types/targeting-diagnostics.d.ts +1 -0
  101. package/dist/types/targeting.d.ts +16 -2
  102. package/dist/types/track.d.ts +15 -1
  103. package/dist/types/types.d.ts +115 -9
  104. package/dist/types/wait.d.ts +14 -1
  105. package/dist/types/weekday-schedule.d.ts +3 -0
  106. package/package.json +17 -8
  107. package/src/activation-runtime.ts +2737 -0
  108. package/src/analytics-runtime.ts +2175 -0
  109. package/src/antiflicker.test.ts +216 -44
  110. package/src/antiflicker.ts +103 -28
  111. package/src/artifact-concurrency.test.ts +1091 -0
  112. package/src/assignment-cookie-sync.test.ts +202 -0
  113. package/src/assignment-cookie-sync.ts +189 -0
  114. package/src/assignment-runtime.ts +3204 -0
  115. package/src/assignment-store.test.ts +56 -0
  116. package/src/assignment-store.ts +7 -38
  117. package/src/bucket.ts +3 -18
  118. package/src/commerce-analytics-lifecycle.test.ts +75 -15
  119. package/src/commerce-config-lifecycle.test.ts +81 -1
  120. package/src/commerce-price-preserve.test.ts +133 -0
  121. package/src/commerce-price.test.ts +97 -0
  122. package/src/commerce-price.ts +74 -20
  123. package/src/commerce-shipping.test.ts +53 -0
  124. package/src/commerce-shipping.ts +5 -11
  125. package/src/compact-store.ts +15 -0
  126. package/src/config-fetch.test.ts +480 -9
  127. package/src/config-runtime.ts +2071 -0
  128. package/src/consent-evaluation-borlabs.test.ts +41 -0
  129. package/src/consent-evaluation-cookiescript.test.ts +50 -0
  130. package/src/consent-evaluation.ts +151 -0
  131. package/src/css.test.ts +30 -0
  132. package/src/css.ts +120 -6
  133. package/src/cwv.test.ts +60 -77
  134. package/src/debug-loader.test.ts +214 -0
  135. package/src/debug-loader.ts +41 -0
  136. package/src/decide-assignments.test.ts +196 -0
  137. package/src/decide-assignments.ts +204 -0
  138. package/src/delivery-timing.test.ts +104 -0
  139. package/src/delivery-timing.ts +89 -0
  140. package/src/early-interaction-capture.test.ts +451 -0
  141. package/src/early-interaction-capture.ts +230 -0
  142. package/src/early-reassert.test.ts +207 -0
  143. package/src/early-reassert.ts +152 -0
  144. package/src/edge-adoption.test.ts +435 -0
  145. package/src/edge-runtime.ts +30 -0
  146. package/src/editor/api.test.ts +109 -0
  147. package/src/editor/api.ts +34 -3
  148. package/src/editor/controller.test.ts +297 -14
  149. package/src/editor/controller.ts +69 -5
  150. package/src/editor/index.test.ts +2 -0
  151. package/src/editor/index.ts +1 -0
  152. package/src/editor/publication-result.ts +58 -0
  153. package/src/editor/types.ts +10 -1
  154. package/src/editor/view-kit.ts +2 -1
  155. package/src/editor/view.test.ts +108 -0
  156. package/src/editor/view.ts +49 -7
  157. package/src/endpoint.test.ts +15 -0
  158. package/src/endpoint.ts +10 -0
  159. package/src/exclusion-groups.ts +8 -18
  160. package/src/flags.test.ts +88 -1
  161. package/src/flags.ts +29 -29
  162. package/src/global-runtime-reconciler.test.ts +271 -0
  163. package/src/goal-eligibility.test.ts +29 -0
  164. package/src/goal-eligibility.ts +17 -0
  165. package/src/goals.test.ts +144 -1
  166. package/src/goals.ts +97 -18
  167. package/src/guards.test.ts +99 -1
  168. package/src/guards.ts +34 -15
  169. package/src/handover-cache-order.test.ts +6 -3
  170. package/src/handover.test.ts +154 -8
  171. package/src/handover.ts +122 -12
  172. package/src/hash.ts +29 -54
  173. package/src/holdout.test.ts +26 -0
  174. package/src/hydration.test.ts +73 -2
  175. package/src/hydration.ts +18 -9
  176. package/src/index.ts +130 -7287
  177. package/src/init-analytics.test.ts +1694 -0
  178. package/src/init-anti-flicker.test.ts +1211 -0
  179. package/src/init-cart-attributes.test.ts +2003 -0
  180. package/src/init-config-resolution.test.ts +999 -0
  181. package/src/init-consent.test.ts +971 -0
  182. package/src/init-empty.test.ts +11 -5406
  183. package/src/init-integrations.test.ts +846 -0
  184. package/src/init-page-activation.test.ts +5064 -0
  185. package/src/init-qa-loading.test.ts +740 -0
  186. package/src/init-redirects.test.ts +2156 -0
  187. package/src/init-test-helpers.ts +287 -0
  188. package/src/inline-bootstrap.test.ts +184 -0
  189. package/src/inline-bootstrap.ts +73 -0
  190. package/src/js.test.ts +51 -1
  191. package/src/js.ts +86 -2
  192. package/src/money.test.ts +55 -1
  193. package/src/money.ts +104 -2
  194. package/src/money.web-parity.contract.test.ts +85 -0
  195. package/src/mutate.test.ts +474 -10
  196. package/src/mutate.ts +739 -230
  197. package/src/page-trigger.test.ts +218 -8
  198. package/src/performance.ts +10 -0
  199. package/src/persistence-decoders.ts +18 -0
  200. package/src/public-api.ts +1828 -0
  201. package/src/qa-assistant-state.test.ts +249 -3
  202. package/src/qa-assistant-state.ts +146 -6
  203. package/src/qa-assistant-view.test.ts +220 -2
  204. package/src/qa-assistant-view.ts +102 -5
  205. package/src/react/index.test.tsx +37 -0
  206. package/src/react/index.tsx +19 -3
  207. package/src/reassert-sentinel.test.ts +155 -1
  208. package/src/reassert-sentinel.ts +168 -8
  209. package/src/redirect.test.ts +541 -0
  210. package/src/redirect.ts +572 -0
  211. package/src/replay-recorder-entry.ts +115 -0
  212. package/src/replay-signal-state.ts +100 -0
  213. package/src/replay-signals.test.ts +249 -0
  214. package/src/replay-signals.ts +151 -0
  215. package/src/replay.test.ts +764 -0
  216. package/src/replay.ts +514 -0
  217. package/src/router.test.ts +61 -2
  218. package/src/router.ts +50 -12
  219. package/src/runtime-state.ts +507 -0
  220. package/src/runtime.test.ts +174 -7
  221. package/src/runtime.ts +88 -14
  222. package/src/sanitize.test.ts +34 -0
  223. package/src/sanitize.ts +23 -12
  224. package/src/shadow.test.ts +187 -0
  225. package/src/shadow.ts +99 -0
  226. package/src/shopify-cart.test.ts +733 -23
  227. package/src/shopify-cart.ts +402 -49
  228. package/src/storefront-vitals.test.ts +217 -0
  229. package/src/storefront-vitals.ts +206 -0
  230. package/src/surface-signals.ts +6 -0
  231. package/src/targeting-conformance.test.ts +9 -0
  232. package/src/targeting-diagnostics.ts +38 -8
  233. package/src/targeting-parity-corpus.json +559 -0
  234. package/src/targeting-parity-corpus.test.ts +18 -0
  235. package/src/targeting.detailed.test.ts +105 -0
  236. package/src/targeting.test.ts +211 -0
  237. package/src/targeting.ts +88 -207
  238. package/src/track.test.ts +147 -0
  239. package/src/track.ts +125 -26
  240. package/src/types.ts +120 -13
  241. package/src/wait.test.ts +23 -0
  242. package/src/wait.ts +129 -13
  243. package/src/weekday-delivery.test.ts +56 -0
  244. package/src/weekday-schedule-parity-corpus.json +92 -0
  245. package/src/weekday-schedule.test.ts +69 -0
  246. package/src/weekday-schedule.ts +20 -0
@@ -1,5 +1,5 @@
1
1
  import { afterEach, describe, expect, it } from "vitest";
2
- import { __test, getQaSessionId, isQaMode, isScreenshotMode } from "./guards";
2
+ import { __test, clearQaForceFromUrl, getQaSessionId, isQaMode, isScreenshotMode, parseForceParam, persistQaForce, persistUrlQaForce } from "./guards";
3
3
 
4
4
  const { parseForceString, parseForceParams, sanitizeQaSessionId, BOT_RE } = __test;
5
5
 
@@ -40,6 +40,9 @@ describe("parseForceString", () => {
40
40
  });
41
41
 
42
42
  describe("parseForceParams", () => {
43
+ it("never parses the clear sentinel as a variation force", () => {
44
+ expect(parseForceParams(new URLSearchParams("drip_force=clear"))).toBeNull();
45
+ });
43
46
  it("keeps supporting canonical drip_force pairs", () => {
44
47
  expect(
45
48
  parseForceParams(
@@ -68,6 +71,69 @@ describe("parseForceParams", () => {
68
71
  ),
69
72
  ).toEqual({ exp_canonical: "var_canonical" });
70
73
  });
74
+
75
+ it("does not fall through to aliases when canonical force is clear or malformed", () => {
76
+ const aliases = "&drip_experiment_id=exp_alias&drip_variation_id=var_alias";
77
+ expect(parseForceParams(new URLSearchParams(`drip_force=clear${aliases}`))).toBeNull();
78
+ expect(parseForceParams(new URLSearchParams(`drip_force=malformed${aliases}`))).toBeNull();
79
+ });
80
+ });
81
+
82
+ describe("QA force lifetime", () => {
83
+ const originals = {
84
+ location: Object.getOwnPropertyDescriptor(globalThis, "location"),
85
+ document: Object.getOwnPropertyDescriptor(globalThis, "document"),
86
+ history: Object.getOwnPropertyDescriptor(globalThis, "history"),
87
+ sessionStorage: Object.getOwnPropertyDescriptor(globalThis, "sessionStorage"),
88
+ };
89
+
90
+ afterEach(() => {
91
+ for (const [key, descriptor] of Object.entries(originals)) {
92
+ if (descriptor) Object.defineProperty(globalThis, key, descriptor);
93
+ else delete (globalThis as Record<string, unknown>)[key];
94
+ }
95
+ });
96
+
97
+ function install(url: string) {
98
+ const cookieWrites: string[] = [];
99
+ const removed: string[] = [];
100
+ let replaced = "";
101
+ Object.defineProperty(globalThis, "location", { configurable: true, value: { href: url, search: new URL(url).search } });
102
+ Object.defineProperty(globalThis, "document", { configurable: true, value: { get cookie() { return "drip_qa_force=exp_old%3Avar_old"; }, set cookie(value: string) { cookieWrites.push(value); } } });
103
+ Object.defineProperty(globalThis, "history", { configurable: true, value: { replaceState: (_a: unknown, _b: string, value: string) => { replaced = value; } } });
104
+ Object.defineProperty(globalThis, "sessionStorage", { configurable: true, value: { removeItem: (key: string) => removed.push(key) } });
105
+ return { cookieWrites, removed, replaced: () => replaced };
106
+ }
107
+
108
+ it("clear sentinel deletes the force cookie and session key and strips QA parameters", () => {
109
+ const observed = install("https://shop.test/p?x=1&drip_force=clear&drip_qa=1&drip_qa_session=qa_1&drip_debug=1");
110
+ expect(clearQaForceFromUrl()).toBe(true);
111
+ expect(observed.cookieWrites).toContain("drip_qa_force=;path=/;max-age=0;samesite=lax");
112
+ expect(observed.removed).toEqual(["drip_qa_session"]);
113
+ expect(observed.replaced()).toBe("https://shop.test/p?x=1");
114
+ });
115
+
116
+ it("renews the cookie on a paramless cookie-backed load", () => {
117
+ const observed = install("https://shop.test/p");
118
+ persistUrlQaForce({ exp_old: "var_old" });
119
+ expect(observed.cookieWrites).toEqual([
120
+ "drip_qa_force=exp_old%3Avar_old;path=/;max-age=1800;samesite=lax",
121
+ ]);
122
+ });
123
+
124
+ it("persists and renews an explicit URL force", () => {
125
+ const observed = install("https://shop.test/p?drip_force=exp_1%3Avar_1");
126
+ persistUrlQaForce({ exp_1: "var_1" });
127
+ expect(observed.cookieWrites).toEqual([
128
+ "drip_qa_force=exp_1%3Avar_1;path=/;max-age=1800;samesite=lax",
129
+ ]);
130
+ });
131
+
132
+ it("keeps assistant persistence explicit and independent of the URL", () => {
133
+ const observed = install("https://shop.test/p");
134
+ persistQaForce({ exp_2: "var_2" });
135
+ expect(observed.cookieWrites).toHaveLength(1);
136
+ });
71
137
  });
72
138
 
73
139
  describe("BOT_RE", () => {
@@ -188,6 +254,38 @@ describe("isQaMode", () => {
188
254
  expect(isScreenshotMode()).toBe(true);
189
255
  expect(isQaMode()).toBe(false);
190
256
  });
257
+
258
+ it("treats an empty force parameter as authoritative over a stale QA cookie", () => {
259
+ Object.defineProperty(globalThis, "document", {
260
+ configurable: true,
261
+ value: { cookie: "drip_qa_force=exp_1%3Avar_1" },
262
+ });
263
+ setSearch("?drip_force=");
264
+
265
+ expect(parseForceParam()).toBeNull();
266
+ expect(isQaMode()).toBe(false);
267
+ });
268
+
269
+ it("keeps the cookie fallback when the force parameter is absent", () => {
270
+ Object.defineProperty(globalThis, "document", {
271
+ configurable: true,
272
+ value: { cookie: "drip_qa_force=exp_1%3Avar_1" },
273
+ });
274
+ setSearch("");
275
+
276
+ expect(parseForceParam()).toEqual({ exp_1: "var_1" });
277
+ expect(isQaMode()).toBe(true);
278
+ });
279
+
280
+ it("keeps a non-empty force parameter authoritative", () => {
281
+ Object.defineProperty(globalThis, "document", {
282
+ configurable: true,
283
+ value: { cookie: "drip_qa_force=exp_old%3Avar_old" },
284
+ });
285
+ setSearch("?drip_force=exp_new%3Avar_new");
286
+
287
+ expect(parseForceParam()).toEqual({ exp_new: "var_new" });
288
+ });
191
289
  });
192
290
 
193
291
  describe("QA session IDs", () => {
package/src/guards.ts CHANGED
@@ -76,9 +76,9 @@ function readFirstParam(params: URLSearchParams, names: string[]): string | null
76
76
 
77
77
  function parseForceParams(params: URLSearchParams): Record<string, string> | null {
78
78
  const param = params.get("drip_force");
79
- if (param) {
80
- const map = parseForceString(param);
81
- if (map) return map;
79
+ if (param !== null) {
80
+ if (param.trim().toLowerCase() === "clear") return null;
81
+ return parseForceString(param);
82
82
  }
83
83
 
84
84
  const experimentId = readFirstParam(params, FORCE_EXPERIMENT_PARAM_NAMES);
@@ -89,6 +89,17 @@ function parseForceParams(params: URLSearchParams): Record<string, string> | nul
89
89
  return null;
90
90
  }
91
91
 
92
+ export function hasExplicitForceParam(): boolean {
93
+ return parseForceParams(new URLSearchParams(location.search)) !== null;
94
+ }
95
+
96
+ export function clearQaForceFromUrl(): boolean {
97
+ const force = new URLSearchParams(location.search).get("drip_force")?.trim().toLowerCase();
98
+ if (force !== "clear") return false;
99
+ exitQaMode();
100
+ return true;
101
+ }
102
+
92
103
  function isDebugQaParam(params: URLSearchParams): boolean {
93
104
  return (
94
105
  params.get("drip_debug") === "1" ||
@@ -97,18 +108,21 @@ function isDebugQaParam(params: URLSearchParams): boolean {
97
108
  );
98
109
  }
99
110
 
100
- export function parseForceParam(options?: { cookieFallback?: boolean }): Record<string, string> | null {
111
+ export function parseForceParam(): Record<string, string> | null {
101
112
  // Check URL param first
102
- const map = parseForceParams(new URLSearchParams(location.search));
113
+ const params = new URLSearchParams(location.search);
114
+ const map = parseForceParams(params);
103
115
  if (map) return map;
116
+ // A present canonical parameter is authoritative even when empty, cleared,
117
+ // or malformed. In particular, `?drip_force=` must not resurrect a stale
118
+ // QA force from the cookie.
119
+ if (params.has("drip_force")) return null;
104
120
 
105
- // Fallback to QA cookie
106
- if (options?.cookieFallback !== false) {
107
- const cookie = readCookie("drip_qa_force");
108
- if (cookie) {
109
- const map = parseForceString(cookie);
110
- if (map) return map;
111
- }
121
+ // Fallback to the functional QA cookie.
122
+ const cookie = readCookie("drip_qa_force");
123
+ if (cookie) {
124
+ const map = parseForceString(cookie);
125
+ if (map) return map;
112
126
  }
113
127
 
114
128
  return null;
@@ -131,19 +145,19 @@ const QA_COOKIE = "drip_qa_force";
131
145
  const QA_SESSION_STORAGE_KEY = "drip_qa_session";
132
146
  const QA_COOKIE_MAX_AGE = 1800; // 30 minutes
133
147
 
134
- export function isQaMode(options?: { cookieFallback?: boolean }): boolean {
148
+ export function isQaMode(): boolean {
135
149
  if (isScreenshotMode()) return false;
136
150
  const params = new URLSearchParams(location.search);
137
151
  const param = params.get("drip_qa");
138
152
  if (param === "1") return true;
139
153
  if (isDebugQaParam(params) && parseForceParams(params)) return true;
140
- if (options?.cookieFallback === false) return false;
154
+ if (params.has("drip_force")) return false;
141
155
  return !!readCookie(QA_COOKIE);
142
156
  }
143
157
 
144
158
  /**
145
159
  * Persist the force map in a cookie so QA sessions survive navigation.
146
- * Cookie expires after 30 minutes.
160
+ * Cookie expires at most 30 minutes after the latest QA page load or assistant force.
147
161
  */
148
162
  export function persistQaForce(forceMap: Record<string, string>): void {
149
163
  const value = Object.entries(forceMap)
@@ -152,6 +166,11 @@ export function persistQaForce(forceMap: Record<string, string>): void {
152
166
  writeCookie(QA_COOKIE, value, QA_COOKIE_MAX_AGE);
153
167
  }
154
168
 
169
+ /** Persist an explicit URL force or renew an existing cookie-backed force. */
170
+ export function persistUrlQaForce(forceMap: Record<string, string>): void {
171
+ if (hasExplicitForceParam() || readCookie(QA_COOKIE)) persistQaForce(forceMap);
172
+ }
173
+
155
174
  function sanitizeQaSessionId(value: string | null): string | null {
156
175
  if (!value) return null;
157
176
  const trimmed = value.trim();
@@ -3,7 +3,7 @@ import { describe, expect, it } from "vitest";
3
3
 
4
4
  describe("cached configuration handover ordering", () => {
5
5
  it("verifies through the credential-free endpoint with a bounded request", () => {
6
- const source = readFileSync(new URL("./index.ts", import.meta.url), "utf8");
6
+ const source = readFileSync(new URL("./config-runtime.ts", import.meta.url), "utf8");
7
7
  const start = source.indexOf("async function verifyBucketingHandoverToken");
8
8
  const end = source.indexOf("function clearScheduledAnalyticsStart", start);
9
9
  const verification = source.slice(start, end);
@@ -17,7 +17,7 @@ describe("cached configuration handover ordering", () => {
17
17
  });
18
18
 
19
19
  it("starts verification before a cached config can initialize and supplies its result", () => {
20
- const source = readFileSync(new URL("./index.ts", import.meta.url), "utf8");
20
+ const source = readFileSync(new URL("./public-api.ts", import.meta.url), "utf8");
21
21
  const cacheRead = source.indexOf("const cached = useConfigCache");
22
22
  const verification = source.indexOf("const handoverPromise = readBucketingHandover", cacheRead);
23
23
  const cachedBranch = source.indexOf("if (cached)", cacheRead);
@@ -27,6 +27,9 @@ describe("cached configuration handover ordering", () => {
27
27
  expect(verification).toBeGreaterThan(cacheRead);
28
28
  expect(verification).toBeLessThan(cachedBranch);
29
29
  expect(cachedPath).toContain("handoverPromise.then(initCachedConfig)");
30
- expect(cachedPath).toContain('"cache",\n perfContext,\n verifiedHandover');
30
+ expect(cachedPath).toContain(
31
+ '"cache",\n perfContext,\n cachedVerifiedRedirectCarry,\n verifiedHandover',
32
+ );
33
+ expect(cachedPath).toContain("verifiedRedirectCarryPromise");
31
34
  });
32
35
  });
@@ -1,5 +1,10 @@
1
1
  import { afterEach, describe, expect, it, vi } from "vitest";
2
- import { createBucketingHandoverPayload, installBucketingHandover, readBucketingHandover } from "./handover";
2
+ import {
3
+ createBucketingHandoverPayload,
4
+ installBucketingHandover,
5
+ readBucketingHandover,
6
+ readBucketingHandoverPayload,
7
+ } from "./handover";
3
8
 
4
9
  describe("installBucketingHandover", () => {
5
10
  afterEach(() => {
@@ -33,6 +38,43 @@ describe("installBucketingHandover", () => {
33
38
  )).resolves.toBeNull();
34
39
  });
35
40
 
41
+ it.each([
42
+ ["CJK", "访客用户"],
43
+ ["emoji", "visitor_🧪🚀"],
44
+ ])("round-trips UTF-8 %s visitor ids", (_label, visitorId) => {
45
+ const payload = createBucketingHandoverPayload(
46
+ visitorId,
47
+ [{ experimentId: "exp_utf8", variationId: "var_utf8", index: 0, mutations: [], isControl: false, hashValue: "hash_utf8" }],
48
+ );
49
+
50
+ expect(payload).toMatch(/^[A-Za-z0-9_-]+$/);
51
+ expect(readBucketingHandoverPayload(payload)).toEqual({
52
+ visitorId,
53
+ forceMap: { exp_utf8: "var_utf8" },
54
+ });
55
+ });
56
+
57
+ it("ignores redirect attribution in an unverified handover payload", () => {
58
+ const encoded = btoa(JSON.stringify({
59
+ v: 2,
60
+ exp: Math.floor(Date.now() / 1000) + 120,
61
+ vid: "visitor_1",
62
+ asg: [{ e: "exp_1", x: "var_1" }],
63
+ ra: {
64
+ e: "exp_1",
65
+ v: "var_1",
66
+ d: "https://destination.example/landing",
67
+ iat: Date.now(),
68
+ t: "unverified-bearer-material",
69
+ },
70
+ })).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
71
+
72
+ expect(readBucketingHandoverPayload(encoded)).toEqual({
73
+ visitorId: "visitor_1",
74
+ forceMap: { exp_1: "var_1" },
75
+ });
76
+ });
77
+
36
78
  it("rejects a valid token minted for a different shop", async () => {
37
79
  vi.stubGlobal("location", { href: "https://checkout.example.com/?drip_bucket=signed.token" });
38
80
  await expect(readBucketingHandover(
@@ -125,9 +167,9 @@ describe("installBucketingHandover", () => {
125
167
  expect(href).toBe("https://checkout.example.com/next-step");
126
168
  });
127
169
 
128
- it("resumes a first configured-domain click without dispatching merchant handlers twice", async () => {
170
+ it("reuses a freshly minted token when only iat and exp change during the request", async () => {
129
171
  vi.useFakeTimers();
130
- vi.setSystemTime(new Date("2026-06-25T12:00:00.000Z"));
172
+ vi.setSystemTime(new Date("2026-06-25T12:00:00.900Z"));
131
173
  const listeners = new Map<string, EventListener>();
132
174
  vi.stubGlobal("document", {
133
175
  addEventListener: vi.fn((type: string, listener: EventListener) => {
@@ -158,7 +200,7 @@ describe("installBucketingHandover", () => {
158
200
 
159
201
  installBucketingHandover(
160
202
  { domains: ["checkout.example.com"], handoverQueryParam: "apex_bucket", signed: true },
161
- () => basePayload,
203
+ () => createBucketingHandoverPayload("visitor_3", [], { handoverTtlSeconds: 120 }),
162
204
  mint,
163
205
  );
164
206
 
@@ -168,6 +210,7 @@ describe("installBucketingHandover", () => {
168
210
  expect(preventDefault).toHaveBeenCalledOnce();
169
211
  expect(mint).toHaveBeenCalledTimes(1);
170
212
 
213
+ vi.setSystemTime(new Date("2026-06-25T12:00:01.100Z"));
171
214
  resolveMint(`${basePayload}.signature`);
172
215
  await vi.waitFor(() => expect(assign).toHaveBeenCalledOnce());
173
216
 
@@ -176,6 +219,58 @@ describe("installBucketingHandover", () => {
176
219
  expect(link.click).not.toHaveBeenCalled();
177
220
  });
178
221
 
222
+ it("does not reuse a freshly minted token after assignments change", async () => {
223
+ vi.useFakeTimers();
224
+ vi.setSystemTime(new Date("2026-06-25T12:00:00.000Z"));
225
+ const listeners = new Map<string, EventListener>();
226
+ vi.stubGlobal("document", {
227
+ addEventListener: vi.fn((type: string, listener: EventListener) => {
228
+ listeners.set(type, listener);
229
+ }),
230
+ removeEventListener: vi.fn(),
231
+ });
232
+ const assign = vi.fn();
233
+ vi.stubGlobal("location", { href: "https://store.example.com/", assign });
234
+
235
+ let href = "https://checkout.example.com/next-step";
236
+ const link = {
237
+ closest: (selector: string) => (selector === "a[href]" ? link : null),
238
+ getAttribute: (name: string) => (name === "href" ? href : null),
239
+ set href(value: string) {
240
+ href = value;
241
+ },
242
+ get href() {
243
+ return href;
244
+ },
245
+ };
246
+ let variationId = "variation_a";
247
+ const getPayload = () => createBucketingHandoverPayload(
248
+ "visitor_assignment",
249
+ [{ experimentId: "experiment_1", variationId }],
250
+ );
251
+ const mintedBasePayload = getPayload();
252
+ let resolveMint: (value: string) => void = () => {};
253
+ const mint = vi.fn(() => new Promise<string>((resolve) => {
254
+ resolveMint = resolve;
255
+ }));
256
+
257
+ installBucketingHandover(
258
+ { domains: ["checkout.example.com"], handoverQueryParam: "apex_bucket", signed: true },
259
+ getPayload,
260
+ mint,
261
+ );
262
+
263
+ const preventDefault = vi.fn();
264
+ listeners.get("click")?.({ type: "click", target: link, preventDefault } as unknown as Event);
265
+ variationId = "variation_b";
266
+ resolveMint(`${mintedBasePayload}.signature`);
267
+ await vi.waitFor(() => expect(assign).toHaveBeenCalledOnce());
268
+
269
+ expect(preventDefault).toHaveBeenCalledOnce();
270
+ expect(href).toBe("https://checkout.example.com/next-step");
271
+ expect(assign).toHaveBeenCalledWith(href);
272
+ });
273
+
179
274
  it("does not reuse expired signed handover payloads", async () => {
180
275
  vi.useFakeTimers();
181
276
  vi.setSystemTime(new Date("2026-06-25T12:00:00.000Z"));
@@ -186,7 +281,8 @@ describe("installBucketingHandover", () => {
186
281
  }),
187
282
  removeEventListener: vi.fn(),
188
283
  });
189
- vi.stubGlobal("location", { href: "https://store.example.com/" });
284
+ const assign = vi.fn();
285
+ vi.stubGlobal("location", { href: "https://store.example.com/", assign });
190
286
 
191
287
  let href = "https://checkout.example.com/next-step";
192
288
  const link = {
@@ -208,16 +304,66 @@ describe("installBucketingHandover", () => {
208
304
  async () => `${initialBasePayload}.signature`,
209
305
  );
210
306
 
211
- listeners.get("pointerdown")?.({ target: link } as unknown as Event);
212
- await Promise.resolve();
307
+ const firstPreventDefault = vi.fn();
308
+ listeners.get("click")?.({ type: "click", target: link, preventDefault: firstPreventDefault } as unknown as Event);
309
+ await vi.waitFor(() => expect(assign).toHaveBeenCalledOnce());
310
+ expect(firstPreventDefault).toHaveBeenCalledOnce();
311
+ expect(href).toContain(`${initialBasePayload}.signature`);
312
+
213
313
  vi.setSystemTime(new Date("2026-06-25T12:02:01.000Z"));
214
314
  href = "https://checkout.example.com/next-step";
215
- listeners.get("click")?.({ target: link } as unknown as Event);
315
+ assign.mockClear();
316
+ const secondPreventDefault = vi.fn();
317
+ listeners.get("click")?.({ type: "click", target: link, preventDefault: secondPreventDefault } as unknown as Event);
318
+ await vi.waitFor(() => expect(assign).toHaveBeenCalledOnce());
216
319
 
320
+ expect(secondPreventDefault).toHaveBeenCalledOnce();
217
321
  expect(href).toBe("https://checkout.example.com/next-step");
218
322
  expect(href).not.toContain(`${initialBasePayload}.signature`);
219
323
  });
220
324
 
325
+ it("treats a malformed mint-time base payload as a cache miss without throwing", async () => {
326
+ vi.useFakeTimers();
327
+ vi.setSystemTime(new Date("2026-06-25T12:00:00.000Z"));
328
+ const listeners = new Map<string, EventListener>();
329
+ vi.stubGlobal("document", {
330
+ addEventListener: vi.fn((type: string, listener: EventListener) => {
331
+ listeners.set(type, listener);
332
+ }),
333
+ removeEventListener: vi.fn(),
334
+ });
335
+ const assign = vi.fn();
336
+ vi.stubGlobal("location", { href: "https://store.example.com/", assign });
337
+
338
+ let href = "https://checkout.example.com/next-step";
339
+ const link = {
340
+ closest: (selector: string) => (selector === "a[href]" ? link : null),
341
+ getAttribute: (name: string) => (name === "href" ? href : null),
342
+ set href(value: string) {
343
+ href = value;
344
+ },
345
+ get href() {
346
+ return href;
347
+ },
348
+ };
349
+ const signedBasePayload = createBucketingHandoverPayload("visitor_valid", []);
350
+ installBucketingHandover(
351
+ { domains: ["checkout.example.com"], handoverQueryParam: "apex_bucket", signed: true },
352
+ () => "%",
353
+ async () => `${signedBasePayload}.signature`,
354
+ );
355
+
356
+ const preventDefault = vi.fn();
357
+ expect(() => {
358
+ listeners.get("click")?.({ type: "click", target: link, preventDefault } as unknown as Event);
359
+ }).not.toThrow();
360
+ await vi.waitFor(() => expect(assign).toHaveBeenCalledOnce());
361
+
362
+ expect(preventDefault).toHaveBeenCalledOnce();
363
+ expect(href).toBe("https://checkout.example.com/next-step");
364
+ expect(assign).toHaveBeenCalledWith(href);
365
+ });
366
+
221
367
  it("adds only signed handover payloads to configured external checkout forms", async () => {
222
368
  const listeners = new Map<string, EventListener>();
223
369
  const hiddenInputs: Array<{ type: string; name: string; value: string }> = [];
package/src/handover.ts CHANGED
@@ -3,6 +3,13 @@ import type { Assignment, ExternalTrackingSettings } from "./types";
3
3
  export type HandoverData = {
4
4
  visitorId?: string;
5
5
  forceMap?: Record<string, string>;
6
+ redirectAttribution?: {
7
+ experimentId: string;
8
+ variationId: string;
9
+ destinationUrl: string;
10
+ issuedAt: number;
11
+ token: string;
12
+ };
6
13
  };
7
14
 
8
15
  const DEFAULT_HANDOVER_TTL_SECONDS = 120;
@@ -30,7 +37,29 @@ function enabledDomains(settings?: ExternalTrackingSettings): string[] {
30
37
 
31
38
  function encodePayload(value: unknown): string {
32
39
  const json = JSON.stringify(value);
33
- return btoa(json).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
40
+ const bytes = new TextEncoder().encode(json);
41
+ let binary = "";
42
+ for (const byte of bytes) binary += String.fromCharCode(byte);
43
+ return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
44
+ }
45
+
46
+ function decodePayloadRecord(value: string): Record<string, unknown> {
47
+ const payloadPart = value.includes(".") ? value.split(".")[0] : value;
48
+ const normalized = payloadPart.replace(/-/g, "+").replace(/_/g, "/");
49
+ const padded = normalized + "=".repeat((4 - (normalized.length % 4)) % 4);
50
+ const binary = atob(padded);
51
+ const bytes = new Uint8Array(binary.length);
52
+ for (let index = 0; index < binary.length; index += 1) {
53
+ bytes[index] = binary.charCodeAt(index);
54
+ }
55
+ let json: string;
56
+ try {
57
+ json = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
58
+ } catch {
59
+ // Preserve compatibility with v2 payloads emitted before UTF-8 encoding.
60
+ json = binary;
61
+ }
62
+ return JSON.parse(json) as Record<string, unknown>;
34
63
  }
35
64
 
36
65
  type VerifiedHandoverPayload = Record<string, unknown>;
@@ -58,19 +87,69 @@ function decodeVerifiedPayload(parsed: VerifiedHandoverPayload, shopId: string):
58
87
  }
59
88
  }
60
89
  }
90
+ const rawAttribution = parsed.ra;
91
+ const redirectAttribution = rawAttribution && typeof rawAttribution === "object"
92
+ && typeof (rawAttribution as Record<string, unknown>).e === "string"
93
+ && typeof (rawAttribution as Record<string, unknown>).v === "string"
94
+ && typeof (rawAttribution as Record<string, unknown>).d === "string"
95
+ && typeof (rawAttribution as Record<string, unknown>).iat === "number"
96
+ && typeof (rawAttribution as Record<string, unknown>).t === "string"
97
+ ? {
98
+ experimentId: (rawAttribution as Record<string, unknown>).e as string,
99
+ variationId: (rawAttribution as Record<string, unknown>).v as string,
100
+ destinationUrl: (rawAttribution as Record<string, unknown>).d as string,
101
+ issuedAt: (rawAttribution as Record<string, unknown>).iat as number,
102
+ token: (rawAttribution as Record<string, unknown>).t as string,
103
+ }
104
+ : undefined;
105
+ return { visitorId: visitorIdV2 ?? visitorId, forceMap, redirectAttribution };
106
+ } catch {
107
+ return null;
108
+ }
109
+ }
110
+
111
+ function decodePayload(value: string): HandoverData | null {
112
+ try {
113
+ const parsed = decodePayloadRecord(value);
114
+ if (parsed.v === 2) {
115
+ const exp = typeof parsed.exp === "number" ? parsed.exp : 0;
116
+ if (!exp || Math.floor(Date.now() / 1000) > exp) return null;
117
+ }
118
+ const visitorId = typeof parsed.visitorId === "string" ? parsed.visitorId : undefined;
119
+ const visitorIdV2 = typeof parsed.vid === "string" ? parsed.vid : undefined;
120
+ const forceMap: Record<string, string> = {};
121
+ const assignments = Array.isArray(parsed.assignments)
122
+ ? parsed.assignments
123
+ : Array.isArray(parsed.asg)
124
+ ? parsed.asg
125
+ : [];
126
+ for (const item of assignments) {
127
+ if (!item || typeof item !== "object") continue;
128
+ const record = item as Record<string, unknown>;
129
+ if (typeof record.experimentId === "string" && typeof record.variationId === "string") {
130
+ forceMap[record.experimentId] = record.variationId;
131
+ } else if (typeof record.e === "string" && typeof record.x === "string") {
132
+ forceMap[record.e] = record.x;
133
+ }
134
+ }
135
+ // Redirect attribution (`ra`) is bearer-backed evidence and is trusted only
136
+ // after Worker verification in decodeVerifiedPayload. This unverified
137
+ // compatibility reader intentionally ignores it.
61
138
  return { visitorId: visitorIdV2 ?? visitorId, forceMap };
62
139
  } catch {
63
140
  return null;
64
141
  }
65
142
  }
66
143
 
67
- function readPayloadExp(value: string): number | null {
144
+ /** Decode a handover token carried by another runtime transport. */
145
+ export function readBucketingHandoverPayload(value: string): HandoverData | null {
146
+ return decodePayload(value);
147
+ }
148
+
149
+ export function bucketingHandoverExpiresAtMs(value: string): number | null {
68
150
  try {
69
- const payloadPart = value.includes(".") ? value.split(".")[0] : value;
70
- const normalized = payloadPart.replace(/-/g, "+").replace(/_/g, "/");
71
- const padded = normalized + "=".repeat((4 - (normalized.length % 4)) % 4);
72
- const parsed = JSON.parse(atob(padded)) as Record<string, unknown>;
73
- return typeof parsed.exp === "number" ? parsed.exp : null;
151
+ const parsed = decodePayloadRecord(value);
152
+ return typeof parsed.exp === "number" ? parsed.exp * 1000 : null;
74
153
  } catch {
75
154
  return null;
76
155
  }
@@ -139,6 +218,15 @@ export async function readBucketingHandover(
139
218
  if (!globalThis.location || !enabledDomains(settings).length || !shopId) return null;
140
219
  const value = new URL(globalThis.location.href).searchParams.get(paramName(settings));
141
220
  if (!value) return null;
221
+ return verifyBucketingHandoverToken(value, shopId, verifyToken);
222
+ }
223
+
224
+ export async function verifyBucketingHandoverToken(
225
+ value: string,
226
+ shopId: string,
227
+ verifyToken: (token: string) => Promise<unknown>,
228
+ ): Promise<HandoverData | null> {
229
+ if (!value || !shopId) return null;
142
230
  try {
143
231
  const verified = await verifyToken(value);
144
232
  return verified && typeof verified === "object"
@@ -156,7 +244,7 @@ export function hasBucketingHandover(settings?: ExternalTrackingSettings): boole
156
244
 
157
245
  export function createBucketingHandoverPayload(
158
246
  visitorId: string,
159
- assignments: Assignment[],
247
+ assignments: Array<Pick<Assignment, "experimentId" | "variationId">>,
160
248
  settings?: ExternalTrackingSettings,
161
249
  ): string {
162
250
  const iat = Math.floor(Date.now() / 1000);
@@ -172,6 +260,23 @@ export function createBucketingHandoverPayload(
172
260
  });
173
261
  }
174
262
 
263
+ function bucketingHandoverCacheKey(encodedPayload: string): string | null {
264
+ try {
265
+ const parsed = decodePayloadRecord(encodedPayload);
266
+ if (typeof parsed.vid !== "string" || !Array.isArray(parsed.asg)) return null;
267
+ const assignments: Array<{ e: string; x: string }> = [];
268
+ for (const assignment of parsed.asg) {
269
+ if (!assignment || typeof assignment !== "object") return null;
270
+ const record = assignment as Record<string, unknown>;
271
+ if (typeof record.e !== "string" || typeof record.x !== "string") return null;
272
+ assignments.push({ e: record.e, x: record.x });
273
+ }
274
+ return JSON.stringify({ vid: parsed.vid, asg: assignments });
275
+ } catch {
276
+ return null;
277
+ }
278
+ }
279
+
175
280
  export function installBucketingHandover(
176
281
  settings: ExternalTrackingSettings | undefined,
177
282
  getPayload: () => string | null,
@@ -180,19 +285,22 @@ export function installBucketingHandover(
180
285
  const domains = enabledDomains(settings);
181
286
  if (!domains.length || !globalThis.document) return () => {};
182
287
  let cachedSignedPayload: string | null = null;
183
- let cachedBasePayload: string | null = null;
288
+ let cachedBaseKey: string | null = null;
184
289
  let cachedSignedPayloadExp = 0;
185
290
  let mintInFlight: Promise<string | null> | null = null;
186
291
  const mint = () => {
187
292
  if (!mintPayload) return null;
188
293
  const basePayload = getPayload();
189
294
  if (!basePayload) return null;
295
+ const baseKey = bucketingHandoverCacheKey(basePayload);
190
296
  if (!mintInFlight) {
191
297
  mintInFlight = mintPayload()
192
298
  .then((payload) => {
193
299
  cachedSignedPayload = payload;
194
- cachedBasePayload = payload ? basePayload : null;
195
- cachedSignedPayloadExp = payload ? readPayloadExp(payload) ?? 0 : 0;
300
+ cachedBaseKey = payload ? baseKey : null;
301
+ cachedSignedPayloadExp = payload
302
+ ? Math.floor((bucketingHandoverExpiresAtMs(payload) ?? 0) / 1000)
303
+ : 0;
196
304
  return payload;
197
305
  })
198
306
  .catch(() => null)
@@ -205,10 +313,12 @@ export function installBucketingHandover(
205
313
 
206
314
  const bestPayload = (): string | null => {
207
315
  const basePayload = getPayload();
316
+ const baseKey = basePayload ? bucketingHandoverCacheKey(basePayload) : null;
208
317
  const now = Math.floor(Date.now() / 1000);
209
318
  if (
210
319
  cachedSignedPayload
211
- && cachedBasePayload === basePayload
320
+ && cachedBaseKey !== null
321
+ && cachedBaseKey === baseKey
212
322
  && cachedSignedPayloadExp > now
213
323
  ) {
214
324
  return cachedSignedPayload;