@faststore/core 4.10.1-dev.5 → 4.10.1-dev.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 (63) hide show
  1. package/.turbo/turbo-generate.log +3 -3
  2. package/.turbo/turbo-test.log +169 -157
  3. package/@generated/graphql.ts +1 -0
  4. package/@generated/schema.graphql +1 -1
  5. package/CHANGELOG.md +39 -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 +11 -2
  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 +4 -3
  21. package/src/pages/pvt/account/cards/index.tsx +5 -5
  22. package/src/pages/pvt/account/orders/[id].tsx +5 -5
  23. package/src/pages/pvt/account/orders/index.tsx +5 -5
  24. package/src/pages/pvt/account/profile.tsx +5 -5
  25. package/src/pages/pvt/account/quotes/index.tsx +5 -5
  26. package/src/pages/pvt/account/security.tsx +5 -5
  27. package/src/pages/pvt/account/user-details.tsx +6 -6
  28. package/src/sdk/account/getB2BSessionClaims.ts +1 -1
  29. package/src/sdk/account/getIsB2BBuyer.ts +28 -0
  30. package/src/sdk/account/refreshToken.ts +89 -11
  31. package/src/sdk/account/refreshTokenPaths.ts +25 -0
  32. package/src/sdk/account/useRefreshToken.ts +10 -5
  33. package/src/sdk/product/isProductNotFound.ts +13 -0
  34. package/src/sdk/product/usePageProductsQuery.ts +80 -21
  35. package/src/sdk/product/useProductNotFoundRedirect.ts +43 -0
  36. package/src/sdk/product/useProductQuery.ts +29 -0
  37. package/src/sdk/session/index.ts +4 -4
  38. package/src/utils/getCheckoutParentDomain.ts +53 -0
  39. package/src/utils/trustedForwardedHost.ts +18 -0
  40. package/test/components/account/Layout.test.tsx +1 -1
  41. package/test/components/account/Menu.browser.test.tsx +7 -5
  42. package/test/components/account/OrganizationDrawer.browser.test.tsx +13 -13
  43. package/test/components/account/OrganizationDrawerBody.test.tsx +3 -3
  44. package/test/components/account/OrganizationSignInButton.browser.test.tsx +19 -4
  45. package/test/components/navigation/Navbar.browser.test.tsx +3 -3
  46. package/test/components/navigation/NavbarAccountArea.browser.test.tsx +5 -5
  47. package/test/components/navigation/NavbarSlider.browser.test.tsx +3 -3
  48. package/test/experimental/myAccountCmsServerSideProps.test.ts +5 -5
  49. package/test/pages/api/logout.test.ts +106 -0
  50. package/test/pages/api/switch-contract.test.ts +443 -15
  51. package/test/pages/pvt/account/cards/index.test.ts +3 -3
  52. package/test/sdk/account/getIsB2BBuyer.test.ts +52 -0
  53. package/test/sdk/account/refreshToken.browser.test.ts +10 -1
  54. package/test/sdk/account/refreshToken.test.ts +150 -18
  55. package/test/sdk/account/useRefreshToken.browser.test.ts +4 -1
  56. package/test/sdk/product/usePageProductsQuery.browser.test.ts +260 -0
  57. package/test/sdk/product/usePageProductsQuery.deliveryPromise.browser.test.ts +130 -0
  58. package/test/sdk/product/useProductNotFoundRedirect.browser.test.ts +83 -0
  59. package/test/sdk/product/useProductQuery.browser.test.ts +154 -0
  60. package/test/sdk/session/refreshTokenRoute.browser.test.ts +106 -0
  61. package/test/sdk/session/validateSession.browser.test.ts +4 -1
  62. package/test/utils/trustedForwardedHost.test.ts +21 -0
  63. package/src/sdk/account/getIsRepresentative.ts +0 -20
@@ -1,10 +1,21 @@
1
1
  import fetch from 'isomorphic-unfetch'
2
2
  import { getStoreURL } from 'src/sdk/localization/useLocalizationConfig'
3
3
  import { sanitizeHost } from 'src/utils/utilities'
4
+ import {
5
+ AUTHENTICATOR_REFRESH_TOKEN_PATH,
6
+ LEGACY_REFRESH_TOKEN_PATH,
7
+ } from './refreshTokenPaths'
4
8
 
9
+ /**
10
+ * Covers both refresh routes:
11
+ * - Authenticator: `{ authStatus: 'Success', refreshAfter: 1792717383 }`
12
+ * (epoch seconds).
13
+ * - Legacy VTEX ID: `{ status: 'Success', refreshAfter: '2026-03-26T02:53:39+00:00' }`.
14
+ */
5
15
  export interface RefreshTokenResponse {
6
16
  status?: string
7
- refreshAfter?: string
17
+ authStatus?: string
18
+ refreshAfter?: string | number
8
19
  }
9
20
 
10
21
  async function fetchWithRetry(
@@ -25,12 +36,12 @@ async function fetchWithRetry(
25
36
  return undefined
26
37
  }
27
38
 
28
- function getRefreshTokenUrl(): string {
39
+ function getRefreshTokenUrl(path: string): string {
29
40
  if (globalThis.window !== undefined) {
30
- return '/api/vtexid/refreshtoken/webstore'
41
+ return path
31
42
  }
32
43
 
33
- return `${new URL(getStoreURL()).origin}/api/vtexid/refreshtoken/webstore`
44
+ return `${new URL(getStoreURL()).origin}${path}`
34
45
  }
35
46
 
36
47
  function getRefreshTokenHeaders(): HeadersInit {
@@ -45,10 +56,43 @@ function getRefreshTokenHeaders(): HeadersInit {
45
56
  return headers
46
57
  }
47
58
 
48
- export const refreshTokenRequest = async (): Promise<
49
- RefreshTokenResponse | undefined
50
- > => {
51
- return fetchWithRetry(getRefreshTokenUrl(), {
59
+ export const isRefreshTokenSuccessful = (
60
+ result: RefreshTokenResponse | undefined
61
+ ): boolean => {
62
+ return (result?.authStatus ?? result?.status)?.toLowerCase?.() === 'success'
63
+ }
64
+
65
+ /**
66
+ * Whether the session belongs to a B2B buyer: one with an organizational unit
67
+ * (`unitId`) and a contract (`customerId`), the same rule the API uses to
68
+ * fill `session.b2b`.
69
+ */
70
+ export const isB2BSession = (
71
+ session:
72
+ | {
73
+ b2b?: {
74
+ unitId?: string | null
75
+ customerId?: string | null
76
+ } | null
77
+ }
78
+ | null
79
+ | undefined
80
+ ): boolean => Boolean(session?.b2b?.unitId && session?.b2b?.customerId)
81
+
82
+ /**
83
+ * Refresh route for the session. B2B sessions use the Authenticator route,
84
+ * which keeps the selected contract (`customerId`, `unitId`,
85
+ * `isRepresentative`). Every other session keeps the legacy VTEX ID route, as
86
+ * VTEX ID recommends. There is no fallback between them: like before, a failed
87
+ * refresh logs the shopper out.
88
+ */
89
+ export const getRefreshTokenPath = (isB2B: boolean): string =>
90
+ isB2B ? AUTHENTICATOR_REFRESH_TOKEN_PATH : LEGACY_REFRESH_TOKEN_PATH
91
+
92
+ export const refreshTokenRequest = async ({
93
+ isB2B = false,
94
+ }: { isB2B?: boolean } = {}): Promise<RefreshTokenResponse | undefined> => {
95
+ return fetchWithRetry(getRefreshTokenUrl(getRefreshTokenPath(isB2B)), {
52
96
  credentials: 'include',
53
97
  headers: getRefreshTokenHeaders(),
54
98
  body: JSON.stringify({}),
@@ -56,8 +100,42 @@ export const refreshTokenRequest = async (): Promise<
56
100
  })
57
101
  }
58
102
 
59
- export const isRefreshTokenSuccessful = (
103
+ /**
104
+ * Normalizes `refreshAfter` to epoch seconds as a string, the format stored in
105
+ * the session. Accepts epoch seconds (number or numeric string) and ISO dates.
106
+ * Returns `null` when the value is missing or unparseable.
107
+ */
108
+ export const getRefreshAfterSeconds = (
60
109
  result: RefreshTokenResponse | undefined
61
- ): boolean => {
62
- return result?.status?.toLowerCase?.() === 'success'
110
+ ): string | null => {
111
+ const value = result?.refreshAfter
112
+
113
+ if (value === undefined || value === null || value === '') {
114
+ return null
115
+ }
116
+
117
+ const seconds =
118
+ typeof value === 'number' || /^\d+$/.test(value)
119
+ ? Number(value)
120
+ : new Date(value).getTime() / 1000
121
+
122
+ return Number.isFinite(seconds) ? String(Math.floor(seconds)) : null
63
123
  }
124
+
125
+ /**
126
+ * How long to wait before refreshing again when a successful refresh carries
127
+ * no usable `refreshAfter`. Keeping the old, already-expired value would make
128
+ * every following session validation call the refresh route again.
129
+ */
130
+ export const REFRESH_AFTER_FALLBACK_SECONDS = 60 * 60
131
+
132
+ /**
133
+ * `refreshAfter` to store after a successful refresh: the value from the
134
+ * response, or a short fallback from now when the response has none.
135
+ */
136
+ export const getNextRefreshAfter = (
137
+ result: RefreshTokenResponse | undefined,
138
+ nowSeconds = Math.floor(Date.now() / 1000)
139
+ ): string =>
140
+ getRefreshAfterSeconds(result) ??
141
+ String(nowSeconds + REFRESH_AFTER_FALLBACK_SECONDS)
@@ -0,0 +1,25 @@
1
+ /**
2
+ * VTEX ID refresh route. The recommended route for every non-B2B session.
3
+ * For B2B it drops the contract claims set by `switch-properties` and also
4
+ * writes the auth cookie on the parent domain.
5
+ */
6
+ export const LEGACY_REFRESH_TOKEN_PATH = '/api/vtexid/refreshtoken/webstore'
7
+
8
+ /**
9
+ * Authenticator refresh route, recommended for the B2B flow only. It keeps the
10
+ * contract claims (`customerId`, `unitId`, `isRepresentative`) and writes the
11
+ * auth cookie only on the request host.
12
+ */
13
+ export const AUTHENTICATOR_REFRESH_TOKEN_PATH =
14
+ '/api/authenticator/v1/refreshtoken/webstore'
15
+
16
+ /**
17
+ * Every path `vid_rt` can be scoped to. VTEX ID writes it only on the legacy
18
+ * path; Authenticator writes it on all three. The browser only sends it to
19
+ * these paths, so it never reaches any other route.
20
+ */
21
+ export const VID_RT_PATHS = [
22
+ LEGACY_REFRESH_TOKEN_PATH,
23
+ '/api/authenticator/refreshtoken/webstore',
24
+ AUTHENTICATOR_REFRESH_TOKEN_PATH,
25
+ ] as const
@@ -1,7 +1,12 @@
1
1
  import { useEffect, useState } from 'react'
2
2
  import { shouldBypassRefreshTokenBrowser } from 'src/utils/shouldBypassRefreshToken'
3
3
  import { logoutAndClearSession, sessionStore } from '../session'
4
- import { isRefreshTokenSuccessful, refreshTokenRequest } from './refreshToken'
4
+ import {
5
+ getNextRefreshAfter,
6
+ isB2BSession,
7
+ isRefreshTokenSuccessful,
8
+ refreshTokenRequest,
9
+ } from './refreshToken'
5
10
 
6
11
  export const useRefreshToken = (
7
12
  needsRefreshToken?: boolean,
@@ -26,13 +31,13 @@ export const useRefreshToken = (
26
31
  const currentSession = sessionStore.read() ?? sessionStore.readInitial()
27
32
 
28
33
  try {
29
- const result = await refreshTokenRequest()
34
+ const result = await refreshTokenRequest({
35
+ isB2B: isB2BSession(currentSession),
36
+ })
30
37
 
31
38
  if (isRefreshTokenSuccessful(result)) {
32
39
  // Update session with new refreshAfter timestamp
33
- const refreshAfter = String(
34
- Math.floor(new Date(result?.refreshAfter).getTime() / 1000)
35
- )
40
+ const refreshAfter = getNextRefreshAfter(result)
36
41
 
37
42
  sessionStore.set({
38
43
  ...currentSession,
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Whether a product request failed because the product is not available to
3
+ * the shopper. A non-2xx BFF response reaches the client as `{ status }` (see
4
+ * `ParseInvalidRequest`), while a 2xx one keeps the GraphQL `extensions`.
5
+ */
6
+ export const isProductNotFound = (error: unknown) => {
7
+ const { status, extensions } = (error ?? {}) as {
8
+ status?: number
9
+ extensions?: { type?: string }
10
+ }
11
+
12
+ return status === 404 || extensions?.type === 'NotFoundError'
13
+ }
@@ -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(
@@ -0,0 +1,43 @@
1
+ import { useRouter } from 'next/router'
2
+ import { useEffect, useRef } from 'react'
3
+
4
+ import { isProductNotFound } from './isProductNotFound'
5
+
6
+ /**
7
+ * The PDP is statically generated without the shopper's session, so an
8
+ * assortment restriction (e.g. a B2B contract limited to a collection) is only
9
+ * enforced when the client refetches the product. Replaces the PDP with the
10
+ * store's 404 page when that refetch says the product is not available to the
11
+ * shopper. The URL becomes `/404`: passing the product path as the `as`
12
+ * argument would make Next load the PDP's data for the 404 page, which expects
13
+ * a different shape and crashes.
14
+ *
15
+ * Applies to every shopper: the API rejects the product from the
16
+ * `vtex_segment` assortment, the same rule Intelligent Search applies to
17
+ * listings, whether or not the session is B2B.
18
+ *
19
+ * Returns whether the PDP must keep its loading state instead of the product's
20
+ * price and buy button while the redirect is pending. Without it, the failed
21
+ * refetch falls back to the static product and its price shows until the 404
22
+ * page loads.
23
+ */
24
+ export const useProductNotFoundRedirect = (error: unknown) => {
25
+ const router = useRouter()
26
+ const shouldRedirect = isProductNotFound(error)
27
+ // The router changes identity on route events, which re-runs the effect.
28
+ // Navigate once per not-found so a pending redirect is not restarted.
29
+ const hasRedirected = useRef(false)
30
+
31
+ useEffect(() => {
32
+ if (!shouldRedirect) {
33
+ hasRedirected.current = false
34
+ return
35
+ }
36
+ if (hasRedirected.current) return
37
+
38
+ hasRedirected.current = true
39
+ router.replace('/404')
40
+ }, [shouldRedirect, router])
41
+
42
+ return shouldRedirect
43
+ }
@@ -6,8 +6,11 @@ import type {
6
6
  ClientProductQueryQueryVariables,
7
7
  } from '@generated/graphql'
8
8
 
9
+ import { request } from '../graphql/request'
10
+ import { onErrorRetry } from '../graphql/retryPolicy'
9
11
  import { useQuery } from '../graphql/useQuery'
10
12
  import { useSession } from '../session'
13
+ import { isProductNotFound } from './isProductNotFound'
11
14
 
12
15
  const query = gql(`
13
16
  query ClientProductQuery($locator: [IStoreSelectedFacet!]!) {
@@ -18,6 +21,19 @@ const query = gql(`
18
21
  }
19
22
  `)
20
23
 
24
+ const nextTick = () => new Promise<void>((resolve) => setTimeout(resolve))
25
+
26
+ const fetchProduct = async <T extends ClientProductQueryQuery>(
27
+ variables: ClientProductQueryQueryVariables
28
+ ) => {
29
+ await nextTick()
30
+
31
+ return (await request<
32
+ ClientProductQueryQuery & T,
33
+ ClientProductQueryQueryVariables
34
+ >(query, variables)) as ClientProductQueryQuery & T
35
+ }
36
+
21
37
  export const useProductQuery = <T extends ClientProductQueryQuery>(
22
38
  productID: string,
23
39
  fallbackData?: T
@@ -45,5 +61,18 @@ export const useProductQuery = <T extends ClientProductQueryQuery>(
45
61
  >(query, variables, {
46
62
  fallbackData,
47
63
  revalidateOnMount: true,
64
+ // `useQuery`'s default fetcher never settles when `request` throws, so SWR
65
+ // never exposes `error` (e.g. the B2B assortment 404 the PDP reacts to).
66
+ // This fetcher keeps the same deferral but forwards the rejection.
67
+ fetcher: () => fetchProduct<T>(variables),
68
+ // A not-found is the answer (the product is not available to this
69
+ // shopper), not a transient failure, so it is not retried. It is matched
70
+ // the same way the PDP redirect matches it. Other errors follow the shared
71
+ // retry policy.
72
+ onErrorRetry: (error, key, config, revalidate, opts) => {
73
+ if (isProductNotFound(error)) return
74
+
75
+ onErrorRetry(error, key, config, revalidate, opts)
76
+ },
48
77
  })
49
78
  }
@@ -12,6 +12,8 @@ import { shouldBypassRefreshTokenBrowser } from 'src/utils/shouldBypassRefreshTo
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
 
@@ -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
+ }
@@ -2,6 +2,24 @@
2
2
  // characters such as ";" or spaces would inject cookie attributes.
3
3
  const HOSTNAME_PATTERN = /^[a-z0-9.-]+$/
4
4
 
5
+ /**
6
+ * Hosts whose upstream cookie domain does not match the browser host (previews
7
+ * and local development). Cookies served there are written host-only.
8
+ */
9
+ export const HOST_ONLY_COOKIE_HOST_SUFFIXES = [
10
+ 'localhost',
11
+ '.vtex.app',
12
+ '.localhost',
13
+ ]
14
+
15
+ /**
16
+ * Whether `host` can be written as a Set-Cookie Domain attribute without
17
+ * injecting extra attributes.
18
+ */
19
+ export function isCookieSafeHostname(host: string): boolean {
20
+ return HOSTNAME_PATTERN.test(host.toLowerCase())
21
+ }
22
+
5
23
  /**
6
24
  * An entry starting with "." matches subdomains only; any other entry matches
7
25
  * the host itself and its subdomains. Matching is anchored on a label boundary,
@@ -60,7 +60,7 @@ describe('Layout', () => {
60
60
  mockUseSession.mockReturnValue({ b2b: { unitId: 'unit-1' } })
61
61
 
62
62
  render(
63
- <Layout accountName="Jane Doe" isRepresentative={false}>
63
+ <Layout accountName="Jane Doe" isB2BBuyer={false}>
64
64
  content
65
65
  </Layout>
66
66
  )
@@ -65,7 +65,7 @@ describe('Menu', () => {
65
65
 
66
66
  it('mounts the OrganizationDrawer with initialView "switch" when Switch is clicked', () => {
67
67
  mockUseSession.mockReturnValue({
68
- b2b: { unitId: 'unit-1', isRepresentative: true },
68
+ b2b: { unitId: 'unit-1', customerId: 'contract-1' },
69
69
  })
70
70
 
71
71
  render(<Menu accountName="Jane" items={items} />)
@@ -79,19 +79,21 @@ describe('Menu', () => {
79
79
  expect.objectContaining({
80
80
  isOpen: true,
81
81
  initialView: 'switch',
82
- isRepresentative: true,
82
+ isB2BBuyer: true,
83
83
  })
84
84
  )
85
85
  })
86
86
 
87
- it('passes isRepresentative as a boolean even when b2b.isRepresentative is undefined', () => {
88
- mockUseSession.mockReturnValue({ b2b: { unitId: 'unit-1' } })
87
+ it('passes isB2BBuyer for a B2B session without the isRepresentative flag', () => {
88
+ mockUseSession.mockReturnValue({
89
+ b2b: { unitId: 'unit-1', isRepresentative: false },
90
+ })
89
91
 
90
92
  render(<Menu accountName="Jane" items={items} />)
91
93
  fireEvent.click(screen.getByRole('button', { name: 'Switch' }))
92
94
 
93
95
  expect(mockOrganizationDrawer).toHaveBeenCalledWith(
94
- expect.objectContaining({ isRepresentative: false })
96
+ expect.objectContaining({ isB2BBuyer: true })
95
97
  )
96
98
  })
97
99