@decocms/apps 1.11.1 → 1.12.1

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decocms/apps",
3
- "version": "1.11.1",
3
+ "version": "1.12.1",
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);
@@ -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
  /**
@@ -1,9 +1,14 @@
1
1
  /**
2
- * VTEX Sitemap utility.
2
+ * VTEX Sitemap utilities.
3
3
  *
4
- * Fetches product and category URLs from VTEX's sitemap API
5
- * and converts them to SitemapEntry format for composition
6
- * with the CMS sitemap generator.
4
+ * Two flavors:
5
+ * - `getVtexSitemapEntries()` flatten VTEX sub-sitemaps into a single
6
+ * `SitemapEntry[]` list, for composition with the CMS sitemap generator.
7
+ * - `createVtexSitemapProxy()` — proxy `/sitemap.xml` and `/sitemap/*`
8
+ * straight from VTEX's commerce-stable origin, preserving the sitemap-index
9
+ * shape (so crawlers stay within Google's per-file size limit). This is the
10
+ * right choice when the storefront has no native sitemap renderer and just
11
+ * needs to expose VTEX's existing crawl tree to the public hostname.
7
12
  */
8
13
 
9
14
  import { getVtexConfig, vtexFetchResponse, vtexHost } from "../client";
@@ -131,3 +136,147 @@ function rewriteUrl(url: string, vtexSitemapHost: string, origin: string): strin
131
136
  return url.replace(`https://${vtexSitemapHost}`, origin);
132
137
  }
133
138
  }
139
+
140
+ // ---------------------------------------------------------------------------
141
+ // VTEX sitemap proxy factory
142
+ // ---------------------------------------------------------------------------
143
+
144
+ /**
145
+ * Returns true if `pathname` is one of the proxied sitemap paths
146
+ * (`/sitemap.xml` or any `/sitemap/*` sub-sitemap).
147
+ */
148
+ export function isVtexSitemapPath(pathname: string): boolean {
149
+ return pathname === "/sitemap.xml" || pathname.startsWith("/sitemap/");
150
+ }
151
+
152
+ export interface VtexSitemapProxyConfig {
153
+ /**
154
+ * Extra `<sitemap>` entries to inject into the root sitemap index
155
+ * (`/sitemap.xml` only — sub-sitemaps are passed through untouched).
156
+ *
157
+ * Useful for site-managed sitemaps such as a static search-result
158
+ * index (`sitemap-busca.xml`) that VTEX doesn't generate.
159
+ *
160
+ * Each value is normalized to an absolute URL on the storefront
161
+ * origin: leading-slash paths become `${origin}${path}`, and bare
162
+ * names become `${origin}/${name}`. Absolute URLs are used as-is.
163
+ *
164
+ * @example ["/sitemap-busca.xml"]
165
+ */
166
+ extraSitemaps?: string[];
167
+
168
+ /**
169
+ * VTEX environment for the upstream sitemap fetch.
170
+ * @default "vtexcommercestable"
171
+ */
172
+ environment?: "vtexcommercestable" | "vtexcommercebeta";
173
+
174
+ /**
175
+ * `Cache-Control` header to set on proxied responses. The default
176
+ * favors edge caching (Cloudflare honors `s-maxage`) with a long
177
+ * stale-while-revalidate window so a slow VTEX origin never blocks
178
+ * crawlers.
179
+ *
180
+ * @default "public, s-maxage=3600, stale-while-revalidate=86400"
181
+ */
182
+ cacheControl?: string;
183
+
184
+ /**
185
+ * Optional fetch override — primarily for tests. Defaults to the
186
+ * platform `fetch`.
187
+ */
188
+ fetchImpl?: typeof fetch;
189
+ }
190
+
191
+ const DEFAULT_SITEMAP_CACHE_CONTROL = "public, s-maxage=3600, stale-while-revalidate=86400";
192
+
193
+ function normalizeExtraSitemap(entry: string, origin: string): string {
194
+ if (entry.startsWith("http://") || entry.startsWith("https://")) return entry;
195
+ const path = entry.startsWith("/") ? entry : `/${entry}`;
196
+ return `${origin}${path}`;
197
+ }
198
+
199
+ /**
200
+ * Creates a sitemap proxy handler that mirrors VTEX's `/sitemap.xml`
201
+ * (and sub-sitemaps) onto the storefront origin.
202
+ *
203
+ * Returns a function compatible with `createDecoWorkerEntry`'s
204
+ * `proxyHandler`: it returns `null` for non-sitemap paths, so it
205
+ * composes naturally with other proxy handlers
206
+ * (`createVtexCheckoutProxy`, custom logic, etc.).
207
+ *
208
+ * The VTEX account is read from the `configureVtex(...)` call done at
209
+ * worker startup — no per-call account configuration is needed.
210
+ *
211
+ * @example
212
+ * ```ts
213
+ * import { createVtexSitemapProxy } from "@decocms/apps/vtex/utils/sitemap";
214
+ * import {
215
+ * createVtexCheckoutProxy,
216
+ * shouldProxyToVtex,
217
+ * } from "@decocms/apps/vtex/utils/proxy";
218
+ *
219
+ * const proxySitemap = createVtexSitemapProxy({
220
+ * extraSitemaps: ["/sitemap-busca.xml"], // optional, site-managed
221
+ * });
222
+ * const proxyCheckout = createVtexCheckoutProxy({ ... });
223
+ *
224
+ * createDecoWorkerEntry(serverEntry, {
225
+ * proxyHandler: async (request, url) => {
226
+ * const sitemap = await proxySitemap(request, url);
227
+ * if (sitemap) return sitemap;
228
+ *
229
+ * if (!shouldProxyToVtex(url.pathname)) return null;
230
+ * return proxyCheckout(request, url);
231
+ * },
232
+ * });
233
+ * ```
234
+ */
235
+ export function createVtexSitemapProxy(
236
+ config: VtexSitemapProxyConfig = {},
237
+ ): (request: Request, url: URL) => Promise<Response | null> {
238
+ const environment = config.environment ?? "vtexcommercestable";
239
+ const cacheControl = config.cacheControl ?? DEFAULT_SITEMAP_CACHE_CONTROL;
240
+ const extraSitemaps = config.extraSitemaps ?? [];
241
+ const fetchImpl = config.fetchImpl ?? fetch;
242
+
243
+ return async (_request: Request, url: URL): Promise<Response | null> => {
244
+ if (!isVtexSitemapPath(url.pathname)) return null;
245
+
246
+ // vtexHost() reads the configured account from configureVtex().
247
+ const vtexSitemapHost = vtexHost(environment);
248
+ const target = `https://${vtexSitemapHost}${url.pathname}`;
249
+
250
+ try {
251
+ const resp = await fetchImpl(target);
252
+ if (!resp.ok) {
253
+ console.error(`[vtex-sitemap] VTEX returned ${resp.status} for ${url.pathname}`);
254
+ return new Response("Sitemap temporarily unavailable", { status: 502 });
255
+ }
256
+
257
+ let xml = await resp.text();
258
+ xml = xml.replaceAll(`https://${vtexSitemapHost}`, url.origin);
259
+
260
+ if (url.pathname === "/sitemap.xml" && extraSitemaps.length > 0) {
261
+ const extraEntries = extraSitemaps
262
+ .map(
263
+ (s) =>
264
+ ` <sitemap>\n <loc>${normalizeExtraSitemap(s, url.origin)}</loc>\n </sitemap>`,
265
+ )
266
+ .join("\n");
267
+ xml = xml.replace("</sitemapindex>", `${extraEntries}\n</sitemapindex>`);
268
+ }
269
+
270
+ return new Response(xml, {
271
+ status: 200,
272
+ headers: {
273
+ "Content-Type": "application/xml; charset=utf-8",
274
+ "Cache-Control": cacheControl,
275
+ },
276
+ });
277
+ } catch (err) {
278
+ console.error("[vtex-sitemap] Failed to proxy VTEX sitemap:", err);
279
+ return new Response("Sitemap temporarily unavailable", { status: 502 });
280
+ }
281
+ };
282
+ }