@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.
- package/README.md +5 -0
- package/dist/bundle-report.json +584 -226
- package/dist/define.cjs +123 -0
- package/dist/define.cjs.map +7 -0
- package/dist/define.mjs +102 -0
- package/dist/define.mjs.map +7 -0
- package/dist/drip.cjs +19283 -0
- package/dist/drip.cjs.map +7 -0
- package/dist/drip.debug.js +1 -1
- package/dist/drip.debug.js.map +1 -1
- package/dist/drip.heatmap.js +1 -1
- package/dist/drip.heatmap.js.map +1 -1
- package/dist/drip.js +1 -1
- package/dist/drip.js.map +1 -1
- package/dist/drip.live.js +1 -1
- package/dist/drip.live.js.map +1 -1
- package/dist/drip.mjs +19267 -0
- package/dist/drip.mjs.map +7 -0
- package/dist/drip.replay-recorder.js +2 -0
- package/dist/drip.replay-recorder.js.map +1 -0
- package/dist/editor.js +147 -142
- package/dist/editor.js.map +4 -4
- package/dist/flags.cjs +1788 -0
- package/dist/flags.cjs.map +7 -0
- package/dist/flags.mjs +1765 -0
- package/dist/flags.mjs.map +7 -0
- package/dist/index.mjs +12 -0
- package/dist/index.mjs.map +2 -2
- package/dist/metafiles/define.cjs.meta.json +28 -0
- package/dist/metafiles/define.mjs.meta.json +32 -0
- package/dist/metafiles/drip.cjs.meta.json +3193 -0
- package/dist/metafiles/drip.debug.meta.json +2859 -574
- package/dist/metafiles/drip.heatmap.meta.json +2732 -460
- package/dist/metafiles/drip.live.meta.json +2732 -460
- package/dist/metafiles/drip.meta.json +2859 -574
- package/dist/metafiles/drip.mjs.meta.json +3235 -0
- package/dist/metafiles/drip.replay-recorder.meta.json +82 -0
- package/dist/metafiles/editor.meta.json +67 -27
- package/dist/metafiles/flags.cjs.meta.json +220 -0
- package/dist/metafiles/flags.mjs.meta.json +225 -0
- package/dist/metafiles/index.meta.json +2717 -491
- package/dist/metafiles/index.mjs.meta.json +14 -4
- package/dist/react/index.js +16636 -13533
- package/dist/react/index.js.map +4 -4
- package/dist/sri.json +20 -15
- package/dist/types/activation-runtime.d.ts +91 -0
- package/dist/types/analytics-runtime.d.ts +166 -0
- package/dist/types/antiflicker.d.ts +9 -3
- package/dist/types/assignment-cookie-sync.d.ts +31 -0
- package/dist/types/assignment-runtime.d.ts +257 -0
- package/dist/types/assignment-store.d.ts +4 -8
- package/dist/types/bucket.d.ts +1 -2
- package/dist/types/compact-store.d.ts +1 -0
- package/dist/types/config-runtime.d.ts +313 -0
- package/dist/types/consent-evaluation.d.ts +28 -0
- package/dist/types/css.d.ts +5 -3
- package/dist/types/debug-loader.d.ts +2 -0
- package/dist/types/decide-assignments.d.ts +31 -0
- package/dist/types/delivery-timing.d.ts +8 -0
- package/dist/types/early-interaction-capture.d.ts +79 -0
- package/dist/types/early-reassert.d.ts +32 -0
- package/dist/types/edge-runtime.d.ts +11 -0
- package/dist/types/editor/api.d.ts +3 -0
- package/dist/types/editor/controller.d.ts +3 -1
- package/dist/types/editor/publication-result.d.ts +11 -0
- package/dist/types/editor/types.d.ts +2 -1
- package/dist/types/editor/view-kit.d.ts +1 -1
- package/dist/types/endpoint.d.ts +8 -0
- package/dist/types/exclusion-groups.d.ts +4 -7
- package/dist/types/flags.d.ts +2 -0
- package/dist/types/goal-eligibility.d.ts +4 -0
- package/dist/types/goals.d.ts +10 -1
- package/dist/types/guards.d.ts +7 -7
- package/dist/types/handover.d.ts +12 -1
- package/dist/types/hash.d.ts +8 -4
- package/dist/types/hydration.d.ts +2 -1
- package/dist/types/index.d.ts +24 -128
- package/dist/types/init-test-helpers.d.ts +48 -0
- package/dist/types/inline-bootstrap.d.ts +26 -0
- package/dist/types/js.d.ts +3 -1
- package/dist/types/money.d.ts +12 -0
- package/dist/types/mutate.d.ts +45 -6
- package/dist/types/performance.d.ts +2 -0
- package/dist/types/persistence-decoders.d.ts +12 -0
- package/dist/types/public-api.d.ts +209 -0
- package/dist/types/qa-assistant-state.d.ts +34 -0
- package/dist/types/react/index.d.ts +28 -3
- package/dist/types/reassert-sentinel.d.ts +6 -0
- package/dist/types/redirect.d.ts +48 -0
- package/dist/types/replay-recorder-entry.d.ts +1 -0
- package/dist/types/replay-signal-state.d.ts +6 -0
- package/dist/types/replay-signals.d.ts +13 -0
- package/dist/types/replay.d.ts +110 -0
- package/dist/types/router.d.ts +12 -2
- package/dist/types/runtime-state.d.ts +166 -0
- package/dist/types/sanitize.d.ts +1 -0
- package/dist/types/shadow.d.ts +3 -0
- package/dist/types/shopify-cart.d.ts +25 -1
- package/dist/types/storefront-vitals.d.ts +24 -0
- package/dist/types/targeting-diagnostics.d.ts +1 -0
- package/dist/types/targeting.d.ts +16 -2
- package/dist/types/track.d.ts +15 -1
- package/dist/types/types.d.ts +115 -9
- package/dist/types/wait.d.ts +14 -1
- package/dist/types/weekday-schedule.d.ts +3 -0
- package/package.json +17 -8
- package/src/activation-runtime.ts +2737 -0
- package/src/analytics-runtime.ts +2175 -0
- package/src/antiflicker.test.ts +216 -44
- package/src/antiflicker.ts +103 -28
- package/src/artifact-concurrency.test.ts +1091 -0
- package/src/assignment-cookie-sync.test.ts +202 -0
- package/src/assignment-cookie-sync.ts +189 -0
- package/src/assignment-runtime.ts +3204 -0
- package/src/assignment-store.test.ts +56 -0
- package/src/assignment-store.ts +7 -38
- package/src/bucket.ts +3 -18
- package/src/commerce-analytics-lifecycle.test.ts +75 -15
- package/src/commerce-config-lifecycle.test.ts +81 -1
- package/src/commerce-price-preserve.test.ts +133 -0
- package/src/commerce-price.test.ts +97 -0
- package/src/commerce-price.ts +74 -20
- package/src/commerce-shipping.test.ts +53 -0
- package/src/commerce-shipping.ts +5 -11
- package/src/compact-store.ts +15 -0
- package/src/config-fetch.test.ts +480 -9
- package/src/config-runtime.ts +2071 -0
- package/src/consent-evaluation-borlabs.test.ts +41 -0
- package/src/consent-evaluation-cookiescript.test.ts +50 -0
- package/src/consent-evaluation.ts +151 -0
- package/src/css.test.ts +30 -0
- package/src/css.ts +120 -6
- package/src/cwv.test.ts +60 -77
- package/src/debug-loader.test.ts +214 -0
- package/src/debug-loader.ts +41 -0
- package/src/decide-assignments.test.ts +196 -0
- package/src/decide-assignments.ts +204 -0
- package/src/delivery-timing.test.ts +104 -0
- package/src/delivery-timing.ts +89 -0
- package/src/early-interaction-capture.test.ts +451 -0
- package/src/early-interaction-capture.ts +230 -0
- package/src/early-reassert.test.ts +207 -0
- package/src/early-reassert.ts +152 -0
- package/src/edge-adoption.test.ts +435 -0
- package/src/edge-runtime.ts +30 -0
- package/src/editor/api.test.ts +109 -0
- package/src/editor/api.ts +34 -3
- package/src/editor/controller.test.ts +297 -14
- package/src/editor/controller.ts +69 -5
- package/src/editor/index.test.ts +2 -0
- package/src/editor/index.ts +1 -0
- package/src/editor/publication-result.ts +58 -0
- package/src/editor/types.ts +10 -1
- package/src/editor/view-kit.ts +2 -1
- package/src/editor/view.test.ts +108 -0
- package/src/editor/view.ts +49 -7
- package/src/endpoint.test.ts +15 -0
- package/src/endpoint.ts +10 -0
- package/src/exclusion-groups.ts +8 -18
- package/src/flags.test.ts +88 -1
- package/src/flags.ts +29 -29
- package/src/global-runtime-reconciler.test.ts +271 -0
- package/src/goal-eligibility.test.ts +29 -0
- package/src/goal-eligibility.ts +17 -0
- package/src/goals.test.ts +144 -1
- package/src/goals.ts +97 -18
- package/src/guards.test.ts +99 -1
- package/src/guards.ts +34 -15
- package/src/handover-cache-order.test.ts +6 -3
- package/src/handover.test.ts +154 -8
- package/src/handover.ts +122 -12
- package/src/hash.ts +29 -54
- package/src/holdout.test.ts +26 -0
- package/src/hydration.test.ts +73 -2
- package/src/hydration.ts +18 -9
- package/src/index.ts +130 -7287
- package/src/init-analytics.test.ts +1694 -0
- package/src/init-anti-flicker.test.ts +1211 -0
- package/src/init-cart-attributes.test.ts +2003 -0
- package/src/init-config-resolution.test.ts +999 -0
- package/src/init-consent.test.ts +971 -0
- package/src/init-empty.test.ts +11 -5406
- package/src/init-integrations.test.ts +846 -0
- package/src/init-page-activation.test.ts +5064 -0
- package/src/init-qa-loading.test.ts +740 -0
- package/src/init-redirects.test.ts +2156 -0
- package/src/init-test-helpers.ts +287 -0
- package/src/inline-bootstrap.test.ts +184 -0
- package/src/inline-bootstrap.ts +73 -0
- package/src/js.test.ts +51 -1
- package/src/js.ts +86 -2
- package/src/money.test.ts +55 -1
- package/src/money.ts +104 -2
- package/src/money.web-parity.contract.test.ts +85 -0
- package/src/mutate.test.ts +474 -10
- package/src/mutate.ts +739 -230
- package/src/page-trigger.test.ts +218 -8
- package/src/performance.ts +10 -0
- package/src/persistence-decoders.ts +18 -0
- package/src/public-api.ts +1828 -0
- package/src/qa-assistant-state.test.ts +249 -3
- package/src/qa-assistant-state.ts +146 -6
- package/src/qa-assistant-view.test.ts +220 -2
- package/src/qa-assistant-view.ts +102 -5
- package/src/react/index.test.tsx +37 -0
- package/src/react/index.tsx +19 -3
- package/src/reassert-sentinel.test.ts +155 -1
- package/src/reassert-sentinel.ts +168 -8
- package/src/redirect.test.ts +541 -0
- package/src/redirect.ts +572 -0
- package/src/replay-recorder-entry.ts +115 -0
- package/src/replay-signal-state.ts +100 -0
- package/src/replay-signals.test.ts +249 -0
- package/src/replay-signals.ts +151 -0
- package/src/replay.test.ts +764 -0
- package/src/replay.ts +514 -0
- package/src/router.test.ts +61 -2
- package/src/router.ts +50 -12
- package/src/runtime-state.ts +507 -0
- package/src/runtime.test.ts +174 -7
- package/src/runtime.ts +88 -14
- package/src/sanitize.test.ts +34 -0
- package/src/sanitize.ts +23 -12
- package/src/shadow.test.ts +187 -0
- package/src/shadow.ts +99 -0
- package/src/shopify-cart.test.ts +733 -23
- package/src/shopify-cart.ts +402 -49
- package/src/storefront-vitals.test.ts +217 -0
- package/src/storefront-vitals.ts +206 -0
- package/src/surface-signals.ts +6 -0
- package/src/targeting-conformance.test.ts +9 -0
- package/src/targeting-diagnostics.ts +38 -8
- package/src/targeting-parity-corpus.json +559 -0
- package/src/targeting-parity-corpus.test.ts +18 -0
- package/src/targeting.detailed.test.ts +105 -0
- package/src/targeting.test.ts +211 -0
- package/src/targeting.ts +88 -207
- package/src/track.test.ts +147 -0
- package/src/track.ts +125 -26
- package/src/types.ts +120 -13
- package/src/wait.test.ts +23 -0
- package/src/wait.ts +129 -13
- package/src/weekday-delivery.test.ts +56 -0
- package/src/weekday-schedule-parity-corpus.json +92 -0
- package/src/weekday-schedule.test.ts +69 -0
- package/src/weekday-schedule.ts +20 -0
package/src/guards.test.ts
CHANGED
|
@@ -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
|
-
|
|
81
|
-
|
|
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(
|
|
111
|
+
export function parseForceParam(): Record<string, string> | null {
|
|
101
112
|
// Check URL param first
|
|
102
|
-
const
|
|
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
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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(
|
|
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 (
|
|
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
|
|
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("./
|
|
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("./
|
|
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(
|
|
30
|
+
expect(cachedPath).toContain(
|
|
31
|
+
'"cache",\n perfContext,\n cachedVerifiedRedirectCarry,\n verifiedHandover',
|
|
32
|
+
);
|
|
33
|
+
expect(cachedPath).toContain("verifiedRedirectCarryPromise");
|
|
31
34
|
});
|
|
32
35
|
});
|
package/src/handover.test.ts
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
2
|
-
import {
|
|
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("
|
|
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.
|
|
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
|
-
() =>
|
|
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
|
-
|
|
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
|
-
|
|
212
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
70
|
-
|
|
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
|
|
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
|
-
|
|
195
|
-
cachedSignedPayloadExp = payload
|
|
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
|
-
&&
|
|
320
|
+
&& cachedBaseKey !== null
|
|
321
|
+
&& cachedBaseKey === baseKey
|
|
212
322
|
&& cachedSignedPayloadExp > now
|
|
213
323
|
) {
|
|
214
324
|
return cachedSignedPayload;
|