@drip-apex/sdk 0.1.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/LICENSE +21 -0
- package/README.md +46 -0
- package/dist/_headers +3 -0
- package/dist/bundle-report.json +564 -0
- package/dist/drip.debug.js +2 -0
- package/dist/drip.debug.js.map +1 -0
- package/dist/drip.heatmap.js +2 -0
- package/dist/drip.heatmap.js.map +1 -0
- package/dist/drip.js +2 -0
- package/dist/drip.js.map +1 -0
- package/dist/drip.live.js +2 -0
- package/dist/drip.live.js.map +1 -0
- package/dist/editor.js +480 -0
- package/dist/editor.js.map +7 -0
- package/dist/index.mjs +645 -0
- package/dist/index.mjs.map +7 -0
- package/dist/metafiles/drip.debug.meta.json +973 -0
- package/dist/metafiles/drip.heatmap.meta.json +918 -0
- package/dist/metafiles/drip.live.meta.json +918 -0
- package/dist/metafiles/drip.meta.json +973 -0
- package/dist/metafiles/editor.meta.json +321 -0
- package/dist/metafiles/index.meta.json +1039 -0
- package/dist/metafiles/index.mjs.meta.json +78 -0
- package/dist/react/index.js +16973 -0
- package/dist/react/index.js.map +7 -0
- package/dist/sri.json +27 -0
- package/dist/types/analytics.d.ts +15 -0
- package/dist/types/antiflicker.d.ts +14 -0
- package/dist/types/assignment-store.d.ts +30 -0
- package/dist/types/audit.d.ts +9 -0
- package/dist/types/bucket.d.ts +2 -0
- package/dist/types/bucketing-cleanup.d.ts +5 -0
- package/dist/types/commerce-gift.d.ts +41 -0
- package/dist/types/commerce-price.d.ts +22 -0
- package/dist/types/commerce-shipping.d.ts +28 -0
- package/dist/types/css.d.ts +5 -0
- package/dist/types/cwv.d.ts +10 -0
- package/dist/types/data-layer-normalize.d.ts +1 -0
- package/dist/types/debug.d.ts +23 -0
- package/dist/types/define.d.ts +87 -0
- package/dist/types/devtools.d.ts +32 -0
- package/dist/types/dom-trigger.d.ts +3 -0
- package/dist/types/editor/api.d.ts +37 -0
- package/dist/types/editor/artifacts.d.ts +15 -0
- package/dist/types/editor/command-runtime.d.ts +48 -0
- package/dist/types/editor/controller.d.ts +97 -0
- package/dist/types/editor/device-preview.d.ts +23 -0
- package/dist/types/editor/index.d.ts +9 -0
- package/dist/types/editor/operator.d.ts +39 -0
- package/dist/types/editor/picker.d.ts +23 -0
- package/dist/types/editor/selector.d.ts +2 -0
- package/dist/types/editor/types.d.ts +55 -0
- package/dist/types/editor/view-kit.d.ts +33 -0
- package/dist/types/editor/view.d.ts +5 -0
- package/dist/types/editor-bootstrap.d.ts +37 -0
- package/dist/types/exclusion-groups.d.ts +59 -0
- package/dist/types/exposure-signals.d.ts +7 -0
- package/dist/types/flags.d.ts +65 -0
- package/dist/types/goals.d.ts +16 -0
- package/dist/types/guards.d.ts +39 -0
- package/dist/types/handover.d.ts +10 -0
- package/dist/types/hash.d.ts +19 -0
- package/dist/types/heatmap.d.ts +8 -0
- package/dist/types/hideflicker.d.ts +15 -0
- package/dist/types/hydration.d.ts +11 -0
- package/dist/types/index.d.ts +144 -0
- package/dist/types/js.d.ts +6 -0
- package/dist/types/module.d.ts +4 -0
- package/dist/types/money.d.ts +14 -0
- package/dist/types/mutate.d.ts +34 -0
- package/dist/types/observer-mute.d.ts +3 -0
- package/dist/types/pii-keys.d.ts +1 -0
- package/dist/types/prerequisites.d.ts +12 -0
- package/dist/types/qa-assistant-state.d.ts +68 -0
- package/dist/types/qa-assistant-view.d.ts +6 -0
- package/dist/types/qa-assistant.d.ts +9 -0
- package/dist/types/qa-toolbar.d.ts +19 -0
- package/dist/types/react/index.d.ts +84 -0
- package/dist/types/reassert-sentinel.d.ts +6 -0
- package/dist/types/router.d.ts +9 -0
- package/dist/types/runtime-tools.d.ts +50 -0
- package/dist/types/runtime.d.ts +22 -0
- package/dist/types/safe-regex.d.ts +6 -0
- package/dist/types/sanitize.d.ts +5 -0
- package/dist/types/semantic.d.ts +7 -0
- package/dist/types/shopify-cart-events.d.ts +7 -0
- package/dist/types/shopify-cart.d.ts +62 -0
- package/dist/types/signals.d.ts +31 -0
- package/dist/types/surface-signals.d.ts +6 -0
- package/dist/types/targeting-diagnostics.d.ts +5 -0
- package/dist/types/targeting.d.ts +50 -0
- package/dist/types/test-fixtures/commerce.d.ts +12 -0
- package/dist/types/track.d.ts +79 -0
- package/dist/types/types.d.ts +859 -0
- package/dist/types/wait.d.ts +7 -0
- package/package.json +74 -0
- package/src/analytics.ts +146 -0
- package/src/antiflicker.test.ts +127 -0
- package/src/antiflicker.ts +147 -0
- package/src/assignment-store.test.ts +171 -0
- package/src/assignment-store.ts +208 -0
- package/src/audit.test.ts +17 -0
- package/src/audit.ts +362 -0
- package/src/bucket.test.ts +65 -0
- package/src/bucket.ts +18 -0
- package/src/bucketing-cleanup.test.ts +30 -0
- package/src/bucketing-cleanup.ts +72 -0
- package/src/bucketing-parity.test.ts +41 -0
- package/src/commerce-analytics-lifecycle.test.ts +1416 -0
- package/src/commerce-config-lifecycle.test.ts +274 -0
- package/src/commerce-epoch-lifecycle.test.ts +62 -0
- package/src/commerce-gift.test.ts +1293 -0
- package/src/commerce-gift.ts +979 -0
- package/src/commerce-offers-display.test.ts +154 -0
- package/src/commerce-price.test.ts +1145 -0
- package/src/commerce-price.ts +941 -0
- package/src/commerce-revocation-lifecycle.test.ts +78 -0
- package/src/commerce-shipping.test.ts +630 -0
- package/src/commerce-shipping.ts +447 -0
- package/src/config-fetch.test.ts +325 -0
- package/src/css.ts +54 -0
- package/src/cwv.test.ts +95 -0
- package/src/cwv.ts +129 -0
- package/src/data-layer-normalize.ts +21 -0
- package/src/debug.test.ts +83 -0
- package/src/debug.ts +138 -0
- package/src/define.test.ts +327 -0
- package/src/define.ts +208 -0
- package/src/devtools.ts +582 -0
- package/src/dom-trigger.test.ts +70 -0
- package/src/dom-trigger.ts +55 -0
- package/src/editor/api.test.ts +154 -0
- package/src/editor/api.ts +185 -0
- package/src/editor/artifacts.test.ts +57 -0
- package/src/editor/artifacts.ts +115 -0
- package/src/editor/command-runtime.test.ts +225 -0
- package/src/editor/command-runtime.ts +572 -0
- package/src/editor/controller.test.ts +371 -0
- package/src/editor/controller.ts +596 -0
- package/src/editor/device-preview.test.ts +60 -0
- package/src/editor/device-preview.ts +120 -0
- package/src/editor/index.test.ts +161 -0
- package/src/editor/index.ts +169 -0
- package/src/editor/operator.test.ts +148 -0
- package/src/editor/operator.ts +256 -0
- package/src/editor/picker.test.ts +58 -0
- package/src/editor/picker.ts +152 -0
- package/src/editor/selector.test.ts +44 -0
- package/src/editor/selector.ts +110 -0
- package/src/editor/types.ts +92 -0
- package/src/editor/view-kit.ts +162 -0
- package/src/editor/view.test.ts +535 -0
- package/src/editor/view.ts +1395 -0
- package/src/editor-bootstrap.test.ts +116 -0
- package/src/editor-bootstrap.ts +213 -0
- package/src/event-schema-contract.test.ts +35 -0
- package/src/exclusion-groups.test.ts +383 -0
- package/src/exclusion-groups.ts +270 -0
- package/src/exposure-signals.test.ts +182 -0
- package/src/exposure-signals.ts +110 -0
- package/src/flags.test.ts +322 -0
- package/src/flags.ts +201 -0
- package/src/goals.test.ts +42 -0
- package/src/goals.ts +200 -0
- package/src/guards.test.ts +249 -0
- package/src/guards.ts +250 -0
- package/src/handover-cache-order.test.ts +32 -0
- package/src/handover.test.ts +349 -0
- package/src/handover.ts +309 -0
- package/src/hash.ts +214 -0
- package/src/heatmap.test.ts +305 -0
- package/src/heatmap.ts +373 -0
- package/src/hideflicker.ts +21 -0
- package/src/hydration.test.ts +252 -0
- package/src/hydration.ts +203 -0
- package/src/index.ts +7513 -0
- package/src/init-empty.test.ts +5572 -0
- package/src/js.test.ts +26 -0
- package/src/js.ts +61 -0
- package/src/module.ts +50 -0
- package/src/money.test.ts +35 -0
- package/src/money.ts +53 -0
- package/src/mutate.test.ts +304 -0
- package/src/mutate.ts +469 -0
- package/src/observer-mute.test.ts +88 -0
- package/src/observer-mute.ts +45 -0
- package/src/page-trigger.test.ts +84 -0
- package/src/personalization.test.ts +68 -0
- package/src/pii-keys.ts +17 -0
- package/src/prerequisites.test.ts +39 -0
- package/src/prerequisites.ts +94 -0
- package/src/qa-assistant-state.test.ts +471 -0
- package/src/qa-assistant-state.ts +505 -0
- package/src/qa-assistant-view.test.ts +185 -0
- package/src/qa-assistant-view.ts +768 -0
- package/src/qa-assistant.ts +40 -0
- package/src/qa-toolbar.ts +444 -0
- package/src/react/index.test.tsx +177 -0
- package/src/react/index.tsx +252 -0
- package/src/reassert-sentinel.test.ts +314 -0
- package/src/reassert-sentinel.ts +191 -0
- package/src/router.test.ts +78 -0
- package/src/router.ts +92 -0
- package/src/runtime-tools.test.ts +112 -0
- package/src/runtime-tools.ts +403 -0
- package/src/runtime.test.ts +217 -0
- package/src/runtime.ts +268 -0
- package/src/safe-regex.ts +35 -0
- package/src/sanitize.ts +34 -0
- package/src/semantic.ts +245 -0
- package/src/shopify-cart-events.test.ts +76 -0
- package/src/shopify-cart-events.ts +115 -0
- package/src/shopify-cart.test.ts +2823 -0
- package/src/shopify-cart.ts +1256 -0
- package/src/signals.ts +226 -0
- package/src/surface-signals.test.ts +83 -0
- package/src/surface-signals.ts +165 -0
- package/src/targeting-diagnostics.ts +560 -0
- package/src/targeting.detailed.test.ts +443 -0
- package/src/targeting.test.ts +1054 -0
- package/src/targeting.ts +1024 -0
- package/src/test-fixtures/commerce.ts +65 -0
- package/src/track.test.ts +817 -0
- package/src/track.ts +519 -0
- package/src/types.ts +991 -0
- package/src/wait.test.ts +96 -0
- package/src/wait.ts +238 -0
|
@@ -0,0 +1,1256 @@
|
|
|
1
|
+
import { fetchWithoutShopifyCartMutationNotification } from "./shopify-cart-events";
|
|
2
|
+
|
|
3
|
+
const CART_ATTRIBUTE_SYNC_KEY = "drip_cart_attr";
|
|
4
|
+
|
|
5
|
+
export type CartAssignment = {
|
|
6
|
+
experimentId: string;
|
|
7
|
+
variationId: string;
|
|
8
|
+
assignmentEpoch?: number;
|
|
9
|
+
commercePriceOnly?: boolean;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export type CartAttributeSyncInput = {
|
|
13
|
+
assignments: CartAssignment[];
|
|
14
|
+
requiredAssignments?: CartAssignment[];
|
|
15
|
+
visitorId: string;
|
|
16
|
+
sessionId: string;
|
|
17
|
+
shopId?: string;
|
|
18
|
+
endpoint?: string;
|
|
19
|
+
ingestSignature?: string;
|
|
20
|
+
url?: string;
|
|
21
|
+
environment?: string;
|
|
22
|
+
cartToken?: string;
|
|
23
|
+
offersAuthorizationRevision?: number;
|
|
24
|
+
offersExperimentIds?: string[];
|
|
25
|
+
shippingAuthorizationRevision?: number;
|
|
26
|
+
shippingExperimentIds?: string[];
|
|
27
|
+
priceAuthorizationRevision?: number;
|
|
28
|
+
priceExperimentIds?: string[];
|
|
29
|
+
adoptOffersAssignments?: (assignments: CartAssignment[]) => boolean;
|
|
30
|
+
/** Pre-write feasibility check; must not apply anything. */
|
|
31
|
+
canAdoptOffersAssignments?: (assignments: CartAssignment[]) => boolean;
|
|
32
|
+
/** Headless capture hook: stage a validated offers adoption for {@link confirmCartAttributesWritten}. */
|
|
33
|
+
stageOffersAdoptionAssignments?: (assignments: CartAssignment[]) => void;
|
|
34
|
+
adoptPriceAssignments?: (assignments: CartAssignment[]) => boolean;
|
|
35
|
+
authorizePriceDisplayAssignments?: (assignments: CartAssignment[]) => void;
|
|
36
|
+
authorizeShippingDisplayAssignments?: (assignments: CartAssignment[]) => void;
|
|
37
|
+
authorizeOffersDisplayAssignments?: (assignments: CartAssignment[]) => void;
|
|
38
|
+
/** Receives the parsed /cart/update.js response body after a successful stamp write. */
|
|
39
|
+
onCartSnapshot?: (cart: unknown) => void;
|
|
40
|
+
exclusionGroupSelections?: Array<{
|
|
41
|
+
groupId: string;
|
|
42
|
+
experimentId: string;
|
|
43
|
+
epoch: number;
|
|
44
|
+
}>;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
export type CartAttributeEntry = { key: string; value: string };
|
|
48
|
+
|
|
49
|
+
export type CartAttributeSyncResult = {
|
|
50
|
+
status: "sent" | "skipped" | "failed";
|
|
51
|
+
reason?: string;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
export type CartAttributeSyncOptions = {
|
|
55
|
+
/** Re-write after a real cart mutation even if this assignment was stamped earlier. */
|
|
56
|
+
force?: boolean;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const inFlightBySignature = new Map<string, Promise<CartAttributeSyncResult>>();
|
|
60
|
+
const validatedAuthorizationSignatures = new Set<string>();
|
|
61
|
+
let authorizedAssignmentsBySignature: {
|
|
62
|
+
price: Map<string, CartAssignment[]>;
|
|
63
|
+
shipping: Map<string, CartAssignment[]>;
|
|
64
|
+
offers: Map<string, CartAssignment[]>;
|
|
65
|
+
} | null = null;
|
|
66
|
+
let latestSyncGeneration = 0;
|
|
67
|
+
|
|
68
|
+
/** Prevent an obsolete authorization response from reaching the cart write. */
|
|
69
|
+
export function invalidateShopifyCartAttributeSync(): void {
|
|
70
|
+
latestSyncGeneration += 1;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function ensureAuthorizedAssignmentCache() {
|
|
74
|
+
return authorizedAssignmentsBySignature ??= {
|
|
75
|
+
price: new Map<string, CartAssignment[]>(),
|
|
76
|
+
shipping: new Map<string, CartAssignment[]>(),
|
|
77
|
+
offers: new Map<string, CartAssignment[]>(),
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function requiresCommerceAuthorization(input: CartAttributeSyncInput): boolean {
|
|
82
|
+
return !!input.requiredAssignments?.length
|
|
83
|
+
|| input.offersAuthorizationRevision != null
|
|
84
|
+
|| input.shippingAuthorizationRevision != null
|
|
85
|
+
|| input.priceAuthorizationRevision != null;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function hasActivePriceAuthorizationPlane(input: CartAttributeSyncInput): boolean {
|
|
89
|
+
return input.priceAuthorizationRevision != null && !!input.priceExperimentIds?.length;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function hasActiveOffersAuthorizationPlane(input: CartAttributeSyncInput): boolean {
|
|
93
|
+
return input.offersAuthorizationRevision != null && !!input.offersExperimentIds?.length;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function offersExperimentIds(input: CartAttributeSyncInput): Set<string> {
|
|
97
|
+
return new Set(input.offersExperimentIds ?? []);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function shippingExperimentIds(input: CartAttributeSyncInput): Set<string> {
|
|
101
|
+
if (input.shippingExperimentIds?.length) return new Set(input.shippingExperimentIds);
|
|
102
|
+
const priceExperimentIds = new Set(input.priceExperimentIds ?? []);
|
|
103
|
+
return new Set(
|
|
104
|
+
input.requiredAssignments
|
|
105
|
+
?.filter((assignment) => !priceExperimentIds.has(assignment.experimentId))
|
|
106
|
+
.map((assignment) => assignment.experimentId) ?? [],
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function hasActiveShippingAuthorizationPlane(input: CartAttributeSyncInput): boolean {
|
|
111
|
+
return input.shippingAuthorizationRevision != null && shippingExperimentIds(input).size > 0;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Clearing a stale grant is privilege-REDUCING, so shipping- and offers-only
|
|
115
|
+
// shops may clear without a signed token; the price plane always needs one.
|
|
116
|
+
function isUnsignedCommerceClearCandidate(input: CartAttributeSyncInput): boolean {
|
|
117
|
+
return input.priceAuthorizationRevision == null &&
|
|
118
|
+
(input.shippingAuthorizationRevision != null ||
|
|
119
|
+
input.offersAuthorizationRevision != null) &&
|
|
120
|
+
!input.requiredAssignments?.length;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function cannotRequestRequiredCommerceAuthorization(
|
|
124
|
+
input: CartAttributeSyncInput,
|
|
125
|
+
): boolean {
|
|
126
|
+
return (
|
|
127
|
+
!!input.assignments[0] ||
|
|
128
|
+
input.shippingAuthorizationRevision != null ||
|
|
129
|
+
input.offersAuthorizationRevision != null ||
|
|
130
|
+
hasActivePriceAuthorizationPlane(input)
|
|
131
|
+
)
|
|
132
|
+
&& requiresCommerceAuthorization(input)
|
|
133
|
+
&& (!input.shopId || !input.endpoint || !input.ingestSignature);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function isShopifyStorefront(): boolean {
|
|
137
|
+
return typeof window !== "undefined" && !!(window as unknown as { Shopify?: unknown }).Shopify;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function readStampedSignature(): string | null {
|
|
141
|
+
try {
|
|
142
|
+
return sessionStorage.getItem(CART_ATTRIBUTE_SYNC_KEY);
|
|
143
|
+
} catch {
|
|
144
|
+
return null;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function writeStampedSignature(signature: string): void {
|
|
149
|
+
try {
|
|
150
|
+
sessionStorage.setItem(CART_ATTRIBUTE_SYNC_KEY, signature);
|
|
151
|
+
} catch {
|
|
152
|
+
// Storage can be unavailable in strict privacy modes; the cart write is still best-effort.
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function normalizeTargetingUrl(value: unknown): string | null {
|
|
157
|
+
if (typeof value !== "string") return null;
|
|
158
|
+
const trimmed = value.trim();
|
|
159
|
+
if (!trimmed) return null;
|
|
160
|
+
try {
|
|
161
|
+
// The Worker evaluates targeting through URL with the same fallback base.
|
|
162
|
+
// Persist that effective value so equivalent URL spellings share a cache
|
|
163
|
+
// entry while a meaningful SPA route change requests a fresh price pin.
|
|
164
|
+
return new URL(trimmed, "https://_").href;
|
|
165
|
+
} catch {
|
|
166
|
+
// The Worker still receives invalid-but-non-empty strings and fails their
|
|
167
|
+
// URL targeting closed, so retain the trimmed value as part of the key.
|
|
168
|
+
return trimmed;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function syncSignature(input: CartAttributeSyncInput): string {
|
|
173
|
+
return JSON.stringify({
|
|
174
|
+
assignments: input.assignments
|
|
175
|
+
.map((assignment) => [
|
|
176
|
+
assignment.experimentId,
|
|
177
|
+
assignment.variationId,
|
|
178
|
+
assignment.assignmentEpoch ?? null,
|
|
179
|
+
])
|
|
180
|
+
.sort((a, b) => JSON.stringify(a).localeCompare(JSON.stringify(b))),
|
|
181
|
+
requiredAssignments: input.requiredAssignments
|
|
182
|
+
?.map((assignment) => [
|
|
183
|
+
assignment.experimentId,
|
|
184
|
+
assignment.variationId,
|
|
185
|
+
assignment.assignmentEpoch ?? null,
|
|
186
|
+
])
|
|
187
|
+
.sort((a, b) => JSON.stringify(a).localeCompare(JSON.stringify(b))),
|
|
188
|
+
visitorId: input.visitorId,
|
|
189
|
+
sessionId: input.sessionId,
|
|
190
|
+
url: requiresCommerceAuthorization(input)
|
|
191
|
+
? normalizeTargetingUrl(input.url)
|
|
192
|
+
: undefined,
|
|
193
|
+
cartToken: normalizeCartToken(input.cartToken),
|
|
194
|
+
...(input.offersAuthorizationRevision != null || input.offersExperimentIds?.length
|
|
195
|
+
? {
|
|
196
|
+
offersAuthorizationRevision: input.offersAuthorizationRevision ?? null,
|
|
197
|
+
offersExperimentIds: input.offersExperimentIds?.slice().sort(),
|
|
198
|
+
}
|
|
199
|
+
: {}),
|
|
200
|
+
shippingAuthorizationRevision: input.shippingAuthorizationRevision ?? null,
|
|
201
|
+
shippingExperimentIds: input.shippingExperimentIds?.slice().sort(),
|
|
202
|
+
priceAuthorizationRevision: input.priceAuthorizationRevision ?? null,
|
|
203
|
+
exclusionGroupSelections: input.exclusionGroupSelections
|
|
204
|
+
?.map((selection) => [
|
|
205
|
+
selection.groupId,
|
|
206
|
+
selection.experimentId,
|
|
207
|
+
selection.epoch,
|
|
208
|
+
])
|
|
209
|
+
.sort((a, b) => JSON.stringify(a).localeCompare(JSON.stringify(b))),
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** Validated locale-aware storefront root ("/fr/" on localized markets, "/" otherwise). */
|
|
214
|
+
export function shopifyRoutesRoot(): string {
|
|
215
|
+
const configuredRoot = (window as Window & {
|
|
216
|
+
Shopify?: { routes?: { root?: string } };
|
|
217
|
+
}).Shopify?.routes?.root;
|
|
218
|
+
if (typeof configuredRoot !== "string") return "/";
|
|
219
|
+
try {
|
|
220
|
+
const storefrontOrigin = window.location?.origin;
|
|
221
|
+
if (!storefrontOrigin) return "/";
|
|
222
|
+
const parsed = new URL(configuredRoot, storefrontOrigin);
|
|
223
|
+
if (parsed.origin !== storefrontOrigin || parsed.search || parsed.hash) return "/";
|
|
224
|
+
return parsed.pathname.endsWith("/") ? parsed.pathname : `${parsed.pathname}/`;
|
|
225
|
+
} catch {
|
|
226
|
+
return "/";
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
export function shopifyCartPath(): string {
|
|
231
|
+
return `${shopifyRoutesRoot()}cart.js`;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function normalizeCartToken(value: unknown): string | null {
|
|
235
|
+
if (typeof value !== "string") return null;
|
|
236
|
+
const withoutQuery = value.trim().split("?", 1)[0] ?? "";
|
|
237
|
+
const token = withoutQuery.startsWith("gid://shopify/Cart/")
|
|
238
|
+
? withoutQuery.slice("gid://shopify/Cart/".length)
|
|
239
|
+
: withoutQuery;
|
|
240
|
+
return token.length > 0 && token.length <= 4096 ? token : null;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function normalizeProofCartToken(value: unknown): string | null {
|
|
244
|
+
return typeof value === "string" && normalizeCartToken(value) ? value.trim() : null;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
async function resolveChargeAuthorizationCartToken(
|
|
248
|
+
input: CartAttributeSyncInput,
|
|
249
|
+
): Promise<string | null> {
|
|
250
|
+
const supplied = normalizeProofCartToken(input.cartToken);
|
|
251
|
+
if (supplied) return supplied;
|
|
252
|
+
if (
|
|
253
|
+
input.priceAuthorizationRevision == null &&
|
|
254
|
+
input.offersAuthorizationRevision == null
|
|
255
|
+
) return null;
|
|
256
|
+
if (!isShopifyStorefront()) return null;
|
|
257
|
+
try {
|
|
258
|
+
const response = await fetchWithoutShopifyCartMutationNotification(shopifyCartPath(), {
|
|
259
|
+
method: "GET",
|
|
260
|
+
headers: { Accept: "application/json" },
|
|
261
|
+
cache: "no-store",
|
|
262
|
+
});
|
|
263
|
+
if (!response.ok) return null;
|
|
264
|
+
return normalizeProofCartToken(((await response.json()) as { token?: unknown }).token);
|
|
265
|
+
} catch {
|
|
266
|
+
return null;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
export function syncShopifyCartAttributes(
|
|
271
|
+
input: CartAttributeSyncInput,
|
|
272
|
+
options: CartAttributeSyncOptions = {},
|
|
273
|
+
): Promise<CartAttributeSyncResult> {
|
|
274
|
+
try {
|
|
275
|
+
if (!isShopifyStorefront()) return Promise.resolve({ status: "skipped", reason: "not_shopify" });
|
|
276
|
+
if (typeof fetch !== "function") return Promise.resolve({ status: "skipped", reason: "fetch_missing" });
|
|
277
|
+
if (!input.assignments[0] && !requiresCommerceAuthorization(input)) {
|
|
278
|
+
return Promise.resolve({ status: "skipped", reason: "no_assignments" });
|
|
279
|
+
}
|
|
280
|
+
if (cannotRequestRequiredCommerceAuthorization(input)) {
|
|
281
|
+
return Promise.resolve({ status: "failed", reason: "authorization_failed" });
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const signature = syncSignature(input);
|
|
285
|
+
const existingInFlight = inFlightBySignature.get(signature);
|
|
286
|
+
if (existingInFlight) {
|
|
287
|
+
if (!options.force) return existingInFlight;
|
|
288
|
+
// A forced sync follows a real cart mutation, so cart identity may have
|
|
289
|
+
// changed underneath the pending request. Invalidate it immediately;
|
|
290
|
+
// ordinary overlaps still share the existing request.
|
|
291
|
+
latestSyncGeneration += 1;
|
|
292
|
+
return existingInFlight.then(() => syncShopifyCartAttributes(input, options));
|
|
293
|
+
}
|
|
294
|
+
const generation = ++latestSyncGeneration;
|
|
295
|
+
const requiresCurrentAssignmentProof = requiresCommerceAuthorization(input);
|
|
296
|
+
if (
|
|
297
|
+
!options.force &&
|
|
298
|
+
readStampedSignature() === signature &&
|
|
299
|
+
(!requiresCurrentAssignmentProof || validatedAuthorizationSignatures.has(signature))
|
|
300
|
+
) {
|
|
301
|
+
if (hasActivePriceAuthorizationPlane(input)) {
|
|
302
|
+
input.authorizePriceDisplayAssignments?.(
|
|
303
|
+
authorizedAssignmentsBySignature?.price.get(signature) ?? [],
|
|
304
|
+
);
|
|
305
|
+
}
|
|
306
|
+
if (hasActiveShippingAuthorizationPlane(input)) {
|
|
307
|
+
input.authorizeShippingDisplayAssignments?.(
|
|
308
|
+
authorizedAssignmentsBySignature?.shipping.get(signature) ?? [],
|
|
309
|
+
);
|
|
310
|
+
}
|
|
311
|
+
if (hasActiveOffersAuthorizationPlane(input)) {
|
|
312
|
+
input.authorizeOffersDisplayAssignments?.(
|
|
313
|
+
authorizedAssignmentsBySignature?.offers.get(signature) ?? [],
|
|
314
|
+
);
|
|
315
|
+
}
|
|
316
|
+
return Promise.resolve({ status: "sent", reason: "already_stamped" });
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
if (requiresCurrentAssignmentProof) validatedAuthorizationSignatures.delete(signature);
|
|
320
|
+
let authorizedSignature = signature;
|
|
321
|
+
const pending = requestCommerceAuthorization(input, generation)
|
|
322
|
+
.then(async ({
|
|
323
|
+
completed,
|
|
324
|
+
cartWriteAllowed,
|
|
325
|
+
token,
|
|
326
|
+
assignments,
|
|
327
|
+
offersAdoptionAssignments,
|
|
328
|
+
}): Promise<{
|
|
329
|
+
response: Response | null;
|
|
330
|
+
authorizationCompleted: boolean;
|
|
331
|
+
authorizedSignature: string;
|
|
332
|
+
authorizedPriceAssignments: CartAssignment[];
|
|
333
|
+
authorizedShippingAssignments: CartAssignment[];
|
|
334
|
+
authorizedOffersAssignments: CartAssignment[];
|
|
335
|
+
offersAdoptionAssignments?: CartAssignment[];
|
|
336
|
+
}> => {
|
|
337
|
+
if (!cartWriteAllowed || generation !== latestSyncGeneration) {
|
|
338
|
+
return {
|
|
339
|
+
response: null,
|
|
340
|
+
authorizationCompleted: false,
|
|
341
|
+
authorizedSignature,
|
|
342
|
+
authorizedPriceAssignments: [],
|
|
343
|
+
authorizedShippingAssignments: [],
|
|
344
|
+
authorizedOffersAssignments: [],
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
const authorizedInput = { ...input, assignments };
|
|
348
|
+
authorizedSignature = syncSignature(authorizedInput);
|
|
349
|
+
const authorizedPriceAssignments = hasActivePriceAuthorizationPlane(input)
|
|
350
|
+
? parseSignedPriceAuthorizationAssignments(
|
|
351
|
+
token,
|
|
352
|
+
new Set(input.priceExperimentIds ?? []),
|
|
353
|
+
) ?? []
|
|
354
|
+
: [];
|
|
355
|
+
const authorizedShippingAssignments = hasActiveShippingAuthorizationPlane(input)
|
|
356
|
+
? parseSignedShippingAuthorizationAssignments(
|
|
357
|
+
token,
|
|
358
|
+
shippingExperimentIds(input),
|
|
359
|
+
) ?? []
|
|
360
|
+
: [];
|
|
361
|
+
const authorizedOffersAssignments = hasActiveOffersAuthorizationPlane(input)
|
|
362
|
+
? parseSignedOffersAuthorizationAssignments(
|
|
363
|
+
token,
|
|
364
|
+
offersExperimentIds(input),
|
|
365
|
+
) ?? []
|
|
366
|
+
: [];
|
|
367
|
+
const attributes = Object.fromEntries(
|
|
368
|
+
buildCartAttributeEntries(authorizedInput, token).map((entry) => [entry.key, entry.value]),
|
|
369
|
+
);
|
|
370
|
+
const response = await fetchWithoutShopifyCartMutationNotification("/cart/update.js", {
|
|
371
|
+
method: "POST",
|
|
372
|
+
headers: { "Content-Type": "application/json" },
|
|
373
|
+
body: JSON.stringify({ attributes }),
|
|
374
|
+
keepalive: true,
|
|
375
|
+
});
|
|
376
|
+
return {
|
|
377
|
+
response,
|
|
378
|
+
authorizationCompleted: completed,
|
|
379
|
+
authorizedSignature,
|
|
380
|
+
authorizedPriceAssignments,
|
|
381
|
+
authorizedShippingAssignments,
|
|
382
|
+
authorizedOffersAssignments,
|
|
383
|
+
offersAdoptionAssignments,
|
|
384
|
+
};
|
|
385
|
+
})
|
|
386
|
+
.then(({
|
|
387
|
+
response,
|
|
388
|
+
authorizationCompleted,
|
|
389
|
+
authorizedSignature: completedSignature,
|
|
390
|
+
authorizedPriceAssignments,
|
|
391
|
+
authorizedShippingAssignments,
|
|
392
|
+
authorizedOffersAssignments,
|
|
393
|
+
offersAdoptionAssignments,
|
|
394
|
+
}) => {
|
|
395
|
+
if (!authorizationCompleted) {
|
|
396
|
+
return { status: "failed", reason: "authorization_failed" } as const;
|
|
397
|
+
}
|
|
398
|
+
if (!response) {
|
|
399
|
+
return { status: "failed", reason: "cart_update_missing" } as const;
|
|
400
|
+
}
|
|
401
|
+
if (!response.ok) {
|
|
402
|
+
return { status: "failed", reason: `cart_update_${response.status}` } as const;
|
|
403
|
+
}
|
|
404
|
+
if (generation === latestSyncGeneration) {
|
|
405
|
+
// The stamp is on the cart; only now may the adopted offer arm reach
|
|
406
|
+
// display/persist/tracking. On failure, skip the signature records so
|
|
407
|
+
// the next sync re-reconciles instead of treating this cart as done.
|
|
408
|
+
if (
|
|
409
|
+
offersAdoptionAssignments &&
|
|
410
|
+
!input.adoptOffersAssignments?.(offersAdoptionAssignments)
|
|
411
|
+
) {
|
|
412
|
+
return { status: "failed", reason: "offers_adoption_failed" } as const;
|
|
413
|
+
}
|
|
414
|
+
if (input.onCartSnapshot) {
|
|
415
|
+
// The stamp-write response body is the full updated cart — a free
|
|
416
|
+
// snapshot consumers use to rediscover Apex-managed lines without
|
|
417
|
+
// an extra request (best-effort; never blocks the sync result).
|
|
418
|
+
// Handed out only for the accepted generation AND after a required
|
|
419
|
+
// adoption succeeded: reconciling against local assignments the
|
|
420
|
+
// stamped envelope does not authorize could retain or insert a
|
|
421
|
+
// gift the Function will not discount.
|
|
422
|
+
void response.clone().json()
|
|
423
|
+
.then((cart) => input.onCartSnapshot?.(cart))
|
|
424
|
+
.catch(() => {});
|
|
425
|
+
}
|
|
426
|
+
writeStampedSignature(completedSignature);
|
|
427
|
+
if (requiresCurrentAssignmentProof) {
|
|
428
|
+
validatedAuthorizationSignatures.clear();
|
|
429
|
+
validatedAuthorizationSignatures.add(completedSignature);
|
|
430
|
+
}
|
|
431
|
+
if (hasActivePriceAuthorizationPlane(input)) {
|
|
432
|
+
const cache = ensureAuthorizedAssignmentCache().price;
|
|
433
|
+
cache.clear();
|
|
434
|
+
cache.set(
|
|
435
|
+
completedSignature,
|
|
436
|
+
authorizedPriceAssignments,
|
|
437
|
+
);
|
|
438
|
+
input.authorizePriceDisplayAssignments?.(authorizedPriceAssignments);
|
|
439
|
+
}
|
|
440
|
+
if (hasActiveShippingAuthorizationPlane(input)) {
|
|
441
|
+
const cache = ensureAuthorizedAssignmentCache().shipping;
|
|
442
|
+
cache.clear();
|
|
443
|
+
cache.set(
|
|
444
|
+
completedSignature,
|
|
445
|
+
authorizedShippingAssignments,
|
|
446
|
+
);
|
|
447
|
+
input.authorizeShippingDisplayAssignments?.(authorizedShippingAssignments);
|
|
448
|
+
}
|
|
449
|
+
if (hasActiveOffersAuthorizationPlane(input)) {
|
|
450
|
+
const cache = ensureAuthorizedAssignmentCache().offers;
|
|
451
|
+
cache.clear();
|
|
452
|
+
cache.set(
|
|
453
|
+
completedSignature,
|
|
454
|
+
authorizedOffersAssignments,
|
|
455
|
+
);
|
|
456
|
+
input.authorizeOffersDisplayAssignments?.(authorizedOffersAssignments);
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
return { status: "sent" } as const;
|
|
460
|
+
})
|
|
461
|
+
.catch((error) => ({
|
|
462
|
+
status: "failed",
|
|
463
|
+
reason: error instanceof Error ? error.message : String(error),
|
|
464
|
+
}) as const)
|
|
465
|
+
.finally(() => {
|
|
466
|
+
if (inFlightBySignature.get(signature) === pending) {
|
|
467
|
+
inFlightBySignature.delete(signature);
|
|
468
|
+
}
|
|
469
|
+
});
|
|
470
|
+
inFlightBySignature.set(signature, pending);
|
|
471
|
+
return pending;
|
|
472
|
+
} catch {
|
|
473
|
+
// Shopify cart attribution must never block or break assignment rendering.
|
|
474
|
+
return Promise.resolve({ status: "failed", reason: "sync_exception" });
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
async function requestCommerceAuthorization(
|
|
479
|
+
input: CartAttributeSyncInput,
|
|
480
|
+
generation?: number,
|
|
481
|
+
): Promise<{
|
|
482
|
+
completed: boolean;
|
|
483
|
+
cartWriteAllowed: boolean;
|
|
484
|
+
token: string | null;
|
|
485
|
+
assignments: CartAssignment[];
|
|
486
|
+
/** Adoption validated pre-write; the caller applies it only after the cart write is confirmed. */
|
|
487
|
+
offersAdoptionAssignments?: CartAssignment[];
|
|
488
|
+
}> {
|
|
489
|
+
const priceWriteRequiresProof = hasActivePriceAuthorizationPlane(input);
|
|
490
|
+
const offersWriteRequiresProof = hasActiveOffersAuthorizationPlane(input);
|
|
491
|
+
const configuredOffersExperimentIds = offersExperimentIds(input);
|
|
492
|
+
const enforceOffersPlaneProof = input.offersAuthorizationRevision != null;
|
|
493
|
+
let requiredOffersAssignments = enforceOffersPlaneProof
|
|
494
|
+
? input.requiredAssignments?.filter((assignment) =>
|
|
495
|
+
configuredOffersExperimentIds.has(assignment.experimentId)
|
|
496
|
+
) ?? []
|
|
497
|
+
: [];
|
|
498
|
+
const configuredShippingExperimentIds = shippingExperimentIds(input);
|
|
499
|
+
const enforceShippingPlaneProof = input.shippingAuthorizationRevision != null &&
|
|
500
|
+
(!!input.shippingExperimentIds?.length || hasActivePriceAuthorizationPlane(input));
|
|
501
|
+
const requiredShippingAssignments = enforceShippingPlaneProof
|
|
502
|
+
? input.requiredAssignments?.filter((assignment) =>
|
|
503
|
+
configuredShippingExperimentIds.has(assignment.experimentId)
|
|
504
|
+
) ?? []
|
|
505
|
+
: [];
|
|
506
|
+
let hasAuthoritativePriceProof = false;
|
|
507
|
+
if (
|
|
508
|
+
!input.assignments[0] &&
|
|
509
|
+
input.shippingAuthorizationRevision == null &&
|
|
510
|
+
input.offersAuthorizationRevision == null &&
|
|
511
|
+
!hasActivePriceAuthorizationPlane(input)
|
|
512
|
+
) {
|
|
513
|
+
return {
|
|
514
|
+
completed: true,
|
|
515
|
+
cartWriteAllowed: true,
|
|
516
|
+
token: null,
|
|
517
|
+
assignments: input.assignments,
|
|
518
|
+
};
|
|
519
|
+
}
|
|
520
|
+
if (!input.shopId || !input.endpoint || !input.ingestSignature) {
|
|
521
|
+
return {
|
|
522
|
+
completed: !requiresCommerceAuthorization(input),
|
|
523
|
+
cartWriteAllowed: true,
|
|
524
|
+
token: null,
|
|
525
|
+
assignments: input.assignments,
|
|
526
|
+
};
|
|
527
|
+
}
|
|
528
|
+
const endpoint = input.endpoint.replace(/\/+$/, "").replace(/\/events$/, "");
|
|
529
|
+
try {
|
|
530
|
+
const cartToken = await resolveChargeAuthorizationCartToken(input);
|
|
531
|
+
if (
|
|
532
|
+
(input.priceAuthorizationRevision != null ||
|
|
533
|
+
input.offersAuthorizationRevision != null) &&
|
|
534
|
+
!cartToken
|
|
535
|
+
) {
|
|
536
|
+
return {
|
|
537
|
+
completed: false,
|
|
538
|
+
cartWriteAllowed: !priceWriteRequiresProof && !offersWriteRequiresProof,
|
|
539
|
+
token: null,
|
|
540
|
+
assignments: input.assignments,
|
|
541
|
+
};
|
|
542
|
+
}
|
|
543
|
+
const requestedExperimentIds = [...new Set([
|
|
544
|
+
...input.assignments.map((assignment) => assignment.experimentId),
|
|
545
|
+
...(hasActivePriceAuthorizationPlane(input) ? input.priceExperimentIds ?? [] : []),
|
|
546
|
+
...(hasActiveOffersAuthorizationPlane(input) ? input.offersExperimentIds ?? [] : []),
|
|
547
|
+
])];
|
|
548
|
+
const response = await fetch(`${endpoint}/v1/assign`, {
|
|
549
|
+
method: "POST",
|
|
550
|
+
headers: {
|
|
551
|
+
"Content-Type": "application/json",
|
|
552
|
+
"X-Drip-Shop": input.shopId,
|
|
553
|
+
"X-Drip-Signature": input.ingestSignature,
|
|
554
|
+
},
|
|
555
|
+
body: JSON.stringify({
|
|
556
|
+
shop_id: input.shopId,
|
|
557
|
+
user_id: input.visitorId,
|
|
558
|
+
url: input.url,
|
|
559
|
+
environment: input.environment,
|
|
560
|
+
...(cartToken ? { cart_token: cartToken } : {}),
|
|
561
|
+
experiments: requestedExperimentIds,
|
|
562
|
+
exclusion_group_selections: input.exclusionGroupSelections?.map((selection) => ({
|
|
563
|
+
group_id: selection.groupId,
|
|
564
|
+
experiment_id: selection.experimentId,
|
|
565
|
+
epoch: selection.epoch,
|
|
566
|
+
})),
|
|
567
|
+
}),
|
|
568
|
+
keepalive: true,
|
|
569
|
+
});
|
|
570
|
+
if (!response.ok) {
|
|
571
|
+
return {
|
|
572
|
+
completed: !requiresCommerceAuthorization(input),
|
|
573
|
+
cartWriteAllowed: !priceWriteRequiresProof && !offersWriteRequiresProof &&
|
|
574
|
+
!isUnsignedCommerceClearCandidate(input),
|
|
575
|
+
token: null,
|
|
576
|
+
assignments: input.assignments,
|
|
577
|
+
};
|
|
578
|
+
}
|
|
579
|
+
const payload = (await response.json()) as {
|
|
580
|
+
assignments?: unknown;
|
|
581
|
+
commerce_authorization?: unknown;
|
|
582
|
+
};
|
|
583
|
+
const token =
|
|
584
|
+
typeof payload.commerce_authorization === "string" &&
|
|
585
|
+
payload.commerce_authorization
|
|
586
|
+
? payload.commerce_authorization
|
|
587
|
+
: null;
|
|
588
|
+
if (
|
|
589
|
+
!input.assignments[0] &&
|
|
590
|
+
isUnsignedCommerceClearCandidate(input) &&
|
|
591
|
+
// With configured offer experiments, a nonempty response is the
|
|
592
|
+
// cart-pinned offer arm returning under a new identity/off-target page —
|
|
593
|
+
// it must flow to the offers proof/adoption path below, not be rejected
|
|
594
|
+
// as a failed stale-clear. Empty-response teardown clears are unaffected.
|
|
595
|
+
!input.offersExperimentIds?.length &&
|
|
596
|
+
(!Array.isArray(payload.assignments) || payload.assignments.length !== 0)
|
|
597
|
+
) {
|
|
598
|
+
return {
|
|
599
|
+
completed: false,
|
|
600
|
+
cartWriteAllowed: false,
|
|
601
|
+
token: null,
|
|
602
|
+
assignments: input.assignments,
|
|
603
|
+
};
|
|
604
|
+
}
|
|
605
|
+
let authorizedAssignments = input.assignments;
|
|
606
|
+
let requiredAssignments = input.requiredAssignments;
|
|
607
|
+
let resolvedPriceAssignments: ReturnType<
|
|
608
|
+
typeof resolveAuthoritativePriceAssignments
|
|
609
|
+
> = null;
|
|
610
|
+
let resolvedOffersAssignments: ReturnType<
|
|
611
|
+
typeof resolveAuthoritativeOffersAssignments
|
|
612
|
+
> = null;
|
|
613
|
+
if (input.priceAuthorizationRevision != null) {
|
|
614
|
+
const priceExperimentIds = new Set(input.priceExperimentIds ?? []);
|
|
615
|
+
const evaluatedPriceExperimentIds = parseEvaluatedPriceExperimentIds(
|
|
616
|
+
token,
|
|
617
|
+
priceExperimentIds,
|
|
618
|
+
);
|
|
619
|
+
if (!evaluatedPriceExperimentIds) {
|
|
620
|
+
return {
|
|
621
|
+
completed: false,
|
|
622
|
+
cartWriteAllowed: !priceWriteRequiresProof && !offersWriteRequiresProof,
|
|
623
|
+
token: null,
|
|
624
|
+
assignments: input.assignments,
|
|
625
|
+
};
|
|
626
|
+
}
|
|
627
|
+
if (priceExperimentIds.size > 0) {
|
|
628
|
+
resolvedPriceAssignments = resolveAuthoritativePriceAssignments(
|
|
629
|
+
payload.assignments,
|
|
630
|
+
input.assignments,
|
|
631
|
+
evaluatedPriceExperimentIds,
|
|
632
|
+
);
|
|
633
|
+
if (!resolvedPriceAssignments) {
|
|
634
|
+
return {
|
|
635
|
+
completed: false,
|
|
636
|
+
cartWriteAllowed: !priceWriteRequiresProof && !offersWriteRequiresProof,
|
|
637
|
+
token: null,
|
|
638
|
+
assignments: input.assignments,
|
|
639
|
+
};
|
|
640
|
+
}
|
|
641
|
+
authorizedAssignments = resolvedPriceAssignments.assignments;
|
|
642
|
+
requiredAssignments = input.requiredAssignments?.flatMap((required) => {
|
|
643
|
+
if (!priceExperimentIds.has(required.experimentId)) return [required];
|
|
644
|
+
// A browser-only price experiment outside the Worker's evaluated set
|
|
645
|
+
// cannot be signed. Keep its local assignment/cart attribution, but do
|
|
646
|
+
// not demand impossible price proof: display eligibility is separately
|
|
647
|
+
// restricted to assignments actually present in the signed price plane.
|
|
648
|
+
if (!evaluatedPriceExperimentIds.has(required.experimentId)) return [];
|
|
649
|
+
const authoritative = resolvedPriceAssignments?.priceAssignments.find(
|
|
650
|
+
(assignment) => assignment.experimentId === required.experimentId,
|
|
651
|
+
);
|
|
652
|
+
return authoritative ? [authoritative] : [];
|
|
653
|
+
});
|
|
654
|
+
}
|
|
655
|
+
const requiredPriceAssignments = requiredAssignments?.filter((assignment) =>
|
|
656
|
+
priceExperimentIds.has(assignment.experimentId)
|
|
657
|
+
) ?? [];
|
|
658
|
+
const requiresNonPriceAssignment = requiredAssignments?.some((assignment) =>
|
|
659
|
+
!priceExperimentIds.has(assignment.experimentId)
|
|
660
|
+
);
|
|
661
|
+
const pricePlane = parsePriceAuthorizationPlane(token);
|
|
662
|
+
hasAuthoritativePriceProof =
|
|
663
|
+
resolvedPriceAssignments?.priceAssignments.length === 0
|
|
664
|
+
? hasSignedEmptyPriceAuthorizationEnvelope(token)
|
|
665
|
+
: !!pricePlane && hasSignedAssignmentProof(
|
|
666
|
+
pricePlane.a,
|
|
667
|
+
resolvedPriceAssignments?.priceAssignments ?? [],
|
|
668
|
+
);
|
|
669
|
+
// Round 17 permits an unsigned successful response to clear shipping proof
|
|
670
|
+
// because removing a shipping grant is privilege-reducing. Price differs:
|
|
671
|
+
// replacing a signed price pin changes what checkout charges, so an active
|
|
672
|
+
// price plane must have a signed assignment or signed-empty envelope before
|
|
673
|
+
// any combined cart-attribute write can touch the existing pin.
|
|
674
|
+
if (
|
|
675
|
+
(priceWriteRequiresProof && !hasAuthoritativePriceProof) ||
|
|
676
|
+
(requiredPriceAssignments.length > 0 &&
|
|
677
|
+
(!pricePlane || !hasSignedAssignmentProof(pricePlane.a, requiredPriceAssignments))) ||
|
|
678
|
+
(resolvedPriceAssignments?.adoptionRequired &&
|
|
679
|
+
!hasAuthoritativePriceProof) ||
|
|
680
|
+
(!token && requiresNonPriceAssignment)
|
|
681
|
+
) {
|
|
682
|
+
return {
|
|
683
|
+
completed: false,
|
|
684
|
+
cartWriteAllowed: !priceWriteRequiresProof && !offersWriteRequiresProof,
|
|
685
|
+
token: null,
|
|
686
|
+
assignments: input.assignments,
|
|
687
|
+
};
|
|
688
|
+
}
|
|
689
|
+
} else if (requiresCommerceAuthorization(input) && !token) {
|
|
690
|
+
const isLegitimateEmptyShippingResponse =
|
|
691
|
+
isUnsignedCommerceClearCandidate(input) &&
|
|
692
|
+
Array.isArray(payload.assignments) &&
|
|
693
|
+
(!!input.assignments[0] || payload.assignments.length === 0);
|
|
694
|
+
if (!isLegitimateEmptyShippingResponse) {
|
|
695
|
+
return {
|
|
696
|
+
completed: false,
|
|
697
|
+
cartWriteAllowed: !priceWriteRequiresProof && !offersWriteRequiresProof,
|
|
698
|
+
token: null,
|
|
699
|
+
assignments: input.assignments,
|
|
700
|
+
};
|
|
701
|
+
}
|
|
702
|
+
// Clearing a stale shipping discount grant is privilege-reducing and needs no
|
|
703
|
+
// signature; unlike conferring a grant, the successful parsed response is enough.
|
|
704
|
+
}
|
|
705
|
+
if (enforceOffersPlaneProof && configuredOffersExperimentIds.size > 0) {
|
|
706
|
+
resolvedOffersAssignments = resolveAuthoritativeOffersAssignments(
|
|
707
|
+
payload.assignments,
|
|
708
|
+
authorizedAssignments,
|
|
709
|
+
configuredOffersExperimentIds,
|
|
710
|
+
);
|
|
711
|
+
if (!resolvedOffersAssignments) {
|
|
712
|
+
return {
|
|
713
|
+
completed: false,
|
|
714
|
+
cartWriteAllowed: false,
|
|
715
|
+
token: null,
|
|
716
|
+
assignments: input.assignments,
|
|
717
|
+
};
|
|
718
|
+
}
|
|
719
|
+
authorizedAssignments = resolvedOffersAssignments.assignments;
|
|
720
|
+
requiredOffersAssignments = resolvedOffersAssignments.offersAssignments;
|
|
721
|
+
requiredAssignments = requiredAssignments?.flatMap((required) => {
|
|
722
|
+
if (!configuredOffersExperimentIds.has(required.experimentId)) return [required];
|
|
723
|
+
const authoritative = resolvedOffersAssignments?.offersAssignments.find(
|
|
724
|
+
(assignment) => assignment.experimentId === required.experimentId,
|
|
725
|
+
);
|
|
726
|
+
return authoritative ? [authoritative] : [];
|
|
727
|
+
});
|
|
728
|
+
}
|
|
729
|
+
if (requiredOffersAssignments.length > 0) {
|
|
730
|
+
const offersPlane = parseAuthorizationPlane(token, "offers");
|
|
731
|
+
const hasOffersProof = !!offersPlane && hasSignedAssignmentProof(
|
|
732
|
+
offersPlane.a,
|
|
733
|
+
requiredOffersAssignments,
|
|
734
|
+
);
|
|
735
|
+
if (!hasOffersProof) {
|
|
736
|
+
if (!priceWriteRequiresProof || !hasAuthoritativePriceProof) {
|
|
737
|
+
return {
|
|
738
|
+
completed: false,
|
|
739
|
+
cartWriteAllowed: false,
|
|
740
|
+
token: null,
|
|
741
|
+
assignments: input.assignments,
|
|
742
|
+
};
|
|
743
|
+
}
|
|
744
|
+
const configuredPriceExperimentIds = new Set(input.priceExperimentIds ?? []);
|
|
745
|
+
authorizedAssignments = authorizedAssignments.filter((assignment) =>
|
|
746
|
+
!configuredOffersExperimentIds.has(assignment.experimentId) ||
|
|
747
|
+
configuredPriceExperimentIds.has(assignment.experimentId)
|
|
748
|
+
);
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
if (requiredShippingAssignments.length > 0) {
|
|
752
|
+
const shippingPlane = parseAuthorizationPlane(token, "shipping");
|
|
753
|
+
const hasShippingProof = !!shippingPlane && hasSignedAssignmentProof(
|
|
754
|
+
shippingPlane.a,
|
|
755
|
+
requiredShippingAssignments,
|
|
756
|
+
);
|
|
757
|
+
if (!hasShippingProof) {
|
|
758
|
+
if (!priceWriteRequiresProof || !hasAuthoritativePriceProof) {
|
|
759
|
+
return {
|
|
760
|
+
completed: false,
|
|
761
|
+
cartWriteAllowed: false,
|
|
762
|
+
token: null,
|
|
763
|
+
assignments: input.assignments,
|
|
764
|
+
};
|
|
765
|
+
}
|
|
766
|
+
// A proven price plane may proceed independently. Remove shipping-only
|
|
767
|
+
// assignments from the stamp; assignments shared with price remain because
|
|
768
|
+
// checkout validates each plane from its own signature.
|
|
769
|
+
const configuredPriceExperimentIds = new Set(input.priceExperimentIds ?? []);
|
|
770
|
+
authorizedAssignments = authorizedAssignments.filter((assignment) =>
|
|
771
|
+
!configuredShippingExperimentIds.has(assignment.experimentId) ||
|
|
772
|
+
configuredPriceExperimentIds.has(assignment.experimentId)
|
|
773
|
+
);
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
if (!hasRequiredAssignmentProof(payload.assignments, requiredAssignments)) {
|
|
777
|
+
return {
|
|
778
|
+
completed: false,
|
|
779
|
+
cartWriteAllowed: !priceWriteRequiresProof && !offersWriteRequiresProof,
|
|
780
|
+
token: null,
|
|
781
|
+
assignments: input.assignments,
|
|
782
|
+
};
|
|
783
|
+
}
|
|
784
|
+
if (
|
|
785
|
+
resolvedPriceAssignments?.adoptionRequired &&
|
|
786
|
+
((generation !== undefined && generation !== latestSyncGeneration) ||
|
|
787
|
+
!input.adoptPriceAssignments?.(
|
|
788
|
+
authorizedAssignments.filter((assignment) =>
|
|
789
|
+
input.priceExperimentIds?.includes(assignment.experimentId)
|
|
790
|
+
),
|
|
791
|
+
))
|
|
792
|
+
) {
|
|
793
|
+
return {
|
|
794
|
+
completed: false,
|
|
795
|
+
cartWriteAllowed: !priceWriteRequiresProof && !offersWriteRequiresProof,
|
|
796
|
+
token: null,
|
|
797
|
+
assignments: input.assignments,
|
|
798
|
+
};
|
|
799
|
+
}
|
|
800
|
+
let offersAdoptionAssignments: CartAssignment[] | undefined;
|
|
801
|
+
if (resolvedOffersAssignments?.adoptionRequired) {
|
|
802
|
+
const adoptable = authorizedAssignments.filter((assignment) =>
|
|
803
|
+
configuredOffersExperimentIds.has(assignment.experimentId)
|
|
804
|
+
);
|
|
805
|
+
// Only validate feasibility here. Applying the adoption (DOM, persist,
|
|
806
|
+
// tracking) before the cart write is confirmed would let the shopper see
|
|
807
|
+
// and record a treatment Shopify never stamped an authorization for —
|
|
808
|
+
// the caller applies it post-write (theme) or post-confirm (headless).
|
|
809
|
+
if (
|
|
810
|
+
(generation !== undefined && generation !== latestSyncGeneration) ||
|
|
811
|
+
!input.canAdoptOffersAssignments?.(adoptable)
|
|
812
|
+
) {
|
|
813
|
+
return {
|
|
814
|
+
completed: false,
|
|
815
|
+
cartWriteAllowed: false,
|
|
816
|
+
token: null,
|
|
817
|
+
assignments: input.assignments,
|
|
818
|
+
};
|
|
819
|
+
}
|
|
820
|
+
offersAdoptionAssignments = adoptable;
|
|
821
|
+
}
|
|
822
|
+
return {
|
|
823
|
+
completed: true,
|
|
824
|
+
cartWriteAllowed: true,
|
|
825
|
+
token,
|
|
826
|
+
assignments: authorizedAssignments,
|
|
827
|
+
offersAdoptionAssignments,
|
|
828
|
+
};
|
|
829
|
+
} catch {
|
|
830
|
+
return {
|
|
831
|
+
completed: !requiresCommerceAuthorization(input),
|
|
832
|
+
cartWriteAllowed: !priceWriteRequiresProof && !offersWriteRequiresProof &&
|
|
833
|
+
!isUnsignedCommerceClearCandidate(input),
|
|
834
|
+
token: null,
|
|
835
|
+
assignments: input.assignments,
|
|
836
|
+
};
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
function hasSignedEmptyPriceAuthorizationEnvelope(token: string | null): boolean {
|
|
841
|
+
if (!token) return false;
|
|
842
|
+
try {
|
|
843
|
+
const envelope = JSON.parse(token) as Record<string, unknown> | null;
|
|
844
|
+
if (
|
|
845
|
+
!envelope ||
|
|
846
|
+
typeof envelope !== "object" ||
|
|
847
|
+
envelope.v !== 2 ||
|
|
848
|
+
!envelope.p ||
|
|
849
|
+
typeof envelope.p !== "object"
|
|
850
|
+
) return false;
|
|
851
|
+
const price = (envelope.p as Record<string, unknown>).price;
|
|
852
|
+
if (!price || typeof price !== "object") return false;
|
|
853
|
+
const plane = price as Record<string, unknown>;
|
|
854
|
+
return plane.v === 1 &&
|
|
855
|
+
Number.isSafeInteger(plane.r) && (plane.r as number) > 0 &&
|
|
856
|
+
typeof plane.c === "string" && /^[a-f0-9]{64}$/.test(plane.c) &&
|
|
857
|
+
typeof plane.x === "string" && /^\d{4}-\d{2}-\d{2}$/.test(plane.x) &&
|
|
858
|
+
Array.isArray(plane.a) && plane.a.length === 0 &&
|
|
859
|
+
typeof plane.cartHash === "string" && /^[a-f0-9]{64}$/.test(plane.cartHash) &&
|
|
860
|
+
typeof plane.s === "string" && /^[a-f0-9]{64}$/.test(plane.s);
|
|
861
|
+
} catch {
|
|
862
|
+
return false;
|
|
863
|
+
}
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
function parsePriceAuthorizationPlane(
|
|
867
|
+
token: string | null,
|
|
868
|
+
): Record<string, unknown> | null {
|
|
869
|
+
if (!token) return null;
|
|
870
|
+
try {
|
|
871
|
+
const candidate = JSON.parse(token) as Record<string, unknown> | null;
|
|
872
|
+
if (!candidate || typeof candidate !== "object") return null;
|
|
873
|
+
if (
|
|
874
|
+
candidate.v === 1 &&
|
|
875
|
+
typeof candidate.cartHash === "string" &&
|
|
876
|
+
candidate.cartHash
|
|
877
|
+
) {
|
|
878
|
+
return candidate;
|
|
879
|
+
}
|
|
880
|
+
if (candidate.v !== 2 || !candidate.p || typeof candidate.p !== "object") return null;
|
|
881
|
+
const price = (candidate.p as Record<string, unknown>).price;
|
|
882
|
+
return price && typeof price === "object"
|
|
883
|
+
? price as Record<string, unknown>
|
|
884
|
+
: null;
|
|
885
|
+
} catch {
|
|
886
|
+
return null;
|
|
887
|
+
}
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
function parseAuthorizationPlane(
|
|
891
|
+
token: string | null,
|
|
892
|
+
plane: "shipping" | "offers",
|
|
893
|
+
): Record<string, unknown> | null {
|
|
894
|
+
if (!token) return null;
|
|
895
|
+
try {
|
|
896
|
+
const candidate = JSON.parse(token) as Record<string, unknown> | null;
|
|
897
|
+
if (candidate?.v !== 2 || !candidate.p || typeof candidate.p !== "object") return null;
|
|
898
|
+
const value = (candidate.p as Record<string, unknown>)[plane];
|
|
899
|
+
return value && typeof value === "object"
|
|
900
|
+
? value as Record<string, unknown>
|
|
901
|
+
: null;
|
|
902
|
+
} catch {
|
|
903
|
+
return null;
|
|
904
|
+
}
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
function parseSignedPriceAuthorizationAssignments(
|
|
908
|
+
token: string | null,
|
|
909
|
+
configuredPriceExperimentIds: ReadonlySet<string>,
|
|
910
|
+
): CartAssignment[] | null {
|
|
911
|
+
const pricePlane = parsePriceAuthorizationPlane(token);
|
|
912
|
+
if (!pricePlane || !Array.isArray(pricePlane.a)) return null;
|
|
913
|
+
const assignments: CartAssignment[] = [];
|
|
914
|
+
const seen = new Set<string>();
|
|
915
|
+
for (const candidate of pricePlane.a) {
|
|
916
|
+
if (!candidate || typeof candidate !== "object") return null;
|
|
917
|
+
const assignment = candidate as Record<string, unknown>;
|
|
918
|
+
if (
|
|
919
|
+
typeof assignment.e !== "string" ||
|
|
920
|
+
!configuredPriceExperimentIds.has(assignment.e) ||
|
|
921
|
+
seen.has(assignment.e) ||
|
|
922
|
+
typeof assignment.v !== "string" ||
|
|
923
|
+
!assignment.v ||
|
|
924
|
+
!Number.isSafeInteger(assignment.ep) ||
|
|
925
|
+
(assignment.ep as number) < 1
|
|
926
|
+
) return null;
|
|
927
|
+
seen.add(assignment.e);
|
|
928
|
+
assignments.push({
|
|
929
|
+
experimentId: assignment.e,
|
|
930
|
+
variationId: assignment.v,
|
|
931
|
+
assignmentEpoch: assignment.ep as number,
|
|
932
|
+
});
|
|
933
|
+
}
|
|
934
|
+
return assignments;
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
function parseSignedShippingAuthorizationAssignments(
|
|
938
|
+
token: string | null,
|
|
939
|
+
configuredShippingExperimentIds: ReadonlySet<string>,
|
|
940
|
+
): CartAssignment[] | null {
|
|
941
|
+
const shippingPlane = parseAuthorizationPlane(token, "shipping");
|
|
942
|
+
if (!shippingPlane || !Array.isArray(shippingPlane.a)) return null;
|
|
943
|
+
const assignments: CartAssignment[] = [];
|
|
944
|
+
const seen = new Set<string>();
|
|
945
|
+
for (const candidate of shippingPlane.a) {
|
|
946
|
+
if (!candidate || typeof candidate !== "object") return null;
|
|
947
|
+
const assignment = candidate as Record<string, unknown>;
|
|
948
|
+
if (
|
|
949
|
+
typeof assignment.e !== "string" ||
|
|
950
|
+
!configuredShippingExperimentIds.has(assignment.e) ||
|
|
951
|
+
seen.has(assignment.e) ||
|
|
952
|
+
typeof assignment.v !== "string" ||
|
|
953
|
+
!assignment.v ||
|
|
954
|
+
!Number.isSafeInteger(assignment.ep) ||
|
|
955
|
+
(assignment.ep as number) < 1
|
|
956
|
+
) return null;
|
|
957
|
+
seen.add(assignment.e);
|
|
958
|
+
assignments.push({
|
|
959
|
+
experimentId: assignment.e,
|
|
960
|
+
variationId: assignment.v,
|
|
961
|
+
assignmentEpoch: assignment.ep as number,
|
|
962
|
+
});
|
|
963
|
+
}
|
|
964
|
+
return assignments;
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
function parseSignedOffersAuthorizationAssignments(
|
|
968
|
+
token: string | null,
|
|
969
|
+
configuredOffersExperimentIds: ReadonlySet<string>,
|
|
970
|
+
): CartAssignment[] | null {
|
|
971
|
+
const offersPlane = parseAuthorizationPlane(token, "offers");
|
|
972
|
+
if (!offersPlane || !Array.isArray(offersPlane.a)) return null;
|
|
973
|
+
const assignments: CartAssignment[] = [];
|
|
974
|
+
const seen = new Set<string>();
|
|
975
|
+
for (const candidate of offersPlane.a) {
|
|
976
|
+
if (!candidate || typeof candidate !== "object") return null;
|
|
977
|
+
const assignment = candidate as Record<string, unknown>;
|
|
978
|
+
if (
|
|
979
|
+
typeof assignment.e !== "string" ||
|
|
980
|
+
!configuredOffersExperimentIds.has(assignment.e) ||
|
|
981
|
+
seen.has(assignment.e) ||
|
|
982
|
+
typeof assignment.v !== "string" ||
|
|
983
|
+
!assignment.v ||
|
|
984
|
+
!Number.isSafeInteger(assignment.ep) ||
|
|
985
|
+
(assignment.ep as number) < 1
|
|
986
|
+
) return null;
|
|
987
|
+
seen.add(assignment.e);
|
|
988
|
+
assignments.push({
|
|
989
|
+
experimentId: assignment.e,
|
|
990
|
+
variationId: assignment.v,
|
|
991
|
+
assignmentEpoch: assignment.ep as number,
|
|
992
|
+
});
|
|
993
|
+
}
|
|
994
|
+
return assignments;
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
function parseEvaluatedPriceExperimentIds(
|
|
998
|
+
token: string | null,
|
|
999
|
+
configuredPriceExperimentIds: ReadonlySet<string>,
|
|
1000
|
+
): Set<string> | null {
|
|
1001
|
+
const rolloutFallback = () => new Set(configuredPriceExperimentIds);
|
|
1002
|
+
if (!token) return rolloutFallback();
|
|
1003
|
+
try {
|
|
1004
|
+
const envelope = JSON.parse(token) as Record<string, unknown> | null;
|
|
1005
|
+
if (!envelope || typeof envelope !== "object" || envelope.v !== 2) {
|
|
1006
|
+
return rolloutFallback();
|
|
1007
|
+
}
|
|
1008
|
+
if (!Object.prototype.hasOwnProperty.call(envelope, "e")) {
|
|
1009
|
+
// Rollout ordering matters: deploy the Worker before this SDK. Until every
|
|
1010
|
+
// response declares `e`, an older V2 envelope must retain round-14 behavior
|
|
1011
|
+
// and treat every configured price experiment as evaluated.
|
|
1012
|
+
return rolloutFallback();
|
|
1013
|
+
}
|
|
1014
|
+
if (!Array.isArray(envelope.e)) return null;
|
|
1015
|
+
const evaluated = new Set<string>();
|
|
1016
|
+
for (const experimentId of envelope.e) {
|
|
1017
|
+
if (
|
|
1018
|
+
typeof experimentId !== "string" ||
|
|
1019
|
+
!experimentId ||
|
|
1020
|
+
!configuredPriceExperimentIds.has(experimentId) ||
|
|
1021
|
+
evaluated.has(experimentId)
|
|
1022
|
+
) return null;
|
|
1023
|
+
evaluated.add(experimentId);
|
|
1024
|
+
}
|
|
1025
|
+
return evaluated;
|
|
1026
|
+
} catch {
|
|
1027
|
+
return rolloutFallback();
|
|
1028
|
+
}
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
function hasSignedAssignmentProof(
|
|
1032
|
+
signedAssignments: unknown,
|
|
1033
|
+
requiredAssignments: NonNullable<CartAttributeSyncInput["requiredAssignments"]>,
|
|
1034
|
+
): boolean {
|
|
1035
|
+
if (!Array.isArray(signedAssignments)) return false;
|
|
1036
|
+
return requiredAssignments.every((required) =>
|
|
1037
|
+
signedAssignments.some((candidate) => {
|
|
1038
|
+
if (!candidate || typeof candidate !== "object") return false;
|
|
1039
|
+
const assignment = candidate as Record<string, unknown>;
|
|
1040
|
+
return assignment.e === required.experimentId &&
|
|
1041
|
+
assignment.v === required.variationId &&
|
|
1042
|
+
assignment.ep === required.assignmentEpoch;
|
|
1043
|
+
})
|
|
1044
|
+
);
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
function parseResponsePriceAssignments(
|
|
1048
|
+
responseAssignments: unknown,
|
|
1049
|
+
priceExperimentIds: ReadonlySet<string>,
|
|
1050
|
+
): CartAssignment[] | null {
|
|
1051
|
+
if (!Array.isArray(responseAssignments)) return null;
|
|
1052
|
+
const assignments: CartAssignment[] = [];
|
|
1053
|
+
const seen = new Set<string>();
|
|
1054
|
+
for (const candidate of responseAssignments) {
|
|
1055
|
+
if (!candidate || typeof candidate !== "object") continue;
|
|
1056
|
+
const assignment = candidate as Record<string, unknown>;
|
|
1057
|
+
if (
|
|
1058
|
+
typeof assignment.experiment_id !== "string" ||
|
|
1059
|
+
!priceExperimentIds.has(assignment.experiment_id)
|
|
1060
|
+
) {
|
|
1061
|
+
continue;
|
|
1062
|
+
}
|
|
1063
|
+
if (
|
|
1064
|
+
seen.has(assignment.experiment_id) ||
|
|
1065
|
+
typeof assignment.variation_id !== "string" ||
|
|
1066
|
+
!assignment.variation_id ||
|
|
1067
|
+
!Number.isSafeInteger(assignment.assignment_epoch) ||
|
|
1068
|
+
(assignment.assignment_epoch as number) < 1
|
|
1069
|
+
) return null;
|
|
1070
|
+
seen.add(assignment.experiment_id);
|
|
1071
|
+
assignments.push({
|
|
1072
|
+
experimentId: assignment.experiment_id,
|
|
1073
|
+
variationId: assignment.variation_id,
|
|
1074
|
+
assignmentEpoch: assignment.assignment_epoch as number,
|
|
1075
|
+
...(assignment.commerce_price_only === true ? { commercePriceOnly: true } : {}),
|
|
1076
|
+
});
|
|
1077
|
+
}
|
|
1078
|
+
return assignments;
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
function resolveAuthoritativePriceAssignments(
|
|
1082
|
+
responseAssignments: unknown,
|
|
1083
|
+
localAssignments: CartAttributeSyncInput["assignments"],
|
|
1084
|
+
evaluatedPriceExperimentIds: ReadonlySet<string>,
|
|
1085
|
+
): {
|
|
1086
|
+
assignments: CartAssignment[];
|
|
1087
|
+
priceAssignments: CartAssignment[];
|
|
1088
|
+
adoptionRequired: boolean;
|
|
1089
|
+
} | null {
|
|
1090
|
+
const priceAssignments = parseResponsePriceAssignments(
|
|
1091
|
+
responseAssignments,
|
|
1092
|
+
evaluatedPriceExperimentIds,
|
|
1093
|
+
);
|
|
1094
|
+
if (!priceAssignments) return null;
|
|
1095
|
+
const localPriceAssignments = localAssignments.filter((assignment) =>
|
|
1096
|
+
evaluatedPriceExperimentIds.has(assignment.experimentId)
|
|
1097
|
+
);
|
|
1098
|
+
const authoritativeByExperimentId = new Map(
|
|
1099
|
+
priceAssignments.map((assignment) => [assignment.experimentId, assignment]),
|
|
1100
|
+
);
|
|
1101
|
+
const adoptionRequired = localPriceAssignments.some(
|
|
1102
|
+
(assignment) => !authoritativeByExperimentId.has(assignment.experimentId),
|
|
1103
|
+
) || priceAssignments.some((assignment) => {
|
|
1104
|
+
const local = localPriceAssignments.find(
|
|
1105
|
+
(candidate) => candidate.experimentId === assignment.experimentId,
|
|
1106
|
+
);
|
|
1107
|
+
return local?.variationId !== assignment.variationId ||
|
|
1108
|
+
local.assignmentEpoch !== assignment.assignmentEpoch ||
|
|
1109
|
+
local.commercePriceOnly !== assignment.commercePriceOnly;
|
|
1110
|
+
});
|
|
1111
|
+
const remainingPriceAssignments = new Map(authoritativeByExperimentId);
|
|
1112
|
+
const assignments = localAssignments.map((assignment) => {
|
|
1113
|
+
if (!evaluatedPriceExperimentIds.has(assignment.experimentId)) return assignment;
|
|
1114
|
+
const authoritative = remainingPriceAssignments.get(assignment.experimentId);
|
|
1115
|
+
if (!authoritative) return null;
|
|
1116
|
+
remainingPriceAssignments.delete(assignment.experimentId);
|
|
1117
|
+
return authoritative;
|
|
1118
|
+
}).filter((assignment): assignment is CartAssignment => assignment !== null);
|
|
1119
|
+
for (const assignment of priceAssignments) {
|
|
1120
|
+
if (remainingPriceAssignments.has(assignment.experimentId)) {
|
|
1121
|
+
assignments.push(assignment);
|
|
1122
|
+
remainingPriceAssignments.delete(assignment.experimentId);
|
|
1123
|
+
}
|
|
1124
|
+
}
|
|
1125
|
+
return { assignments, priceAssignments, adoptionRequired };
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1128
|
+
function resolveAuthoritativeOffersAssignments(
|
|
1129
|
+
responseAssignments: unknown,
|
|
1130
|
+
localAssignments: CartAttributeSyncInput["assignments"],
|
|
1131
|
+
configuredOffersExperimentIds: ReadonlySet<string>,
|
|
1132
|
+
): {
|
|
1133
|
+
assignments: CartAssignment[];
|
|
1134
|
+
offersAssignments: CartAssignment[];
|
|
1135
|
+
adoptionRequired: boolean;
|
|
1136
|
+
} | null {
|
|
1137
|
+
const offersAssignments = parseResponsePriceAssignments(
|
|
1138
|
+
responseAssignments,
|
|
1139
|
+
configuredOffersExperimentIds,
|
|
1140
|
+
);
|
|
1141
|
+
if (!offersAssignments) return null;
|
|
1142
|
+
const resolved = resolveAuthoritativePriceAssignments(
|
|
1143
|
+
responseAssignments,
|
|
1144
|
+
localAssignments,
|
|
1145
|
+
configuredOffersExperimentIds,
|
|
1146
|
+
);
|
|
1147
|
+
return resolved && {
|
|
1148
|
+
assignments: resolved.assignments,
|
|
1149
|
+
offersAssignments,
|
|
1150
|
+
adoptionRequired: resolved.adoptionRequired,
|
|
1151
|
+
};
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1154
|
+
export async function buildSignedCartAttributeEntries(
|
|
1155
|
+
input: CartAttributeSyncInput,
|
|
1156
|
+
): Promise<CartAttributeEntry[]> {
|
|
1157
|
+
const generation = ++latestSyncGeneration;
|
|
1158
|
+
const authorization = await requestCommerceAuthorization(input, generation);
|
|
1159
|
+
if (generation !== latestSyncGeneration || !authorization.completed) return [];
|
|
1160
|
+
if (authorization.offersAdoptionAssignments) {
|
|
1161
|
+
// Headless integrators own the cart write; hand the validated adoption to
|
|
1162
|
+
// the caller so it applies only after confirmCartAttributesWritten.
|
|
1163
|
+
input.stageOffersAdoptionAssignments?.(authorization.offersAdoptionAssignments);
|
|
1164
|
+
}
|
|
1165
|
+
if (hasActivePriceAuthorizationPlane(input)) {
|
|
1166
|
+
const authorizedPriceAssignments = parseSignedPriceAuthorizationAssignments(
|
|
1167
|
+
authorization.token,
|
|
1168
|
+
new Set(input.priceExperimentIds ?? []),
|
|
1169
|
+
);
|
|
1170
|
+
if (!authorizedPriceAssignments) return [];
|
|
1171
|
+
input.authorizePriceDisplayAssignments?.(authorizedPriceAssignments);
|
|
1172
|
+
}
|
|
1173
|
+
if (hasActiveShippingAuthorizationPlane(input)) {
|
|
1174
|
+
const authorizedShippingAssignments = parseSignedShippingAuthorizationAssignments(
|
|
1175
|
+
authorization.token,
|
|
1176
|
+
shippingExperimentIds(input),
|
|
1177
|
+
) ?? [];
|
|
1178
|
+
input.authorizeShippingDisplayAssignments?.(authorizedShippingAssignments);
|
|
1179
|
+
}
|
|
1180
|
+
if (hasActiveOffersAuthorizationPlane(input)) {
|
|
1181
|
+
const authorizedOffersAssignments = parseSignedOffersAuthorizationAssignments(
|
|
1182
|
+
authorization.token,
|
|
1183
|
+
offersExperimentIds(input),
|
|
1184
|
+
) ?? [];
|
|
1185
|
+
input.authorizeOffersDisplayAssignments?.(authorizedOffersAssignments);
|
|
1186
|
+
}
|
|
1187
|
+
return buildCartAttributeEntries(
|
|
1188
|
+
{ ...input, assignments: authorization.assignments },
|
|
1189
|
+
authorization.token,
|
|
1190
|
+
);
|
|
1191
|
+
}
|
|
1192
|
+
|
|
1193
|
+
function hasRequiredAssignmentProof(
|
|
1194
|
+
responseAssignments: unknown,
|
|
1195
|
+
requiredAssignments: CartAttributeSyncInput["requiredAssignments"],
|
|
1196
|
+
): boolean {
|
|
1197
|
+
if (!requiredAssignments?.length) return true;
|
|
1198
|
+
if (!Array.isArray(responseAssignments)) return false;
|
|
1199
|
+
|
|
1200
|
+
return requiredAssignments.every((required) => {
|
|
1201
|
+
if (!Number.isInteger(required.assignmentEpoch) || (required.assignmentEpoch ?? 0) < 1) {
|
|
1202
|
+
return false;
|
|
1203
|
+
}
|
|
1204
|
+
return responseAssignments.some((candidate) => {
|
|
1205
|
+
if (!candidate || typeof candidate !== "object") return false;
|
|
1206
|
+
const assignment = candidate as Record<string, unknown>;
|
|
1207
|
+
return assignment.experiment_id === required.experimentId
|
|
1208
|
+
&& assignment.variation_id === required.variationId
|
|
1209
|
+
&& assignment.assignment_epoch === required.assignmentEpoch;
|
|
1210
|
+
});
|
|
1211
|
+
});
|
|
1212
|
+
}
|
|
1213
|
+
|
|
1214
|
+
export function buildCartAttributeEntries(
|
|
1215
|
+
input: CartAttributeSyncInput,
|
|
1216
|
+
commerceAuthorization: string | null = null,
|
|
1217
|
+
): CartAttributeEntry[] {
|
|
1218
|
+
const primaryAssignment = input.assignments[0];
|
|
1219
|
+
const entries: CartAttributeEntry[] = [
|
|
1220
|
+
{
|
|
1221
|
+
key: "apex_assignments",
|
|
1222
|
+
value: JSON.stringify(
|
|
1223
|
+
input.assignments.map((assignment) => ({
|
|
1224
|
+
e: assignment.experimentId,
|
|
1225
|
+
v: assignment.variationId,
|
|
1226
|
+
ep: assignment.assignmentEpoch,
|
|
1227
|
+
})),
|
|
1228
|
+
),
|
|
1229
|
+
},
|
|
1230
|
+
{
|
|
1231
|
+
key: "apex_assignment_authorization",
|
|
1232
|
+
value: commerceAuthorization ?? "",
|
|
1233
|
+
},
|
|
1234
|
+
];
|
|
1235
|
+
if (!primaryAssignment) {
|
|
1236
|
+
return [
|
|
1237
|
+
...entries,
|
|
1238
|
+
{ key: "apex_experiment_id", value: "" },
|
|
1239
|
+
{ key: "apex_variation_id", value: "" },
|
|
1240
|
+
{ key: "apex_assignment_epoch", value: "" },
|
|
1241
|
+
{ key: "apex_visitor_id", value: input.visitorId },
|
|
1242
|
+
{ key: "apex_session_id", value: input.sessionId },
|
|
1243
|
+
];
|
|
1244
|
+
}
|
|
1245
|
+
return [
|
|
1246
|
+
...entries,
|
|
1247
|
+
{ key: "apex_experiment_id", value: primaryAssignment.experimentId },
|
|
1248
|
+
{ key: "apex_variation_id", value: primaryAssignment.variationId },
|
|
1249
|
+
{
|
|
1250
|
+
key: "apex_assignment_epoch",
|
|
1251
|
+
value: String(primaryAssignment.assignmentEpoch ?? ""),
|
|
1252
|
+
},
|
|
1253
|
+
{ key: "apex_visitor_id", value: input.visitorId },
|
|
1254
|
+
{ key: "apex_session_id", value: input.sessionId },
|
|
1255
|
+
];
|
|
1256
|
+
}
|