@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/.turbo/turbo-generate.log +3 -3
- package/.turbo/turbo-test.log +267 -229
- package/CHANGELOG.md +17 -0
- package/package.json +6 -6
- package/src/pages/api/graphql.ts +97 -57
- package/src/pages/pvt/account/403.tsx +5 -4
- package/src/sdk/account/useRefreshToken.ts +5 -6
- package/src/sdk/product/usePageProductsQuery.ts +80 -21
- package/src/sdk/session/index.ts +42 -30
- package/src/utils/isLocalHost.ts +2 -16
- package/src/utils/shouldBypassRefreshToken.ts +63 -0
- package/test/pages/api/graphql.test.ts +175 -0
- package/test/pages/pvt/account/403.test.ts +67 -0
- package/test/sdk/account/useRefreshToken.browser.test.ts +69 -0
- package/test/sdk/product/usePageProductsQuery.browser.test.ts +260 -0
- package/test/sdk/product/usePageProductsQuery.deliveryPromise.browser.test.ts +130 -0
- package/test/sdk/session/validateSession.browser.test.ts +108 -5
- package/test/utils/shouldBypassRefreshToken.browser.test.ts +38 -0
- package/test/utils/shouldBypassRefreshToken.test.ts +98 -0
- package/test/utils/isLocalHost.browser.test.ts +0 -37
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.
|
|
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.
|
|
75
|
-
"@faststore/
|
|
76
|
-
"@faststore/
|
|
77
|
-
"@faststore/
|
|
78
|
-
"@faststore/
|
|
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",
|
package/src/pages/api/graphql.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
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
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
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 {
|
|
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
|
|
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
|
-
!
|
|
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 {
|
|
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
|
|
17
|
-
//
|
|
18
|
-
//
|
|
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 (
|
|
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
|
|
86
|
-
//
|
|
87
|
-
//
|
|
88
|
-
|
|
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
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|
-
//
|
|
127
|
-
//
|
|
128
|
-
|
|
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
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
[localizedVariables, postalCode]
|
|
189
|
+
() => ({
|
|
190
|
+
...localizedVariables,
|
|
191
|
+
...getShopperScope({ postalCode, contract }),
|
|
192
|
+
}),
|
|
193
|
+
[localizedVariables, postalCode, contract]
|
|
135
194
|
)
|
|
136
195
|
|
|
137
196
|
const hasSameVariables = deepEquals(
|
package/src/sdk/session/index.ts
CHANGED
|
@@ -8,7 +8,7 @@ import type {
|
|
|
8
8
|
ValidateSessionMutationVariables,
|
|
9
9
|
} from '@generated/graphql'
|
|
10
10
|
import deepEqual from 'fast-deep-equal'
|
|
11
|
-
import {
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
package/src/utils/isLocalHost.ts
CHANGED
|
@@ -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
|
-
*
|
|
15
|
-
*
|
|
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
|
+
}
|