@decocms/apps 1.12.0 → 1.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -3,9 +3,13 @@
3
3
  [![npm version](https://img.shields.io/npm/v/@decocms/apps.svg)](https://www.npmjs.com/package/@decocms/apps)
4
4
  [![license](https://img.shields.io/npm/l/@decocms/apps.svg)](https://github.com/decocms/apps-start/blob/main/LICENSE)
5
5
 
6
- Commerce integrations for [Deco](https://deco.cx) storefronts on **TanStack Start + React 19 + Cloudflare Workers**.
6
+ Commerce integrations for [deco.cx](https://deco.cx) storefronts on **TanStack Start + React 19 + Cloudflare Workers**.
7
7
 
8
- Provides VTEX and Shopify loaders, actions, hooks, and shared commerce types based on schema.org. Built on top of [`@decocms/start`](https://www.npmjs.com/package/@decocms/start).
8
+ `@decocms/apps` provides VTEX, Shopify, and Resend integrations (loaders, actions, hooks, middleware) plus shared schema.org commerce types. It depends on [`@decocms/start`](https://www.npmjs.com/package/@decocms/start).
9
+
10
+ 📖 **[Read the full documentation →](https://docs.deco.cx/v2/en/commerce/overview)**
11
+
12
+ ---
9
13
 
10
14
  ## Install
11
15
 
@@ -13,55 +17,192 @@ Provides VTEX and Shopify loaders, actions, hooks, and shared commerce types bas
13
17
  npm install @decocms/apps
14
18
  ```
15
19
 
16
- ## Integrations
20
+ ---
21
+
22
+ ## Minimum wiring
17
23
 
18
24
  ### VTEX
19
25
 
20
- Full VTEX Intelligent Search and Checkout integration.
26
+ A working VTEX storefront needs three things: a `deco-vtex` config block, an `initVtexFromBlocks()` call in setup, and the commerce loader registry.
27
+
28
+ #### 1. Config block (`.deco/blocks/deco-vtex.json`)
29
+
30
+ ```json
31
+ {
32
+ "__resolveType": "deco-vtex",
33
+ "account": "my-store",
34
+ "publicUrl": "https://www.my-store.com.br",
35
+ "salesChannel": "1",
36
+ "appKey": { "__resolveType": "secret/key" },
37
+ "appToken": { "__resolveType": "secret/key" }
38
+ }
39
+ ```
40
+
41
+ #### 2. Setup (`src/setup.ts`)
42
+
43
+ ```ts
44
+ import { createSiteSetup } from "@decocms/start/setup";
45
+ import { createInstrumentedFetch } from "@decocms/start/sdk/instrumentedFetch";
46
+ import { initVtexFromBlocks, setVtexFetch } from "@decocms/apps/vtex/client";
47
+ import { createVtexCommerceLoaders } from "@decocms/apps/vtex/commerceLoaders";
48
+
49
+ createSiteSetup({
50
+ sections: import.meta.glob("./sections/**/*.tsx", { eager: true }),
51
+ blocks,
52
+ meta: () => meta,
53
+ initPlatform: () => initVtexFromBlocks(),
54
+ getCommerceLoaders: () => createVtexCommerceLoaders(),
55
+ });
56
+
57
+ setVtexFetch(createInstrumentedFetch("vtex"));
58
+ ```
59
+
60
+ #### 3. Hooks in components
61
+
62
+ ```tsx
63
+ import { useCart, useUser, useWishlist } from "@decocms/apps/vtex/hooks";
64
+
65
+ function AddToCartButton({ sku }: { sku: string }) {
66
+ const { addItems, isMutating } = useCart();
67
+ return (
68
+ <button
69
+ onClick={() => addItems([{ id: sku, quantity: 1 }])}
70
+ disabled={isMutating}
71
+ >
72
+ Add to cart
73
+ </button>
74
+ );
75
+ }
76
+ ```
21
77
 
22
- | Import | Purpose |
23
- |--------|---------|
24
- | `@decocms/apps/vtex` | Configuration and setup |
25
- | `@decocms/apps/vtex/client` | VTEX API client with SWR caching |
26
- | `@decocms/apps/vtex/loaders/*` | Product, cart, search, catalog, session, wishlist |
27
- | `@decocms/apps/vtex/actions/*` | Checkout, auth, newsletter, profile, wishlist |
28
- | `@decocms/apps/vtex/hooks` | useCart, useUser, useWishlist, useAutocomplete |
29
- | `@decocms/apps/vtex/inline-loaders/*` | PDP, PLP, product list, suggestions |
30
- | `@decocms/apps/vtex/middleware` | Cookie propagation and session handling |
31
- | `@decocms/apps/vtex/invoke` | Server function wrappers |
32
- | `@decocms/apps/vtex/utils/*` | Transform, enrichment, segment, cookies |
78
+ That's it. Loaders are auto-registered, hooks are typed, edge cache + cookie propagation work out of the box.
33
79
 
34
80
  ### Shopify
35
81
 
36
- Storefront API integration via GraphQL.
82
+ ```ts
83
+ import { createSiteSetup } from "@decocms/start/setup";
84
+ import { initShopifyFromBlocks } from "@decocms/apps/shopify/client";
85
+ import { createShopifyCommerceLoaders } from "@decocms/apps/shopify/commerceLoaders";
86
+
87
+ createSiteSetup({
88
+ sections: import.meta.glob("./sections/**/*.tsx", { eager: true }),
89
+ blocks,
90
+ meta: () => meta,
91
+ initPlatform: () => initShopifyFromBlocks(),
92
+ getCommerceLoaders: () => createShopifyCommerceLoaders(),
93
+ });
94
+ ```
95
+
96
+ Config block (`deco-shopify`) needs `storeName`, `storefrontAccessToken`, `languageCode`, `countryCode`.
97
+
98
+ > ⚠️ Shopify cart loaders require **cart-cookie wiring** in your route handler. See [Shopify reference](https://docs.deco.cx/v2/en/commerce/shopify) for the canonical pattern.
99
+
100
+ ### Resend
101
+
102
+ ```ts
103
+ import { initResendFromBlocks } from "@decocms/apps/resend/client";
104
+ import { sendEmail } from "@decocms/apps/resend/sdk";
105
+
106
+ await sendEmail({
107
+ to: "customer@example.com",
108
+ subject: "Order confirmed",
109
+ html: "<h1>Thanks!</h1>",
110
+ });
111
+ ```
112
+
113
+ ---
114
+
115
+ ## What's exported
37
116
 
38
- | Import | Purpose |
39
- |--------|---------|
40
- | `@decocms/apps/shopify` | Configuration and setup |
41
- | `@decocms/apps/shopify/client` | Storefront GraphQL client |
42
- | `@decocms/apps/shopify/loaders/*` | PDP, PLP, product list, cart, user |
43
- | `@decocms/apps/shopify/actions/cart/*` | Add, update items, coupons |
44
- | `@decocms/apps/shopify/actions/user/*` | Sign in, sign up |
117
+ ### VTEX
118
+
119
+ | Subpath | Purpose |
120
+ |---------|---------|
121
+ | `@decocms/apps/vtex` | Barrel index |
122
+ | `@decocms/apps/vtex/client` | `vtexFetch`, `vtexFetchWithCookies`, `intelligentSearch`, `setVtexFetch`, `initVtexFromBlocks`, `configureVtex` |
123
+ | `@decocms/apps/vtex/commerceLoaders` | `createVtexCommerceLoaders` |
124
+ | `@decocms/apps/vtex/loaders/*` | Cart, user, wishlist, search, catalog, sessions, orders, autocomplete |
125
+ | `@decocms/apps/vtex/actions/*` | Cart mutations, auth, profile, address, wishlist, newsletter |
126
+ | `@decocms/apps/vtex/hooks` | `useCart`, `useUser`, `useWishlist`, `useAutocomplete`, plus `createUseCart` / `createUseUser` / `createUseWishlist` factories |
127
+ | `@decocms/apps/vtex/inline-loaders/*` | PDP, PLP, shelves, suggestions, minicart |
128
+ | `@decocms/apps/vtex/middleware` | `extractVtexContext`, `vtexCacheKeySuffix`, `propagateISCookies`, `createVtexCheckoutProxy` |
129
+ | `@decocms/apps/vtex/utils/*` | Transform, segment, cookies, slugCache, sortwhitelist |
130
+
131
+ > 💡 **Calling VTEX loaders/actions from the client.** Use the typed `invoke` client generated by `@decocms/start` — `invoke["vtex/loaders/cart.ts"](props)` — or use the React hooks above. There is **no** `@decocms/apps/vtex/invoke` subpath.
132
+
133
+ ### Shopify
134
+
135
+ | Subpath | Purpose |
136
+ |---------|---------|
137
+ | `@decocms/apps/shopify` | Barrel |
138
+ | `@decocms/apps/shopify/client` | `setShopifyFetch`, GraphQL helpers |
139
+ | `@decocms/apps/shopify/loaders/*` | PDP, PLP, ProductList, RelatedProducts, Cart, Account |
140
+ | `@decocms/apps/shopify/actions/cart/*` | `addItems`, `updateItems`, `discountCodesUpdate` |
141
+ | `@decocms/apps/shopify/actions/user/*` | `signIn`, `signUp` |
45
142
  | `@decocms/apps/shopify/utils/*` | Transform, cookies, GraphQL queries |
46
143
 
47
- ### Shared Commerce
144
+ ### Resend
48
145
 
49
- Platform-agnostic types and utilities.
146
+ | Subpath | Purpose |
147
+ |---------|---------|
148
+ | `@decocms/apps/resend/client` | `initResendFromBlocks` |
149
+ | `@decocms/apps/resend/sdk` | `sendEmail` |
150
+ | `@decocms/apps/resend/actions/send` | Invocable email action |
50
151
 
51
- | Import | Purpose |
52
- |--------|---------|
53
- | `@decocms/apps/commerce/types` | schema.org Product, Offer, BreadcrumbList, etc. |
54
- | `@decocms/apps/commerce/components/Image` | Optimized commerce image component |
152
+ ### Shared commerce
153
+
154
+ Platform-agnostic types and components.
155
+
156
+ | Subpath | Purpose |
157
+ |---------|---------|
158
+ | `@decocms/apps/commerce/types` | schema.org `Product`, `ProductDetailsPage`, `ProductListingPage`, `Offer`, `BreadcrumbList`, etc. |
159
+ | `@decocms/apps/commerce/components/Image` | Optimized commerce image with CDN routing |
160
+ | `@decocms/apps/commerce/components/Picture` | `<picture>` with responsive sources |
55
161
  | `@decocms/apps/commerce/components/JsonLd` | Structured data for SEO |
56
- | `@decocms/apps/commerce/sdk/*` | useOffer, formatPrice, analytics, URL utils |
57
- | `@decocms/apps/commerce/utils/*` | productToAnalyticsItem, canonical, stateByZip |
162
+ | `@decocms/apps/commerce/sdk/useOffer` | Pick the best offer per region/seller |
163
+ | `@decocms/apps/commerce/sdk/format` | `formatPrice`, `formatPriceRange` |
164
+ | `@decocms/apps/commerce/sdk/analytics` | Event types + `mapProductToAnalyticsItem` |
165
+ | `@decocms/apps/commerce/sdk/useVariantPossibilities` | Variant axis builder for selectors |
166
+
167
+ ### Website (utility app)
168
+
169
+ | Subpath | Purpose |
170
+ |---------|---------|
171
+ | `@decocms/apps/website` | `configureWebsite`, `configureSeo` |
172
+ | `@decocms/apps/website/loaders/redirectsFromCsv` | Bulk redirects from CSV |
173
+ | `@decocms/apps/website/loaders/fonts/*` | Google fonts, custom CDN font loaders |
174
+
175
+ Complete export tables: [docs.deco.cx/v2/en/reference/commerce-exports](https://docs.deco.cx/v2/en/reference/commerce-exports).
58
176
 
59
- ## Peer Dependencies
177
+ ---
60
178
 
61
- - `@decocms/start` >= 0.19.0
62
- - `@tanstack/react-query` >= 5
63
- - `react` >= 18
64
- - `react-dom` >= 18
179
+ ## Documentation
180
+
181
+ The commerce documentation lives at **[docs.deco.cx/v2/en/commerce](https://docs.deco.cx/v2/en/commerce/overview)**:
182
+
183
+ - [VTEX overview](https://docs.deco.cx/v2/en/commerce/vtex-overview) — config block, secrets, install steps.
184
+ - [VTEX loaders & actions](https://docs.deco.cx/v2/en/commerce/vtex-loaders-and-actions) — input/output cookbook.
185
+ - [VTEX hooks](https://docs.deco.cx/v2/en/commerce/vtex-hooks) — `useCart`, `useUser`, `useWishlist`, `useAutocomplete`.
186
+ - [VTEX gotchas](https://docs.deco.cx/v2/en/commerce/vtex-gotchas) — cookies, sales channel, regionId, IS sort sanitization.
187
+ - [Shopify](https://docs.deco.cx/v2/en/commerce/shopify) — configure, loaders, actions, cart-cookie wiring.
188
+ - [Resend](https://docs.deco.cx/v2/en/commerce/resend) — email setup.
189
+
190
+ ---
191
+
192
+ ## Peer dependencies
193
+
194
+ ```json
195
+ {
196
+ "@decocms/start": ">=2.0.0",
197
+ "@tanstack/react-query": ">=5.0.0",
198
+ "react": ">=19.0.0",
199
+ "react-dom": ">=19.0.0"
200
+ }
201
+ ```
202
+
203
+ The published peer floor is React 18 to ease incremental migration, but the v2 stack assumes React 19 + the React Compiler.
204
+
205
+ ---
65
206
 
66
207
  ## Development
67
208
 
@@ -70,6 +211,10 @@ npm run typecheck # tsc --noEmit
70
211
  npm run check # typecheck + unused export detection
71
212
  ```
72
213
 
214
+ This is a library — there is no dev server. Consumer storefronts run their own `vite dev`.
215
+
216
+ ---
217
+
73
218
  ## License
74
219
 
75
220
  MIT
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decocms/apps",
3
- "version": "1.12.0",
3
+ "version": "1.13.0",
4
4
  "type": "module",
5
5
  "description": "Deco commerce apps for TanStack Start - Shopify, VTEX, commerce types, analytics utils",
6
6
  "exports": {
package/vtex/client.ts CHANGED
@@ -4,6 +4,7 @@
4
4
  */
5
5
 
6
6
  import { RequestContext } from "@decocms/start/sdk/requestContext";
7
+ import { sanitizeOutboundCookieHeader, warnDroppedCookies } from "./utils/cookieSanitizer";
7
8
  import { type FetchCacheOptions, fetchWithCache } from "./utils/fetchCache";
8
9
  import { ANONYMOUS_COOKIE, SESSION_COOKIE } from "./utils/intelligentSearch";
9
10
  import { parseSegment, SEGMENT_COOKIE_NAME } from "./utils/segment";
@@ -276,14 +277,31 @@ export async function vtexCachedFetch<T>(
276
277
  * This mirrors deco-cx/deco's `proxySetCookie(response.headers, ctx.response.headers)`.
277
278
  */
278
279
  export async function vtexFetchWithCookies<T>(path: string, init?: RequestInit): Promise<T> {
279
- // Auto-inject request cookies from RequestContext
280
+ // Auto-inject request cookies from RequestContext.
281
+ //
282
+ // We sanitize the forwarded Cookie header before sending it to VTEX:
283
+ // the janus gateway returns 503 (empty body) on any cookie value that
284
+ // isn't strict ASCII per RFC 6265. Third-party analytics tags that write
285
+ // raw UTF-8 into document.cookie (e.g. category names with accents) will
286
+ // otherwise poison every checkout call for the affected user. The drop
287
+ // report is emitted via warnDroppedCookies() so we have observability the
288
+ // next time a tag misbehaves.
280
289
  const existingHeaders = init?.headers as Record<string, string> | undefined;
281
290
  if (!existingHeaders?.cookie) {
282
291
  const ctx = RequestContext.current;
283
- const cookies = ctx?.request.headers.get("cookie");
284
- if (cookies) {
285
- init = { ...init, headers: { ...existingHeaders, cookie: cookies } };
292
+ const raw = ctx?.request.headers.get("cookie");
293
+ if (raw) {
294
+ const { cookies, dropped } = sanitizeOutboundCookieHeader(raw);
295
+ if (dropped.length) warnDroppedCookies(dropped, vtexHost());
296
+ if (cookies) {
297
+ init = { ...init, headers: { ...existingHeaders, cookie: cookies } };
298
+ }
286
299
  }
300
+ } else {
301
+ // Caller passed an explicit cookie — sanitize it too.
302
+ const { cookies, dropped } = sanitizeOutboundCookieHeader(existingHeaders.cookie);
303
+ if (dropped.length) warnDroppedCookies(dropped, vtexHost());
304
+ init = { ...init, headers: { ...existingHeaders, cookie: cookies } };
287
305
  }
288
306
 
289
307
  const response = await vtexFetchResponse(path, init);
@@ -17,12 +17,14 @@ export interface PDPProps {
17
17
  indexingSkus?: boolean;
18
18
  /** Use product.description instead of metaTagDescription for SEO */
19
19
  preferDescription?: boolean;
20
+ /** Use lean variant transform (no images/video) for hasVariant[]. Defaults to true. */
21
+ leanVariants?: boolean;
20
22
  }
21
23
 
22
24
  export default async function vtexProductDetailsPage(
23
25
  props: PDPProps,
24
26
  ): Promise<ProductDetailsPage | null> {
25
- const { slug, skuId, indexingSkus, preferDescription } = props;
27
+ const { slug, skuId, indexingSkus, preferDescription, leanVariants = true } = props;
26
28
  if (!slug) return null;
27
29
 
28
30
  try {
@@ -53,7 +55,7 @@ export default async function vtexProductDetailsPage(
53
55
  const page = toProductPage(product, sku, kitItems, {
54
56
  baseUrl,
55
57
  priceCurrency: "BRL",
56
- leanVariants: true,
58
+ leanVariants,
57
59
  });
58
60
 
59
61
  return {
@@ -7,27 +7,20 @@
7
7
  * TanStack Start's Vite plugin only transforms source files.
8
8
  */
9
9
  import { getVtexConfig } from "../client";
10
+ import { extractVtexCookies } from "./cookieSanitizer";
10
11
 
11
12
  const DOMAIN_RE = /;\s*domain=[^;]*/gi;
12
13
 
13
- const VTEX_COOKIE_PREFIXES = [
14
- "vtex_session=",
15
- "vtex_segment=",
16
- "VtexIdclientAutCookie",
17
- "checkout.vtex.com",
18
- "CheckoutOrderFormOwnership",
19
- ];
20
-
21
14
  /**
22
15
  * Extract VTEX-relevant cookies from a raw Cookie header string.
23
- * Filters out analytics/CF cookies that can cause VTEX 503 errors.
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.
24
21
  */
25
22
  export function extractVtexCookiesFromHeader(raw: string): string {
26
- return raw
27
- .split(";")
28
- .map((c) => c.trim())
29
- .filter((c) => VTEX_COOKIE_PREFIXES.some((prefix) => c.startsWith(prefix)))
30
- .join("; ");
23
+ return extractVtexCookies(raw);
31
24
  }
32
25
 
33
26
  /**
@@ -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
+ }
@@ -121,29 +121,22 @@ export const proxySetCookie = (from: Headers, to: Headers, toDomain?: URL | stri
121
121
  export const CHECKOUT_DATA_ACCESS_COOKIE = "CheckoutDataAccess";
122
122
  export const VTEX_CHKO_AUTH = "Vtex_CHKO_Auth";
123
123
 
124
- /**
125
- * Cookie name prefixes that are VTEX-relevant.
126
- * Used by getVtexCookies() to filter request cookies before forwarding
127
- * to VTEX APIs — avoids undici non-ASCII warnings from other cookies.
128
- */
129
- export const VTEX_COOKIE_PREFIXES = [
130
- "VtexIdclientAutCookie",
131
- "checkout.vtex",
132
- "CheckoutOrderFormOwnership",
133
- "vtex_is_",
134
- ];
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";
135
130
 
136
131
  /**
137
132
  * Filter a request's cookies to only VTEX-relevant ones.
138
- * Prevents undici non-ASCII header warnings when forwarding cookies to VTEX APIs.
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).
139
137
  */
140
138
  export function getVtexCookies(request: Request): string {
141
- const raw = request.headers.get("cookie") ?? "";
142
- return raw
143
- .split(";")
144
- .map((c) => c.trim())
145
- .filter((c) => VTEX_COOKIE_PREFIXES.some((p) => c.startsWith(p)))
146
- .join("; ");
139
+ return extractVtexCookies(request.headers.get("cookie") ?? "");
147
140
  }
148
141
 
149
142
  /**