@faststore/core 4.10.1-dev.4 → 4.10.1-dev.6

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/CHANGELOG.md CHANGED
@@ -3,6 +3,23 @@
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
+ ## [4.10.1-dev.6](https://github.com/vtex/faststore/compare/v4.10.1-dev.5...v4.10.1-dev.6) (2026-10-28)
7
+
8
+ ### Bug Fixes
9
+
10
+ - **core:** document that gallery scope keys reach the request URL ([dc61bdf](https://github.com/vtex/faststore/commit/dc61bdf2411b88ac15d29066a668a26f521785de))
11
+ - **core:** hash the B2B contract scope sent with gallery queries ([f8bfac9](https://github.com/vtex/faststore/commit/f8bfac9f5dd4fbda848982a701e54eb1f3d2c14b))
12
+ - **core:** refetch PLP first page for B2B buyers so the contract assortment applies ([72ddfdf](https://github.com/vtex/faststore/commit/72ddfdf06b7cc7a74aca7036cce37c400fef2cc0))
13
+ - **core:** tell B2B buyers apart by org unit in the gallery cache key ([c7fe237](https://github.com/vtex/faststore/commit/c7fe237088515c40a49f34b46297022279c57f93))
14
+ - **core:** widen the B2B contract scope hash to 53 bits ([f05f556](https://github.com/vtex/faststore/commit/f05f556c135b914af7c1058633ca50144a41669c))
15
+
16
+ ## [4.10.1-dev.5](https://github.com/vtex/faststore/compare/v4.10.1-dev.4...v4.10.1-dev.5) (2026-10-28)
17
+
18
+ ### Bug Fixes
19
+
20
+ - **core:** bypass refresh-token flow on WebOps preview hosts ([0c8db36](https://github.com/vtex/faststore/commit/0c8db363b2f62ff054e3f3524cd15951b3aafe91))
21
+ - **core:** detect preview hosts from x-forwarded-host on the server ([ec5d8a7](https://github.com/vtex/faststore/commit/ec5d8a7c70eca06c90153fe7cb5b58aa192bfb46))
22
+
6
23
  ## [4.10.1-dev.4](https://github.com/vtex/faststore/compare/v4.10.1-dev.3...v4.10.1-dev.4) (2026-10-28)
7
24
 
8
25
  **Note:** Version bump only for package @faststore/core
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@faststore/core",
3
- "version": "4.10.1-dev.4",
3
+ "version": "4.10.1-dev.6",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -71,11 +71,11 @@
71
71
  "style-loader": "^3.3.1",
72
72
  "swr": "^2.2.5",
73
73
  "use-sync-external-store": "^1.6.0",
74
- "@faststore/api": "4.10.1-dev.4",
75
- "@faststore/diagnostics": "4.10.1-dev.4",
76
- "@faststore/lighthouse": "4.10.1-dev.4",
77
- "@faststore/sdk": "4.10.1-dev.4",
78
- "@faststore/ui": "4.10.1-dev.4"
74
+ "@faststore/api": "4.10.1-dev.6",
75
+ "@faststore/lighthouse": "4.10.1-dev.6",
76
+ "@faststore/diagnostics": "4.10.1-dev.6",
77
+ "@faststore/ui": "4.10.1-dev.6",
78
+ "@faststore/sdk": "4.10.1-dev.6"
79
79
  },
80
80
  "devDependencies": {
81
81
  "@cypress/code-coverage": "^3.12.1",
@@ -1,5 +1,6 @@
1
1
  import {
2
2
  BadRequestError,
3
+ type CacheControl,
3
4
  UnauthorizedError,
4
5
  isFastStoreError,
5
6
  stringifyCacheControl,
@@ -10,7 +11,7 @@ import type { NextApiHandler, NextApiRequest } from 'next'
10
11
  import discoveryConfig from 'discovery.config'
11
12
  import { getJWTAutCookie } from 'src/utils/getCookie'
12
13
  import { getRequestHostname } from 'src/utils/getRequestHostname'
13
- import { isLocalHost } from 'src/utils/isLocalHost'
14
+ import { shouldBypassRefreshTokenForRequest } from 'src/utils/shouldBypassRefreshToken'
14
15
  import {
15
16
  isHostAllowed,
16
17
  removeCookieDomain,
@@ -260,6 +261,87 @@ const respondToUnexpectedError = (
260
261
  response.status(500).end()
261
262
  }
262
263
 
264
+ /**
265
+ * With `experimental.refreshToken` on, rejects a ValidateSession with 401 when
266
+ * the token needs a refresh, so the client runs the refresh-token flow first.
267
+ *
268
+ * Skipped on localhost and WebOps previews: neither proxies VTEX ID, so the
269
+ * refresh would fail and log out a manually injected auth cookie.
270
+ */
271
+ const assertValidateSessionTokenIsFresh = ({
272
+ request,
273
+ operationName,
274
+ sessionRefreshAfter,
275
+ }: {
276
+ request: NextApiRequest
277
+ operationName: string | undefined
278
+ sessionRefreshAfter: string | null | undefined
279
+ }) => {
280
+ if (
281
+ operationName !== 'ValidateSession' ||
282
+ !discoveryConfig.experimental?.refreshToken ||
283
+ shouldBypassRefreshTokenForRequest(request.headers)
284
+ ) {
285
+ return
286
+ }
287
+
288
+ const jwt = getJWTAutCookie({
289
+ headers: request.headers,
290
+ account: discoveryConfig.api.storeId,
291
+ })
292
+
293
+ if (shouldForceRefreshTokenForValidateSession({ jwt, sessionRefreshAfter })) {
294
+ throw new UnauthorizedError(
295
+ 'Unauthorized: Token expired. Please login again or refresh the page.'
296
+ )
297
+ }
298
+ }
299
+
300
+ /**
301
+ * Cache-Control for a successful response: the directives collected while
302
+ * executing the operation when there are any, otherwise a default
303
+ * `s-maxage`/`stale-while-revalidate` for GET queries and `no-store` for
304
+ * everything else.
305
+ */
306
+ const getSuccessCacheControl = ({
307
+ request,
308
+ operationName,
309
+ cacheControl,
310
+ hasAuthCookie,
311
+ }: {
312
+ request: NextApiRequest
313
+ operationName: string | undefined
314
+ cacheControl: CacheControl | undefined
315
+ hasAuthCookie: boolean
316
+ }) => {
317
+ if (cacheControl) {
318
+ return stringifyCacheControl(cacheControl, hasAuthCookie)
319
+ }
320
+
321
+ if (
322
+ request.method !== 'GET' ||
323
+ !operationName?.toLowerCase()?.endsWith('query')
324
+ ) {
325
+ return 'no-cache, no-store'
326
+ }
327
+
328
+ const graphqlCacheControl = discoveryConfig?.experimental?.graphqlCacheControl
329
+
330
+ const maxAge =
331
+ graphqlCacheControl?.maxAge && graphqlCacheControl.maxAge > 0
332
+ ? graphqlCacheControl.maxAge
333
+ : DEFAULT_MAX_AGE // 5 minutes
334
+
335
+ const staleWhileRevalidate =
336
+ graphqlCacheControl?.staleWhileRevalidate &&
337
+ graphqlCacheControl.staleWhileRevalidate > 0
338
+ ? graphqlCacheControl.staleWhileRevalidate
339
+ : DEFAULT_STALE_WHILE_REVALIDATE // 1 hour
340
+
341
+ const scope = hasAuthCookie ? 'private' : 'public'
342
+ return `${scope}, s-maxage=${maxAge}, stale-while-revalidate=${staleWhileRevalidate}`
343
+ }
344
+
263
345
  const handler: NextApiHandler = async (request, response) => {
264
346
  if (request.method !== 'POST' && request.method !== 'GET') {
265
347
  response.status(405).end()
@@ -286,29 +368,11 @@ const handler: NextApiHandler = async (request, response) => {
286
368
  operation?.__meta__?.operationHash
287
369
  )
288
370
 
289
- const isLocal = isLocalHost(getRequestHostname(request.headers.host))
290
-
291
- if (
292
- !isLocal &&
293
- operation.__meta__.operationName === 'ValidateSession' &&
294
- discoveryConfig.experimental?.refreshToken
295
- ) {
296
- const jwt = getJWTAutCookie({
297
- headers: request.headers,
298
- account: discoveryConfig.api.storeId,
299
- })
300
-
301
- const shouldRefreshToken = shouldForceRefreshTokenForValidateSession({
302
- jwt,
303
- sessionRefreshAfter: variables?.session?.refreshAfter,
304
- })
305
-
306
- if (shouldRefreshToken) {
307
- throw new UnauthorizedError(
308
- 'Unauthorized: Token expired. Please login again or refresh the page.'
309
- )
310
- }
311
- }
371
+ assertValidateSessionTokenIsFresh({
372
+ request,
373
+ operationName: operation.__meta__.operationName,
374
+ sessionRefreshAfter: variables?.session?.refreshAfter,
375
+ })
312
376
 
313
377
  // Prevents to call ValidateSession or ValidateCartMutation without session (required) and get GraphQLError
314
378
  const doNotRun =
@@ -338,39 +402,15 @@ const handler: NextApiHandler = async (request, response) => {
338
402
 
339
403
  const hasAuthCookie = hasVtexIdclientAutCookie(request)
340
404
 
341
- if (extensions.cacheControl) {
342
- const cacheControl = stringifyCacheControl(
343
- extensions.cacheControl,
344
- hasAuthCookie
345
- )
346
- response.setHeader('cache-control', cacheControl)
347
- } else if (
348
- request.method === 'GET' &&
349
- operation.__meta__.operationName?.toLowerCase()?.endsWith('query')
350
- ) {
351
- const maxAge =
352
- discoveryConfig?.experimental?.graphqlCacheControl?.maxAge &&
353
- discoveryConfig?.experimental?.graphqlCacheControl?.maxAge > 0
354
- ? discoveryConfig.experimental.graphqlCacheControl.maxAge
355
- : DEFAULT_MAX_AGE // 5 minutes
356
-
357
- const staleWhileRevalidate =
358
- discoveryConfig?.experimental?.graphqlCacheControl
359
- ?.staleWhileRevalidate &&
360
- discoveryConfig?.experimental?.graphqlCacheControl
361
- ?.staleWhileRevalidate > 0
362
- ? discoveryConfig.experimental.graphqlCacheControl
363
- .staleWhileRevalidate
364
- : DEFAULT_STALE_WHILE_REVALIDATE // 1 hour
365
-
366
- const scope = hasAuthCookie ? 'private' : 'public'
367
- response.setHeader(
368
- 'cache-control',
369
- `${scope}, s-maxage=${maxAge}, stale-while-revalidate=${staleWhileRevalidate}`
370
- )
371
- } else {
372
- response.setHeader('cache-control', 'no-cache, no-store')
373
- }
405
+ response.setHeader(
406
+ 'cache-control',
407
+ getSuccessCacheControl({
408
+ request,
409
+ operationName: operation.__meta__.operationName,
410
+ cacheControl: extensions.cacheControl,
411
+ hasAuthCookie,
412
+ })
413
+ )
374
414
 
375
415
  const setCookieValues = Array.from(extensions.cookies.values())
376
416
  if (setCookieValues.length > 0 && !hasErrors) {
@@ -24,8 +24,7 @@ import PageProvider from 'src/sdk/overrides/PageProvider'
24
24
  import { execute } from 'src/server'
25
25
  import { injectGlobalSections } from 'src/server/cms/global'
26
26
  import { fetchMyAccountPageContent } from 'src/server/cms/fetchMyAccountPageContent'
27
- import { getRequestHostname } from 'src/utils/getRequestHostname'
28
- import { isLocalHost } from 'src/utils/isLocalHost'
27
+ import { shouldBypassRefreshTokenForRequest } from 'src/utils/shouldBypassRefreshToken'
29
28
  import { withLocaleValidationSSR } from 'src/utils/localization/withLocaleValidation'
30
29
  import { getMyAccountRedirect } from 'src/utils/myAccountRedirect'
31
30
 
@@ -137,14 +136,16 @@ const getServerSidePropsBase: GetServerSideProps<
137
136
 
138
137
  const statusCode: number = (account.errors[0] as any)?.extensions?.status
139
138
 
140
- const isLocal = isLocalHost(getRequestHostname(context.req.headers.host))
139
+ const bypassRefreshToken = shouldBypassRefreshTokenForRequest(
140
+ context.req.headers
141
+ )
141
142
 
142
143
  return {
143
144
  props: {
144
145
  globalSections: globalSectionsResult,
145
146
  pageSections: pageContent.sections,
146
147
  needsRefreshToken:
147
- !isLocal &&
148
+ !bypassRefreshToken &&
148
149
  (statusCode === 401 || statusCode === 403) &&
149
150
  storeConfig.experimental?.refreshToken,
150
151
  fromPage,
@@ -1,5 +1,5 @@
1
1
  import { useEffect, useState } from 'react'
2
- import { isLocalHostBrowser } from 'src/utils/isLocalHost'
2
+ import { shouldBypassRefreshTokenBrowser } from 'src/utils/shouldBypassRefreshToken'
3
3
  import { logoutAndClearSession, sessionStore } from '../session'
4
4
  import { isRefreshTokenSuccessful, refreshTokenRequest } from './refreshToken'
5
5
 
@@ -13,13 +13,12 @@ export const useRefreshToken = (
13
13
  const handleRefreshTokenAndUpdateSession = async () => {
14
14
  if (!needsRefreshToken) return
15
15
 
16
- // The refresh-token endpoint lives on the production origin and relies on
17
- // the `vid_rt` cookie scoped to that origin. From localhost the request is
18
- // cross-origin, so the cookie is not sent and the refresh always fails —
19
- // which would also wipe the manually injected `VtexIdclientAutCookie_<account>`
16
+ // The refresh-token endpoint relies on VTEX ID, which neither localhost
17
+ // nor WebOps previews proxy, so the refresh always fails there — which
18
+ // would also wipe the manually injected `VtexIdclientAutCookie_<account>`
20
19
  // via `logoutAndClearSession`. Bail out and fall back to the static 403
21
20
  // view so developers can keep testing logged-in flows.
22
- if (isLocalHostBrowser()) {
21
+ if (shouldBypassRefreshTokenBrowser()) {
23
22
  setShouldShow403(true)
24
23
  return
25
24
  }
@@ -69,6 +69,69 @@ export const query = gql(`
69
69
 
70
70
  const getKey = (object: any) => JSON.stringify(object)
71
71
 
72
+ const isDeliveryPromiseEnabled = storeConfig.deliveryPromise?.enabled ?? false
73
+
74
+ /**
75
+ * Cache-only keys that scope gallery pages to the shopper context. They make
76
+ * the local cache comparison (and the SWR key) miss when a page was loaded for
77
+ * another context (e.g. the anonymous SSG page). They are not declared by the
78
+ * operation, so the API ignores them, but they are still sent with the
79
+ * variables, which go in the URL because this query is a GET operation.
80
+ * - `_postalCode`: pages are invalidated on region change. Only applied when
81
+ * deliveryPromise is enabled to avoid unnecessary cache fragmentation.
82
+ * - `_contract`: the B2B contract assortment is applied server-side from the
83
+ * session cookie, so it never shows up in the query variables. Keyed off
84
+ * the presence of a `b2b` session (not `customerId`'s value) because
85
+ * `customerId` can resolve to an empty string for a legitimate B2B buyer
86
+ * (see `buildB2bSession` in `@faststore/api`). The fallback chain still
87
+ * forces the re-fetch for them: the real `customerId`, when present, keeps
88
+ * invalidating the cache on contract switch; otherwise the organizational
89
+ * unit (`unitId`) tells buyers of different units apart; a generic marker is
90
+ * the last resort. Since the value ends up in the URL, it is hashed so the
91
+ * raw contract or unit id is not written to access, CDN or RUM logs as is.
92
+ * These ids are not secrets (the session already hands them to the
93
+ * browser), so the hash is cosmetic, not a privacy control: it is unkeyed
94
+ * and a guessable id can be recovered from it. The URL still differs per
95
+ * contract, so a contract page is never served from the anonymous page's
96
+ * cache entry.
97
+ */
98
+ const getShopperScope = ({
99
+ postalCode,
100
+ contract,
101
+ }: {
102
+ postalCode?: string | null
103
+ contract?: string | null
104
+ }) => ({
105
+ ...(isDeliveryPromiseEnabled && { _postalCode: postalCode ?? '' }),
106
+ ...(contract && { _contract: hashContractScope(contract) }),
107
+ })
108
+
109
+ /** The id a B2B session is scoped by, or `null` outside a B2B session. */
110
+ const getContractScope = (
111
+ b2b?: { customerId?: string | null; unitId?: string | null } | null
112
+ ) => (b2b ? b2b.customerId || b2b.unitId || 'b2b' : null)
113
+
114
+ /**
115
+ * 53-bit string hash (cyrb53): wide enough that two contracts in the same
116
+ * browser never share a cache key in practice, without exposing the raw id.
117
+ */
118
+ export function hashContractScope(value: string) {
119
+ let h1 = 0xdeadbeef
120
+ let h2 = 0x41c6ce57
121
+ for (const char of value) {
122
+ const code = char.codePointAt(0) ?? 0
123
+ h1 = Math.imul(h1 ^ code, 2654435761)
124
+ h2 = Math.imul(h2 ^ code, 1597334677)
125
+ }
126
+ h1 =
127
+ Math.imul(h1 ^ (h1 >>> 16), 2246822507) ^
128
+ Math.imul(h2 ^ (h2 >>> 13), 3266489909)
129
+ h2 =
130
+ Math.imul(h2 ^ (h2 >>> 16), 2246822507) ^
131
+ Math.imul(h1 ^ (h1 >>> 13), 3266489909)
132
+ return (4294967296 * (2097151 & h2) + (h1 >>> 0)).toString(36)
133
+ }
134
+
72
135
  interface UseCreateUseGalleryPageProps {
73
136
  initialPages: ClientManyProductsQueryWithSearchIdQuery
74
137
  serverManyProductsVariables: ClientManyProductsQueryWithSearchIdQueryVariables
@@ -82,21 +145,17 @@ export const useCreateUseGalleryPage = (
82
145
  ) => {
83
146
  const initialPages = params?.initialPages?.search ? [params.initialPages] : []
84
147
 
85
- // Seed the cache with the same region-aware key shape that useGalleryPage uses,
86
- // so hasSameVariables correctly matches on first mount and avoids an unnecessary
87
- // re-fetch of the SSR-loaded page.
88
- const isDeliveryPromiseEnabled = storeConfig.deliveryPromise?.enabled ?? false
148
+ // Seed the cache with the same key shape that useGalleryPage uses, so
149
+ // hasSameVariables matches on first mount and avoids an unnecessary re-fetch
150
+ // of the SSR-loaded page. The SSG page is built without a buyer, so it is
151
+ // seeded without a contract: B2B buyers re-fetch it with their assortment.
89
152
  const { postalCode } = useSession()
90
153
  const initialVariables = params?.serverManyProductsVariables
91
154
  ? [
92
- getKey(
93
- isDeliveryPromiseEnabled
94
- ? {
95
- ...params.serverManyProductsVariables,
96
- _postalCode: postalCode ?? '',
97
- }
98
- : params.serverManyProductsVariables
99
- ),
155
+ getKey({
156
+ ...params.serverManyProductsVariables,
157
+ ...getShopperScope({ postalCode }),
158
+ }),
100
159
  ]
101
160
  : []
102
161
 
@@ -113,7 +172,7 @@ export const useCreateUseGalleryPage = (
113
172
  itemsPerPage,
114
173
  } = useSearch()
115
174
 
116
- const { postalCode, isValidating: isSessionValidating } = useSession()
175
+ const { postalCode, b2b, isValidating: isSessionValidating } = useSession()
117
176
 
118
177
  const localizedVariables = useLocalizedVariables({
119
178
  first: itemsPerPage,
@@ -123,15 +182,15 @@ export const useCreateUseGalleryPage = (
123
182
  selectedFacets,
124
183
  })
125
184
 
126
- // Include postalCode in the cache key so pages are invalidated on region change.
127
- // _postalCode is not sent to the API — it only affects the local cache comparison.
128
- // Only applied when deliveryPromise is enabled to avoid unnecessary cache fragmentation.
185
+ // Keyed on the contract id, not the `b2b` object, which is rebuilt on
186
+ // every session update.
187
+ const contract = getContractScope(b2b)
129
188
  const localizedVariablesWithRegion = useMemo(
130
- () =>
131
- isDeliveryPromiseEnabled
132
- ? { ...localizedVariables, _postalCode: postalCode ?? '' }
133
- : localizedVariables,
134
- [localizedVariables, postalCode]
189
+ () => ({
190
+ ...localizedVariables,
191
+ ...getShopperScope({ postalCode, contract }),
192
+ }),
193
+ [localizedVariables, postalCode, contract]
135
194
  )
136
195
 
137
196
  const hasSameVariables = deepEquals(
@@ -8,7 +8,7 @@ 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 {
@@ -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)
@@ -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
- }
@@ -0,0 +1,63 @@
1
+ import { getRequestHostname } from './getRequestHostname'
2
+ import { isLocalHost } from './isLocalHost'
3
+ import { isHostAllowed, singleForwardedHost } from './trustedForwardedHost'
4
+
5
+ /**
6
+ * WebOps preview deployments are served under `<preview>.preview.vtex.app`.
7
+ *
8
+ * Only this subdomain is matched: `{account}.vtex.app` is a production default
9
+ * domain and MUST keep the refresh-token flow.
10
+ */
11
+ const PREVIEW_HOST_SUFFIXES = ['.preview.vtex.app']
12
+
13
+ /**
14
+ * Returns `true` when the refresh-token flow must be skipped for the given
15
+ * hostname: local development (`localhost` / `127.0.0.1`) and WebOps previews.
16
+ *
17
+ * Neither environment proxies VTEX ID, so `/api/vtexid/refreshtoken/webstore`
18
+ * always fails there and the resulting logout wipes a manually injected
19
+ * `VtexIdclientAutCookie_<account>` cookie. Skipping the flow lets developers
20
+ * test logged-in scenarios with that cookie alone.
21
+ *
22
+ * The input MUST be a bare hostname (no port). Use {@link getRequestHostname}
23
+ * on server-side request headers before calling this helper. A trailing dot
24
+ * (fully qualified name) is ignored.
25
+ */
26
+ export function shouldBypassRefreshToken(
27
+ hostname: string | null | undefined
28
+ ): boolean {
29
+ if (!hostname) return false
30
+ const normalizedHostname = hostname.toLowerCase().replace(/\.$/, '')
31
+ return (
32
+ isLocalHost(normalizedHostname) ||
33
+ isHostAllowed(normalizedHostname, PREVIEW_HOST_SUFFIXES)
34
+ )
35
+ }
36
+
37
+ /**
38
+ * Server-side check from the request headers. Behind the WebOps preview
39
+ * ingress the browser host arrives in `x-forwarded-host` rather than `Host`
40
+ * (see `normalizeSetCookieDomain` in `pages/api/graphql.ts`), so both are
41
+ * checked. Both are client-controlled: a forged value only skips the forced
42
+ * refresh, which grants nothing since the JWT is still validated upstream.
43
+ */
44
+ export function shouldBypassRefreshTokenForRequest(headers: {
45
+ host?: string
46
+ 'x-forwarded-host'?: string | string[]
47
+ }): boolean {
48
+ return (
49
+ shouldBypassRefreshToken(getRequestHostname(headers.host)) ||
50
+ shouldBypassRefreshToken(
51
+ getRequestHostname(singleForwardedHost(headers['x-forwarded-host']))
52
+ )
53
+ )
54
+ }
55
+
56
+ /**
57
+ * Browser-side wrapper that reads `window.location.hostname`. Returns `false`
58
+ * when `window` is not available (SSR/Node).
59
+ */
60
+ export function shouldBypassRefreshTokenBrowser(): boolean {
61
+ if (globalThis.window === undefined) return false
62
+ return shouldBypassRefreshToken(globalThis.window.location.hostname)
63
+ }