@salesforce/retail-react-app 10.2.0 → 10.3.0-nightly-20260814082538

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
@@ -1,3 +1,8 @@
1
+ ## v10.3.0-dev (Aug 12, 2026)
2
+ - [Feature] Forward two backend-gating signals on the Commerce Client shopper-agent widget's `routingAttributes`: `clientVersion` (sourced from `cc_cdnVersion`, so the runtime can gate rich components on the client's capability version; omitted when unset) and `isCartMgmtSupported` (string `'true'`/`'false'`, default `'false'`, read from `cc_routingAttributes`). Brings the PWA commerce-client path to parity with the SFRA cartridge.
3
+ - [Feature] Enable shopper identity (customer context) for the Commerce Client (Cimulate) shopper-agent provider. Unlike MIAW, Commerce Client widgets do not expose `getAuthLinkKey`, so the browser extracts the Commerce Client JWT from the deployment-scoped `cim_af_ct_<orgId>_<embeddedServiceName>` key (`sessionStorage` is authoritative; `localStorage` is a compatibility fallback) and POSTs it to a new same-origin proxy `POST /api/agent/authlink` (`registerAuthLinkRoute`, mounted in `app/ssr.js`). The proxy calls SCRT2's `/iamessage/api/v2/authorization/authlink` with an `Authorization: Bearer <jwt>` header to obtain an `auth_link_key`; the SCRT2 origin is read server-side from `scrt2Url` in `COMMERCE_AGENT_SETTINGS` (a different host from Core's My Domain) and validated against a Salesforce domain allowlist, with an Origin/Referer CSRF check, to prevent SSRF. The `auth_link_key` is then forwarded to the existing Token Bridge to bind the conversation to the shopper's SLAS session. Linking is idempotent — deduped by `conversationId` + SLAS identity — and re-runs both on a new conversation (`onCimulateWidgetReady`) and on a SLAS identity transition (guest ↔ registered / account switch), gated on `my_domain` resolution. [#3943](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3943)
4
+ - [Bugfix] Fix the Shopper Agent Token Bridge proxy (`/api/agent/identity/bridge`) failing to reach Core in two cases: (1) same-origin POSTs were rejected with 403 `FORBIDDEN_ORIGIN` because the CSRF Origin check compared `URL.hostname` (no port) against `req.headers.host` (with port), so a non-standard dev port like `localhost:3000` never matched — now compares `URL.host`; (2) a scheme-less My Domain value (e.g. `orgfarm-1234.my.salesforce.com`) was rejected as 400 `UNTRUSTED_MYDOMAIN` because `new URL()` threw on it — the value is now normalized to an absolute `https://` origin before validation and fetch. The `AGENT_MYDOMAIN` environment variable configures the My Domain.
5
+
1
6
  ## v10.2.0 (Aug 12, 2026)
2
7
  - [Bugfix] Fix the Data Cloud recommender catalog event field name: `personalizationContextId` → `personalizationContentId`. The official `@salesforce/cc-datacloud-typescript` SDK type and Salesforce docs both define `personalizationContentId` (Content); the `Context` spelling did not match, so the recommender UUID (`__recoUUID`) was sent under a key Data Cloud does not recognize and is expected to be dropped on ingest.
3
8
  - [Feature] Add `cc_showFab` for the Commerce Client shopper-agent widget: when `'true'`, renders a floating action button at `cc_widgetPosition` that opens the agent panel and hides while it is open. Defaults to `'false'`.
@@ -0,0 +1,317 @@
1
+ /*
2
+ * Copyright (c) 2026, Salesforce, Inc.
3
+ * All rights reserved.
4
+ * SPDX-License-Identifier: BSD-3-Clause
5
+ * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause
6
+ */
7
+
8
+ /* -------------------------------------------------------------------------
9
+ * Auth Link Proxy — calls SCRT's `/iamessage/api/v2/authorization/authlink` from PWA Kit.
10
+ *
11
+ * This module provides a same-origin proxy for Commerce Client to retrieve auth link keys
12
+ * from SCRT, since `window.embeddedservice_bootstrap.userVerificationAPI.getAuthLinkKey`
13
+ * is not available for Commerce Client messaging widgets.
14
+ *
15
+ * This module is intentionally free of React (and of the
16
+ * `@salesforce/retail-react-app/...` self-referential imports used by the
17
+ * UI component) so it can be loaded by `app/ssr.js` under bare `babel-node`
18
+ * during local development. The React component (`./index.jsx`) re-uses
19
+ * the browser-side helper `callAuthLinkProxy` from here.
20
+ *
21
+ * Flow:
22
+ * 1. Browser: Commerce Client widget is ready (onCimulateWidgetReady event)
23
+ * - Extract Commerce Client JWT from the cim_af_ct_* storage key
24
+ * - Send commerce_client_jwt in request body to /api/agent/authlink
25
+ * 2. Server route (registerAuthLinkRoute, mounted in app/ssr.js):
26
+ * - Reads commerce_client_jwt from request body
27
+ * - Validates JWT is present
28
+ * - Reads scrt2Url from the COMMERCE_AGENT_SETTINGS environment variable
29
+ * - Validates scrt2Url against Salesforce domain allowlist (SSRF prevention)
30
+ * - Forwards to SCRT with `Authorization: Bearer <commerce_client_jwt>`
31
+ *
32
+ * NOTE: unlike the Token Bridge, this route does NOT use a siteId — the SCRT
33
+ * authlink endpoint authenticates with the Commerce Client JWT (Bearer) alone,
34
+ * so there is no x-site-id header on this request.
35
+ * 3. SCRT's response (auth_link_key) is forwarded to the browser
36
+ * 4. Browser then calls the existing Token Bridge proxy with auth_link_key
37
+ *
38
+ * IMPORTANT: This endpoint uses the Commerce Client JWT, NOT the SLAS token.
39
+ * The Commerce Client JWT is extracted from the cim_af_ct_* storage key
40
+ * which is set by the Commerce Client widget itself.
41
+ *
42
+ * SCRT2 host resolution: the auth link endpoint (/iamessage/*) is served by
43
+ * SCRT2 (*.salesforce-scrt.com), which is a DIFFERENT host from Core's My Domain
44
+ * (AGENT_MYDOMAIN, used by the Token Bridge). The SCRT2 origin is read from the
45
+ * scrt2Url field of the COMMERCE_AGENT_SETTINGS environment variable, then
46
+ * validated against a Salesforce domain allowlist (prevents SSRF).
47
+ * ------------------------------------------------------------------------- */
48
+
49
+ // eslint-disable-next-line no-relative-import-paths/no-relative-import-paths
50
+ import {isTrustedSalesforceDomain, isTrustedSCRTDomain} from './salesforce-domain-allowlist.js'
51
+
52
+ export const AUTH_LINK_PROXY_PATH = '/api/agent/authlink'
53
+
54
+ /**
55
+ * Upstream timeout (ms) for the SCRT authlink call. Without a bound, a hung SCRT
56
+ * connection would tie up the request until the platform's socket timeout, so
57
+ * we abort well before that and return a 504 the caller can act on.
58
+ */
59
+ const SCRT_FETCH_TIMEOUT_MS = 10000
60
+
61
+ /**
62
+ * SCRT auth link endpoint path — fixed at the **v2** IA-message API.
63
+ *
64
+ * The presented JWT must be minted for the same version; the Commerce Client
65
+ * (Cimulate) widget stores a v2 continuation token, which is what this endpoint
66
+ * expects. A version mismatch is rejected by SCRT with HTTP 401 error 900020
67
+ * (`JWT_VALID_NOT_AUTHORIZED_TO_API`) — the fix is to present a v2 token, NOT
68
+ * to change this path.
69
+ */
70
+ const SCRT_AUTHLINK_PATH = '/iamessage/api/v2/authorization/authlink'
71
+
72
+ /**
73
+ * Extract the SCRT2 origin from the COMMERCE_AGENT_SETTINGS environment variable.
74
+ *
75
+ * The auth link endpoint (`/iamessage/*`) is served by SCRT2, whose host
76
+ * (`*.salesforce-scrt.com`) is different from Core's My Domain (AGENT_MYDOMAIN).
77
+ * SCRT2's base URL is already provisioned to the storefront as the `scrt2Url`
78
+ * field of COMMERCE_AGENT_SETTINGS, so we read it from the same server-side
79
+ * source rather than introducing a new env var.
80
+ *
81
+ * Any trailing slash is stripped so it can be concatenated with an absolute
82
+ * path. Example: https://orgfarm-8fcc267362.test1.my.pc-rnd.salesforce-scrt.com
83
+ *
84
+ * @returns {string|null} - The SCRT2 origin (no trailing slash) or null if not found
85
+ */
86
+ export function extractScrt2UrlFromEnv() {
87
+ const raw = process.env.COMMERCE_AGENT_SETTINGS
88
+
89
+ if (!raw) {
90
+ console.error('[auth-link-proxy] COMMERCE_AGENT_SETTINGS environment variable not set')
91
+ return null
92
+ }
93
+
94
+ let settings
95
+ try {
96
+ settings = typeof raw === 'string' ? JSON.parse(raw) : raw
97
+ } catch (err) {
98
+ console.error('[auth-link-proxy] COMMERCE_AGENT_SETTINGS is not valid JSON', {
99
+ message: err.message
100
+ })
101
+ return null
102
+ }
103
+
104
+ const scrt2Url = settings?.scrt2Url
105
+ if (!scrt2Url || typeof scrt2Url !== 'string' || !scrt2Url.trim()) {
106
+ console.error('[auth-link-proxy] scrt2Url not present in COMMERCE_AGENT_SETTINGS')
107
+ return null
108
+ }
109
+
110
+ // Strip trailing slash(es) so `${scrt2Url}${SCRT_AUTHLINK_PATH}` is well-formed.
111
+ return scrt2Url.trim().replace(/\/+$/, '')
112
+ }
113
+
114
+ // isTrustedSalesforceDomain (Core) and isTrustedSCRTDomain (SCRT2) are shared with
115
+ // token-bridge.js via ./salesforce-domain-allowlist.js. This proxy uses the Core list
116
+ // for the CSRF Origin check (Storefront Preview iframe is served from Core) and the
117
+ // SCRT2 list for the upstream SSRF check on scrt2Url.
118
+
119
+ /**
120
+ * Express handler for POST /api/agent/authlink.
121
+ *
122
+ * Retrieves auth_link_key from SCRT's /iamessage/api/v2/authorization/authlink
123
+ * endpoint. Uses Commerce Client JWT (from the cim_af_ct_* storage key) for
124
+ * authorization.
125
+ *
126
+ * Request:
127
+ * Body:
128
+ * {
129
+ * "commerce_client_jwt": "<jwt_from_cim_af_ct_storage>"
130
+ * }
131
+ *
132
+ * Response:
133
+ * The SCRT status code and body are forwarded verbatim.
134
+ * Success: { "auth_link_key": "..." }
135
+ * SCRT error: <scrt error body, forwarded unchanged>
136
+ * Pre-flight error (no SCRT call): { "error": "ERROR_CODE" }
137
+ *
138
+ * @param {Object} req - Express request object
139
+ * @param {Object} res - Express response object
140
+ */
141
+ export async function handleAuthLinkProxy(req, res) {
142
+ try {
143
+ const {commerce_client_jwt: commerceClientJWT} = req.body || {}
144
+
145
+ // Validate that Commerce Client JWT is provided
146
+ if (!commerceClientJWT || typeof commerceClientJWT !== 'string') {
147
+ console.error('[auth-link-proxy] Commerce Client JWT not provided')
148
+ return res.status(401).json({error: 'MISSING_COMMERCE_CLIENT_JWT'})
149
+ }
150
+
151
+ // CSRF protection: Validate Origin header for state-changing POST
152
+ const origin = req.headers.origin || req.headers.referer
153
+ if (origin) {
154
+ try {
155
+ const originUrl = new URL(origin)
156
+ // Compare host (hostname + port), NOT hostname alone: the Origin/
157
+ // Referer header carries the port for non-default ports (e.g. local
158
+ // dev at localhost:3001), and so does the HTTP Host header. Using
159
+ // `.hostname` would strip the port from one side only ("localhost"
160
+ // vs "localhost:3001") and reject a genuine same-origin request.
161
+ // `.host` also normalizes away default ports (:443/:80), so prod
162
+ // (https://…, no port in Host) still matches.
163
+ const originHost = originUrl.host.toLowerCase()
164
+
165
+ // Allow same-origin requests (PWA Kit storefront calling its own API)
166
+ const requestHost = req.headers.host?.toLowerCase()
167
+ const isSameOrigin = originHost === requestHost
168
+
169
+ // Allow trusted Salesforce origins (for Storefront Preview iframe)
170
+ const isTrustedSalesforceOrigin = isTrustedSalesforceDomain(origin)
171
+
172
+ if (!isSameOrigin && !isTrustedSalesforceOrigin) {
173
+ console.error('[auth-link-proxy] CSRF attempt blocked: untrusted Origin', {
174
+ origin,
175
+ requestHost
176
+ })
177
+ return res.status(403).json({error: 'FORBIDDEN_ORIGIN'})
178
+ }
179
+ } catch (err) {
180
+ // Invalid Origin URL
181
+ console.error('[auth-link-proxy] Invalid Origin header', {origin})
182
+ return res.status(400).json({error: 'INVALID_ORIGIN'})
183
+ }
184
+ }
185
+ // If no Origin/Referer header, allow (same-origin POSTs from some browsers/tools)
186
+
187
+ // Resolve the SCRT2 origin from COMMERCE_AGENT_SETTINGS.scrt2Url.
188
+ // NOTE: this is intentionally NOT AGENT_MYDOMAIN — the auth link endpoint
189
+ // (/iamessage/*) lives on SCRT2 (*.salesforce-scrt.com), a different host
190
+ // from Core's My Domain. Pointing at AGENT_MYDOMAIN returns a 404 from Core.
191
+ const scrt2Url = extractScrt2UrlFromEnv()
192
+
193
+ if (!scrt2Url) {
194
+ console.error(
195
+ '[auth-link-proxy] SCRT2 URL is not configured. ' +
196
+ 'Set scrt2Url in the COMMERCE_AGENT_SETTINGS environment variable.'
197
+ )
198
+ return res.status(500).json({error: 'SCRT2_URL_NOT_CONFIGURED'})
199
+ }
200
+
201
+ // SSRF prevention: validate scrt2Url against the SCRT2 allowlist
202
+ if (!isTrustedSCRTDomain(scrt2Url)) {
203
+ console.error(
204
+ '[auth-link-proxy] SSRF attempt blocked: scrt2Url is not a trusted Salesforce domain',
205
+ {scrt2Url}
206
+ )
207
+ return res.status(400).json({error: 'UNTRUSTED_SCRT2_URL'})
208
+ }
209
+
210
+ // Call SCRT's auth link endpoint with the Commerce Client JWT.
211
+ // The path is fixed at v2 (see SCRT_AUTHLINK_PATH). The request is bounded
212
+ // by SCRT_FETCH_TIMEOUT_MS via an AbortController so a hung upstream does
213
+ // not tie up the connection indefinitely.
214
+ const scrtRequestUrl = `${scrt2Url}${SCRT_AUTHLINK_PATH}`
215
+ const controller = new AbortController()
216
+ const timeoutId = setTimeout(() => controller.abort(), SCRT_FETCH_TIMEOUT_MS)
217
+ let scrtResponse
218
+ let body = null
219
+ try {
220
+ scrtResponse = await fetch(scrtRequestUrl, {
221
+ method: 'GET',
222
+ headers: {
223
+ Authorization: `Bearer ${commerceClientJWT}`
224
+ },
225
+ signal: controller.signal
226
+ })
227
+ try {
228
+ body = await scrtResponse.json()
229
+ } catch (err) {
230
+ if (err?.name === 'AbortError') {
231
+ throw err
232
+ }
233
+ body = null
234
+ }
235
+ } finally {
236
+ clearTimeout(timeoutId)
237
+ }
238
+
239
+ // Forward the status and body from SCRT
240
+ if (!scrtResponse.ok) {
241
+ console.error('[auth-link-proxy] SCRT auth link request failed', {
242
+ status: scrtResponse.status,
243
+ scrtUrl: scrtRequestUrl,
244
+ body
245
+ })
246
+ }
247
+
248
+ // Forward SCRT's status and body to the caller unchanged. The SCRT2 URL
249
+ // that was called is intentionally NOT put in the response — it is only
250
+ // logged server-side (above) for diagnostics, never exposed to the browser.
251
+ return res.status(scrtResponse.status).json(body)
252
+ } catch (err) {
253
+ // AbortError => the SCRT_FETCH_TIMEOUT_MS deadline fired. Surface it as a
254
+ // distinct 504 so the caller can tell a slow upstream from a real 500.
255
+ if (err?.name === 'AbortError') {
256
+ console.error('[auth-link-proxy] SCRT auth link request timed out', {
257
+ timeoutMs: SCRT_FETCH_TIMEOUT_MS
258
+ })
259
+ return res.status(504).json({error: 'SCRT_TIMEOUT'})
260
+ }
261
+ console.error('[auth-link-proxy] Unexpected error:', err)
262
+ return res.status(500).json({error: 'INTERNAL_ERROR'})
263
+ }
264
+ }
265
+
266
+ /**
267
+ * Mount the Auth Link proxy on the given Express app (called from app/ssr.js).
268
+ *
269
+ * @param {Object} app - Express application instance
270
+ */
271
+ export function registerAuthLinkRoute(app) {
272
+ app.post(AUTH_LINK_PROXY_PATH, handleAuthLinkProxy)
273
+ }
274
+
275
+ /**
276
+ * Browser helper: POSTs to the same-origin proxy and returns SCRT's auth_link_key.
277
+ *
278
+ * Uses the Commerce Client JWT (extracted from the cim_af_ct_* storage key)
279
+ * to authenticate with SCRT's authlink endpoint.
280
+ *
281
+ * The SCRT2 origin is derived server-side from scrt2Url in the
282
+ * COMMERCE_AGENT_SETTINGS environment variable.
283
+ *
284
+ * @param {Object} options - Request options
285
+ * @param {string} options.commerceClientJWT - Commerce Client JWT from the cim_af_ct_* storage key (required)
286
+ * @returns {Promise<Object>} Promise that resolves to { auth_link_key: "..." }
287
+ */
288
+ export const callAuthLinkProxy = async ({commerceClientJWT}) => {
289
+ const requestBody = {
290
+ commerce_client_jwt: commerceClientJWT
291
+ }
292
+
293
+ // No x-site-id header: the SCRT authlink endpoint authenticates with the
294
+ // Commerce Client JWT (Bearer) alone. Unlike the Token Bridge — which needs
295
+ // the siteId to resolve HttpOnly cookie names (cc-at_{siteId}) — authlink has
296
+ // nothing to key off the siteId, so sending it would be dead weight.
297
+ const res = await fetch(AUTH_LINK_PROXY_PATH, {
298
+ method: 'POST',
299
+ headers: {
300
+ 'Content-Type': 'application/json'
301
+ },
302
+ body: JSON.stringify(requestBody)
303
+ })
304
+
305
+ let responseBody = null
306
+ try {
307
+ responseBody = await res.json()
308
+ } catch {
309
+ responseBody = null
310
+ }
311
+
312
+ if (!res.ok) {
313
+ throw new Error(`Auth link proxy failed: ${responseBody?.error || `HTTP_${res.status}`}`)
314
+ }
315
+
316
+ return responseBody
317
+ }