@salesforce/pwa-kit-runtime 3.19.0-preview.0 → 3.19.0

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@salesforce/pwa-kit-runtime",
3
- "version": "3.19.0-preview.0",
3
+ "version": "3.19.0",
4
4
  "description": "The PWAKit Runtime",
5
5
  "homepage": "https://github.com/SalesforceCommerceCloud/pwa-kit/tree/develop/packages/pwa-kit-runtime#readme",
6
6
  "bugs": {
@@ -48,11 +48,11 @@
48
48
  },
49
49
  "devDependencies": {
50
50
  "@loadable/component": "^5.15.3",
51
- "@salesforce/pwa-kit-dev": "3.19.0-preview.0",
51
+ "@salesforce/pwa-kit-dev": "3.19.0",
52
52
  "@serverless/event-mocks": "^1.1.1",
53
53
  "aws-lambda-mock-context": "^3.2.1",
54
54
  "fs-extra": "^11.1.1",
55
- "internal-lib-build": "3.19.0-preview.0",
55
+ "internal-lib-build": "3.19.0",
56
56
  "nock": "^13.3.0",
57
57
  "nodemon": "^2.0.22",
58
58
  "sinon": "^13.0.2",
@@ -66,5 +66,5 @@
66
66
  "publishConfig": {
67
67
  "directory": "dist"
68
68
  },
69
- "gitHead": "f93e393e81306cfc61186f9162324f7ff8ac7b3c"
69
+ "gitHead": "5192e11826187be0dbdacf4754c84ac18af64f67"
70
70
  }
@@ -1197,6 +1197,11 @@ const RemoteServerFactory = exports.RemoteServerFactory = {
1197
1197
  targetProtocol: 'https'
1198
1198
  });
1199
1199
 
1200
+ // Internal preview-context signal consumed by the response
1201
+ // interceptor (process-token-response); never forwarded to
1202
+ // SLAS, regardless of the HttpOnly session-cookie flag.
1203
+ proxyRequest.removeHeader(_constants.X_PREVIEW_PARENT);
1204
+
1200
1205
  // Credential mode is taken from the allow-list entry
1201
1206
  // attached by the pre-proxy guard.
1202
1207
  const {
@@ -1369,6 +1374,11 @@ const RemoteServerFactory = exports.RemoteServerFactory = {
1369
1374
  targetHost: options.slasHostName,
1370
1375
  targetProtocol: 'https'
1371
1376
  });
1377
+
1378
+ // Internal preview-context signal consumed by the response
1379
+ // interceptor (process-token-response); never forwarded to
1380
+ // SLAS, regardless of the HttpOnly session-cookie flag.
1381
+ proxyRequest.removeHeader(_constants.X_PREVIEW_PARENT);
1372
1382
  if (httpOnlyCookiesEnabled) {
1373
1383
  handleHttpOnlyCookiesOnProxyReq(proxyRequest, incomingRequest);
1374
1384
  }
@@ -888,6 +888,52 @@ describe('HttpOnly session cookies', () => {
888
888
  mockSlasServerInstance.close();
889
889
  }
890
890
  }));
891
+ test('strips x-pwakit-preview-parent from the upstream request even when HttpOnly is disabled', /*#__PURE__*/_asyncToGenerator(function* () {
892
+ // The header is an internal preview-context signal consumed by the
893
+ // response interceptor; it must never reach SLAS, regardless of the
894
+ // HttpOnly session-cookie flag (the private proxy is wired whenever
895
+ // useSLASPrivateClient is true).
896
+ delete process.env.MRT_ENABLE_HTTPONLY_SESSION_COOKIES;
897
+ let capturedPreviewParent = 'sentinel';
898
+ const mockSlasServer = mockExpress();
899
+ mockSlasServer.post('/shopper/auth/v1/organizations/f_ecom_test/oauth2/token', (req, res) => {
900
+ capturedPreviewParent = req.headers[_constants.X_PREVIEW_PARENT];
901
+ res.status(200).json({
902
+ access_token: 'mock-token',
903
+ expires_in: 1800,
904
+ refresh_token: 'mock-refresh-token'
905
+ });
906
+ });
907
+ const mockSlasServerInstance = mockSlasServer.listen(0);
908
+ const mockSlasPort = mockSlasServerInstance.address().port;
909
+ try {
910
+ const app = mockExpress();
911
+ const options = _buildRemoteServer.RemoteServerFactory._configure({
912
+ useSLASPrivateClient: true,
913
+ slasTarget: `http://localhost:${mockSlasPort}`,
914
+ mobify: {
915
+ app: {
916
+ commerceAPI: {
917
+ parameters: {
918
+ shortCode: 'test',
919
+ organizationId: 'f_ecom_test',
920
+ clientId: 'test-client-id',
921
+ siteId: 'testsite'
922
+ }
923
+ }
924
+ }
925
+ }
926
+ });
927
+ process.env.PWA_KIT_SLAS_CLIENT_SECRET = 'test-secret';
928
+ _buildRemoteServer.RemoteServerFactory._setupSlasPrivateClientProxy(app, options, false);
929
+ const response = yield request(app).post('/mobify/slas/private/shopper/auth/v1/organizations/f_ecom_test/oauth2/token').set(_constants.X_PREVIEW_PARENT, 'https://runtime.commercecloud.com');
930
+ expect(response.status).toBe(200);
931
+ // Upstream SLAS must not see the internal preview-context header.
932
+ expect(capturedPreviewParent).toBeUndefined();
933
+ } finally {
934
+ mockSlasServerInstance.close();
935
+ }
936
+ }));
891
937
  test('returns 500 when siteId is missing', /*#__PURE__*/_asyncToGenerator(function* () {
892
938
  process.env.MRT_ENABLE_HTTPONLY_SESSION_COOKIES = 'true';
893
939
  const mockSlasServer = mockExpress();
@@ -3,7 +3,7 @@
3
3
  Object.defineProperty(exports, "__esModule", {
4
4
  value: true
5
5
  });
6
- exports.X_SITE_ID = exports.X_ORIGINAL_CONTENT_TYPE = exports.X_MOBIFY_QUERYSTRING = exports.X_MOBIFY_FROM_CACHE = exports.X_GRANT_TYPE = exports.X_ENCODED_HEADERS = exports.STRICT_TRANSPORT_SECURITY = exports.STOREFRONT_PREVIEW_PARENT_ALLOW_LIST = exports.STOREFRONT_PREVIEW_CTX_COOKIE = exports.STATIC_ASSETS = exports.SLAS_TOKEN_RESPONSE_ENDPOINTS = exports.SLAS_LOGOUT_ENDPOINT = exports.SLAS_CUSTOM_PROXY_PATH = exports.SET_COOKIE = exports.PROXY_PATH_PREFIX = exports.NO_CACHE = exports.DWSID_COOKIE_NAME = exports.CONTENT_TYPE = exports.CONTENT_SECURITY_POLICY = exports.CONTENT_ENCODING = exports.CACHE_CONTROL = exports.BUILD = exports.APPLICATION_OCTET_STREAM = void 0;
6
+ exports.X_SITE_ID = exports.X_PREVIEW_PARENT = exports.X_ORIGINAL_CONTENT_TYPE = exports.X_MOBIFY_QUERYSTRING = exports.X_MOBIFY_FROM_CACHE = exports.X_GRANT_TYPE = exports.X_ENCODED_HEADERS = exports.STRICT_TRANSPORT_SECURITY = exports.STOREFRONT_PREVIEW_PARENT_ALLOW_LIST = exports.STOREFRONT_PREVIEW_CTX_COOKIE = exports.STATIC_ASSETS = exports.SLAS_TOKEN_RESPONSE_ENDPOINTS = exports.SLAS_LOGOUT_ENDPOINT = exports.SLAS_CUSTOM_PROXY_PATH = exports.SET_COOKIE = exports.PROXY_PATH_PREFIX = exports.NO_CACHE = exports.DWSID_COOKIE_NAME = exports.CONTENT_TYPE = exports.CONTENT_SECURITY_POLICY = exports.CONTENT_ENCODING = exports.CACHE_CONTROL = exports.BUILD = exports.APPLICATION_OCTET_STREAM = void 0;
7
7
  /*
8
8
  * Copyright (c) 2021, salesforce.com, inc.
9
9
  * All rights reserved.
@@ -47,6 +47,12 @@ const SLAS_LOGOUT_ENDPOINT = exports.SLAS_LOGOUT_ENDPOINT = /\/oauth2\/logout/;
47
47
  const DWSID_COOKIE_NAME = exports.DWSID_COOKIE_NAME = 'dwsid';
48
48
  const X_SITE_ID = exports.X_SITE_ID = 'x-site-id';
49
49
  const X_GRANT_TYPE = exports.X_GRANT_TYPE = 'x-grant-type';
50
+ // Client-sent header carrying the trusted Storefront Preview parent origin on
51
+ // SLAS token requests. Read (and re-validated against
52
+ // STOREFRONT_PREVIEW_PARENT_ALLOW_LIST) when choosing SameSite=None; Partitioned
53
+ // for session cookies, as a CDN-cache-proof alternative to the
54
+ // __Host-pwakit_preview_ctx marker cookie. Stripped before forwarding to SLAS/SCAPI.
55
+ const X_PREVIEW_PARENT = exports.X_PREVIEW_PARENT = 'x-pwakit-preview-parent';
50
56
 
51
57
  // Server-only marker cookie set when the storefront is loaded inside a
52
58
  // trusted Storefront Preview iframe (parent origin attested by Sec-Fetch-*
@@ -20,10 +20,11 @@ var _constants = require("./constants");
20
20
  *
21
21
  * `attributes` covers the *static* per-cookie attributes only (`httpOnly`,
22
22
  * `secure`, `path`). `SameSite` and `Partitioned` are resolved per-request
23
- * by `setHttpOnlySessionCookies` based on the Storefront Preview marker
24
- * cookie — see `preview-context.js`. Top-level traffic gets `SameSite=Lax`;
25
- * traffic with a validated trusted-iframe marker gets
26
- * `SameSite=None; Partitioned`.
23
+ * by `setHttpOnlySessionCookies` based on the Storefront Preview signal —
24
+ * either the server-set marker cookie or the client-sent
25
+ * `x-pwakit-preview-parent` header (see `isTrustedPreviewRequest` in
26
+ * `preview-context.js`). Top-level traffic gets `SameSite=Lax`; traffic from a
27
+ * validated trusted iframe gets `SameSite=None; Partitioned`.
27
28
  */
28
29
  const SESSION_COOKIE_CONFIG = exports.SESSION_COOKIE_CONFIG = {
29
30
  accessToken: {
@@ -4,7 +4,9 @@ Object.defineProperty(exports, "__esModule", {
4
4
  value: true
5
5
  });
6
6
  exports.clearStorefrontPreviewMarker = clearStorefrontPreviewMarker;
7
+ exports.isTrustedPreviewRequest = isTrustedPreviewRequest;
7
8
  exports.readStorefrontPreviewMarker = readStorefrontPreviewMarker;
9
+ exports.readTrustedPreviewParentHeader = readTrustedPreviewParentHeader;
8
10
  exports.tryWriteStorefrontPreviewMarker = tryWriteStorefrontPreviewMarker;
9
11
  var _ssrProxying = require("../../utils/ssr-proxying");
10
12
  var _constants = require("./constants");
@@ -175,6 +177,44 @@ function readStorefrontPreviewMarker(req) {
175
177
  return value;
176
178
  }
177
179
 
180
+ /**
181
+ * Returns the validated parent origin from the `x-pwakit-preview-parent`
182
+ * request header, or undefined when the header is absent or carries a value
183
+ * that is not on the trusted allow-list.
184
+ *
185
+ * The client (commerce-sdk-react) sends this header on SLAS token requests
186
+ * when it detects — via `window.location.ancestorOrigins` — that the
187
+ * storefront is running inside a trusted Storefront Preview iframe. Because
188
+ * the token request is a POST that is never served from the CDN cache, this
189
+ * header reliably reaches the origin even when the iframe *document* load
190
+ * (which sets the `__Host-pwakit_preview_ctx` marker cookie) is served from
191
+ * cache and never runs the marker-writer middleware.
192
+ *
193
+ * The value is re-validated here against the same allow-list as the marker
194
+ * cookie. A spoofed header is harmless: the worst case is that a caller
195
+ * receives its own session cookies with SameSite=None; Partitioned (which are
196
+ * partitioned by the caller's top-level site and grant no cross-site access),
197
+ * and only when the asserted origin is itself on the trusted allow-list.
198
+ */
199
+ function readTrustedPreviewParentHeader(req) {
200
+ var _req$headers5;
201
+ const value = (_req$headers5 = req.headers) === null || _req$headers5 === void 0 ? void 0 : _req$headers5[_constants.X_PREVIEW_PARENT];
202
+ if (!value || !isAllowedParentOrigin(value)) return undefined;
203
+ return value;
204
+ }
205
+
206
+ /**
207
+ * Returns true when the request originates from a trusted Storefront Preview
208
+ * iframe — either via the server-set marker cookie (iframe document load that
209
+ * reached the origin) or via the client-sent `x-pwakit-preview-parent` header
210
+ * (CDN-cache-proof). Either signal is sufficient; both are re-validated
211
+ * against the trusted allow-list. Used to decide SameSite=None; Partitioned
212
+ * vs. SameSite=Lax for session cookies.
213
+ */
214
+ function isTrustedPreviewRequest(req) {
215
+ return Boolean(readStorefrontPreviewMarker(req) || readTrustedPreviewParentHeader(req));
216
+ }
217
+
178
218
  /**
179
219
  * Append a Set-Cookie header to expire the storefront-preview marker
180
220
  * cookie. Used on logout (called from `expireHttpOnlySessionCookies`) so
@@ -189,6 +189,86 @@ describe('readStorefrontPreviewMarker', () => {
189
189
  expect((0, _previewContext.readStorefrontPreviewMarker)(req)).toBeUndefined();
190
190
  });
191
191
  });
192
+ describe('readTrustedPreviewParentHeader', () => {
193
+ test('returns undefined when the header is absent', () => {
194
+ expect((0, _previewContext.readTrustedPreviewParentHeader)({
195
+ headers: {}
196
+ })).toBeUndefined();
197
+ });
198
+ test('returns the validated origin when the header value is on the allow-list', () => {
199
+ const req = {
200
+ headers: {
201
+ [_constants.X_PREVIEW_PARENT]: TRUSTED_PARENT
202
+ }
203
+ };
204
+ expect((0, _previewContext.readTrustedPreviewParentHeader)(req)).toBe(TRUSTED_PARENT);
205
+ });
206
+ test.each(_constants.STOREFRONT_PREVIEW_PARENT_ALLOW_LIST)('returns the origin for trusted parent %s', origin => {
207
+ const req = {
208
+ headers: {
209
+ [_constants.X_PREVIEW_PARENT]: origin
210
+ }
211
+ };
212
+ expect((0, _previewContext.readTrustedPreviewParentHeader)(req)).toBe(origin);
213
+ });
214
+ test('returns undefined when the header value is not on the allow-list', () => {
215
+ const req = {
216
+ headers: {
217
+ [_constants.X_PREVIEW_PARENT]: 'https://evil.example.com'
218
+ }
219
+ };
220
+ expect((0, _previewContext.readTrustedPreviewParentHeader)(req)).toBeUndefined();
221
+ });
222
+ test('returns undefined when the header value is empty', () => {
223
+ const req = {
224
+ headers: {
225
+ [_constants.X_PREVIEW_PARENT]: ''
226
+ }
227
+ };
228
+ expect((0, _previewContext.readTrustedPreviewParentHeader)(req)).toBeUndefined();
229
+ });
230
+ });
231
+ describe('isTrustedPreviewRequest', () => {
232
+ test('true when only the marker cookie is present', () => {
233
+ const req = {
234
+ headers: {
235
+ cookie: `${_constants.STOREFRONT_PREVIEW_CTX_COOKIE}=${TRUSTED_PARENT}`
236
+ }
237
+ };
238
+ expect((0, _previewContext.isTrustedPreviewRequest)(req)).toBe(true);
239
+ });
240
+ test('true when only the client header is present (CDN cache bypasses the marker)', () => {
241
+ const req = {
242
+ headers: {
243
+ [_constants.X_PREVIEW_PARENT]: TRUSTED_PARENT
244
+ }
245
+ };
246
+ expect((0, _previewContext.isTrustedPreviewRequest)(req)).toBe(true);
247
+ });
248
+ test('true when both signals are present', () => {
249
+ const req = {
250
+ headers: {
251
+ cookie: `${_constants.STOREFRONT_PREVIEW_CTX_COOKIE}=${TRUSTED_PARENT}`,
252
+ [_constants.X_PREVIEW_PARENT]: TRUSTED_PARENT
253
+ }
254
+ };
255
+ expect((0, _previewContext.isTrustedPreviewRequest)(req)).toBe(true);
256
+ });
257
+ test('false when neither signal is present', () => {
258
+ expect((0, _previewContext.isTrustedPreviewRequest)({
259
+ headers: {}
260
+ })).toBe(false);
261
+ });
262
+ test('false when both signals carry untrusted values', () => {
263
+ const req = {
264
+ headers: {
265
+ cookie: `${_constants.STOREFRONT_PREVIEW_CTX_COOKIE}=https://evil.example.com`,
266
+ [_constants.X_PREVIEW_PARENT]: 'https://evil.example.com'
267
+ }
268
+ };
269
+ expect((0, _previewContext.isTrustedPreviewRequest)(req)).toBe(false);
270
+ });
271
+ });
192
272
  describe('STOREFRONT_PREVIEW_PARENT_ALLOW_LIST parity', () => {
193
273
  // The server-side allow-list (constants.js) MUST mirror IFRAME_HOST_ALLOW_LIST
194
274
  // in commerce-sdk-react/src/constant.ts. Drift would cause client and server
@@ -38,15 +38,17 @@ const PREVIEW_IFRAME_SITE_ATTRS = Object.freeze({
38
38
 
39
39
  /**
40
40
  * Resolves the SameSite/Partitioned attributes to apply to all session
41
- * cookies on this response. When the request carries a validated
42
- * Storefront-Preview marker (set under server-attested conditions on the
43
- * iframe document load), returns `{sameSite: 'none', partitioned: true}`
44
- * so cookies attach inside the cross-site iframe. Otherwise returns
41
+ * cookies on this response. When the request originates from a trusted
42
+ * Storefront Preview iframe — signalled either by the server-set marker
43
+ * cookie (iframe document load that reached the origin) or by the
44
+ * client-sent `x-pwakit-preview-parent` header (CDN-cache-proof, rides on
45
+ * the non-cacheable token POST) — returns `{sameSite: 'none', partitioned:
46
+ * true}` so cookies attach inside the cross-site iframe. Otherwise returns
45
47
  * `{sameSite: 'lax'}` (the existing top-level behavior).
46
48
  * @private
47
49
  */
48
50
  function getSiteAttrsForRequest(req) {
49
- return (0, _previewContext.readStorefrontPreviewMarker)(req) ? PREVIEW_IFRAME_SITE_ATTRS : DEFAULT_SITE_ATTRS;
51
+ return (0, _previewContext.isTrustedPreviewRequest)(req) ? PREVIEW_IFRAME_SITE_ATTRS : DEFAULT_SITE_ATTRS;
50
52
  }
51
53
 
52
54
  // Refresh token cookie TTL defaults (seconds). Must stay in sync with commerce-sdk-react auth constants.
@@ -55,11 +57,27 @@ const DEFAULT_SLAS_REFRESH_TOKEN_REGISTERED_TTL = 90 * 24 * 60 * 60;
55
57
 
56
58
  /**
57
59
  * Returns a function that appends a Set-Cookie header to `res`. When
58
- * `cookieDomain` is configured, every write also emits a second expiring
59
- * Set-Cookie for the same name without a Domain attribute, expiring any
60
- * pre-existing host-scoped cookie. This mirrors
61
- * `CookieStorage.removeHostAndDomainCookie` in commerce-sdk-react and prevents
62
- * stale duplicates when a merchant first enables the cookieDomain config.
60
+ * `cookieDomain` is configured, every write first emits an expiring
61
+ * Set-Cookie for the same name WITHOUT a Domain attribute (clearing any
62
+ * pre-existing host-scoped cookie), THEN emits the real Domain-scoped cookie.
63
+ * This mirrors `CookieStorage.removeHostAndDomainCookie` in commerce-sdk-react
64
+ * and prevents stale duplicates when a merchant first enables the cookieDomain
65
+ * config.
66
+ *
67
+ * Order matters for SSR: on a cookieless SSR load, commerce-sdk-isomorphic
68
+ * reconstructs the guest-login TokenResponse from this response's Set-Cookie
69
+ * headers, parsing the array with last-write-wins per cookie name. Emitting the
70
+ * host-scoped deletion (empty value) BEFORE the real cookie ensures the real
71
+ * token value wins instead of being clobbered by the empty deletion —
72
+ * otherwise the SSR shopper token would be empty and data-bearing routes
73
+ * (e.g. a PLP) would 401 (regression of W-23388089). This rests on a cross-repo
74
+ * invariant: the parser is commerce-sdk-isomorphic's server-side TokenResponse
75
+ * reconstruction (helpers/slasHelper), which walks the Set-Cookie array keyed
76
+ * only by cookie NAME (Domain ignored) and assigns each token field with
77
+ * last-write-wins — notably both cc-nx and cc-nx-g map to refresh_token. If a
78
+ * future SDK version stops being last-write-wins (or skips empty values), this
79
+ * ordering must be revisited. In the browser the two writes target different
80
+ * cookie scopes (host vs Domain), so their relative order has no effect.
63
81
  *
64
82
  * `siteAttrs` (sameSite/partitioned) is decided per-request by
65
83
  * `getSiteAttrsForRequest` and applied uniformly to every cookie this
@@ -75,13 +93,9 @@ function makeAppendCookie(res, cookieDomain, siteAttrs) {
75
93
  expires,
76
94
  attributes
77
95
  }) => {
78
- res.append(_constants.SET_COOKIE, (0, _ssrProxying.cookieAsString)(_objectSpread(_objectSpread(_objectSpread({
79
- name,
80
- value,
81
- expires
82
- }, attributes), siteAttrs), cookieDomain && {
83
- domain: cookieDomain
84
- })));
96
+ // Clear any pre-existing host-scoped cookie first. This must precede the
97
+ // real write so the SSR last-write-wins Set-Cookie parser recovers the
98
+ // real token value, not the empty deletion (see JSDoc above).
85
99
  if (cookieDomain) {
86
100
  res.append(_constants.SET_COOKIE, (0, _ssrProxying.cookieAsString)(_objectSpread(_objectSpread({
87
101
  name,
@@ -89,6 +103,13 @@ function makeAppendCookie(res, cookieDomain, siteAttrs) {
89
103
  expires: new Date(0)
90
104
  }, attributes), siteAttrs)));
91
105
  }
106
+ res.append(_constants.SET_COOKIE, (0, _ssrProxying.cookieAsString)(_objectSpread(_objectSpread(_objectSpread({
107
+ name,
108
+ value,
109
+ expires
110
+ }, attributes), siteAttrs), cookieDomain && {
111
+ domain: cookieDomain
112
+ })));
92
113
  };
93
114
  }
94
115
 
@@ -271,16 +292,21 @@ function setHttpOnlySessionCookies(responseBuffer, proxyRes, req, res, options)
271
292
  const refreshTTL = getRefreshTokenCookieTTL(parsed.refresh_token_expires_in, isGuest, commerceAPI);
272
293
  const refreshExpires = new Date(Date.now() + refreshTTL * 1000);
273
294
  const refreshConfig = isGuest ? refreshTokenGuest : refreshTokenRegistered;
274
- appendCookie({
275
- name: (0, _httponlyCookieConfig.getCookieName)(refreshConfig, site),
276
- value: parsed.refresh_token,
277
- expires: refreshExpires,
278
- attributes: refreshConfig.attributes
279
- });
280
295
 
281
296
  // Delete the opposite refresh token cookie to mirror client-side behavior:
282
297
  // Login (guest → registered): delete guest cookie cc-nx-g
283
298
  // Logout (registered → guest): delete registered cookie cc-nx
299
+ //
300
+ // This deletion MUST be emitted BEFORE the real refresh-token write
301
+ // below. Both cc-nx and cc-nx-g map to `refresh_token` in
302
+ // commerce-sdk-isomorphic's SSR TokenResponse reconstruction, which
303
+ // parses the Set-Cookie array with last-write-wins per token field
304
+ // (ignoring the Domain attribute). If the empty opposite-cookie deletion
305
+ // were emitted last it would clobber the reconstructed refresh_token to
306
+ // empty — the same failure mode fixed for the access token in
307
+ // makeAppendCookie. The browser is unaffected either way (cc-nx and
308
+ // cc-nx-g are distinct cookie names, so their relative order is
309
+ // irrelevant there).
284
310
  const staleRefreshConfig = isGuest ? refreshTokenRegistered : refreshTokenGuest;
285
311
  appendCookie({
286
312
  name: (0, _httponlyCookieConfig.getCookieName)(staleRefreshConfig, site),
@@ -288,6 +314,12 @@ function setHttpOnlySessionCookies(responseBuffer, proxyRes, req, res, options)
288
314
  expires: new Date(0),
289
315
  attributes: staleRefreshConfig.attributes
290
316
  });
317
+ appendCookie({
318
+ name: (0, _httponlyCookieConfig.getCookieName)(refreshConfig, site),
319
+ value: parsed.refresh_token,
320
+ expires: refreshExpires,
321
+ attributes: refreshConfig.attributes
322
+ });
291
323
 
292
324
  // Hybrid SFRA + PWA: mirror session-scoped SLAS metadata as
293
325
  // siteId-suffixed cookies so SFRA can read the same session state. These
@@ -65,6 +65,27 @@ function makeOptionsWithCookieDomain(cookieDomain) {
65
65
  function findCookies(cookies, name) {
66
66
  return cookies.filter(c => c.startsWith(`${name}=`)).map(parseCookie);
67
67
  }
68
+
69
+ // Mirrors commerce-sdk-isomorphic's SSR TokenResponse reconstruction: it walks
70
+ // the response's Set-Cookie array in emission order and assigns per token field
71
+ // with last-write-wins, keyed only by cookie NAME (the Domain attribute is
72
+ // ignored). The SDK reconstructs exactly three fields — cc-at → access_token,
73
+ // cc-nx/cc-nx-g → refresh_token (BOTH names), idp_access_token → idp_access_token
74
+ // — so those are the ones modelled here. This is what a cookieless SSR guest
75
+ // login uses to recover its tokens, so the raw emission order of these
76
+ // Set-Cookie headers is load-bearing — see the ordering notes in makeAppendCookie
77
+ // and the refresh-token block of process-token-response.js.
78
+ function reconstructSsrTokens(cookies, site = 'testsite') {
79
+ const out = {};
80
+ for (const raw of cookies) {
81
+ const {
82
+ name,
83
+ value
84
+ } = parseCookie(raw);
85
+ if (name === `cc-at_${site}`) out.access_token = value;else if (name === `cc-nx_${site}` || name === `cc-nx-g_${site}`) out.refresh_token = value;else if (name === `idp_access_token_${site}`) out.idp_access_token = value;
86
+ }
87
+ return out;
88
+ }
68
89
  describe('getRefreshTokenCookieTTL', () => {
69
90
  const GUEST_DEFAULT = 30 * 24 * 60 * 60;
70
91
  const REGISTERED_DEFAULT = 90 * 24 * 60 * 60;
@@ -577,6 +598,107 @@ describe('cookieDomain support', () => {
577
598
  expect(atActive.domain).toBe('.example.com');
578
599
  expect(atActive.httpOnly).toBe(true);
579
600
  });
601
+ test('emits the host-scoped deletion BEFORE the real Domain-scoped write (raw emission order)', () => {
602
+ // The existing cleanup test above sorts the two writes by Domain, so it
603
+ // would still pass if the two res.append calls in makeAppendCookie were
604
+ // flipped back — reintroducing the SSR-reconstruction 401. Assert the
605
+ // raw emission order directly so that regression is caught.
606
+ const res = makeRes();
607
+ const accessToken = makeJWT({
608
+ iat: 1000,
609
+ exp: 2800,
610
+ isb: 'uido:ecom::upn:Guest'
611
+ });
612
+ const buf = makeResponseBuffer({
613
+ access_token: accessToken,
614
+ refresh_token: 'refresh-value'
615
+ });
616
+ (0, _processTokenResponse.setHttpOnlySessionCookies)(buf, {}, makeReq(), res, makeOptionsWithCookieDomain('.example.com'));
617
+
618
+ // For a given cookie name, the empty host-scoped deletion must be emitted
619
+ // before the real Domain-scoped value so last-write-wins recovers the value.
620
+ const assertDeletionFirst = (name, realValue) => {
621
+ const idxOf = predicate => res.cookies.findIndex(c => c.startsWith(`${name}=`) && predicate(parseCookie(c)));
622
+ const deletionIdx = idxOf(c => c.value === '' && c.domain === undefined);
623
+ const realIdx = idxOf(c => c.value === realValue && c.domain === '.example.com');
624
+ expect(deletionIdx).toBeGreaterThanOrEqual(0);
625
+ expect(realIdx).toBeGreaterThanOrEqual(0);
626
+ expect(deletionIdx).toBeLessThan(realIdx);
627
+ };
628
+ assertDeletionFirst('cc-at_testsite', accessToken);
629
+ assertDeletionFirst('cc-nx-g_testsite', 'refresh-value');
630
+ });
631
+ test('SSR last-write-wins reconstruction recovers the real access, refresh AND idp tokens', () => {
632
+ // Guards the end-to-end invariant, including the cross-name refresh case
633
+ // that the per-name ordering test above cannot catch: the empty
634
+ // opposite-refresh deletion (cc-nx) is emitted as a separate appendCookie
635
+ // AFTER the real cc-nx-g in the pre-fix code, and both names map to
636
+ // refresh_token — so the deletion would clobber the reconstructed token.
637
+ // idp_access_token is also SDK-reconstructed and body-stripped, so it
638
+ // relies on the same makeAppendCookie deletion-before-real ordering; pin
639
+ // it here too.
640
+ const accessToken = makeJWT({
641
+ iat: 1000,
642
+ exp: 2800,
643
+ isb: 'uido:ecom::upn:Guest'
644
+ });
645
+ const body = {
646
+ access_token: accessToken,
647
+ refresh_token: 'refresh-value',
648
+ idp_access_token: 'idp-at-value'
649
+ };
650
+ const expected = {
651
+ access_token: accessToken,
652
+ refresh_token: 'refresh-value',
653
+ idp_access_token: 'idp-at-value'
654
+ };
655
+
656
+ // With cookieDomain configured (the reported 401 scenario).
657
+ const withDomain = makeRes();
658
+ (0, _processTokenResponse.setHttpOnlySessionCookies)(makeResponseBuffer(body), {}, makeReq(), withDomain, makeOptionsWithCookieDomain('.example.com'));
659
+ expect(reconstructSsrTokens(withDomain.cookies)).toEqual(expected);
660
+
661
+ // And without cookieDomain: the opposite-refresh deletion is still emitted
662
+ // last in the pre-fix code, so refresh_token must survive here too.
663
+ const noDomain = makeRes();
664
+ (0, _processTokenResponse.setHttpOnlySessionCookies)(makeResponseBuffer(body), {}, makeReq(), noDomain, {});
665
+ expect(reconstructSsrTokens(noDomain.cookies)).toEqual(expected);
666
+ });
667
+ test('SSR last-write-wins reconstruction recovers tokens for a REGISTERED login (mirror case)', () => {
668
+ // The guest test above exercises cc-nx-g as the real cookie and cc-nx as
669
+ // the empty opposite-deletion. The registered branch is the mirror: cc-nx
670
+ // is the real refresh cookie and cc-nx-g is the empty deletion, so the
671
+ // deletion-before-real ordering must hold for it too. A registered JWT
672
+ // uses a non-Guest `isb`.
673
+ const registeredToken = makeJWT({
674
+ iat: 1000,
675
+ exp: 2800,
676
+ isb: 'uido:ecom::upn:user@example.com::uidn:Jane'
677
+ });
678
+ const res = makeRes();
679
+ (0, _processTokenResponse.setHttpOnlySessionCookies)(makeResponseBuffer({
680
+ access_token: registeredToken,
681
+ refresh_token: 'refresh-value'
682
+ }), {}, makeReq(), res, makeOptionsWithCookieDomain('.example.com'));
683
+ expect(reconstructSsrTokens(res.cookies)).toEqual({
684
+ access_token: registeredToken,
685
+ refresh_token: 'refresh-value'
686
+ });
687
+ });
688
+ test('logout expires both refresh cookies so SSR cannot reconstruct a token', () => {
689
+ // After logout SSR must not be able to rebuild a refresh token from the
690
+ // response. Order doesn't matter here (all writes are empty) but presence
691
+ // does: cc-nx and cc-nx-g both map to refresh_token, so both must be
692
+ // emitted empty.
693
+ const res = makeRes();
694
+ (0, _processTokenResponse.expireHttpOnlySessionCookies)(makeReq(), res, makeOptionsWithCookieDomain('.example.com'));
695
+ for (const name of ['cc-nx_testsite', 'cc-nx-g_testsite']) {
696
+ const matches = findCookies(res.cookies, name);
697
+ expect(matches.length).toBeGreaterThan(0);
698
+ expect(matches.every(c => c.value === '')).toBe(true);
699
+ }
700
+ expect(reconstructSsrTokens(res.cookies).refresh_token).toBe('');
701
+ });
580
702
  test('setHttpOnlySessionCookies warns and ignores invalid cookieDomain', () => {
581
703
  const res = makeRes();
582
704
  const accessToken = makeJWT({
@@ -645,6 +767,17 @@ describe('trusted Storefront Preview iframe', () => {
645
767
  }
646
768
  };
647
769
  }
770
+
771
+ // The client-sent header path — used when the iframe document load is
772
+ // served from the CDN cache and never sets the marker cookie.
773
+ function makeReqWithHeader(value, siteId = 'testsite') {
774
+ return {
775
+ headers: {
776
+ [_constants.X_SITE_ID]: siteId,
777
+ [_constants.X_PREVIEW_PARENT]: value
778
+ }
779
+ };
780
+ }
648
781
  function makeFullSlasResponse() {
649
782
  const accessToken = makeJWT({
650
783
  iat: 1000,
@@ -717,6 +850,30 @@ describe('trusted Storefront Preview iframe', () => {
717
850
  expect(cookieStr).not.toMatch(/Partitioned/i);
718
851
  }
719
852
  });
853
+ test('every session cookie carries SameSite=none; Partitioned when only the client header is present (CDN-cached document load, no marker cookie)', () => {
854
+ const res = makeRes();
855
+ const {
856
+ buf
857
+ } = makeFullSlasResponse();
858
+ (0, _processTokenResponse.setHttpOnlySessionCookies)(buf, {}, makeReqWithHeader(TRUSTED_PARENT), res, {});
859
+ expect(res.cookies.length).toBeGreaterThan(0);
860
+ for (const cookieStr of res.cookies) {
861
+ expect(cookieStr).toMatch(/SameSite=none/i);
862
+ expect(cookieStr).toMatch(/Partitioned/i);
863
+ expect(cookieStr).not.toMatch(/SameSite=lax/i);
864
+ }
865
+ });
866
+ test('falls back to SameSite=Lax when the client header value is not on the allow-list', () => {
867
+ const res = makeRes();
868
+ const {
869
+ buf
870
+ } = makeFullSlasResponse();
871
+ (0, _processTokenResponse.setHttpOnlySessionCookies)(buf, {}, makeReqWithHeader('https://attacker.example.com'), res, {});
872
+ for (const cookieStr of res.cookies) {
873
+ expect(cookieStr).toMatch(/SameSite=lax/i);
874
+ expect(cookieStr).not.toMatch(/Partitioned/i);
875
+ }
876
+ });
720
877
  test('expireHttpOnlySessionCookies under the iframe path emits SameSite=none; Partitioned on every deletion (so partitioned cookies actually clear)', () => {
721
878
  const res = makeRes();
722
879
  (0, _processTokenResponse.expireHttpOnlySessionCookies)(makeReqWithMarker(TRUSTED_PARENT), res, {});