@decocms/apps-vtex 7.2.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/package.json +67 -0
- package/src/README.md +6 -0
- package/src/__tests__/client-segment-cookie.test.ts +255 -0
- package/src/__tests__/client-set-cookie-forward.test.ts +257 -0
- package/src/actions/address.ts +250 -0
- package/src/actions/analytics/sendEvent.ts +86 -0
- package/src/actions/auth.ts +333 -0
- package/src/actions/checkout.ts +522 -0
- package/src/actions/index.ts +11 -0
- package/src/actions/masterData.ts +168 -0
- package/src/actions/misc.ts +188 -0
- package/src/actions/newsletter.ts +105 -0
- package/src/actions/orders.ts +34 -0
- package/src/actions/profile.ts +201 -0
- package/src/actions/session.ts +85 -0
- package/src/actions/trigger.ts +43 -0
- package/src/actions/wishlist.ts +114 -0
- package/src/client.ts +667 -0
- package/src/commerceLoaders.ts +261 -0
- package/src/hooks/__tests__/createUseCart.test.ts +184 -0
- package/src/hooks/__tests__/createUseUser.test.ts +48 -0
- package/src/hooks/__tests__/createUseWishlist.test.ts +87 -0
- package/src/hooks/createUseCart.ts +360 -0
- package/src/hooks/createUseUser.ts +153 -0
- package/src/hooks/createUseWishlist.ts +242 -0
- package/src/hooks/index.ts +19 -0
- package/src/hooks/useAutocomplete.ts +83 -0
- package/src/hooks/useCart.ts +243 -0
- package/src/hooks/useUser.ts +78 -0
- package/src/hooks/useWishlist.ts +119 -0
- package/src/index.ts +17 -0
- package/src/invoke.ts +181 -0
- package/src/loaders/ProductDetailsPage.ts +1 -0
- package/src/loaders/ProductList.ts +1 -0
- package/src/loaders/ProductListingPage.ts +1 -0
- package/src/loaders/address.ts +115 -0
- package/src/loaders/autocomplete.ts +58 -0
- package/src/loaders/brands.ts +44 -0
- package/src/loaders/cart.ts +52 -0
- package/src/loaders/catalog.ts +163 -0
- package/src/loaders/collections.ts +55 -0
- package/src/loaders/index.ts +19 -0
- package/src/loaders/intelligentSearch/__tests__/productListingPage.test.ts +20 -0
- package/src/loaders/intelligentSearch/productDetailsPage.ts +95 -0
- package/src/loaders/intelligentSearch/productList.ts +154 -0
- package/src/loaders/intelligentSearch/productListingPage.ts +506 -0
- package/src/loaders/intelligentSearch/suggestions.ts +46 -0
- package/src/loaders/legacy/productDetailsPage.ts +1 -0
- package/src/loaders/legacy/productList.ts +1 -0
- package/src/loaders/legacy/relatedProductsLoader.ts +81 -0
- package/src/loaders/legacy.ts +635 -0
- package/src/loaders/logistics.ts +111 -0
- package/src/loaders/minicart.ts +78 -0
- package/src/loaders/navbar.ts +26 -0
- package/src/loaders/orders.ts +92 -0
- package/src/loaders/pageType.ts +68 -0
- package/src/loaders/payment.ts +97 -0
- package/src/loaders/productListFull.ts +159 -0
- package/src/loaders/profile.ts +138 -0
- package/src/loaders/promotion.ts +30 -0
- package/src/loaders/search.ts +124 -0
- package/src/loaders/session.ts +83 -0
- package/src/loaders/user.ts +87 -0
- package/src/loaders/wishlist.ts +89 -0
- package/src/loaders/wishlistProducts.ts +69 -0
- package/src/loaders/workflow/products.ts +64 -0
- package/src/loaders/workflow.ts +316 -0
- package/src/logo.png +0 -0
- package/src/middleware.ts +240 -0
- package/src/mod.ts +152 -0
- package/src/registry.ts +9 -0
- package/src/types.ts +248 -0
- package/src/utils/__tests__/cookieSanitizer.test.ts +162 -0
- package/src/utils/__tests__/fetch.test.ts +80 -0
- package/src/utils/__tests__/fetchCache.test.ts +112 -0
- package/src/utils/__tests__/instrumentedFetch.test.ts +158 -0
- package/src/utils/__tests__/intelligentSearch.test.ts +88 -0
- package/src/utils/__tests__/minicart.test.ts +184 -0
- package/src/utils/__tests__/operationRouter.test.ts +227 -0
- package/src/utils/__tests__/resourceRange.test.ts +32 -0
- package/src/utils/__tests__/sitemap.test.ts +185 -0
- package/src/utils/__tests__/slugify.test.ts +31 -0
- package/src/utils/__tests__/transform.test.ts +698 -0
- package/src/utils/accountLoaders.ts +203 -0
- package/src/utils/authHelpers.ts +85 -0
- package/src/utils/batch.ts +18 -0
- package/src/utils/cookieSanitizer.ts +173 -0
- package/src/utils/cookies.ts +167 -0
- package/src/utils/enrichment.ts +560 -0
- package/src/utils/fetch.ts +107 -0
- package/src/utils/fetchCache.ts +222 -0
- package/src/utils/index.ts +19 -0
- package/src/utils/instrumentedFetch.ts +78 -0
- package/src/utils/intelligentSearch.ts +96 -0
- package/src/utils/legacy.ts +139 -0
- package/src/utils/minicart.ts +139 -0
- package/src/utils/operationRouter.ts +139 -0
- package/src/utils/pickAndOmit.ts +25 -0
- package/src/utils/proxy.ts +435 -0
- package/src/utils/resourceRange.ts +10 -0
- package/src/utils/segment.ts +152 -0
- package/src/utils/similars.ts +37 -0
- package/src/utils/sitemap.ts +282 -0
- package/src/utils/slugCache.ts +28 -0
- package/src/utils/slugify.ts +13 -0
- package/src/utils/transform.ts +1509 -0
- package/src/utils/types.ts +1884 -0
- package/src/utils/vtexId.ts +138 -0
- package/tsconfig.json +7 -0
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pre-built section loaders for VTEX account pages.
|
|
3
|
+
*
|
|
4
|
+
* Every VTEX site with account pages repeats the same pattern:
|
|
5
|
+
* 1. Extract VTEX cookies from request
|
|
6
|
+
* 2. Call the VTEX user/profile/address/payment API
|
|
7
|
+
* 3. Return enriched props with { device, logged, ...data }
|
|
8
|
+
* 4. Catch errors gracefully (return logged: false)
|
|
9
|
+
*
|
|
10
|
+
* These factories encapsulate that boilerplate.
|
|
11
|
+
*
|
|
12
|
+
* @example
|
|
13
|
+
* ```ts
|
|
14
|
+
* import { vtexAccountLoaders } from "@decocms/apps/vtex/utils/accountLoaders";
|
|
15
|
+
*
|
|
16
|
+
* registerSectionLoaders({
|
|
17
|
+
* "site/sections/Account/PersonalData.tsx": vtexAccountLoaders.personalData(),
|
|
18
|
+
* "site/sections/Account/MyOrders.tsx": vtexAccountLoaders.orders(),
|
|
19
|
+
* "site/sections/Account/Cards.tsx": vtexAccountLoaders.cards(),
|
|
20
|
+
* "site/sections/Account/Addresses.tsx": vtexAccountLoaders.addresses(),
|
|
21
|
+
* "site/sections/Account/Auth.tsx": vtexAccountLoaders.authentication(),
|
|
22
|
+
* "site/sections/Account/Other.tsx": vtexAccountLoaders.loggedIn(),
|
|
23
|
+
* });
|
|
24
|
+
* ```
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { detectDevice } from "@decocms/blocks/sdk/useDevice";
|
|
28
|
+
import { getUserAddresses, type VtexAddress } from "../loaders/address";
|
|
29
|
+
import { getUserPayments, type Payment } from "../loaders/payment";
|
|
30
|
+
import { getCurrentProfile, type Profile } from "../loaders/profile";
|
|
31
|
+
import { getUser } from "../loaders/user";
|
|
32
|
+
import { getVtexCookies } from "./cookies";
|
|
33
|
+
|
|
34
|
+
type Device = "mobile" | "tablet" | "desktop";
|
|
35
|
+
|
|
36
|
+
type SectionLoaderFn = (
|
|
37
|
+
props: Record<string, unknown>,
|
|
38
|
+
req: Request,
|
|
39
|
+
) => Promise<Record<string, unknown>> | Record<string, unknown>;
|
|
40
|
+
|
|
41
|
+
function getDevice(req: Request): Device {
|
|
42
|
+
return detectDevice(req.headers.get("user-agent") ?? "");
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// ---------------------------------------------------------------------------
|
|
46
|
+
// personalData — fetches full VTEX profile for personal data sections
|
|
47
|
+
// ---------------------------------------------------------------------------
|
|
48
|
+
|
|
49
|
+
export interface PersonalDataOptions {
|
|
50
|
+
/** Extra custom profile fields to request beyond the standard set. */
|
|
51
|
+
extraProfileFields?: string[];
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Transform the raw VTEX Profile into the shape your component expects.
|
|
55
|
+
* When omitted, the raw Profile object is returned as `profile`.
|
|
56
|
+
*
|
|
57
|
+
* @example
|
|
58
|
+
* ```ts
|
|
59
|
+
* vtexAccountLoaders.personalData({
|
|
60
|
+
* mapProfile: (p) => ({
|
|
61
|
+
* "@id": p.userId ?? p.id,
|
|
62
|
+
* email: p.email,
|
|
63
|
+
* givenName: p.firstName ?? null,
|
|
64
|
+
* familyName: p.lastName ?? null,
|
|
65
|
+
* taxID: p.document,
|
|
66
|
+
* }),
|
|
67
|
+
* })
|
|
68
|
+
* ```
|
|
69
|
+
*/
|
|
70
|
+
mapProfile?: (profile: Profile) => Record<string, unknown>;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function personalData(options?: PersonalDataOptions): SectionLoaderFn {
|
|
74
|
+
const { extraProfileFields, mapProfile } = options ?? {};
|
|
75
|
+
return async (props, req) => {
|
|
76
|
+
const cookie = getVtexCookies(req);
|
|
77
|
+
try {
|
|
78
|
+
const profile = await getCurrentProfile(cookie, extraProfileFields);
|
|
79
|
+
const data = mapProfile ? mapProfile(profile) : profile;
|
|
80
|
+
return {
|
|
81
|
+
...props,
|
|
82
|
+
device: getDevice(req),
|
|
83
|
+
logged: !!profile,
|
|
84
|
+
loading: false,
|
|
85
|
+
userData: data,
|
|
86
|
+
};
|
|
87
|
+
} catch (error) {
|
|
88
|
+
console.error("[accountLoaders.personalData]", error);
|
|
89
|
+
return { ...props, device: getDevice(req), logged: false, loading: false, userData: null };
|
|
90
|
+
}
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// ---------------------------------------------------------------------------
|
|
95
|
+
// orders — checks login status for order listing sections
|
|
96
|
+
// ---------------------------------------------------------------------------
|
|
97
|
+
|
|
98
|
+
function orders(): SectionLoaderFn {
|
|
99
|
+
return async (props, req) => {
|
|
100
|
+
const cookie = getVtexCookies(req);
|
|
101
|
+
try {
|
|
102
|
+
const userData = await getUser(cookie);
|
|
103
|
+
return { ...props, device: getDevice(req), logged: !!userData };
|
|
104
|
+
} catch {
|
|
105
|
+
return { ...props, device: getDevice(req), logged: false };
|
|
106
|
+
}
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// ---------------------------------------------------------------------------
|
|
111
|
+
// cards — fetches saved payment tokens for card management sections
|
|
112
|
+
// ---------------------------------------------------------------------------
|
|
113
|
+
|
|
114
|
+
function cards(): SectionLoaderFn {
|
|
115
|
+
return async (props, req) => {
|
|
116
|
+
const cookie = getVtexCookies(req);
|
|
117
|
+
try {
|
|
118
|
+
const user = await getUser(cookie);
|
|
119
|
+
const logged = !!user;
|
|
120
|
+
let payments: Payment[] = [];
|
|
121
|
+
if (logged) {
|
|
122
|
+
try {
|
|
123
|
+
payments = (await getUserPayments(cookie)) ?? [];
|
|
124
|
+
} catch {
|
|
125
|
+
payments = [];
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
return { ...props, logged, payments };
|
|
129
|
+
} catch {
|
|
130
|
+
return { ...props, logged: false, payments: [] };
|
|
131
|
+
}
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// ---------------------------------------------------------------------------
|
|
136
|
+
// addresses — fetches address list for address management sections
|
|
137
|
+
// ---------------------------------------------------------------------------
|
|
138
|
+
|
|
139
|
+
function addresses(): SectionLoaderFn {
|
|
140
|
+
return async (props, req) => {
|
|
141
|
+
const cookie = getVtexCookies(req);
|
|
142
|
+
try {
|
|
143
|
+
const userData = await getUser(cookie);
|
|
144
|
+
const logged = !!userData;
|
|
145
|
+
let userAddressData: VtexAddress[] | null = null;
|
|
146
|
+
if (logged) {
|
|
147
|
+
try {
|
|
148
|
+
userAddressData = await getUserAddresses(cookie);
|
|
149
|
+
} catch {
|
|
150
|
+
userAddressData = null;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
return { ...props, device: getDevice(req), logged, userAddressData };
|
|
154
|
+
} catch {
|
|
155
|
+
return { ...props, device: getDevice(req), logged: false, userAddressData: null };
|
|
156
|
+
}
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// ---------------------------------------------------------------------------
|
|
161
|
+
// authentication — checks login + returns user data for auth pages
|
|
162
|
+
// ---------------------------------------------------------------------------
|
|
163
|
+
|
|
164
|
+
function authentication(): SectionLoaderFn {
|
|
165
|
+
return async (props, req) => {
|
|
166
|
+
const cookie = getVtexCookies(req);
|
|
167
|
+
try {
|
|
168
|
+
const userData = await getUser(cookie);
|
|
169
|
+
return { ...props, device: getDevice(req), logged: !!userData, userData };
|
|
170
|
+
} catch {
|
|
171
|
+
return { ...props, device: getDevice(req), logged: false, userData: null };
|
|
172
|
+
}
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// ---------------------------------------------------------------------------
|
|
177
|
+
// loggedIn — generic "is the user logged in?" loader
|
|
178
|
+
// ---------------------------------------------------------------------------
|
|
179
|
+
|
|
180
|
+
function loggedIn(): SectionLoaderFn {
|
|
181
|
+
return async (props, req) => {
|
|
182
|
+
const cookie = getVtexCookies(req);
|
|
183
|
+
try {
|
|
184
|
+
const userData = await getUser(cookie);
|
|
185
|
+
return { ...props, device: getDevice(req), logged: !!userData };
|
|
186
|
+
} catch {
|
|
187
|
+
return { ...props, device: getDevice(req), logged: false };
|
|
188
|
+
}
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// ---------------------------------------------------------------------------
|
|
193
|
+
// Public API
|
|
194
|
+
// ---------------------------------------------------------------------------
|
|
195
|
+
|
|
196
|
+
export const vtexAccountLoaders = {
|
|
197
|
+
personalData,
|
|
198
|
+
orders,
|
|
199
|
+
cards,
|
|
200
|
+
addresses,
|
|
201
|
+
authentication,
|
|
202
|
+
loggedIn,
|
|
203
|
+
} as const;
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* VTEX auth helpers — pure functions for cookie extraction, JWT parsing,
|
|
3
|
+
* Set-Cookie forwarding, and logout.
|
|
4
|
+
*
|
|
5
|
+
* These are consumed by site-level createServerFn wrappers in invoke.ts.
|
|
6
|
+
* createServerFn itself must live in site source (not node_modules) because
|
|
7
|
+
* TanStack Start's Vite plugin only transforms source files.
|
|
8
|
+
*/
|
|
9
|
+
import { getVtexConfig, getVtexFetch } from "../client";
|
|
10
|
+
import { extractVtexCookies } from "./cookieSanitizer";
|
|
11
|
+
|
|
12
|
+
const DOMAIN_RE = /;\s*domain=[^;]*/gi;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Extract VTEX-relevant cookies from a raw Cookie header string.
|
|
16
|
+
*
|
|
17
|
+
* Strict allowlist: drops any cookie not on `VTEX_COOKIE_PREFIXES`, plus
|
|
18
|
+
* any cookie whose value contains non-ASCII bytes (which would otherwise
|
|
19
|
+
* make VTEX's janus gateway return 503 Service Unavailable). Both filters
|
|
20
|
+
* live in `./cookieSanitizer` — this is a thin compatibility wrapper.
|
|
21
|
+
*/
|
|
22
|
+
export function extractVtexCookiesFromHeader(raw: string): string {
|
|
23
|
+
return extractVtexCookies(raw);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Strip Domain= from Set-Cookie headers so cookies are associated
|
|
28
|
+
* with the storefront domain instead of the VTEX domain.
|
|
29
|
+
*/
|
|
30
|
+
export function stripCookieDomain(cookies: string[]): string[] {
|
|
31
|
+
return cookies.map((c) => c.replace(DOMAIN_RE, ""));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Standard VTEX cookies to expire on logout. */
|
|
35
|
+
export const VTEX_LOGOUT_COOKIES = [
|
|
36
|
+
"checkout.vtex.com=; Path=/; Max-Age=0; Secure; HttpOnly; SameSite=Lax",
|
|
37
|
+
"CheckoutOrderFormOwnership=; Path=/; Max-Age=0; Secure; HttpOnly; SameSite=Lax",
|
|
38
|
+
"checkout.vtex.com__orderFormId=; Path=/; Max-Age=0",
|
|
39
|
+
"vtex_session=; Path=/; Max-Age=0",
|
|
40
|
+
"vtex_segment=; Path=/; Max-Age=0",
|
|
41
|
+
];
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Perform VTEX logout — calls the VTEX ID logout endpoint and returns
|
|
45
|
+
* the Set-Cookie headers (with domain stripped) to expire auth cookies.
|
|
46
|
+
*/
|
|
47
|
+
export async function performVtexLogout(cookies: string): Promise<{ setCookies: string[] }> {
|
|
48
|
+
const config = getVtexConfig();
|
|
49
|
+
const domain = config.domain ?? "com.br";
|
|
50
|
+
const logoutUrl = `https://${config.account}.vtexcommercestable.${domain}/api/vtexid/pub/logout?scope=${config.account}&returnUrl=/`;
|
|
51
|
+
|
|
52
|
+
const res = await getVtexFetch()(logoutUrl, {
|
|
53
|
+
method: "GET",
|
|
54
|
+
headers: { cookie: cookies },
|
|
55
|
+
redirect: "manual",
|
|
56
|
+
operation: "vtexid.logout",
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
const upstreamCookies = res.headers.getSetCookie?.() ?? [];
|
|
60
|
+
|
|
61
|
+
return {
|
|
62
|
+
setCookies: [...stripCookieDomain(upstreamCookies), ...VTEX_LOGOUT_COOKIES],
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Parse VTEX auth JWT to extract email and userId.
|
|
68
|
+
* Reads the VtexIdclientAutCookie_* cookie from a raw Cookie header.
|
|
69
|
+
*/
|
|
70
|
+
export function parseVtexAuthJwt(rawCookies: string): { email: string; userId: string } | null {
|
|
71
|
+
try {
|
|
72
|
+
const match = rawCookies.match(/VtexIdclientAutCookie_[^=]+=([^;]+)/);
|
|
73
|
+
if (!match) return null;
|
|
74
|
+
const token = match[1];
|
|
75
|
+
const parts = token.split(".");
|
|
76
|
+
if (parts.length < 2) return null;
|
|
77
|
+
const payload = JSON.parse(
|
|
78
|
+
Buffer.from(parts[1].replace(/-/g, "+").replace(/_/g, "/"), "base64").toString("utf-8"),
|
|
79
|
+
);
|
|
80
|
+
if (!payload.sub) return null;
|
|
81
|
+
return { email: payload.sub, userId: payload.userId ?? "" };
|
|
82
|
+
} catch {
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export const batch = <T>(iterable: IterableIterator<T> | T[], size: number): T[][] => {
|
|
2
|
+
const batches: T[][] = [];
|
|
3
|
+
|
|
4
|
+
let current = 0;
|
|
5
|
+
for (const item of iterable) {
|
|
6
|
+
if (batches[current]?.length === size) {
|
|
7
|
+
current++;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
if (!batches[current]) {
|
|
11
|
+
batches[current] = [];
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
batches[current].push(item);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
return batches;
|
|
18
|
+
};
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Outbound cookie sanitization for VTEX API calls.
|
|
3
|
+
*
|
|
4
|
+
* VTEX's janus gateway strictly enforces RFC 6265: any cookie value containing
|
|
5
|
+
* non-ASCII bytes causes the gateway to return `503 Service Unavailable` with
|
|
6
|
+
* an empty body, before the request reaches the backing service. This is
|
|
7
|
+
* deterministic — a single poisoned cookie (e.g. an analytics tag writing a
|
|
8
|
+
* category name with accents into `document.cookie` without encoding) can
|
|
9
|
+
* break every checkout call for a user.
|
|
10
|
+
*
|
|
11
|
+
* This module provides two filters:
|
|
12
|
+
*
|
|
13
|
+
* - `sanitizeOutboundCookieHeader()` — drops cookies whose value contains
|
|
14
|
+
* non-ASCII bytes or that look malformed. Default for cookie forwarding
|
|
15
|
+
* in `vtexFetchWithCookies`. Safe to apply to any cookie payload.
|
|
16
|
+
*
|
|
17
|
+
* - `extractVtexCookies()` — allowlist mode. Drops anything that isn't on
|
|
18
|
+
* `VTEX_COOKIE_PREFIXES`. Use when calling endpoints that have no business
|
|
19
|
+
* seeing the user's full cookie soup (e.g. logout, masterdata, profile).
|
|
20
|
+
*
|
|
21
|
+
* The allowlist is the canonical list of cookie name prefixes that VTEX APIs
|
|
22
|
+
* actually consume. It lives here so it's the single source of truth for the
|
|
23
|
+
* package — both `getVtexCookies()` (cookies.ts) and
|
|
24
|
+
* `extractVtexCookiesFromHeader()` (authHelpers.ts) delegate to this module.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Cookie name prefixes that are VTEX-relevant and safe to forward to
|
|
29
|
+
* `*.vtexcommercestable.com.br` / `*.myvtex.com` / `secure.<storefront>` APIs.
|
|
30
|
+
*/
|
|
31
|
+
export const VTEX_COOKIE_PREFIXES: readonly string[] = [
|
|
32
|
+
"VtexIdclientAutCookie",
|
|
33
|
+
"checkout.vtex.com",
|
|
34
|
+
"CheckoutOrderFormOwnership",
|
|
35
|
+
"vtex_session",
|
|
36
|
+
"vtex_segment",
|
|
37
|
+
"vtex_is_",
|
|
38
|
+
"janus_sid",
|
|
39
|
+
];
|
|
40
|
+
|
|
41
|
+
export type DropReason = "non_ascii" | "not_in_allowlist" | "malformed";
|
|
42
|
+
|
|
43
|
+
export interface DroppedCookie {
|
|
44
|
+
name: string;
|
|
45
|
+
reason: DropReason;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface CookieSanitizeResult {
|
|
49
|
+
/** Cookie header string ready to forward (may be empty). */
|
|
50
|
+
cookies: string;
|
|
51
|
+
/** Cookies that were filtered out, with the reason per cookie. */
|
|
52
|
+
dropped: DroppedCookie[];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface CookieSanitizeOptions {
|
|
56
|
+
/**
|
|
57
|
+
* When true, additionally enforces an allowlist: only cookies whose name
|
|
58
|
+
* starts with one of `VTEX_COOKIE_PREFIXES` are kept.
|
|
59
|
+
* @default false
|
|
60
|
+
*/
|
|
61
|
+
allowlist?: boolean;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Cookie pairs are `name=value`, where `name` (token) must be visible ASCII
|
|
66
|
+
* with no separators, and `value` must be ASCII (`0x20–0x7E`) per RFC 6265.
|
|
67
|
+
* We intentionally allow `=` inside the value (some VTEX cookies are
|
|
68
|
+
* URL-encoded JWTs).
|
|
69
|
+
*/
|
|
70
|
+
const TOKEN_RE = /^[!#$%&'*+\-.0-9A-Z^_`a-z|~]+$/;
|
|
71
|
+
const ASCII_VALUE_RE = /^[\x20-\x7E]*$/;
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Filter a `Cookie:` request header so it's safe to forward to a VTEX origin.
|
|
75
|
+
*
|
|
76
|
+
* Drops:
|
|
77
|
+
* - pairs without `=` (malformed)
|
|
78
|
+
* - pairs whose name isn't a valid HTTP token (malformed)
|
|
79
|
+
* - pairs whose value contains non-ASCII bytes (`non_ascii`)
|
|
80
|
+
* - when `opts.allowlist` is true: pairs not on `VTEX_COOKIE_PREFIXES`
|
|
81
|
+
* (`not_in_allowlist`)
|
|
82
|
+
*
|
|
83
|
+
* Returns the cleaned header plus a per-cookie drop report so callers can
|
|
84
|
+
* log/observe which cookies were removed.
|
|
85
|
+
*
|
|
86
|
+
* @example
|
|
87
|
+
* ```ts
|
|
88
|
+
* const { cookies, dropped } = sanitizeOutboundCookieHeader(
|
|
89
|
+
* request.headers.get("cookie") ?? "",
|
|
90
|
+
* );
|
|
91
|
+
* if (dropped.length) console.warn("[vtex] dropped cookies", dropped);
|
|
92
|
+
* fetch(vtexUrl, { headers: { cookie: cookies } });
|
|
93
|
+
* ```
|
|
94
|
+
*/
|
|
95
|
+
export function sanitizeOutboundCookieHeader(
|
|
96
|
+
raw: string,
|
|
97
|
+
opts: CookieSanitizeOptions = {},
|
|
98
|
+
): CookieSanitizeResult {
|
|
99
|
+
if (!raw) return { cookies: "", dropped: [] };
|
|
100
|
+
|
|
101
|
+
const kept: string[] = [];
|
|
102
|
+
const dropped: DroppedCookie[] = [];
|
|
103
|
+
const allowlist = opts.allowlist === true;
|
|
104
|
+
|
|
105
|
+
for (const segment of raw.split(";")) {
|
|
106
|
+
const pair = segment.trim();
|
|
107
|
+
if (!pair) continue;
|
|
108
|
+
|
|
109
|
+
const eq = pair.indexOf("=");
|
|
110
|
+
if (eq <= 0) {
|
|
111
|
+
dropped.push({ name: pair.slice(0, 32), reason: "malformed" });
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const name = pair.slice(0, eq);
|
|
116
|
+
const value = pair.slice(eq + 1);
|
|
117
|
+
|
|
118
|
+
if (!TOKEN_RE.test(name)) {
|
|
119
|
+
dropped.push({ name, reason: "malformed" });
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (!ASCII_VALUE_RE.test(value)) {
|
|
124
|
+
dropped.push({ name, reason: "non_ascii" });
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (allowlist && !VTEX_COOKIE_PREFIXES.some((p) => name.startsWith(p))) {
|
|
129
|
+
dropped.push({ name, reason: "not_in_allowlist" });
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
kept.push(`${name}=${value}`);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return { cookies: kept.join("; "), dropped };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Allowlist convenience wrapper — keep only VTEX-prefixed, ASCII-clean cookies.
|
|
141
|
+
*
|
|
142
|
+
* Equivalent to `sanitizeOutboundCookieHeader(raw, { allowlist: true }).cookies`.
|
|
143
|
+
*/
|
|
144
|
+
export function extractVtexCookies(raw: string): string {
|
|
145
|
+
return sanitizeOutboundCookieHeader(raw, { allowlist: true }).cookies;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Track which cookie names we've already warned about to avoid spamming
|
|
150
|
+
* the worker logs. Process-scoped — Cloudflare Workers reset this on each
|
|
151
|
+
* isolate restart, which is exactly the cadence we want.
|
|
152
|
+
*/
|
|
153
|
+
const _warned = new Set<string>();
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Emit a structured `console.warn` for each dropped cookie, deduped by
|
|
157
|
+
* `name+reason` for the lifetime of the worker isolate. Call sites pass an
|
|
158
|
+
* arbitrary `host` label so we can correlate in logs.
|
|
159
|
+
*/
|
|
160
|
+
export function warnDroppedCookies(dropped: DroppedCookie[], host: string): void {
|
|
161
|
+
if (dropped.length === 0) return;
|
|
162
|
+
for (const d of dropped) {
|
|
163
|
+
const key = `${host}::${d.name}::${d.reason}`;
|
|
164
|
+
if (_warned.has(key)) continue;
|
|
165
|
+
_warned.add(key);
|
|
166
|
+
console.warn(`[vtex.cookie.dropped] host=${host} name=${d.name} reason=${d.reason}`);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Reset the dedup set — exposed for tests. */
|
|
171
|
+
export function _resetCookieWarnDedupForTests(): void {
|
|
172
|
+
_warned.clear();
|
|
173
|
+
}
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
interface Cookie {
|
|
2
|
+
name: string;
|
|
3
|
+
value: string;
|
|
4
|
+
domain?: string;
|
|
5
|
+
path?: string;
|
|
6
|
+
expires?: Date;
|
|
7
|
+
maxAge?: number;
|
|
8
|
+
secure?: boolean;
|
|
9
|
+
httpOnly?: boolean;
|
|
10
|
+
sameSite?: "Strict" | "Lax" | "None";
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function parseSingleSetCookie(raw: string): Cookie | null {
|
|
14
|
+
const parts = raw.split(";").map((p) => p.trim());
|
|
15
|
+
const [nameValue, ...attrs] = parts;
|
|
16
|
+
const eqIdx = nameValue.indexOf("=");
|
|
17
|
+
if (eqIdx < 0) return null;
|
|
18
|
+
const cookie: Cookie = {
|
|
19
|
+
name: nameValue.slice(0, eqIdx),
|
|
20
|
+
value: nameValue.slice(eqIdx + 1),
|
|
21
|
+
};
|
|
22
|
+
for (const attr of attrs) {
|
|
23
|
+
const eqi = attr.indexOf("=");
|
|
24
|
+
const k = (eqi >= 0 ? attr.slice(0, eqi) : attr).trim();
|
|
25
|
+
const v = eqi >= 0 ? attr.slice(eqi + 1).trim() : "";
|
|
26
|
+
const lower = k.toLowerCase();
|
|
27
|
+
if (lower === "domain") cookie.domain = v;
|
|
28
|
+
else if (lower === "path") cookie.path = v;
|
|
29
|
+
else if (lower === "secure") cookie.secure = true;
|
|
30
|
+
else if (lower === "httponly") cookie.httpOnly = true;
|
|
31
|
+
else if (lower === "samesite") cookie.sameSite = v as Cookie["sameSite"];
|
|
32
|
+
else if (lower === "max-age") {
|
|
33
|
+
const n = Number(v);
|
|
34
|
+
if (!Number.isNaN(n)) cookie.maxAge = n;
|
|
35
|
+
} else if (lower === "expires") {
|
|
36
|
+
const d = new Date(v);
|
|
37
|
+
if (!Number.isNaN(d.getTime())) cookie.expires = d;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return cookie;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Extract individual Set-Cookie values from a Headers object.
|
|
45
|
+
*
|
|
46
|
+
* Uses Headers.getSetCookie() (available in Cloudflare Workers and Node 18+)
|
|
47
|
+
* which returns each Set-Cookie as a separate string — unlike Headers.get()
|
|
48
|
+
* or Headers.forEach() which join multiple values with ", " and corrupt
|
|
49
|
+
* cookie strings that contain commas in Expires dates.
|
|
50
|
+
*/
|
|
51
|
+
function getSetCookies(headers: Headers): Cookie[] {
|
|
52
|
+
const rawCookies: string[] =
|
|
53
|
+
typeof headers.getSetCookie === "function"
|
|
54
|
+
? headers.getSetCookie()
|
|
55
|
+
: getRawSetCookiesFallback(headers);
|
|
56
|
+
|
|
57
|
+
const cookies: Cookie[] = [];
|
|
58
|
+
for (const raw of rawCookies) {
|
|
59
|
+
const cookie = parseSingleSetCookie(raw);
|
|
60
|
+
if (cookie) cookies.push(cookie);
|
|
61
|
+
}
|
|
62
|
+
return cookies;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Fallback for runtimes without Headers.getSetCookie().
|
|
67
|
+
* Splits the comma-joined string heuristically — not perfect for cookies
|
|
68
|
+
* with Expires containing commas, but better than the old approach.
|
|
69
|
+
*/
|
|
70
|
+
function getRawSetCookiesFallback(headers: Headers): string[] {
|
|
71
|
+
const joined = headers.get("set-cookie");
|
|
72
|
+
if (!joined) return [];
|
|
73
|
+
const results: string[] = [];
|
|
74
|
+
let current = "";
|
|
75
|
+
for (const segment of joined.split(",")) {
|
|
76
|
+
const trimmed = segment.trimStart();
|
|
77
|
+
const looksLikeNewCookie = /^[^=;]+=[^;]/.test(trimmed) && current.length > 0;
|
|
78
|
+
if (looksLikeNewCookie) {
|
|
79
|
+
results.push(current.trim());
|
|
80
|
+
current = trimmed;
|
|
81
|
+
} else {
|
|
82
|
+
current += (current ? "," : "") + segment;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
if (current.trim()) results.push(current.trim());
|
|
86
|
+
return results;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function setCookie(headers: Headers, cookie: Cookie): void {
|
|
90
|
+
let str = `${cookie.name}=${cookie.value}`;
|
|
91
|
+
if (cookie.domain) str += `; Domain=${cookie.domain}`;
|
|
92
|
+
if (cookie.path) str += `; Path=${cookie.path}`;
|
|
93
|
+
if (cookie.secure) str += "; Secure";
|
|
94
|
+
if (cookie.httpOnly) str += "; HttpOnly";
|
|
95
|
+
if (cookie.sameSite) str += `; SameSite=${cookie.sameSite}`;
|
|
96
|
+
if (cookie.maxAge != null) str += `; Max-Age=${cookie.maxAge}`;
|
|
97
|
+
if (cookie.expires) str += `; Expires=${cookie.expires.toUTCString()}`;
|
|
98
|
+
headers.append("Set-Cookie", str);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export const stringify = (cookies: Record<string, string>) =>
|
|
102
|
+
Object.entries(cookies)
|
|
103
|
+
.map(([key, value]) => `${key}=${value}`)
|
|
104
|
+
.join("; ");
|
|
105
|
+
|
|
106
|
+
export const proxySetCookie = (from: Headers, to: Headers, toDomain?: URL | string) => {
|
|
107
|
+
const newDomain = toDomain && new URL(toDomain);
|
|
108
|
+
|
|
109
|
+
for (const cookie of getSetCookies(from)) {
|
|
110
|
+
const newCookie = newDomain
|
|
111
|
+
? {
|
|
112
|
+
...cookie,
|
|
113
|
+
domain: newDomain.hostname,
|
|
114
|
+
}
|
|
115
|
+
: cookie;
|
|
116
|
+
|
|
117
|
+
setCookie(to, newCookie);
|
|
118
|
+
}
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
export const CHECKOUT_DATA_ACCESS_COOKIE = "CheckoutDataAccess";
|
|
122
|
+
export const VTEX_CHKO_AUTH = "Vtex_CHKO_Auth";
|
|
123
|
+
|
|
124
|
+
// Re-export the canonical allowlist from cookieSanitizer so consumers that
|
|
125
|
+
// previously imported it from this module keep working. The single source
|
|
126
|
+
// of truth lives in cookieSanitizer.ts.
|
|
127
|
+
export { VTEX_COOKIE_PREFIXES } from "./cookieSanitizer";
|
|
128
|
+
|
|
129
|
+
import { extractVtexCookies } from "./cookieSanitizer";
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Filter a request's cookies to only VTEX-relevant ones.
|
|
133
|
+
*
|
|
134
|
+
* Strict allowlist: drops any cookie not on `VTEX_COOKIE_PREFIXES` plus any
|
|
135
|
+
* cookie whose value contains non-ASCII bytes (which would make VTEX's
|
|
136
|
+
* janus gateway return 503).
|
|
137
|
+
*/
|
|
138
|
+
export function getVtexCookies(request: Request): string {
|
|
139
|
+
return extractVtexCookies(request.headers.get("cookie") ?? "");
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Ensure the unsuffixed VtexIdclientAutCookie is present alongside the
|
|
144
|
+
* account-suffixed variant (e.g. VtexIdclientAutCookie_myaccount).
|
|
145
|
+
*
|
|
146
|
+
* VTEX GraphQL requires both the suffixed AND unsuffixed cookie for
|
|
147
|
+
* authenticated mutations. The browser only stores the suffixed variant,
|
|
148
|
+
* so server-side code must synthesize the unsuffixed one.
|
|
149
|
+
*/
|
|
150
|
+
export function ensureUnsuffixedAuthCookie(cookieStr: string): string {
|
|
151
|
+
if (!cookieStr) return cookieStr;
|
|
152
|
+
const cookies = cookieStr.split(";").map((c) => c.trim());
|
|
153
|
+
let hasUnsuffixed = false;
|
|
154
|
+
let suffixedToken: string | null = null;
|
|
155
|
+
for (const c of cookies) {
|
|
156
|
+
const [name, ...rest] = c.split("=");
|
|
157
|
+
if (name === "VtexIdclientAutCookie") {
|
|
158
|
+
hasUnsuffixed = true;
|
|
159
|
+
} else if (name?.startsWith("VtexIdclientAutCookie_") && !suffixedToken) {
|
|
160
|
+
suffixedToken = rest.join("=");
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
if (!hasUnsuffixed && suffixedToken) {
|
|
164
|
+
return `VtexIdclientAutCookie=${suffixedToken}; ${cookieStr}`;
|
|
165
|
+
}
|
|
166
|
+
return cookieStr;
|
|
167
|
+
}
|