@faststore/core 3.100.4 → 3.100.7

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.
Files changed (43) hide show
  1. package/.next/BUILD_ID +1 -1
  2. package/.next/build-manifest.json +22 -22
  3. package/.next/cache/.tsbuildinfo +1 -1
  4. package/.next/cache/config.json +3 -3
  5. package/.next/cache/webpack/client-production/0.pack +2 -2
  6. package/.next/cache/webpack/client-production/index.pack +0 -0
  7. package/.next/cache/webpack/edge-server-production/0.pack +0 -0
  8. package/.next/cache/webpack/edge-server-production/index.pack +0 -0
  9. package/.next/cache/webpack/server-production/0.pack +2 -2
  10. package/.next/cache/webpack/server-production/index.pack +0 -0
  11. package/.next/prerender-manifest.js +1 -1
  12. package/.next/prerender-manifest.json +1 -1
  13. package/.next/react-loadable-manifest.json +1 -1
  14. package/.next/routes-manifest.json +1 -1
  15. package/.next/server/chunks/948.js +2 -2
  16. package/.next/server/functions-config-manifest.json +1 -1
  17. package/.next/server/middleware-build-manifest.js +1 -1
  18. package/.next/server/middleware-react-loadable-manifest.js +1 -1
  19. package/.next/server/pages/api/graphql.js +3 -3
  20. package/.next/server/pages/api/preview.js +1 -1
  21. package/.next/server/pages/en-US/404.html +2 -2
  22. package/.next/server/pages/en-US/500.html +2 -2
  23. package/.next/server/pages/en-US/checkout.html +2 -2
  24. package/.next/server/pages/en-US/login.html +2 -2
  25. package/.next/server/pages/en-US/password-protection.html +2 -2
  26. package/.next/server/pages/en-US/s.html +2 -2
  27. package/.next/server/pages/en-US.html +2 -2
  28. package/.next/server/pages-manifest.json +1 -1
  29. package/.next/static/chunks/3802.9744a010fe6a4d07.js +1 -0
  30. package/.next/static/chunks/{webpack-6a844a3f6a390f62.js → webpack-bde714c56fd09a0c.js} +1 -1
  31. package/.next/trace +156 -156
  32. package/.turbo/turbo-build.log +11 -11
  33. package/.turbo/turbo-test.log +11 -9
  34. package/CHANGELOG.md +18 -0
  35. package/package.json +2 -2
  36. package/src/pages/api/graphql.ts +40 -26
  37. package/src/server/content/service.ts +69 -21
  38. package/src/utils/trustedForwardedHost.ts +50 -0
  39. package/test/server/content/service.test.ts +222 -0
  40. package/test/utils/trustedForwardedHost.test.ts +103 -0
  41. package/.next/static/chunks/3802.904f302bf009f795.js +0 -1
  42. /package/.next/static/{s20QxDT2pZcGbsFUk-djq → Oxei21wY_TrPYtPGUrFiR}/_buildManifest.js +0 -0
  43. /package/.next/static/{s20QxDT2pZcGbsFUk-djq → Oxei21wY_TrPYtPGUrFiR}/_ssgManifest.js +0 -0
@@ -1,23 +1,23 @@
1
1
 
2
- > @faststore/core@3.100.3 prebuild /home/runner/work/faststore/faststore/packages/core
2
+ > @faststore/core@3.100.6 prebuild /home/runner/work/faststore/faststore/packages/core
3
3
  > na run partytown && na run generate
4
4
 
5
5
 
6
- > @faststore/core@3.100.3 partytown /home/runner/work/faststore/faststore/packages/core
6
+ > @faststore/core@3.100.6 partytown /home/runner/work/faststore/faststore/packages/core
7
7
  > partytown copylib ./public/~partytown
8
8
 
9
9
  Partytown lib copied to: /home/runner/work/faststore/faststore/packages/core/public/~partytown
10
10
 
11
- > @faststore/core@3.100.3 generate /home/runner/work/faststore/faststore/packages/core
11
+ > @faststore/core@3.100.6 generate /home/runner/work/faststore/faststore/packages/core
12
12
  > na run generate:schema && na run generate:codegen && na run format:generated
13
13
 
14
14
 
15
- > @faststore/core@3.100.3 generate:schema /home/runner/work/faststore/faststore/packages/core
15
+ > @faststore/core@3.100.6 generate:schema /home/runner/work/faststore/faststore/packages/core
16
16
  > tsx src/server/generator/generateGraphQLSchemaFile.ts
17
17
 
18
18
  Schema GraphQL file generated successfully
19
19
 
20
- > @faststore/core@3.100.3 generate:codegen /home/runner/work/faststore/faststore/packages/core
20
+ > @faststore/core@3.100.6 generate:codegen /home/runner/work/faststore/faststore/packages/core
21
21
  > graphql-codegen
22
22
 
23
23
  [STARTED] Parse Configuration
@@ -37,11 +37,11 @@ Running lifecycle hook "afterStart" scripts...
37
37
  [CLI] Loading Documents
38
38
  [CLI] Generating output
39
39
 
40
- > @faststore/core@3.100.3 format:generated /home/runner/work/faststore/faststore/packages/core
40
+ > @faststore/core@3.100.6 format:generated /home/runner/work/faststore/faststore/packages/core
41
41
  > prettier --write "@generated/**/*.{ts,js,tsx,jsx,json}" --loglevel error
42
42
 
43
43
 
44
- > @faststore/core@3.100.3 build /home/runner/work/faststore/faststore/packages/core
44
+ > @faststore/core@3.100.6 build /home/runner/work/faststore/faststore/packages/core
45
45
  > next build
46
46
 
47
47
  ⚠ No build cache found. Please configure build caching for faster rebuilds. Read more: https://nextjs.org/docs/messages/no-cache
@@ -58,7 +58,7 @@ Browserslist: caniuse-lite is outdated. Please run:
58
58
  Creating an optimized production build ...
59
59
  Disabled SWC as replacement for Babel because of custom Babel configuration ".babelrc.js" https://nextjs.org/docs/messages/swc-disabled
60
60
  Using external babel configuration from /home/runner/work/faststore/faststore/packages/core/.babelrc.js
61
- Browserslist: browsers data (caniuse-lite) is 19 months old. Please run:
61
+ Browserslist: browsers data (caniuse-lite) is 21 months old. Please run:
62
62
  npx update-browserslist-db@latest
63
63
  Why you should do it regularly: https://github.com/browserslist/update-db#readme
64
64
  <w> [webpack.cache.PackFileCacheStrategy] Skipped not serializable cache item 'Compilation/modules|/home/runner/work/faststore/faststore/node_modules/.pnpm/next@13.5.11_@babel+core@7.26.7_@opentelemetry+api@1.4.1_react-dom@18.3.1_react@18.3.1__react@18.3.1_sass@1.83.4/node_modules/next/dist/build/webpack/loaders/css-loader/src/index.js??ruleSet[1].rules[7].oneOf[9].use[1]!/home/runner/work/faststore/faststore/node_modules/.pnpm/next@13.5.11_@babel+core@7.26.7_@opentelemetry+api@1.4.1_react-dom@18.3.1_react@18.3.1__react@18.3.1_sass@1.83.4/node_modules/next/dist/build/webpack/loaders/postcss-loader/src/index.js??ruleSet[1].rules[7].oneOf[9].use[2]!/home/runner/work/faststore/faststore/node_modules/.pnpm/next@13.5.11_@babel+core@7.26.7_@opentelemetry+api@1.4.1_react-dom@18.3.1_react@18.3.1__react@18.3.1_sass@1.83.4/node_modules/next/dist/build/webpack/loaders/resolve-url-loader/index.js??ruleSet[1].rules[7].oneOf[9].use[3]!/home/runner/work/faststore/faststore/node_modules/.pnpm/next@13.5.11_@babel+core@7.26.7_@opentelemetry+api@1.4.1_react-dom@18.3.1_react@18.3.1__react@18.3.1_sass@1.83.4/node_modules/next/dist/compiled/sass-loader/cjs.js??ruleSet[1].rules[7].oneOf[9].use[4]!/home/runner/work/faststore/faststore/packages/core/src/components/common/Toast/section.module.scss': No serializer registered for Warning
@@ -83,7 +83,7 @@ Learn more: https://nextjs.org/docs/api-reference/edge-runtime
83
83
 
84
84
  Import trace for requested module:
85
85
  ../../node_modules/.pnpm/jose@6.2.3/node_modules/jose/dist/webapi/lib/deflate.js
86
- ../../node_modules/.pnpm/jose@6.2.3/node_modules/jose/dist/webapi/jwe/flattened/encrypt.js
86
+ ../../node_modules/.pnpm/jose@6.2.3/node_modules/jose/dist/webapi/jwe/flattened/decrypt.js
87
87
  ../../node_modules/.pnpm/jose@6.2.3/node_modules/jose/dist/webapi/index.js
88
88
  ./src/server/password-protection-service.ts
89
89
 
@@ -93,7 +93,7 @@ Learn more: https://nextjs.org/docs/api-reference/edge-runtime
93
93
 
94
94
  Import trace for requested module:
95
95
  ../../node_modules/.pnpm/jose@6.2.3/node_modules/jose/dist/webapi/lib/deflate.js
96
- ../../node_modules/.pnpm/jose@6.2.3/node_modules/jose/dist/webapi/jwe/flattened/encrypt.js
96
+ ../../node_modules/.pnpm/jose@6.2.3/node_modules/jose/dist/webapi/jwe/flattened/decrypt.js
97
97
  ../../node_modules/.pnpm/jose@6.2.3/node_modules/jose/dist/webapi/index.js
98
98
  ./src/server/password-protection-service.ts
99
99
 
@@ -154,7 +154,7 @@ Route (pages) Size First Load JS
154
154
  ├ chunks/framework-d514426edf885c68.js 45.4 kB
155
155
  ├ chunks/main-a2d1cfe626f2fef1.js 33.4 kB
156
156
  ├ chunks/pages/_app-35e8dda638ee2672.js 26.6 kB
157
- ├ chunks/webpack-6a844a3f6a390f62.js 3.9 kB
157
+ ├ chunks/webpack-bde714c56fd09a0c.js 3.9 kB
158
158
  └ css/146a8678babc61bf.css 9.2 kB
159
159
 
160
160
  ƒ Middleware 44.8 kB
@@ -1,21 +1,23 @@
1
1
 
2
- > @faststore/core@3.100.3 test /home/runner/work/faststore/faststore/packages/core
2
+ > @faststore/core@3.100.6 test /home/runner/work/faststore/faststore/packages/core
3
3
  > jest
4
4
 
5
- PASS test/utils/clearCookies.test.ts (11.489 s)
6
- PASS test/utils/cookieCacheBusting.test.ts (13.647 s)
5
+ PASS test/server/content/service.test.ts (15.95 s)
6
+ PASS test/utils/cookieCacheBusting.test.ts (15.164 s)
7
+ PASS test/server/password-protection-service.test.ts (17.702 s)
7
8
  PASS test/utils/multipleTemplates.test.ts
8
- PASS test/server/password-protection-service.test.ts (16.602 s)
9
+ PASS test/utils/clearCookies.test.ts
9
10
  PASS test/sdk/redirects/index.test.ts
11
+ PASS test/utils/trustedForwardedHost.test.ts
10
12
  PASS test/utils/validateSessionRefreshToken.test.ts
11
13
  PASS test/server/cms/global.test.ts
12
14
  PASS test/utils/isLocalHost.test.ts
13
15
  PASS test/utils/isLocalHost.browser.test.ts
14
- PASS test/server/cms/index.test.ts (5.293 s)
15
- PASS test/server/index.test.ts (10.491 s)
16
+ PASS test/server/cms/index.test.ts
17
+ PASS test/server/index.test.ts (9.929 s)
16
18
 
17
- Test Suites: 11 passed, 11 total
18
- Tests: 92 passed, 92 total
19
+ Test Suites: 13 passed, 13 total
20
+ Tests: 128 passed, 128 total
19
21
  Snapshots: 0 total
20
- Time: 27.151 s
22
+ Time: 28.81 s
21
23
  Ran all test suites.
package/CHANGELOG.md CHANGED
@@ -3,6 +3,24 @@
3
3
  All notable changes to this project will be documented in this file.
4
4
  See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
5
5
 
6
+ ## [3.100.7](https://github.com/vtex/faststore/compare/v3.100.6...v3.100.7) (2026-10-26)
7
+
8
+ ### Bug Fixes
9
+
10
+ - **core:** cache Content Platform template list across page generations (3.x) ([#3534](https://github.com/vtex/faststore/issues/3534)) ([825c176](https://github.com/vtex/faststore/commit/825c176357b0f40ac9d112ee9789dce0c8198cf0)), closes [#3526](https://github.com/vtex/faststore/issues/3526) [#3384](https://github.com/vtex/faststore/issues/3384) [#3526](https://github.com/vtex/faststore/issues/3526) [#3526](https://github.com/vtex/faststore/issues/3526) [#3384](https://github.com/vtex/faststore/issues/3384)
11
+
12
+ ## [3.100.6](https://github.com/vtex/faststore/compare/v3.100.5...v3.100.6) (2026-10-07)
13
+
14
+ ### Bug Fixes
15
+
16
+ - address security & functional concerns from dk review (3.x backport) ([c928a1f](https://github.com/vtex/faststore/commit/c928a1fc04bc3554d83302c4cf760adab0f7e81c))
17
+ - **core:** harden x-forwarded-host handling and drop orderNumber fallback ([55990c1](https://github.com/vtex/faststore/commit/55990c1c6208c06ae482e71816df2035292bf2e9)), closes [#3178](https://github.com/vtex/faststore/issues/3178)
18
+ - **core:** ignore multi-value x-forwarded-host and cover port and case ([81ba26b](https://github.com/vtex/faststore/commit/81ba26b93175878b9cc3e32683f9c13b0abf25e9))
19
+ - **core:** log ignored x-forwarded-host and cover CR/LF hostnames ([e6a326f](https://github.com/vtex/faststore/commit/e6a326f6d0d4a91c75b9f0a10abfdc7c22c897be))
20
+ - **core:** make preview cookies host-only instead of rewriting their domain ([989fc69](https://github.com/vtex/faststore/commit/989fc69c573c980e354d11e3c3d8a719e2833fb2))
21
+ - **core:** reject hostnames that would inject Set-Cookie attributes ([109d819](https://github.com/vtex/faststore/commit/109d81917e1200dd78b9500f4cb81d128c78003d))
22
+ - resolve ValidateCartMutation infinite loop in preview environments (SFS-3034) ([ad7800d](https://github.com/vtex/faststore/commit/ad7800d65493c7477c9fb30119f76925647c40fc)), closes [#3178](https://github.com/vtex/faststore/issues/3178) [#3178](https://github.com/vtex/faststore/issues/3178) [#3190](https://github.com/vtex/faststore/issues/3190) [#3188](https://github.com/vtex/faststore/issues/3188) [#1467584](https://github.com/vtex/faststore/issues/1467584)
23
+
6
24
  ## [3.100.4](https://github.com/vtex/faststore/compare/v3.100.3...v3.100.4) (2026-08-28)
7
25
 
8
26
  ### Bug Fixes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@faststore/core",
3
- "version": "3.100.4",
3
+ "version": "3.100.7",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -127,5 +127,5 @@
127
127
  "ts-jest": "29.1.1",
128
128
  "typescript": "5.3.2"
129
129
  },
130
- "gitHead": "b30af2d573626311c245c445860a45cb26478798"
130
+ "gitHead": "fc9b34b4fc943c17e225249677b49e393dc2d1f4"
131
131
  }
@@ -10,6 +10,11 @@ import type { NextApiHandler, NextApiRequest } from 'next'
10
10
  import discoveryConfig from 'discovery.config'
11
11
  import { getJWTAutCookie } from 'src/utils/getCookie'
12
12
  import { isLocalHost } from 'src/utils/isLocalHost'
13
+ import {
14
+ isHostAllowed,
15
+ removeCookieDomain,
16
+ singleForwardedHost,
17
+ } from 'src/utils/trustedForwardedHost'
13
18
  import { shouldForceRefreshTokenForValidateSession } from 'src/utils/validateSessionRefreshToken'
14
19
  import { execute } from '../../server'
15
20
 
@@ -20,24 +25,45 @@ const ALLOWED_HOST_SUFFIXES = ['localhost', '.vtex.app', '.localhost']
20
25
  // Example: "Set-Cookie: key=value; Domain=example.com; Path=/"
21
26
  const MATCH_DOMAIN_REGEXP = /(?:^|;\s*)(?:domain=)([^;]+)/i
22
27
 
28
+ const toHostname = (hostHeader: string | undefined) => {
29
+ const trimmed = hostHeader?.trim()
30
+ if (!trimmed) {
31
+ return null
32
+ }
33
+
34
+ try {
35
+ return new URL(`https://${trimmed}`).hostname
36
+ } catch {
37
+ return null
38
+ }
39
+ }
40
+
23
41
  /**
24
- * Extracts hostname from the incoming request.
42
+ * Extracts hostname from the incoming request. The preview ingress forwards a
43
+ * client-supplied x-forwarded-host as is, so it only decides whether the cookie
44
+ * Domain is dropped, never which domain is used. A multi-value header is
45
+ * ignored; a single value is used only if it is on the allowlist.
25
46
  */
26
47
  const getRequestHostname = ({
27
48
  request,
28
49
  }: {
29
50
  request: NextApiRequest
30
51
  }): string | null => {
31
- const hostHeader = request.headers.host?.trim()
32
- if (!hostHeader) {
33
- return null
52
+ const forwardedHeader = request.headers['x-forwarded-host']
53
+ const forwardedHostname = toHostname(singleForwardedHost(forwardedHeader))
54
+ if (forwardedHeader && !forwardedHostname) {
55
+ // The value is client-controllable, so it is not logged.
56
+ console.warn('Ignoring multi-value or malformed x-forwarded-host')
34
57
  }
35
58
 
36
- try {
37
- return new URL(`https://${hostHeader}`).hostname
38
- } catch {
39
- return null
59
+ if (
60
+ forwardedHostname &&
61
+ isHostAllowed(forwardedHostname, ALLOWED_HOST_SUFFIXES)
62
+ ) {
63
+ return forwardedHostname
40
64
  }
65
+
66
+ return toHostname(request.headers.host)
41
67
  }
42
68
 
43
69
  /**
@@ -57,22 +83,10 @@ const shouldReplaceCookieDomain = ({
57
83
  }
58
84
 
59
85
  /**
60
- * Determines if host is eligible for domain normalization.
61
- */
62
- const isAllowedHost = ({
63
- host,
64
- allowList,
65
- }: {
66
- host: string
67
- allowList: string[]
68
- }) => {
69
- const normalizedHost = host.toLowerCase()
70
-
71
- return allowList.some((suffix) => normalizedHost.endsWith(suffix))
72
- }
73
-
74
- /**
75
- * Ensure the cookie domain matches the current host so the browser can store it.
86
+ * On allowlisted hosts (previews, localhost) the upstream cookie domain does not
87
+ * match the browser host, so the Domain attribute is dropped and the cookie
88
+ * becomes host-only. The host is never written into the cookie, so a forged
89
+ * x-forwarded-host cannot choose the cookie scope.
76
90
  */
77
91
  const normalizeSetCookieDomain = ({
78
92
  request,
@@ -93,13 +107,13 @@ const normalizeSetCookieDomain = ({
93
107
  const cookieDomain = domainMatch[1]
94
108
 
95
109
  if (
96
- !isAllowedHost({ host, allowList: ALLOWED_HOST_SUFFIXES }) ||
110
+ !isHostAllowed(host, ALLOWED_HOST_SUFFIXES) ||
97
111
  !shouldReplaceCookieDomain({ cookieDomain, host })
98
112
  ) {
99
113
  return setCookie
100
114
  }
101
115
 
102
- return setCookie.replace(MATCH_DOMAIN_REGEXP, `; domain=${host}`)
116
+ return removeCookieDomain(setCookie)
103
117
  }
104
118
 
105
119
  const parseRequest = (request: NextApiRequest) => {
@@ -19,9 +19,20 @@ import { isBranchPreview, isContentPlatformSource } from './utils'
19
19
 
20
20
  type ContentResult = ContentData | (ContentEntry & PageContentType)
21
21
 
22
+ type ContentListCacheEntry = {
23
+ expiresAt: number
24
+ promise: Promise<{ data: ContentResult[] }>
25
+ }
26
+
27
+ // Bounds how stale the template list can get in long-lived processes (ISR);
28
+ // a CMS publish already triggers a new build.
29
+ const CONTENT_LIST_CACHE_TTL_MS = 5 * 60 * 1000
30
+
22
31
  export class ContentService {
23
32
  private clientCP: ClientCP
24
33
 
34
+ private readonly contentListCache = new Map<string, ContentListCacheEntry>()
35
+
25
36
  constructor() {
26
37
  this.clientCP = new ClientCP({
27
38
  tenant: config.api.storeId,
@@ -46,29 +57,12 @@ export class ContentService {
46
57
 
47
58
  if (isContentPlatformSource()) {
48
59
  const serviceParams = this.convertOptionsToParams(options)
49
- // TODO: This should be temporary until the cp data-plane have a search engine.
50
- const MAX_PAGES = 25
51
- const allEntries: ContentEntry[] = []
52
- let scroll: string | undefined
53
- let pages = 0
54
-
55
- do {
56
- const result = await this.clientCP.listEntries(serviceParams, scroll)
57
- allEntries.push(...result.entries)
58
- const next = result.scroll ?? undefined
59
- if (next && next === scroll) break
60
- scroll = next
61
- } while (scroll && ++pages < MAX_PAGES)
62
-
63
- if (pages >= MAX_PAGES) {
64
- console.warn('listEntries pagination hit MAX_PAGES cap', serviceParams)
60
+
61
+ if (options.isPreview) {
62
+ return this.listAndFillEntries(serviceParams, true)
65
63
  }
66
64
 
67
- return this.fillEntriesWithData(
68
- allEntries,
69
- serviceParams,
70
- options.isPreview
71
- )
65
+ return this.getCachedContentList(serviceParams)
72
66
  }
73
67
  return getCMSPage(options.cmsOptions)
74
68
  }
@@ -136,6 +130,60 @@ export class ContentService {
136
130
  }
137
131
  }
138
132
 
133
+ private getCachedContentList(
134
+ serviceParams: EntryPathParams
135
+ ): Promise<{ data: ContentResult[] }> {
136
+ // listEntries only depends on these fields; the page slug must stay out of the key.
137
+ const { accountName, storeId, contentType, locale } = serviceParams
138
+ const key = JSON.stringify([accountName, storeId, contentType, locale])
139
+
140
+ const cached = this.contentListCache.get(key)
141
+ if (cached && cached.expiresAt > Date.now()) {
142
+ return cached.promise
143
+ }
144
+
145
+ const promise = this.listAndFillEntries(serviceParams, false).catch(
146
+ (err: unknown) => {
147
+ if (this.contentListCache.get(key)?.promise === promise) {
148
+ this.contentListCache.delete(key)
149
+ }
150
+ throw err
151
+ }
152
+ )
153
+
154
+ this.contentListCache.set(key, {
155
+ expiresAt: Date.now() + CONTENT_LIST_CACHE_TTL_MS,
156
+ promise,
157
+ })
158
+
159
+ return promise
160
+ }
161
+
162
+ private async listAndFillEntries(
163
+ serviceParams: EntryPathParams,
164
+ isPreview: boolean
165
+ ): Promise<{ data: (ContentEntry & PageContentType)[] }> {
166
+ // TODO: This should be temporary until the cp data-plane have a search engine.
167
+ const MAX_PAGES = 25
168
+ const allEntries: ContentEntry[] = []
169
+ let scroll: string | undefined
170
+ let pages = 0
171
+
172
+ do {
173
+ const result = await this.clientCP.listEntries(serviceParams, scroll)
174
+ allEntries.push(...result.entries)
175
+ const next = result.scroll ?? undefined
176
+ if (next && next === scroll) break
177
+ scroll = next
178
+ } while (scroll && ++pages < MAX_PAGES)
179
+
180
+ if (pages >= MAX_PAGES) {
181
+ console.warn('listEntries pagination hit MAX_PAGES cap', serviceParams)
182
+ }
183
+
184
+ return this.fillEntriesWithData(allEntries, serviceParams, isPreview)
185
+ }
186
+
139
187
  private async fillEntriesWithData(
140
188
  entries: ContentEntry[],
141
189
  serviceParams: EntryPathParams,
@@ -0,0 +1,50 @@
1
+ // The result is written verbatim into a Set-Cookie Domain attribute, so
2
+ // characters such as ";" or spaces would inject cookie attributes.
3
+ const HOSTNAME_PATTERN = /^[a-z0-9.-]+$/
4
+
5
+ /**
6
+ * An entry starting with "." matches subdomains only; any other entry matches
7
+ * the host itself and its subdomains. Matching is anchored on a label boundary,
8
+ * so "evil-localhost" never matches "localhost".
9
+ */
10
+ export function isHostAllowed(host: string, allowList: string[]) {
11
+ const normalizedHost = host.toLowerCase()
12
+
13
+ if (!HOSTNAME_PATTERN.test(normalizedHost)) {
14
+ return false
15
+ }
16
+
17
+ return allowList.some((entry) =>
18
+ entry.startsWith('.')
19
+ ? normalizedHost.endsWith(entry)
20
+ : normalizedHost === entry || normalizedHost.endsWith(`.${entry}`)
21
+ )
22
+ }
23
+
24
+ /**
25
+ * Returns the `x-forwarded-host` value only when exactly one is present.
26
+ *
27
+ * Which hop of a proxy chain is trustworthy is not known here: with several
28
+ * values the leftmost one is whatever the client sent. So a multi-value header
29
+ * (comma-joined by Node, or an array) is ignored and the caller falls back to
30
+ * `host`. A single value still assumes the ingress overwrites the header.
31
+ */
32
+ export function singleForwardedHost(
33
+ header: string | string[] | undefined
34
+ ): string | undefined {
35
+ if (Array.isArray(header)) {
36
+ return header.length === 1 ? singleForwardedHost(header[0]) : undefined
37
+ }
38
+
39
+ const value = header?.trim()
40
+
41
+ return value && !value.includes(',') ? value : undefined
42
+ }
43
+
44
+ /**
45
+ * Drops every Domain attribute so the cookie becomes host-only: the browser
46
+ * scopes it to the exact host it requested, whatever header picked this path.
47
+ */
48
+ export function removeCookieDomain(setCookie: string): string {
49
+ return setCookie.replaceAll(/;\s*domain=[^;]*/gi, '')
50
+ }
@@ -0,0 +1,222 @@
1
+ import type { ContentEntry, EntryPathParams } from '@vtex/client-cp'
2
+
3
+ import { ContentService } from '../../../src/server/content/service'
4
+ import type { ContentParams } from '../../../src/server/content/types'
5
+
6
+ jest.mock('../../../src/server/content/utils', () => ({
7
+ ...jest.requireActual('../../../src/server/content/utils'),
8
+ isContentPlatformSource: jest.fn(() => true),
9
+ }))
10
+
11
+ const NOT_FOUND = 'Request failed with status code 404'
12
+ const FIVE_MINUTES = 5 * 60 * 1000
13
+
14
+ const entryData: Record<string, Record<string, unknown>> = {
15
+ generic: { sections: [{ name: 'Generic' }] },
16
+ collection: {
17
+ settings: { template: { value: '/colecao' } },
18
+ sections: [{ name: 'Collection' }],
19
+ },
20
+ other: {
21
+ settings: { template: { value: '/outra' } },
22
+ sections: [{ name: 'Other' }],
23
+ },
24
+ }
25
+
26
+ const toEntry = (id: string) => ({ id, name: id }) as ContentEntry
27
+
28
+ function createFakeClientCP(pages: string[][]) {
29
+ return {
30
+ listEntries: jest.fn(async (_params: EntryPathParams, scroll?: string) => {
31
+ const index = scroll ? Number(scroll) : 0
32
+ return {
33
+ entries: pages[index].map(toEntry),
34
+ scroll: index + 1 < pages.length ? String(index + 1) : null,
35
+ }
36
+ }),
37
+ getEntry: jest.fn(async ({ entryId }: EntryPathParams) => ({
38
+ ...entryData[entryId as string],
39
+ })),
40
+ previewEntryById: jest.fn(async ({ entryId }: EntryPathParams) => ({
41
+ ...entryData[entryId as string],
42
+ })),
43
+ getEntryBySlug: jest.fn(
44
+ async (_params: EntryPathParams): Promise<Record<string, unknown>> => {
45
+ throw new Error(NOT_FOUND)
46
+ }
47
+ ),
48
+ previewEntryBySlug: jest.fn(
49
+ async (_params: EntryPathParams): Promise<Record<string, unknown>> => {
50
+ throw new Error(NOT_FOUND)
51
+ }
52
+ ),
53
+ }
54
+ }
55
+
56
+ function createServiceWithClient(
57
+ pages: string[][] = [['generic', 'collection', 'other']]
58
+ ) {
59
+ const service = new ContentService()
60
+ const clientCP = createFakeClientCP(pages)
61
+ Object.defineProperty(service, 'clientCP', { value: clientCP })
62
+ return { service, clientCP }
63
+ }
64
+
65
+ describe('ContentService Content Platform template list cache', () => {
66
+ beforeEach(() => {
67
+ jest.spyOn(console, 'warn').mockImplementation(() => {})
68
+ })
69
+
70
+ afterEach(() => {
71
+ jest.restoreAllMocks()
72
+ jest.useRealTimers()
73
+ })
74
+
75
+ it('shares one list and entry fetch across concurrent PLPs with different slugs', async () => {
76
+ const { service, clientCP } = createServiceWithClient()
77
+
78
+ const [collectionPage, otherPage, unknownPage] = await Promise.all([
79
+ service.getPlpContent({ slug: 'colecao/camisetas' }, []),
80
+ service.getPlpContent({ slug: 'outra' }, []),
81
+ service.getPlpContent({ slug: 'nao-existe' }, []),
82
+ ])
83
+
84
+ expect(clientCP.listEntries).toHaveBeenCalledTimes(1)
85
+ expect(clientCP.getEntry).toHaveBeenCalledTimes(3)
86
+ expect(collectionPage.sections).toEqual([{ name: 'Collection' }])
87
+ expect(otherPage.sections).toEqual([{ name: 'Other' }])
88
+ expect(unknownPage.sections).toEqual([{ name: 'Generic' }])
89
+ })
90
+
91
+ it('reuses the cached list within the TTL', async () => {
92
+ const { service, clientCP } = createServiceWithClient()
93
+
94
+ await service.getPlpContent({ slug: 'colecao' }, [])
95
+ await service.getPlpContent({ slug: 'outra' }, [])
96
+
97
+ expect(clientCP.listEntries).toHaveBeenCalledTimes(1)
98
+ expect(clientCP.getEntry).toHaveBeenCalledTimes(3)
99
+ })
100
+
101
+ it('still prefers a direct slug match over the cached list', async () => {
102
+ const { service, clientCP } = createServiceWithClient()
103
+ await service.getPlpContent({ slug: 'colecao' }, [])
104
+
105
+ clientCP.getEntryBySlug.mockResolvedValueOnce({
106
+ sections: [{ name: 'Direct' }],
107
+ })
108
+ const page = await service.getPlpContent({ slug: 'colecao' }, [])
109
+
110
+ expect(page.sections).toEqual([{ name: 'Direct' }])
111
+ expect(clientCP.listEntries).toHaveBeenCalledTimes(1)
112
+ })
113
+
114
+ it('consumes every scroll page once', async () => {
115
+ const { service, clientCP } = createServiceWithClient([
116
+ ['generic'],
117
+ ['collection'],
118
+ ['other'],
119
+ ])
120
+
121
+ await Promise.all([
122
+ service.getMultipleContent({ contentType: 'plp' }),
123
+ service.getMultipleContent({ contentType: 'plp' }),
124
+ ])
125
+ const { data } = await service.getMultipleContent({ contentType: 'plp' })
126
+
127
+ expect(clientCP.listEntries).toHaveBeenCalledTimes(3)
128
+ expect(clientCP.listEntries.mock.calls.map(([, scroll]) => scroll)).toEqual(
129
+ [undefined, '1', '2']
130
+ )
131
+ expect(data.map((entry) => entry.id)).toEqual([
132
+ 'generic',
133
+ 'collection',
134
+ 'other',
135
+ ])
136
+ })
137
+
138
+ it('keeps separate entries per content type', async () => {
139
+ const { service, clientCP } = createServiceWithClient()
140
+
141
+ await service.getMultipleContent({ contentType: 'plp' })
142
+ await service.getMultipleContent({ contentType: 'pdp' })
143
+ await service.getMultipleContent({ contentType: 'pdp' })
144
+
145
+ expect(clientCP.listEntries).toHaveBeenCalledTimes(2)
146
+ expect(
147
+ clientCP.listEntries.mock.calls.map(([params]) => params.contentType)
148
+ ).toEqual(['plp', 'pdp'])
149
+ })
150
+
151
+ it('bypasses the cache for previews', async () => {
152
+ const { service, clientCP } = createServiceWithClient()
153
+ const previewParams: ContentParams = {
154
+ contentType: 'plp',
155
+ previewData: {
156
+ contentType: 'plp',
157
+ documentId: 'collection',
158
+ versionId: 'version-1',
159
+ },
160
+ }
161
+
162
+ await service.getMultipleContent({ contentType: 'plp' })
163
+ await service.getMultipleContent(previewParams)
164
+ await service.getMultipleContent(previewParams)
165
+ await service.getMultipleContent({ contentType: 'plp' })
166
+
167
+ expect(clientCP.listEntries).toHaveBeenCalledTimes(3)
168
+ expect(clientCP.getEntry).toHaveBeenCalledTimes(3)
169
+ expect(clientCP.previewEntryById).toHaveBeenCalledTimes(6)
170
+ })
171
+
172
+ it('does not cache failures', async () => {
173
+ const { service, clientCP } = createServiceWithClient()
174
+ const error = new Error('Request failed with status code 500')
175
+ clientCP.listEntries.mockRejectedValueOnce(error)
176
+
177
+ await expect(
178
+ service.getMultipleContent({ contentType: 'plp' })
179
+ ).rejects.toBe(error)
180
+ const { data } = await service.getMultipleContent({ contentType: 'plp' })
181
+
182
+ expect(clientCP.listEntries).toHaveBeenCalledTimes(2)
183
+ expect(data).toHaveLength(3)
184
+ })
185
+
186
+ it('does not let a late failure evict a newer fetch', async () => {
187
+ jest.useFakeTimers()
188
+ const { service, clientCP } = createServiceWithClient()
189
+ let rejectFirst: (err: Error) => void = () => {}
190
+ clientCP.listEntries.mockImplementationOnce(
191
+ () =>
192
+ new Promise((_, reject) => {
193
+ rejectFirst = reject
194
+ })
195
+ )
196
+
197
+ const first = service.getMultipleContent({ contentType: 'plp' })
198
+ const firstResult = expect(first).rejects.toThrow('late failure')
199
+ jest.advanceTimersByTime(FIVE_MINUTES)
200
+ await service.getMultipleContent({ contentType: 'plp' })
201
+
202
+ rejectFirst(new Error('late failure'))
203
+ await firstResult
204
+ await service.getMultipleContent({ contentType: 'plp' })
205
+
206
+ expect(clientCP.listEntries).toHaveBeenCalledTimes(2)
207
+ })
208
+
209
+ it('fetches the list again after the TTL', async () => {
210
+ jest.useFakeTimers()
211
+ const { service, clientCP } = createServiceWithClient()
212
+
213
+ await service.getMultipleContent({ contentType: 'plp' })
214
+ jest.advanceTimersByTime(FIVE_MINUTES - 1)
215
+ await service.getMultipleContent({ contentType: 'plp' })
216
+ expect(clientCP.listEntries).toHaveBeenCalledTimes(1)
217
+
218
+ jest.advanceTimersByTime(1)
219
+ await service.getMultipleContent({ contentType: 'plp' })
220
+ expect(clientCP.listEntries).toHaveBeenCalledTimes(2)
221
+ })
222
+ })