Skip to content

Commit 42bb9d9

Browse files
Use BM-backed ECA token broker and generalize AI overviews
1 parent 5adb464 commit 42bb9d9

11 files changed

Lines changed: 625 additions & 16 deletions

File tree

‎packages/template-retail-react-app/CHANGELOG.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
## v10.4.0-dev (Oct 20, 2026)
2+
- [Bugfix] Describe AI overview products as selected highlights and invite shoppers to browse all matching products below, without announcing a fixed number of options.
3+
- [Feature] Make AI search `eca` authentication consume a server-authenticated ECOM broker for Business Manager credentials; keep local ECA secrets in an explicit `eca_direct` mode. Document the required companion ECOM endpoint and scope, which must be deployed before BM-backed ECA can be enabled.
24
- [Feature] Add opt-in AI-assisted search with global/per-site configuration, product-first streamed overviews, search-scoped follow-ups, and Commerce Agent context handoff. Isolate API-key and ECA Gateway authentication behind a server-only mode switch. The preview uses process-local conversation storage; scaled deployments require shared storage and distributed controls. Increase the `main.js` budget from 111 kB to 112 kB for the shared feature configuration and session helpers.
35
- [Bugfix] Keep Commerce Client shopper-agent product links on the PWA Kit storefront. The agent backend sends product links as SFRA URLs on the backend origin (e.g. `/en_US/<slug>-<id>.html`), which the widget's `onNavigate` never sees, so clicking a product card left for the SFRA PDP. The storefront now reads the product id from those links in the widget's product slots (cards, product detail title, comparison columns), rewrites their `href` to the PWA product page, and routes plain clicks through the router. Links in agent message text are left alone. When the product id is not certain from the URL (the last path segment contains `-`, so it may be a name slug and id, or an id that contains `-`), it is resolved through Shopper SEO `getUrlMapping`; if that finds no product the link keeps its backend URL rather than pointing at a storefront 404. [#4076](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/4076)
46
- [Feature] Add a neutral Shopper Agent express-payment iframe framework with cart and product routes, a merchant provider lifecycle contract, validated iframe-to-host events, and automatic iframe sizing. [#4063](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/4063)

‎packages/template-retail-react-app/app/pages/product-list/partials/search-summary.jsx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -198,7 +198,7 @@ const SearchSummary = ({
198198
)}
199199
<Picks products={picks} currency={currency} />
200200
<Text fontSize="xs" color="#7b8772" mt={3}>
201-
Highlights from the top search matches. Ask below to check more product details.
201+
A few highlights from your search. Browse all matching products below.
202202
</Text>
203203
</Box>
204204
{(config.followUps || config.agentHandoff) && (

‎packages/template-retail-react-app/app/server/llm-gateway.test.js‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import {
1212
import {createEcaAuth} from '@salesforce/retail-react-app/app/server/llm-gateway/auth/eca'
1313

1414
const env = {
15-
LLM_GATEWAY_AUTH_MODE: 'eca',
15+
LLM_GATEWAY_AUTH_MODE: 'eca_direct',
1616
LLM_GATEWAY_ECA_TOKEN_URL: 'https://shop.my.salesforce.com/services/oauth2/token',
1717
LLM_GATEWAY_ECA_CLIENT_ID: 'test-client',
1818
LLM_GATEWAY_ECA_CLIENT_SECRET: 'test-secret',
@@ -44,7 +44,7 @@ const collect = async (client) => {
4444
for await (const delta of client.stream([{role: 'user', content: 'shirt'}])) text += delta
4545
return text
4646
}
47-
test('one auth-mode switch selects ECA OAuth, Bearer and the public Gateway request shape', async () => {
47+
test('explicit direct mode selects ECA OAuth, Bearer and the public Gateway request shape', async () => {
4848
const token = jwt(Date.now() / 1000 + 1800)
4949
const fetchImpl = jest
5050
.fn()
Lines changed: 201 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,201 @@
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+
// This server-only adapter consumes the ECOM broker contract in docs/BM-ECA-TOKEN-BROKER.md.
9+
// The broker must be deployed separately: this is not a shipped SCAPI endpoint.
10+
const AM_TOKEN_URL = 'https://account.demandware.com/dwsso/oauth2/access_token'
11+
const BROKER_SCOPE = 'sfcc.llm-auth'
12+
const MAX_TOKEN_LENGTH = 32768
13+
14+
export function bmBrokerEndpoint(value) {
15+
let url
16+
try {
17+
url = new URL(value)
18+
} catch {
19+
throw new Error('Invalid BM token broker configuration')
20+
}
21+
if (
22+
url.protocol !== 'https:' ||
23+
!/^[a-z0-9-]+\.api\.commercecloud\.salesforce\.com$/.test(url.hostname) ||
24+
url.username ||
25+
url.password ||
26+
url.search ||
27+
url.hash ||
28+
(url.port && url.port !== '443') ||
29+
!/^\/merchant\/llm-auth\/v1\/organizations\/f_ecom_[A-Za-z0-9_-]+\/access-tokens$/.test(
30+
url.pathname
31+
)
32+
)
33+
throw new Error('Invalid BM token broker configuration')
34+
return url
35+
}
36+
const fail = (message, status) => {
37+
const error = new Error(message)
38+
if (Number.isInteger(status)) error.status = status
39+
return error
40+
}
41+
const refreshTime = (expires, now) => expires - Math.min(120000, (expires - now) / 10)
42+
const parseBrokerToken = (payload, now) => {
43+
try {
44+
const token = payload.access_token
45+
if (
46+
typeof token !== 'string' ||
47+
token.length > MAX_TOKEN_LENGTH ||
48+
!/^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/.test(token) ||
49+
typeof payload.token_type !== 'string' ||
50+
payload.token_type.toLowerCase() !== 'bearer' ||
51+
Object.keys(payload).some(
52+
(key) => !['access_token', 'token_type', 'core_tenant_id'].includes(key)
53+
)
54+
)
55+
throw new Error()
56+
// Decode metadata only. The authenticated ECOM broker controls issuance; Gateway validates the JWT.
57+
const [encodedHeader, encodedClaims] = token.split('.')
58+
const header = JSON.parse(Buffer.from(encodedHeader, 'base64url').toString())
59+
const claims = JSON.parse(Buffer.from(encodedClaims, 'base64url').toString())
60+
const expires = claims.exp * 1000
61+
if (!Number.isSafeInteger(claims.exp) || expires <= now + 5000 || expires > now + 1830000)
62+
throw new Error()
63+
const tenant = header.tnk || payload.core_tenant_id
64+
if (
65+
tenant != null &&
66+
(typeof tenant !== 'string' || !/^core\/[A-Za-z0-9-]+\/[A-Za-z0-9]{18}$/.test(tenant))
67+
)
68+
throw new Error()
69+
if (
70+
header.tnk &&
71+
payload.core_tenant_id &&
72+
header.tnk.toLowerCase() !== payload.core_tenant_id.toLowerCase()
73+
)
74+
throw new Error()
75+
return {token, tenant, refreshAt: refreshTime(expires, now)}
76+
} catch {
77+
throw new Error('Invalid BM broker token response')
78+
}
79+
}
80+
81+
export function createBmEcaAuth({env, fetchImpl = fetch, now = Date.now}) {
82+
const endpoint = bmBrokerEndpoint(env.LLM_GATEWAY_BM_TOKEN_URL)
83+
const clientId = env.LLM_GATEWAY_BM_AM_CLIENT_ID
84+
const clientSecret = env.LLM_GATEWAY_BM_AM_CLIENT_SECRET
85+
if (!clientId || !clientSecret)
86+
throw new Error('Set LLM_GATEWAY_BM_AM_CLIENT_ID and LLM_GATEWAY_BM_AM_CLIENT_SECRET')
87+
// AM tenant scope is derived from the fixed broker URL, not shopper request data.
88+
const organization = endpoint.pathname.split('/')[5]
89+
const scope = `SALESFORCE_COMMERCE_API:${organization.slice('f_ecom_'.length)} ${BROKER_SCOPE}`
90+
let amToken = null
91+
let cached = null
92+
let inFlight = null
93+
94+
const post = async (url, options, message) => {
95+
const controller = new AbortController()
96+
const timeout = setTimeout(() => controller.abort(), 10000)
97+
try {
98+
const response = await fetchImpl(url, {
99+
...options,
100+
method: 'POST',
101+
redirect: 'error',
102+
signal: controller.signal
103+
})
104+
if (!response.ok) {
105+
await response.body?.cancel?.().catch(() => {})
106+
throw fail(message, response.status)
107+
}
108+
return await response.json()
109+
} catch (error) {
110+
// Do not propagate raw fetch errors, OAuth descriptions, credentials or response bodies.
111+
throw fail(message, error?.status)
112+
} finally {
113+
clearTimeout(timeout)
114+
}
115+
}
116+
const getAmToken = async () => {
117+
if (amToken && amToken.refreshAt > now()) return amToken
118+
const result = await post(
119+
AM_TOKEN_URL,
120+
{
121+
headers: {
122+
Authorization: `Basic ${Buffer.from(`${clientId}:${clientSecret}`).toString(
123+
'base64'
124+
)}`,
125+
'Content-Type': 'application/x-www-form-urlencoded',
126+
Accept: 'application/json',
127+
'Cache-Control': 'no-store'
128+
},
129+
body: new URLSearchParams({grant_type: 'client_credentials', scope}).toString()
130+
},
131+
'BM broker service authentication failed'
132+
)
133+
if (
134+
typeof result?.access_token !== 'string' ||
135+
!result.access_token ||
136+
result.access_token.length > MAX_TOKEN_LENGTH ||
137+
/\s/.test(result.access_token) ||
138+
typeof result.token_type !== 'string' ||
139+
result.token_type.toLowerCase() !== 'bearer' ||
140+
!Number.isInteger(result.expires_in) ||
141+
result.expires_in < 10 ||
142+
result.expires_in > 86400
143+
)
144+
throw new Error('Invalid BM service token response')
145+
amToken = {
146+
token: result.access_token,
147+
refreshAt: refreshTime(now() + result.expires_in * 1000, now())
148+
}
149+
return amToken
150+
}
151+
const mint = async () => {
152+
for (let attempt = 0; attempt < 2; attempt++) {
153+
const lease = await getAmToken()
154+
let payload
155+
try {
156+
// No ECA credentials, shopper bearer tokens, prompts or service IDs are sent here.
157+
// The ECOM endpoint chooses the instance's stored ECA and always mints a fresh token.
158+
payload = await post(
159+
endpoint,
160+
{
161+
headers: {
162+
Authorization: `Bearer ${lease.token}`,
163+
Accept: 'application/json',
164+
'Cache-Control': 'no-store'
165+
}
166+
},
167+
'BM credential broker request failed'
168+
)
169+
} catch (error) {
170+
if (error.status !== 401 || attempt > 0) throw error
171+
if (amToken === lease) amToken = null
172+
continue
173+
}
174+
cached = parseBrokerToken(payload, now())
175+
return
176+
}
177+
}
178+
return {
179+
refreshable: true,
180+
async authorize() {
181+
if (!cached || cached.refreshAt <= now()) {
182+
if (!inFlight)
183+
inFlight = mint().finally(() => {
184+
inFlight = null
185+
})
186+
// Shared exchange is not tied to one shopper's abort signal.
187+
await inFlight
188+
}
189+
const lease = cached
190+
return {
191+
headers: {
192+
Authorization: `Bearer ${lease.token}`,
193+
...(lease.tenant ? {'x-sfdc-core-tenant-id': lease.tenant} : {})
194+
},
195+
invalidate() {
196+
if (cached === lease) cached = null
197+
}
198+
}
199+
}
200+
}
201+
}

0 commit comments

Comments
 (0)