@faststore/core 4.10.1 → 4.10.2

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 (79) hide show
  1. package/.turbo/turbo-generate.log +3 -3
  2. package/.turbo/turbo-test.log +278 -235
  3. package/@generated/graphql.ts +1 -0
  4. package/@generated/schema.graphql +1 -1
  5. package/CHANGELOG.md +86 -0
  6. package/package.json +6 -6
  7. package/src/components/account/Drawer/OrganizationDrawer/OrganizationDrawer.tsx +3 -3
  8. package/src/components/account/Drawer/OrganizationDrawer/OrganizationDrawerBody.tsx +5 -7
  9. package/src/components/account/Drawer/OrganizationSignInButton/OrganizationSignInButton.tsx +4 -1
  10. package/src/components/account/Layout/Layout.tsx +5 -7
  11. package/src/components/account/Menu/Menu.tsx +1 -1
  12. package/src/components/navigation/Navbar/Navbar.tsx +3 -3
  13. package/src/components/navigation/Navbar/NavbarAccountArea.tsx +5 -5
  14. package/src/components/navigation/NavbarSlider/NavbarSlider.tsx +3 -3
  15. package/src/experimental/myAccountCmsServerSideProps.ts +3 -3
  16. package/src/experimental/myAccountServerSideProps.ts +4 -4
  17. package/src/pages/[slug]/p.tsx +18 -4
  18. package/src/pages/api/fs/logout.ts +18 -10
  19. package/src/pages/api/fs/switch-contract.ts +170 -21
  20. package/src/pages/api/graphql.ts +101 -60
  21. package/src/pages/pvt/account/403.tsx +5 -4
  22. package/src/pages/pvt/account/cards/index.tsx +5 -5
  23. package/src/pages/pvt/account/orders/[id].tsx +5 -5
  24. package/src/pages/pvt/account/orders/index.tsx +5 -5
  25. package/src/pages/pvt/account/profile.tsx +5 -5
  26. package/src/pages/pvt/account/quotes/index.tsx +5 -5
  27. package/src/pages/pvt/account/security.tsx +5 -5
  28. package/src/pages/pvt/account/user-details.tsx +6 -6
  29. package/src/sdk/account/getB2BSessionClaims.ts +1 -1
  30. package/src/sdk/account/getIsB2BBuyer.ts +28 -0
  31. package/src/sdk/account/refreshToken.ts +89 -11
  32. package/src/sdk/account/refreshTokenPaths.ts +25 -0
  33. package/src/sdk/account/useRefreshToken.ts +15 -11
  34. package/src/sdk/product/isProductNotFound.ts +13 -0
  35. package/src/sdk/product/usePageProductsQuery.ts +80 -21
  36. package/src/sdk/product/useProductNotFoundRedirect.ts +43 -0
  37. package/src/sdk/product/useProductQuery.ts +29 -0
  38. package/src/sdk/session/index.ts +46 -34
  39. package/src/server/cms/index.ts +59 -7
  40. package/src/server/cms/pdp.ts +10 -5
  41. package/src/server/cms/plp.ts +10 -5
  42. package/src/server/content/promiseCache.ts +68 -0
  43. package/src/server/content/service.ts +71 -51
  44. package/src/utils/getCheckoutParentDomain.ts +53 -0
  45. package/src/utils/isLocalHost.ts +2 -16
  46. package/src/utils/shouldBypassRefreshToken.ts +63 -0
  47. package/src/utils/trustedForwardedHost.ts +18 -0
  48. package/test/components/account/Layout.test.tsx +1 -1
  49. package/test/components/account/Menu.browser.test.tsx +7 -5
  50. package/test/components/account/OrganizationDrawer.browser.test.tsx +13 -13
  51. package/test/components/account/OrganizationDrawerBody.test.tsx +3 -3
  52. package/test/components/account/OrganizationSignInButton.browser.test.tsx +19 -4
  53. package/test/components/navigation/Navbar.browser.test.tsx +3 -3
  54. package/test/components/navigation/NavbarAccountArea.browser.test.tsx +5 -5
  55. package/test/components/navigation/NavbarSlider.browser.test.tsx +3 -3
  56. package/test/experimental/myAccountCmsServerSideProps.test.ts +5 -5
  57. package/test/pages/api/graphql.test.ts +175 -0
  58. package/test/pages/api/logout.test.ts +106 -0
  59. package/test/pages/api/switch-contract.test.ts +443 -15
  60. package/test/pages/pvt/account/403.test.ts +67 -0
  61. package/test/pages/pvt/account/cards/index.test.ts +3 -3
  62. package/test/sdk/account/getIsB2BBuyer.test.ts +52 -0
  63. package/test/sdk/account/refreshToken.browser.test.ts +10 -1
  64. package/test/sdk/account/refreshToken.test.ts +150 -18
  65. package/test/sdk/account/useRefreshToken.browser.test.ts +72 -0
  66. package/test/sdk/product/usePageProductsQuery.browser.test.ts +260 -0
  67. package/test/sdk/product/usePageProductsQuery.deliveryPromise.browser.test.ts +130 -0
  68. package/test/sdk/product/useProductNotFoundRedirect.browser.test.ts +83 -0
  69. package/test/sdk/product/useProductQuery.browser.test.ts +154 -0
  70. package/test/sdk/session/refreshTokenRoute.browser.test.ts +106 -0
  71. package/test/sdk/session/validateSession.browser.test.ts +111 -5
  72. package/test/server/cms/index.test.ts +102 -9
  73. package/test/server/content/promiseCache.test.ts +150 -0
  74. package/test/server/content/service.test.ts +277 -11
  75. package/test/utils/shouldBypassRefreshToken.browser.test.ts +38 -0
  76. package/test/utils/shouldBypassRefreshToken.test.ts +98 -0
  77. package/test/utils/trustedForwardedHost.test.ts +21 -0
  78. package/src/sdk/account/getIsRepresentative.ts +0 -20
  79. package/test/utils/isLocalHost.browser.test.ts +0 -37
@@ -8,10 +8,12 @@ import type {
8
8
  ValidateSessionMutationVariables,
9
9
  } from '@generated/graphql'
10
10
  import deepEqual from 'fast-deep-equal'
11
- import { isLocalHostBrowser } from 'src/utils/isLocalHost'
11
+ import { shouldBypassRefreshTokenBrowser } from 'src/utils/shouldBypassRefreshToken'
12
12
  import { filterChannel } from 'src/utils/utilities'
13
13
  import storeConfig from '../../../discovery.config'
14
14
  import {
15
+ getNextRefreshAfter,
16
+ isB2BSession,
15
17
  isRefreshTokenSuccessful,
16
18
  refreshTokenRequest,
17
19
  } from '../account/refreshToken'
@@ -94,14 +96,12 @@ export const mutation = gql(`
94
96
  `)
95
97
 
96
98
  async function handleRefreshToken(session: Session): Promise<Session | null> {
97
- const result = await refreshTokenRequest()
99
+ const result = await refreshTokenRequest({ isB2B: isB2BSession(session) })
98
100
 
99
101
  if (isRefreshTokenSuccessful(result)) {
100
102
  return {
101
103
  ...session,
102
- refreshAfter: String(
103
- Math.floor(new Date(result?.refreshAfter).getTime() / 1000)
104
- ),
104
+ refreshAfter: getNextRefreshAfter(result),
105
105
  }
106
106
  }
107
107
 
@@ -109,6 +109,43 @@ async function handleRefreshToken(session: Session): Promise<Session | null> {
109
109
  return null
110
110
  }
111
111
 
112
+ /**
113
+ * The refresh-token flow runs only when `experimental.refreshToken` is on and
114
+ * the host can reach VTEX ID: localhost and WebOps previews do not proxy it.
115
+ */
116
+ function canRefreshToken(): boolean {
117
+ return (
118
+ !!storeConfig.experimental?.refreshToken &&
119
+ !shouldBypassRefreshTokenBrowser()
120
+ )
121
+ }
122
+
123
+ /**
124
+ * With deliveryPromise on and no postalCode in the session, fills it from the
125
+ * B2B saved address or from the shopper location.
126
+ */
127
+ async function syncDeliveryPromisePostalCode(session: Session) {
128
+ if (
129
+ !storeConfig.deliveryPromise?.enabled ||
130
+ (session.postalCode &&
131
+ session.postalCode !== storeConfig.session.postalCode)
132
+ ) {
133
+ return
134
+ }
135
+
136
+ // Case B2B: If a B2B shopper is logged in and a saved address is available, the postalCode field is automatically updated with the postal code from that address by the B2B session apps (shopper-session and profile-session).
137
+ if (session.b2b?.savedPostalCode) {
138
+ sessionStore.set({
139
+ ...session,
140
+ postalCode: session.b2b.savedPostalCode,
141
+ })
142
+ return
143
+ }
144
+
145
+ const sessionWithLocation = await getPostalCode(session)
146
+ !!sessionWithLocation && sessionStore.set(sessionWithLocation)
147
+ }
148
+
112
149
  function isRefreshAfterExpired(session: Session): boolean {
113
150
  return (
114
151
  !!session.refreshAfter &&
@@ -126,12 +163,8 @@ export const validateSession = async (session: Session) => {
126
163
  // If the refreshToken is enabled and the refreshAfter is expired, refresh the token.
127
164
  // On success, continue to the validation flow with the refreshed session.
128
165
  // On failure (logoutAndClearSession already triggered), bail out.
129
- // Skipped in local environments where the refresh token infrastructure is unavailable.
130
- if (
131
- !isLocalHostBrowser() &&
132
- storeConfig.experimental?.refreshToken &&
133
- isRefreshAfterExpired(session)
134
- ) {
166
+ // Skipped on localhost and WebOps previews, where the refresh token infrastructure is unavailable.
167
+ if (canRefreshToken() && isRefreshAfterExpired(session)) {
135
168
  const refreshed = await handleRefreshToken(session)
136
169
  if (!refreshed) {
137
170
  return null
@@ -139,23 +172,7 @@ export const validateSession = async (session: Session) => {
139
172
  session = refreshed
140
173
  }
141
174
 
142
- // If deliveryPromise is enabled and there is no postalCode in the session
143
- if (
144
- storeConfig.deliveryPromise?.enabled &&
145
- (!session.postalCode ||
146
- session.postalCode === storeConfig.session.postalCode)
147
- ) {
148
- // Case B2B: If a B2B shopper is logged in and a saved address is available, the postalCode field is automatically updated with the postal code from that address by the B2B session apps (shopper-session and profile-session).
149
- if (session.b2b && session.b2b?.savedPostalCode) {
150
- sessionStore.set({
151
- ...session,
152
- postalCode: session.b2b.savedPostalCode,
153
- })
154
- } else {
155
- const sessionWithLocation = await getPostalCode(session)
156
- !!sessionWithLocation && sessionStore.set(sessionWithLocation)
157
- }
158
- }
175
+ await syncDeliveryPromisePostalCode(session)
159
176
 
160
177
  try {
161
178
  // Prevents to call ValidateSession without session (required) and get Error
@@ -173,12 +190,7 @@ export const validateSession = async (session: Session) => {
173
190
 
174
191
  return data.validateSession
175
192
  } catch (error) {
176
- const shouldRefreshToken =
177
- !isLocalHostBrowser() &&
178
- error?.status === 401 &&
179
- storeConfig.experimental?.refreshToken
180
-
181
- if (shouldRefreshToken) {
193
+ if (error?.status === 401 && canRefreshToken()) {
182
194
  const refreshed = await handleRefreshToken(session)
183
195
  if (refreshed) {
184
196
  sessionStore.set(refreshed)
@@ -3,6 +3,7 @@ import ClientCMS from '@vtex/client-cms'
3
3
 
4
4
  import MultipleContentError from 'src/sdk/error/MultipleContentError'
5
5
  import { getStoreURL } from 'src/sdk/localization/useLocalizationConfig'
6
+ import { createPromiseCache } from 'src/server/content/promiseCache'
6
7
  import { sanitizeHost } from 'src/utils/utilities'
7
8
  import config from '../../../discovery.config'
8
9
 
@@ -79,16 +80,64 @@ export const clientCMS = new ClientCMS({
79
80
  host: sanitizeHost(new URL(getStoreURL()).origin),
80
81
  })
81
82
 
83
+ type CMSPageResult = { data: ContentData[] }
84
+
85
+ export const cmsPageCache = createPromiseCache<CMSPageResult>()
86
+
87
+ type GetCMSPageOptions = {
88
+ /**
89
+ * Reuse a document fetched by locator. Only safe when the locator does not
90
+ * come from a preview, since preview versions change between requests.
91
+ */
92
+ cache?: boolean
93
+ }
94
+
95
+ /**
96
+ * Listings by content type never carry preview data (previews always resolve
97
+ * to a locator), so they are shared across pages. Filtered listings depend on
98
+ * the page (e.g. landing page slug) and stay uncached.
99
+ */
82
100
  export const getCMSPage = async (
83
101
  options: Options,
84
- cmsClient: ClientCMS = clientCMS
85
- ) => {
102
+ cmsClient: ClientCMS = clientCMS,
103
+ { cache = false }: GetCMSPageOptions = {}
104
+ ): Promise<CMSPageResult> => {
105
+ const shareable = cmsClient === clientCMS
106
+
86
107
  if (isLocator(options)) {
87
- return await cmsClient
88
- .getCMSPage(options)
89
- .then((page) => ({ data: [page] }))
108
+ const fetchByLocator = () =>
109
+ cmsClient.getCMSPage(options).then((page) => ({ data: [page] }))
110
+
111
+ if (!cache || !shareable) {
112
+ return fetchByLocator()
113
+ }
114
+
115
+ const { contentType, documentId, versionId, releaseId } = options
116
+ return cmsPageCache.get(
117
+ JSON.stringify([
118
+ 'locator',
119
+ contentType,
120
+ documentId,
121
+ versionId,
122
+ releaseId,
123
+ ]),
124
+ fetchByLocator
125
+ )
126
+ }
127
+
128
+ if (options.filters || !shareable) {
129
+ return listCMSPages(options, cmsClient)
90
130
  }
91
131
 
132
+ return cmsPageCache.get(JSON.stringify(['list', options.contentType]), () =>
133
+ listCMSPages(options, cmsClient)
134
+ )
135
+ }
136
+
137
+ const listCMSPages = async (
138
+ options: Exclude<Options, Locator>,
139
+ cmsClient: ClientCMS
140
+ ): Promise<CMSPageResult> => {
92
141
  const pages = []
93
142
  const page = 1
94
143
  const perPage = 10
@@ -124,8 +173,11 @@ export const getCMSPage = async (
124
173
  return { data: pages }
125
174
  }
126
175
 
127
- export const getPage = async <T extends ContentData>(options: Options) => {
128
- const result = await getCMSPage(options)
176
+ export const getPage = async <T extends ContentData>(
177
+ options: Options,
178
+ getOptions?: GetCMSPageOptions
179
+ ) => {
180
+ const result = await getCMSPage(options, clientCMS, getOptions)
129
181
 
130
182
  const pages = result.data
131
183
 
@@ -58,11 +58,16 @@ const getPDPFromCmsEnvData = async (
58
58
 
59
59
  const template = findBestPDPTemplate(pages, product)
60
60
 
61
- return getPage<PDPContentType>({
62
- contentType: 'pdp',
63
- documentId: template.documentId as string,
64
- versionId: template.versionId,
65
- })
61
+ // The template locator comes from the build-time CMS data, never from a
62
+ // preview, so the fetched document can be shared across page generations.
63
+ return getPage<PDPContentType>(
64
+ {
65
+ contentType: 'pdp',
66
+ documentId: template.documentId as string,
67
+ versionId: template.versionId,
68
+ },
69
+ { cache: true }
70
+ )
66
71
  }
67
72
 
68
73
  const getPDPFromCms = async (
@@ -74,11 +74,16 @@ const getPLPFromCmsEnvData = async (
74
74
 
75
75
  const template = findBestPLPTemplate(pages, slug, rewrites)
76
76
 
77
- return getPage<PLPContentType>({
78
- contentType: 'plp',
79
- documentId: template.documentId as string,
80
- versionId: template.versionId,
81
- })
77
+ // The template locator comes from the build-time CMS data, never from a
78
+ // preview, so the fetched document can be shared across page generations.
79
+ return getPage<PLPContentType>(
80
+ {
81
+ contentType: 'plp',
82
+ documentId: template.documentId as string,
83
+ versionId: template.versionId,
84
+ },
85
+ { cache: true }
86
+ )
82
87
  }
83
88
 
84
89
  export const getPLPFromCms = async (
@@ -0,0 +1,68 @@
1
+ // Bounds how stale shared CMS content can get in long-lived processes (ISR);
2
+ // a CMS publish already triggers a new build.
3
+ export const CONTENT_CACHE_TTL_MS = 5 * 60 * 1000
4
+
5
+ type PromiseCacheEntry<T> = {
6
+ expiresAt: number
7
+ promise: Promise<T>
8
+ }
9
+
10
+ /**
11
+ * Per-process memoization of async loaders. Concurrent callers share the
12
+ * in-flight promise, entries expire after `ttlMs`, and rejections are evicted
13
+ * so the next caller retries. When `shouldCache` rejects a resolved value, the
14
+ * entry is evicted too, so only the callers already waiting share that value.
15
+ * Expired entries are also dropped whenever a new one is stored, so keys that
16
+ * are never requested again (e.g. superseded document versions) do not pile up
17
+ * for the lifetime of the process.
18
+ */
19
+ export function createPromiseCache<T>(ttlMs = CONTENT_CACHE_TTL_MS) {
20
+ const entries = new Map<string, PromiseCacheEntry<T>>()
21
+
22
+ return {
23
+ get(
24
+ key: string,
25
+ load: () => Promise<T>,
26
+ shouldCache: (value: T) => boolean = () => true
27
+ ): Promise<T> {
28
+ const cached = entries.get(key)
29
+ if (cached && cached.expiresAt > Date.now()) {
30
+ return cached.promise
31
+ }
32
+
33
+ const evict = () => {
34
+ if (entries.get(key)?.promise === promise) {
35
+ entries.delete(key)
36
+ }
37
+ }
38
+
39
+ const now = Date.now()
40
+ entries.forEach((entry, entryKey) => {
41
+ if (entry.expiresAt <= now) entries.delete(entryKey)
42
+ })
43
+
44
+ const promise = load().then(
45
+ (value) => {
46
+ if (!shouldCache(value)) evict()
47
+ return value
48
+ },
49
+ (err: unknown) => {
50
+ evict()
51
+ throw err
52
+ }
53
+ )
54
+
55
+ entries.set(key, { expiresAt: now + ttlMs, promise })
56
+
57
+ return promise
58
+ },
59
+
60
+ clear() {
61
+ entries.clear()
62
+ },
63
+
64
+ get size() {
65
+ return entries.size
66
+ },
67
+ }
68
+ }
@@ -14,31 +14,29 @@ import {
14
14
  import config from '../../../discovery.config'
15
15
  import { getPDP, type PDPContentType } from '../cms/pdp'
16
16
  import { getPLP, type PLPContentType } from '../cms/plp'
17
+ import { createPromiseCache } from './promiseCache'
17
18
  import type { ContentOptions, ContentParams } from './types'
18
19
  import { isBranchPreview, isContentPlatformSource } from './utils'
19
20
 
20
- type ContentResult = ContentData | (ContentEntry & PageContentType)
21
+ type ContentResult = ContentData | PageContentType
21
22
 
22
- type ContentListCacheEntry = {
23
- expiresAt: number
24
- promise: Promise<{ data: ContentResult[] }>
25
- }
23
+ const CONTENT_AUTHOR_FIELDS = ['createdBy', 'updatedBy'] as const
26
24
 
27
25
  const OPTIONAL_CONTENT_TYPES = [
28
26
  'globalHeaderSections',
29
27
  'globalFooterSections',
30
28
  ] as const
31
29
 
32
- // Bounds how stale the template list can get in long-lived processes (ISR);
33
- // a CMS publish already triggers a new build.
34
- const CONTENT_LIST_CACHE_TTL_MS = 5 * 60 * 1000
35
-
36
30
  export class ContentService {
37
31
  private readonly clientCP = new ClientCP({
38
32
  tenant: config.api.storeId,
39
33
  })
40
34
 
41
- private readonly contentListCache = new Map<string, ContentListCacheEntry>()
35
+ private readonly contentListCache = createPromiseCache<{
36
+ data: ContentResult[]
37
+ }>()
38
+
39
+ private readonly firstEntryCache = createPromiseCache<PageContentType>()
42
40
 
43
41
  async getSingleContent<T extends ContentData>(
44
42
  params: ContentParams
@@ -48,7 +46,7 @@ export class ContentService {
48
46
  if (isContentPlatformSource()) {
49
47
  return this.getFromCP<T>(options)
50
48
  }
51
- return getPage(options.cmsOptions)
49
+ return getPage(options.cmsOptions, { cache: !options.isPreview })
52
50
  }
53
51
 
54
52
  async getMultipleContent(
@@ -134,36 +132,15 @@ export class ContentService {
134
132
  private getCachedContentList(
135
133
  serviceParams: EntryPathParams
136
134
  ): Promise<{ data: ContentResult[] }> {
137
- // listEntries only depends on these fields; the page slug must stay out of the key.
138
- const { accountName, storeId, contentType, locale } = serviceParams
139
- const key = JSON.stringify([accountName, storeId, contentType, locale])
140
-
141
- const cached = this.contentListCache.get(key)
142
- if (cached && cached.expiresAt > Date.now()) {
143
- return cached.promise
144
- }
145
-
146
- const promise = this.listAndFillEntries(serviceParams, false).catch(
147
- (err: unknown) => {
148
- if (this.contentListCache.get(key)?.promise === promise) {
149
- this.contentListCache.delete(key)
150
- }
151
- throw err
152
- }
135
+ return this.contentListCache.get(getContentTypeKey(serviceParams), () =>
136
+ this.listAndFillEntries(serviceParams, false)
153
137
  )
154
-
155
- this.contentListCache.set(key, {
156
- expiresAt: Date.now() + CONTENT_LIST_CACHE_TTL_MS,
157
- promise,
158
- })
159
-
160
- return promise
161
138
  }
162
139
 
163
140
  private async listAndFillEntries(
164
141
  serviceParams: EntryPathParams,
165
142
  isPreview: boolean
166
- ): Promise<{ data: (ContentEntry & PageContentType)[] }> {
143
+ ): Promise<{ data: PageContentType[] }> {
167
144
  // TODO: This should be temporary until the cp data-plane have a search engine.
168
145
  const MAX_PAGES = 25
169
146
  const allEntries: ContentEntry[] = []
@@ -189,7 +166,7 @@ export class ContentService {
189
166
  entries: ContentEntry[],
190
167
  serviceParams: EntryPathParams,
191
168
  isPreview: boolean
192
- ): Promise<{ data: (ContentEntry & PageContentType)[] }> {
169
+ ): Promise<{ data: PageContentType[] }> {
193
170
  const data = await Promise.all(
194
171
  entries.map(async (entry) => {
195
172
  const entryData = await this.getEntryData(
@@ -206,9 +183,21 @@ export class ContentService {
206
183
  params: EntryPathParams,
207
184
  isPreview: boolean
208
185
  ): Promise<PageContentType> {
209
- return params.entryId || params.slug
210
- ? await this.getEntryData(params, isPreview)
211
- : await this.fetchFirstEntryFromList(params, isPreview)
186
+ if (params.entryId || params.slug) {
187
+ return this.getEntryData(params, isPreview)
188
+ }
189
+
190
+ if (isPreview) {
191
+ return this.fetchFirstEntryFromList(params, true)
192
+ }
193
+
194
+ // An empty required type is likely content that is about to be published,
195
+ // so it is not kept for the whole TTL. Optional types are empty by design.
196
+ return this.firstEntryCache.get(
197
+ getContentTypeKey(params),
198
+ () => this.fetchFirstEntryFromList(params, false),
199
+ (entry) => !isEmptyEntry(entry) || isOptionalContentType(params)
200
+ )
212
201
  }
213
202
 
214
203
  private async getEntryData(
@@ -220,15 +209,21 @@ export class ContentService {
220
209
  throw new Error(`${operation} requires entryId or slug`)
221
210
  }
222
211
 
212
+ const entry = await this.loadEntry(params, isPreview)
213
+
214
+ return withoutContentAuthorMetadata(entry as PageContentType)
215
+ }
216
+
217
+ private loadEntry(params: EntryPathParams, isPreview: boolean) {
223
218
  if (isPreview) {
224
219
  return params.entryId
225
- ? (this.clientCP.previewEntryById(params) as Promise<PageContentType>)
226
- : (this.clientCP.previewEntryBySlug(params) as Promise<PageContentType>)
220
+ ? this.clientCP.previewEntryById(params)
221
+ : this.clientCP.previewEntryBySlug(params)
227
222
  }
228
223
 
229
224
  return params.entryId
230
- ? (this.clientCP.getEntry(params) as Promise<PageContentType>)
231
- : (this.clientCP.getEntryBySlug(params) as Promise<PageContentType>)
225
+ ? this.clientCP.getEntry(params)
226
+ : this.clientCP.getEntryBySlug(params)
232
227
  }
233
228
 
234
229
  private async fetchFirstEntryFromList(
@@ -237,11 +232,7 @@ export class ContentService {
237
232
  ): Promise<PageContentType> {
238
233
  const { entries } = await this.clientCP.listEntries(params)
239
234
  if (!entries || entries.length === 0) {
240
- const isOptional = OPTIONAL_CONTENT_TYPES.includes(
241
- params.contentType as (typeof OPTIONAL_CONTENT_TYPES)[number]
242
- )
243
-
244
- if (!isOptional) {
235
+ if (!isOptionalContentType(params)) {
245
236
  console.warn('No entries found for params', params)
246
237
  }
247
238
  return {} as PageContentType
@@ -341,16 +332,45 @@ export class ContentService {
341
332
  private mergeEntryWithData(
342
333
  entry: ContentEntry,
343
334
  data: PageContentType
344
- ): ContentEntry & PageContentType {
335
+ ): PageContentType {
345
336
  return {
346
- ...entry,
347
- ...data,
337
+ ...withoutContentAuthorMetadata(data),
348
338
  id: entry.id,
349
339
  name: entry.name ?? '',
350
340
  }
351
341
  }
352
342
  }
353
343
 
344
+ function isOptionalContentType({ contentType }: EntryPathParams): boolean {
345
+ return OPTIONAL_CONTENT_TYPES.includes(
346
+ contentType as (typeof OPTIONAL_CONTENT_TYPES)[number]
347
+ )
348
+ }
349
+
350
+ function isEmptyEntry(entry: PageContentType): boolean {
351
+ return Object.keys(entry).length === 0
352
+ }
353
+
354
+ // listEntries only depends on these fields; the page slug must stay out of the key.
355
+ function getContentTypeKey({
356
+ accountName,
357
+ storeId,
358
+ contentType,
359
+ locale,
360
+ }: EntryPathParams): string {
361
+ return JSON.stringify([accountName, storeId, contentType, locale])
362
+ }
363
+
364
+ function withoutContentAuthorMetadata<T extends object>(value: T): T {
365
+ const content = { ...value } as Record<string, unknown>
366
+
367
+ for (const field of CONTENT_AUTHOR_FIELDS) {
368
+ delete content[field]
369
+ }
370
+
371
+ return content as T
372
+ }
373
+
354
374
  function isNotFoundError(err: unknown): boolean {
355
375
  if (err instanceof MissingContentError) return true
356
376
  if (err instanceof Error && /\b404\b/.test(err.message)) return true
@@ -0,0 +1,53 @@
1
+ import storeConfig from 'discovery.config'
2
+
3
+ const parseHostname = (url: unknown): string | undefined => {
4
+ if (typeof url !== 'string' || !url) {
5
+ return undefined
6
+ }
7
+
8
+ try {
9
+ return new URL(url).hostname.toLowerCase()
10
+ } catch {
11
+ return undefined
12
+ }
13
+ }
14
+
15
+ /**
16
+ * Common parent of the store host and the checkout host (`secureSubdomain`)
17
+ * when checkout runs on a sibling host: `www.store.com` and `secure.store.com`
18
+ * share `store.com`.
19
+ *
20
+ * Returns `undefined` when a cookie scoped to the store host already reaches
21
+ * checkout (same host, or checkout on a subdomain of it), and when the hosts
22
+ * share fewer than two labels.
23
+ */
24
+ export const getCheckoutParentDomain = (
25
+ hostname: string
26
+ ): string | undefined => {
27
+ const checkoutHostname = parseHostname(storeConfig.secureSubdomain)
28
+
29
+ if (!checkoutHostname) {
30
+ return undefined
31
+ }
32
+
33
+ const storeLabels = hostname.toLowerCase().split('.').reverse()
34
+ const checkoutLabels = checkoutHostname.split('.').reverse()
35
+ const common: string[] = []
36
+
37
+ for (
38
+ let index = 0;
39
+ index < Math.min(storeLabels.length, checkoutLabels.length) &&
40
+ storeLabels[index] === checkoutLabels[index];
41
+ index++
42
+ ) {
43
+ common.unshift(storeLabels[index])
44
+ }
45
+
46
+ if (common.length < 2) {
47
+ return undefined
48
+ }
49
+
50
+ const parent = common.join('.')
51
+
52
+ return parent === hostname.toLowerCase() ? undefined : parent
53
+ }
@@ -1,9 +1,5 @@
1
1
  /**
2
2
  * Hostnames that represent a local development environment.
3
- *
4
- * These are the only hosts where the FastStore app skips the refresh-token
5
- * flow so developers can drive the app with a manually injected
6
- * `VtexIdclientAutCookie_<account>` cookie regardless of feature flags.
7
3
  */
8
4
  const LOCAL_HOSTNAMES = new Set(['localhost', '127.0.0.1'])
9
5
 
@@ -11,9 +7,8 @@ const LOCAL_HOSTNAMES = new Set(['localhost', '127.0.0.1'])
11
7
  * Returns `true` when the given hostname corresponds to a local development
12
8
  * environment (`localhost` / `127.0.0.1`).
13
9
  *
14
- * Refresh-token and other production-only flows MUST be bypassed on these
15
- * hostnames so that developers can test logged-in scenarios with manually
16
- * injected cookies, independent of any experimental flag.
10
+ * To decide whether to skip the refresh-token flow, use
11
+ * `shouldBypassRefreshToken`, which also covers WebOps preview hosts.
17
12
  *
18
13
  * The input MUST be a bare hostname (no port). Use {@link getRequestHostname}
19
14
  * on server-side request headers before calling this helper.
@@ -22,12 +17,3 @@ export function isLocalHost(hostname: string | null | undefined): boolean {
22
17
  if (!hostname) return false
23
18
  return LOCAL_HOSTNAMES.has(hostname.toLowerCase())
24
19
  }
25
-
26
- /**
27
- * Browser-side wrapper that reads `window.location.hostname`. Returns `false`
28
- * when `window` is not available (SSR/Node).
29
- */
30
- export function isLocalHostBrowser(): boolean {
31
- if (typeof window === 'undefined') return false
32
- return isLocalHost(window.location.hostname)
33
- }