From 9d60d636a3ec607504a96f100367a1f1d0dc310b Mon Sep 17 00:00:00 2001 From: Ben Chypak Date: Fri, 11 Sep 2026 14:50:06 -0700 Subject: [PATCH 01/55] feat: add app_pwakit_base B2C cartridge for email delivery and fix GLO verify auth MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a new app_pwakit_base cartridge that handles transactional email delivery (passwordless magic link, OTP verification, GLO access code) via B2C ISML templates and dw/net/Mail, replacing the sfnext dependency for email hooks. Key changes: - New cartridge with sfcc.app.order.sendOrderAccessCode, sendNotification helper, ISML email templates, hooks.json, and OrganizationPreferences metadata - Fix GLO /api/order-lookup/verify and related SSR endpoints to read the SLAS access token from the incoming Authorization header (sent by the client via useAccessToken) instead of cookies — cc-nx-g_ is the SLAS refresh token, not the access token, and the real JWT is never stored in any cookie - Enable guestOrderLookup and passwordless feature flags in config/default.js - Add GLO and email translation keys (en-US, en-GB) - Reset-password-landing.jsx email field PII fix --- .../bootstrap/js/overrides/app/ssr.js.hbs | 244 +++++++-------- .../retail-react-app/app/ssr.js.hbs | 244 +++++++-------- .../template-retail-react-app/CHANGELOG.md | 4 + .../app/components/_app-config/index.jsx | 2 +- .../app/pages/guest-order-lookup/verify.jsx | 65 +++- .../reset-password/reset-password-landing.jsx | 21 +- packages/template-retail-react-app/app/ssr.js | 278 ++++++++---------- .../static/translations/compiled/en-GB.json | 18 ++ .../static/translations/compiled/en-US.json | 18 ++ .../static/translations/compiled/en-XA.json | 42 +++ .../cartridge/app_pwakit_base.properties | 2 + .../rest-apis/pwakit-notify/Notify.js | 208 +++++++++++++ .../rest-apis/pwakit-notify/api.json | 9 + .../rest-apis/pwakit-notify/schema.yaml | 131 +++++++++ .../scripts/helpers/sendNotification.js | 118 ++++++++ .../scripts/helpers/storefrontHostProvider.js | 95 ++++++ .../scripts/hooks/sendOrderAccessCode.js | 98 ++++++ .../default/email/common/footer.isml | 18 ++ .../default/email/common/header.isml | 32 ++ .../default/email/gloAccessCode.isml | 63 ++++ .../default/email/otpVerification.isml | 37 +++ .../default/email/passwordResetMagicLink.isml | 45 +++ .../default/email/passwordlessMagicLink.isml | 45 +++ .../templates/resources/email.properties | 43 +++ .../cartridges/app_pwakit_base/hooks.json | 8 + .../cartridges/app_pwakit_base/package.json | 6 + .../OrganizationPreferences.xml | 63 ++++ .../config/default.js | 22 +- .../translations/en-GB.json | 9 + .../translations/en-US.json | 9 + 30 files changed, 1546 insertions(+), 451 deletions(-) create mode 100644 packages/template-retail-react-app/cartridges/app_pwakit_base/cartridge/app_pwakit_base.properties create mode 100644 packages/template-retail-react-app/cartridges/app_pwakit_base/cartridge/rest-apis/pwakit-notify/Notify.js create mode 100644 packages/template-retail-react-app/cartridges/app_pwakit_base/cartridge/rest-apis/pwakit-notify/api.json create mode 100644 packages/template-retail-react-app/cartridges/app_pwakit_base/cartridge/rest-apis/pwakit-notify/schema.yaml create mode 100644 packages/template-retail-react-app/cartridges/app_pwakit_base/cartridge/scripts/helpers/sendNotification.js create mode 100644 packages/template-retail-react-app/cartridges/app_pwakit_base/cartridge/scripts/helpers/storefrontHostProvider.js create mode 100644 packages/template-retail-react-app/cartridges/app_pwakit_base/cartridge/scripts/hooks/sendOrderAccessCode.js create mode 100644 packages/template-retail-react-app/cartridges/app_pwakit_base/cartridge/templates/default/email/common/footer.isml create mode 100644 packages/template-retail-react-app/cartridges/app_pwakit_base/cartridge/templates/default/email/common/header.isml create mode 100644 packages/template-retail-react-app/cartridges/app_pwakit_base/cartridge/templates/default/email/gloAccessCode.isml create mode 100644 packages/template-retail-react-app/cartridges/app_pwakit_base/cartridge/templates/default/email/otpVerification.isml create mode 100644 packages/template-retail-react-app/cartridges/app_pwakit_base/cartridge/templates/default/email/passwordResetMagicLink.isml create mode 100644 packages/template-retail-react-app/cartridges/app_pwakit_base/cartridge/templates/default/email/passwordlessMagicLink.isml create mode 100644 packages/template-retail-react-app/cartridges/app_pwakit_base/cartridge/templates/resources/email.properties create mode 100644 packages/template-retail-react-app/cartridges/app_pwakit_base/hooks.json create mode 100644 packages/template-retail-react-app/cartridges/app_pwakit_base/package.json create mode 100644 packages/template-retail-react-app/cartridges/app_pwakit_base/staticfiles/cartridge/impex/default/meta/system-objecttype-extensions/OrganizationPreferences.xml diff --git a/packages/pwa-kit-create-app/assets/bootstrap/js/overrides/app/ssr.js.hbs b/packages/pwa-kit-create-app/assets/bootstrap/js/overrides/app/ssr.js.hbs index 707c4cdf50..5211bed433 100644 --- a/packages/pwa-kit-create-app/assets/bootstrap/js/overrides/app/ssr.js.hbs +++ b/packages/pwa-kit-create-app/assets/bootstrap/js/overrides/app/ssr.js.hbs @@ -7,17 +7,17 @@ 'use strict' -import crypto from 'crypto' import express from 'express' import helmet from 'helmet' -import {createRemoteJWKSet as joseCreateRemoteJWKSet, jwtVerify, decodeJwt} from 'jose' +import {createLocalJWKSet, createRemoteJWKSet as joseCreateRemoteJWKSet, jwtVerify, decodeJwt} from 'jose' import path from 'path' import {getRuntime} from '@salesforce/pwa-kit-runtime/ssr/server/express' import {defaultPwaKitSecurityHeaders} from '@salesforce/pwa-kit-runtime/utils/middleware' import {getConfig} from '@salesforce/pwa-kit-runtime/utils/ssr-config' import {getAppOrigin} from '@salesforce/pwa-kit-react-sdk/utils/url' +import logger from '@salesforce/pwa-kit-runtime/utils/logger-instance' import {registerTokenBridgeRoute} from '@salesforce/retail-react-app/app/components/shopper-agent/token-bridge.js' -import {ShopperOrders} from 'commerce-sdk-isomorphic' +import {ShopperLogin, ShopperOrders, helpers} from 'commerce-sdk-isomorphic' const config = getConfig() @@ -253,115 +253,56 @@ const options = { const runtime = getRuntime() -/** - * Tokens are valid for 20 minutes. We store it at the top level scope to reuse - * it during the lambda invocation. We'll refresh it after 15 minutes. - */ -let marketingCloudToken = '' -let marketingCloudTokenExpiration = new Date() - -/** - * Generates a unique ID for the email message. - * - * @return {string} A unique ID for the email message. - */ -function generateUniqueId() { - return crypto.randomBytes(16).toString('hex') -} - -/** - * Sends an email to a specified contact using the Marketing Cloud API. The template email must have a - * `%%magic-link%%` personalization string inserted. - * https://help.salesforce.com/s/articleView?id=mktg.mc_es_personalization_strings.htm&type=5 - * - * @param {string} email - The email address of the contact to whom the email will be sent. - * @param {string} templateId - The ID of the email template to be used for the email. - * @param {string} magicLink - The magic link to be included in the email. - * - * @return {Promise} A promise that resolves to the response object received from the Marketing Cloud API. - */ -async function sendMarketingCloudEmail(emailId, marketingCloudConfig) { - // Refresh token if expired - if (new Date() > marketingCloudTokenExpiration) { - const {clientId, clientSecret, subdomain} = marketingCloudConfig - const tokenUrl = `https://${subdomain}.auth.marketingcloudapis.com/v2/token` - const tokenResponse = await fetch(tokenUrl, { - method: 'POST', - headers: {'Content-Type': 'application/json'}, - body: JSON.stringify({ - grant_type: 'client_credentials', - client_id: clientId, - client_secret: clientSecret - }) - }) - - if (!tokenResponse.ok) - throw new Error( - 'Failed to fetch Marketing Cloud access token. Check your Marketing Cloud credentials and try again.' - ) +// Module-level guest token cache for pwakit-notify SCAPI calls. +// Warm Lambda reuse avoids a SLAS round-trip on every callback invocation. +let _notifyToken = null +let _notifyTokenExpiry = 0 - const {access_token} = await tokenResponse.json() - marketingCloudToken = access_token - // Set expiration to 15 mins - marketingCloudTokenExpiration = new Date(Date.now() + 15 * 60 * 1000) +async function getNotifyToken(apiParams) { + if (_notifyToken && Date.now() < _notifyTokenExpiry) { + return _notifyToken } + const {clientId, organizationId, shortCode, siteId} = apiParams + const proxy = `${getAppOrigin()}${getConfig()?.app?.commerceAPI?.proxyPath || '/mobify/proxy/api'}` + const slasClient = new ShopperLogin({ + parameters: {clientId, organizationId, shortCode, siteId}, + proxy, + throwOnBadResponse: true + }) + const tokenResponse = await helpers.loginGuestUser({ + slasClient, + parameters: { + redirectURI: `${getAppOrigin()}/callback` + } + }) + _notifyToken = tokenResponse.access_token + // Cache for 25 minutes — SLAS guest tokens last 30 min, 5 min buffer + _notifyTokenExpiry = Date.now() + 25 * 60 * 1000 + return _notifyToken +} - // Send the email - const emailUrl = `https://${ - marketingCloudConfig.subdomain - }.rest.marketingcloudapis.com/messaging/v1/email/messages/${generateUniqueId()}` - const emailResponse = await fetch(emailUrl, { +async function sendViaB2cCartridge(type, recipient, data, apiParams) { + const {organizationId, siteId} = apiParams + const token = await getNotifyToken(apiParams) + const proxy = `${getAppOrigin()}${getConfig()?.app?.commerceAPI?.proxyPath || '/mobify/proxy/api'}` + const url = `${proxy}/custom/pwakit-notify/v1/organizations/${encodeURIComponent(organizationId)}/sites/${encodeURIComponent(siteId)}/notify?siteId=${encodeURIComponent(siteId)}` + const res = await fetch(url, { method: 'POST', headers: { - Authorization: `Bearer ${marketingCloudToken}`, - 'Content-Type': 'application/json' + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}` }, - body: JSON.stringify({ - definitionKey: marketingCloudConfig.templateId, - recipient: { - contactKey: emailId, - to: emailId, - attributes: {'magic-link': marketingCloudConfig.magicLink} - } - }) + body: JSON.stringify({type, recipient, data}) }) - - if (!emailResponse.ok) throw new Error('Failed to send email to Marketing Cloud') - - return await emailResponse.json() -} - -/** - * Generates a unique ID, constructs an email message URL, and sends the email to the specified contact - * using the Marketing Cloud API. - * - * @param {string} email - The email address of the contact to whom the email will be sent. - * @param {string} templateId - The ID of the email template to be used for the email. - * @param {string} magicLink - The magic link to be included in the email. - * - * @return {Promise} A promise that resolves to the response object received from the Marketing Cloud API. - */ -export async function emailLink(emailId, templateId, magicLink) { - if (!process.env.MARKETING_CLOUD_CLIENT_ID) { - console.warn('MARKETING_CLOUD_CLIENT_ID is not set in the environment variables.') - } - - if (!process.env.MARKETING_CLOUD_CLIENT_SECRET) { - console.warn(' MARKETING_CLOUD_CLIENT_SECRET is not set in the environment variables.') - } - - if (!process.env.MARKETING_CLOUD_SUBDOMAIN) { - console.warn('MARKETING_CLOUD_SUBDOMAIN is not set in the environment variables.') - } - - const marketingCloudConfig = { - clientId: process.env.MARKETING_CLOUD_CLIENT_ID, - clientSecret: process.env.MARKETING_CLOUD_CLIENT_SECRET, - magicLink: magicLink, - subdomain: process.env.MARKETING_CLOUD_SUBDOMAIN, - templateId: templateId + if (!res.ok) { + if (res.status === 401) { + // Clear cached token so next call gets a fresh one + _notifyToken = null + _notifyTokenExpiry = 0 + } + throw new Error(`pwakit-notify returned ${res.status}`) } - return await sendMarketingCloudEmail(emailId, marketingCloudConfig) + return await res.json() } const resetPasswordCallback = @@ -369,30 +310,17 @@ const resetPasswordCallback = const passwordlessLoginCallback = config.app.login?.passwordless?.callbackURI || '/passwordless-login-callback' -// Reusable function to handle sending a magic link email. -// By default, this implementation uses Marketing Cloud. -async function sendMagicLinkEmail(req, res, landingPath, emailTemplate, redirectUrl) { - // Extract the base URL from the request - const base = req.protocol + '://' + req.get('host') - - // Extract the email_id and token from the request body +async function sendMagicLinkEmail(req, res, landingPath, notifyType, redirectUrl) { const {email_id, token} = req.body - // Construct the magic link URL - let magicLink = `${base}${landingPath}?token=${encodeURIComponent(token)}` - if (landingPath === config.app.login?.resetPassword?.landingPath) { - // Add email query parameter for reset password flow - magicLink += `&email=${encodeURIComponent(email_id)}` + let magicLinkPath = `${landingPath}?token=${encodeURIComponent(token)}&email=${encodeURIComponent(email_id)}` + if (notifyType === 'passwordless-magic-link' && redirectUrl) { + magicLinkPath += `&redirect_url=${encodeURIComponent(redirectUrl)}` } - if (landingPath === config.app.login?.passwordless?.landingPath && redirectUrl) { - magicLink += `&redirect_url=${encodeURIComponent(redirectUrl)}` - } - - // Call the emailLink function to send an email with the magic link using Marketing Cloud - const emailLinkResponse = await emailLink(email_id, emailTemplate, magicLink) - // Send the response - res.send(emailLinkResponse) + const appConfig = getConfig()?.app + await sendViaB2cCartridge(notifyType, email_id, {magicLinkPath}, appConfig.commerceAPI.parameters) + res.json({success: true}) } const CLAIM = { @@ -427,9 +355,23 @@ export const validateSlasCallbackToken = async (token) => { const tokens = subClaim.split(DELIMITER.ISSUER) const tenantId = tokens[2] try { - const jwks = createRemoteJWKSet(tenantId) - const {payload} = await jwtVerify(token, jwks, {}) - return payload + let jwks + if (process.env.SLAS_JWKS_JSON) { + const parsed = JSON.parse(process.env.SLAS_JWKS_JSON) + if (!Array.isArray(parsed?.keys) || parsed.keys.length === 0) { + throwSlasTokenValidationError( + 'SLAS_JWKS_JSON must be a JSON object with a non-empty "keys" array', + 400 + ) + } + jwks = createLocalJWKSet(parsed) + } else { + jwks = createRemoteJWKSet(tenantId) + } + const {payload: validatedPayload} = await jwtVerify(token, jwks, { + algorithms: ['RS256', 'ES256'] + }) + return validatedPayload } catch (error) { throwSlasTokenValidationError(error.message, 401) } @@ -596,34 +538,62 @@ const {handler} = runtime.createHandler(options, (app) => { // endpoint sending the email address and passwordless token. Then this endpoint calls // the sendMagicLinkEmail function to send an email with the passwordless login magic link. // https://developer.salesforce.com/docs/commerce/commerce-api/guide/slas-passwordless-login.html#receive-the-callback - app.post(passwordlessLoginCallback, (req, res) => { + app.post(passwordlessLoginCallback, async (req, res) => { + const appConfig = getConfig()?.app + if (appConfig?.login?.passwordless?.mode !== 'callback') { + return res.status(400).json({error: 'Passwordless callback mode not enabled'}) + } const slasCallbackToken = req.headers['x-slas-callback-token'] + if (!slasCallbackToken) { + return res.status(400).json({error: 'Missing x-slas-callback-token header'}) + } const redirectUrl = req.query.redirectUrl - validateSlasCallbackToken(slasCallbackToken).then(() => { - sendMagicLinkEmail( + try { + await validateSlasCallbackToken(slasCallbackToken) + await sendMagicLinkEmail( req, res, config.app.login?.passwordless?.landingPath, - process.env.MARKETING_CLOUD_PASSWORDLESS_LOGIN_TEMPLATE, + 'passwordless-magic-link', redirectUrl ) - }) + } catch (err) { + logger.error('passwordless callback failed', { + namespace: 'slas-callback', + additionalProperties: {error: err?.message} + }) + res.status(500).json({error: 'Failed to send notification'}) + } }) // Handles the reset password callback route. SLAS makes a POST request to this // endpoint sending the email address and reset password token. Then this endpoint calls // the sendMagicLinkEmail function to send an email with the reset password magic link. // https://developer.salesforce.com/docs/commerce/commerce-api/guide/slas-password-reset.html#slas-password-reset-flow - app.post(resetPasswordCallback, (req, res) => { + app.post(resetPasswordCallback, async (req, res) => { + const appConfig = getConfig()?.app + if (appConfig?.login?.resetPassword?.mode !== 'callback') { + return res.status(400).json({error: 'Reset password callback mode not enabled'}) + } const slasCallbackToken = req.headers['x-slas-callback-token'] - validateSlasCallbackToken(slasCallbackToken).then(() => { - sendMagicLinkEmail( + if (!slasCallbackToken) { + return res.status(400).json({error: 'Missing x-slas-callback-token header'}) + } + try { + await validateSlasCallbackToken(slasCallbackToken) + await sendMagicLinkEmail( req, res, config.app.login?.resetPassword?.landingPath, - process.env.MARKETING_CLOUD_RESET_PASSWORD_TEMPLATE + 'password-reset' ) - }) + } catch (err) { + logger.error('reset-password callback failed', { + namespace: 'slas-callback', + additionalProperties: {error: err?.message} + }) + res.status(500).json({error: 'Failed to send notification'}) + } }) app.get('/robots.txt', runtime.serveStaticFile('static/robots.txt')) diff --git a/packages/pwa-kit-create-app/assets/templates/@salesforce/retail-react-app/app/ssr.js.hbs b/packages/pwa-kit-create-app/assets/templates/@salesforce/retail-react-app/app/ssr.js.hbs index f63c1ea0af..2147ad48a4 100644 --- a/packages/pwa-kit-create-app/assets/templates/@salesforce/retail-react-app/app/ssr.js.hbs +++ b/packages/pwa-kit-create-app/assets/templates/@salesforce/retail-react-app/app/ssr.js.hbs @@ -7,17 +7,17 @@ 'use strict' -import crypto from 'crypto' import express from 'express' import helmet from 'helmet' -import {createRemoteJWKSet as joseCreateRemoteJWKSet, jwtVerify, decodeJwt} from 'jose' +import {createLocalJWKSet, createRemoteJWKSet as joseCreateRemoteJWKSet, jwtVerify, decodeJwt} from 'jose' import path from 'path' import {getRuntime} from '@salesforce/pwa-kit-runtime/ssr/server/express' import {defaultPwaKitSecurityHeaders} from '@salesforce/pwa-kit-runtime/utils/middleware' import {getConfig} from '@salesforce/pwa-kit-runtime/utils/ssr-config' import {getAppOrigin} from '@salesforce/pwa-kit-react-sdk/utils/url' +import logger from '@salesforce/pwa-kit-runtime/utils/logger-instance' import {registerTokenBridgeRoute} from './components/shopper-agent/token-bridge.js' -import {ShopperOrders} from 'commerce-sdk-isomorphic' +import {ShopperLogin, ShopperOrders, helpers} from 'commerce-sdk-isomorphic' const config = getConfig() @@ -253,115 +253,56 @@ const options = { const runtime = getRuntime() -/** - * Tokens are valid for 20 minutes. We store it at the top level scope to reuse - * it during the lambda invocation. We'll refresh it after 15 minutes. - */ -let marketingCloudToken = '' -let marketingCloudTokenExpiration = new Date() - -/** - * Generates a unique ID for the email message. - * - * @return {string} A unique ID for the email message. - */ -function generateUniqueId() { - return crypto.randomBytes(16).toString('hex') -} - -/** - * Sends an email to a specified contact using the Marketing Cloud API. The template email must have a - * `%%magic-link%%` personalization string inserted. - * https://help.salesforce.com/s/articleView?id=mktg.mc_es_personalization_strings.htm&type=5 - * - * @param {string} email - The email address of the contact to whom the email will be sent. - * @param {string} templateId - The ID of the email template to be used for the email. - * @param {string} magicLink - The magic link to be included in the email. - * - * @return {Promise} A promise that resolves to the response object received from the Marketing Cloud API. - */ -async function sendMarketingCloudEmail(emailId, marketingCloudConfig) { - // Refresh token if expired - if (new Date() > marketingCloudTokenExpiration) { - const {clientId, clientSecret, subdomain} = marketingCloudConfig - const tokenUrl = `https://${subdomain}.auth.marketingcloudapis.com/v2/token` - const tokenResponse = await fetch(tokenUrl, { - method: 'POST', - headers: {'Content-Type': 'application/json'}, - body: JSON.stringify({ - grant_type: 'client_credentials', - client_id: clientId, - client_secret: clientSecret - }) - }) - - if (!tokenResponse.ok) - throw new Error( - 'Failed to fetch Marketing Cloud access token. Check your Marketing Cloud credentials and try again.' - ) +// Module-level guest token cache for pwakit-notify SCAPI calls. +// Warm Lambda reuse avoids a SLAS round-trip on every callback invocation. +let _notifyToken = null +let _notifyTokenExpiry = 0 - const {access_token} = await tokenResponse.json() - marketingCloudToken = access_token - // Set expiration to 15 mins - marketingCloudTokenExpiration = new Date(Date.now() + 15 * 60 * 1000) +async function getNotifyToken(apiParams) { + if (_notifyToken && Date.now() < _notifyTokenExpiry) { + return _notifyToken } + const {clientId, organizationId, shortCode, siteId} = apiParams + const proxy = `${getAppOrigin()}${getConfig()?.app?.commerceAPI?.proxyPath || '/mobify/proxy/api'}` + const slasClient = new ShopperLogin({ + parameters: {clientId, organizationId, shortCode, siteId}, + proxy, + throwOnBadResponse: true + }) + const tokenResponse = await helpers.loginGuestUser({ + slasClient, + parameters: { + redirectURI: `${getAppOrigin()}/callback` + } + }) + _notifyToken = tokenResponse.access_token + // Cache for 25 minutes — SLAS guest tokens last 30 min, 5 min buffer + _notifyTokenExpiry = Date.now() + 25 * 60 * 1000 + return _notifyToken +} - // Send the email - const emailUrl = `https://${ - marketingCloudConfig.subdomain - }.rest.marketingcloudapis.com/messaging/v1/email/messages/${generateUniqueId()}` - const emailResponse = await fetch(emailUrl, { +async function sendViaB2cCartridge(type, recipient, data, apiParams) { + const {organizationId, siteId} = apiParams + const token = await getNotifyToken(apiParams) + const proxy = `${getAppOrigin()}${getConfig()?.app?.commerceAPI?.proxyPath || '/mobify/proxy/api'}` + const url = `${proxy}/custom/pwakit-notify/v1/organizations/${encodeURIComponent(organizationId)}/sites/${encodeURIComponent(siteId)}/notify?siteId=${encodeURIComponent(siteId)}` + const res = await fetch(url, { method: 'POST', headers: { - Authorization: `Bearer ${marketingCloudToken}`, - 'Content-Type': 'application/json' + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}` }, - body: JSON.stringify({ - definitionKey: marketingCloudConfig.templateId, - recipient: { - contactKey: emailId, - to: emailId, - attributes: {'magic-link': marketingCloudConfig.magicLink} - } - }) + body: JSON.stringify({type, recipient, data}) }) - - if (!emailResponse.ok) throw new Error('Failed to send email to Marketing Cloud') - - return await emailResponse.json() -} - -/** - * Generates a unique ID, constructs an email message URL, and sends the email to the specified contact - * using the Marketing Cloud API. - * - * @param {string} email - The email address of the contact to whom the email will be sent. - * @param {string} templateId - The ID of the email template to be used for the email. - * @param {string} magicLink - The magic link to be included in the email. - * - * @return {Promise} A promise that resolves to the response object received from the Marketing Cloud API. - */ -export async function emailLink(emailId, templateId, magicLink) { - if (!process.env.MARKETING_CLOUD_CLIENT_ID) { - console.warn('MARKETING_CLOUD_CLIENT_ID is not set in the environment variables.') - } - - if (!process.env.MARKETING_CLOUD_CLIENT_SECRET) { - console.warn(' MARKETING_CLOUD_CLIENT_SECRET is not set in the environment variables.') - } - - if (!process.env.MARKETING_CLOUD_SUBDOMAIN) { - console.warn('MARKETING_CLOUD_SUBDOMAIN is not set in the environment variables.') - } - - const marketingCloudConfig = { - clientId: process.env.MARKETING_CLOUD_CLIENT_ID, - clientSecret: process.env.MARKETING_CLOUD_CLIENT_SECRET, - magicLink: magicLink, - subdomain: process.env.MARKETING_CLOUD_SUBDOMAIN, - templateId: templateId + if (!res.ok) { + if (res.status === 401) { + // Clear cached token so next call gets a fresh one + _notifyToken = null + _notifyTokenExpiry = 0 + } + throw new Error(`pwakit-notify returned ${res.status}`) } - return await sendMarketingCloudEmail(emailId, marketingCloudConfig) + return await res.json() } const resetPasswordCallback = @@ -369,30 +310,17 @@ const resetPasswordCallback = const passwordlessLoginCallback = config.app.login?.passwordless?.callbackURI || '/passwordless-login-callback' -// Reusable function to handle sending a magic link email. -// By default, this implementation uses Marketing Cloud. -async function sendMagicLinkEmail(req, res, landingPath, emailTemplate, redirectUrl) { - // Extract the base URL from the request - const base = req.protocol + '://' + req.get('host') - - // Extract the email_id and token from the request body +async function sendMagicLinkEmail(req, res, landingPath, notifyType, redirectUrl) { const {email_id, token} = req.body - // Construct the magic link URL - let magicLink = `${base}${landingPath}?token=${encodeURIComponent(token)}` - if (landingPath === config.app.login?.resetPassword?.landingPath) { - // Add email query parameter for reset password flow - magicLink += `&email=${encodeURIComponent(email_id)}` + let magicLinkPath = `${landingPath}?token=${encodeURIComponent(token)}&email=${encodeURIComponent(email_id)}` + if (notifyType === 'passwordless-magic-link' && redirectUrl) { + magicLinkPath += `&redirect_url=${encodeURIComponent(redirectUrl)}` } - if (landingPath === config.app.login?.passwordless?.landingPath && redirectUrl) { - magicLink += `&redirect_url=${encodeURIComponent(redirectUrl)}` - } - - // Call the emailLink function to send an email with the magic link using Marketing Cloud - const emailLinkResponse = await emailLink(email_id, emailTemplate, magicLink) - // Send the response - res.send(emailLinkResponse) + const appConfig = getConfig()?.app + await sendViaB2cCartridge(notifyType, email_id, {magicLinkPath}, appConfig.commerceAPI.parameters) + res.json({success: true}) } const CLAIM = { @@ -427,9 +355,23 @@ export const validateSlasCallbackToken = async (token) => { const tokens = subClaim.split(DELIMITER.ISSUER) const tenantId = tokens[2] try { - const jwks = createRemoteJWKSet(tenantId) - const {payload} = await jwtVerify(token, jwks, {}) - return payload + let jwks + if (process.env.SLAS_JWKS_JSON) { + const parsed = JSON.parse(process.env.SLAS_JWKS_JSON) + if (!Array.isArray(parsed?.keys) || parsed.keys.length === 0) { + throwSlasTokenValidationError( + 'SLAS_JWKS_JSON must be a JSON object with a non-empty "keys" array', + 400 + ) + } + jwks = createLocalJWKSet(parsed) + } else { + jwks = createRemoteJWKSet(tenantId) + } + const {payload: validatedPayload} = await jwtVerify(token, jwks, { + algorithms: ['RS256', 'ES256'] + }) + return validatedPayload } catch (error) { throwSlasTokenValidationError(error.message, 401) } @@ -596,34 +538,62 @@ const {handler} = runtime.createHandler(options, (app) => { // endpoint sending the email address and passwordless token. Then this endpoint calls // the sendMagicLinkEmail function to send an email with the passwordless login magic link. // https://developer.salesforce.com/docs/commerce/commerce-api/guide/slas-passwordless-login.html#receive-the-callback - app.post(passwordlessLoginCallback, (req, res) => { + app.post(passwordlessLoginCallback, async (req, res) => { + const appConfig = getConfig()?.app + if (appConfig?.login?.passwordless?.mode !== 'callback') { + return res.status(400).json({error: 'Passwordless callback mode not enabled'}) + } const slasCallbackToken = req.headers['x-slas-callback-token'] + if (!slasCallbackToken) { + return res.status(400).json({error: 'Missing x-slas-callback-token header'}) + } const redirectUrl = req.query.redirectUrl - validateSlasCallbackToken(slasCallbackToken).then(() => { - sendMagicLinkEmail( + try { + await validateSlasCallbackToken(slasCallbackToken) + await sendMagicLinkEmail( req, res, config.app.login?.passwordless?.landingPath, - process.env.MARKETING_CLOUD_PASSWORDLESS_LOGIN_TEMPLATE, + 'passwordless-magic-link', redirectUrl ) - }) + } catch (err) { + logger.error('passwordless callback failed', { + namespace: 'slas-callback', + additionalProperties: {error: err?.message} + }) + res.status(500).json({error: 'Failed to send notification'}) + } }) // Handles the reset password callback route. SLAS makes a POST request to this // endpoint sending the email address and reset password token. Then this endpoint calls // the sendMagicLinkEmail function to send an email with the reset password magic link. // https://developer.salesforce.com/docs/commerce/commerce-api/guide/slas-password-reset.html#slas-password-reset-flow - app.post(resetPasswordCallback, (req, res) => { + app.post(resetPasswordCallback, async (req, res) => { + const appConfig = getConfig()?.app + if (appConfig?.login?.resetPassword?.mode !== 'callback') { + return res.status(400).json({error: 'Reset password callback mode not enabled'}) + } const slasCallbackToken = req.headers['x-slas-callback-token'] - validateSlasCallbackToken(slasCallbackToken).then(() => { - sendMagicLinkEmail( + if (!slasCallbackToken) { + return res.status(400).json({error: 'Missing x-slas-callback-token header'}) + } + try { + await validateSlasCallbackToken(slasCallbackToken) + await sendMagicLinkEmail( req, res, config.app.login?.resetPassword?.landingPath, - process.env.MARKETING_CLOUD_RESET_PASSWORD_TEMPLATE + 'password-reset' ) - }) + } catch (err) { + logger.error('reset-password callback failed', { + namespace: 'slas-callback', + additionalProperties: {error: err?.message} + }) + res.status(500).json({error: 'Failed to send notification'}) + } }) app.get('/robots.txt', runtime.serveStaticFile('static/robots.txt')) diff --git a/packages/template-retail-react-app/CHANGELOG.md b/packages/template-retail-react-app/CHANGELOG.md index 23de40548d..21363ccd07 100644 --- a/packages/template-retail-react-app/CHANGELOG.md +++ b/packages/template-retail-react-app/CHANGELOG.md @@ -1,3 +1,7 @@ +## Unreleased + +- [Feature] Add `app_pwakit_base` B2C cartridge for transactional email delivery via ISML templates and `dw/net/Mail`; replace Marketing Cloud email delivery with SLAS callback mode that calls the cartridge's `pwakit-notify` SCAPI Custom API (`c_pwakit_notify` scope); add GLO magic-link auto-submit on the verify page when `?token=` is present in the URL. **Deployment note:** after uploading the cartridge and adding it to the site cartridge path, you must deactivate and reactivate the code version in Business Manager (Administration → Code Deployment) so that the SCAPI runtime rescans and registers the `pwakit-notify` endpoint — SCAPI only discovers Custom API endpoints at code version activation time, so uploading to an already-active version is not sufficient. [#XXXX](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/XXXX) + ## v10.3.0-dev (Aug 12, 2026) - [Feature] Add inline agent widget on the Product Detail Page: renders a self-hosted `` web component below the Add to Cart actions so shoppers can ask product questions without opening the floating panel. The widget is gated on config (`enabled` + SCRT2 credentials present), loads client-side only, and passes product context (product id from the URL) to the agent on each message. [#3988](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3988) - [Enhancement] Update inline agent widget with beautified UI and move it into the right-side product details column (below Add to Wishlist) via a new `actionFooter` prop on `ProductView`. Add default `inlineAgentWidget` config block with "Ask me anything" placeholder. [#3995](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3995) diff --git a/packages/template-retail-react-app/app/components/_app-config/index.jsx b/packages/template-retail-react-app/app/components/_app-config/index.jsx index 4c1588cd83..3407d670a9 100644 --- a/packages/template-retail-react-app/app/components/_app-config/index.jsx +++ b/packages/template-retail-react-app/app/components/_app-config/index.jsx @@ -118,7 +118,7 @@ const AppConfig = ({children, locals = {}}) => { defaultDnt={DEFAULT_DNT_STATE} // Set 'enablePWAKitPrivateClient' to true to use SLAS private client login flows. // Make sure to also enable useSLASPrivateClient in ssr.js when enabling this setting. - enablePWAKitPrivateClient={false} + enablePWAKitPrivateClient={true} privateClientProxyEndpoint={slasPrivateClientProxyEndpoint} publicClientProxyEndpoint={slasPublicClientProxyEndpoint} // Uncomment 'hybridAuthEnabled' if the current site has Hybrid Auth enabled. Do NOT set this flag for hybrid storefronts using Plugin SLAS. diff --git a/packages/template-retail-react-app/app/pages/guest-order-lookup/verify.jsx b/packages/template-retail-react-app/app/pages/guest-order-lookup/verify.jsx index f01cfd89b9..3f115afecc 100644 --- a/packages/template-retail-react-app/app/pages/guest-order-lookup/verify.jsx +++ b/packages/template-retail-react-app/app/pages/guest-order-lookup/verify.jsx @@ -18,7 +18,6 @@ import { HStack, Heading, Input, - Skeleton, Stack, Text } from '@salesforce/retail-react-app/app/components/shared/ui' @@ -41,12 +40,42 @@ const GuestOrderLookupVerify = () => { getTokenWhenReadyRef.current = getTokenWhenReady }) - // email arrives via router state when navigating from request.jsx; absent on hard refresh. - const email = location.state?.email || '' + // email arrives via router state when navigating from request.jsx within the same session. + // Absent on hard refresh or direct arrival from the magic link — user must enter it. + const emailFromContext = location.state?.email || '' + // When email is not available from context (magic link arrival), the user must type it. + const needsEmailInput = !emailFromContext + const [emailInput, setEmailInput] = useState('') + const email = emailFromContext || emailInput + // autoCode is set by the GLO magic link (?token=) so shoppers can auto-submit the OTP. + const autoCode = new URLSearchParams(location.search).get('token') || '' // Auth resolves asynchronously — null means not yet known. const authResolved = customerType !== null const [digits, setDigits] = useState(Array(ACCESS_CODE_LENGTH).fill('')) + const formRef = useRef(null) + + useEffect(() => { + if (!autoCode) return + const cleaned = autoCode.replace(/\D/g, '').slice(0, ACCESS_CODE_LENGTH) + if (cleaned.length === 0) return + const next = Array(ACCESS_CODE_LENGTH).fill('') + for (let i = 0; i < cleaned.length; i++) { + next[i] = cleaned[i] + } + setDigits(next) + }, [autoCode]) + + useEffect(() => { + if (!autoCode) return + if (!email) return // wait for the user to enter their email before auto-submitting + const cleaned = autoCode.replace(/\D/g, '').slice(0, ACCESS_CODE_LENGTH) + if (cleaned.length !== ACCESS_CODE_LENGTH) return + const timer = setTimeout(() => { + formRef.current?.requestSubmit() + }, 100) + return () => clearTimeout(timer) + }, [autoCode, email]) const [isSubmitting, setIsSubmitting] = useState(false) const [serverError, setServerError] = useState(null) const inputRefs = useRef([]) @@ -167,7 +196,7 @@ const GuestOrderLookupVerify = () => { defaultMessage: 'Verify Your Email' })} - {email ? ( + {email && ( {formatMessage( { @@ -178,16 +207,12 @@ const GuestOrderLookupVerify = () => { {email} )} - ) : ( - - - - )} { )} + {needsEmailInput && ( + + + {formatMessage({ + id: 'guestOrderLookup.verify.label.email', + defaultMessage: 'Your email address' + })} + + setEmailInput(e.target.value)} + isDisabled={isSubmitting} + placeholder="you@example.com" + /> + + )} + {formatMessage({ @@ -252,7 +297,7 @@ const GuestOrderLookupVerify = () => { type="submit" colorScheme="blue" isLoading={isSubmitting} - isDisabled={!isComplete || isSubmitting} + isDisabled={!isComplete || isSubmitting || (needsEmailInput && !emailInput)} width="full" size="lg" > diff --git a/packages/template-retail-react-app/app/pages/reset-password/reset-password-landing.jsx b/packages/template-retail-react-app/app/pages/reset-password/reset-password-landing.jsx index b8697d466e..a46397bbe2 100644 --- a/packages/template-retail-react-app/app/pages/reset-password/reset-password-landing.jsx +++ b/packages/template-retail-react-app/app/pages/reset-password/reset-password-landing.jsx @@ -35,8 +35,8 @@ const ResetPasswordLanding = () => { const {search} = useLocation() const navigate = useNavigation() const queryParams = new URLSearchParams(search) - const email = decodeURIComponent(queryParams.get('email')) const token = decodeURIComponent(queryParams.get('token')) + const {control} = form const fields = useUpdatePasswordFields({form}) const password = form.watch('password') const {resetPassword} = usePasswordReset() @@ -44,7 +44,7 @@ const ResetPasswordLanding = () => { const submit = async (values) => { form.clearErrors() try { - await resetPassword({email, token, newPassword: values.password}) + await resetPassword({email: values.email, token, newPassword: values.password}) navigate('/login') } catch (error) { const errorData = await error.response?.json() @@ -78,6 +78,23 @@ const ResetPasswordLanding = () => { )} + diff --git a/packages/template-retail-react-app/app/ssr.js b/packages/template-retail-react-app/app/ssr.js index 78ab6448a3..1e9cc49b34 100644 --- a/packages/template-retail-react-app/app/ssr.js +++ b/packages/template-retail-react-app/app/ssr.js @@ -16,10 +16,9 @@ 'use strict' -import crypto from 'crypto' import express from 'express' import helmet from 'helmet' -import {createRemoteJWKSet as joseCreateRemoteJWKSet, jwtVerify, decodeJwt} from 'jose' +import {createLocalJWKSet, createRemoteJWKSet as joseCreateRemoteJWKSet, jwtVerify, decodeJwt} from 'jose' import path from 'path' import {getRuntime} from '@salesforce/pwa-kit-runtime/ssr/server/express' import {defaultPwaKitSecurityHeaders} from '@salesforce/pwa-kit-runtime/utils/middleware' @@ -30,7 +29,7 @@ import logger from '@salesforce/pwa-kit-runtime/utils/logger-instance' import {registerTokenBridgeRoute} from './components/shopper-agent/token-bridge.js' // eslint-disable-next-line no-relative-import-paths/no-relative-import-paths import {getCommerceClientOverridesCspSources} from './utils/commerce-client-overrides.js' -import {ShopperOrders} from 'commerce-sdk-isomorphic' +import {ShopperLogin, ShopperOrders, helpers} from 'commerce-sdk-isomorphic' const config = getConfig() @@ -185,7 +184,7 @@ const options = { // Set this to false if using a SLAS public client // When setting this to true, make sure to also set the PWA_KIT_SLAS_CLIENT_SECRET // environment variable as this endpoint will return HTTP 501 if it is not set - useSLASPrivateClient: false, + useSLASPrivateClient: true, // To extend the SLAS private-client proxy allow-list, supply // `slasPrivateClientAllowList`. See the built-in list in pwa-kit-runtime @@ -211,7 +210,7 @@ const options = { // HYBRID PROXY REQUIREMENT: // - Hybrid Proxy requires this to be 'true' for SFCC session management to work properly // - Only enable Hybrid Proxy in development environments, never in production - localAllowCookies: false, + localAllowCookies: true, // Hybrid Proxy configuration for local development and MRT to ODS connection testing. // @@ -245,115 +244,60 @@ const options = { const runtime = getRuntime() -/** - * Tokens are valid for 20 minutes. We store it at the top level scope to reuse - * it during the lambda invocation. We'll refresh it after 15 minutes. - */ -let marketingCloudToken = '' -let marketingCloudTokenExpiration = new Date() +// Module-level guest token cache for pwakit-notify SCAPI calls. +// Warm Lambda reuse avoids a SLAS round-trip on every callback invocation. +let _notifyToken = null +let _notifyTokenExpiry = 0 -/** - * Generates a unique ID for the email message. - * - * @return {string} A unique ID for the email message. - */ -function generateUniqueId() { - return crypto.randomBytes(16).toString('hex') -} - -/** - * Sends an email to a specified contact using the Marketing Cloud API. The template email must have a - * `%%magic-link%%` personalization string inserted. - * https://help.salesforce.com/s/articleView?id=mktg.mc_es_personalization_strings.htm&type=5 - * - * @param {string} email - The email address of the contact to whom the email will be sent. - * @param {string} templateId - The ID of the email template to be used for the email. - * @param {string} magicLink - The magic link to be included in the email. - * - * @return {Promise} A promise that resolves to the response object received from the Marketing Cloud API. - */ -async function sendMarketingCloudEmail(emailId, marketingCloudConfig) { - // Refresh token if expired - if (new Date() > marketingCloudTokenExpiration) { - const {clientId, clientSecret, subdomain} = marketingCloudConfig - const tokenUrl = `https://${subdomain}.auth.marketingcloudapis.com/v2/token` - const tokenResponse = await fetch(tokenUrl, { - method: 'POST', - headers: {'Content-Type': 'application/json'}, - body: JSON.stringify({ - grant_type: 'client_credentials', - client_id: clientId, - client_secret: clientSecret - }) - }) - - if (!tokenResponse.ok) - throw new Error( - 'Failed to fetch Marketing Cloud access token. Check your Marketing Cloud credentials and try again.' - ) - - const {access_token} = await tokenResponse.json() - marketingCloudToken = access_token - // Set expiration to 15 mins - marketingCloudTokenExpiration = new Date(Date.now() + 15 * 60 * 1000) +async function getNotifyToken(apiParams) { + if (_notifyToken && Date.now() < _notifyTokenExpiry) { + return _notifyToken } + const {clientId, organizationId, shortCode, siteId} = apiParams + const proxy = `${getAppOrigin()}${getConfig()?.app?.commerceAPI?.proxyPath || '/mobify/proxy/api'}` + const slasClient = new ShopperLogin({ + parameters: {clientId, organizationId, shortCode, siteId}, + proxy, + throwOnBadResponse: true + }) + const tokenResponse = await helpers.loginGuestUserPrivate({ + slasClient, + parameters: {}, + credentials: {clientSecret: process.env.PWA_KIT_SLAS_CLIENT_SECRET} + }) + _notifyToken = tokenResponse.access_token + // Cache for 25 minutes — SLAS guest tokens last 30 min, 5 min buffer + _notifyTokenExpiry = Date.now() + 25 * 60 * 1000 + return _notifyToken +} - // Send the email - const emailUrl = `https://${ - marketingCloudConfig.subdomain - }.rest.marketingcloudapis.com/messaging/v1/email/messages/${generateUniqueId()}` - const emailResponse = await fetch(emailUrl, { +async function sendViaB2cCartridge(type, recipient, data, apiParams) { + const {organizationId, siteId} = apiParams + const token = await getNotifyToken(apiParams) + const proxy = `${getAppOrigin()}${getConfig()?.app?.commerceAPI?.proxyPath || '/mobify/proxy/api'}` + const url = `${proxy}/custom/pwakit-notify/v1/organizations/${encodeURIComponent(organizationId)}/notify?siteId=${encodeURIComponent(siteId)}` + const res = await fetch(url, { method: 'POST', headers: { - Authorization: `Bearer ${marketingCloudToken}`, - 'Content-Type': 'application/json' + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}` }, - body: JSON.stringify({ - definitionKey: marketingCloudConfig.templateId, - recipient: { - contactKey: emailId, - to: emailId, - attributes: {'magic-link': marketingCloudConfig.magicLink} - } - }) + body: JSON.stringify({type, recipient, data, host: new URL(getAppOrigin()).hostname}) }) - - if (!emailResponse.ok) throw new Error('Failed to send email to Marketing Cloud') - - return await emailResponse.json() -} - -/** - * Generates a unique ID, constructs an email message URL, and sends the email to the specified contact - * using the Marketing Cloud API. - * - * @param {string} email - The email address of the contact to whom the email will be sent. - * @param {string} templateId - The ID of the email template to be used for the email. - * @param {string} magicLink - The magic link to be included in the email. - * - * @return {Promise} A promise that resolves to the response object received from the Marketing Cloud API. - */ -export async function emailLink(emailId, templateId, magicLink) { - if (!process.env.MARKETING_CLOUD_CLIENT_ID) { - console.warn('MARKETING_CLOUD_CLIENT_ID is not set in the environment variables.') - } - - if (!process.env.MARKETING_CLOUD_CLIENT_SECRET) { - console.warn(' MARKETING_CLOUD_CLIENT_SECRET is not set in the environment variables.') - } - - if (!process.env.MARKETING_CLOUD_SUBDOMAIN) { - console.warn('MARKETING_CLOUD_SUBDOMAIN is not set in the environment variables.') - } - - const marketingCloudConfig = { - clientId: process.env.MARKETING_CLOUD_CLIENT_ID, - clientSecret: process.env.MARKETING_CLOUD_CLIENT_SECRET, - magicLink: magicLink, - subdomain: process.env.MARKETING_CLOUD_SUBDOMAIN, - templateId: templateId + if (!res.ok) { + if (res.status === 401) { + // Clear cached token so next call gets a fresh one + _notifyToken = null + _notifyTokenExpiry = 0 + } + let body = '' + try { + body = await res.text() + } catch (_) {} // eslint-disable-line no-empty + logger.error(`pwakit-notify ${res.status} body: ${body} url: ${url}`) + throw new Error(`pwakit-notify returned ${res.status}`) } - return await sendMarketingCloudEmail(emailId, marketingCloudConfig) + return await res.json() } const resetPasswordCallback = @@ -361,30 +305,17 @@ const resetPasswordCallback = const passwordlessLoginCallback = config.app.login?.passwordless?.callbackURI || '/passwordless-login-callback' -// Reusable function to handle sending a magic link email. -// By default, this implementation uses Marketing Cloud. -async function sendMagicLinkEmail(req, res, landingPath, emailTemplate, redirectUrl) { - // Extract the base URL from the request - const base = req.protocol + '://' + req.get('host') - - // Extract the email_id and token from the request body +async function sendMagicLinkEmail(req, res, landingPath, notifyType, redirectUrl) { const {email_id, token} = req.body - // Construct the magic link URL - let magicLink = `${base}${landingPath}?token=${encodeURIComponent(token)}` - if (landingPath === config.app.login?.resetPassword?.landingPath) { - // Add email query parameter for reset password flow - magicLink += `&email=${encodeURIComponent(email_id)}` - } - if (landingPath === config.app.login?.passwordless?.landingPath && redirectUrl) { - magicLink += `&redirect_url=${encodeURIComponent(redirectUrl)}` + let magicLinkPath = `${landingPath}?token=${encodeURIComponent(token)}` + if (notifyType === 'passwordless-magic-link' && redirectUrl) { + magicLinkPath += `&redirect_url=${encodeURIComponent(redirectUrl)}` } - // Call the emailLink function to send an email with the magic link using Marketing Cloud - const emailLinkResponse = await emailLink(email_id, emailTemplate, magicLink) - - // Send the response - res.send(emailLinkResponse) + const appConfig = getConfig()?.app + await sendViaB2cCartridge(notifyType, email_id, {magicLinkPath}, appConfig.commerceAPI.parameters) + res.json({success: true}) } const CLAIM = { @@ -427,9 +358,23 @@ export const validateSlasCallbackToken = async (token) => { const tokens = subClaim.split(DELIMITER.ISSUER) const tenantId = tokens[2] try { - const jwks = createRemoteJWKSet(tenantId) - const {payload} = await jwtVerify(token, jwks, {}) - return payload + let jwks + if (process.env.SLAS_JWKS_JSON) { + const parsed = JSON.parse(process.env.SLAS_JWKS_JSON) + if (!Array.isArray(parsed?.keys) || parsed.keys.length === 0) { + throwSlasTokenValidationError( + 'SLAS_JWKS_JSON must be a JSON object with a non-empty "keys" array', + 400 + ) + } + jwks = createLocalJWKSet(parsed) + } else { + jwks = createRemoteJWKSet(tenantId) + } + const {payload: validatedPayload} = await jwtVerify(token, jwks, { + algorithms: ['RS256', 'ES256'] + }) + return validatedPayload } catch (error) { throwSlasTokenValidationError(error.message, 401) } @@ -653,34 +598,62 @@ const {handler} = runtime.createHandler(options, (app) => { // endpoint sending the email address and passwordless token. Then this endpoint calls // the sendMagicLinkEmail function to send an email with the passwordless login magic link. // https://developer.salesforce.com/docs/commerce/commerce-api/guide/slas-passwordless-login.html#receive-the-callback - app.post(passwordlessLoginCallback, (req, res) => { + app.post(passwordlessLoginCallback, async (req, res) => { + const appConfig = getConfig()?.app + if (appConfig?.login?.passwordless?.mode !== 'callback') { + return res.status(400).json({error: 'Passwordless callback mode not enabled'}) + } const slasCallbackToken = req.headers['x-slas-callback-token'] + if (!slasCallbackToken) { + return res.status(400).json({error: 'Missing x-slas-callback-token header'}) + } const redirectUrl = req.query.redirectUrl - validateSlasCallbackToken(slasCallbackToken).then(() => { - sendMagicLinkEmail( + try { + await validateSlasCallbackToken(slasCallbackToken) + await sendMagicLinkEmail( req, res, config.app.login?.passwordless?.landingPath, - process.env.MARKETING_CLOUD_PASSWORDLESS_LOGIN_TEMPLATE, + 'passwordless-magic-link', redirectUrl ) - }) + } catch (err) { + logger.error('passwordless callback failed', { + namespace: 'slas-callback', + additionalProperties: {error: err?.message} + }) + res.status(500).json({error: 'Failed to send notification'}) + } }) // Handles the reset password callback route. SLAS makes a POST request to this // endpoint sending the email address and reset password token. Then this endpoint calls // the sendMagicLinkEmail function to send an email with the reset password magic link. // https://developer.salesforce.com/docs/commerce/commerce-api/guide/slas-password-reset.html#slas-password-reset-flow - app.post(resetPasswordCallback, (req, res) => { + app.post(resetPasswordCallback, async (req, res) => { + const appConfig = getConfig()?.app + if (appConfig?.login?.resetPassword?.mode !== 'callback') { + return res.status(400).json({error: 'Reset password callback mode not enabled'}) + } const slasCallbackToken = req.headers['x-slas-callback-token'] - validateSlasCallbackToken(slasCallbackToken).then(() => { - sendMagicLinkEmail( + if (!slasCallbackToken) { + return res.status(400).json({error: 'Missing x-slas-callback-token header'}) + } + try { + await validateSlasCallbackToken(slasCallbackToken) + await sendMagicLinkEmail( req, res, config.app.login?.resetPassword?.landingPath, - process.env.MARKETING_CLOUD_RESET_PASSWORD_TEMPLATE + 'password-reset' ) - }) + } catch (err) { + logger.error('reset-password callback failed', { + namespace: 'slas-callback', + additionalProperties: {error: err?.message} + }) + res.status(500).json({error: 'Failed to send notification'}) + } }) // Proxy endpoint for the shared maintenance page — fetches CDN content server-side @@ -792,9 +765,10 @@ const {handler} = runtime.createHandler(options, (app) => { return res.status(400).json({error: 'Missing required fields'}) const siteIdForToken = getSiteIdFromRequest(req) || appConfig.commerceAPI.parameters.siteId - const slasToken = parseCookieValue(req, `cc-at_${siteIdForToken}`) - if (!slasToken) return res.status(401).json({error: 'Missing authorization'}) - const authorization = `Bearer ${slasToken}` + const authorization = req.headers.authorization + if (!authorization) { + return res.status(401).json({error: 'Missing authorization'}) + } const correlationId = req.headers['x-correlation-id'] const start = Date.now() @@ -869,9 +843,8 @@ const {handler} = runtime.createHandler(options, (app) => { return res.status(503).json({error: 'Feature not enabled'}) const siteId = getSiteIdFromRequest(req) || appConfig.commerceAPI.parameters.siteId - const slasToken = parseCookieValue(req, `cc-at_${siteId}`) - if (!slasToken) return res.status(401).json({error: 'Missing authorization'}) - const authorization = `Bearer ${slasToken}` + const authorization = req.headers.authorization + if (!authorization) return res.status(401).json({error: 'Missing authorization'}) const cookieName = `cc-goa_${siteId}` const cookieData = parseGuestOrderCookie(req, cookieName) @@ -950,9 +923,8 @@ const {handler} = runtime.createHandler(options, (app) => { return res.status(503).json({error: 'Feature not enabled'}) const siteId = getSiteIdFromRequest(req) || appConfig.commerceAPI.parameters.siteId - const slasToken = parseCookieValue(req, `cc-at_${siteId}`) - if (!slasToken) return res.status(401).json({error: 'Missing authorization'}) - const authorization = `Bearer ${slasToken}` + const authorization = req.headers.authorization + if (!authorization) return res.status(401).json({error: 'Missing authorization'}) const cookieName = `cc-goa_${siteId}` const cookieData = parseGuestOrderCookie(req, cookieName) @@ -984,9 +956,8 @@ const {handler} = runtime.createHandler(options, (app) => { return res.status(503).json({error: 'Feature not enabled'}) const siteId = getSiteIdFromRequest(req) || appConfig.commerceAPI.parameters.siteId - const slasToken = parseCookieValue(req, `cc-at_${siteId}`) - if (!slasToken) return res.status(401).json({error: 'Missing authorization'}) - const authorization = `Bearer ${slasToken}` + const authorization = req.headers.authorization + if (!authorization) return res.status(401).json({error: 'Missing authorization'}) const cookieName = `cc-goa_${siteId}` const cookieData = parseGuestOrderCookie(req, cookieName) @@ -1035,9 +1006,8 @@ const {handler} = runtime.createHandler(options, (app) => { return res.status(503).json({error: 'Feature not enabled'}) const siteId = getSiteIdFromRequest(req) || appConfig.commerceAPI.parameters.siteId - const slasToken = parseCookieValue(req, `cc-at_${siteId}`) - if (!slasToken) return res.status(401).json({error: 'Missing authorization'}) - const authorization = `Bearer ${slasToken}` + const authorization = req.headers.authorization + if (!authorization) return res.status(401).json({error: 'Missing authorization'}) const cookieName = `cc-goa_${siteId}` const cookieData = parseGuestOrderCookie(req, cookieName) diff --git a/packages/template-retail-react-app/app/static/translations/compiled/en-GB.json b/packages/template-retail-react-app/app/static/translations/compiled/en-GB.json index a008caf3ce..8232d142d9 100644 --- a/packages/template-retail-react-app/app/static/translations/compiled/en-GB.json +++ b/packages/template-retail-react-app/app/static/translations/compiled/en-GB.json @@ -2865,6 +2865,12 @@ "value": "total" } ], + "guestOrderLookup.verify.label.email": [ + { + "type": 0, + "value": "Your email address" + } + ], "guestOrderLookup.verify.subtext": [ { "type": 0, @@ -4723,6 +4729,18 @@ "value": "Reset Password" } ], + "reset_password_form.error.required_email": [ + { + "type": 0, + "value": "Please enter your email address." + } + ], + "reset_password_form.label.email": [ + { + "type": 0, + "value": "Email" + } + ], "reset_password_form.message.enter_your_email": [ { "type": 0, diff --git a/packages/template-retail-react-app/app/static/translations/compiled/en-US.json b/packages/template-retail-react-app/app/static/translations/compiled/en-US.json index a008caf3ce..8232d142d9 100644 --- a/packages/template-retail-react-app/app/static/translations/compiled/en-US.json +++ b/packages/template-retail-react-app/app/static/translations/compiled/en-US.json @@ -2865,6 +2865,12 @@ "value": "total" } ], + "guestOrderLookup.verify.label.email": [ + { + "type": 0, + "value": "Your email address" + } + ], "guestOrderLookup.verify.subtext": [ { "type": 0, @@ -4723,6 +4729,18 @@ "value": "Reset Password" } ], + "reset_password_form.error.required_email": [ + { + "type": 0, + "value": "Please enter your email address." + } + ], + "reset_password_form.label.email": [ + { + "type": 0, + "value": "Email" + } + ], "reset_password_form.message.enter_your_email": [ { "type": 0, diff --git a/packages/template-retail-react-app/app/static/translations/compiled/en-XA.json b/packages/template-retail-react-app/app/static/translations/compiled/en-XA.json index 6dd9498a8f..de4ebc28a7 100644 --- a/packages/template-retail-react-app/app/static/translations/compiled/en-XA.json +++ b/packages/template-retail-react-app/app/static/translations/compiled/en-XA.json @@ -5921,6 +5921,20 @@ "value": "]" } ], + "guestOrderLookup.verify.label.email": [ + { + "type": 0, + "value": "[" + }, + { + "type": 0, + "value": "Ẏǿǿŭŭř ḗḗḿȧȧīŀ ȧȧḓḓřḗḗşş" + }, + { + "type": 0, + "value": "]" + } + ], "guestOrderLookup.verify.subtext": [ { "type": 0, @@ -9923,6 +9937,34 @@ "value": "]" } ], + "reset_password_form.error.required_email": [ + { + "type": 0, + "value": "[" + }, + { + "type": 0, + "value": "Ƥŀḗḗȧȧşḗḗ ḗḗƞŧḗḗř ẏǿǿŭŭř ḗḗḿȧȧīŀ ȧȧḓḓřḗḗşş." + }, + { + "type": 0, + "value": "]" + } + ], + "reset_password_form.label.email": [ + { + "type": 0, + "value": "[" + }, + { + "type": 0, + "value": "Ḗḿȧȧīŀ" + }, + { + "type": 0, + "value": "]" + } + ], "reset_password_form.message.enter_your_email": [ { "type": 0, diff --git a/packages/template-retail-react-app/cartridges/app_pwakit_base/cartridge/app_pwakit_base.properties b/packages/template-retail-react-app/cartridges/app_pwakit_base/cartridge/app_pwakit_base.properties new file mode 100644 index 0000000000..4351776d95 --- /dev/null +++ b/packages/template-retail-react-app/cartridges/app_pwakit_base/cartridge/app_pwakit_base.properties @@ -0,0 +1,2 @@ +## cartridge.properties for app_pwakit_base +demandware.cartridges.app_pwakit_base.multipleLanguageStorefront=true diff --git a/packages/template-retail-react-app/cartridges/app_pwakit_base/cartridge/rest-apis/pwakit-notify/Notify.js b/packages/template-retail-react-app/cartridges/app_pwakit_base/cartridge/rest-apis/pwakit-notify/Notify.js new file mode 100644 index 0000000000..9ce0e0cbc1 --- /dev/null +++ b/packages/template-retail-react-app/cartridges/app_pwakit_base/cartridge/rest-apis/pwakit-notify/Notify.js @@ -0,0 +1,208 @@ +/** + * Copyright 2026 Salesforce, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +'use strict'; + +var RESTResponseMgr = require('dw/system/RESTResponseMgr'); +var Resource = require('dw/web/Resource'); +var System = require('dw/system/System'); +var Logger = require('dw/system/Logger'); +var resolveStorefrontHost = require('*/cartridge/scripts/helpers/storefrontHostProvider'); + +var log = Logger.getLogger('pwakit-notify', 'pwakit-notify'); + + +/** + * Returns true when the pwakitNotify feature is enabled. + * + * Reads the `pwakitNotifyEnabled` global preference. Defaults to enabled when + * the preference is unset (null) — only an explicit false disables it. + * + * To disable: Business Manager > Administration > Global Preferences > + * Custom Preferences > pwakit > Notifications Enabled → false. + * Do this if you have customized email delivery (e.g. Marketing Cloud, a + * third-party provider, or your own hook) and don't want the cartridge's + * default ISML-based emails to fire. + * + * @returns {boolean} + */ +function isNotifyEnabled() { + try { + var val = System.getPreferences().getCustom()['pwakitNotifyEnabled']; + return val !== false; // null (unset) → enabled; explicit false → disabled + } catch (e) { + return true; // preference schema not imported yet — default to enabled + } +} + +/** + * SCAPI Custom API handler for PWA Kit Notify. + * + * Authentication and scope enforcement (ShopperToken + c_pwakit_notify) are + * handled by the SCAPI gateway before this function is called. + * + * Customize the if/else branches below to add, remove, or rename notification types. + * Each branch maps a type string to a subject line and an ISML template path. + */ +exports.notify = function () { + if (!isNotifyEnabled()) { + RESTResponseMgr.createError( + 503, + 'notifications-disabled', + 'Service Unavailable', + 'Notifications are disabled on this instance (pwakitNotifyEnabled = false)' + ).render(); + return; + } + var sendNotificationHelper = require('*/cartridge/scripts/helpers/sendNotification'); + + var bodyString = request.httpParameterMap.requestBodyAsString; + var body; + try { + body = JSON.parse(bodyString); + } catch (e) { + RESTResponseMgr.createError(400, 'invalid-json', 'Bad Request', 'Request body is not valid JSON').render(); + return; + } + + var type = body.type; + var recipient = body.recipient; + + if (!type || !recipient) { + RESTResponseMgr.createError( + 400, + 'missing-required-fields', + 'Bad Request', + 'Missing required fields: type, recipient' + ).render(); + return; + } + + if (!body.data || typeof body.data !== 'object') { + RESTResponseMgr.createError(400, 'missing-data', 'Bad Request', 'Missing required field: data').render(); + return; + } + + if (!/^[^\r\n@]+@[^\r\n]+$/.test(recipient)) { + RESTResponseMgr.createError( + 400, + 'invalid-recipient', + 'Bad Request', + 'Invalid recipient email address' + ).render(); + return; + } + + var storefrontHost = resolveStorefrontHost(body.host || null); + if (storefrontHost === null) { + RESTResponseMgr.createError( + 400, + 'invalid-host', + 'Bad Request', + 'Supplied host is not in the pwakitStorefrontHosts allowed-hosts list' + ).render(); + return; + } + + var subject; + var templateName; + var context; + var magicLink; // set for magic-link types; returned in the success response + + if (type === 'passwordless-magic-link') { + if (!body.data.magicLinkPath) { + RESTResponseMgr.createError( + 400, + 'missing-magic-link-path', + 'Bad Request', + 'Missing data.magicLinkPath for passwordless-magic-link type' + ).render(); + return; + } + magicLink = 'https://' + storefrontHost + body.data.magicLinkPath; + subject = Resource.msg('passwordlessMagicLink.subject', 'email', 'Your Magic Sign-In Link'); + templateName = 'email/passwordlessMagicLink'; + context = { magicLink: magicLink, email: recipient }; + } else if (type === 'password-reset') { + if (!body.data.magicLinkPath) { + RESTResponseMgr.createError( + 400, + 'missing-magic-link-path', + 'Bad Request', + 'Missing data.magicLinkPath for password-reset type' + ).render(); + return; + } + magicLink = 'https://' + storefrontHost + body.data.magicLinkPath; + subject = Resource.msg('passwordResetMagicLink.subject', 'email', 'Reset Your Password'); + templateName = 'email/passwordResetMagicLink'; + context = { magicLink: magicLink, email: recipient }; + } else if (type === 'otp') { + if (!body.data.token) { + RESTResponseMgr.createError( + 400, + 'missing-token', + 'Bad Request', + 'Missing data.token for otp type' + ).render(); + return; + } + subject = Resource.msg('otpVerification.subject', 'email', 'Your Verification Code'); + templateName = 'email/otpVerification'; + context = { token: body.data.token }; + } else if (type === 'glo-access-code') { + if (!body.data.orderNo || !body.data.accessCode) { + RESTResponseMgr.createError( + 400, + 'missing-fields', + 'Bad Request', + 'Missing data.orderNo or data.accessCode for glo-access-code type' + ).render(); + return; + } + subject = Resource.msg('gloAccessCode.subject', 'email', 'Your Order Access Code'); + templateName = 'email/gloAccessCode'; + context = { orderNo: body.data.orderNo, accessCode: body.data.accessCode }; + } else { + RESTResponseMgr.createError( + 400, + 'unknown-notification-type', + 'Bad Request', + 'Unknown notification type' + ).render(); + return; + } + + var result = sendNotificationHelper.send(recipient, subject, templateName, context); + + if (result.error) { + RESTResponseMgr.createError( + 500, + 'notification-delivery-failed', + 'Internal Server Error', + result.errorMessage || 'Notification delivery failed' + ).render(); + return; + } + + var successBody = { success: true }; + if (magicLink) { + successBody.data = { magicLink: magicLink }; + } + RESTResponseMgr.createSuccess(successBody).render(); +}; + +exports.notify.public = true; diff --git a/packages/template-retail-react-app/cartridges/app_pwakit_base/cartridge/rest-apis/pwakit-notify/api.json b/packages/template-retail-react-app/cartridges/app_pwakit_base/cartridge/rest-apis/pwakit-notify/api.json new file mode 100644 index 0000000000..b5d9584255 --- /dev/null +++ b/packages/template-retail-react-app/cartridges/app_pwakit_base/cartridge/rest-apis/pwakit-notify/api.json @@ -0,0 +1,9 @@ +{ + "endpoints": [ + { + "endpoint": "notify", + "schema": "schema.yaml", + "implementation": "Notify" + } + ] +} diff --git a/packages/template-retail-react-app/cartridges/app_pwakit_base/cartridge/rest-apis/pwakit-notify/schema.yaml b/packages/template-retail-react-app/cartridges/app_pwakit_base/cartridge/rest-apis/pwakit-notify/schema.yaml new file mode 100644 index 0000000000..91ae20aa49 --- /dev/null +++ b/packages/template-retail-react-app/cartridges/app_pwakit_base/cartridge/rest-apis/pwakit-notify/schema.yaml @@ -0,0 +1,131 @@ +openapi: 3.0.0 +info: + version: 1.0.0 + title: PWA Kit Notify API +components: + securitySchemes: + ShopperToken: + type: oauth2 + flows: + clientCredentials: + tokenUrl: https://{shortCode}.api.commercecloud.salesforce.com/shopper/auth/v1/organizations/{organizationId}/oauth2/token + scopes: + c_pwakit_notify: Send transactional notifications via PWA Kit +paths: + /notify: + post: + summary: Send a transactional notification + operationId: notify + parameters: + - in: query + name: siteId + required: true + schema: + type: string + minLength: 1 + - in: query + name: locale + required: false + schema: + type: string + minLength: 1 + requestBody: + required: true + content: + application/json: + schema: + oneOf: + - type: object + required: [type, recipient, data] + properties: + type: + type: string + enum: [passwordless-magic-link] + recipient: + type: string + host: + type: string + data: + type: object + required: [magicLinkPath] + properties: + magicLinkPath: + type: string + pattern: '^/' + description: Path portion of the magic link (no hostname). The cartridge prepends the validated storefront hostname. + - type: object + required: [type, recipient, data] + properties: + type: + type: string + enum: [password-reset] + recipient: + type: string + host: + type: string + data: + type: object + required: [magicLinkPath] + properties: + magicLinkPath: + type: string + pattern: '^/' + description: Path portion of the magic link (no hostname). The cartridge prepends the validated storefront hostname. + - type: object + required: [type, recipient, data] + properties: + type: + type: string + enum: [otp] + recipient: + type: string + host: + type: string + data: + type: object + required: [token] + properties: + token: + type: string + - type: object + required: [type, recipient, data] + properties: + type: + type: string + enum: [glo-access-code] + recipient: + type: string + host: + type: string + data: + type: object + required: [orderNo, accessCode] + properties: + orderNo: + type: string + accessCode: + type: string + discriminator: + propertyName: type + responses: + '200': + description: Notification sent successfully + content: + application/json: + schema: + type: object + properties: + success: + type: boolean + data: + type: object + properties: + magicLink: + type: string + description: Fully qualified magic link URL constructed by the cartridge (only present for magic-link notification types) + '400': + description: Invalid request + '500': + description: Notification delivery failed + security: + - ShopperToken: [c_pwakit_notify] diff --git a/packages/template-retail-react-app/cartridges/app_pwakit_base/cartridge/scripts/helpers/sendNotification.js b/packages/template-retail-react-app/cartridges/app_pwakit_base/cartridge/scripts/helpers/sendNotification.js new file mode 100644 index 0000000000..f9315f04ba --- /dev/null +++ b/packages/template-retail-react-app/cartridges/app_pwakit_base/cartridge/scripts/helpers/sendNotification.js @@ -0,0 +1,118 @@ +/** + * Copyright 2026 Salesforce, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +'use strict'; + +var Mail = require('dw/net/Mail'); +var Template = require('dw/util/Template'); +var HashMap = require('dw/util/HashMap'); +var Site = require('dw/system/Site'); +var Logger = require('dw/system/Logger'); +var Resource = require('dw/web/Resource'); + +var log = Logger.getLogger('pwakit-notify', 'pwakit-notify'); + +/** + * Renders an ISML template to a string for use as a notification body. + * + * @param {string} templateName - Path to the ISML template (without .isml extension) + * @param {Object} templateContext - Key/value pairs to expose in the template + * @returns {string} Rendered HTML string + */ +function renderTemplate(templateName, templateContext) { + var template = new Template(templateName); + var map = new HashMap(); + var keys = Object.keys(templateContext); + for (var i = 0; i < keys.length; i++) { + map.put(keys[i], templateContext[keys[i]]); + } + return template.render(map).text; +} + +/** + * Returns a minimal plain-text fallback for the given notification type and context. + * Screen readers and plain-mail clients need this; spam filters score multipart higher. + */ +function buildPlainText(templateName, context) { + if (context.magicLink) { + return Resource.msg('email.plaintext.useThisLink', 'email', 'Use this link to continue:') + '\n' + context.magicLink; + } + if (context.token) { + return Resource.msg('email.plaintext.verificationCode', 'email', 'Your verification code:') + ' ' + context.token; + } + if (context.accessCode) { + return Resource.msg('email.plaintext.orderAccessCode', 'email', 'Your order access code:') + ' ' + context.accessCode + + '\n' + Resource.msg('email.plaintext.orderNumber', 'email', 'Order:') + ' ' + (context.orderNo || ''); + } + return ''; +} + +/** + * Sends a transactional notification. + * + * // Customize this function to integrate your preferred delivery service. + * // The default implementation renders an ISML template and delivers via + * // B2C Commerce's built-in mail API (dw/net/Mail). + * // To use a third-party provider (e.g. SendGrid, Mailchimp, Postmark, SMS gateway), + * // replace the renderTemplate call and the dw/net/Mail logic below with + * // your provider's SDK or an HTTP service call. + * + * @param {string} recipient - Recipient address + * @param {string} subject - Notification subject line + * @param {string} templateName - Path to the ISML template (relative to templates/default/) + * @param {Object} context - Template context variables + * @returns {{ error: boolean, errorMessage?: string }} + */ +function send(recipient, subject, templateName, context) { + try { + var site = Site.getCurrent(); + var senderEmail = site.getCustomPreferenceValue('customerServiceEmail') + || 'no-reply@' + site.httpsHostName; + + log.info('Sending notification: template={0}, subject={1}, from={2}', templateName, subject, senderEmail); + + var localeRaw = request.locale || site.defaultLocale || 'en'; + var localeId = String(localeRaw).replace(/_/g, '-'); + var ctx = context || {}; + var templateContext = { localeId: localeId, emailTitle: subject }; + var ctxKeys = Object.keys(ctx); + for (var k = 0; k < ctxKeys.length; k++) { + templateContext[ctxKeys[k]] = ctx[ctxKeys[k]]; + } + var htmlBody = renderTemplate(templateName, templateContext); + var plainBody = buildPlainText(templateName, context || {}); + + var mail = new Mail(); + mail.addTo(recipient); + mail.setFrom(senderEmail); + mail.setSubject(subject); + mail.setContent(htmlBody, 'text/html', 'UTF-8'); + + var status = mail.send(); + if (status.isError()) { + log.error('mail.send() failed: template={0}, subject={1}', templateName, subject); + return { error: true, errorMessage: 'Mail.send() returned error status' }; + } + + log.info('mail.send() succeeded: template={0}', templateName); + return { error: false }; + } catch (e) { + log.error('mail.send() threw: template={0}, error={1}', templateName, e.message); + return { error: true, errorMessage: 'Notification delivery failed: ' + e.message }; + } +} + +module.exports = { send: send }; diff --git a/packages/template-retail-react-app/cartridges/app_pwakit_base/cartridge/scripts/helpers/storefrontHostProvider.js b/packages/template-retail-react-app/cartridges/app_pwakit_base/cartridge/scripts/helpers/storefrontHostProvider.js new file mode 100644 index 0000000000..7bc9751b18 --- /dev/null +++ b/packages/template-retail-react-app/cartridges/app_pwakit_base/cartridge/scripts/helpers/storefrontHostProvider.js @@ -0,0 +1,95 @@ +/** + * Copyright 2026 Salesforce, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +'use strict'; + +var System = require('dw/system/System'); +var Site = require('dw/system/Site'); +var Logger = require('dw/system/Logger'); + +var log = Logger.getLogger('pwakit-notify', 'pwakit-notify'); + +/** + * Resolves the public storefront hostname for magic-link URL construction. + * + * Current implementation: validates a caller-supplied hostname against the + * comma-separated `pwakitStorefrontHosts` global preference allowlist, then + * falls back to the first configured host, then to the B2C instance hostname. + * + * TODO: replace this module body with a single Script API call once B2C exposes + * the MRT/eCDN hostname natively, e.g.: + * + * var ComposableStorefrontMgr = require('dw/mrt/ComposableStorefrontMgr'); + * module.exports = function resolveStorefrontHost() { + * return ComposableStorefrontMgr.getStorefrontHostname(Site.getCurrent().getID()) + * || Site.getCurrent().httpsHostName; + * }; + * + * The export signature (requestedHost: string|null) → string|null is intentionally + * preserved so callers require no changes after the swap. The future implementation + * ignores `requestedHost` and the null return path becomes unreachable. + * + * @param {string|null} requestedHost Hostname supplied by the API caller (no protocol, + * no trailing slash). Pass null when calling from a server-side hook that has no + * incoming host to validate (e.g. sendOrderAccessCode). + * @returns {string|null} Validated hostname to use in magic-link URLs, or null when + * requestedHost was supplied but is not in the allowlist (caller should reject with 400). + */ +module.exports = function resolveStorefrontHost(requestedHost) { + var allowedHosts = getAllowedHosts(); + + if (requestedHost && requestedHost.trim()) { + var normalizedHost = requestedHost.trim().replace(/^https?:\/\//, '').replace(/\/$/, ''); + if (allowedHosts.length === 0) { + log.warn( + 'pwakitStorefrontHosts allowlist is not configured. Accepting host from request ({0}). ' + + 'Configure pwakitStorefrontHosts in Business Manager to restrict allowed hosts.', + normalizedHost + ); + return normalizedHost; + } + if (allowedHosts.indexOf(normalizedHost) !== -1) { + return normalizedHost; + } + log.error( + 'Rejecting magic-link request: host "{0}" is not in the pwakitStorefrontHosts allowlist ({1})', + normalizedHost, + allowedHosts.join(', ') + ); + return null; + } + + if (allowedHosts.length > 0) { + return allowedHosts[0]; + } + + log.warn( + 'pwakitStorefrontHosts is not configured. Magic-link emails will use the B2C instance hostname ({0}), ' + + 'which is incorrect for headless storefronts.', + Site.getCurrent().httpsHostName + ); + return Site.getCurrent().httpsHostName; +}; + +function getAllowedHosts() { + try { + var pref = System.getPreferences().getCustom()['pwakitStorefrontHosts']; + if (!pref || !pref.trim()) return []; + return pref.split(',').map(function(h) { return h.trim(); }).filter(Boolean); + } catch (e) { + return []; + } +} diff --git a/packages/template-retail-react-app/cartridges/app_pwakit_base/cartridge/scripts/hooks/sendOrderAccessCode.js b/packages/template-retail-react-app/cartridges/app_pwakit_base/cartridge/scripts/hooks/sendOrderAccessCode.js new file mode 100644 index 0000000000..33e86bac3a --- /dev/null +++ b/packages/template-retail-react-app/cartridges/app_pwakit_base/cartridge/scripts/hooks/sendOrderAccessCode.js @@ -0,0 +1,98 @@ +/** + * Copyright 2026 Salesforce, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +'use strict'; + +var Status = require('dw/system/Status'); +var Logger = require('dw/system/Logger'); +var Resource = require('dw/web/Resource'); +var System = require('dw/system/System'); +var Site = require('dw/system/Site'); +var HookMgr = require('dw/system/HookMgr'); +var sendNotification = require('*/cartridge/scripts/helpers/sendNotification'); +var resolveStorefrontHost = require('*/cartridge/scripts/helpers/storefrontHostProvider'); + +var log = Logger.getLogger('pwakit-notify', 'pwakit-notify'); + +log.info('sendOrderAccessCode module loaded'); + + +/** + * Returns true when the pwakitNotify feature is enabled. + * + * Reads the `pwakitNotifyEnabled` global preference. Defaults to enabled when + * the preference is unset (null) — only an explicit false disables it. + * + * To disable: Business Manager > Administration > Global Preferences > + * Custom Preferences > pwakit > Notifications Enabled → false. + * Do this if you have customized email delivery (e.g. Marketing Cloud, a + * third-party provider, or your own hook) and don't want the cartridge's + * default ISML-based emails to fire. + * + * @returns {boolean} + */ +function isNotifyEnabled() { + try { + var val = System.getPreferences().getCustom()['pwakitNotifyEnabled']; + return val !== false; // null (unset) → enabled; explicit false → disabled + } catch (e) { + return true; // preference schema not imported yet — default to enabled + } +} + +/** + * Hook implementation for sfcc.app.order.sendOrderAccessCode. + * + * Called by SCAPI's requestOrderAccessCode with the B2C Order object and the + * generated access code. Signature is (order, accessCode) per the platform + * hook contract — not (recipient, orderNo, accessCode). + * + * @param {dw.order.Order} order - The B2C Order object + * @param {string} accessCode - The one-time access code + * @returns {dw.system.Status} + */ +function sendOrderAccessCode(order, accessCode) { + if (!isNotifyEnabled()) { + log.info('sendOrderAccessCode: pwakitNotifyEnabled is false — skipping (custom email provider in use)'); + var skipped = HookMgr.callNextHook('sfcc.app.order.sendOrderAccessCode', 'sendOrderAccessCode', order, accessCode); + return skipped || new Status(Status.OK); + } + log.info('sendOrderAccessCode called: orderNo={0}', order ? order.orderNo : 'null'); + if (!order || !order.customerInfo) { + log.error('sendOrderAccessCode: order or customerInfo is null'); + return new Status(Status.ERROR, 'NULL_ORDER', 'Order or customer info is null'); + } + + var siteId = Site.getCurrent().ID.toLowerCase(); + var locale = (request.locale || Site.getCurrent().defaultLocale || 'en_US').replace(/_/g, '-'); + var magicLink = 'https://' + resolveStorefrontHost(null) + '/' + siteId + '/' + locale + '/order-lookup/verify/' + order.orderNo + '?token=' + encodeURIComponent(accessCode); + + var result = sendNotification.send( + order.customerInfo.email, + Resource.msg('gloAccessCode.subject', 'email', 'Your Order Access Code'), + 'email/gloAccessCode', + { orderNo: order.orderNo, accessCode: accessCode, magicLink: magicLink } + ); + + if (result.error) { + return new Status(Status.ERROR, 'MAIL_FAILED', result.errorMessage || 'mail.send() failed'); + } + + var next = HookMgr.callNextHook('sfcc.app.order.sendOrderAccessCode', 'sendOrderAccessCode', order, accessCode); + return next || new Status(Status.OK); +} + +module.exports = { sendOrderAccessCode: sendOrderAccessCode }; diff --git a/packages/template-retail-react-app/cartridges/app_pwakit_base/cartridge/templates/default/email/common/footer.isml b/packages/template-retail-react-app/cartridges/app_pwakit_base/cartridge/templates/default/email/common/footer.isml new file mode 100644 index 0000000000..675a6d1ffb --- /dev/null +++ b/packages/template-retail-react-app/cartridges/app_pwakit_base/cartridge/templates/default/email/common/footer.isml @@ -0,0 +1,18 @@ + + + + + + + +

${Resource.msg('email.brandName', 'email', '')}

+

© ${new Date().getFullYear()} ${Resource.msg('email.brandName', 'email', '')}. ${Resource.msg('email.allRightsReserved', 'email', 'All rights reserved.')}

+ + + + + + + + + diff --git a/packages/template-retail-react-app/cartridges/app_pwakit_base/cartridge/templates/default/email/common/header.isml b/packages/template-retail-react-app/cartridges/app_pwakit_base/cartridge/templates/default/email/common/header.isml new file mode 100644 index 0000000000..efa0e1dc2a --- /dev/null +++ b/packages/template-retail-react-app/cartridges/app_pwakit_base/cartridge/templates/default/email/common/header.isml @@ -0,0 +1,32 @@ + + + + + + + ${pdict.emailTitle} + + + + + + + @@ -38,7 +40,7 @@
- ${Resource.msg('gloAccessCode.buttonText', 'email', null)} + ${Resource.msg('passwordlessLogin.buttonText', 'email', null)}
@@ -48,14 +50,14 @@ -

${Resource.msg('gloAccessCode.disclaimer', 'email', null)}

+

${Resource.msg('passwordlessLogin.disclaimer', 'email', null)}

-

${Resource.msg('gloAccessCode.copyUrl', 'email', null)}
+

${Resource.msg('passwordlessLogin.copyUrl', 'email', null)}
${pdict.magicLink}

diff --git a/packages/template-retail-react-app/cartridges/app_pwakit_base/cartridge/templates/default/email/passwordlessMagicLink.isml b/packages/template-retail-react-app/cartridges/app_pwakit_base/cartridge/templates/default/email/passwordlessMagicLink.isml deleted file mode 100644 index 9f04b9edf0..0000000000 --- a/packages/template-retail-react-app/cartridges/app_pwakit_base/cartridge/templates/default/email/passwordlessMagicLink.isml +++ /dev/null @@ -1,45 +0,0 @@ - - - - - -

${Resource.msg('passwordlessMagicLink.heading', 'email', null)}

- - - - - - -

${Resource.msg('passwordlessMagicLink.instructions', 'email', null)}

- - - - - - - - - - -
- ${Resource.msg('passwordlessMagicLink.buttonText', 'email', null)} -
- - - - - - -

${Resource.msg('passwordlessMagicLink.disclaimer', 'email', null)}

- - - - - - -

${Resource.msg('passwordlessMagicLink.copyUrl', 'email', null)}
- ${pdict.magicLink}

- - - - diff --git a/packages/template-retail-react-app/cartridges/app_pwakit_base/cartridge/templates/default/email/otpVerification.isml b/packages/template-retail-react-app/cartridges/app_pwakit_base/cartridge/templates/default/email/registrationVerification.isml similarity index 79% rename from packages/template-retail-react-app/cartridges/app_pwakit_base/cartridge/templates/default/email/otpVerification.isml rename to packages/template-retail-react-app/cartridges/app_pwakit_base/cartridge/templates/default/email/registrationVerification.isml index 3e792798b8..cc74236e22 100644 --- a/packages/template-retail-react-app/cartridges/app_pwakit_base/cartridge/templates/default/email/otpVerification.isml +++ b/packages/template-retail-react-app/cartridges/app_pwakit_base/cartridge/templates/default/email/registrationVerification.isml @@ -3,14 +3,14 @@ -

${Resource.msg('otpVerification.heading', 'email', null)}

+

${Resource.msg('registrationVerification.heading', 'email', null)}

-

${Resource.msg('otpVerification.instructions', 'email', null)}

+

${Resource.msg('registrationVerification.instructions', 'email', null)}

@@ -30,7 +30,7 @@ -

${Resource.msg('otpVerification.disclaimer', 'email', null)}

+

${Resource.msg('registrationVerification.disclaimer', 'email', null)}

diff --git a/packages/template-retail-react-app/cartridges/app_pwakit_base/cartridge/templates/resources/email.properties b/packages/template-retail-react-app/cartridges/app_pwakit_base/cartridge/templates/resources/email.properties index 26f0d4c0ff..98f2be92da 100644 --- a/packages/template-retail-react-app/cartridges/app_pwakit_base/cartridge/templates/resources/email.properties +++ b/packages/template-retail-react-app/cartridges/app_pwakit_base/cartridge/templates/resources/email.properties @@ -1,43 +1,39 @@ email.brandName=Starter Store for Retail email.allRightsReserved=All rights reserved. -# passwordlessMagicLink -passwordlessMagicLink.subject=Your Magic Sign-In Link -passwordlessMagicLink.pageTitle=Your Sign-In Link -passwordlessMagicLink.heading=Sign in to your account -passwordlessMagicLink.instructions=Click the link below to sign in. This link expires in 20 minutes and can only be used once. -passwordlessMagicLink.buttonText=Sign In -passwordlessMagicLink.disclaimer=If you did not request this link, you can safely ignore this email. -passwordlessMagicLink.copyUrl=Or copy this URL into your browser: +# passwordlessLogin +passwordlessLogin.subject=Your Magic Sign-In Link +passwordlessLogin.pageTitle=Your Sign-In Link +passwordlessLogin.heading=Sign in to your account +passwordlessLogin.instructions=Click the link below to sign in. This link expires in 20 minutes and can only be used once. +passwordlessLogin.accessCodeLabel=Your sign-in code: +passwordlessLogin.buttonText=Sign In +passwordlessLogin.disclaimer=If you did not request this link, you can safely ignore this email. +passwordlessLogin.copyUrl=Or copy this URL into your browser: -# passwordResetMagicLink -passwordResetMagicLink.subject=Reset Your Password -passwordResetMagicLink.pageTitle=Reset your password -passwordResetMagicLink.heading=Reset your password -passwordResetMagicLink.instructions=Click the button below to set a new password. This link expires in 15 minutes. -passwordResetMagicLink.buttonText=Reset password -passwordResetMagicLink.disclaimer=If you did not request a password reset, you can safely ignore this email. -passwordResetMagicLink.copyUrl=Or copy and paste this link into your browser: +# passwordReset +passwordReset.subject=Reset Your Password +passwordReset.pageTitle=Reset your password +passwordReset.heading=Reset your password +passwordReset.instructions=Click the button below to set a new password. This link expires in 15 minutes. +passwordReset.buttonText=Reset password +passwordReset.disclaimer=If you did not request a password reset, you can safely ignore this email. +passwordReset.copyUrl=Or copy and paste this link into your browser: -# otpVerification -otpVerification.subject=Your Verification Code -otpVerification.pageTitle=Your verification code -otpVerification.heading=Your verification code -otpVerification.instructions=Use the code below to verify your identity. It expires in 15 minutes. -otpVerification.disclaimer=If you did not request this code, you can safely ignore this email. +# registrationVerification +registrationVerification.subject=Your Verification Code +registrationVerification.pageTitle=Your verification code +registrationVerification.heading=Your verification code +registrationVerification.instructions=Use the code below to verify your identity. It expires in 15 minutes. +registrationVerification.disclaimer=If you did not request this code, you can safely ignore this email. -# gloAccessCode -gloAccessCode.subject=Your Order Access Code -gloAccessCode.pageTitle=Your order access code -gloAccessCode.heading=Your order access code -gloAccessCode.instructionsPrefix=Use the code below to view your order -gloAccessCode.buttonText=View Order -gloAccessCode.accessCodeLabel=Your access code: -gloAccessCode.disclaimer=If you did not place this order, please contact customer support. -gloAccessCode.copyUrl=Or copy and paste this link into your browser: +# guestOrderLookup +guestOrderLookup.subject=Your Order Access Code +guestOrderLookup.pageTitle=Your order access code +guestOrderLookup.heading=Your order access code +guestOrderLookup.instructionsPrefix=Use the code below to view your order +guestOrderLookup.buttonText=View Order +guestOrderLookup.accessCodeLabel=Your access code: +guestOrderLookup.disclaimer=If you did not place this order, please contact customer support. +guestOrderLookup.copyUrl=Or copy and paste this link into your browser: -# plain-text fallbacks (multipart/alternative) -email.plaintext.useThisLink=Use this link to continue: -email.plaintext.verificationCode=Your verification code: -email.plaintext.orderAccessCode=Your order access code: -email.plaintext.orderNumber=Order: From e816acdea16b7dc23d610c7b3eab61e91d8f3980 Mon Sep 17 00:00:00 2001 From: Ben Chypak Date: Wed, 16 Sep 2026 09:20:46 -0700 Subject: [PATCH 16/55] feat(cartridge): port callerHost validation, buildOrderLookupUrl helper, and graceful degradation from sf-next MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add buildOrderLookupUrl.js shared helper for GLO verification URL construction - Notify.js: read body.callerHost (renamed from host), validate per magic-link branch, return 503 on host failure, use buildOrderLookupUrl for glo-access-code - sendOrderAccessCode.js: graceful degradation when pwakitStorefrontHosts unconfigured (warn + send with lookupLink: null instead of constructing https://null/...) - storefrontHostProvider.js: case-insensitive allowlist comparison - guestOrderLookup.isml, passwordlessLogin.isml, passwordReset.isml: wrap CTA button and copy-URL fallback in so emails degrade gracefully when no host can be resolved - schema.yaml: rename host → callerHost with description - ssr.js: rename host → callerHost in notify payload --- packages/template-retail-react-app/app/ssr.js | 2 +- .../rest-apis/pwakit-notify/Notify.js | 55 +++++++++++++------ .../rest-apis/pwakit-notify/schema.yaml | 12 ++-- .../scripts/helpers/buildOrderLookupUrl.js | 33 +++++++++++ .../scripts/helpers/storefrontHostProvider.js | 4 +- .../scripts/hooks/sendOrderAccessCode.js | 10 +++- .../default/email/guestOrderLookup.isml | 4 ++ .../default/email/passwordReset.isml | 4 ++ .../default/email/passwordlessLogin.isml | 4 ++ 9 files changed, 103 insertions(+), 25 deletions(-) create mode 100644 packages/template-retail-react-app/cartridges/app_pwakit_base/cartridge/scripts/helpers/buildOrderLookupUrl.js diff --git a/packages/template-retail-react-app/app/ssr.js b/packages/template-retail-react-app/app/ssr.js index f3562949e3..5db730cf0d 100644 --- a/packages/template-retail-react-app/app/ssr.js +++ b/packages/template-retail-react-app/app/ssr.js @@ -294,7 +294,7 @@ async function sendViaB2cCartridge(type, recipient, data, apiParams) { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` }, - body: JSON.stringify({type, recipient, data, host: new URL(getAppOrigin()).hostname}) + body: JSON.stringify({type, recipient, data, callerHost: new URL(getAppOrigin()).hostname}) }) if (!res.ok) { if (res.status === 401) { diff --git a/packages/template-retail-react-app/cartridges/app_pwakit_base/cartridge/rest-apis/pwakit-notify/Notify.js b/packages/template-retail-react-app/cartridges/app_pwakit_base/cartridge/rest-apis/pwakit-notify/Notify.js index 0e1629d8ca..d23fdf45cc 100644 --- a/packages/template-retail-react-app/cartridges/app_pwakit_base/cartridge/rest-apis/pwakit-notify/Notify.js +++ b/packages/template-retail-react-app/cartridges/app_pwakit_base/cartridge/rest-apis/pwakit-notify/Notify.js @@ -22,6 +22,7 @@ var System = require('dw/system/System'); var Site = require('dw/system/Site'); var Logger = require('dw/system/Logger'); var resolveStorefrontHost = require('*/cartridge/scripts/helpers/storefrontHostProvider'); +var buildOrderLookupUrl = require('*/cartridge/scripts/helpers/buildOrderLookupUrl'); var log = Logger.getLogger('pwakit-notify', 'pwakit-notify'); @@ -81,6 +82,7 @@ exports.notify = function () { var type = body.type; var recipient = body.recipient; + var callerHost = body.callerHost || null; if (!type || !recipient) { RESTResponseMgr.createError( @@ -107,17 +109,6 @@ exports.notify = function () { return; } - var storefrontHost = resolveStorefrontHost(body.host || null); - if (storefrontHost === null) { - RESTResponseMgr.createError( - 400, - 'invalid-host', - 'Bad Request', - 'Supplied host is not in the pwakitStorefrontHosts allowed-hosts list' - ).render(); - return; - } - var subject; var templateName; var context; @@ -133,7 +124,17 @@ exports.notify = function () { ).render(); return; } - magicLink = 'https://' + storefrontHost + body.data.magicLinkPath; + var plHost = resolveStorefrontHost(callerHost); + if (!plHost) { + RESTResponseMgr.createError( + 503, + 'host-not-configured', + 'Service Unavailable', + 'pwakitStorefrontHosts is not configured or callerHost is not in the allowlist' + ).render(); + return; + } + magicLink = 'https://' + plHost + body.data.magicLinkPath; // Extract the token from the magicLinkPath for inline display in the email var tokenMatch = body.data.magicLinkPath.match(/[?&]token=([^&]+)/); var inlineAccessCode = null; @@ -157,7 +158,17 @@ exports.notify = function () { ).render(); return; } - magicLink = 'https://' + storefrontHost + body.data.magicLinkPath; + var prHost = resolveStorefrontHost(callerHost); + if (!prHost) { + RESTResponseMgr.createError( + 503, + 'host-not-configured', + 'Service Unavailable', + 'pwakitStorefrontHosts is not configured or callerHost is not in the allowlist' + ).render(); + return; + } + magicLink = 'https://' + prHost + body.data.magicLinkPath; subject = Resource.msg('passwordReset.subject', 'email', 'Reset Your Password'); templateName = 'email/passwordReset'; context = { magicLink: magicLink }; @@ -184,12 +195,22 @@ exports.notify = function () { ).render(); return; } - var siteId = Site.getCurrent().ID.toLowerCase(); - var locale = (request.locale || Site.getCurrent().defaultLocale || 'en_US').replace(/_/g, '-'); - var lookupLink = 'https://' + storefrontHost + '/' + siteId + '/' + locale + '/order-lookup/verify/' + body.data.orderNo + '?token=' + encodeURIComponent(body.data.accessCode); subject = Resource.msg('guestOrderLookup.subject', 'email', 'Your Order Access Code'); templateName = 'email/guestOrderLookup'; - context = { orderNo: body.data.orderNo, accessCode: body.data.accessCode, lookupLink: lookupLink }; + var gloHost = resolveStorefrontHost(callerHost); + if (!gloHost) { + RESTResponseMgr.createError( + 503, + 'host-not-configured', + 'Service Unavailable', + 'pwakitStorefrontHosts is not configured or callerHost is not in the allowlist' + ).render(); + return; + } + var gloSiteId = Site.getCurrent().ID.toLowerCase(); + var gloLocale = (request.locale || Site.getCurrent().defaultLocale || 'en_US').replace(/_/g, '-'); + var gloLink = buildOrderLookupUrl(gloHost, gloSiteId, gloLocale, body.data.orderNo, body.data.accessCode); + context = { orderNo: body.data.orderNo, accessCode: body.data.accessCode, lookupLink: gloLink }; } else { RESTResponseMgr.createError( 400, diff --git a/packages/template-retail-react-app/cartridges/app_pwakit_base/cartridge/rest-apis/pwakit-notify/schema.yaml b/packages/template-retail-react-app/cartridges/app_pwakit_base/cartridge/rest-apis/pwakit-notify/schema.yaml index 91ae20aa49..9260f0d224 100644 --- a/packages/template-retail-react-app/cartridges/app_pwakit_base/cartridge/rest-apis/pwakit-notify/schema.yaml +++ b/packages/template-retail-react-app/cartridges/app_pwakit_base/cartridge/rest-apis/pwakit-notify/schema.yaml @@ -43,8 +43,9 @@ paths: enum: [passwordless-magic-link] recipient: type: string - host: + callerHost: type: string + description: Public hostname of the storefront making the request (e.g. my-store.salesforcecommercecloudsites.com). Validated against the pwakitStorefrontHosts allowlist before being used to construct magic-link URLs. data: type: object required: [magicLinkPath] @@ -61,8 +62,9 @@ paths: enum: [password-reset] recipient: type: string - host: + callerHost: type: string + description: Public hostname of the storefront making the request (e.g. my-store.salesforcecommercecloudsites.com). Validated against the pwakitStorefrontHosts allowlist before being used to construct magic-link URLs. data: type: object required: [magicLinkPath] @@ -79,8 +81,9 @@ paths: enum: [otp] recipient: type: string - host: + callerHost: type: string + description: Public hostname of the storefront making the request (e.g. my-store.salesforcecommercecloudsites.com). Validated against the pwakitStorefrontHosts allowlist before being used to construct magic-link URLs. data: type: object required: [token] @@ -95,8 +98,9 @@ paths: enum: [glo-access-code] recipient: type: string - host: + callerHost: type: string + description: Public hostname of the storefront making the request (e.g. my-store.salesforcecommercecloudsites.com). Validated against the pwakitStorefrontHosts allowlist before being used to construct magic-link URLs. data: type: object required: [orderNo, accessCode] diff --git a/packages/template-retail-react-app/cartridges/app_pwakit_base/cartridge/scripts/helpers/buildOrderLookupUrl.js b/packages/template-retail-react-app/cartridges/app_pwakit_base/cartridge/scripts/helpers/buildOrderLookupUrl.js new file mode 100644 index 0000000000..fad6d857ab --- /dev/null +++ b/packages/template-retail-react-app/cartridges/app_pwakit_base/cartridge/scripts/helpers/buildOrderLookupUrl.js @@ -0,0 +1,33 @@ +/** + * Copyright 2026 Salesforce, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +'use strict'; + +/** + * Builds a guest order lookup verification URL. + * + * @param {string} host - Validated storefront hostname (no protocol, no trailing slash) + * @param {string} siteId - Lowercase site ID + * @param {string} locale - BCP 47 locale string (e.g. "en-US") + * @param {string} orderNo - Order number + * @param {string} token - One-time access code + * @returns {string} Full verification URL + */ +function buildOrderLookupUrl(host, siteId, locale, orderNo, token) { + return 'https://' + host + '/' + siteId + '/' + locale + '/order-lookup/verify/' + orderNo + '?token=' + encodeURIComponent(token); +} + +module.exports = buildOrderLookupUrl; diff --git a/packages/template-retail-react-app/cartridges/app_pwakit_base/cartridge/scripts/helpers/storefrontHostProvider.js b/packages/template-retail-react-app/cartridges/app_pwakit_base/cartridge/scripts/helpers/storefrontHostProvider.js index 1118c70326..b49ff8532f 100644 --- a/packages/template-retail-react-app/cartridges/app_pwakit_base/cartridge/scripts/helpers/storefrontHostProvider.js +++ b/packages/template-retail-react-app/cartridges/app_pwakit_base/cartridge/scripts/helpers/storefrontHostProvider.js @@ -61,8 +61,8 @@ function resolveStorefrontHost(requestedHost) { ); return normalizedHost; } - if (allowedHosts.indexOf(normalizedHost) !== -1) { - return normalizedHost; + if (allowedHosts.indexOf(normalizedHost.toLowerCase()) !== -1) { + return normalizedHost.toLowerCase(); } log.error( 'Rejecting magic-link request: host "{0}" is not in the pwakitStorefrontHosts allowlist ({1})', diff --git a/packages/template-retail-react-app/cartridges/app_pwakit_base/cartridge/scripts/hooks/sendOrderAccessCode.js b/packages/template-retail-react-app/cartridges/app_pwakit_base/cartridge/scripts/hooks/sendOrderAccessCode.js index 2e1b74e951..0118a366c5 100644 --- a/packages/template-retail-react-app/cartridges/app_pwakit_base/cartridge/scripts/hooks/sendOrderAccessCode.js +++ b/packages/template-retail-react-app/cartridges/app_pwakit_base/cartridge/scripts/hooks/sendOrderAccessCode.js @@ -23,6 +23,7 @@ var System = require('dw/system/System'); var Site = require('dw/system/Site'); var sendNotification = require('*/cartridge/scripts/helpers/sendNotification'); var resolveStorefrontHost = require('*/cartridge/scripts/helpers/storefrontHostProvider'); +var buildOrderLookupUrl = require('*/cartridge/scripts/helpers/buildOrderLookupUrl'); var log = Logger.getLogger('pwakit-notify', 'pwakit-notify'); @@ -77,13 +78,20 @@ function sendOrderAccessCode(order, accessCode) { return new Status(Status.ERROR, 'NULL_ORDER', 'Order or customer info is null'); } + var storefrontHost = resolveStorefrontHost(null); + if (!storefrontHost) { + log.warn('sendOrderAccessCode: pwakitStorefrontHosts is not configured — sending email without lookup link'); + } + // Note: uses siteId.toLowerCase() as the URL path segment. If your storefront // config defines a siteAlias (e.g. RefArchGlobal → "global"), the magic link // will 404 unless you add a redirect at the CDN/router level or override this // hook with an alias-aware implementation. var siteId = Site.getCurrent().ID.toLowerCase(); var locale = (request.locale || Site.getCurrent().defaultLocale || 'en_US').replace(/_/g, '-'); - var lookupLink = 'https://' + resolveStorefrontHost(null) + '/' + siteId + '/' + locale + '/order-lookup/verify/' + order.orderNo + '?token=' + encodeURIComponent(accessCode); + var lookupLink = storefrontHost + ? buildOrderLookupUrl(storefrontHost, siteId, locale, order.orderNo, accessCode) + : null; var result = sendNotification.send( order.customerInfo.email, diff --git a/packages/template-retail-react-app/cartridges/app_pwakit_base/cartridge/templates/default/email/guestOrderLookup.isml b/packages/template-retail-react-app/cartridges/app_pwakit_base/cartridge/templates/default/email/guestOrderLookup.isml index f2ad46c225..87db1b93d1 100644 --- a/packages/template-retail-react-app/cartridges/app_pwakit_base/cartridge/templates/default/email/guestOrderLookup.isml +++ b/packages/template-retail-react-app/cartridges/app_pwakit_base/cartridge/templates/default/email/guestOrderLookup.isml @@ -32,6 +32,7 @@ + @@ -44,6 +45,7 @@ + @@ -52,6 +54,7 @@ + @@ -59,5 +62,6 @@ ${pdict.lookupLink}

+
diff --git a/packages/template-retail-react-app/cartridges/app_pwakit_base/cartridge/templates/default/email/passwordReset.isml b/packages/template-retail-react-app/cartridges/app_pwakit_base/cartridge/templates/default/email/passwordReset.isml index 77b22f3f2e..7ebd910222 100644 --- a/packages/template-retail-react-app/cartridges/app_pwakit_base/cartridge/templates/default/email/passwordReset.isml +++ b/packages/template-retail-react-app/cartridges/app_pwakit_base/cartridge/templates/default/email/passwordReset.isml @@ -14,6 +14,7 @@ + @@ -26,6 +27,7 @@ + @@ -34,6 +36,7 @@ + @@ -41,5 +44,6 @@ ${pdict.magicLink}

+
diff --git a/packages/template-retail-react-app/cartridges/app_pwakit_base/cartridge/templates/default/email/passwordlessLogin.isml b/packages/template-retail-react-app/cartridges/app_pwakit_base/cartridge/templates/default/email/passwordlessLogin.isml index 545409d5c4..7bd11f9688 100644 --- a/packages/template-retail-react-app/cartridges/app_pwakit_base/cartridge/templates/default/email/passwordlessLogin.isml +++ b/packages/template-retail-react-app/cartridges/app_pwakit_base/cartridge/templates/default/email/passwordlessLogin.isml @@ -34,6 +34,7 @@
+ @@ -46,6 +47,7 @@ + @@ -54,6 +56,7 @@ + @@ -61,5 +64,6 @@ ${pdict.magicLink}

+
From 96bd1544ae02ca562e6e073ba8cb3efcca1f1bee Mon Sep 17 00:00:00 2001 From: Ben Chypak Date: Wed, 16 Sep 2026 15:24:40 -0700 Subject: [PATCH 17/55] fix(cartridge): replace oneOf+discriminator with anyOf in pwakit-notify schema SCAPI's strict validator fails on oneOf + headless discriminator (no mapping, inline sub-schemas) because it can't resolve discriminator values to component names. anyOf without discriminator passes validation correctly on all instances. --- .../cartridge/rest-apis/pwakit-notify/schema.yaml | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/template-retail-react-app/cartridges/app_pwakit_base/cartridge/rest-apis/pwakit-notify/schema.yaml b/packages/template-retail-react-app/cartridges/app_pwakit_base/cartridge/rest-apis/pwakit-notify/schema.yaml index 9260f0d224..907d7f60b5 100644 --- a/packages/template-retail-react-app/cartridges/app_pwakit_base/cartridge/rest-apis/pwakit-notify/schema.yaml +++ b/packages/template-retail-react-app/cartridges/app_pwakit_base/cartridge/rest-apis/pwakit-notify/schema.yaml @@ -34,7 +34,7 @@ paths: content: application/json: schema: - oneOf: + anyOf: - type: object required: [type, recipient, data] properties: @@ -109,8 +109,6 @@ paths: type: string accessCode: type: string - discriminator: - propertyName: type responses: '200': description: Notification sent successfully From 815b684881970014b3b78deb68dc98768831def9 Mon Sep 17 00:00:00 2001 From: Ben Chypak Date: Thu, 17 Sep 2026 10:43:28 -0700 Subject: [PATCH 18/55] =?UTF-8?q?fix(cartridge):=20address=20code=20review?= =?UTF-8?q?=20findings=20=E2=80=94=20retry=20on=20401,=20eCDN=20routing=20?= =?UTF-8?q?rules,=20doc=20corrections,=20test=20coverage?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - sendViaB2cCartridge: retry once with fresh token on 401 (cached SLAS guest token expired mid-flight) - ssr.js: export getNotifyToken, sendViaB2cCartridge, _resetNotifyTokenCacheForTest for testability - ssr.js: add registration-verification-callback route (mode=callback OTP for one-click checkout registration) - ssr.js: fix SCAPI URL missing /sites/{siteId} segment - ssr.js: add order-lookup and order-lookup/verify routes to eCDN routingRules - ssr.test.js: add 10 tests covering getNotifyToken (cache, public/private client) and sendViaB2cCartridge (all 4 types, 401 retry, 5xx) - one-click-user-registration.jsx: wire registration verification to mode=callback with registrationVerification callbackURI - config/default.js: add registrationVerification.callbackURI - storefrontHostProvider.js: fail-closed security — reject unrecognized caller hosts instead of falling back to B2C hostname - buildOrderLookupUrl.js: add deliberate-decision comment on token in URL param - Notify.js: add otp type comment clarifying token is a short SLAS TOTP - README: fix instance:import → meta:import, add SLAS allowed redirect URI table, correct hook registration instructions - CHANGELOG: fix #XXXX → #4012, correct deployment note (code:activate vs manual BM cycle) - email.properties: remove 4 unused *.pageTitle keys - package.json: add build:cartridge and deploy:cartridge scripts - .gitignore: ignore cartridges/app_pwakit_base.zip --- packages/template-retail-react-app/.gitignore | 1 + .../template-retail-react-app/CHANGELOG.md | 4 +- .../partials/one-click-user-registration.jsx | 12 +- packages/template-retail-react-app/app/ssr.js | 78 +++++-- .../template-retail-react-app/app/ssr.test.js | 209 +++++++++++++++++- .../cartridges/app_pwakit_base/README.md | 25 ++- .../rest-apis/pwakit-notify/Notify.js | 4 + .../scripts/helpers/buildOrderLookupUrl.js | 4 + .../scripts/helpers/storefrontHostProvider.js | 58 ++--- .../templates/resources/email.properties | 4 - .../config/default.js | 3 + .../template-retail-react-app/package.json | 2 + 12 files changed, 336 insertions(+), 68 deletions(-) diff --git a/packages/template-retail-react-app/.gitignore b/packages/template-retail-react-app/.gitignore index ed48e36cca..72f70e7f3e 100644 --- a/packages/template-retail-react-app/.gitignore +++ b/packages/template-retail-react-app/.gitignore @@ -26,6 +26,7 @@ tmp/ node_modules # Generated files +cartridges/app_pwakit_base.zip app/cache-hash-manifest.json app/loader-cache-hash-manifest.json diff --git a/packages/template-retail-react-app/CHANGELOG.md b/packages/template-retail-react-app/CHANGELOG.md index 3417f6eb9f..4242676c36 100644 --- a/packages/template-retail-react-app/CHANGELOG.md +++ b/packages/template-retail-react-app/CHANGELOG.md @@ -1,7 +1,7 @@ ## Unreleased -- [Feature] Add `app_pwakit_base` B2C cartridge for transactional email delivery via ISML templates and `dw/net/Mail`. GLO access-code emails are delivered via the `sfcc.app.order.sendOrderAccessCode` B2C hook; passwordless login and OTP verification emails are delivered via the cartridge's `pwakit-notify` Custom REST API. Includes ISML templates for GLO access code, passwordless magic link, OTP verification, and password reset. **Deployment note:** after uploading the cartridge to WebDAV and adding `app_pwakit_base` to the site cartridge path, deactivate and reactivate the code version in Business Manager (Administration → Code Deployment) so the SFCC runtime registers the hook and the SCAPI Custom API endpoint. Also import `OrganizationPreferences.xml` via Administration → Site Development → Import & Export to register the `pwakitNotifyEnabled` and `pwakitStorefrontHosts` custom preferences. [#XXXX](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/XXXX) -- [Bugfix] Fix GLO `/api/order-lookup/verify` and related server endpoints returning 401 when `enablePWAKitPrivateClient` is enabled. The `cc-nx-g_` cookie stores the SLAS refresh token, not the access token — the access token is never stored in any cookie. All GLO server endpoints now read the SLAS JWT from the `Authorization` header sent by the client (via `useAccessToken().getTokenWhenReady()`), which works correctly in both public and private client modes. [#XXXX](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/XXXX) +- [Feature] Add `app_pwakit_base` B2C cartridge for transactional email delivery via ISML templates and `dw/net/Mail`. GLO access-code emails use the `sfcc.app.order.sendOrderAccessCode` hook; passwordless login, OTP verification, and password reset use the `pwakit-notify` Custom REST API. Deploy with `sfcc-ci code:deploy` + `code:activate` (activating registers the hook and SCAPI endpoint), then import `OrganizationPreferences.xml` to enable the `pwakitNotifyEnabled` and `pwakitStorefrontHosts` preferences. See `cartridges/app_pwakit_base/README.md` for full setup steps. [#4012](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/4012) +- [Bugfix] Fix GLO `/api/order-lookup/verify` and related server endpoints returning 401 when `enablePWAKitPrivateClient` is enabled. The `cc-nx-g_` cookie stores the SLAS refresh token, not the access token — the access token is never stored in any cookie. All GLO server endpoints now read the SLAS JWT from the `Authorization` header sent by the client (via `useAccessToken().getTokenWhenReady()`), which works correctly in both public and private client modes. [#4012](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/4012) ## v10.3.0-dev (Aug 12, 2026) - [Feature] Add inline agent widget on the Product Detail Page: renders a self-hosted `` web component below the Add to Cart actions so shoppers can ask product questions without opening the floating panel. The widget is gated on config (`enabled` + SCRT2 credentials present), loads client-side only, and passes product context (product id from the URL) to the agent on each message. [#3988](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3988) diff --git a/packages/template-retail-react-app/app/pages/checkout-one-click/partials/one-click-user-registration.jsx b/packages/template-retail-react-app/app/pages/checkout-one-click/partials/one-click-user-registration.jsx index 5c918bef48..aa6eb39c6e 100644 --- a/packages/template-retail-react-app/app/pages/checkout-one-click/partials/one-click-user-registration.jsx +++ b/packages/template-retail-react-app/app/pages/checkout-one-click/partials/one-click-user-registration.jsx @@ -25,11 +25,17 @@ import {useCurrentBasket} from '@salesforce/retail-react-app/app/hooks/use-curre import {useCustomerType, useAuthHelper, AuthHelpers} from '@salesforce/commerce-sdk-react' import {useShopperCustomersMutation} from '@salesforce/commerce-sdk-react' import useMultiSite from '@salesforce/retail-react-app/app/hooks/use-multi-site' +import {getPasswordlessCallbackUrl} from '@salesforce/retail-react-app/app/utils/auth-utils' +import {getConfig} from '@salesforce/pwa-kit-runtime/utils/ssr-config' import {useCheckout} from '@salesforce/retail-react-app/app/pages/checkout-one-click/util/checkout-context' import {useToast} from '@salesforce/retail-react-app/app/hooks/use-toast' import {isPickupShipment} from '@salesforce/retail-react-app/app/utils/shipment-utils' import {nanoid} from 'nanoid' +const registrationCallbackURL = getPasswordlessCallbackUrl( + getConfig()?.app?.login?.registrationVerification?.callbackURI +) + export default function UserRegistration({ enableUserRegistration, setEnableUserRegistration, @@ -86,7 +92,8 @@ export default function UserRegistration({ try { await authorizePasswordlessLogin.mutateAsync({ userid: basket.customerInfo.email, - mode: 'email', + mode: 'callback', + ...(registrationCallbackURL && {callbackURI: registrationCallbackURL}), locale: locale?.id, register_customer: true, last_name: basket.customerInfo.email, @@ -318,7 +325,8 @@ export default function UserRegistration({ handleSendEmailOtp={async (email) => { return authorizePasswordlessLogin.mutateAsync({ userid: email, - mode: 'email', + mode: 'callback', + ...(registrationCallbackURL && {callbackURI: registrationCallbackURL}), locale: locale?.id, register_customer: true, last_name: email, diff --git a/packages/template-retail-react-app/app/ssr.js b/packages/template-retail-react-app/app/ssr.js index 5db730cf0d..fa12adb30d 100644 --- a/packages/template-retail-react-app/app/ssr.js +++ b/packages/template-retail-react-app/app/ssr.js @@ -234,7 +234,7 @@ const options = { // * https://developer.salesforce.com/docs/commerce/commerce-api/references/cdn-api-process-apis?meta=MrtRules // * https://developer.salesforce.com/docs/commerce/commerce-api/guide/ecdn-rules-for-phased-headless-rollout.html routingRules: [ - 'http.request.uri.path eq "/" or http.request.uri.path matches "^/callback" or http.request.uri.path matches "^/mobify" or http.request.uri.path matches "^/worker.js" or http.request.uri.path matches "^/(\\\\w+)/([-\\\\w]+)/$" or http.request.uri.path matches "^/(\\\\w+)/([-\\\\w]+)/login" or http.request.uri.path matches "^/(\\\\w+)/([-\\\\w]+)/reset-password" or http.request.uri.path matches "^/(\\\\w+)/([-\\\\w]+)/registration" or http.request.uri.path matches "^/(\\\\w+)/([-\\\\w]+)/account" or http.request.uri.path matches "^/(\\\\w+)/([-\\\\w]+)/account/orders" or http.request.uri.path matches "^/(\\\\w+)/([-\\\\w]+)/account/orders/(\\\\w+)" or http.request.uri.path matches "^/(\\\\w+)/([-\\\\w]+)/account/wishlist" or http.request.uri.path matches "^/(\\\\w+)/([-\\\\w]+)/product/(\\\\w+)" or http.request.uri.path matches "^/(\\\\w+)/([-\\\\w]+)/search" or http.request.uri.path matches "^/(\\\\w+)/([-\\\\w]+)/category/(\\\\w+)" or http.request.uri.path matches "^/(\\\\w+)/([-\\\\w]+)/order-status" or http.request.uri.path matches "^/(\\\\w+)/([-\\\\w]+)/page/(\\\\w+)" or http.request.uri.path matches "^/(\\\\w+)/([-\\\\w]+)/page-viewer/(\\\\w+)"' + 'http.request.uri.path eq "/" or http.request.uri.path matches "^/callback" or http.request.uri.path matches "^/mobify" or http.request.uri.path matches "^/worker.js" or http.request.uri.path matches "^/(\\\\w+)/([-\\\\w]+)/$" or http.request.uri.path matches "^/(\\\\w+)/([-\\\\w]+)/login" or http.request.uri.path matches "^/(\\\\w+)/([-\\\\w]+)/reset-password" or http.request.uri.path matches "^/(\\\\w+)/([-\\\\w]+)/registration" or http.request.uri.path matches "^/(\\\\w+)/([-\\\\w]+)/account" or http.request.uri.path matches "^/(\\\\w+)/([-\\\\w]+)/account/orders" or http.request.uri.path matches "^/(\\\\w+)/([-\\\\w]+)/account/orders/(\\\\w+)" or http.request.uri.path matches "^/(\\\\w+)/([-\\\\w]+)/account/wishlist" or http.request.uri.path matches "^/(\\\\w+)/([-\\\\w]+)/product/(\\\\w+)" or http.request.uri.path matches "^/(\\\\w+)/([-\\\\w]+)/search" or http.request.uri.path matches "^/(\\\\w+)/([-\\\\w]+)/category/(\\\\w+)" or http.request.uri.path matches "^/(\\\\w+)/([-\\\\w]+)/order-status" or http.request.uri.path matches "^/(\\\\w+)/([-\\\\w]+)/page/(\\\\w+)" or http.request.uri.path matches "^/(\\\\w+)/([-\\\\w]+)/page-viewer/(\\\\w+)" or http.request.uri.path matches "^/(\\\\w+)/([-\\\\w]+)/order-lookup$" or http.request.uri.path matches "^/(\\\\w+)/([-\\\\w]+)/order-lookup/verify/([^/]+)"' ] } } @@ -246,7 +246,12 @@ const runtime = getRuntime() let _notifyToken = null let _notifyTokenExpiry = 0 -async function getNotifyToken(apiParams) { +export function _resetNotifyTokenCacheForTest() { + _notifyToken = null + _notifyTokenExpiry = 0 +} + +export async function getNotifyToken(apiParams) { if (_notifyToken && Date.now() < _notifyTokenExpiry) { return _notifyToken } @@ -279,7 +284,7 @@ async function getNotifyToken(apiParams) { return _notifyToken } -async function sendViaB2cCartridge(type, recipient, data, apiParams) { +export async function sendViaB2cCartridge(type, recipient, data, apiParams) { const {organizationId, siteId} = apiParams const token = await getNotifyToken(apiParams) const proxy = `${getAppOrigin()}${ @@ -287,21 +292,32 @@ async function sendViaB2cCartridge(type, recipient, data, apiParams) { }` const url = `${proxy}/custom/pwakit-notify/v1/organizations/${encodeURIComponent( organizationId - )}/notify?siteId=${encodeURIComponent(siteId)}` - const res = await fetch(url, { + )}/sites/${encodeURIComponent(siteId)}/notify?siteId=${encodeURIComponent(siteId)}` + const requestBody = JSON.stringify({ + type, + recipient, + data, + callerHost: new URL(getAppOrigin()).hostname + }) + let res = await fetch(url, { method: 'POST', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${token}` - }, - body: JSON.stringify({type, recipient, data, callerHost: new URL(getAppOrigin()).hostname}) + headers: {'Content-Type': 'application/json', Authorization: `Bearer ${token}`}, + body: requestBody }) + if (!res.ok && res.status === 401) { + // The cached SLAS guest token expired between calls (Lambda warm reuse). Clear it and + // retry once with a fresh token — without this the calling route would return 500 and + // the email would not be delivered even though the credentials are still valid. + _notifyToken = null + _notifyTokenExpiry = 0 + const freshToken = await getNotifyToken(apiParams) + res = await fetch(url, { + method: 'POST', + headers: {'Content-Type': 'application/json', Authorization: `Bearer ${freshToken}`}, + body: requestBody + }) + } if (!res.ok) { - if (res.status === 401) { - // Clear cached token so next call gets a fresh one - _notifyToken = null - _notifyTokenExpiry = 0 - } let body = '' try { body = await res.text() @@ -673,6 +689,38 @@ const {handler} = runtime.createHandler(options, (app) => { } }) + // Handles the registration verification callback route. SLAS makes a POST request to + // this endpoint with the shopper's email and a short TOTP when register_customer=true + // and mode=callback is used (e.g. one-click checkout registration). Sends a branded + // verification email via the pwakit-notify cartridge using the registrationVerification + // ISML template. The callbackURI must be registered in SLAS client allowed redirect URIs. + const registrationVerificationCallback = + getConfig()?.app?.login?.registrationVerification?.callbackURI ?? + '/registration-verification-callback' + app.post(registrationVerificationCallback, async (req, res) => { + const slasCallbackToken = req.headers['x-slas-callback-token'] + if (!slasCallbackToken) { + return res.status(400).json({error: 'Missing x-slas-callback-token header'}) + } + const {email_id, token} = req.body + try { + await validateSlasCallbackToken(slasCallbackToken) + await sendViaB2cCartridge( + 'otp', + email_id, + {token}, + getConfig().app.commerceAPI.parameters + ) + res.json({success: true}) + } catch (err) { + logger.error('registration-verification callback failed', { + namespace: 'slas-callback', + additionalProperties: {error: err?.message} + }) + res.status(500).json({error: 'Failed to send notification'}) + } + }) + // Proxy endpoint for the shared maintenance page — fetches CDN content server-side // to avoid CORS restrictions on the client. app.get('/api/maintenance-page', async (_req, res) => { diff --git a/packages/template-retail-react-app/app/ssr.test.js b/packages/template-retail-react-app/app/ssr.test.js index 3fa5848abe..9af0dd84b3 100644 --- a/packages/template-retail-react-app/app/ssr.test.js +++ b/packages/template-retail-react-app/app/ssr.test.js @@ -31,12 +31,18 @@ jest.mock('@salesforce/pwa-kit-runtime/utils/ssr-config', () => ({ resetPassword: { callbackURI: '/reset-password-callback', landingPath: '/reset-password' + }, + registrationVerification: { + callbackURI: '/registration-verification-callback' } }, commerceAPI: { + proxyPath: '/mobify/proxy/api', parameters: { + clientId: 'test-client-id', shortCode: 'test-shortcode', - organizationId: 'f_ecom_test_001' + organizationId: 'f_ecom_test_001', + siteId: 'RefArch' } } } @@ -63,6 +69,15 @@ jest.mock('express', () => { return mockExpress }) +jest.mock('commerce-sdk-isomorphic', () => { + const mockShopperLogin = jest.fn() + const mockHelpers = { + loginGuestUser: jest.fn(), + loginGuestUserPrivate: jest.fn() + } + return {ShopperLogin: mockShopperLogin, helpers: mockHelpers} +}) + jest.mock('jose', () => ({ createRemoteJWKSet: jest.fn(() => { return jest.fn().mockResolvedValue({ @@ -98,7 +113,14 @@ jest.mock('jose', () => ({ })) // Import only the functions we need to test -import {validateSlasCallbackToken, handleCallback} from '@salesforce/retail-react-app/app/ssr.js' +import { + validateSlasCallbackToken, + handleCallback, + getNotifyToken, + sendViaB2cCartridge, + _resetNotifyTokenCacheForTest +} from '@salesforce/retail-react-app/app/ssr.js' +import {helpers} from 'commerce-sdk-isomorphic' // Mock environment variables const originalEnv = process.env @@ -231,3 +253,186 @@ describe('handleCallback', () => { expect(res.send).toHaveBeenCalled() }) }) + +const TEST_API_PARAMS = { + clientId: 'test-client-id', + organizationId: 'f_ecom_test_001', + shortCode: 'test-shortcode', + siteId: 'RefArch' +} + +describe('getNotifyToken', () => { + beforeEach(() => { + _resetNotifyTokenCacheForTest() + delete process.env.PWA_KIT_SLAS_CLIENT_SECRET + }) + + test('fetches a new token via public client (no secret)', async () => { + helpers.loginGuestUser.mockResolvedValueOnce({access_token: 'guest-token-abc'}) + + const token = await getNotifyToken(TEST_API_PARAMS) + + expect(token).toBe('guest-token-abc') + expect(helpers.loginGuestUser).toHaveBeenCalledTimes(1) + expect(helpers.loginGuestUserPrivate).not.toHaveBeenCalled() + }) + + test('fetches a new token via private client when secret is set', async () => { + process.env.PWA_KIT_SLAS_CLIENT_SECRET = 'super-secret' + helpers.loginGuestUserPrivate.mockResolvedValueOnce({access_token: 'private-token-xyz'}) + + const token = await getNotifyToken(TEST_API_PARAMS) + + expect(token).toBe('private-token-xyz') + expect(helpers.loginGuestUserPrivate).toHaveBeenCalledTimes(1) + expect(helpers.loginGuestUser).not.toHaveBeenCalled() + }) + + test('returns cached token without making a new SLAS call', async () => { + helpers.loginGuestUser.mockResolvedValue({access_token: 'cached-token'}) + + const first = await getNotifyToken(TEST_API_PARAMS) + const second = await getNotifyToken(TEST_API_PARAMS) + + expect(first).toBe('cached-token') + expect(second).toBe('cached-token') + expect(helpers.loginGuestUser).toHaveBeenCalledTimes(1) + }) +}) + +describe('sendViaB2cCartridge', () => { + let fetchSpy + + beforeEach(() => { + _resetNotifyTokenCacheForTest() + delete process.env.PWA_KIT_SLAS_CLIENT_SECRET + helpers.loginGuestUser.mockResolvedValue({access_token: 'notify-token'}) + fetchSpy = jest.spyOn(global, 'fetch').mockImplementation(() => + Promise.resolve({ + ok: true, + json: jest.fn().mockResolvedValue({success: true}), + text: jest.fn().mockResolvedValue('') + }) + ) + }) + + afterEach(() => { + fetchSpy.mockRestore() + }) + + const mockFetchOk = (body = {success: true}) => { + fetchSpy.mockResolvedValueOnce({ + ok: true, + json: jest.fn().mockResolvedValue(body), + text: jest.fn().mockResolvedValue(JSON.stringify(body)) + }) + } + + const mockFetchError = (status, body = '') => { + fetchSpy.mockResolvedValueOnce({ + ok: false, + status, + text: jest.fn().mockResolvedValue(body) + }) + } + + test('sends passwordless-magic-link with correct payload', async () => { + mockFetchOk({success: true, data: {magicLink: 'https://example.com/magic'}}) + + const result = await sendViaB2cCartridge( + 'passwordless-magic-link', + 'user@example.com', + {magicLinkPath: '/en-US/passwordless?token=abc'}, + TEST_API_PARAMS + ) + + expect(result).toEqual({success: true, data: {magicLink: 'https://example.com/magic'}}) + const [url, opts] = fetchSpy.mock.calls[0] + expect(url).toContain('/f_ecom_test_001/sites/RefArch/notify') + const sentBody = JSON.parse(opts.body) + expect(sentBody.type).toBe('passwordless-magic-link') + expect(sentBody.recipient).toBe('user@example.com') + expect(sentBody.data.magicLinkPath).toBe('/en-US/passwordless?token=abc') + expect(opts.headers['Authorization']).toBe('Bearer notify-token') + }) + + test('sends password-reset with correct payload', async () => { + mockFetchOk() + + await sendViaB2cCartridge( + 'password-reset', + 'user@example.com', + {magicLinkPath: '/en-US/reset-password?token=xyz'}, + TEST_API_PARAMS + ) + + const sentBody = JSON.parse(fetchSpy.mock.calls[0][1].body) + expect(sentBody.type).toBe('password-reset') + }) + + test('sends otp with correct payload', async () => { + mockFetchOk() + + await sendViaB2cCartridge('otp', 'user@example.com', {token: '123456'}, TEST_API_PARAMS) + + const sentBody = JSON.parse(fetchSpy.mock.calls[0][1].body) + expect(sentBody.type).toBe('otp') + expect(sentBody.data.token).toBe('123456') + }) + + test('sends glo-access-code with correct payload', async () => { + mockFetchOk() + + await sendViaB2cCartridge( + 'glo-access-code', + 'user@example.com', + {orderNo: 'ORDER123', accessCode: 'ABCD1234'}, + TEST_API_PARAMS + ) + + const sentBody = JSON.parse(fetchSpy.mock.calls[0][1].body) + expect(sentBody.type).toBe('glo-access-code') + expect(sentBody.data.orderNo).toBe('ORDER123') + expect(sentBody.data.accessCode).toBe('ABCD1234') + }) + + test('retries with a fresh token on 401 and succeeds', async () => { + helpers.loginGuestUser + .mockResolvedValueOnce({access_token: 'stale-token'}) + .mockResolvedValueOnce({access_token: 'fresh-token'}) + // First fetch: 401 with stale token; second fetch: 200 with fresh token + fetchSpy + .mockResolvedValueOnce({ok: false, status: 401, text: jest.fn().mockResolvedValue('')}) + .mockResolvedValueOnce({ + ok: true, + json: jest.fn().mockResolvedValue({success: true}), + text: jest.fn().mockResolvedValue('') + }) + + const result = await sendViaB2cCartridge( + 'otp', + 'user@example.com', + {token: '654321'}, + TEST_API_PARAMS + ) + + expect(result).toEqual({success: true}) + expect(fetchSpy).toHaveBeenCalledTimes(2) + const [, retryOpts] = fetchSpy.mock.calls[1] + expect(retryOpts.headers['Authorization']).toBe('Bearer fresh-token') + }) + + test('throws on 5xx without clearing token cache', async () => { + mockFetchError(503, 'Service Unavailable') + + await expect( + sendViaB2cCartridge('otp', 'user@example.com', {token: '999'}, TEST_API_PARAMS) + ).rejects.toThrow('pwakit-notify returned 503') + + // Token should still be cached (503 is not 401) + helpers.loginGuestUser.mockClear() + mockFetchOk() + await sendViaB2cCartridge('otp', 'user@example.com', {token: '111'}, TEST_API_PARAMS) + expect(helpers.loginGuestUser).not.toHaveBeenCalled() + }) +}) diff --git a/packages/template-retail-react-app/cartridges/app_pwakit_base/README.md b/packages/template-retail-react-app/cartridges/app_pwakit_base/README.md index 4bba293c48..8fa44843d0 100644 --- a/packages/template-retail-react-app/cartridges/app_pwakit_base/README.md +++ b/packages/template-retail-react-app/cartridges/app_pwakit_base/README.md @@ -13,6 +13,22 @@ All emails are rendered from ISML templates in `cartridge/templates/default/emai ### Prerequisites +#### SLAS allowed redirect URIs + +Each PWA Kit SSR callback route that uses `mode: 'callback'` must be pre-registered in your SLAS client's **Allowed Redirect URIs** (Admin Center → SLAS → your client → Redirect URIs). SLAS silently rejects callback flows if the URI is not on the allowlist. + +The default callback URIs that must be registered (relative to your storefront origin): + +| Flow | Default URI | Config key | +|---|---|---| +| Passwordless login | `/passwordless-login-callback` | `app.login.passwordless.callbackURI` | +| Password reset | `/reset-password-callback` | `app.login.resetPassword.callbackURI` | +| Registration verification | `/registration-verification-callback` | `app.login.registrationVerification.callbackURI` | + +Each URI can be customized in `config/default.js`. Update the SLAS allowlist whenever a URI changes. + +--- + Install the `sfcc-ci` CLI and configure it with your instance credentials: ```bash @@ -56,13 +72,10 @@ sfcc-ci cartridge:add app_pwakit_base \ **3. Import Organization Preferences** (one-time) ```bash -sfcc-ci instance:upload \ - --instance \ - staticfiles/cartridge/impex/default/meta/system-objecttype-extensions/OrganizationPreferences.xml - -sfcc-ci instance:import \ +sfcc-ci meta:import \ --instance \ - OrganizationPreferences.xml + --meta-path cartridges/app_pwakit_base/staticfiles/cartridge/impex/default/meta \ + --directory meta ``` This registers `pwakitNotifyEnabled` and `pwakitStorefrontHosts` under **Administration → Global Preferences → Custom Preferences → PWA Kit**. diff --git a/packages/template-retail-react-app/cartridges/app_pwakit_base/cartridge/rest-apis/pwakit-notify/Notify.js b/packages/template-retail-react-app/cartridges/app_pwakit_base/cartridge/rest-apis/pwakit-notify/Notify.js index d23fdf45cc..0636e9becc 100644 --- a/packages/template-retail-react-app/cartridges/app_pwakit_base/cartridge/rest-apis/pwakit-notify/Notify.js +++ b/packages/template-retail-react-app/cartridges/app_pwakit_base/cartridge/rest-apis/pwakit-notify/Notify.js @@ -173,6 +173,10 @@ exports.notify = function () { templateName = 'email/passwordReset'; context = { magicLink: magicLink }; } else if (type === 'otp') { + // Used by the account registration / email verification flow. The `token` + // field is a short 6-8 digit SLAS TOTP (not a JWT). The ssr.js caller for + // this type has not yet been implemented in pwa-kit — see sf-next's + // otp-callback.server.ts for the reference implementation. if (!body.data.token) { RESTResponseMgr.createError( 400, diff --git a/packages/template-retail-react-app/cartridges/app_pwakit_base/cartridge/scripts/helpers/buildOrderLookupUrl.js b/packages/template-retail-react-app/cartridges/app_pwakit_base/cartridge/scripts/helpers/buildOrderLookupUrl.js index fad6d857ab..ae03161eb9 100644 --- a/packages/template-retail-react-app/cartridges/app_pwakit_base/cartridge/scripts/helpers/buildOrderLookupUrl.js +++ b/packages/template-retail-react-app/cartridges/app_pwakit_base/cartridge/scripts/helpers/buildOrderLookupUrl.js @@ -27,6 +27,10 @@ * @returns {string} Full verification URL */ function buildOrderLookupUrl(host, siteId, locale, orderNo, token) { + // The access code is intentionally included as a query param so customers can click + // directly through without manually entering it. The code alone is not sufficient to + // access an order — it must be paired with a matching email address, which is never + // present in this URL. The code is also displayed inline in the email body as a fallback. return 'https://' + host + '/' + siteId + '/' + locale + '/order-lookup/verify/' + orderNo + '?token=' + encodeURIComponent(token); } diff --git a/packages/template-retail-react-app/cartridges/app_pwakit_base/cartridge/scripts/helpers/storefrontHostProvider.js b/packages/template-retail-react-app/cartridges/app_pwakit_base/cartridge/scripts/helpers/storefrontHostProvider.js index b49ff8532f..8e32cce8bb 100644 --- a/packages/template-retail-react-app/cartridges/app_pwakit_base/cartridge/scripts/helpers/storefrontHostProvider.js +++ b/packages/template-retail-react-app/cartridges/app_pwakit_base/cartridge/scripts/helpers/storefrontHostProvider.js @@ -17,7 +17,6 @@ 'use strict'; var System = require('dw/system/System'); -var Site = require('dw/system/Site'); var Logger = require('dw/system/Logger'); var log = Logger.getLogger('pwakit-notify', 'pwakit-notify'); @@ -25,49 +24,35 @@ var log = Logger.getLogger('pwakit-notify', 'pwakit-notify'); /** * Resolves the public storefront hostname for magic-link URL construction. * - * Current implementation: validates a caller-supplied hostname against the - * comma-separated `pwakitStorefrontHosts` global preference allowlist, then - * falls back to the first configured host, then to the B2C instance hostname. + * Validates a caller-supplied hostname against the comma-separated + * `pwakitStorefrontHosts` global preference allowlist. When no callerHost is + * provided, returns the first configured host. * - * TODO: replace this module body with a single Script API call once B2C exposes - * the MRT/eCDN hostname natively, e.g.: + * Returns null when: + * - callerHost is provided but not in the allowlist (including when the allowlist is empty) + * - callerHost is not provided and the allowlist is empty or unset * - * var ComposableStorefrontMgr = require('dw/mrt/ComposableStorefrontMgr'); - * module.exports = function resolveStorefrontHost() { - * return ComposableStorefrontMgr.getStorefrontHostname(Site.getCurrent().getID()) - * || Site.getCurrent().httpsHostName; - * }; + * Callers treat a null return as "no host available" — magic-link CTA buttons + * are omitted from the email but the email is still sent (e.g. with an inline + * access code). Templates must guard all magic-link usage with + * / lookupLink equivalent. * - * The export signature (requestedHost: string|null) → string|null is intentionally - * preserved so callers require no changes after the swap. The future implementation - * ignores `requestedHost` and the null return path becomes unreachable. - * - * @param {string|null} requestedHost Hostname supplied by the API caller (no protocol, + * @param {string|null} callerHost Hostname supplied by the API caller (no protocol, * no trailing slash). Pass null when calling from a server-side hook that has no * incoming host to validate (e.g. sendOrderAccessCode). - * @returns {string|null} Validated hostname to use in magic-link URLs, or null when - * requestedHost was supplied but is not in the allowlist (caller should reject with 400). + * @returns {string|null} Validated hostname, or null if unresolvable. */ -function resolveStorefrontHost(requestedHost) { +function resolveStorefrontHost(callerHost) { var allowedHosts = getAllowedHosts(); - if (requestedHost && requestedHost.trim()) { - var normalizedHost = requestedHost.trim().replace(/^https?:\/\//, '').replace(/\/$/, ''); - if (allowedHosts.length === 0) { - log.warn( - 'pwakitStorefrontHosts allowlist is not configured. Accepting host from request ({0}). ' + - 'Configure pwakitStorefrontHosts in Business Manager to restrict allowed hosts.', - normalizedHost - ); - return normalizedHost; - } + if (callerHost && callerHost.trim()) { + var normalizedHost = callerHost.trim().replace(/^https?:\/\//, '').replace(/\/$/, ''); if (allowedHosts.indexOf(normalizedHost.toLowerCase()) !== -1) { return normalizedHost.toLowerCase(); } - log.error( - 'Rejecting magic-link request: host "{0}" is not in the pwakitStorefrontHosts allowlist ({1})', - normalizedHost, - allowedHosts.join(', ') + log.warn( + 'storefrontHostProvider: caller-supplied host "{0}" is not in the pwakitStorefrontHosts allowlist — email sent without magic link.', + normalizedHost ); return null; } @@ -77,11 +62,10 @@ function resolveStorefrontHost(requestedHost) { } log.warn( - 'pwakitStorefrontHosts is not configured. Magic-link emails will use the B2C instance hostname ({0}), ' + - 'which is incorrect for headless storefronts.', - Site.getCurrent().httpsHostName + 'storefrontHostProvider: no callerHost provided and pwakitStorefrontHosts is not configured — email sent without magic link. ' + + 'Configure pwakitStorefrontHosts in Business Manager > Global Preferences > Custom Preferences > pwakit.' ); - return Site.getCurrent().httpsHostName; + return null; } function getAllowedHosts() { diff --git a/packages/template-retail-react-app/cartridges/app_pwakit_base/cartridge/templates/resources/email.properties b/packages/template-retail-react-app/cartridges/app_pwakit_base/cartridge/templates/resources/email.properties index 98f2be92da..5fb0d64a7e 100644 --- a/packages/template-retail-react-app/cartridges/app_pwakit_base/cartridge/templates/resources/email.properties +++ b/packages/template-retail-react-app/cartridges/app_pwakit_base/cartridge/templates/resources/email.properties @@ -3,7 +3,6 @@ email.allRightsReserved=All rights reserved. # passwordlessLogin passwordlessLogin.subject=Your Magic Sign-In Link -passwordlessLogin.pageTitle=Your Sign-In Link passwordlessLogin.heading=Sign in to your account passwordlessLogin.instructions=Click the link below to sign in. This link expires in 20 minutes and can only be used once. passwordlessLogin.accessCodeLabel=Your sign-in code: @@ -13,7 +12,6 @@ passwordlessLogin.copyUrl=Or copy this URL into your browser: # passwordReset passwordReset.subject=Reset Your Password -passwordReset.pageTitle=Reset your password passwordReset.heading=Reset your password passwordReset.instructions=Click the button below to set a new password. This link expires in 15 minutes. passwordReset.buttonText=Reset password @@ -22,14 +20,12 @@ passwordReset.copyUrl=Or copy and paste this link into your browser: # registrationVerification registrationVerification.subject=Your Verification Code -registrationVerification.pageTitle=Your verification code registrationVerification.heading=Your verification code registrationVerification.instructions=Use the code below to verify your identity. It expires in 15 minutes. registrationVerification.disclaimer=If you did not request this code, you can safely ignore this email. # guestOrderLookup guestOrderLookup.subject=Your Order Access Code -guestOrderLookup.pageTitle=Your order access code guestOrderLookup.heading=Your order access code guestOrderLookup.instructionsPrefix=Use the code below to view your order guestOrderLookup.buttonText=View Order diff --git a/packages/template-retail-react-app/config/default.js b/packages/template-retail-react-app/config/default.js index f50e6a5c3a..f453c4d667 100644 --- a/packages/template-retail-react-app/config/default.js +++ b/packages/template-retail-react-app/config/default.js @@ -136,6 +136,9 @@ module.exports = { mode: 'callback', landingPath: '/reset-password-landing', callbackURI: '/reset-password-callback' + }, + registrationVerification: { + callbackURI: '/registration-verification-callback' } }, defaultSite: 'RefArchGlobal', diff --git a/packages/template-retail-react-app/package.json b/packages/template-retail-react-app/package.json index 22317b55b8..8f98ceea29 100644 --- a/packages/template-retail-react-app/package.json +++ b/packages/template-retail-react-app/package.json @@ -11,6 +11,8 @@ "scripts": { "analyze-build": "cross-env MOBIFY_ANALYZE=true npm run build", "build": "npm run build-translations && pwa-kit-dev build", + "build:cartridge": "cd cartridges && zip -r app_pwakit_base.zip app_pwakit_base/", + "deploy:cartridge": "npm run build:cartridge && sfcc-ci code:deploy cartridges/app_pwakit_base.zip && sfcc-ci code:activate", "build-translations": "npm run extract-default-translations && npm run compile-translations && npm run compile-translations:pseudo", "compile-translations": "node ./scripts/translations/compile-folder.js translations", "compile-translations:pseudo": "node ./scripts/translations/compile-pseudo.js translations/en-US.json", From af5f1c6458e3f7f4f1bcc6dde45cff9e8dd32d0f Mon Sep 17 00:00:00 2001 From: Ben Chypak Date: Thu, 17 Sep 2026 11:25:54 -0700 Subject: [PATCH 19/55] chore(cartridge): switch deploy tooling from sfcc-ci to b2c-cli Replace the old zip+sfcc-ci workflow with b2c code deploy. Add the Eclipse .project marker that b2c requires for cartridge discovery, and pass --config dw.json so credentials resolve from the package root without needing to cd into the cartridges directory. Update README to match: b2c CLI install, flat dw.json requirement, clean single First-time setup section, and b2c-based redeploy steps. dw.json and cartridges/dw.json are added to .gitignore; the zip artifact is removed since b2c handles archiving internally. --- packages/template-retail-react-app/.gitignore | 3 +- .../cartridges/app_pwakit_base/.project | 10 +++++ .../cartridges/app_pwakit_base/README.md | 44 +++++++++---------- .../template-retail-react-app/package.json | 3 +- 4 files changed, 35 insertions(+), 25 deletions(-) create mode 100644 packages/template-retail-react-app/cartridges/app_pwakit_base/.project diff --git a/packages/template-retail-react-app/.gitignore b/packages/template-retail-react-app/.gitignore index 72f70e7f3e..057be4a403 100644 --- a/packages/template-retail-react-app/.gitignore +++ b/packages/template-retail-react-app/.gitignore @@ -26,7 +26,8 @@ tmp/ node_modules # Generated files -cartridges/app_pwakit_base.zip +dw.json +cartridges/dw.json app/cache-hash-manifest.json app/loader-cache-hash-manifest.json diff --git a/packages/template-retail-react-app/cartridges/app_pwakit_base/.project b/packages/template-retail-react-app/cartridges/app_pwakit_base/.project new file mode 100644 index 0000000000..4c48d3613f --- /dev/null +++ b/packages/template-retail-react-app/cartridges/app_pwakit_base/.project @@ -0,0 +1,10 @@ + + + app_pwakit_base + + + + + com.demandware.studio.core.beehiveNature + + diff --git a/packages/template-retail-react-app/cartridges/app_pwakit_base/README.md b/packages/template-retail-react-app/cartridges/app_pwakit_base/README.md index 8fa44843d0..f8c2ee7dd8 100644 --- a/packages/template-retail-react-app/cartridges/app_pwakit_base/README.md +++ b/packages/template-retail-react-app/cartridges/app_pwakit_base/README.md @@ -29,24 +29,33 @@ Each URI can be customized in `config/default.js`. Update the SLAS allowlist whe --- -Install the `sfcc-ci` CLI and configure it with your instance credentials: +Install the `b2c` CLI: ```bash -npm install -g sfcc-ci -sfcc-ci auth:login .sandbox.us01.dx.commercecloud.salesforce.com +npm install -g @salesforce/b2c-cli ``` -Or configure via `dw.json` at the repo root: +Create a **flat** `dw.json` in `packages/template-retail-react-app/`: ```json { - "hostname": ".sandbox.us01.dx.commercecloud.salesforce.com", + "hostname": ".demandware.net", "username": "", "password": "", - "code-version": "" + "code-version": "", + "account-manager-host": "", + "client-id": "" } ``` +> **Note:** `b2c code deploy` requires the flat single-object format for WebDAV authentication. The multi-config `configs` array format works for `b2c setup inspect` but not for actual deployments (known issue in `b2c-cli` ≤ 1.23.1). + +Verify the CLI resolves your instance correctly: + +```bash +b2c setup inspect +``` + --- ### First-time setup @@ -54,20 +63,12 @@ Or configure via `dw.json` at the repo root: **1. Deploy the cartridge** ```bash -cd packages/template-retail-react-app/cartridges -zip -r app_pwakit_base.zip app_pwakit_base/ -sfcc-ci code:deploy --instance --code-version app_pwakit_base.zip +npm run deploy:cartridge ``` -**2. Add to cartridge path** (one-time) +**2. Add to cartridge path** (one-time, via Business Manager) -```bash -sfcc-ci cartridge:add app_pwakit_base \ - --instance \ - --position before \ - --target app_storefront_base \ - --siteid -``` +In Business Manager → **Administration → Sites → Manage Sites → → Settings**, add `app_pwakit_base` to the cartridge path before `app_storefront_base`. **3. Import Organization Preferences** (one-time) @@ -83,19 +84,18 @@ This registers `pwakitNotifyEnabled` and `pwakitStorefrontHosts` under **Adminis **4. Activate the code version** ```bash -sfcc-ci code:activate --instance +b2c code activate --config packages/template-retail-react-app/dw.json ``` -`code:activate` triggers SFCC's hook and Custom API discovery. There is no need to manually deactivate and reactivate through Business Manager. +Activation triggers SFCC's hook and Custom API discovery. --- ### Redeploying (subsequent updates) ```bash -zip -r app_pwakit_base.zip app_pwakit_base/ -sfcc-ci code:deploy --instance --code-version app_pwakit_base.zip -sfcc-ci code:activate --instance +npm run deploy:cartridge +b2c code activate --config packages/template-retail-react-app/dw.json ``` --- diff --git a/packages/template-retail-react-app/package.json b/packages/template-retail-react-app/package.json index 8f98ceea29..f05516bcd2 100644 --- a/packages/template-retail-react-app/package.json +++ b/packages/template-retail-react-app/package.json @@ -11,8 +11,7 @@ "scripts": { "analyze-build": "cross-env MOBIFY_ANALYZE=true npm run build", "build": "npm run build-translations && pwa-kit-dev build", - "build:cartridge": "cd cartridges && zip -r app_pwakit_base.zip app_pwakit_base/", - "deploy:cartridge": "npm run build:cartridge && sfcc-ci code:deploy cartridges/app_pwakit_base.zip && sfcc-ci code:activate", + "deploy:cartridge": "b2c code deploy cartridges -c app_pwakit_base --config dw.json", "build-translations": "npm run extract-default-translations && npm run compile-translations && npm run compile-translations:pseudo", "compile-translations": "node ./scripts/translations/compile-folder.js translations", "compile-translations:pseudo": "node ./scripts/translations/compile-pseudo.js translations/en-US.json", From 61324d587cc812ecf758d5b520444e0d317cf2a9 Mon Sep 17 00:00:00 2001 From: Ben Chypak Date: Thu, 17 Sep 2026 13:54:55 -0700 Subject: [PATCH 20/55] chore(cartridge): add --activate flag to deploy:cartridge script --- .../cartridges/app_pwakit_base/README.md | 3 ++- packages/template-retail-react-app/package.json | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/template-retail-react-app/cartridges/app_pwakit_base/README.md b/packages/template-retail-react-app/cartridges/app_pwakit_base/README.md index f8c2ee7dd8..84ddd9e12d 100644 --- a/packages/template-retail-react-app/cartridges/app_pwakit_base/README.md +++ b/packages/template-retail-react-app/cartridges/app_pwakit_base/README.md @@ -44,7 +44,8 @@ Create a **flat** `dw.json` in `packages/template-retail-react-app/`: "password": "", "code-version": "", "account-manager-host": "", - "client-id": "" + "client-id": "", + "client-secret": "" } ``` diff --git a/packages/template-retail-react-app/package.json b/packages/template-retail-react-app/package.json index f05516bcd2..2280cca316 100644 --- a/packages/template-retail-react-app/package.json +++ b/packages/template-retail-react-app/package.json @@ -11,7 +11,7 @@ "scripts": { "analyze-build": "cross-env MOBIFY_ANALYZE=true npm run build", "build": "npm run build-translations && pwa-kit-dev build", - "deploy:cartridge": "b2c code deploy cartridges -c app_pwakit_base --config dw.json", + "deploy:cartridge": "b2c code deploy cartridges -c app_pwakit_base --config dw.json --activate", "build-translations": "npm run extract-default-translations && npm run compile-translations && npm run compile-translations:pseudo", "compile-translations": "node ./scripts/translations/compile-folder.js translations", "compile-translations:pseudo": "node ./scripts/translations/compile-pseudo.js translations/en-US.json", From c36fa8c746b366b9d5df4dcacf02f2656bccf9e6 Mon Sep 17 00:00:00 2001 From: Ben Chypak Date: Thu, 17 Sep 2026 14:23:52 -0700 Subject: [PATCH 21/55] chore(cartridge): use --reload instead of --activate to force SCAPI custom API re-scan --- packages/template-retail-react-app/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/template-retail-react-app/package.json b/packages/template-retail-react-app/package.json index 2280cca316..b19c245d4a 100644 --- a/packages/template-retail-react-app/package.json +++ b/packages/template-retail-react-app/package.json @@ -11,7 +11,7 @@ "scripts": { "analyze-build": "cross-env MOBIFY_ANALYZE=true npm run build", "build": "npm run build-translations && pwa-kit-dev build", - "deploy:cartridge": "b2c code deploy cartridges -c app_pwakit_base --config dw.json --activate", + "deploy:cartridge": "b2c code deploy cartridges -c app_pwakit_base --config dw.json --reload", "build-translations": "npm run extract-default-translations && npm run compile-translations && npm run compile-translations:pseudo", "compile-translations": "node ./scripts/translations/compile-folder.js translations", "compile-translations:pseudo": "node ./scripts/translations/compile-pseudo.js translations/en-US.json", From 8d31ea1b3b28d51ff55dd2673cdb0a05d32cd608 Mon Sep 17 00:00:00 2001 From: Ben Chypak Date: Thu, 17 Sep 2026 14:32:25 -0700 Subject: [PATCH 22/55] =?UTF-8?q?fix(ssr):=20revert=20pwakit-notify=20URL?= =?UTF-8?q?=20to=20org-level=20path=20=E2=80=94=20/sites/{siteId}=20broke?= =?UTF-8?q?=20SCAPI=20routing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The code review mistakenly added /sites/{siteId} to the pwakit-notify URL path, treating it as a site-scoped SCAPI endpoint. The API is org-level — siteId is a query param only (as defined in schema.yaml). SCAPI 404s the site-level path form. Restores the working URL: /custom/pwakit-notify/v1/organizations/{orgId}/notify?siteId={siteId} --- packages/template-retail-react-app/app/ssr.js | 4 ++-- packages/template-retail-react-app/app/ssr.test.js | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/template-retail-react-app/app/ssr.js b/packages/template-retail-react-app/app/ssr.js index fa12adb30d..2a30c20a34 100644 --- a/packages/template-retail-react-app/app/ssr.js +++ b/packages/template-retail-react-app/app/ssr.js @@ -181,7 +181,7 @@ const options = { // Set this to false if using a SLAS public client // When setting this to true, make sure to also set the PWA_KIT_SLAS_CLIENT_SECRET // environment variable as this endpoint will return HTTP 501 if it is not set - useSLASPrivateClient: false, + useSLASPrivateClient: true, // To extend the SLAS private-client proxy allow-list, supply // `slasPrivateClientAllowList`. See the built-in list in pwa-kit-runtime @@ -292,7 +292,7 @@ export async function sendViaB2cCartridge(type, recipient, data, apiParams) { }` const url = `${proxy}/custom/pwakit-notify/v1/organizations/${encodeURIComponent( organizationId - )}/sites/${encodeURIComponent(siteId)}/notify?siteId=${encodeURIComponent(siteId)}` + )}/notify?siteId=${encodeURIComponent(siteId)}` const requestBody = JSON.stringify({ type, recipient, diff --git a/packages/template-retail-react-app/app/ssr.test.js b/packages/template-retail-react-app/app/ssr.test.js index 9af0dd84b3..931c41a2e1 100644 --- a/packages/template-retail-react-app/app/ssr.test.js +++ b/packages/template-retail-react-app/app/ssr.test.js @@ -348,7 +348,7 @@ describe('sendViaB2cCartridge', () => { expect(result).toEqual({success: true, data: {magicLink: 'https://example.com/magic'}}) const [url, opts] = fetchSpy.mock.calls[0] - expect(url).toContain('/f_ecom_test_001/sites/RefArch/notify') + expect(url).toContain('/f_ecom_test_001/notify') const sentBody = JSON.parse(opts.body) expect(sentBody.type).toBe('passwordless-magic-link') expect(sentBody.recipient).toBe('user@example.com') From b191898b2e7abf95adfda370fb7fb05567ef872f Mon Sep 17 00:00:00 2001 From: Ben Chypak Date: Thu, 17 Sep 2026 14:49:18 -0700 Subject: [PATCH 23/55] feat: convert pwakit-notify to site-scoped SCAPI custom API Move siteId from query parameter to URL path in the pwakit-notify Custom REST API so SCAPI establishes site context automatically before invoking the cartridge. This makes Site.getCurrent() reliable inside Notify.js and aligns the endpoint with SCAPI's site-scoped routing convention used by built-in APIs. - schema.yaml: path changed from /notify to /sites/{siteId}/notify; siteId moved from query to path parameter - ssr.js: URL updated to include /sites/{siteId}/ in path, removing the siteId query param - ssr.test.js: URL assertion updated to match new site-scoped path - README: deployment docs updated; --reload makes separate activation step unnecessary - config/default.js, _app-config: sandbox config (RefArch, private SLAS client) --- .../app/components/_app-config/index.jsx | 2 +- packages/template-retail-react-app/app/ssr.js | 2 +- packages/template-retail-react-app/app/ssr.test.js | 2 +- .../cartridges/app_pwakit_base/README.md | 9 +++------ .../cartridge/rest-apis/pwakit-notify/schema.yaml | 4 ++-- packages/template-retail-react-app/config/default.js | 6 +++--- 6 files changed, 11 insertions(+), 14 deletions(-) diff --git a/packages/template-retail-react-app/app/components/_app-config/index.jsx b/packages/template-retail-react-app/app/components/_app-config/index.jsx index 4c1588cd83..3407d670a9 100644 --- a/packages/template-retail-react-app/app/components/_app-config/index.jsx +++ b/packages/template-retail-react-app/app/components/_app-config/index.jsx @@ -118,7 +118,7 @@ const AppConfig = ({children, locals = {}}) => { defaultDnt={DEFAULT_DNT_STATE} // Set 'enablePWAKitPrivateClient' to true to use SLAS private client login flows. // Make sure to also enable useSLASPrivateClient in ssr.js when enabling this setting. - enablePWAKitPrivateClient={false} + enablePWAKitPrivateClient={true} privateClientProxyEndpoint={slasPrivateClientProxyEndpoint} publicClientProxyEndpoint={slasPublicClientProxyEndpoint} // Uncomment 'hybridAuthEnabled' if the current site has Hybrid Auth enabled. Do NOT set this flag for hybrid storefronts using Plugin SLAS. diff --git a/packages/template-retail-react-app/app/ssr.js b/packages/template-retail-react-app/app/ssr.js index 2a30c20a34..bea14ffc64 100644 --- a/packages/template-retail-react-app/app/ssr.js +++ b/packages/template-retail-react-app/app/ssr.js @@ -292,7 +292,7 @@ export async function sendViaB2cCartridge(type, recipient, data, apiParams) { }` const url = `${proxy}/custom/pwakit-notify/v1/organizations/${encodeURIComponent( organizationId - )}/notify?siteId=${encodeURIComponent(siteId)}` + )}/sites/${encodeURIComponent(siteId)}/notify` const requestBody = JSON.stringify({ type, recipient, diff --git a/packages/template-retail-react-app/app/ssr.test.js b/packages/template-retail-react-app/app/ssr.test.js index 931c41a2e1..9af0dd84b3 100644 --- a/packages/template-retail-react-app/app/ssr.test.js +++ b/packages/template-retail-react-app/app/ssr.test.js @@ -348,7 +348,7 @@ describe('sendViaB2cCartridge', () => { expect(result).toEqual({success: true, data: {magicLink: 'https://example.com/magic'}}) const [url, opts] = fetchSpy.mock.calls[0] - expect(url).toContain('/f_ecom_test_001/notify') + expect(url).toContain('/f_ecom_test_001/sites/RefArch/notify') const sentBody = JSON.parse(opts.body) expect(sentBody.type).toBe('passwordless-magic-link') expect(sentBody.recipient).toBe('user@example.com') diff --git a/packages/template-retail-react-app/cartridges/app_pwakit_base/README.md b/packages/template-retail-react-app/cartridges/app_pwakit_base/README.md index 84ddd9e12d..83cf4e36cf 100644 --- a/packages/template-retail-react-app/cartridges/app_pwakit_base/README.md +++ b/packages/template-retail-react-app/cartridges/app_pwakit_base/README.md @@ -84,11 +84,7 @@ This registers `pwakitNotifyEnabled` and `pwakitStorefrontHosts` under **Adminis **4. Activate the code version** -```bash -b2c code activate --config packages/template-retail-react-app/dw.json -``` - -Activation triggers SFCC's hook and Custom API discovery. +The `deploy:cartridge` script uses `--reload`, which handles activation automatically. No separate activation step is needed after the first deploy. --- @@ -96,9 +92,10 @@ Activation triggers SFCC's hook and Custom API discovery. ```bash npm run deploy:cartridge -b2c code activate --config packages/template-retail-react-app/dw.json ``` +The `deploy:cartridge` script uses `--reload`, which deactivates and re-activates the code version in one step. SFCC rescans for Custom REST APIs and hooks on every (re-)activation, so a separate `b2c code activate` call is not needed. + --- ### Verifying the deployment with Claude Code diff --git a/packages/template-retail-react-app/cartridges/app_pwakit_base/cartridge/rest-apis/pwakit-notify/schema.yaml b/packages/template-retail-react-app/cartridges/app_pwakit_base/cartridge/rest-apis/pwakit-notify/schema.yaml index 907d7f60b5..c89d8ecffd 100644 --- a/packages/template-retail-react-app/cartridges/app_pwakit_base/cartridge/rest-apis/pwakit-notify/schema.yaml +++ b/packages/template-retail-react-app/cartridges/app_pwakit_base/cartridge/rest-apis/pwakit-notify/schema.yaml @@ -12,12 +12,12 @@ components: scopes: c_pwakit_notify: Send transactional notifications via PWA Kit paths: - /notify: + /sites/{siteId}/notify: post: summary: Send a transactional notification operationId: notify parameters: - - in: query + - in: path name: siteId required: true schema: diff --git a/packages/template-retail-react-app/config/default.js b/packages/template-retail-react-app/config/default.js index f453c4d667..adeb5e6d64 100644 --- a/packages/template-retail-react-app/config/default.js +++ b/packages/template-retail-react-app/config/default.js @@ -141,7 +141,7 @@ module.exports = { callbackURI: '/registration-verification-callback' } }, - defaultSite: 'RefArchGlobal', + defaultSite: 'RefArch', siteAliases: { RefArch: 'us', RefArchGlobal: 'global' @@ -153,7 +153,7 @@ module.exports = { clientId: '11deeece-0b48-4b60-bf1b-d96649faec43', organizationId: 'f_ecom_zysg_001', shortCode: 'sandbox-001', - siteId: 'RefArchGlobal' + siteId: 'RefArch' } // Optional: Set the domain for auth cookies to share them across subdomains. // If not set, cookies default to the current host. @@ -194,7 +194,7 @@ module.exports = { }, storeLocatorEnabled: true, guestOrderLookup: { - enabled: false, + enabled: true, orderNumberRegex: '^[a-zA-Z0-9-]{6,32}$', requestCodeThrottle: { windowMs: 60000, From 04eb018ba6b0c2cc68ef4e7556463543180fe157 Mon Sep 17 00:00:00 2001 From: Ben Chypak Date: Thu, 17 Sep 2026 14:54:45 -0700 Subject: [PATCH 24/55] =?UTF-8?q?docs:=20update=20CHANGELOG=20=E2=80=94=20?= =?UTF-8?q?b2c-cli=20deploy,=20site-scoped=20URL,=20c=5Fpwakit=5Fnotify=20?= =?UTF-8?q?scope?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/template-retail-react-app/CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/template-retail-react-app/CHANGELOG.md b/packages/template-retail-react-app/CHANGELOG.md index 4242676c36..0ef064a50d 100644 --- a/packages/template-retail-react-app/CHANGELOG.md +++ b/packages/template-retail-react-app/CHANGELOG.md @@ -1,6 +1,6 @@ ## Unreleased -- [Feature] Add `app_pwakit_base` B2C cartridge for transactional email delivery via ISML templates and `dw/net/Mail`. GLO access-code emails use the `sfcc.app.order.sendOrderAccessCode` hook; passwordless login, OTP verification, and password reset use the `pwakit-notify` Custom REST API. Deploy with `sfcc-ci code:deploy` + `code:activate` (activating registers the hook and SCAPI endpoint), then import `OrganizationPreferences.xml` to enable the `pwakitNotifyEnabled` and `pwakitStorefrontHosts` preferences. See `cartridges/app_pwakit_base/README.md` for full setup steps. [#4012](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/4012) +- [Feature] Add `app_pwakit_base` B2C cartridge for transactional email delivery via ISML templates and `dw/net/Mail`. GLO access-code emails use the `sfcc.app.order.sendOrderAccessCode` hook; passwordless login, OTP verification, and password reset use the `pwakit-notify` Custom REST API (site-scoped: `POST .../organizations/{orgId}/sites/{siteId}/notify`, guarded by the `c_pwakit_notify` SLAS scope). Deploy with `npm run deploy:cartridge` from `packages/template-retail-react-app` (uses `b2c-cli` with `--reload` to upload the cartridge and re-register the SCAPI endpoint in one step), then import `OrganizationPreferences.xml` to enable the `pwakitNotifyEnabled` and `pwakitStorefrontHosts` preferences, and add `c_pwakit_notify` to your SLAS client's Allowed Scopes in Account Manager. See `cartridges/app_pwakit_base/README.md` for full setup steps. [#4012](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/4012) - [Bugfix] Fix GLO `/api/order-lookup/verify` and related server endpoints returning 401 when `enablePWAKitPrivateClient` is enabled. The `cc-nx-g_` cookie stores the SLAS refresh token, not the access token — the access token is never stored in any cookie. All GLO server endpoints now read the SLAS JWT from the `Authorization` header sent by the client (via `useAccessToken().getTokenWhenReady()`), which works correctly in both public and private client modes. [#4012](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/4012) ## v10.3.0-dev (Aug 12, 2026) From 9963fd137276522b2406247660f0ac6c20b63e43 Mon Sep 17 00:00:00 2001 From: Ben Chypak Date: Thu, 17 Sep 2026 15:28:29 -0700 Subject: [PATCH 25/55] docs: clarify c_pwakit_notify must be a default scope, not just allowed --- packages/template-retail-react-app/CHANGELOG.md | 2 +- .../cartridges/app_pwakit_base/README.md | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/template-retail-react-app/CHANGELOG.md b/packages/template-retail-react-app/CHANGELOG.md index 0ef064a50d..a40ec25aae 100644 --- a/packages/template-retail-react-app/CHANGELOG.md +++ b/packages/template-retail-react-app/CHANGELOG.md @@ -1,6 +1,6 @@ ## Unreleased -- [Feature] Add `app_pwakit_base` B2C cartridge for transactional email delivery via ISML templates and `dw/net/Mail`. GLO access-code emails use the `sfcc.app.order.sendOrderAccessCode` hook; passwordless login, OTP verification, and password reset use the `pwakit-notify` Custom REST API (site-scoped: `POST .../organizations/{orgId}/sites/{siteId}/notify`, guarded by the `c_pwakit_notify` SLAS scope). Deploy with `npm run deploy:cartridge` from `packages/template-retail-react-app` (uses `b2c-cli` with `--reload` to upload the cartridge and re-register the SCAPI endpoint in one step), then import `OrganizationPreferences.xml` to enable the `pwakitNotifyEnabled` and `pwakitStorefrontHosts` preferences, and add `c_pwakit_notify` to your SLAS client's Allowed Scopes in Account Manager. See `cartridges/app_pwakit_base/README.md` for full setup steps. [#4012](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/4012) +- [Feature] Add `app_pwakit_base` B2C cartridge for transactional email delivery via ISML templates and `dw/net/Mail`. GLO access-code emails use the `sfcc.app.order.sendOrderAccessCode` hook; passwordless login, OTP verification, and password reset use the `pwakit-notify` Custom REST API (site-scoped: `POST .../organizations/{orgId}/sites/{siteId}/notify`, guarded by the `c_pwakit_notify` SLAS scope). Deploy with `npm run deploy:cartridge` from `packages/template-retail-react-app` (uses `b2c-cli` with `--reload` to upload the cartridge and re-register the SCAPI endpoint in one step), then import `OrganizationPreferences.xml` to enable the `pwakitNotifyEnabled` and `pwakitStorefrontHosts` preferences, and add `c_pwakit_notify` to your SLAS client's **default Scopes** (not just Allowed Scopes) in Account Manager — the `commerce-sdk-isomorphic` guest-login helpers don't accept a `scope` parameter, so the scope must be configured as a default on the client. See `cartridges/app_pwakit_base/README.md` for full setup steps. [#4012](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/4012) - [Bugfix] Fix GLO `/api/order-lookup/verify` and related server endpoints returning 401 when `enablePWAKitPrivateClient` is enabled. The `cc-nx-g_` cookie stores the SLAS refresh token, not the access token — the access token is never stored in any cookie. All GLO server endpoints now read the SLAS JWT from the `Authorization` header sent by the client (via `useAccessToken().getTokenWhenReady()`), which works correctly in both public and private client modes. [#4012](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/4012) ## v10.3.0-dev (Aug 12, 2026) diff --git a/packages/template-retail-react-app/cartridges/app_pwakit_base/README.md b/packages/template-retail-react-app/cartridges/app_pwakit_base/README.md index 83cf4e36cf..33673f97c5 100644 --- a/packages/template-retail-react-app/cartridges/app_pwakit_base/README.md +++ b/packages/template-retail-react-app/cartridges/app_pwakit_base/README.md @@ -129,9 +129,9 @@ Both public and private SLAS client setups are supported: In [Account Manager](https://account.demandware.com) → **API Client**, find the client whose `clientId` matches `config/default.js` → `commerceAPI.parameters.clientId`. -Under **Allowed Scopes**, add: `c_pwakit_notify` +Under **Scopes** (the default scopes list, not just the allowed scopes), add: `c_pwakit_notify` -This applies to both public and private clients. +Adding it to the default scopes list ensures every guest token issued for this client includes it automatically. This applies to both public and private clients. The `commerce-sdk-isomorphic` guest-login helpers do not accept a `scope` parameter, so the scope must be configured as a default on the client — requesting it at call time is not possible through the SDK. ### 2. (Private client only) Set the client secret on the SSR server From 276feb63f84f33e915b6d42f090c6ae5c967e9e0 Mon Sep 17 00:00:00 2001 From: Ben Chypak Date: Thu, 17 Sep 2026 15:43:59 -0700 Subject: [PATCH 26/55] revert: restore org-scoped pwakit-notify endpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Site-scoped Custom REST API dispatch (/sites/{siteId}/notify path in schema) causes "Custom API request couldn't be translated" in B2C's ScapiDispatcherServlet — the endpoint registers correctly but the platform cannot dispatch site-scoped custom API calls to cartridge scripts. Org-scoped with siteId as a query parameter is the supported pattern and matches how sfnext-notify works on this platform. Reverts schema.yaml path back to /notify with siteId as query param, ssr.js URL back to .../notify?siteId=..., test assertion to match, and CHANGELOG/README to reflect org-scoped design. --- packages/template-retail-react-app/CHANGELOG.md | 2 +- packages/template-retail-react-app/app/ssr.js | 2 +- packages/template-retail-react-app/app/ssr.test.js | 2 +- .../cartridge/rest-apis/pwakit-notify/schema.yaml | 6 +++--- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/template-retail-react-app/CHANGELOG.md b/packages/template-retail-react-app/CHANGELOG.md index a40ec25aae..7dbe9825e3 100644 --- a/packages/template-retail-react-app/CHANGELOG.md +++ b/packages/template-retail-react-app/CHANGELOG.md @@ -1,6 +1,6 @@ ## Unreleased -- [Feature] Add `app_pwakit_base` B2C cartridge for transactional email delivery via ISML templates and `dw/net/Mail`. GLO access-code emails use the `sfcc.app.order.sendOrderAccessCode` hook; passwordless login, OTP verification, and password reset use the `pwakit-notify` Custom REST API (site-scoped: `POST .../organizations/{orgId}/sites/{siteId}/notify`, guarded by the `c_pwakit_notify` SLAS scope). Deploy with `npm run deploy:cartridge` from `packages/template-retail-react-app` (uses `b2c-cli` with `--reload` to upload the cartridge and re-register the SCAPI endpoint in one step), then import `OrganizationPreferences.xml` to enable the `pwakitNotifyEnabled` and `pwakitStorefrontHosts` preferences, and add `c_pwakit_notify` to your SLAS client's **default Scopes** (not just Allowed Scopes) in Account Manager — the `commerce-sdk-isomorphic` guest-login helpers don't accept a `scope` parameter, so the scope must be configured as a default on the client. See `cartridges/app_pwakit_base/README.md` for full setup steps. [#4012](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/4012) +- [Feature] Add `app_pwakit_base` B2C cartridge for transactional email delivery via ISML templates and `dw/net/Mail`. GLO access-code emails use the `sfcc.app.order.sendOrderAccessCode` hook; passwordless login, OTP verification, and password reset use the `pwakit-notify` Custom REST API (`POST .../organizations/{orgId}/notify?siteId={siteId}`, guarded by the `c_pwakit_notify` SLAS scope). Deploy with `npm run deploy:cartridge` from `packages/template-retail-react-app` (uses `b2c-cli` with `--reload` to upload the cartridge and re-register the SCAPI endpoint in one step), then import `OrganizationPreferences.xml` to enable the `pwakitNotifyEnabled` and `pwakitStorefrontHosts` preferences, and add `c_pwakit_notify` to your SLAS client's **default Scopes** (not just Allowed Scopes) in Account Manager — the `commerce-sdk-isomorphic` guest-login helpers don't accept a `scope` parameter, so the scope must be configured as a default on the client. See `cartridges/app_pwakit_base/README.md` for full setup steps. [#4012](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/4012) - [Bugfix] Fix GLO `/api/order-lookup/verify` and related server endpoints returning 401 when `enablePWAKitPrivateClient` is enabled. The `cc-nx-g_` cookie stores the SLAS refresh token, not the access token — the access token is never stored in any cookie. All GLO server endpoints now read the SLAS JWT from the `Authorization` header sent by the client (via `useAccessToken().getTokenWhenReady()`), which works correctly in both public and private client modes. [#4012](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/4012) ## v10.3.0-dev (Aug 12, 2026) diff --git a/packages/template-retail-react-app/app/ssr.js b/packages/template-retail-react-app/app/ssr.js index bea14ffc64..2a30c20a34 100644 --- a/packages/template-retail-react-app/app/ssr.js +++ b/packages/template-retail-react-app/app/ssr.js @@ -292,7 +292,7 @@ export async function sendViaB2cCartridge(type, recipient, data, apiParams) { }` const url = `${proxy}/custom/pwakit-notify/v1/organizations/${encodeURIComponent( organizationId - )}/sites/${encodeURIComponent(siteId)}/notify` + )}/notify?siteId=${encodeURIComponent(siteId)}` const requestBody = JSON.stringify({ type, recipient, diff --git a/packages/template-retail-react-app/app/ssr.test.js b/packages/template-retail-react-app/app/ssr.test.js index 9af0dd84b3..931c41a2e1 100644 --- a/packages/template-retail-react-app/app/ssr.test.js +++ b/packages/template-retail-react-app/app/ssr.test.js @@ -348,7 +348,7 @@ describe('sendViaB2cCartridge', () => { expect(result).toEqual({success: true, data: {magicLink: 'https://example.com/magic'}}) const [url, opts] = fetchSpy.mock.calls[0] - expect(url).toContain('/f_ecom_test_001/sites/RefArch/notify') + expect(url).toContain('/f_ecom_test_001/notify') const sentBody = JSON.parse(opts.body) expect(sentBody.type).toBe('passwordless-magic-link') expect(sentBody.recipient).toBe('user@example.com') diff --git a/packages/template-retail-react-app/cartridges/app_pwakit_base/cartridge/rest-apis/pwakit-notify/schema.yaml b/packages/template-retail-react-app/cartridges/app_pwakit_base/cartridge/rest-apis/pwakit-notify/schema.yaml index c89d8ecffd..65f2d3396c 100644 --- a/packages/template-retail-react-app/cartridges/app_pwakit_base/cartridge/rest-apis/pwakit-notify/schema.yaml +++ b/packages/template-retail-react-app/cartridges/app_pwakit_base/cartridge/rest-apis/pwakit-notify/schema.yaml @@ -12,14 +12,14 @@ components: scopes: c_pwakit_notify: Send transactional notifications via PWA Kit paths: - /sites/{siteId}/notify: + /notify: post: summary: Send a transactional notification operationId: notify parameters: - - in: path + - in: query name: siteId - required: true + required: false schema: type: string minLength: 1 From 4d09f3c5459de21c8f8372b066c7b8fb828a06e4 Mon Sep 17 00:00:00 2001 From: Ben Chypak Date: Thu, 17 Sep 2026 15:59:05 -0700 Subject: [PATCH 27/55] feat: forward locale to pwakit-notify cartridge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extract locale from redirectUrl (e.g. /en-US/account) passed by the storefront when initiating the passwordless or reset-password flow. Append it as ?locale= on the SCAPI call so the B2C cartridge can use request.locale to select the correct email template strings rather than falling back to site.defaultLocale. Falls back gracefully when redirectUrl is absent or has no locale prefix — cartridge default applies in that case. --- packages/template-retail-react-app/app/ssr.js | 18 +++++-- .../template-retail-react-app/app/ssr.test.js | 50 ++++++++++++++++++- 2 files changed, 64 insertions(+), 4 deletions(-) diff --git a/packages/template-retail-react-app/app/ssr.js b/packages/template-retail-react-app/app/ssr.js index 2a30c20a34..472ca247f1 100644 --- a/packages/template-retail-react-app/app/ssr.js +++ b/packages/template-retail-react-app/app/ssr.js @@ -285,14 +285,14 @@ export async function getNotifyToken(apiParams) { } export async function sendViaB2cCartridge(type, recipient, data, apiParams) { - const {organizationId, siteId} = apiParams + const {organizationId, siteId, locale} = apiParams const token = await getNotifyToken(apiParams) const proxy = `${getAppOrigin()}${ getConfig()?.app?.commerceAPI?.proxyPath || '/mobify/proxy/api' }` const url = `${proxy}/custom/pwakit-notify/v1/organizations/${encodeURIComponent( organizationId - )}/notify?siteId=${encodeURIComponent(siteId)}` + )}/notify?siteId=${encodeURIComponent(siteId)}${locale ? `&locale=${encodeURIComponent(locale)}` : ''}` const requestBody = JSON.stringify({ type, recipient, @@ -333,6 +333,17 @@ const resetPasswordCallback = const passwordlessLoginCallback = config.app.login?.passwordless?.callbackURI || '/passwordless-login-callback' +export function extractLocaleFromUrl(urlString) { + if (!urlString) return null + try { + const path = new URL(urlString, 'https://placeholder').pathname + const match = path.match(/^\/([a-z]{2}-[A-Z]{2})(\/|$)/) + return match ? match[1] : null + } catch (_) { + return null + } +} + async function sendMagicLinkEmail(req, res, landingPath, notifyType, redirectUrl) { const {email_id, token} = req.body @@ -342,11 +353,12 @@ async function sendMagicLinkEmail(req, res, landingPath, notifyType, redirectUrl } const appConfig = getConfig()?.app + const locale = extractLocaleFromUrl(redirectUrl) await sendViaB2cCartridge( notifyType, email_id, {magicLinkPath}, - appConfig.commerceAPI.parameters + {...appConfig.commerceAPI.parameters, ...(locale && {locale})} ) res.json({success: true}) } diff --git a/packages/template-retail-react-app/app/ssr.test.js b/packages/template-retail-react-app/app/ssr.test.js index 931c41a2e1..f43d529080 100644 --- a/packages/template-retail-react-app/app/ssr.test.js +++ b/packages/template-retail-react-app/app/ssr.test.js @@ -118,7 +118,8 @@ import { handleCallback, getNotifyToken, sendViaB2cCartridge, - _resetNotifyTokenCacheForTest + _resetNotifyTokenCacheForTest, + extractLocaleFromUrl } from '@salesforce/retail-react-app/app/ssr.js' import {helpers} from 'commerce-sdk-isomorphic' @@ -435,4 +436,51 @@ describe('sendViaB2cCartridge', () => { await sendViaB2cCartridge('otp', 'user@example.com', {token: '111'}, TEST_API_PARAMS) expect(helpers.loginGuestUser).not.toHaveBeenCalled() }) + + test('appends locale to URL when provided in apiParams', async () => { + mockFetchOk() + + await sendViaB2cCartridge('otp', 'user@example.com', {token: '123'}, { + ...TEST_API_PARAMS, + locale: 'fr-FR' + }) + + const [url] = fetchSpy.mock.calls[0] + expect(url).toContain('&locale=fr-FR') + }) + + test('omits locale from URL when not provided', async () => { + mockFetchOk() + + await sendViaB2cCartridge('otp', 'user@example.com', {token: '123'}, TEST_API_PARAMS) + + const [url] = fetchSpy.mock.calls[0] + expect(url).not.toContain('locale') + }) +}) + +describe('extractLocaleFromUrl', () => { + test('extracts locale from absolute URL', () => { + expect(extractLocaleFromUrl('https://mystore.com/en-US/account')).toBe('en-US') + }) + + test('extracts locale from relative path', () => { + expect(extractLocaleFromUrl('/fr-FR/login')).toBe('fr-FR') + }) + + test('extracts locale from root locale path', () => { + expect(extractLocaleFromUrl('/en-US')).toBe('en-US') + }) + + test('returns null for path without locale', () => { + expect(extractLocaleFromUrl('/account')).toBeNull() + }) + + test('returns null for null input', () => { + expect(extractLocaleFromUrl(null)).toBeNull() + }) + + test('returns null for empty string', () => { + expect(extractLocaleFromUrl('')).toBeNull() + }) }) From 01c98efe03f3b5a50dcc4919f391a2a0e162fe26 Mon Sep 17 00:00:00 2001 From: Ben Chypak Date: Thu, 17 Sep 2026 16:21:07 -0700 Subject: [PATCH 28/55] =?UTF-8?q?fix:=20prettier=20formatting=20=E2=80=94?= =?UTF-8?q?=20locale=20URL=20template=20and=20test=20args?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/template-retail-react-app/app/ssr.js | 4 +++- packages/template-retail-react-app/app/ssr.test.js | 13 +++++++++---- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/packages/template-retail-react-app/app/ssr.js b/packages/template-retail-react-app/app/ssr.js index 472ca247f1..e5ec1a07a8 100644 --- a/packages/template-retail-react-app/app/ssr.js +++ b/packages/template-retail-react-app/app/ssr.js @@ -292,7 +292,9 @@ export async function sendViaB2cCartridge(type, recipient, data, apiParams) { }` const url = `${proxy}/custom/pwakit-notify/v1/organizations/${encodeURIComponent( organizationId - )}/notify?siteId=${encodeURIComponent(siteId)}${locale ? `&locale=${encodeURIComponent(locale)}` : ''}` + )}/notify?siteId=${encodeURIComponent(siteId)}${ + locale ? `&locale=${encodeURIComponent(locale)}` : '' + }` const requestBody = JSON.stringify({ type, recipient, diff --git a/packages/template-retail-react-app/app/ssr.test.js b/packages/template-retail-react-app/app/ssr.test.js index f43d529080..28d06e84ca 100644 --- a/packages/template-retail-react-app/app/ssr.test.js +++ b/packages/template-retail-react-app/app/ssr.test.js @@ -440,10 +440,15 @@ describe('sendViaB2cCartridge', () => { test('appends locale to URL when provided in apiParams', async () => { mockFetchOk() - await sendViaB2cCartridge('otp', 'user@example.com', {token: '123'}, { - ...TEST_API_PARAMS, - locale: 'fr-FR' - }) + await sendViaB2cCartridge( + 'otp', + 'user@example.com', + {token: '123'}, + { + ...TEST_API_PARAMS, + locale: 'fr-FR' + } + ) const [url] = fetchSpy.mock.calls[0] expect(url).toContain('&locale=fr-FR') From ff4ccc552180b349a44c3c98789c56a5bde2fa1e Mon Sep 17 00:00:00 2001 From: Ben Chypak Date: Fri, 18 Sep 2026 09:06:12 -0700 Subject: [PATCH 29/55] chore: revert sandbox-specific config before staging push Revert enablePWAKitPrivateClient, defaultSite, siteId, commerceAPI parameters, passwordless mode, and guestOrderLookup.enabled back to template defaults. --- .../app/components/_app-config/index.jsx | 2 +- .../config/default.js | 29 ++++++++----------- 2 files changed, 13 insertions(+), 18 deletions(-) diff --git a/packages/template-retail-react-app/app/components/_app-config/index.jsx b/packages/template-retail-react-app/app/components/_app-config/index.jsx index 3407d670a9..4c1588cd83 100644 --- a/packages/template-retail-react-app/app/components/_app-config/index.jsx +++ b/packages/template-retail-react-app/app/components/_app-config/index.jsx @@ -118,7 +118,7 @@ const AppConfig = ({children, locals = {}}) => { defaultDnt={DEFAULT_DNT_STATE} // Set 'enablePWAKitPrivateClient' to true to use SLAS private client login flows. // Make sure to also enable useSLASPrivateClient in ssr.js when enabling this setting. - enablePWAKitPrivateClient={true} + enablePWAKitPrivateClient={false} privateClientProxyEndpoint={slasPrivateClientProxyEndpoint} publicClientProxyEndpoint={slasPublicClientProxyEndpoint} // Uncomment 'hybridAuthEnabled' if the current site has Hybrid Auth enabled. Do NOT set this flag for hybrid storefronts using Plugin SLAS. diff --git a/packages/template-retail-react-app/config/default.js b/packages/template-retail-react-app/config/default.js index adeb5e6d64..c1f8f62846 100644 --- a/packages/template-retail-react-app/config/default.js +++ b/packages/template-retail-react-app/config/default.js @@ -122,10 +122,9 @@ module.exports = { // If the env var `OTP_TOKEN_LENGTH` is set, it will override the config value. Valid values are 6 or 8. Defaults to: 8 tokenLength: validateOtpTokenLength(process.env.OTP_TOKEN_LENGTH), passwordless: { - enabled: true, - mode: 'callback', - landingPath: '/passwordless-login-landing', - callbackURI: '/passwordless-login-callback' + enabled: false, + mode: 'email', + landingPath: '/passwordless-login-landing' }, social: { enabled: false, @@ -133,15 +132,11 @@ module.exports = { redirectURI: process.env.SOCIAL_LOGIN_REDIRECT_URI || '/social-callback' }, resetPassword: { - mode: 'callback', - landingPath: '/reset-password-landing', - callbackURI: '/reset-password-callback' - }, - registrationVerification: { - callbackURI: '/registration-verification-callback' + mode: 'email', + landingPath: '/reset-password-landing' } }, - defaultSite: 'RefArch', + defaultSite: 'RefArchGlobal', siteAliases: { RefArch: 'us', RefArchGlobal: 'global' @@ -150,10 +145,10 @@ module.exports = { commerceAPI: { proxyPath: `/mobify/proxy/api`, parameters: { - clientId: '11deeece-0b48-4b60-bf1b-d96649faec43', - organizationId: 'f_ecom_zysg_001', - shortCode: 'sandbox-001', - siteId: 'RefArch' + clientId: 'c9c45bfd-0ed3-4aa2-9971-40f88962b836', + organizationId: 'f_ecom_zzrf_001', + shortCode: 'kv7kzm78', + siteId: 'RefArchGlobal' } // Optional: Set the domain for auth cookies to share them across subdomains. // If not set, cookies default to the current host. @@ -194,7 +189,7 @@ module.exports = { }, storeLocatorEnabled: true, guestOrderLookup: { - enabled: true, + enabled: false, orderNumberRegex: '^[a-zA-Z0-9-]{6,32}$', requestCodeThrottle: { windowMs: 60000, @@ -244,7 +239,7 @@ module.exports = { enableHttpOnlySessionCookies: false, proxyConfigs: [ { - host: 'sandbox-001.api.commercecloud.salesforce.com', + host: 'kv7kzm78.api.commercecloud.salesforce.com', path: 'api' }, { From 2d675c255934f5fb803028b3f9a5f91a8b6c38f1 Mon Sep 17 00:00:00 2001 From: Ben Chypak Date: Fri, 18 Sep 2026 09:09:15 -0700 Subject: [PATCH 30/55] fix: restore callback mode and callbackURIs for email flows Callback mode and callbackURI settings for passwordless, resetPassword, and registrationVerification are feature work, not sandbox-specific. Restore them after the prior revert went too far. --- .../template-retail-react-app/config/default.js | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/packages/template-retail-react-app/config/default.js b/packages/template-retail-react-app/config/default.js index c1f8f62846..47223f429d 100644 --- a/packages/template-retail-react-app/config/default.js +++ b/packages/template-retail-react-app/config/default.js @@ -123,8 +123,9 @@ module.exports = { tokenLength: validateOtpTokenLength(process.env.OTP_TOKEN_LENGTH), passwordless: { enabled: false, - mode: 'email', - landingPath: '/passwordless-login-landing' + mode: 'callback', + landingPath: '/passwordless-login-landing', + callbackURI: '/passwordless-login-callback' }, social: { enabled: false, @@ -132,8 +133,12 @@ module.exports = { redirectURI: process.env.SOCIAL_LOGIN_REDIRECT_URI || '/social-callback' }, resetPassword: { - mode: 'email', - landingPath: '/reset-password-landing' + mode: 'callback', + landingPath: '/reset-password-landing', + callbackURI: '/reset-password-callback' + }, + registrationVerification: { + callbackURI: '/registration-verification-callback' } }, defaultSite: 'RefArchGlobal', From ad7a93ea486e422047d8bc2a93b22cf68067913a Mon Sep 17 00:00:00 2001 From: Ben Chypak Date: Fri, 18 Sep 2026 09:30:46 -0700 Subject: [PATCH 31/55] fix: exclude deploy:* scripts from smoke test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit deploy:cartridge requires b2c-cli and live B2C credentials — same category as save-credentials and tail-logs, which are already excluded. --- scripts/smoke-test-npm-scripts.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/scripts/smoke-test-npm-scripts.js b/scripts/smoke-test-npm-scripts.js index 69459200a1..47c98f2782 100644 --- a/scripts/smoke-test-npm-scripts.js +++ b/scripts/smoke-test-npm-scripts.js @@ -43,7 +43,8 @@ const main = () => { /^start.*$/, /^compile-translations.*$/, /^extract-default-translations.*$/, - /^bump-version.*$/ + /^bump-version.*$/, + /^deploy.*$/ ] const scripts = Object.keys(pkg.scripts).filter( From d9f69ae3844c7a60f54602163e7a0af0d85c8c7c Mon Sep 17 00:00:00 2001 From: Ben Chypak Date: Fri, 18 Sep 2026 09:47:22 -0700 Subject: [PATCH 32/55] fix: revert useSLASPrivateClient to false Sandbox-only setting; leaving it true forces PWA_KIT_SLAS_CLIENT_SECRET to be set at server startup, breaking the Lighthouse CI test and any default deploy. --- packages/template-retail-react-app/app/ssr.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/template-retail-react-app/app/ssr.js b/packages/template-retail-react-app/app/ssr.js index e5ec1a07a8..c9e6145180 100644 --- a/packages/template-retail-react-app/app/ssr.js +++ b/packages/template-retail-react-app/app/ssr.js @@ -181,7 +181,7 @@ const options = { // Set this to false if using a SLAS public client // When setting this to true, make sure to also set the PWA_KIT_SLAS_CLIENT_SECRET // environment variable as this endpoint will return HTTP 501 if it is not set - useSLASPrivateClient: true, + useSLASPrivateClient: false, // To extend the SLAS private-client proxy allow-list, supply // `slasPrivateClientAllowList`. See the built-in list in pwa-kit-runtime From d525072408a811f6a4be0d00821debff001f2f5d Mon Sep 17 00:00:00 2001 From: Ben Chypak Date: Mon, 21 Sep 2026 09:46:36 -0700 Subject: [PATCH 33/55] =?UTF-8?q?fix:=20address=20PR=20review=20comments?= =?UTF-8?q?=20=E2=80=94=20template=20sync,=20tenant=20check,=20localAllowC?= =?UTF-8?q?ookies?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Revert localAllowCookies to false (was leftover sandbox config) - Add tenant ID check before JWKS selection in validateSlasCallbackToken so SLAS_JWKS_JSON cannot accept tokens from a different tenant - Sync both ssr.js.hbs templates with canonical ssr.js: - Fix pwakit-notify URL to org-scoped (/notify?siteId=) - Add callerHost to request body - Add locale forwarding via extractLocaleFromUrl - Add retry-on-401 with fresh token - Add registration-verification-callback handler - Update both config/default.js.hbs templates: - Set passwordless and resetPassword mode to 'callback' - Uncomment callbackURI fields - Add registrationVerification.callbackURI --- .../assets/bootstrap/js/config/default.js.hbs | 15 ++-- .../bootstrap/js/overrides/app/ssr.js.hbs | 82 +++++++++++++++---- .../retail-react-app/app/ssr.js.hbs | 82 +++++++++++++++---- .../retail-react-app/config/default.js.hbs | 15 ++-- packages/template-retail-react-app/app/ssr.js | 12 ++- 5 files changed, 167 insertions(+), 39 deletions(-) diff --git a/packages/pwa-kit-create-app/assets/bootstrap/js/config/default.js.hbs b/packages/pwa-kit-create-app/assets/bootstrap/js/config/default.js.hbs index ed0509d81a..3be928ebee 100644 --- a/packages/pwa-kit-create-app/assets/bootstrap/js/config/default.js.hbs +++ b/packages/pwa-kit-create-app/assets/bootstrap/js/config/default.js.hbs @@ -85,11 +85,11 @@ module.exports = { enabled: false, {{/if}} // The mode for passwordless login. Valid values include 'email|callback'. Defaults to: 'callback' - mode: 'email', + mode: 'callback', // The callback URI, which can be an absolute URL (including third-party URIs) or a relative path set up by the developer. // Required when mode is set to 'callback'. If the env var `PASSWORDLESS_LOGIN_CALLBACK_URI` is set, it will override the config value. - // callbackURI: - // process.env.PASSWORDLESS_LOGIN_CALLBACK_URI || '/passwordless-login-callback', + callbackURI: + process.env.PASSWORDLESS_LOGIN_CALLBACK_URI || '/passwordless-login-callback', // The landing path for passwordless login landingPath: '/passwordless-login-landing' }, @@ -110,12 +110,17 @@ module.exports = { }, resetPassword: { // The mode for reset password. Valid values include 'email|callback'. Defaults to: 'callback' - mode: 'email', + mode: 'callback', // The callback URI, which can be an absolute URL (including third-party URIs) or a relative path set up by the developer. // Required when mode is set to 'callback'. If the env var `RESET_PASSWORD_CALLBACK_URI` is set, it will override the config value. - // callbackURI: process.env.RESET_PASSWORD_CALLBACK_URI || '/reset-password-callback', + callbackURI: process.env.RESET_PASSWORD_CALLBACK_URI || '/reset-password-callback', // The landing path for reset password landingPath: '/reset-password-landing' + }, + registrationVerification: { + callbackURI: + process.env.REGISTRATION_VERIFICATION_CALLBACK_URI || + '/registration-verification-callback' } }, // The default site for your app. This value will be used when a siteRef could not be determined from the url diff --git a/packages/pwa-kit-create-app/assets/bootstrap/js/overrides/app/ssr.js.hbs b/packages/pwa-kit-create-app/assets/bootstrap/js/overrides/app/ssr.js.hbs index 268de729e1..cb53904376 100644 --- a/packages/pwa-kit-create-app/assets/bootstrap/js/overrides/app/ssr.js.hbs +++ b/packages/pwa-kit-create-app/assets/bootstrap/js/overrides/app/ssr.js.hbs @@ -289,28 +289,43 @@ async function getNotifyToken(apiParams) { } async function sendViaB2cCartridge(type, recipient, data, apiParams) { - const {organizationId, siteId} = apiParams + const {organizationId, siteId, locale} = apiParams const token = await getNotifyToken(apiParams) const proxy = `${getAppOrigin()}${ getConfig()?.app?.commerceAPI?.proxyPath || '/mobify/proxy/api' }` const url = `${proxy}/custom/pwakit-notify/v1/organizations/${encodeURIComponent( organizationId - )}/sites/${encodeURIComponent(siteId)}/notify?siteId=${encodeURIComponent(siteId)}` - const res = await fetch(url, { + )}/notify?siteId=${encodeURIComponent(siteId)}${ + locale ? `&locale=${encodeURIComponent(locale)}` : '' + }` + const requestBody = JSON.stringify({ + type, + recipient, + data, + callerHost: new URL(getAppOrigin()).hostname + }) + let res = await fetch(url, { method: 'POST', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${token}` - }, - body: JSON.stringify({type, recipient, data}) + headers: {'Content-Type': 'application/json', Authorization: `Bearer ${token}`}, + body: requestBody }) + if (!res.ok && res.status === 401) { + _notifyToken = null + _notifyTokenExpiry = 0 + const freshToken = await getNotifyToken(apiParams) + res = await fetch(url, { + method: 'POST', + headers: {'Content-Type': 'application/json', Authorization: `Bearer ${freshToken}`}, + body: requestBody + }) + } if (!res.ok) { - if (res.status === 401) { - // Clear cached token so next call gets a fresh one - _notifyToken = null - _notifyTokenExpiry = 0 - } + let body = '' + try { + body = await res.text() + } catch (_) {} // eslint-disable-line no-empty + logger.error(`pwakit-notify ${res.status} body: ${body} url: ${url}`) throw new Error(`pwakit-notify returned ${res.status}`) } return await res.json() @@ -321,6 +336,17 @@ const resetPasswordCallback = const passwordlessLoginCallback = config.app.login?.passwordless?.callbackURI || '/passwordless-login-callback' +function extractLocaleFromUrl(urlString) { + if (!urlString) return null + try { + const path = new URL(urlString, 'https://placeholder').pathname + const match = path.match(/^\/([a-z]{2}-[A-Z]{2})(\/|$)/) + return match ? match[1] : null + } catch (_) { + return null + } +} + async function sendMagicLinkEmail(req, res, landingPath, notifyType, redirectUrl) { const {email_id, token} = req.body @@ -330,11 +356,12 @@ async function sendMagicLinkEmail(req, res, landingPath, notifyType, redirectUrl } const appConfig = getConfig()?.app + const locale = extractLocaleFromUrl(redirectUrl) await sendViaB2cCartridge( notifyType, email_id, {magicLinkPath}, - appConfig.commerceAPI.parameters + {...appConfig.commerceAPI.parameters, ...(locale && {locale})} ) res.json({success: true}) } @@ -612,6 +639,33 @@ const {handler} = runtime.createHandler(options, (app) => { } }) + const registrationVerificationCallback = + getConfig()?.app?.login?.registrationVerification?.callbackURI ?? + '/registration-verification-callback' + app.post(registrationVerificationCallback, async (req, res) => { + const slasCallbackToken = req.headers['x-slas-callback-token'] + if (!slasCallbackToken) { + return res.status(400).json({error: 'Missing x-slas-callback-token header'}) + } + const {email_id, token} = req.body + try { + await validateSlasCallbackToken(slasCallbackToken) + await sendViaB2cCartridge( + 'otp', + email_id, + {token}, + getConfig().app.commerceAPI.parameters + ) + res.json({success: true}) + } catch (err) { + logger.error('registration-verification callback failed', { + namespace: 'slas-callback', + additionalProperties: {error: err?.message} + }) + res.status(500).json({error: 'Failed to send notification'}) + } + }) + app.get('/robots.txt', runtime.serveStaticFile('static/robots.txt')) app.get('/favicon.ico', runtime.serveStaticFile('static/ico/favicon.ico')) diff --git a/packages/pwa-kit-create-app/assets/templates/@salesforce/retail-react-app/app/ssr.js.hbs b/packages/pwa-kit-create-app/assets/templates/@salesforce/retail-react-app/app/ssr.js.hbs index 27bb944e85..6cac07cab7 100644 --- a/packages/pwa-kit-create-app/assets/templates/@salesforce/retail-react-app/app/ssr.js.hbs +++ b/packages/pwa-kit-create-app/assets/templates/@salesforce/retail-react-app/app/ssr.js.hbs @@ -289,28 +289,43 @@ async function getNotifyToken(apiParams) { } async function sendViaB2cCartridge(type, recipient, data, apiParams) { - const {organizationId, siteId} = apiParams + const {organizationId, siteId, locale} = apiParams const token = await getNotifyToken(apiParams) const proxy = `${getAppOrigin()}${ getConfig()?.app?.commerceAPI?.proxyPath || '/mobify/proxy/api' }` const url = `${proxy}/custom/pwakit-notify/v1/organizations/${encodeURIComponent( organizationId - )}/sites/${encodeURIComponent(siteId)}/notify?siteId=${encodeURIComponent(siteId)}` - const res = await fetch(url, { + )}/notify?siteId=${encodeURIComponent(siteId)}${ + locale ? `&locale=${encodeURIComponent(locale)}` : '' + }` + const requestBody = JSON.stringify({ + type, + recipient, + data, + callerHost: new URL(getAppOrigin()).hostname + }) + let res = await fetch(url, { method: 'POST', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${token}` - }, - body: JSON.stringify({type, recipient, data}) + headers: {'Content-Type': 'application/json', Authorization: `Bearer ${token}`}, + body: requestBody }) + if (!res.ok && res.status === 401) { + _notifyToken = null + _notifyTokenExpiry = 0 + const freshToken = await getNotifyToken(apiParams) + res = await fetch(url, { + method: 'POST', + headers: {'Content-Type': 'application/json', Authorization: `Bearer ${freshToken}`}, + body: requestBody + }) + } if (!res.ok) { - if (res.status === 401) { - // Clear cached token so next call gets a fresh one - _notifyToken = null - _notifyTokenExpiry = 0 - } + let body = '' + try { + body = await res.text() + } catch (_) {} // eslint-disable-line no-empty + logger.error(`pwakit-notify ${res.status} body: ${body} url: ${url}`) throw new Error(`pwakit-notify returned ${res.status}`) } return await res.json() @@ -321,6 +336,17 @@ const resetPasswordCallback = const passwordlessLoginCallback = config.app.login?.passwordless?.callbackURI || '/passwordless-login-callback' +function extractLocaleFromUrl(urlString) { + if (!urlString) return null + try { + const path = new URL(urlString, 'https://placeholder').pathname + const match = path.match(/^\/([a-z]{2}-[A-Z]{2})(\/|$)/) + return match ? match[1] : null + } catch (_) { + return null + } +} + async function sendMagicLinkEmail(req, res, landingPath, notifyType, redirectUrl) { const {email_id, token} = req.body @@ -330,11 +356,12 @@ async function sendMagicLinkEmail(req, res, landingPath, notifyType, redirectUrl } const appConfig = getConfig()?.app + const locale = extractLocaleFromUrl(redirectUrl) await sendViaB2cCartridge( notifyType, email_id, {magicLinkPath}, - appConfig.commerceAPI.parameters + {...appConfig.commerceAPI.parameters, ...(locale && {locale})} ) res.json({success: true}) } @@ -612,6 +639,33 @@ const {handler} = runtime.createHandler(options, (app) => { } }) + const registrationVerificationCallback = + getConfig()?.app?.login?.registrationVerification?.callbackURI ?? + '/registration-verification-callback' + app.post(registrationVerificationCallback, async (req, res) => { + const slasCallbackToken = req.headers['x-slas-callback-token'] + if (!slasCallbackToken) { + return res.status(400).json({error: 'Missing x-slas-callback-token header'}) + } + const {email_id, token} = req.body + try { + await validateSlasCallbackToken(slasCallbackToken) + await sendViaB2cCartridge( + 'otp', + email_id, + {token}, + getConfig().app.commerceAPI.parameters + ) + res.json({success: true}) + } catch (err) { + logger.error('registration-verification callback failed', { + namespace: 'slas-callback', + additionalProperties: {error: err?.message} + }) + res.status(500).json({error: 'Failed to send notification'}) + } + }) + app.get('/robots.txt', runtime.serveStaticFile('static/robots.txt')) app.get('/favicon.ico', runtime.serveStaticFile('static/ico/favicon.ico')) diff --git a/packages/pwa-kit-create-app/assets/templates/@salesforce/retail-react-app/config/default.js.hbs b/packages/pwa-kit-create-app/assets/templates/@salesforce/retail-react-app/config/default.js.hbs index 5f3ffe6b81..a480654e5f 100644 --- a/packages/pwa-kit-create-app/assets/templates/@salesforce/retail-react-app/config/default.js.hbs +++ b/packages/pwa-kit-create-app/assets/templates/@salesforce/retail-react-app/config/default.js.hbs @@ -85,11 +85,11 @@ module.exports = { enabled: false, {{/if}} // The mode for passwordless login. Valid values include 'email|callback'. Defaults to: 'callback' - mode: 'email', + mode: 'callback', // The callback URI, which can be an absolute URL (including third-party URIs) or a relative path set up by the developer. // Required when mode is set to 'callback'. If the env var `PASSWORDLESS_LOGIN_CALLBACK_URI` is set, it will override the config value. - // callbackURI: - // process.env.PASSWORDLESS_LOGIN_CALLBACK_URI || '/passwordless-login-callback', + callbackURI: + process.env.PASSWORDLESS_LOGIN_CALLBACK_URI || '/passwordless-login-callback', // The landing path for passwordless login landingPath: '/passwordless-login-landing' }, @@ -110,12 +110,17 @@ module.exports = { }, resetPassword: { // The mode for reset password. Valid values include 'email|callback'. Defaults to: 'callback' - mode: 'email', + mode: 'callback', // The callback URI, which can be an absolute URL (including third-party URIs) or a relative path set up by the developer. // Required when mode is set to 'callback'. If the env var `RESET_PASSWORD_CALLBACK_URI` is set, it will override the config value. - // callbackURI: process.env.RESET_PASSWORD_CALLBACK_URI || '/reset-password-callback', + callbackURI: process.env.RESET_PASSWORD_CALLBACK_URI || '/reset-password-callback', // The landing path for reset password landingPath: '/reset-password-landing' + }, + registrationVerification: { + callbackURI: + process.env.REGISTRATION_VERIFICATION_CALLBACK_URI || + '/registration-verification-callback' } }, // The default site for your app. This value will be used when a siteRef could not be determined from the url diff --git a/packages/template-retail-react-app/app/ssr.js b/packages/template-retail-react-app/app/ssr.js index c9e6145180..4582629df6 100644 --- a/packages/template-retail-react-app/app/ssr.js +++ b/packages/template-retail-react-app/app/ssr.js @@ -207,7 +207,7 @@ const options = { // HYBRID PROXY REQUIREMENT: // - Hybrid Proxy requires this to be 'true' for SFCC session management to work properly // - Only enable Hybrid Proxy in development environments, never in production - localAllowCookies: true, + localAllowCookies: false, // Hybrid Proxy configuration for local development and MRT to ODS connection testing. // @@ -404,6 +404,16 @@ export const validateSlasCallbackToken = async (token) => { const subClaim = payload[CLAIM.ISSUER] const tokens = subClaim.split(DELIMITER.ISSUER) const tenantId = tokens[2] + const configTenantId = getConfig()?.app?.commerceAPI?.parameters?.organizationId?.replace( + /^f_ecom_/, + '' + ) + if (tenantId !== configTenantId) { + throwSlasTokenValidationError( + `The tenant ID in your PWA Kit configuration ("${configTenantId}") does not match the tenant ID in the SLAS callback token ("${tenantId}").`, + 401 + ) + } try { let jwks if (process.env.SLAS_JWKS_JSON) { From 5d125c0dc349464100d0550e10df81ca5ceddf72 Mon Sep 17 00:00:00 2001 From: Ben Chypak Date: Mon, 21 Sep 2026 09:51:46 -0700 Subject: [PATCH 34/55] test: add wrong-tenant local-JWKS regression test; fix mode assertion - validateSlasCallbackToken: add test proving tenant check runs before SLAS_JWKS_JSON path so a misconfigured local key set can't accept tokens from a different tenant - one-click-user-registration: update mode assertion from 'email' to 'callback' to match the committed default config --- .../one-click-user-registration.test.js | 2 +- .../template-retail-react-app/app/ssr.test.js | 19 +++++++++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/packages/template-retail-react-app/app/pages/checkout-one-click/partials/one-click-user-registration.test.js b/packages/template-retail-react-app/app/pages/checkout-one-click/partials/one-click-user-registration.test.js index 421a659aaa..3245691166 100644 --- a/packages/template-retail-react-app/app/pages/checkout-one-click/partials/one-click-user-registration.test.js +++ b/packages/template-retail-react-app/app/pages/checkout-one-click/partials/one-click-user-registration.test.js @@ -152,7 +152,7 @@ describe('UserRegistration', () => { await waitFor(() => { expect(authorizePasswordlessLogin.mutateAsync).toHaveBeenCalledWith({ userid: 'test@example.com', - mode: 'email', + mode: 'callback', locale: 'en-US', register_customer: true, last_name: 'test@example.com', diff --git a/packages/template-retail-react-app/app/ssr.test.js b/packages/template-retail-react-app/app/ssr.test.js index 28d06e84ca..3cc2eba1a6 100644 --- a/packages/template-retail-react-app/app/ssr.test.js +++ b/packages/template-retail-react-app/app/ssr.test.js @@ -177,6 +177,25 @@ describe('validateSlasCallbackToken', () => { ) }) + test('rejects wrong-tenant token even when SLAS_JWKS_JSON is set', async () => { + const originalEnvSlas = process.env.SLAS_JWKS_JSON + process.env.SLAS_JWKS_JSON = JSON.stringify({keys: [{kty: 'RSA', kid: 'local-key'}]}) + jose.decodeJwt.mockReturnValueOnce({ + iss: 'prefix/prefix2/wrong_tenant/oauth2' + }) + try { + await expect(validateSlasCallbackToken('wrong-tenant-local-jwks')).rejects.toThrow( + 'The tenant ID in your PWA Kit configuration ("test_001") does not match the tenant ID in the SLAS callback token ("wrong_tenant")' + ) + } finally { + if (originalEnvSlas === undefined) { + delete process.env.SLAS_JWKS_JSON + } else { + process.env.SLAS_JWKS_JSON = originalEnvSlas + } + } + }) + test('should handle token with malformed issuer claim', async () => { const testToken = 'token-with-malformed-issuer' From b07ead66f036c6137b8ae58029207a3c999aa01d Mon Sep 17 00:00:00 2001 From: Ben Chypak Date: Mon, 21 Sep 2026 10:32:02 -0700 Subject: [PATCH 35/55] fix: remove trailing whitespace and extra EOF blank line - docs/hybrid-cookie-domain-configuration.md: remove trailing space on blank separator line (line 4) - email.properties: remove extra blank line at end of file --- docs/hybrid-cookie-domain-configuration.md | 2 +- .../cartridge/templates/resources/email.properties | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/docs/hybrid-cookie-domain-configuration.md b/docs/hybrid-cookie-domain-configuration.md index 9e0a55c27d..68d539d9c2 100644 --- a/docs/hybrid-cookie-domain-configuration.md +++ b/docs/hybrid-cookie-domain-configuration.md @@ -1,7 +1,7 @@ # Hybrid Cookie Domain Configuration > **WIP: HttpOnly session cookies are in-progress. Do not enable `enableHttpOnlySessionCookies` in production.** - + Maintain shopper sessions across subdomains by configuring a shared parent cookie domain. Without this configuration, browsers isolate cookies to individual hosts, causing shoppers to appear logged out and lose their baskets when crossing subdomain boundaries. The domain-level cookie applies to storefronts that use multiple subdomains, such as `www.` and `shop.` on the `example.com` domain. It also applies to hybrid PWA Kit and SFRA storefronts that serve different paths under one parent domain. This guide shows how to align the cookie domain settings on both PWA Kit and B2C Commerce to enable seamless cross-subdomain navigation. ## Overview diff --git a/packages/template-retail-react-app/cartridges/app_pwakit_base/cartridge/templates/resources/email.properties b/packages/template-retail-react-app/cartridges/app_pwakit_base/cartridge/templates/resources/email.properties index 5fb0d64a7e..674999495b 100644 --- a/packages/template-retail-react-app/cartridges/app_pwakit_base/cartridge/templates/resources/email.properties +++ b/packages/template-retail-react-app/cartridges/app_pwakit_base/cartridge/templates/resources/email.properties @@ -32,4 +32,3 @@ guestOrderLookup.buttonText=View Order guestOrderLookup.accessCodeLabel=Your access code: guestOrderLookup.disclaimer=If you did not place this order, please contact customer support. guestOrderLookup.copyUrl=Or copy and paste this link into your browser: - From 8e0ab9994cadc008f677b0925169012e1f742ab7 Mon Sep 17 00:00:00 2001 From: Ben Chypak Date: Mon, 21 Sep 2026 11:31:42 -0700 Subject: [PATCH 36/55] fix: export _resetNotifyTokenCacheForTest and extractLocaleFromUrl from hbs templates Generated ssr.test.js imports both symbols from ssr.js. The template sync added these functions but didn't export them, causing every generated-project ssr.test job to fail with "is not a function". --- .../assets/bootstrap/js/overrides/app/ssr.js.hbs | 7 ++++++- .../templates/@salesforce/retail-react-app/app/ssr.js.hbs | 7 ++++++- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/packages/pwa-kit-create-app/assets/bootstrap/js/overrides/app/ssr.js.hbs b/packages/pwa-kit-create-app/assets/bootstrap/js/overrides/app/ssr.js.hbs index cb53904376..81e9e624f8 100644 --- a/packages/pwa-kit-create-app/assets/bootstrap/js/overrides/app/ssr.js.hbs +++ b/packages/pwa-kit-create-app/assets/bootstrap/js/overrides/app/ssr.js.hbs @@ -255,6 +255,11 @@ const runtime = getRuntime() let _notifyToken = null let _notifyTokenExpiry = 0 +export function _resetNotifyTokenCacheForTest() { + _notifyToken = null + _notifyTokenExpiry = 0 +} + async function getNotifyToken(apiParams) { if (_notifyToken && Date.now() < _notifyTokenExpiry) { return _notifyToken @@ -336,7 +341,7 @@ const resetPasswordCallback = const passwordlessLoginCallback = config.app.login?.passwordless?.callbackURI || '/passwordless-login-callback' -function extractLocaleFromUrl(urlString) { +export function extractLocaleFromUrl(urlString) { if (!urlString) return null try { const path = new URL(urlString, 'https://placeholder').pathname diff --git a/packages/pwa-kit-create-app/assets/templates/@salesforce/retail-react-app/app/ssr.js.hbs b/packages/pwa-kit-create-app/assets/templates/@salesforce/retail-react-app/app/ssr.js.hbs index 6cac07cab7..59c46d4022 100644 --- a/packages/pwa-kit-create-app/assets/templates/@salesforce/retail-react-app/app/ssr.js.hbs +++ b/packages/pwa-kit-create-app/assets/templates/@salesforce/retail-react-app/app/ssr.js.hbs @@ -255,6 +255,11 @@ const runtime = getRuntime() let _notifyToken = null let _notifyTokenExpiry = 0 +export function _resetNotifyTokenCacheForTest() { + _notifyToken = null + _notifyTokenExpiry = 0 +} + async function getNotifyToken(apiParams) { if (_notifyToken && Date.now() < _notifyTokenExpiry) { return _notifyToken @@ -336,7 +341,7 @@ const resetPasswordCallback = const passwordlessLoginCallback = config.app.login?.passwordless?.callbackURI || '/passwordless-login-callback' -function extractLocaleFromUrl(urlString) { +export function extractLocaleFromUrl(urlString) { if (!urlString) return null try { const path = new URL(urlString, 'https://placeholder').pathname From 17c50151f780b10e133a75c34ff078be5bdb8fd8 Mon Sep 17 00:00:00 2001 From: Ben Chypak Date: Mon, 21 Sep 2026 13:50:40 -0700 Subject: [PATCH 37/55] fix: export getNotifyToken/sendViaB2cCartridge and add tenant check before JWKS in hbs templates Generated project tests import these functions directly from ssr.js, so they must be exported. Also replicates the tenant-ID check that was added in the main template to ensure it fires before createLocalJWKSet when SLAS_JWKS_JSON is set. --- .../assets/bootstrap/js/overrides/app/ssr.js.hbs | 14 ++++++++++++-- .../@salesforce/retail-react-app/app/ssr.js.hbs | 14 ++++++++++++-- 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/packages/pwa-kit-create-app/assets/bootstrap/js/overrides/app/ssr.js.hbs b/packages/pwa-kit-create-app/assets/bootstrap/js/overrides/app/ssr.js.hbs index 81e9e624f8..69db038504 100644 --- a/packages/pwa-kit-create-app/assets/bootstrap/js/overrides/app/ssr.js.hbs +++ b/packages/pwa-kit-create-app/assets/bootstrap/js/overrides/app/ssr.js.hbs @@ -260,7 +260,7 @@ export function _resetNotifyTokenCacheForTest() { _notifyTokenExpiry = 0 } -async function getNotifyToken(apiParams) { +export async function getNotifyToken(apiParams) { if (_notifyToken && Date.now() < _notifyTokenExpiry) { return _notifyToken } @@ -293,7 +293,7 @@ async function getNotifyToken(apiParams) { return _notifyToken } -async function sendViaB2cCartridge(type, recipient, data, apiParams) { +export async function sendViaB2cCartridge(type, recipient, data, apiParams) { const {organizationId, siteId, locale} = apiParams const token = await getNotifyToken(apiParams) const proxy = `${getAppOrigin()}${ @@ -402,6 +402,16 @@ export const validateSlasCallbackToken = async (token) => { const subClaim = payload[CLAIM.ISSUER] const tokens = subClaim.split(DELIMITER.ISSUER) const tenantId = tokens[2] + const configTenantId = getConfig()?.app?.commerceAPI?.parameters?.organizationId?.replace( + /^f_ecom_/, + '' + ) + if (tenantId !== configTenantId) { + throwSlasTokenValidationError( + `The tenant ID in your PWA Kit configuration ("${configTenantId}") does not match the tenant ID in the SLAS callback token ("${tenantId}").`, + 401 + ) + } try { let jwks if (process.env.SLAS_JWKS_JSON) { diff --git a/packages/pwa-kit-create-app/assets/templates/@salesforce/retail-react-app/app/ssr.js.hbs b/packages/pwa-kit-create-app/assets/templates/@salesforce/retail-react-app/app/ssr.js.hbs index 59c46d4022..a7c207766f 100644 --- a/packages/pwa-kit-create-app/assets/templates/@salesforce/retail-react-app/app/ssr.js.hbs +++ b/packages/pwa-kit-create-app/assets/templates/@salesforce/retail-react-app/app/ssr.js.hbs @@ -260,7 +260,7 @@ export function _resetNotifyTokenCacheForTest() { _notifyTokenExpiry = 0 } -async function getNotifyToken(apiParams) { +export async function getNotifyToken(apiParams) { if (_notifyToken && Date.now() < _notifyTokenExpiry) { return _notifyToken } @@ -293,7 +293,7 @@ async function getNotifyToken(apiParams) { return _notifyToken } -async function sendViaB2cCartridge(type, recipient, data, apiParams) { +export async function sendViaB2cCartridge(type, recipient, data, apiParams) { const {organizationId, siteId, locale} = apiParams const token = await getNotifyToken(apiParams) const proxy = `${getAppOrigin()}${ @@ -402,6 +402,16 @@ export const validateSlasCallbackToken = async (token) => { const subClaim = payload[CLAIM.ISSUER] const tokens = subClaim.split(DELIMITER.ISSUER) const tenantId = tokens[2] + const configTenantId = getConfig()?.app?.commerceAPI?.parameters?.organizationId?.replace( + /^f_ecom_/, + '' + ) + if (tenantId !== configTenantId) { + throwSlasTokenValidationError( + `The tenant ID in your PWA Kit configuration ("${configTenantId}") does not match the tenant ID in the SLAS callback token ("${tenantId}").`, + 401 + ) + } try { let jwks if (process.env.SLAS_JWKS_JSON) { From 6caa21bfa9b8a10da2d742ee07af16fa73e25ca1 Mon Sep 17 00:00:00 2001 From: Ben Chypak Date: Tue, 22 Sep 2026 09:43:08 -0700 Subject: [PATCH 38/55] =?UTF-8?q?fix:=20address=20syedshehrozhussain=20rev?= =?UTF-8?q?iew=20=E2=80=94=20ISML=20encoding,=20host=20case,=20open=20rela?= =?UTF-8?q?y=20docs,=20migration=20note?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ISML injection: replace bare ${pdict.*} with in all three email templates (passwordlessLogin, passwordReset, guestOrderLookup) for href attributes and body text values (magicLink, lookupLink, accessCode, orderNo) - storefrontHostProvider case-sensitivity: lowercase entries in getAllowedHosts() so mixed-case pwakitStorefrontHosts values match the normalized caller host - Open relay mitigation: add optional commerceAPI.notifyClientId config that lets merchants use a dedicated private SLAS client for the notify token, keeping c_pwakit_notify off browser guest tokens; README updated with recommended setup and clear warning about the open relay risk of using the public storefront client - README/XML fallback claim: remove incorrect "falls back to B2C instance hostname" language; replace with accurate "magic-link CTA omitted" description - CHANGELOG migration note: document that resetPassword.mode now defaults to 'callback' with no enabled gate — adopters must deploy the cartridge first --- .../bootstrap/js/overrides/app/ssr.js.hbs | 6 ++- .../retail-react-app/app/ssr.js.hbs | 6 ++- .../template-retail-react-app/CHANGELOG.md | 1 + packages/template-retail-react-app/app/ssr.js | 6 ++- .../cartridges/app_pwakit_base/README.md | 48 ++++++++++++------- .../scripts/helpers/storefrontHostProvider.js | 2 +- .../default/email/guestOrderLookup.isml | 8 ++-- .../default/email/passwordReset.isml | 4 +- .../default/email/passwordlessLogin.isml | 6 +-- .../OrganizationPreferences.xml | 5 +- 10 files changed, 59 insertions(+), 33 deletions(-) diff --git a/packages/pwa-kit-create-app/assets/bootstrap/js/overrides/app/ssr.js.hbs b/packages/pwa-kit-create-app/assets/bootstrap/js/overrides/app/ssr.js.hbs index 69db038504..664d7ad53d 100644 --- a/packages/pwa-kit-create-app/assets/bootstrap/js/overrides/app/ssr.js.hbs +++ b/packages/pwa-kit-create-app/assets/bootstrap/js/overrides/app/ssr.js.hbs @@ -264,7 +264,11 @@ export async function getNotifyToken(apiParams) { if (_notifyToken && Date.now() < _notifyTokenExpiry) { return _notifyToken } - const {clientId, organizationId, shortCode, siteId} = apiParams + const {organizationId, shortCode, siteId} = apiParams + // notifyClientId lets merchants use a dedicated private SLAS client for the + // notify scope so c_pwakit_notify never appears on browser guest tokens. + const clientId = + getConfig()?.app?.commerceAPI?.notifyClientId || apiParams.clientId const proxy = `${getAppOrigin()}${ getConfig()?.app?.commerceAPI?.proxyPath || '/mobify/proxy/api' }` diff --git a/packages/pwa-kit-create-app/assets/templates/@salesforce/retail-react-app/app/ssr.js.hbs b/packages/pwa-kit-create-app/assets/templates/@salesforce/retail-react-app/app/ssr.js.hbs index a7c207766f..73e53b5f9d 100644 --- a/packages/pwa-kit-create-app/assets/templates/@salesforce/retail-react-app/app/ssr.js.hbs +++ b/packages/pwa-kit-create-app/assets/templates/@salesforce/retail-react-app/app/ssr.js.hbs @@ -264,7 +264,11 @@ export async function getNotifyToken(apiParams) { if (_notifyToken && Date.now() < _notifyTokenExpiry) { return _notifyToken } - const {clientId, organizationId, shortCode, siteId} = apiParams + const {organizationId, shortCode, siteId} = apiParams + // notifyClientId lets merchants use a dedicated private SLAS client for the + // notify scope so c_pwakit_notify never appears on browser guest tokens. + const clientId = + getConfig()?.app?.commerceAPI?.notifyClientId || apiParams.clientId const proxy = `${getAppOrigin()}${ getConfig()?.app?.commerceAPI?.proxyPath || '/mobify/proxy/api' }` diff --git a/packages/template-retail-react-app/CHANGELOG.md b/packages/template-retail-react-app/CHANGELOG.md index f34045a5d3..7e8ebef9c6 100644 --- a/packages/template-retail-react-app/CHANGELOG.md +++ b/packages/template-retail-react-app/CHANGELOG.md @@ -1,6 +1,7 @@ ## Unreleased - [Feature] Add `app_pwakit_base` B2C cartridge for transactional email delivery via ISML templates and `dw/net/Mail`. GLO access-code emails use the `sfcc.app.order.sendOrderAccessCode` hook; passwordless login, OTP verification, and password reset use the `pwakit-notify` Custom REST API (`POST .../organizations/{orgId}/notify?siteId={siteId}`, guarded by the `c_pwakit_notify` SLAS scope). Deploy with `npm run deploy:cartridge` from `packages/template-retail-react-app` (uses `b2c-cli` with `--reload` to upload the cartridge and re-register the SCAPI endpoint in one step), then import `OrganizationPreferences.xml` to enable the `pwakitNotifyEnabled` and `pwakitStorefrontHosts` preferences, and add `c_pwakit_notify` to your SLAS client's **default Scopes** (not just Allowed Scopes) in Account Manager — the `commerce-sdk-isomorphic` guest-login helpers don't accept a `scope` parameter, so the scope must be configured as a default on the client. See `cartridges/app_pwakit_base/README.md` for full setup steps. [#4012](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/4012) +- [Breaking change] `config/default.js` now sets `mode: 'callback'` for both `passwordless` and `resetPassword` flows (previously `'email'`). This routes password-reset confirmation through the SSR `/callback/resetPassword` route and the new `pwakit-notify` cartridge. **Projects that adopt this config without deploying the cartridge will have broken password resets** — there is no `resetPassword.enabled` guard (unlike `passwordless.enabled`). Either deploy the cartridge before adopting the config change, or explicitly override `resetPassword.mode` back to `'email'`. [#4012](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/4012) - [Bugfix] Fix GLO `/api/guestOrderLookup/verify` and related server endpoints returning 401 when `enablePWAKitPrivateClient` is enabled. The `cc-nx-g_` cookie stores the SLAS refresh token, not the access token — the access token is never stored in any cookie. All GLO server endpoints now read the SLAS JWT from the `Authorization` header sent by the client (via `useAccessToken().getTokenWhenReady()`), which works correctly in both public and private client modes. [#4012](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/4012) ## v10.3.0-dev (Aug 12, 2026) diff --git a/packages/template-retail-react-app/app/ssr.js b/packages/template-retail-react-app/app/ssr.js index 4582629df6..6a8b5bd1bc 100644 --- a/packages/template-retail-react-app/app/ssr.js +++ b/packages/template-retail-react-app/app/ssr.js @@ -255,7 +255,11 @@ export async function getNotifyToken(apiParams) { if (_notifyToken && Date.now() < _notifyTokenExpiry) { return _notifyToken } - const {clientId, organizationId, shortCode, siteId} = apiParams + const {organizationId, shortCode, siteId} = apiParams + // notifyClientId lets merchants use a dedicated private SLAS client for the + // notify scope so c_pwakit_notify never appears on browser guest tokens. + const clientId = + getConfig()?.app?.commerceAPI?.notifyClientId || apiParams.clientId const proxy = `${getAppOrigin()}${ getConfig()?.app?.commerceAPI?.proxyPath || '/mobify/proxy/api' }` diff --git a/packages/template-retail-react-app/cartridges/app_pwakit_base/README.md b/packages/template-retail-react-app/cartridges/app_pwakit_base/README.md index 33673f97c5..c5e5409d56 100644 --- a/packages/template-retail-react-app/cartridges/app_pwakit_base/README.md +++ b/packages/template-retail-react-app/cartridges/app_pwakit_base/README.md @@ -114,38 +114,50 @@ The plugin can also tail `pwakit-notify` log output in real time while you trigg ## SLAS Admin configuration -The `pwakit-notify` Custom REST API (passwordless login, OTP, password reset emails) is called by the PWA Kit SSR server using a **private SLAS client** token. The SSR server calls `loginGuestUserPrivate` with a client secret to mint a server-side guest token, then uses that token to call the Custom REST API endpoint. SCAPI enforces the `c_pwakit_notify` scope on that token before invoking the endpoint. +The `pwakit-notify` Custom REST API (passwordless login, OTP, password reset emails) is called by the PWA Kit SSR server using a SLAS guest token that carries the `c_pwakit_notify` scope. SCAPI enforces this scope before invoking the endpoint. **This is not the same as `enablePWAKitPrivateClient`.** That flag controls whether shoppers use a private client for their own auth flows. The `pwakit-notify` token is a separate server-side credential and the two are independent. -Both public and private SLAS client setups are supported: +> **GLO email is not affected** — the `sendOrderAccessCode` hook is invoked directly by SCAPI and does not use this token at all. If you only need GLO email, you can skip this section entirely. -- **Public client** (`PWA_KIT_SLAS_CLIENT_SECRET` not set): the SSR server mints a guest token via the PKCE guest flow (`loginGuestUser`) using the existing public `clientId`. No secret required; the PKCE authorize + code exchange happens entirely server-side. -- **Private client** (`PWA_KIT_SLAS_CLIENT_SECRET` set): uses `loginGuestUserPrivate` (client_credentials grant) — one fewer round-trip to SLAS on the first call. Prefer this if you already have a private client configured. +### Recommended: dedicated private SLAS client (closes open relay risk) -> **GLO email is not affected** — the `sendOrderAccessCode` hook is invoked directly by SCAPI and does not use this token at all. If you only need GLO email, you can skip this section entirely. +If you add `c_pwakit_notify` to the default scopes of the **same public storefront client** that browsers use, every guest token issued by that client — including tokens minted by anonymous browser visitors — will carry the scope. An attacker who obtains a guest token could then call the notify endpoint directly and send merchant-branded emails to arbitrary addresses. -### 1. Add the scope to your SLAS client +**To prevent this**, configure a dedicated private SLAS API client exclusively for server-side use: -In [Account Manager](https://account.demandware.com) → **API Client**, find the client whose `clientId` matches `config/default.js` → `commerceAPI.parameters.clientId`. +1. In [Account Manager](https://account.demandware.com) → **API Client**, create a new client (or use an existing private client). Add `c_pwakit_notify` to this client's **default scopes**. Do **not** add it to your main public storefront client. +2. Set `PWA_KIT_SLAS_CLIENT_SECRET` on the MRT environment to this client's secret: -Under **Scopes** (the default scopes list, not just the allowed scopes), add: `c_pwakit_notify` + ```bash + pwa-kit-dev push --set-env PWA_KIT_SLAS_CLIENT_SECRET= ... + ``` -Adding it to the default scopes list ensures every guest token issued for this client includes it automatically. This applies to both public and private clients. The `commerce-sdk-isomorphic` guest-login helpers do not accept a `scope` parameter, so the scope must be configured as a default on the client — requesting it at call time is not possible through the SDK. + Or via the MRT Admin UI: **Environments → → Environment Variables**. -### 2. (Private client only) Set the client secret on the SSR server +3. Set `commerceAPI.notifyClientId` in `config/default.js` to this dedicated client's ID: -If you are using a private client, set `PWA_KIT_SLAS_CLIENT_SECRET` on the MRT environment: + ```js + module.exports = { + app: { + commerceAPI: { + parameters: { clientId: '', ... }, + notifyClientId: '' + } + } + } + ``` -```bash -pwa-kit-dev push --set-env PWA_KIT_SLAS_CLIENT_SECRET= ... -``` + When `notifyClientId` is set, the SSR server uses it (with `PWA_KIT_SLAS_CLIENT_SECRET`) to mint the notify token, keeping the scope off all browser guest tokens. + +### Alternative: same client, public flow (simpler but has open relay risk) -Or via the MRT Admin UI: **Environments → → Environment Variables**. +If you accept the open relay risk described above, you can use the same public storefront client without a secret. No additional config is required — just add `c_pwakit_notify` to that client's default scopes. -If this variable is absent, the SSR server automatically falls back to the public PKCE guest flow. +- **Public client** (`PWA_KIT_SLAS_CLIENT_SECRET` not set, `notifyClientId` not set): the SSR server mints a guest token via the PKCE guest flow using the existing public `clientId`. No secret required, but every guest token will carry the scope. +- **Private client, same ID** (`PWA_KIT_SLAS_CLIENT_SECRET` set, `notifyClientId` not set): uses `loginGuestUserPrivate` (client_credentials grant) for the notify token, but the public PKCE flow for the same client still issues tokens with the scope. -### 3. Verify +### Verify Trigger a passwordless login or password reset flow and confirm the email is delivered. If you see `401` errors in the `pwakit-notify` log, the most common causes are: @@ -160,7 +172,7 @@ Trigger a passwordless login or password reset flow and confirm the email is del | Preference | Type | Description | |---|---|---| | `pwakitNotifyEnabled` | Boolean | Set to `false` to disable the cartridge's built-in email delivery (e.g. when using a third-party provider). Defaults to `true` when unset. | -| `pwakitStorefrontHosts` | String | Comma-separated list of allowed public-facing storefront hostnames (no protocol, no trailing slash). Example: `my-store.salesforcecommercecloudsites.com`. The first entry is used for server-side magic-link construction (GLO access code, passwordless magic link). Falls back to the B2C instance hostname if unset — this will be incorrect for headless deployments. | +| `pwakitStorefrontHosts` | String | Comma-separated list of allowed public-facing storefront hostnames (no protocol, no trailing slash). Example: `my-store.salesforcecommercecloudsites.com`. The first entry is used for server-side magic-link construction (GLO access code, passwordless magic link). If unset (or if the caller-supplied host is not in the list), magic-link CTA buttons are omitted from the email — the email is still delivered with an inline access code where applicable. | The sender address is read from the **Site Preferences** `customerServiceEmail` custom attribute. If unset, it falls back to `no-reply@`. diff --git a/packages/template-retail-react-app/cartridges/app_pwakit_base/cartridge/scripts/helpers/storefrontHostProvider.js b/packages/template-retail-react-app/cartridges/app_pwakit_base/cartridge/scripts/helpers/storefrontHostProvider.js index 8e32cce8bb..fa453a30b8 100644 --- a/packages/template-retail-react-app/cartridges/app_pwakit_base/cartridge/scripts/helpers/storefrontHostProvider.js +++ b/packages/template-retail-react-app/cartridges/app_pwakit_base/cartridge/scripts/helpers/storefrontHostProvider.js @@ -72,7 +72,7 @@ function getAllowedHosts() { try { var pref = System.getPreferences().getCustom()['pwakitStorefrontHosts']; if (!pref || !pref.trim()) return []; - return pref.split(',').map(function(h) { return h.trim(); }).filter(Boolean); + return pref.split(',').map(function(h) { return h.trim().toLowerCase(); }).filter(Boolean); } catch (e) { return []; } diff --git a/packages/template-retail-react-app/cartridges/app_pwakit_base/cartridge/templates/default/email/guestOrderLookup.isml b/packages/template-retail-react-app/cartridges/app_pwakit_base/cartridge/templates/default/email/guestOrderLookup.isml index 87db1b93d1..e405789240 100644 --- a/packages/template-retail-react-app/cartridges/app_pwakit_base/cartridge/templates/default/email/guestOrderLookup.isml +++ b/packages/template-retail-react-app/cartridges/app_pwakit_base/cartridge/templates/default/email/guestOrderLookup.isml @@ -10,7 +10,7 @@ -

${Resource.msg('guestOrderLookup.instructionsPrefix', 'email', null)} ${pdict.orderNo}.

+

${Resource.msg('guestOrderLookup.instructionsPrefix', 'email', null)} .

@@ -25,7 +25,7 @@
- ${pdict.accessCode} +
@@ -39,7 +39,7 @@
- ${Resource.msg('guestOrderLookup.buttonText', 'email', null)} + " style="display:inline-block;padding:14px 32px;font-size:14px;font-weight:600;color:#FFFFFF;text-decoration:none;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Arial,sans-serif;">${Resource.msg('guestOrderLookup.buttonText', 'email', null)}
@@ -59,7 +59,7 @@

${Resource.msg('guestOrderLookup.copyUrl', 'email', null)}
- ${pdict.lookupLink}

+ " style="color:#0176D3 !important;word-break:break-all;text-decoration:underline;">

diff --git a/packages/template-retail-react-app/cartridges/app_pwakit_base/cartridge/templates/default/email/passwordReset.isml b/packages/template-retail-react-app/cartridges/app_pwakit_base/cartridge/templates/default/email/passwordReset.isml index 7ebd910222..5539be78b4 100644 --- a/packages/template-retail-react-app/cartridges/app_pwakit_base/cartridge/templates/default/email/passwordReset.isml +++ b/packages/template-retail-react-app/cartridges/app_pwakit_base/cartridge/templates/default/email/passwordReset.isml @@ -21,7 +21,7 @@
- ${Resource.msg('passwordReset.buttonText', 'email', null)} + " style="display:inline-block;padding:14px 32px;font-size:14px;font-weight:600;color:#FFFFFF;text-decoration:none;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Arial,sans-serif;">${Resource.msg('passwordReset.buttonText', 'email', null)}
@@ -41,7 +41,7 @@

${Resource.msg('passwordReset.copyUrl', 'email', null)}
- ${pdict.magicLink}

+ " style="color:#0176D3 !important;word-break:break-all;text-decoration:underline;">

diff --git a/packages/template-retail-react-app/cartridges/app_pwakit_base/cartridge/templates/default/email/passwordlessLogin.isml b/packages/template-retail-react-app/cartridges/app_pwakit_base/cartridge/templates/default/email/passwordlessLogin.isml index 7bd11f9688..a8fddbca5e 100644 --- a/packages/template-retail-react-app/cartridges/app_pwakit_base/cartridge/templates/default/email/passwordlessLogin.isml +++ b/packages/template-retail-react-app/cartridges/app_pwakit_base/cartridge/templates/default/email/passwordlessLogin.isml @@ -26,7 +26,7 @@
- ${pdict.accessCode} +
@@ -41,7 +41,7 @@
- ${Resource.msg('passwordlessLogin.buttonText', 'email', null)} + " style="display:inline-block;padding:14px 32px;font-size:14px;font-weight:600;color:#FFFFFF;text-decoration:none;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Arial,sans-serif;">${Resource.msg('passwordlessLogin.buttonText', 'email', null)}
@@ -61,7 +61,7 @@

${Resource.msg('passwordlessLogin.copyUrl', 'email', null)}
- ${pdict.magicLink}

+ " style="color:#0176D3 !important;word-break:break-all;text-decoration:underline;">

diff --git a/packages/template-retail-react-app/cartridges/app_pwakit_base/staticfiles/cartridge/impex/default/meta/system-objecttype-extensions/OrganizationPreferences.xml b/packages/template-retail-react-app/cartridges/app_pwakit_base/staticfiles/cartridge/impex/default/meta/system-objecttype-extensions/OrganizationPreferences.xml index 70e5ecbd32..c23008a647 100644 --- a/packages/template-retail-react-app/cartridges/app_pwakit_base/staticfiles/cartridge/impex/default/meta/system-objecttype-extensions/OrganizationPreferences.xml +++ b/packages/template-retail-react-app/cartridges/app_pwakit_base/staticfiles/cartridge/impex/default/meta/system-objecttype-extensions/OrganizationPreferences.xml @@ -38,8 +38,9 @@ Example: my-store.salesforcecommercecloudsites.com,staging.my-store.com The REST API caller (PWA Kit) passes its own hostname with each request; the cartridge validates it against this list before constructing magic-link URLs. If the caller supplies - no host the first entry in this list is used. If the list is empty the cartridge falls back - to the B2C instance hostname, which will not resolve correctly in headless deployments. + no host the first entry in this list is used. If the list is empty, or the caller-supplied + host is not in the list, magic-link CTA buttons are omitted from the email — the email is + still delivered with an inline access code where applicable. Note: when the B2C Script API gains native access to the MRT/eCDN hostname this preference will be deprecated in favour of that API. From 7e8773c292aca21e6bb37fd425cef0b40b24e169 Mon Sep 17 00:00:00 2001 From: Ben Chypak Date: Tue, 22 Sep 2026 09:45:17 -0700 Subject: [PATCH 39/55] revert: remove notifyClientId and private-client relay recommendation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A dedicated private SLAS client doesn't actually prevent spam: the SSR BFF layer is equally public, so the real mitigation is rate limiting at the API level — not a client-credential change. Reverts the notifyClientId config option and the README "dedicated client" recommendation added in the previous commit. The other fixes from that commit (ISML encoding, host case-sensitivity, fallback docs, migration note) remain. --- .../bootstrap/js/overrides/app/ssr.js.hbs | 6 +-- .../retail-react-app/app/ssr.js.hbs | 6 +-- packages/template-retail-react-app/app/ssr.js | 6 +-- .../cartridges/app_pwakit_base/README.md | 44 +++++++------------ 4 files changed, 19 insertions(+), 43 deletions(-) diff --git a/packages/pwa-kit-create-app/assets/bootstrap/js/overrides/app/ssr.js.hbs b/packages/pwa-kit-create-app/assets/bootstrap/js/overrides/app/ssr.js.hbs index 664d7ad53d..69db038504 100644 --- a/packages/pwa-kit-create-app/assets/bootstrap/js/overrides/app/ssr.js.hbs +++ b/packages/pwa-kit-create-app/assets/bootstrap/js/overrides/app/ssr.js.hbs @@ -264,11 +264,7 @@ export async function getNotifyToken(apiParams) { if (_notifyToken && Date.now() < _notifyTokenExpiry) { return _notifyToken } - const {organizationId, shortCode, siteId} = apiParams - // notifyClientId lets merchants use a dedicated private SLAS client for the - // notify scope so c_pwakit_notify never appears on browser guest tokens. - const clientId = - getConfig()?.app?.commerceAPI?.notifyClientId || apiParams.clientId + const {clientId, organizationId, shortCode, siteId} = apiParams const proxy = `${getAppOrigin()}${ getConfig()?.app?.commerceAPI?.proxyPath || '/mobify/proxy/api' }` diff --git a/packages/pwa-kit-create-app/assets/templates/@salesforce/retail-react-app/app/ssr.js.hbs b/packages/pwa-kit-create-app/assets/templates/@salesforce/retail-react-app/app/ssr.js.hbs index 73e53b5f9d..a7c207766f 100644 --- a/packages/pwa-kit-create-app/assets/templates/@salesforce/retail-react-app/app/ssr.js.hbs +++ b/packages/pwa-kit-create-app/assets/templates/@salesforce/retail-react-app/app/ssr.js.hbs @@ -264,11 +264,7 @@ export async function getNotifyToken(apiParams) { if (_notifyToken && Date.now() < _notifyTokenExpiry) { return _notifyToken } - const {organizationId, shortCode, siteId} = apiParams - // notifyClientId lets merchants use a dedicated private SLAS client for the - // notify scope so c_pwakit_notify never appears on browser guest tokens. - const clientId = - getConfig()?.app?.commerceAPI?.notifyClientId || apiParams.clientId + const {clientId, organizationId, shortCode, siteId} = apiParams const proxy = `${getAppOrigin()}${ getConfig()?.app?.commerceAPI?.proxyPath || '/mobify/proxy/api' }` diff --git a/packages/template-retail-react-app/app/ssr.js b/packages/template-retail-react-app/app/ssr.js index 6a8b5bd1bc..4582629df6 100644 --- a/packages/template-retail-react-app/app/ssr.js +++ b/packages/template-retail-react-app/app/ssr.js @@ -255,11 +255,7 @@ export async function getNotifyToken(apiParams) { if (_notifyToken && Date.now() < _notifyTokenExpiry) { return _notifyToken } - const {organizationId, shortCode, siteId} = apiParams - // notifyClientId lets merchants use a dedicated private SLAS client for the - // notify scope so c_pwakit_notify never appears on browser guest tokens. - const clientId = - getConfig()?.app?.commerceAPI?.notifyClientId || apiParams.clientId + const {clientId, organizationId, shortCode, siteId} = apiParams const proxy = `${getAppOrigin()}${ getConfig()?.app?.commerceAPI?.proxyPath || '/mobify/proxy/api' }` diff --git a/packages/template-retail-react-app/cartridges/app_pwakit_base/README.md b/packages/template-retail-react-app/cartridges/app_pwakit_base/README.md index c5e5409d56..f5ab5763cf 100644 --- a/packages/template-retail-react-app/cartridges/app_pwakit_base/README.md +++ b/packages/template-retail-react-app/cartridges/app_pwakit_base/README.md @@ -118,46 +118,34 @@ The `pwakit-notify` Custom REST API (passwordless login, OTP, password reset ema **This is not the same as `enablePWAKitPrivateClient`.** That flag controls whether shoppers use a private client for their own auth flows. The `pwakit-notify` token is a separate server-side credential and the two are independent. -> **GLO email is not affected** — the `sendOrderAccessCode` hook is invoked directly by SCAPI and does not use this token at all. If you only need GLO email, you can skip this section entirely. - -### Recommended: dedicated private SLAS client (closes open relay risk) +Both public and private SLAS client setups are supported: -If you add `c_pwakit_notify` to the default scopes of the **same public storefront client** that browsers use, every guest token issued by that client — including tokens minted by anonymous browser visitors — will carry the scope. An attacker who obtains a guest token could then call the notify endpoint directly and send merchant-branded emails to arbitrary addresses. +- **Public client** (`PWA_KIT_SLAS_CLIENT_SECRET` not set): the SSR server mints a guest token via the PKCE guest flow (`loginGuestUser`) using the existing public `clientId`. No secret required; the PKCE authorize + code exchange happens entirely server-side. +- **Private client** (`PWA_KIT_SLAS_CLIENT_SECRET` set): uses `loginGuestUserPrivate` (client_credentials grant) — one fewer round-trip to SLAS on the first call. Prefer this if you already have a private client configured. -**To prevent this**, configure a dedicated private SLAS API client exclusively for server-side use: +> **GLO email is not affected** — the `sendOrderAccessCode` hook is invoked directly by SCAPI and does not use this token at all. If you only need GLO email, you can skip this section entirely. -1. In [Account Manager](https://account.demandware.com) → **API Client**, create a new client (or use an existing private client). Add `c_pwakit_notify` to this client's **default scopes**. Do **not** add it to your main public storefront client. -2. Set `PWA_KIT_SLAS_CLIENT_SECRET` on the MRT environment to this client's secret: +### 1. Add the scope to your SLAS client - ```bash - pwa-kit-dev push --set-env PWA_KIT_SLAS_CLIENT_SECRET= ... - ``` +In [Account Manager](https://account.demandware.com) → **API Client**, find the client whose `clientId` matches `config/default.js` → `commerceAPI.parameters.clientId`. - Or via the MRT Admin UI: **Environments → → Environment Variables**. +Under **Scopes** (the default scopes list, not just the allowed scopes), add: `c_pwakit_notify` -3. Set `commerceAPI.notifyClientId` in `config/default.js` to this dedicated client's ID: +Adding it to the default scopes list ensures every guest token issued for this client includes it automatically. This applies to both public and private clients. The `commerce-sdk-isomorphic` guest-login helpers do not accept a `scope` parameter, so the scope must be configured as a default on the client — requesting it at call time is not possible through the SDK. - ```js - module.exports = { - app: { - commerceAPI: { - parameters: { clientId: '', ... }, - notifyClientId: '' - } - } - } - ``` +### 2. (Private client only) Set the client secret on the SSR server - When `notifyClientId` is set, the SSR server uses it (with `PWA_KIT_SLAS_CLIENT_SECRET`) to mint the notify token, keeping the scope off all browser guest tokens. +If you are using a private client, set `PWA_KIT_SLAS_CLIENT_SECRET` on the MRT environment: -### Alternative: same client, public flow (simpler but has open relay risk) +```bash +pwa-kit-dev push --set-env PWA_KIT_SLAS_CLIENT_SECRET= ... +``` -If you accept the open relay risk described above, you can use the same public storefront client without a secret. No additional config is required — just add `c_pwakit_notify` to that client's default scopes. +Or via the MRT Admin UI: **Environments → → Environment Variables**. -- **Public client** (`PWA_KIT_SLAS_CLIENT_SECRET` not set, `notifyClientId` not set): the SSR server mints a guest token via the PKCE guest flow using the existing public `clientId`. No secret required, but every guest token will carry the scope. -- **Private client, same ID** (`PWA_KIT_SLAS_CLIENT_SECRET` set, `notifyClientId` not set): uses `loginGuestUserPrivate` (client_credentials grant) for the notify token, but the public PKCE flow for the same client still issues tokens with the scope. +If this variable is absent, the SSR server automatically falls back to the public PKCE guest flow. -### Verify +### 3. Verify Trigger a passwordless login or password reset flow and confirm the email is delivered. If you see `401` errors in the `pwakit-notify` log, the most common causes are: From 2125383b783aa411abda1f8947656907e543d568 Mon Sep 17 00:00:00 2001 From: Ben Chypak Date: Tue, 29 Sep 2026 14:56:06 -0700 Subject: [PATCH 40/55] security: validate recipient exists in CustomerMgr before sending notify emails MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add CustomerMgr.getCustomerByLogin() checks to the passwordless-magic-link, password-reset, and otp branches in Notify.js. Each returns a silent 200 (no email sent) when no registered customer is found, preventing crafted payloads from sending transactional emails to arbitrary addresses. glo-access-code is intentionally excluded — GLO is for guest shoppers who have no registered profile by definition. Verified/unverified state is not checked for the otp type; that is managed by SLAS and is not reliably exposed through the B2C scripting API. Updates README with a customer existence validation reference table. --- .../cartridges/app_pwakit_base/README.md | 13 +++++++ .../rest-apis/pwakit-notify/Notify.js | 38 +++++++++++++++++-- 2 files changed, 48 insertions(+), 3 deletions(-) diff --git a/packages/template-retail-react-app/cartridges/app_pwakit_base/README.md b/packages/template-retail-react-app/cartridges/app_pwakit_base/README.md index f5ab5763cf..f434159e2f 100644 --- a/packages/template-retail-react-app/cartridges/app_pwakit_base/README.md +++ b/packages/template-retail-react-app/cartridges/app_pwakit_base/README.md @@ -164,6 +164,19 @@ Trigger a passwordless login or password reset flow and confirm the email is del The sender address is read from the **Site Preferences** `customerServiceEmail` custom attribute. If unset, it falls back to `no-reply@`. +### Customer existence validation + +The `pwakit-notify` endpoint validates the recipient against B2C's customer directory differently depending on the notification type: + +| Type | Customer check | Reason | +|---|---|---| +| `passwordless-magic-link` | Yes — silently succeeds if not found | Passwordless login requires a pre-existing account. Silently succeeding (no email sent, 200 returned) prevents email enumeration. | +| `password-reset` | Yes — silently succeeds if not found | Same as above. | +| `otp` | Yes — silently succeeds if not found | SLAS creates the B2C customer profile at authorize time, before the callback fires. Requiring the profile to exist prevents a crafted payload from sending verification emails to arbitrary addresses. Verified/unverified state is not checked — that is managed by SLAS and is not reliably exposed through the B2C scripting API. | +| `glo-access-code` | **No** | GLO is for shoppers who placed an order as a guest. By definition there is no registered customer profile. | + +The silent-success behaviour for `passwordless-magic-link` and `password-reset` is intentional — the storefront flow completes normally and the shopper receives no indication of whether their email is registered. If you need to audit skipped sends, filter the `pwakit-notify` log for `skipping send`. + ### Known limitation — site URL aliases The magic link path uses `siteId.toLowerCase()` as the URL path segment (e.g. `refarchglobal`). If your PWA Kit storefront defines a `siteAlias` in `config/default.js` (e.g. `RefArchGlobal → global`), the generated link will not match the registered route. Add a CDN-level redirect from `//...` to `//...` to resolve this, or override the `sendOrderAccessCode` hook with an alias-aware implementation. diff --git a/packages/template-retail-react-app/cartridges/app_pwakit_base/cartridge/rest-apis/pwakit-notify/Notify.js b/packages/template-retail-react-app/cartridges/app_pwakit_base/cartridge/rest-apis/pwakit-notify/Notify.js index 0636e9becc..9abe2d5320 100644 --- a/packages/template-retail-react-app/cartridges/app_pwakit_base/cartridge/rest-apis/pwakit-notify/Notify.js +++ b/packages/template-retail-react-app/cartridges/app_pwakit_base/cartridge/rest-apis/pwakit-notify/Notify.js @@ -21,6 +21,7 @@ var Resource = require('dw/web/Resource'); var System = require('dw/system/System'); var Site = require('dw/system/Site'); var Logger = require('dw/system/Logger'); +var CustomerMgr = require('dw/customer/CustomerMgr'); var resolveStorefrontHost = require('*/cartridge/scripts/helpers/storefrontHostProvider'); var buildOrderLookupUrl = require('*/cartridge/scripts/helpers/buildOrderLookupUrl'); @@ -115,6 +116,15 @@ exports.notify = function () { var magicLink; // set for magic-link types; returned in the success response if (type === 'passwordless-magic-link') { + // Passwordless login requires a pre-existing registered account. Succeed + // silently for unrecognised addresses — this prevents email enumeration + // while avoiding delivery to non-existent accounts. + var plCustomer = CustomerMgr.getCustomerByLogin(recipient); + if (!plCustomer || !plCustomer.registered) { + log.debug('passwordless-magic-link: no registered customer found for recipient — skipping send'); + RESTResponseMgr.createSuccess({success: true}).render(); + return; + } if (!body.data.magicLinkPath) { RESTResponseMgr.createError( 400, @@ -149,6 +159,15 @@ exports.notify = function () { templateName = 'email/passwordlessLogin'; context = { magicLink: magicLink, accessCode: inlineAccessCode }; } else if (type === 'password-reset') { + // Password reset requires a pre-existing registered account. Succeed + // silently for unrecognised addresses — this prevents email enumeration + // while avoiding delivery to non-existent accounts. + var prCustomer = CustomerMgr.getCustomerByLogin(recipient); + if (!prCustomer || !prCustomer.registered) { + log.debug('password-reset: no registered customer found for recipient — skipping send'); + RESTResponseMgr.createSuccess({success: true}).render(); + return; + } if (!body.data.magicLinkPath) { RESTResponseMgr.createError( 400, @@ -174,9 +193,19 @@ exports.notify = function () { context = { magicLink: magicLink }; } else if (type === 'otp') { // Used by the account registration / email verification flow. The `token` - // field is a short 6-8 digit SLAS TOTP (not a JWT). The ssr.js caller for - // this type has not yet been implemented in pwa-kit — see sf-next's - // otp-callback.server.ts for the reference implementation. + // field is a short 6-8 digit SLAS TOTP (not a JWT). + // + // SLAS creates the B2C customer profile at authorize time, before this + // callback fires. Requiring the profile to exist in CustomerMgr prevents + // a crafted payload from sending verification emails to arbitrary addresses. + // We do not check verified/unverified state — that is managed by SLAS and + // is not reliably exposed through the B2C scripting API. + var otpCustomer = CustomerMgr.getCustomerByLogin(recipient); + if (!otpCustomer || !otpCustomer.registered) { + log.debug('otp: no registered customer found for recipient — skipping send'); + RESTResponseMgr.createSuccess({success: true}).render(); + return; + } if (!body.data.token) { RESTResponseMgr.createError( 400, @@ -190,6 +219,9 @@ exports.notify = function () { templateName = 'email/registrationVerification'; context = { token: body.data.token }; } else if (type === 'glo-access-code') { + // No customer existence check here: GLO is specifically for shoppers who + // placed an order as a guest without creating an account. By definition + // there is no registered customer profile to look up. if (!body.data.orderNo || !body.data.accessCode) { RESTResponseMgr.createError( 400, From a412e610809ac5a9c7ec5d94bbeb528f72cb5e79 Mon Sep 17 00:00:00 2001 From: Ben Chypak Date: Sun, 4 Oct 2026 23:40:58 -0700 Subject: [PATCH 41/55] security: use dedicated SLAS private client for pwakit-notify token MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the shared shopper client with a dedicated SLAS private client for server-to-server pwakit-notify calls. The notify token is now minted with a separate clientId (commerceAPI.parameters.notifyClientId) and secret (PWA_KIT_NOTIFY_SLAS_CLIENT_SECRET), neither of which is shared with the browser. The shopper client should have c_pwakit_notify removed from its scopes to complete the isolation. - getNotifyToken reads notifyClientId from apiParams and clientSecret from the new env var; throws with a clear message if either is missing - Public PKCE guest flow fallback removed — private client is required - Both ssr.js.hbs templates and default.js.hbs templates updated to match - Tests assert ShopperLogin and loginGuestUserPrivate receive the notify credentials, not the shopper client credentials - README updated: SLAS Admin (not Account Manager), channels requirement, accurate failure behaviour on first use --- .../assets/bootstrap/js/config/default.js.hbs | 1 + .../bootstrap/js/overrides/app/ssr.js.hbs | 31 +++++---- .../retail-react-app/app/ssr.js.hbs | 31 +++++---- .../retail-react-app/config/default.js.hbs | 1 + packages/template-retail-react-app/app/ssr.js | 31 +++++---- .../template-retail-react-app/app/ssr.test.js | 63 ++++++++++++------- .../cartridges/app_pwakit_base/README.md | 41 +++++++----- .../config/default.js | 17 ++--- 8 files changed, 125 insertions(+), 91 deletions(-) diff --git a/packages/pwa-kit-create-app/assets/bootstrap/js/config/default.js.hbs b/packages/pwa-kit-create-app/assets/bootstrap/js/config/default.js.hbs index 3be928ebee..5c05f7937d 100644 --- a/packages/pwa-kit-create-app/assets/bootstrap/js/config/default.js.hbs +++ b/packages/pwa-kit-create-app/assets/bootstrap/js/config/default.js.hbs @@ -144,6 +144,7 @@ module.exports = { proxyPath: '/mobify/proxy/api', parameters: { clientId: '{{answers.project.commerce.clientId}}', + notifyClientId: '', organizationId: '{{answers.project.commerce.organizationId}}', shortCode: '{{answers.project.commerce.shortCode}}', siteId: '{{answers.project.commerce.siteId}}' diff --git a/packages/pwa-kit-create-app/assets/bootstrap/js/overrides/app/ssr.js.hbs b/packages/pwa-kit-create-app/assets/bootstrap/js/overrides/app/ssr.js.hbs index 69db038504..dc22ce31dc 100644 --- a/packages/pwa-kit-create-app/assets/bootstrap/js/overrides/app/ssr.js.hbs +++ b/packages/pwa-kit-create-app/assets/bootstrap/js/overrides/app/ssr.js.hbs @@ -264,29 +264,28 @@ export async function getNotifyToken(apiParams) { if (_notifyToken && Date.now() < _notifyTokenExpiry) { return _notifyToken } - const {clientId, organizationId, shortCode, siteId} = apiParams + const {notifyClientId, organizationId, shortCode, siteId} = apiParams const proxy = `${getAppOrigin()}${ getConfig()?.app?.commerceAPI?.proxyPath || '/mobify/proxy/api' }` + // notifyClientId comes from config (commerceAPI.parameters.notifyClientId); the secret + // is env-only because it's a credential. Both are required — they're a matched SLAS pair. + const clientSecret = process.env.PWA_KIT_NOTIFY_SLAS_CLIENT_SECRET + if (!notifyClientId || !clientSecret) { + throw new Error( + 'commerceAPI.parameters.notifyClientId (config) and PWA_KIT_NOTIFY_SLAS_CLIENT_SECRET (env) are required. Configure a dedicated SLAS private client scoped only to c_pwakit_notify.' + ) + } const slasClient = new ShopperLogin({ - parameters: {clientId, organizationId, shortCode, siteId}, + parameters: {clientId: notifyClientId, organizationId, shortCode, siteId}, proxy, throwOnBadResponse: true }) - // Private client: single round-trip via client_credentials grant. - // Public client: PKCE guest flow — loginGuestUser handles authorize + code exchange - // server-side; no browser needed, redirectURI just needs to be a registered value. - const clientSecret = process.env.PWA_KIT_SLAS_CLIENT_SECRET - const tokenResponse = clientSecret - ? await helpers.loginGuestUserPrivate({ - slasClient, - parameters: {}, - credentials: {clientSecret} - }) - : await helpers.loginGuestUser({ - slasClient, - parameters: {redirectURI: `${getAppOrigin()}/callback`} - }) + const tokenResponse = await helpers.loginGuestUserPrivate({ + slasClient, + parameters: {}, + credentials: {clientSecret} + }) _notifyToken = tokenResponse.access_token // Cache for 25 min — SLAS guest tokens last 30 min, 5 min buffer _notifyTokenExpiry = Date.now() + 25 * 60 * 1000 diff --git a/packages/pwa-kit-create-app/assets/templates/@salesforce/retail-react-app/app/ssr.js.hbs b/packages/pwa-kit-create-app/assets/templates/@salesforce/retail-react-app/app/ssr.js.hbs index a7c207766f..9146057953 100644 --- a/packages/pwa-kit-create-app/assets/templates/@salesforce/retail-react-app/app/ssr.js.hbs +++ b/packages/pwa-kit-create-app/assets/templates/@salesforce/retail-react-app/app/ssr.js.hbs @@ -264,29 +264,28 @@ export async function getNotifyToken(apiParams) { if (_notifyToken && Date.now() < _notifyTokenExpiry) { return _notifyToken } - const {clientId, organizationId, shortCode, siteId} = apiParams + const {notifyClientId, organizationId, shortCode, siteId} = apiParams const proxy = `${getAppOrigin()}${ getConfig()?.app?.commerceAPI?.proxyPath || '/mobify/proxy/api' }` + // notifyClientId comes from config (commerceAPI.parameters.notifyClientId); the secret + // is env-only because it's a credential. Both are required — they're a matched SLAS pair. + const clientSecret = process.env.PWA_KIT_NOTIFY_SLAS_CLIENT_SECRET + if (!notifyClientId || !clientSecret) { + throw new Error( + 'commerceAPI.parameters.notifyClientId (config) and PWA_KIT_NOTIFY_SLAS_CLIENT_SECRET (env) are required. Configure a dedicated SLAS private client scoped only to c_pwakit_notify.' + ) + } const slasClient = new ShopperLogin({ - parameters: {clientId, organizationId, shortCode, siteId}, + parameters: {clientId: notifyClientId, organizationId, shortCode, siteId}, proxy, throwOnBadResponse: true }) - // Private client: single round-trip via client_credentials grant. - // Public client: PKCE guest flow — loginGuestUser handles authorize + code exchange - // server-side; no browser needed, redirectURI just needs to be a registered value. - const clientSecret = process.env.PWA_KIT_SLAS_CLIENT_SECRET - const tokenResponse = clientSecret - ? await helpers.loginGuestUserPrivate({ - slasClient, - parameters: {}, - credentials: {clientSecret} - }) - : await helpers.loginGuestUser({ - slasClient, - parameters: {redirectURI: `${getAppOrigin()}/callback`} - }) + const tokenResponse = await helpers.loginGuestUserPrivate({ + slasClient, + parameters: {}, + credentials: {clientSecret} + }) _notifyToken = tokenResponse.access_token // Cache for 25 min — SLAS guest tokens last 30 min, 5 min buffer _notifyTokenExpiry = Date.now() + 25 * 60 * 1000 diff --git a/packages/pwa-kit-create-app/assets/templates/@salesforce/retail-react-app/config/default.js.hbs b/packages/pwa-kit-create-app/assets/templates/@salesforce/retail-react-app/config/default.js.hbs index a480654e5f..f361abec25 100644 --- a/packages/pwa-kit-create-app/assets/templates/@salesforce/retail-react-app/config/default.js.hbs +++ b/packages/pwa-kit-create-app/assets/templates/@salesforce/retail-react-app/config/default.js.hbs @@ -144,6 +144,7 @@ module.exports = { proxyPath: '/mobify/proxy/api', parameters: { clientId: '{{answers.project.commerce.clientId}}', + notifyClientId: '', organizationId: '{{answers.project.commerce.organizationId}}', shortCode: '{{answers.project.commerce.shortCode}}', siteId: '{{answers.project.commerce.siteId}}' diff --git a/packages/template-retail-react-app/app/ssr.js b/packages/template-retail-react-app/app/ssr.js index 6efb21d512..02da2c4c7f 100644 --- a/packages/template-retail-react-app/app/ssr.js +++ b/packages/template-retail-react-app/app/ssr.js @@ -255,29 +255,28 @@ export async function getNotifyToken(apiParams) { if (_notifyToken && Date.now() < _notifyTokenExpiry) { return _notifyToken } - const {clientId, organizationId, shortCode, siteId} = apiParams + const {notifyClientId, organizationId, shortCode, siteId} = apiParams const proxy = `${getAppOrigin()}${ getConfig()?.app?.commerceAPI?.proxyPath || '/mobify/proxy/api' }` + // notifyClientId comes from config (commerceAPI.parameters.notifyClientId); the secret + // is env-only because it's a credential. Both are required — they're a matched SLAS pair. + const clientSecret = process.env.PWA_KIT_NOTIFY_SLAS_CLIENT_SECRET + if (!notifyClientId || !clientSecret) { + throw new Error( + 'commerceAPI.parameters.notifyClientId (config) and PWA_KIT_NOTIFY_SLAS_CLIENT_SECRET (env) are required. Configure a dedicated SLAS private client scoped only to c_pwakit_notify.' + ) + } const slasClient = new ShopperLogin({ - parameters: {clientId, organizationId, shortCode, siteId}, + parameters: {clientId: notifyClientId, organizationId, shortCode, siteId}, proxy, throwOnBadResponse: true }) - // Private client: single round-trip via client_credentials grant. - // Public client: PKCE guest flow — loginGuestUser handles authorize + code exchange - // server-side; no browser needed, redirectURI just needs to be a registered value. - const clientSecret = process.env.PWA_KIT_SLAS_CLIENT_SECRET - const tokenResponse = clientSecret - ? await helpers.loginGuestUserPrivate({ - slasClient, - parameters: {}, - credentials: {clientSecret} - }) - : await helpers.loginGuestUser({ - slasClient, - parameters: {redirectURI: `${getAppOrigin()}/callback`} - }) + const tokenResponse = await helpers.loginGuestUserPrivate({ + slasClient, + parameters: {}, + credentials: {clientSecret} + }) _notifyToken = tokenResponse.access_token // Cache for 25 min — SLAS guest tokens last 30 min, 5 min buffer _notifyTokenExpiry = Date.now() + 25 * 60 * 1000 diff --git a/packages/template-retail-react-app/app/ssr.test.js b/packages/template-retail-react-app/app/ssr.test.js index 3cc2eba1a6..aa1d86c793 100644 --- a/packages/template-retail-react-app/app/ssr.test.js +++ b/packages/template-retail-react-app/app/ssr.test.js @@ -121,7 +121,7 @@ import { _resetNotifyTokenCacheForTest, extractLocaleFromUrl } from '@salesforce/retail-react-app/app/ssr.js' -import {helpers} from 'commerce-sdk-isomorphic' +import {ShopperLogin, helpers} from 'commerce-sdk-isomorphic' // Mock environment variables const originalEnv = process.env @@ -276,6 +276,7 @@ describe('handleCallback', () => { const TEST_API_PARAMS = { clientId: 'test-client-id', + notifyClientId: 'test-notify-client-id', organizationId: 'f_ecom_test_001', shortCode: 'test-shortcode', siteId: 'RefArch' @@ -284,39 +285,58 @@ const TEST_API_PARAMS = { describe('getNotifyToken', () => { beforeEach(() => { _resetNotifyTokenCacheForTest() - delete process.env.PWA_KIT_SLAS_CLIENT_SECRET + process.env.PWA_KIT_NOTIFY_SLAS_CLIENT_SECRET = 'notify-secret' }) - test('fetches a new token via public client (no secret)', async () => { - helpers.loginGuestUser.mockResolvedValueOnce({access_token: 'guest-token-abc'}) - - const token = await getNotifyToken(TEST_API_PARAMS) - - expect(token).toBe('guest-token-abc') - expect(helpers.loginGuestUser).toHaveBeenCalledTimes(1) - expect(helpers.loginGuestUserPrivate).not.toHaveBeenCalled() + afterEach(() => { + delete process.env.PWA_KIT_NOTIFY_SLAS_CLIENT_SECRET }) - test('fetches a new token via private client when secret is set', async () => { - process.env.PWA_KIT_SLAS_CLIENT_SECRET = 'super-secret' - helpers.loginGuestUserPrivate.mockResolvedValueOnce({access_token: 'private-token-xyz'}) + test('fetches a token via private client using the notify client credentials', async () => { + helpers.loginGuestUserPrivate.mockResolvedValueOnce({access_token: 'notify-token-abc'}) const token = await getNotifyToken(TEST_API_PARAMS) - expect(token).toBe('private-token-xyz') + expect(token).toBe('notify-token-abc') expect(helpers.loginGuestUserPrivate).toHaveBeenCalledTimes(1) expect(helpers.loginGuestUser).not.toHaveBeenCalled() + // Guard against regression: must use the dedicated notify client, not the shopper client + expect(ShopperLogin).toHaveBeenCalledWith( + expect.objectContaining({ + parameters: expect.objectContaining({clientId: 'test-notify-client-id'}) + }) + ) + expect(helpers.loginGuestUserPrivate).toHaveBeenCalledWith( + expect.objectContaining({ + credentials: {clientSecret: 'notify-secret'} + }) + ) + }) + + test('throws when notifyClientId is missing from apiParams', async () => { + const paramsWithoutNotifyClient = {...TEST_API_PARAMS} + delete paramsWithoutNotifyClient.notifyClientId + await expect(getNotifyToken(paramsWithoutNotifyClient)).rejects.toThrow( + 'commerceAPI.parameters.notifyClientId (config) and PWA_KIT_NOTIFY_SLAS_CLIENT_SECRET (env) are required' + ) + }) + + test('throws when PWA_KIT_NOTIFY_SLAS_CLIENT_SECRET is not set', async () => { + delete process.env.PWA_KIT_NOTIFY_SLAS_CLIENT_SECRET + await expect(getNotifyToken(TEST_API_PARAMS)).rejects.toThrow( + 'commerceAPI.parameters.notifyClientId (config) and PWA_KIT_NOTIFY_SLAS_CLIENT_SECRET (env) are required' + ) }) test('returns cached token without making a new SLAS call', async () => { - helpers.loginGuestUser.mockResolvedValue({access_token: 'cached-token'}) + helpers.loginGuestUserPrivate.mockResolvedValue({access_token: 'cached-token'}) const first = await getNotifyToken(TEST_API_PARAMS) const second = await getNotifyToken(TEST_API_PARAMS) expect(first).toBe('cached-token') expect(second).toBe('cached-token') - expect(helpers.loginGuestUser).toHaveBeenCalledTimes(1) + expect(helpers.loginGuestUserPrivate).toHaveBeenCalledTimes(1) }) }) @@ -325,8 +345,8 @@ describe('sendViaB2cCartridge', () => { beforeEach(() => { _resetNotifyTokenCacheForTest() - delete process.env.PWA_KIT_SLAS_CLIENT_SECRET - helpers.loginGuestUser.mockResolvedValue({access_token: 'notify-token'}) + process.env.PWA_KIT_NOTIFY_SLAS_CLIENT_SECRET = 'notify-secret' + helpers.loginGuestUserPrivate.mockResolvedValue({access_token: 'notify-token'}) fetchSpy = jest.spyOn(global, 'fetch').mockImplementation(() => Promise.resolve({ ok: true, @@ -337,6 +357,7 @@ describe('sendViaB2cCartridge', () => { }) afterEach(() => { + delete process.env.PWA_KIT_NOTIFY_SLAS_CLIENT_SECRET fetchSpy.mockRestore() }) @@ -417,7 +438,7 @@ describe('sendViaB2cCartridge', () => { }) test('retries with a fresh token on 401 and succeeds', async () => { - helpers.loginGuestUser + helpers.loginGuestUserPrivate .mockResolvedValueOnce({access_token: 'stale-token'}) .mockResolvedValueOnce({access_token: 'fresh-token'}) // First fetch: 401 with stale token; second fetch: 200 with fresh token @@ -450,10 +471,10 @@ describe('sendViaB2cCartridge', () => { ).rejects.toThrow('pwakit-notify returned 503') // Token should still be cached (503 is not 401) - helpers.loginGuestUser.mockClear() + helpers.loginGuestUserPrivate.mockClear() mockFetchOk() await sendViaB2cCartridge('otp', 'user@example.com', {token: '111'}, TEST_API_PARAMS) - expect(helpers.loginGuestUser).not.toHaveBeenCalled() + expect(helpers.loginGuestUserPrivate).not.toHaveBeenCalled() }) test('appends locale to URL when provided in apiParams', async () => { diff --git a/packages/template-retail-react-app/cartridges/app_pwakit_base/README.md b/packages/template-retail-react-app/cartridges/app_pwakit_base/README.md index f434159e2f..6e686d0b83 100644 --- a/packages/template-retail-react-app/cartridges/app_pwakit_base/README.md +++ b/packages/template-retail-react-app/cartridges/app_pwakit_base/README.md @@ -118,39 +118,52 @@ The `pwakit-notify` Custom REST API (passwordless login, OTP, password reset ema **This is not the same as `enablePWAKitPrivateClient`.** That flag controls whether shoppers use a private client for their own auth flows. The `pwakit-notify` token is a separate server-side credential and the two are independent. -Both public and private SLAS client setups are supported: - -- **Public client** (`PWA_KIT_SLAS_CLIENT_SECRET` not set): the SSR server mints a guest token via the PKCE guest flow (`loginGuestUser`) using the existing public `clientId`. No secret required; the PKCE authorize + code exchange happens entirely server-side. -- **Private client** (`PWA_KIT_SLAS_CLIENT_SECRET` set): uses `loginGuestUserPrivate` (client_credentials grant) — one fewer round-trip to SLAS on the first call. Prefer this if you already have a private client configured. +A dedicated SLAS private client scoped only to `c_pwakit_notify` is required. Using the shopper client would expose that scope to browser tokens, which this setup is specifically designed to prevent. > **GLO email is not affected** — the `sendOrderAccessCode` hook is invoked directly by SCAPI and does not use this token at all. If you only need GLO email, you can skip this section entirely. -### 1. Add the scope to your SLAS client +### 1. Register a dedicated notify SLAS client + +In [SLAS Admin](https://account.demandware.com/dwsso/oauth2/authorize) (separate from Account Manager), create a new private client with: + +- **Private client**: yes (`isPrivateClient: true`) +- **Default Scopes**: `c_pwakit_notify` only — do not include shopper scopes +- **Channels**: add every site ID this storefront serves, or the guest token request will fail +- **Client Secret**: generate and save a secret; you will need it in step 2 -In [Account Manager](https://account.demandware.com) → **API Client**, find the client whose `clientId` matches `config/default.js` → `commerceAPI.parameters.clientId`. +Note the new client's ID. Do **not** add `c_pwakit_notify` to your existing shopper client — if that scope is present on the shopper client, browser tokens could carry it. -Under **Scopes** (the default scopes list, not just the allowed scopes), add: `c_pwakit_notify` +### 2. Configure the client ID and secret -Adding it to the default scopes list ensures every guest token issued for this client includes it automatically. This applies to both public and private clients. The `commerce-sdk-isomorphic` guest-login helpers do not accept a `scope` parameter, so the scope must be configured as a default on the client — requesting it at call time is not possible through the SDK. +Set the client ID in `config/default.js` → `commerceAPI.parameters.notifyClientId`: -### 2. (Private client only) Set the client secret on the SSR server +```js +commerceAPI: { + parameters: { + clientId: '', + notifyClientId: '', + // ... + } +} +``` -If you are using a private client, set `PWA_KIT_SLAS_CLIENT_SECRET` on the MRT environment: +Set the client secret as an environment variable on the MRT environment: ```bash -pwa-kit-dev push --set-env PWA_KIT_SLAS_CLIENT_SECRET= ... +pwa-kit-dev push --set-env PWA_KIT_NOTIFY_SLAS_CLIENT_SECRET= ... ``` Or via the MRT Admin UI: **Environments → → Environment Variables**. -If this variable is absent, the SSR server automatically falls back to the public PKCE guest flow. +If either value is missing, the callback routes will return 500 and log the missing-config error on first use — there is no fallback to the shopper client. ### 3. Verify Trigger a passwordless login or password reset flow and confirm the email is delivered. If you see `401` errors in the `pwakit-notify` log, the most common causes are: -- `c_pwakit_notify` not added to the API client's Allowed Scopes in Account Manager -- `PWA_KIT_SLAS_CLIENT_SECRET` set but incorrect (private client path fails, public fallback not used because the variable is present) +- `c_pwakit_notify` not added to the notify client's Default Scopes in Account Manager +- `commerceAPI.parameters.notifyClientId` in config does not match the registered client +- `PWA_KIT_NOTIFY_SLAS_CLIENT_SECRET` incorrect or not yet deployed - Code version not yet activated after deploying the cartridge --- diff --git a/packages/template-retail-react-app/config/default.js b/packages/template-retail-react-app/config/default.js index 7fb0180430..7c2078e3c5 100644 --- a/packages/template-retail-react-app/config/default.js +++ b/packages/template-retail-react-app/config/default.js @@ -151,7 +151,7 @@ module.exports = { // If the env var `OTP_TOKEN_LENGTH` is set, it will override the config value. Valid values are 6 or 8. Defaults to: 8 tokenLength: validateOtpTokenLength(process.env.OTP_TOKEN_LENGTH), passwordless: { - enabled: false, + enabled: true, mode: 'callback', landingPath: '/passwordless-login-landing', callbackURI: '/passwordless-login-callback' @@ -170,7 +170,7 @@ module.exports = { callbackURI: '/registration-verification-callback' } }, - defaultSite: 'RefArchGlobal', + defaultSite: 'RefArch', siteAliases: { RefArch: 'us', RefArchGlobal: 'global' @@ -179,10 +179,11 @@ module.exports = { commerceAPI: { proxyPath: `/mobify/proxy/api`, parameters: { - clientId: 'c9c45bfd-0ed3-4aa2-9971-40f88962b836', - organizationId: 'f_ecom_zzrf_001', - shortCode: 'kv7kzm78', - siteId: 'RefArchGlobal' + clientId: '11deeece-0b48-4b60-bf1b-d96649faec43', + notifyClientId: 'cac0e789-e1db-4c39-805c-924574c78b79', + organizationId: 'f_ecom_zysg_001', + shortCode: 'sandbox-001', + siteId: 'RefArch' } // Optional: Set the domain for auth cookies to share them across subdomains. // If not set, cookies default to the current host. @@ -223,7 +224,7 @@ module.exports = { }, storeLocatorEnabled: true, guestOrderLookup: { - enabled: false, + enabled: true, orderNumberRegex: '^[a-zA-Z0-9-]{6,32}$', requestCodeThrottle: { windowMs: 60000, @@ -273,7 +274,7 @@ module.exports = { enableHttpOnlySessionCookies: false, proxyConfigs: [ { - host: 'kv7kzm78.api.commercecloud.salesforce.com', + host: 'sandbox-001.api.commercecloud.salesforce.com', path: 'api' }, { From 2958676a765ddd805bbfb7312f945cfd8096af94 Mon Sep 17 00:00:00 2001 From: Ben Chypak Date: Wed, 7 Oct 2026 15:00:11 -0700 Subject: [PATCH 42/55] Revert "security: use dedicated SLAS private client for pwakit-notify token" This reverts commit a412e610809ac5a9c7ec5d94bbeb528f72cb5e79. --- .../assets/bootstrap/js/config/default.js.hbs | 1 - .../bootstrap/js/overrides/app/ssr.js.hbs | 31 ++++----- .../retail-react-app/app/ssr.js.hbs | 31 ++++----- .../retail-react-app/config/default.js.hbs | 1 - packages/template-retail-react-app/app/ssr.js | 31 ++++----- .../template-retail-react-app/app/ssr.test.js | 63 +++++++------------ .../cartridges/app_pwakit_base/README.md | 41 +++++------- .../config/default.js | 17 +++-- 8 files changed, 91 insertions(+), 125 deletions(-) diff --git a/packages/pwa-kit-create-app/assets/bootstrap/js/config/default.js.hbs b/packages/pwa-kit-create-app/assets/bootstrap/js/config/default.js.hbs index 5c05f7937d..3be928ebee 100644 --- a/packages/pwa-kit-create-app/assets/bootstrap/js/config/default.js.hbs +++ b/packages/pwa-kit-create-app/assets/bootstrap/js/config/default.js.hbs @@ -144,7 +144,6 @@ module.exports = { proxyPath: '/mobify/proxy/api', parameters: { clientId: '{{answers.project.commerce.clientId}}', - notifyClientId: '', organizationId: '{{answers.project.commerce.organizationId}}', shortCode: '{{answers.project.commerce.shortCode}}', siteId: '{{answers.project.commerce.siteId}}' diff --git a/packages/pwa-kit-create-app/assets/bootstrap/js/overrides/app/ssr.js.hbs b/packages/pwa-kit-create-app/assets/bootstrap/js/overrides/app/ssr.js.hbs index dc22ce31dc..69db038504 100644 --- a/packages/pwa-kit-create-app/assets/bootstrap/js/overrides/app/ssr.js.hbs +++ b/packages/pwa-kit-create-app/assets/bootstrap/js/overrides/app/ssr.js.hbs @@ -264,28 +264,29 @@ export async function getNotifyToken(apiParams) { if (_notifyToken && Date.now() < _notifyTokenExpiry) { return _notifyToken } - const {notifyClientId, organizationId, shortCode, siteId} = apiParams + const {clientId, organizationId, shortCode, siteId} = apiParams const proxy = `${getAppOrigin()}${ getConfig()?.app?.commerceAPI?.proxyPath || '/mobify/proxy/api' }` - // notifyClientId comes from config (commerceAPI.parameters.notifyClientId); the secret - // is env-only because it's a credential. Both are required — they're a matched SLAS pair. - const clientSecret = process.env.PWA_KIT_NOTIFY_SLAS_CLIENT_SECRET - if (!notifyClientId || !clientSecret) { - throw new Error( - 'commerceAPI.parameters.notifyClientId (config) and PWA_KIT_NOTIFY_SLAS_CLIENT_SECRET (env) are required. Configure a dedicated SLAS private client scoped only to c_pwakit_notify.' - ) - } const slasClient = new ShopperLogin({ - parameters: {clientId: notifyClientId, organizationId, shortCode, siteId}, + parameters: {clientId, organizationId, shortCode, siteId}, proxy, throwOnBadResponse: true }) - const tokenResponse = await helpers.loginGuestUserPrivate({ - slasClient, - parameters: {}, - credentials: {clientSecret} - }) + // Private client: single round-trip via client_credentials grant. + // Public client: PKCE guest flow — loginGuestUser handles authorize + code exchange + // server-side; no browser needed, redirectURI just needs to be a registered value. + const clientSecret = process.env.PWA_KIT_SLAS_CLIENT_SECRET + const tokenResponse = clientSecret + ? await helpers.loginGuestUserPrivate({ + slasClient, + parameters: {}, + credentials: {clientSecret} + }) + : await helpers.loginGuestUser({ + slasClient, + parameters: {redirectURI: `${getAppOrigin()}/callback`} + }) _notifyToken = tokenResponse.access_token // Cache for 25 min — SLAS guest tokens last 30 min, 5 min buffer _notifyTokenExpiry = Date.now() + 25 * 60 * 1000 diff --git a/packages/pwa-kit-create-app/assets/templates/@salesforce/retail-react-app/app/ssr.js.hbs b/packages/pwa-kit-create-app/assets/templates/@salesforce/retail-react-app/app/ssr.js.hbs index 9146057953..a7c207766f 100644 --- a/packages/pwa-kit-create-app/assets/templates/@salesforce/retail-react-app/app/ssr.js.hbs +++ b/packages/pwa-kit-create-app/assets/templates/@salesforce/retail-react-app/app/ssr.js.hbs @@ -264,28 +264,29 @@ export async function getNotifyToken(apiParams) { if (_notifyToken && Date.now() < _notifyTokenExpiry) { return _notifyToken } - const {notifyClientId, organizationId, shortCode, siteId} = apiParams + const {clientId, organizationId, shortCode, siteId} = apiParams const proxy = `${getAppOrigin()}${ getConfig()?.app?.commerceAPI?.proxyPath || '/mobify/proxy/api' }` - // notifyClientId comes from config (commerceAPI.parameters.notifyClientId); the secret - // is env-only because it's a credential. Both are required — they're a matched SLAS pair. - const clientSecret = process.env.PWA_KIT_NOTIFY_SLAS_CLIENT_SECRET - if (!notifyClientId || !clientSecret) { - throw new Error( - 'commerceAPI.parameters.notifyClientId (config) and PWA_KIT_NOTIFY_SLAS_CLIENT_SECRET (env) are required. Configure a dedicated SLAS private client scoped only to c_pwakit_notify.' - ) - } const slasClient = new ShopperLogin({ - parameters: {clientId: notifyClientId, organizationId, shortCode, siteId}, + parameters: {clientId, organizationId, shortCode, siteId}, proxy, throwOnBadResponse: true }) - const tokenResponse = await helpers.loginGuestUserPrivate({ - slasClient, - parameters: {}, - credentials: {clientSecret} - }) + // Private client: single round-trip via client_credentials grant. + // Public client: PKCE guest flow — loginGuestUser handles authorize + code exchange + // server-side; no browser needed, redirectURI just needs to be a registered value. + const clientSecret = process.env.PWA_KIT_SLAS_CLIENT_SECRET + const tokenResponse = clientSecret + ? await helpers.loginGuestUserPrivate({ + slasClient, + parameters: {}, + credentials: {clientSecret} + }) + : await helpers.loginGuestUser({ + slasClient, + parameters: {redirectURI: `${getAppOrigin()}/callback`} + }) _notifyToken = tokenResponse.access_token // Cache for 25 min — SLAS guest tokens last 30 min, 5 min buffer _notifyTokenExpiry = Date.now() + 25 * 60 * 1000 diff --git a/packages/pwa-kit-create-app/assets/templates/@salesforce/retail-react-app/config/default.js.hbs b/packages/pwa-kit-create-app/assets/templates/@salesforce/retail-react-app/config/default.js.hbs index f361abec25..a480654e5f 100644 --- a/packages/pwa-kit-create-app/assets/templates/@salesforce/retail-react-app/config/default.js.hbs +++ b/packages/pwa-kit-create-app/assets/templates/@salesforce/retail-react-app/config/default.js.hbs @@ -144,7 +144,6 @@ module.exports = { proxyPath: '/mobify/proxy/api', parameters: { clientId: '{{answers.project.commerce.clientId}}', - notifyClientId: '', organizationId: '{{answers.project.commerce.organizationId}}', shortCode: '{{answers.project.commerce.shortCode}}', siteId: '{{answers.project.commerce.siteId}}' diff --git a/packages/template-retail-react-app/app/ssr.js b/packages/template-retail-react-app/app/ssr.js index 02da2c4c7f..6efb21d512 100644 --- a/packages/template-retail-react-app/app/ssr.js +++ b/packages/template-retail-react-app/app/ssr.js @@ -255,28 +255,29 @@ export async function getNotifyToken(apiParams) { if (_notifyToken && Date.now() < _notifyTokenExpiry) { return _notifyToken } - const {notifyClientId, organizationId, shortCode, siteId} = apiParams + const {clientId, organizationId, shortCode, siteId} = apiParams const proxy = `${getAppOrigin()}${ getConfig()?.app?.commerceAPI?.proxyPath || '/mobify/proxy/api' }` - // notifyClientId comes from config (commerceAPI.parameters.notifyClientId); the secret - // is env-only because it's a credential. Both are required — they're a matched SLAS pair. - const clientSecret = process.env.PWA_KIT_NOTIFY_SLAS_CLIENT_SECRET - if (!notifyClientId || !clientSecret) { - throw new Error( - 'commerceAPI.parameters.notifyClientId (config) and PWA_KIT_NOTIFY_SLAS_CLIENT_SECRET (env) are required. Configure a dedicated SLAS private client scoped only to c_pwakit_notify.' - ) - } const slasClient = new ShopperLogin({ - parameters: {clientId: notifyClientId, organizationId, shortCode, siteId}, + parameters: {clientId, organizationId, shortCode, siteId}, proxy, throwOnBadResponse: true }) - const tokenResponse = await helpers.loginGuestUserPrivate({ - slasClient, - parameters: {}, - credentials: {clientSecret} - }) + // Private client: single round-trip via client_credentials grant. + // Public client: PKCE guest flow — loginGuestUser handles authorize + code exchange + // server-side; no browser needed, redirectURI just needs to be a registered value. + const clientSecret = process.env.PWA_KIT_SLAS_CLIENT_SECRET + const tokenResponse = clientSecret + ? await helpers.loginGuestUserPrivate({ + slasClient, + parameters: {}, + credentials: {clientSecret} + }) + : await helpers.loginGuestUser({ + slasClient, + parameters: {redirectURI: `${getAppOrigin()}/callback`} + }) _notifyToken = tokenResponse.access_token // Cache for 25 min — SLAS guest tokens last 30 min, 5 min buffer _notifyTokenExpiry = Date.now() + 25 * 60 * 1000 diff --git a/packages/template-retail-react-app/app/ssr.test.js b/packages/template-retail-react-app/app/ssr.test.js index aa1d86c793..3cc2eba1a6 100644 --- a/packages/template-retail-react-app/app/ssr.test.js +++ b/packages/template-retail-react-app/app/ssr.test.js @@ -121,7 +121,7 @@ import { _resetNotifyTokenCacheForTest, extractLocaleFromUrl } from '@salesforce/retail-react-app/app/ssr.js' -import {ShopperLogin, helpers} from 'commerce-sdk-isomorphic' +import {helpers} from 'commerce-sdk-isomorphic' // Mock environment variables const originalEnv = process.env @@ -276,7 +276,6 @@ describe('handleCallback', () => { const TEST_API_PARAMS = { clientId: 'test-client-id', - notifyClientId: 'test-notify-client-id', organizationId: 'f_ecom_test_001', shortCode: 'test-shortcode', siteId: 'RefArch' @@ -285,58 +284,39 @@ const TEST_API_PARAMS = { describe('getNotifyToken', () => { beforeEach(() => { _resetNotifyTokenCacheForTest() - process.env.PWA_KIT_NOTIFY_SLAS_CLIENT_SECRET = 'notify-secret' + delete process.env.PWA_KIT_SLAS_CLIENT_SECRET }) - afterEach(() => { - delete process.env.PWA_KIT_NOTIFY_SLAS_CLIENT_SECRET + test('fetches a new token via public client (no secret)', async () => { + helpers.loginGuestUser.mockResolvedValueOnce({access_token: 'guest-token-abc'}) + + const token = await getNotifyToken(TEST_API_PARAMS) + + expect(token).toBe('guest-token-abc') + expect(helpers.loginGuestUser).toHaveBeenCalledTimes(1) + expect(helpers.loginGuestUserPrivate).not.toHaveBeenCalled() }) - test('fetches a token via private client using the notify client credentials', async () => { - helpers.loginGuestUserPrivate.mockResolvedValueOnce({access_token: 'notify-token-abc'}) + test('fetches a new token via private client when secret is set', async () => { + process.env.PWA_KIT_SLAS_CLIENT_SECRET = 'super-secret' + helpers.loginGuestUserPrivate.mockResolvedValueOnce({access_token: 'private-token-xyz'}) const token = await getNotifyToken(TEST_API_PARAMS) - expect(token).toBe('notify-token-abc') + expect(token).toBe('private-token-xyz') expect(helpers.loginGuestUserPrivate).toHaveBeenCalledTimes(1) expect(helpers.loginGuestUser).not.toHaveBeenCalled() - // Guard against regression: must use the dedicated notify client, not the shopper client - expect(ShopperLogin).toHaveBeenCalledWith( - expect.objectContaining({ - parameters: expect.objectContaining({clientId: 'test-notify-client-id'}) - }) - ) - expect(helpers.loginGuestUserPrivate).toHaveBeenCalledWith( - expect.objectContaining({ - credentials: {clientSecret: 'notify-secret'} - }) - ) - }) - - test('throws when notifyClientId is missing from apiParams', async () => { - const paramsWithoutNotifyClient = {...TEST_API_PARAMS} - delete paramsWithoutNotifyClient.notifyClientId - await expect(getNotifyToken(paramsWithoutNotifyClient)).rejects.toThrow( - 'commerceAPI.parameters.notifyClientId (config) and PWA_KIT_NOTIFY_SLAS_CLIENT_SECRET (env) are required' - ) - }) - - test('throws when PWA_KIT_NOTIFY_SLAS_CLIENT_SECRET is not set', async () => { - delete process.env.PWA_KIT_NOTIFY_SLAS_CLIENT_SECRET - await expect(getNotifyToken(TEST_API_PARAMS)).rejects.toThrow( - 'commerceAPI.parameters.notifyClientId (config) and PWA_KIT_NOTIFY_SLAS_CLIENT_SECRET (env) are required' - ) }) test('returns cached token without making a new SLAS call', async () => { - helpers.loginGuestUserPrivate.mockResolvedValue({access_token: 'cached-token'}) + helpers.loginGuestUser.mockResolvedValue({access_token: 'cached-token'}) const first = await getNotifyToken(TEST_API_PARAMS) const second = await getNotifyToken(TEST_API_PARAMS) expect(first).toBe('cached-token') expect(second).toBe('cached-token') - expect(helpers.loginGuestUserPrivate).toHaveBeenCalledTimes(1) + expect(helpers.loginGuestUser).toHaveBeenCalledTimes(1) }) }) @@ -345,8 +325,8 @@ describe('sendViaB2cCartridge', () => { beforeEach(() => { _resetNotifyTokenCacheForTest() - process.env.PWA_KIT_NOTIFY_SLAS_CLIENT_SECRET = 'notify-secret' - helpers.loginGuestUserPrivate.mockResolvedValue({access_token: 'notify-token'}) + delete process.env.PWA_KIT_SLAS_CLIENT_SECRET + helpers.loginGuestUser.mockResolvedValue({access_token: 'notify-token'}) fetchSpy = jest.spyOn(global, 'fetch').mockImplementation(() => Promise.resolve({ ok: true, @@ -357,7 +337,6 @@ describe('sendViaB2cCartridge', () => { }) afterEach(() => { - delete process.env.PWA_KIT_NOTIFY_SLAS_CLIENT_SECRET fetchSpy.mockRestore() }) @@ -438,7 +417,7 @@ describe('sendViaB2cCartridge', () => { }) test('retries with a fresh token on 401 and succeeds', async () => { - helpers.loginGuestUserPrivate + helpers.loginGuestUser .mockResolvedValueOnce({access_token: 'stale-token'}) .mockResolvedValueOnce({access_token: 'fresh-token'}) // First fetch: 401 with stale token; second fetch: 200 with fresh token @@ -471,10 +450,10 @@ describe('sendViaB2cCartridge', () => { ).rejects.toThrow('pwakit-notify returned 503') // Token should still be cached (503 is not 401) - helpers.loginGuestUserPrivate.mockClear() + helpers.loginGuestUser.mockClear() mockFetchOk() await sendViaB2cCartridge('otp', 'user@example.com', {token: '111'}, TEST_API_PARAMS) - expect(helpers.loginGuestUserPrivate).not.toHaveBeenCalled() + expect(helpers.loginGuestUser).not.toHaveBeenCalled() }) test('appends locale to URL when provided in apiParams', async () => { diff --git a/packages/template-retail-react-app/cartridges/app_pwakit_base/README.md b/packages/template-retail-react-app/cartridges/app_pwakit_base/README.md index 6e686d0b83..f434159e2f 100644 --- a/packages/template-retail-react-app/cartridges/app_pwakit_base/README.md +++ b/packages/template-retail-react-app/cartridges/app_pwakit_base/README.md @@ -118,52 +118,39 @@ The `pwakit-notify` Custom REST API (passwordless login, OTP, password reset ema **This is not the same as `enablePWAKitPrivateClient`.** That flag controls whether shoppers use a private client for their own auth flows. The `pwakit-notify` token is a separate server-side credential and the two are independent. -A dedicated SLAS private client scoped only to `c_pwakit_notify` is required. Using the shopper client would expose that scope to browser tokens, which this setup is specifically designed to prevent. +Both public and private SLAS client setups are supported: -> **GLO email is not affected** — the `sendOrderAccessCode` hook is invoked directly by SCAPI and does not use this token at all. If you only need GLO email, you can skip this section entirely. - -### 1. Register a dedicated notify SLAS client +- **Public client** (`PWA_KIT_SLAS_CLIENT_SECRET` not set): the SSR server mints a guest token via the PKCE guest flow (`loginGuestUser`) using the existing public `clientId`. No secret required; the PKCE authorize + code exchange happens entirely server-side. +- **Private client** (`PWA_KIT_SLAS_CLIENT_SECRET` set): uses `loginGuestUserPrivate` (client_credentials grant) — one fewer round-trip to SLAS on the first call. Prefer this if you already have a private client configured. -In [SLAS Admin](https://account.demandware.com/dwsso/oauth2/authorize) (separate from Account Manager), create a new private client with: +> **GLO email is not affected** — the `sendOrderAccessCode` hook is invoked directly by SCAPI and does not use this token at all. If you only need GLO email, you can skip this section entirely. -- **Private client**: yes (`isPrivateClient: true`) -- **Default Scopes**: `c_pwakit_notify` only — do not include shopper scopes -- **Channels**: add every site ID this storefront serves, or the guest token request will fail -- **Client Secret**: generate and save a secret; you will need it in step 2 +### 1. Add the scope to your SLAS client -Note the new client's ID. Do **not** add `c_pwakit_notify` to your existing shopper client — if that scope is present on the shopper client, browser tokens could carry it. +In [Account Manager](https://account.demandware.com) → **API Client**, find the client whose `clientId` matches `config/default.js` → `commerceAPI.parameters.clientId`. -### 2. Configure the client ID and secret +Under **Scopes** (the default scopes list, not just the allowed scopes), add: `c_pwakit_notify` -Set the client ID in `config/default.js` → `commerceAPI.parameters.notifyClientId`: +Adding it to the default scopes list ensures every guest token issued for this client includes it automatically. This applies to both public and private clients. The `commerce-sdk-isomorphic` guest-login helpers do not accept a `scope` parameter, so the scope must be configured as a default on the client — requesting it at call time is not possible through the SDK. -```js -commerceAPI: { - parameters: { - clientId: '', - notifyClientId: '', - // ... - } -} -``` +### 2. (Private client only) Set the client secret on the SSR server -Set the client secret as an environment variable on the MRT environment: +If you are using a private client, set `PWA_KIT_SLAS_CLIENT_SECRET` on the MRT environment: ```bash -pwa-kit-dev push --set-env PWA_KIT_NOTIFY_SLAS_CLIENT_SECRET= ... +pwa-kit-dev push --set-env PWA_KIT_SLAS_CLIENT_SECRET= ... ``` Or via the MRT Admin UI: **Environments → → Environment Variables**. -If either value is missing, the callback routes will return 500 and log the missing-config error on first use — there is no fallback to the shopper client. +If this variable is absent, the SSR server automatically falls back to the public PKCE guest flow. ### 3. Verify Trigger a passwordless login or password reset flow and confirm the email is delivered. If you see `401` errors in the `pwakit-notify` log, the most common causes are: -- `c_pwakit_notify` not added to the notify client's Default Scopes in Account Manager -- `commerceAPI.parameters.notifyClientId` in config does not match the registered client -- `PWA_KIT_NOTIFY_SLAS_CLIENT_SECRET` incorrect or not yet deployed +- `c_pwakit_notify` not added to the API client's Allowed Scopes in Account Manager +- `PWA_KIT_SLAS_CLIENT_SECRET` set but incorrect (private client path fails, public fallback not used because the variable is present) - Code version not yet activated after deploying the cartridge --- diff --git a/packages/template-retail-react-app/config/default.js b/packages/template-retail-react-app/config/default.js index 7c2078e3c5..7fb0180430 100644 --- a/packages/template-retail-react-app/config/default.js +++ b/packages/template-retail-react-app/config/default.js @@ -151,7 +151,7 @@ module.exports = { // If the env var `OTP_TOKEN_LENGTH` is set, it will override the config value. Valid values are 6 or 8. Defaults to: 8 tokenLength: validateOtpTokenLength(process.env.OTP_TOKEN_LENGTH), passwordless: { - enabled: true, + enabled: false, mode: 'callback', landingPath: '/passwordless-login-landing', callbackURI: '/passwordless-login-callback' @@ -170,7 +170,7 @@ module.exports = { callbackURI: '/registration-verification-callback' } }, - defaultSite: 'RefArch', + defaultSite: 'RefArchGlobal', siteAliases: { RefArch: 'us', RefArchGlobal: 'global' @@ -179,11 +179,10 @@ module.exports = { commerceAPI: { proxyPath: `/mobify/proxy/api`, parameters: { - clientId: '11deeece-0b48-4b60-bf1b-d96649faec43', - notifyClientId: 'cac0e789-e1db-4c39-805c-924574c78b79', - organizationId: 'f_ecom_zysg_001', - shortCode: 'sandbox-001', - siteId: 'RefArch' + clientId: 'c9c45bfd-0ed3-4aa2-9971-40f88962b836', + organizationId: 'f_ecom_zzrf_001', + shortCode: 'kv7kzm78', + siteId: 'RefArchGlobal' } // Optional: Set the domain for auth cookies to share them across subdomains. // If not set, cookies default to the current host. @@ -224,7 +223,7 @@ module.exports = { }, storeLocatorEnabled: true, guestOrderLookup: { - enabled: true, + enabled: false, orderNumberRegex: '^[a-zA-Z0-9-]{6,32}$', requestCodeThrottle: { windowMs: 60000, @@ -274,7 +273,7 @@ module.exports = { enableHttpOnlySessionCookies: false, proxyConfigs: [ { - host: 'sandbox-001.api.commercecloud.salesforce.com', + host: 'kv7kzm78.api.commercecloud.salesforce.com', path: 'api' }, { From 462a2ab8f6d7e41abae58b962a9eae0a046caedb Mon Sep 17 00:00:00 2001 From: Ben Chypak Date: Wed, 7 Oct 2026 15:25:19 -0700 Subject: [PATCH 43/55] fix: escape OTP token in registrationVerification.isml with isprint htmlcontent --- .../templates/default/email/registrationVerification.isml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/template-retail-react-app/cartridges/app_pwakit_base/cartridge/templates/default/email/registrationVerification.isml b/packages/template-retail-react-app/cartridges/app_pwakit_base/cartridge/templates/default/email/registrationVerification.isml index cc74236e22..86e7ba6873 100644 --- a/packages/template-retail-react-app/cartridges/app_pwakit_base/cartridge/templates/default/email/registrationVerification.isml +++ b/packages/template-retail-react-app/cartridges/app_pwakit_base/cartridge/templates/default/email/registrationVerification.isml @@ -20,7 +20,7 @@
- ${pdict.token} +
From ce15eef6cb776f39afa22569b4120973cd824847 Mon Sep 17 00:00:00 2001 From: Ben Chypak Date: Thu, 8 Oct 2026 09:02:04 -0700 Subject: [PATCH 44/55] Reapply "security: use dedicated SLAS private client for pwakit-notify token" This reverts commit 2958676a765ddd805bbfb7312f945cfd8096af94. --- .../assets/bootstrap/js/config/default.js.hbs | 1 + .../bootstrap/js/overrides/app/ssr.js.hbs | 31 +++++---- .../retail-react-app/app/ssr.js.hbs | 31 +++++---- .../retail-react-app/config/default.js.hbs | 1 + packages/template-retail-react-app/app/ssr.js | 31 +++++---- .../template-retail-react-app/app/ssr.test.js | 63 ++++++++++++------- .../cartridges/app_pwakit_base/README.md | 41 +++++++----- .../config/default.js | 17 ++--- 8 files changed, 125 insertions(+), 91 deletions(-) diff --git a/packages/pwa-kit-create-app/assets/bootstrap/js/config/default.js.hbs b/packages/pwa-kit-create-app/assets/bootstrap/js/config/default.js.hbs index 3be928ebee..5c05f7937d 100644 --- a/packages/pwa-kit-create-app/assets/bootstrap/js/config/default.js.hbs +++ b/packages/pwa-kit-create-app/assets/bootstrap/js/config/default.js.hbs @@ -144,6 +144,7 @@ module.exports = { proxyPath: '/mobify/proxy/api', parameters: { clientId: '{{answers.project.commerce.clientId}}', + notifyClientId: '', organizationId: '{{answers.project.commerce.organizationId}}', shortCode: '{{answers.project.commerce.shortCode}}', siteId: '{{answers.project.commerce.siteId}}' diff --git a/packages/pwa-kit-create-app/assets/bootstrap/js/overrides/app/ssr.js.hbs b/packages/pwa-kit-create-app/assets/bootstrap/js/overrides/app/ssr.js.hbs index 69db038504..dc22ce31dc 100644 --- a/packages/pwa-kit-create-app/assets/bootstrap/js/overrides/app/ssr.js.hbs +++ b/packages/pwa-kit-create-app/assets/bootstrap/js/overrides/app/ssr.js.hbs @@ -264,29 +264,28 @@ export async function getNotifyToken(apiParams) { if (_notifyToken && Date.now() < _notifyTokenExpiry) { return _notifyToken } - const {clientId, organizationId, shortCode, siteId} = apiParams + const {notifyClientId, organizationId, shortCode, siteId} = apiParams const proxy = `${getAppOrigin()}${ getConfig()?.app?.commerceAPI?.proxyPath || '/mobify/proxy/api' }` + // notifyClientId comes from config (commerceAPI.parameters.notifyClientId); the secret + // is env-only because it's a credential. Both are required — they're a matched SLAS pair. + const clientSecret = process.env.PWA_KIT_NOTIFY_SLAS_CLIENT_SECRET + if (!notifyClientId || !clientSecret) { + throw new Error( + 'commerceAPI.parameters.notifyClientId (config) and PWA_KIT_NOTIFY_SLAS_CLIENT_SECRET (env) are required. Configure a dedicated SLAS private client scoped only to c_pwakit_notify.' + ) + } const slasClient = new ShopperLogin({ - parameters: {clientId, organizationId, shortCode, siteId}, + parameters: {clientId: notifyClientId, organizationId, shortCode, siteId}, proxy, throwOnBadResponse: true }) - // Private client: single round-trip via client_credentials grant. - // Public client: PKCE guest flow — loginGuestUser handles authorize + code exchange - // server-side; no browser needed, redirectURI just needs to be a registered value. - const clientSecret = process.env.PWA_KIT_SLAS_CLIENT_SECRET - const tokenResponse = clientSecret - ? await helpers.loginGuestUserPrivate({ - slasClient, - parameters: {}, - credentials: {clientSecret} - }) - : await helpers.loginGuestUser({ - slasClient, - parameters: {redirectURI: `${getAppOrigin()}/callback`} - }) + const tokenResponse = await helpers.loginGuestUserPrivate({ + slasClient, + parameters: {}, + credentials: {clientSecret} + }) _notifyToken = tokenResponse.access_token // Cache for 25 min — SLAS guest tokens last 30 min, 5 min buffer _notifyTokenExpiry = Date.now() + 25 * 60 * 1000 diff --git a/packages/pwa-kit-create-app/assets/templates/@salesforce/retail-react-app/app/ssr.js.hbs b/packages/pwa-kit-create-app/assets/templates/@salesforce/retail-react-app/app/ssr.js.hbs index a7c207766f..9146057953 100644 --- a/packages/pwa-kit-create-app/assets/templates/@salesforce/retail-react-app/app/ssr.js.hbs +++ b/packages/pwa-kit-create-app/assets/templates/@salesforce/retail-react-app/app/ssr.js.hbs @@ -264,29 +264,28 @@ export async function getNotifyToken(apiParams) { if (_notifyToken && Date.now() < _notifyTokenExpiry) { return _notifyToken } - const {clientId, organizationId, shortCode, siteId} = apiParams + const {notifyClientId, organizationId, shortCode, siteId} = apiParams const proxy = `${getAppOrigin()}${ getConfig()?.app?.commerceAPI?.proxyPath || '/mobify/proxy/api' }` + // notifyClientId comes from config (commerceAPI.parameters.notifyClientId); the secret + // is env-only because it's a credential. Both are required — they're a matched SLAS pair. + const clientSecret = process.env.PWA_KIT_NOTIFY_SLAS_CLIENT_SECRET + if (!notifyClientId || !clientSecret) { + throw new Error( + 'commerceAPI.parameters.notifyClientId (config) and PWA_KIT_NOTIFY_SLAS_CLIENT_SECRET (env) are required. Configure a dedicated SLAS private client scoped only to c_pwakit_notify.' + ) + } const slasClient = new ShopperLogin({ - parameters: {clientId, organizationId, shortCode, siteId}, + parameters: {clientId: notifyClientId, organizationId, shortCode, siteId}, proxy, throwOnBadResponse: true }) - // Private client: single round-trip via client_credentials grant. - // Public client: PKCE guest flow — loginGuestUser handles authorize + code exchange - // server-side; no browser needed, redirectURI just needs to be a registered value. - const clientSecret = process.env.PWA_KIT_SLAS_CLIENT_SECRET - const tokenResponse = clientSecret - ? await helpers.loginGuestUserPrivate({ - slasClient, - parameters: {}, - credentials: {clientSecret} - }) - : await helpers.loginGuestUser({ - slasClient, - parameters: {redirectURI: `${getAppOrigin()}/callback`} - }) + const tokenResponse = await helpers.loginGuestUserPrivate({ + slasClient, + parameters: {}, + credentials: {clientSecret} + }) _notifyToken = tokenResponse.access_token // Cache for 25 min — SLAS guest tokens last 30 min, 5 min buffer _notifyTokenExpiry = Date.now() + 25 * 60 * 1000 diff --git a/packages/pwa-kit-create-app/assets/templates/@salesforce/retail-react-app/config/default.js.hbs b/packages/pwa-kit-create-app/assets/templates/@salesforce/retail-react-app/config/default.js.hbs index a480654e5f..f361abec25 100644 --- a/packages/pwa-kit-create-app/assets/templates/@salesforce/retail-react-app/config/default.js.hbs +++ b/packages/pwa-kit-create-app/assets/templates/@salesforce/retail-react-app/config/default.js.hbs @@ -144,6 +144,7 @@ module.exports = { proxyPath: '/mobify/proxy/api', parameters: { clientId: '{{answers.project.commerce.clientId}}', + notifyClientId: '', organizationId: '{{answers.project.commerce.organizationId}}', shortCode: '{{answers.project.commerce.shortCode}}', siteId: '{{answers.project.commerce.siteId}}' diff --git a/packages/template-retail-react-app/app/ssr.js b/packages/template-retail-react-app/app/ssr.js index 6efb21d512..02da2c4c7f 100644 --- a/packages/template-retail-react-app/app/ssr.js +++ b/packages/template-retail-react-app/app/ssr.js @@ -255,29 +255,28 @@ export async function getNotifyToken(apiParams) { if (_notifyToken && Date.now() < _notifyTokenExpiry) { return _notifyToken } - const {clientId, organizationId, shortCode, siteId} = apiParams + const {notifyClientId, organizationId, shortCode, siteId} = apiParams const proxy = `${getAppOrigin()}${ getConfig()?.app?.commerceAPI?.proxyPath || '/mobify/proxy/api' }` + // notifyClientId comes from config (commerceAPI.parameters.notifyClientId); the secret + // is env-only because it's a credential. Both are required — they're a matched SLAS pair. + const clientSecret = process.env.PWA_KIT_NOTIFY_SLAS_CLIENT_SECRET + if (!notifyClientId || !clientSecret) { + throw new Error( + 'commerceAPI.parameters.notifyClientId (config) and PWA_KIT_NOTIFY_SLAS_CLIENT_SECRET (env) are required. Configure a dedicated SLAS private client scoped only to c_pwakit_notify.' + ) + } const slasClient = new ShopperLogin({ - parameters: {clientId, organizationId, shortCode, siteId}, + parameters: {clientId: notifyClientId, organizationId, shortCode, siteId}, proxy, throwOnBadResponse: true }) - // Private client: single round-trip via client_credentials grant. - // Public client: PKCE guest flow — loginGuestUser handles authorize + code exchange - // server-side; no browser needed, redirectURI just needs to be a registered value. - const clientSecret = process.env.PWA_KIT_SLAS_CLIENT_SECRET - const tokenResponse = clientSecret - ? await helpers.loginGuestUserPrivate({ - slasClient, - parameters: {}, - credentials: {clientSecret} - }) - : await helpers.loginGuestUser({ - slasClient, - parameters: {redirectURI: `${getAppOrigin()}/callback`} - }) + const tokenResponse = await helpers.loginGuestUserPrivate({ + slasClient, + parameters: {}, + credentials: {clientSecret} + }) _notifyToken = tokenResponse.access_token // Cache for 25 min — SLAS guest tokens last 30 min, 5 min buffer _notifyTokenExpiry = Date.now() + 25 * 60 * 1000 diff --git a/packages/template-retail-react-app/app/ssr.test.js b/packages/template-retail-react-app/app/ssr.test.js index 3cc2eba1a6..aa1d86c793 100644 --- a/packages/template-retail-react-app/app/ssr.test.js +++ b/packages/template-retail-react-app/app/ssr.test.js @@ -121,7 +121,7 @@ import { _resetNotifyTokenCacheForTest, extractLocaleFromUrl } from '@salesforce/retail-react-app/app/ssr.js' -import {helpers} from 'commerce-sdk-isomorphic' +import {ShopperLogin, helpers} from 'commerce-sdk-isomorphic' // Mock environment variables const originalEnv = process.env @@ -276,6 +276,7 @@ describe('handleCallback', () => { const TEST_API_PARAMS = { clientId: 'test-client-id', + notifyClientId: 'test-notify-client-id', organizationId: 'f_ecom_test_001', shortCode: 'test-shortcode', siteId: 'RefArch' @@ -284,39 +285,58 @@ const TEST_API_PARAMS = { describe('getNotifyToken', () => { beforeEach(() => { _resetNotifyTokenCacheForTest() - delete process.env.PWA_KIT_SLAS_CLIENT_SECRET + process.env.PWA_KIT_NOTIFY_SLAS_CLIENT_SECRET = 'notify-secret' }) - test('fetches a new token via public client (no secret)', async () => { - helpers.loginGuestUser.mockResolvedValueOnce({access_token: 'guest-token-abc'}) - - const token = await getNotifyToken(TEST_API_PARAMS) - - expect(token).toBe('guest-token-abc') - expect(helpers.loginGuestUser).toHaveBeenCalledTimes(1) - expect(helpers.loginGuestUserPrivate).not.toHaveBeenCalled() + afterEach(() => { + delete process.env.PWA_KIT_NOTIFY_SLAS_CLIENT_SECRET }) - test('fetches a new token via private client when secret is set', async () => { - process.env.PWA_KIT_SLAS_CLIENT_SECRET = 'super-secret' - helpers.loginGuestUserPrivate.mockResolvedValueOnce({access_token: 'private-token-xyz'}) + test('fetches a token via private client using the notify client credentials', async () => { + helpers.loginGuestUserPrivate.mockResolvedValueOnce({access_token: 'notify-token-abc'}) const token = await getNotifyToken(TEST_API_PARAMS) - expect(token).toBe('private-token-xyz') + expect(token).toBe('notify-token-abc') expect(helpers.loginGuestUserPrivate).toHaveBeenCalledTimes(1) expect(helpers.loginGuestUser).not.toHaveBeenCalled() + // Guard against regression: must use the dedicated notify client, not the shopper client + expect(ShopperLogin).toHaveBeenCalledWith( + expect.objectContaining({ + parameters: expect.objectContaining({clientId: 'test-notify-client-id'}) + }) + ) + expect(helpers.loginGuestUserPrivate).toHaveBeenCalledWith( + expect.objectContaining({ + credentials: {clientSecret: 'notify-secret'} + }) + ) + }) + + test('throws when notifyClientId is missing from apiParams', async () => { + const paramsWithoutNotifyClient = {...TEST_API_PARAMS} + delete paramsWithoutNotifyClient.notifyClientId + await expect(getNotifyToken(paramsWithoutNotifyClient)).rejects.toThrow( + 'commerceAPI.parameters.notifyClientId (config) and PWA_KIT_NOTIFY_SLAS_CLIENT_SECRET (env) are required' + ) + }) + + test('throws when PWA_KIT_NOTIFY_SLAS_CLIENT_SECRET is not set', async () => { + delete process.env.PWA_KIT_NOTIFY_SLAS_CLIENT_SECRET + await expect(getNotifyToken(TEST_API_PARAMS)).rejects.toThrow( + 'commerceAPI.parameters.notifyClientId (config) and PWA_KIT_NOTIFY_SLAS_CLIENT_SECRET (env) are required' + ) }) test('returns cached token without making a new SLAS call', async () => { - helpers.loginGuestUser.mockResolvedValue({access_token: 'cached-token'}) + helpers.loginGuestUserPrivate.mockResolvedValue({access_token: 'cached-token'}) const first = await getNotifyToken(TEST_API_PARAMS) const second = await getNotifyToken(TEST_API_PARAMS) expect(first).toBe('cached-token') expect(second).toBe('cached-token') - expect(helpers.loginGuestUser).toHaveBeenCalledTimes(1) + expect(helpers.loginGuestUserPrivate).toHaveBeenCalledTimes(1) }) }) @@ -325,8 +345,8 @@ describe('sendViaB2cCartridge', () => { beforeEach(() => { _resetNotifyTokenCacheForTest() - delete process.env.PWA_KIT_SLAS_CLIENT_SECRET - helpers.loginGuestUser.mockResolvedValue({access_token: 'notify-token'}) + process.env.PWA_KIT_NOTIFY_SLAS_CLIENT_SECRET = 'notify-secret' + helpers.loginGuestUserPrivate.mockResolvedValue({access_token: 'notify-token'}) fetchSpy = jest.spyOn(global, 'fetch').mockImplementation(() => Promise.resolve({ ok: true, @@ -337,6 +357,7 @@ describe('sendViaB2cCartridge', () => { }) afterEach(() => { + delete process.env.PWA_KIT_NOTIFY_SLAS_CLIENT_SECRET fetchSpy.mockRestore() }) @@ -417,7 +438,7 @@ describe('sendViaB2cCartridge', () => { }) test('retries with a fresh token on 401 and succeeds', async () => { - helpers.loginGuestUser + helpers.loginGuestUserPrivate .mockResolvedValueOnce({access_token: 'stale-token'}) .mockResolvedValueOnce({access_token: 'fresh-token'}) // First fetch: 401 with stale token; second fetch: 200 with fresh token @@ -450,10 +471,10 @@ describe('sendViaB2cCartridge', () => { ).rejects.toThrow('pwakit-notify returned 503') // Token should still be cached (503 is not 401) - helpers.loginGuestUser.mockClear() + helpers.loginGuestUserPrivate.mockClear() mockFetchOk() await sendViaB2cCartridge('otp', 'user@example.com', {token: '111'}, TEST_API_PARAMS) - expect(helpers.loginGuestUser).not.toHaveBeenCalled() + expect(helpers.loginGuestUserPrivate).not.toHaveBeenCalled() }) test('appends locale to URL when provided in apiParams', async () => { diff --git a/packages/template-retail-react-app/cartridges/app_pwakit_base/README.md b/packages/template-retail-react-app/cartridges/app_pwakit_base/README.md index 4ac3ea0dd1..1a42bad495 100644 --- a/packages/template-retail-react-app/cartridges/app_pwakit_base/README.md +++ b/packages/template-retail-react-app/cartridges/app_pwakit_base/README.md @@ -118,39 +118,52 @@ The `pwakit-notify` Custom REST API (passwordless login, OTP, password reset ema **This is not the same as `enablePWAKitPrivateClient`.** That flag controls whether shoppers use a private client for their own auth flows. The `pwakit-notify` token is a separate server-side credential and the two are independent. -Both public and private SLAS client setups are supported: - -- **Public client** (`PWA_KIT_SLAS_CLIENT_SECRET` not set): the SSR server mints a guest token via the PKCE guest flow (`loginGuestUser`) using the existing public `clientId`. No secret required; the PKCE authorize + code exchange happens entirely server-side. -- **Private client** (`PWA_KIT_SLAS_CLIENT_SECRET` set): uses `loginGuestUserPrivate` (client_credentials grant) — one fewer round-trip to SLAS on the first call. Prefer this if you already have a private client configured. +A dedicated SLAS private client scoped only to `c_pwakit_notify` is required. Using the shopper client would expose that scope to browser tokens, which this setup is specifically designed to prevent. > **GLO email is not affected** — the `sendOrderAccessCode` hook is invoked directly by SCAPI and does not use this token at all. If you only need GLO email, you can skip this section entirely. -### 1. Add the scope to your SLAS client +### 1. Register a dedicated notify SLAS client + +In [SLAS Admin](https://account.demandware.com/dwsso/oauth2/authorize) (separate from Account Manager), create a new private client with: + +- **Private client**: yes (`isPrivateClient: true`) +- **Default Scopes**: `c_pwakit_notify` only — do not include shopper scopes +- **Channels**: add every site ID this storefront serves, or the guest token request will fail +- **Client Secret**: generate and save a secret; you will need it in step 2 -In [Account Manager](https://account.demandware.com) → **API Client**, find the client whose `clientId` matches `config/default.js` → `commerceAPI.parameters.clientId`. +Note the new client's ID. Do **not** add `c_pwakit_notify` to your existing shopper client — if that scope is present on the shopper client, browser tokens could carry it. -Under **Scopes** (the default scopes list, not just the allowed scopes), add: `c_pwakit_notify` +### 2. Configure the client ID and secret -Adding it to the default scopes list ensures every guest token issued for this client includes it automatically. This applies to both public and private clients. The `commerce-sdk-isomorphic` guest-login helpers do not accept a `scope` parameter, so the scope must be configured as a default on the client — requesting it at call time is not possible through the SDK. +Set the client ID in `config/default.js` → `commerceAPI.parameters.notifyClientId`: -### 2. (Private client only) Set the client secret on the SSR server +```js +commerceAPI: { + parameters: { + clientId: '', + notifyClientId: '', + // ... + } +} +``` -If you are using a private client, set `PWA_KIT_SLAS_CLIENT_SECRET` on the MRT environment: +Set the client secret as an environment variable on the MRT environment: ```bash -pwa-kit-dev push --set-env PWA_KIT_SLAS_CLIENT_SECRET= ... +pwa-kit-dev push --set-env PWA_KIT_NOTIFY_SLAS_CLIENT_SECRET= ... ``` Or via the MRT Admin UI: **Environments → → Environment Variables**. -If this variable is absent, the SSR server automatically falls back to the public PKCE guest flow. +If either value is missing, the callback routes will return 500 and log the missing-config error on first use — there is no fallback to the shopper client. ### 3. Verify Trigger a passwordless login or password reset flow and confirm the email is delivered. If you see `401` errors in the `pwakit-notify` log, the most common causes are: -- `c_pwakit_notify` not added to the API client's Allowed Scopes in Account Manager -- `PWA_KIT_SLAS_CLIENT_SECRET` set but incorrect (private client path fails, public fallback not used because the variable is present) +- `c_pwakit_notify` not added to the notify client's Default Scopes in Account Manager +- `commerceAPI.parameters.notifyClientId` in config does not match the registered client +- `PWA_KIT_NOTIFY_SLAS_CLIENT_SECRET` incorrect or not yet deployed - Code version not yet activated after deploying the cartridge --- diff --git a/packages/template-retail-react-app/config/default.js b/packages/template-retail-react-app/config/default.js index 7fb0180430..7c2078e3c5 100644 --- a/packages/template-retail-react-app/config/default.js +++ b/packages/template-retail-react-app/config/default.js @@ -151,7 +151,7 @@ module.exports = { // If the env var `OTP_TOKEN_LENGTH` is set, it will override the config value. Valid values are 6 or 8. Defaults to: 8 tokenLength: validateOtpTokenLength(process.env.OTP_TOKEN_LENGTH), passwordless: { - enabled: false, + enabled: true, mode: 'callback', landingPath: '/passwordless-login-landing', callbackURI: '/passwordless-login-callback' @@ -170,7 +170,7 @@ module.exports = { callbackURI: '/registration-verification-callback' } }, - defaultSite: 'RefArchGlobal', + defaultSite: 'RefArch', siteAliases: { RefArch: 'us', RefArchGlobal: 'global' @@ -179,10 +179,11 @@ module.exports = { commerceAPI: { proxyPath: `/mobify/proxy/api`, parameters: { - clientId: 'c9c45bfd-0ed3-4aa2-9971-40f88962b836', - organizationId: 'f_ecom_zzrf_001', - shortCode: 'kv7kzm78', - siteId: 'RefArchGlobal' + clientId: '11deeece-0b48-4b60-bf1b-d96649faec43', + notifyClientId: 'cac0e789-e1db-4c39-805c-924574c78b79', + organizationId: 'f_ecom_zysg_001', + shortCode: 'sandbox-001', + siteId: 'RefArch' } // Optional: Set the domain for auth cookies to share them across subdomains. // If not set, cookies default to the current host. @@ -223,7 +224,7 @@ module.exports = { }, storeLocatorEnabled: true, guestOrderLookup: { - enabled: false, + enabled: true, orderNumberRegex: '^[a-zA-Z0-9-]{6,32}$', requestCodeThrottle: { windowMs: 60000, @@ -273,7 +274,7 @@ module.exports = { enableHttpOnlySessionCookies: false, proxyConfigs: [ { - host: 'kv7kzm78.api.commercecloud.salesforce.com', + host: 'sandbox-001.api.commercecloud.salesforce.com', path: 'api' }, { From 38a48079a8b0a13d36ee2eb90d83c9bff2077c01 Mon Sep 17 00:00:00 2001 From: Ben Chypak Date: Thu, 8 Oct 2026 09:04:16 -0700 Subject: [PATCH 45/55] fix: address security review findings from sf-jie-dai MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove stale SIH section from README (SIH doesn't block proxy-path browser calls; dedicated notify client is the correct mitigation) - Revert generated app passwordless/resetPassword defaults to 'email' mode — generated projects don't scaffold the cartridge, so 'callback' default causes 500 on password reset out of the box - Add /passwordless-login-callback, /reset-password-callback, and /registration-verification-callback to hybrid proxy routingRules so SLAS POSTs to these paths are handled by MRT in hybrid setups --- .../assets/bootstrap/js/config/default.js.hbs | 8 +++---- .../retail-react-app/config/default.js.hbs | 8 +++---- packages/template-retail-react-app/app/ssr.js | 2 +- .../cartridges/app_pwakit_base/README.md | 22 ------------------- 4 files changed, 9 insertions(+), 31 deletions(-) diff --git a/packages/pwa-kit-create-app/assets/bootstrap/js/config/default.js.hbs b/packages/pwa-kit-create-app/assets/bootstrap/js/config/default.js.hbs index 5c05f7937d..b97df63ecd 100644 --- a/packages/pwa-kit-create-app/assets/bootstrap/js/config/default.js.hbs +++ b/packages/pwa-kit-create-app/assets/bootstrap/js/config/default.js.hbs @@ -84,8 +84,8 @@ module.exports = { {{else}} enabled: false, {{/if}} - // The mode for passwordless login. Valid values include 'email|callback'. Defaults to: 'callback' - mode: 'callback', + // The mode for passwordless login. Valid values include 'email|callback'. Defaults to: 'email' + mode: 'email', // The callback URI, which can be an absolute URL (including third-party URIs) or a relative path set up by the developer. // Required when mode is set to 'callback'. If the env var `PASSWORDLESS_LOGIN_CALLBACK_URI` is set, it will override the config value. callbackURI: @@ -109,8 +109,8 @@ module.exports = { redirectURI: process.env.SOCIAL_LOGIN_REDIRECT_URI || '/social-callback' }, resetPassword: { - // The mode for reset password. Valid values include 'email|callback'. Defaults to: 'callback' - mode: 'callback', + // The mode for reset password. Valid values include 'email|callback'. Defaults to: 'email' + mode: 'email', // The callback URI, which can be an absolute URL (including third-party URIs) or a relative path set up by the developer. // Required when mode is set to 'callback'. If the env var `RESET_PASSWORD_CALLBACK_URI` is set, it will override the config value. callbackURI: process.env.RESET_PASSWORD_CALLBACK_URI || '/reset-password-callback', diff --git a/packages/pwa-kit-create-app/assets/templates/@salesforce/retail-react-app/config/default.js.hbs b/packages/pwa-kit-create-app/assets/templates/@salesforce/retail-react-app/config/default.js.hbs index f361abec25..47ad382b3c 100644 --- a/packages/pwa-kit-create-app/assets/templates/@salesforce/retail-react-app/config/default.js.hbs +++ b/packages/pwa-kit-create-app/assets/templates/@salesforce/retail-react-app/config/default.js.hbs @@ -84,8 +84,8 @@ module.exports = { {{else}} enabled: false, {{/if}} - // The mode for passwordless login. Valid values include 'email|callback'. Defaults to: 'callback' - mode: 'callback', + // The mode for passwordless login. Valid values include 'email|callback'. Defaults to: 'email' + mode: 'email', // The callback URI, which can be an absolute URL (including third-party URIs) or a relative path set up by the developer. // Required when mode is set to 'callback'. If the env var `PASSWORDLESS_LOGIN_CALLBACK_URI` is set, it will override the config value. callbackURI: @@ -109,8 +109,8 @@ module.exports = { redirectURI: process.env.SOCIAL_LOGIN_REDIRECT_URI || '/social-callback' }, resetPassword: { - // The mode for reset password. Valid values include 'email|callback'. Defaults to: 'callback' - mode: 'callback', + // The mode for reset password. Valid values include 'email|callback'. Defaults to: 'email' + mode: 'email', // The callback URI, which can be an absolute URL (including third-party URIs) or a relative path set up by the developer. // Required when mode is set to 'callback'. If the env var `RESET_PASSWORD_CALLBACK_URI` is set, it will override the config value. callbackURI: process.env.RESET_PASSWORD_CALLBACK_URI || '/reset-password-callback', diff --git a/packages/template-retail-react-app/app/ssr.js b/packages/template-retail-react-app/app/ssr.js index 02da2c4c7f..a2345e57bc 100644 --- a/packages/template-retail-react-app/app/ssr.js +++ b/packages/template-retail-react-app/app/ssr.js @@ -234,7 +234,7 @@ const options = { // * https://developer.salesforce.com/docs/commerce/commerce-api/references/cdn-api-process-apis?meta=MrtRules // * https://developer.salesforce.com/docs/commerce/commerce-api/guide/ecdn-rules-for-phased-headless-rollout.html routingRules: [ - 'http.request.uri.path eq "/" or http.request.uri.path matches "^/callback" or http.request.uri.path matches "^/mobify" or http.request.uri.path matches "^/worker.js" or http.request.uri.path matches "^/(\\\\w+)/([-\\\\w]+)/$" or http.request.uri.path matches "^/(\\\\w+)/([-\\\\w]+)/login" or http.request.uri.path matches "^/(\\\\w+)/([-\\\\w]+)/reset-password" or http.request.uri.path matches "^/(\\\\w+)/([-\\\\w]+)/registration" or http.request.uri.path matches "^/(\\\\w+)/([-\\\\w]+)/account" or http.request.uri.path matches "^/(\\\\w+)/([-\\\\w]+)/account/orders" or http.request.uri.path matches "^/(\\\\w+)/([-\\\\w]+)/account/orders/(\\\\w+)" or http.request.uri.path matches "^/(\\\\w+)/([-\\\\w]+)/account/wishlist" or http.request.uri.path matches "^/(\\\\w+)/([-\\\\w]+)/product/(\\\\w+)" or http.request.uri.path matches "^/(\\\\w+)/([-\\\\w]+)/search" or http.request.uri.path matches "^/(\\\\w+)/([-\\\\w]+)/category/(\\\\w+)" or http.request.uri.path matches "^/(\\\\w+)/([-\\\\w]+)/order-status" or http.request.uri.path matches "^/(\\\\w+)/([-\\\\w]+)/page/(\\\\w+)" or http.request.uri.path matches "^/(\\\\w+)/([-\\\\w]+)/page-viewer/(\\\\w+)" or http.request.uri.path matches "^/(\\\\w+)/([-\\\\w]+)/order-lookup$" or http.request.uri.path matches "^/(\\\\w+)/([-\\\\w]+)/order-lookup/verify/([^/]+)"' + 'http.request.uri.path eq "/" or http.request.uri.path matches "^/callback" or http.request.uri.path matches "^/passwordless-login-callback" or http.request.uri.path matches "^/reset-password-callback" or http.request.uri.path matches "^/registration-verification-callback" or http.request.uri.path matches "^/mobify" or http.request.uri.path matches "^/worker.js" or http.request.uri.path matches "^/(\\\\w+)/([-\\\\w]+)/$" or http.request.uri.path matches "^/(\\\\w+)/([-\\\\w]+)/login" or http.request.uri.path matches "^/(\\\\w+)/([-\\\\w]+)/reset-password" or http.request.uri.path matches "^/(\\\\w+)/([-\\\\w]+)/registration" or http.request.uri.path matches "^/(\\\\w+)/([-\\\\w]+)/account" or http.request.uri.path matches "^/(\\\\w+)/([-\\\\w]+)/account/orders" or http.request.uri.path matches "^/(\\\\w+)/([-\\\\w]+)/account/orders/(\\\\w+)" or http.request.uri.path matches "^/(\\\\w+)/([-\\\\w]+)/account/wishlist" or http.request.uri.path matches "^/(\\\\w+)/([-\\\\w]+)/product/(\\\\w+)" or http.request.uri.path matches "^/(\\\\w+)/([-\\\\w]+)/search" or http.request.uri.path matches "^/(\\\\w+)/([-\\\\w]+)/category/(\\\\w+)" or http.request.uri.path matches "^/(\\\\w+)/([-\\\\w]+)/order-status" or http.request.uri.path matches "^/(\\\\w+)/([-\\\\w]+)/page/(\\\\w+)" or http.request.uri.path matches "^/(\\\\w+)/([-\\\\w]+)/page-viewer/(\\\\w+)" or http.request.uri.path matches "^/(\\\\w+)/([-\\\\w]+)/order-lookup$" or http.request.uri.path matches "^/(\\\\w+)/([-\\\\w]+)/order-lookup/verify/([^/]+)"' ] } } diff --git a/packages/template-retail-react-app/cartridges/app_pwakit_base/README.md b/packages/template-retail-react-app/cartridges/app_pwakit_base/README.md index 1a42bad495..6e686d0b83 100644 --- a/packages/template-retail-react-app/cartridges/app_pwakit_base/README.md +++ b/packages/template-retail-react-app/cartridges/app_pwakit_base/README.md @@ -168,28 +168,6 @@ Trigger a passwordless login or password reset flow and confirm the email is del --- -## Security: restricting direct browser access to pwakit-notify - -The `pwakit-notify` Custom REST API accepts any valid SLAS token that carries the `c_pwakit_notify` scope. Because this scope is configured on the same client used by the browser for shopper flows, a browser or bot can mint a guest token and call `pwakit-notify` directly, bypassing the SSR — an open email relay risk. - -**Starting with B2C Commerce 26.10**, close this vector using **SCAPI Request Identification (SCAPI Identity Header / SIH)**: Salesforce MRT attaches a shared-secret header to every request it forwards to SCAPI, and an eCDN firewall rule on the SCAPI zone blocks requests that arrive without it. - -### Setup - -1. In Business Manager, enable the **Managed Runtime SCAPI Request Identification** feature switch on each instance (Development, Staging, Production separately). Wait for the setup job to finish before adding a block rule for that instance. -2. In Business Manager on your **Production** instance, go to **Administration → Sites → Embedded CDN Settings → SCAPI zone → Security Rules**. A Salesforce-managed allow rule will appear for each instance where you enabled the switch. -3. On the same **Security Rules** tab, create a custom firewall rule for each instance with: - - **URI Path contains ``** (e.g. `f_ecom_blnd_prd` for Production — same segment shown in the Salesforce-managed allow rule for that instance) - - **URI Path contains `/custom/pwakit-notify`** -4. Start with the `log` action. Monitor matches in Security Analytics for at least one full business cycle before switching to `block`. -5. Drag the rule into place directly after the Salesforce-managed allow rule for that instance. Rule order matters — the allow rule must evaluate first so legitimate MRT traffic is passed before the block rule runs. - -> **Important:** Use `/custom/pwakit-notify` as the path matcher, **not `/shopper`**. The `pwakit-notify` endpoint is a Custom REST API (`/custom/...`) and won't be matched by a `/shopper`-scoped rule. - -For full setup steps and rollback instructions, see [Restrict SCAPI Access to Managed Runtime Traffic](https://developer.salesforce.com/docs/commerce/pwa-kit-managed-runtime/guide/scapi-request-identification.html). - ---- - ## Configuration | Preference | Type | Description | From 4f9232ac1681c053905c644ea318ee600780d98e Mon Sep 17 00:00:00 2001 From: Ben Chypak Date: Thu, 8 Oct 2026 09:20:31 -0700 Subject: [PATCH 46/55] chore: revert generated defaults to callback mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Callback mode is the intended default for generated apps — the cartridge is the expected setup, and an error signals that it is missing rather than silently falling back to the old email flow. --- .../assets/bootstrap/js/config/default.js.hbs | 4 ++-- .../@salesforce/retail-react-app/config/default.js.hbs | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/pwa-kit-create-app/assets/bootstrap/js/config/default.js.hbs b/packages/pwa-kit-create-app/assets/bootstrap/js/config/default.js.hbs index b97df63ecd..f9fd2ce6ae 100644 --- a/packages/pwa-kit-create-app/assets/bootstrap/js/config/default.js.hbs +++ b/packages/pwa-kit-create-app/assets/bootstrap/js/config/default.js.hbs @@ -85,7 +85,7 @@ module.exports = { enabled: false, {{/if}} // The mode for passwordless login. Valid values include 'email|callback'. Defaults to: 'email' - mode: 'email', + mode: 'callback', // The callback URI, which can be an absolute URL (including third-party URIs) or a relative path set up by the developer. // Required when mode is set to 'callback'. If the env var `PASSWORDLESS_LOGIN_CALLBACK_URI` is set, it will override the config value. callbackURI: @@ -110,7 +110,7 @@ module.exports = { }, resetPassword: { // The mode for reset password. Valid values include 'email|callback'. Defaults to: 'email' - mode: 'email', + mode: 'callback', // The callback URI, which can be an absolute URL (including third-party URIs) or a relative path set up by the developer. // Required when mode is set to 'callback'. If the env var `RESET_PASSWORD_CALLBACK_URI` is set, it will override the config value. callbackURI: process.env.RESET_PASSWORD_CALLBACK_URI || '/reset-password-callback', diff --git a/packages/pwa-kit-create-app/assets/templates/@salesforce/retail-react-app/config/default.js.hbs b/packages/pwa-kit-create-app/assets/templates/@salesforce/retail-react-app/config/default.js.hbs index 47ad382b3c..2b7af13bdf 100644 --- a/packages/pwa-kit-create-app/assets/templates/@salesforce/retail-react-app/config/default.js.hbs +++ b/packages/pwa-kit-create-app/assets/templates/@salesforce/retail-react-app/config/default.js.hbs @@ -85,7 +85,7 @@ module.exports = { enabled: false, {{/if}} // The mode for passwordless login. Valid values include 'email|callback'. Defaults to: 'email' - mode: 'email', + mode: 'callback', // The callback URI, which can be an absolute URL (including third-party URIs) or a relative path set up by the developer. // Required when mode is set to 'callback'. If the env var `PASSWORDLESS_LOGIN_CALLBACK_URI` is set, it will override the config value. callbackURI: @@ -110,7 +110,7 @@ module.exports = { }, resetPassword: { // The mode for reset password. Valid values include 'email|callback'. Defaults to: 'email' - mode: 'email', + mode: 'callback', // The callback URI, which can be an absolute URL (including third-party URIs) or a relative path set up by the developer. // Required when mode is set to 'callback'. If the env var `RESET_PASSWORD_CALLBACK_URI` is set, it will override the config value. callbackURI: process.env.RESET_PASSWORD_CALLBACK_URI || '/reset-password-callback', From dd875299735960ae1cab88e4ed871cae42f34e09 Mon Sep 17 00:00:00 2001 From: Ben Chypak Date: Thu, 8 Oct 2026 11:33:09 -0700 Subject: [PATCH 47/55] refactor: replace org-level host allowlist with per-site pwakitStorefrontHost preference MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace storefrontHostProvider.js (org-level comma-separated allowlist + caller-supplied callerHost validation) with getStorefrontHost.js (reads pwakitStorefrontHost site preference directly — no allowlist, no caller host) - Remove callerHost from ssr.js request body and schema - Remove glo-access-code type from pwakit-notify REST API (GLO goes through the sendOrderAccessCode hook, not the REST API) - Add SitePreferences.xml registering pwakitStorefrontHost; remove pwakitStorefrontHosts from OrganizationPreferences.xml - Wire buildPlainText multipart plain-text body in sendNotification.js - Make siteId query param required in schema - Update README to reflect site preference, new setup step, and removal of glo-access-code from the REST API type table --- packages/template-retail-react-app/app/ssr.js | 3 +- .../cartridges/app_pwakit_base/README.md | 15 ++-- .../rest-apis/pwakit-notify/Notify.js | 60 +++----------- .../rest-apis/pwakit-notify/schema.yaml | 30 +------ .../scripts/helpers/getStorefrontHost.js | 60 ++++++++++++++ .../scripts/helpers/sendNotification.js | 35 +++++--- .../scripts/helpers/storefrontHostProvider.js | 81 ------------------- .../scripts/hooks/sendOrderAccessCode.js | 6 +- .../OrganizationPreferences.xml | 20 ----- .../SitePreferences.xml | 46 +++++++++++ 10 files changed, 158 insertions(+), 198 deletions(-) create mode 100644 packages/template-retail-react-app/cartridges/app_pwakit_base/cartridge/scripts/helpers/getStorefrontHost.js delete mode 100644 packages/template-retail-react-app/cartridges/app_pwakit_base/cartridge/scripts/helpers/storefrontHostProvider.js create mode 100644 packages/template-retail-react-app/cartridges/app_pwakit_base/staticfiles/cartridge/impex/default/meta/system-objecttype-extensions/SitePreferences.xml diff --git a/packages/template-retail-react-app/app/ssr.js b/packages/template-retail-react-app/app/ssr.js index a2345e57bc..c7cfa98e42 100644 --- a/packages/template-retail-react-app/app/ssr.js +++ b/packages/template-retail-react-app/app/ssr.js @@ -297,8 +297,7 @@ export async function sendViaB2cCartridge(type, recipient, data, apiParams) { const requestBody = JSON.stringify({ type, recipient, - data, - callerHost: new URL(getAppOrigin()).hostname + data }) let res = await fetch(url, { method: 'POST', diff --git a/packages/template-retail-react-app/cartridges/app_pwakit_base/README.md b/packages/template-retail-react-app/cartridges/app_pwakit_base/README.md index 6e686d0b83..8d0b602b59 100644 --- a/packages/template-retail-react-app/cartridges/app_pwakit_base/README.md +++ b/packages/template-retail-react-app/cartridges/app_pwakit_base/README.md @@ -80,9 +80,15 @@ sfcc-ci meta:import \ --directory meta ``` -This registers `pwakitNotifyEnabled` and `pwakitStorefrontHosts` under **Administration → Global Preferences → Custom Preferences → PWA Kit**. +This registers `pwakitNotifyEnabled` under **Administration → Global Preferences → Custom Preferences → PWA Kit**, and `pwakitStorefrontHost` under **Merchant Tools → Custom Preferences → pwakit** (site-level). -**4. Activate the code version** +**4. Configure `pwakitStorefrontHost` per site** (one-time, via Business Manager) + +In Business Manager → **Merchant Tools → Custom Preferences → pwakit**, set `Storefront Host` to the public-facing hostname of your storefront (no protocol, no trailing slash). Example: `my-store.salesforcecommercecloudsites.com`. This is used to construct magic-link and order-lookup URLs in transactional emails. + +If left unset, magic-link CTA buttons are omitted from emails — the email is still delivered with an inline access code where applicable. + +**5. Activate the code version** The `deploy:cartridge` script uses `--reload`, which handles activation automatically. No separate activation step is needed after the first deploy. @@ -172,8 +178,8 @@ Trigger a passwordless login or password reset flow and confirm the email is del | Preference | Type | Description | |---|---|---| -| `pwakitNotifyEnabled` | Boolean | Set to `false` to disable the cartridge's built-in email delivery (e.g. when using a third-party provider). Defaults to `true` when unset. | -| `pwakitStorefrontHosts` | String | Comma-separated list of allowed public-facing storefront hostnames (no protocol, no trailing slash). Example: `my-store.salesforcecommercecloudsites.com`. The first entry is used for server-side magic-link construction (GLO access code, passwordless magic link). If unset (or if the caller-supplied host is not in the list), magic-link CTA buttons are omitted from the email — the email is still delivered with an inline access code where applicable. | +| `pwakitNotifyEnabled` | Boolean (org) | Set to `false` to disable the cartridge's built-in email delivery (e.g. when using a third-party provider). Defaults to `true` when unset. | +| `pwakitStorefrontHost` | String (site) | Public-facing hostname of this storefront site (no protocol, no trailing slash). Example: `my-store.salesforcecommercecloudsites.com`. Used to construct magic-link and order-lookup URLs in transactional emails. Configure once per site in **Merchant Tools → Custom Preferences → pwakit**. If unset, magic-link CTA buttons are omitted — the email is still delivered with an inline access code where applicable. | The sender address is read from the **Site Preferences** `customerServiceEmail` custom attribute. If unset, it falls back to `no-reply@`. @@ -186,7 +192,6 @@ The `pwakit-notify` endpoint validates the recipient against B2C's customer dire | `passwordless-magic-link` | Yes — silently succeeds if not found | Passwordless login requires a pre-existing account. Silently succeeding (no email sent, 200 returned) prevents email enumeration. | | `password-reset` | Yes — silently succeeds if not found | Same as above. | | `otp` | Yes — silently succeeds if not found | SLAS creates the B2C customer profile at authorize time, before the callback fires. Requiring the profile to exist prevents a crafted payload from sending verification emails to arbitrary addresses. Verified/unverified state is not checked — that is managed by SLAS and is not reliably exposed through the B2C scripting API. | -| `glo-access-code` | **No** | GLO is for shoppers who placed an order as a guest. By definition there is no registered customer profile. | The silent-success behaviour for `passwordless-magic-link` and `password-reset` is intentional — the storefront flow completes normally and the shopper receives no indication of whether their email is registered. If you need to audit skipped sends, filter the `pwakit-notify` log for `skipping send`. diff --git a/packages/template-retail-react-app/cartridges/app_pwakit_base/cartridge/rest-apis/pwakit-notify/Notify.js b/packages/template-retail-react-app/cartridges/app_pwakit_base/cartridge/rest-apis/pwakit-notify/Notify.js index 9abe2d5320..9250312ec7 100644 --- a/packages/template-retail-react-app/cartridges/app_pwakit_base/cartridge/rest-apis/pwakit-notify/Notify.js +++ b/packages/template-retail-react-app/cartridges/app_pwakit_base/cartridge/rest-apis/pwakit-notify/Notify.js @@ -19,14 +19,20 @@ var RESTResponseMgr = require('dw/system/RESTResponseMgr'); var Resource = require('dw/web/Resource'); var System = require('dw/system/System'); -var Site = require('dw/system/Site'); var Logger = require('dw/system/Logger'); var CustomerMgr = require('dw/customer/CustomerMgr'); -var resolveStorefrontHost = require('*/cartridge/scripts/helpers/storefrontHostProvider'); -var buildOrderLookupUrl = require('*/cartridge/scripts/helpers/buildOrderLookupUrl'); +var getStorefrontHost = require('*/cartridge/scripts/helpers/getStorefrontHost'); var log = Logger.getLogger('pwakit-notify', 'pwakit-notify'); +function renderHostNotConfigured() { + RESTResponseMgr.createError( + 503, + 'host-not-configured', + 'Service Unavailable', + 'pwakitStorefrontHost is not configured for this site' + ).render(); +} /** * Returns true when the pwakitNotify feature is enabled. @@ -83,7 +89,6 @@ exports.notify = function () { var type = body.type; var recipient = body.recipient; - var callerHost = body.callerHost || null; if (!type || !recipient) { RESTResponseMgr.createError( @@ -134,14 +139,9 @@ exports.notify = function () { ).render(); return; } - var plHost = resolveStorefrontHost(callerHost); + var plHost = getStorefrontHost(); if (!plHost) { - RESTResponseMgr.createError( - 503, - 'host-not-configured', - 'Service Unavailable', - 'pwakitStorefrontHosts is not configured or callerHost is not in the allowlist' - ).render(); + renderHostNotConfigured(); return; } magicLink = 'https://' + plHost + body.data.magicLinkPath; @@ -177,14 +177,9 @@ exports.notify = function () { ).render(); return; } - var prHost = resolveStorefrontHost(callerHost); + var prHost = getStorefrontHost(); if (!prHost) { - RESTResponseMgr.createError( - 503, - 'host-not-configured', - 'Service Unavailable', - 'pwakitStorefrontHosts is not configured or callerHost is not in the allowlist' - ).render(); + renderHostNotConfigured(); return; } magicLink = 'https://' + prHost + body.data.magicLinkPath; @@ -218,35 +213,6 @@ exports.notify = function () { subject = Resource.msg('registrationVerification.subject', 'email', 'Your Verification Code'); templateName = 'email/registrationVerification'; context = { token: body.data.token }; - } else if (type === 'glo-access-code') { - // No customer existence check here: GLO is specifically for shoppers who - // placed an order as a guest without creating an account. By definition - // there is no registered customer profile to look up. - if (!body.data.orderNo || !body.data.accessCode) { - RESTResponseMgr.createError( - 400, - 'missing-fields', - 'Bad Request', - 'Missing data.orderNo or data.accessCode for glo-access-code type' - ).render(); - return; - } - subject = Resource.msg('guestOrderLookup.subject', 'email', 'Your Order Access Code'); - templateName = 'email/guestOrderLookup'; - var gloHost = resolveStorefrontHost(callerHost); - if (!gloHost) { - RESTResponseMgr.createError( - 503, - 'host-not-configured', - 'Service Unavailable', - 'pwakitStorefrontHosts is not configured or callerHost is not in the allowlist' - ).render(); - return; - } - var gloSiteId = Site.getCurrent().ID.toLowerCase(); - var gloLocale = (request.locale || Site.getCurrent().defaultLocale || 'en_US').replace(/_/g, '-'); - var gloLink = buildOrderLookupUrl(gloHost, gloSiteId, gloLocale, body.data.orderNo, body.data.accessCode); - context = { orderNo: body.data.orderNo, accessCode: body.data.accessCode, lookupLink: gloLink }; } else { RESTResponseMgr.createError( 400, diff --git a/packages/template-retail-react-app/cartridges/app_pwakit_base/cartridge/rest-apis/pwakit-notify/schema.yaml b/packages/template-retail-react-app/cartridges/app_pwakit_base/cartridge/rest-apis/pwakit-notify/schema.yaml index 65f2d3396c..fad7c30fad 100644 --- a/packages/template-retail-react-app/cartridges/app_pwakit_base/cartridge/rest-apis/pwakit-notify/schema.yaml +++ b/packages/template-retail-react-app/cartridges/app_pwakit_base/cartridge/rest-apis/pwakit-notify/schema.yaml @@ -19,7 +19,7 @@ paths: parameters: - in: query name: siteId - required: false + required: true schema: type: string minLength: 1 @@ -43,9 +43,6 @@ paths: enum: [passwordless-magic-link] recipient: type: string - callerHost: - type: string - description: Public hostname of the storefront making the request (e.g. my-store.salesforcecommercecloudsites.com). Validated against the pwakitStorefrontHosts allowlist before being used to construct magic-link URLs. data: type: object required: [magicLinkPath] @@ -62,9 +59,6 @@ paths: enum: [password-reset] recipient: type: string - callerHost: - type: string - description: Public hostname of the storefront making the request (e.g. my-store.salesforcecommercecloudsites.com). Validated against the pwakitStorefrontHosts allowlist before being used to construct magic-link URLs. data: type: object required: [magicLinkPath] @@ -81,34 +75,12 @@ paths: enum: [otp] recipient: type: string - callerHost: - type: string - description: Public hostname of the storefront making the request (e.g. my-store.salesforcecommercecloudsites.com). Validated against the pwakitStorefrontHosts allowlist before being used to construct magic-link URLs. data: type: object required: [token] properties: token: type: string - - type: object - required: [type, recipient, data] - properties: - type: - type: string - enum: [glo-access-code] - recipient: - type: string - callerHost: - type: string - description: Public hostname of the storefront making the request (e.g. my-store.salesforcecommercecloudsites.com). Validated against the pwakitStorefrontHosts allowlist before being used to construct magic-link URLs. - data: - type: object - required: [orderNo, accessCode] - properties: - orderNo: - type: string - accessCode: - type: string responses: '200': description: Notification sent successfully diff --git a/packages/template-retail-react-app/cartridges/app_pwakit_base/cartridge/scripts/helpers/getStorefrontHost.js b/packages/template-retail-react-app/cartridges/app_pwakit_base/cartridge/scripts/helpers/getStorefrontHost.js new file mode 100644 index 0000000000..ba67416084 --- /dev/null +++ b/packages/template-retail-react-app/cartridges/app_pwakit_base/cartridge/scripts/helpers/getStorefrontHost.js @@ -0,0 +1,60 @@ +/** + * Copyright 2026 Salesforce, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +'use strict'; + +var Site = require('dw/system/Site'); +var Logger = require('dw/system/Logger'); + +var log = Logger.getLogger('pwakit-notify', 'pwakit-notify'); + +/** + * Returns the public-facing hostname for the current storefront site. + * + * Resolution order: + * 1. (Future) Site.getCurrent().mrtExternalHostname — platform-provided eCDN hostname. + * Uncomment the lines below when this API becomes available. + * 2. pwakitStorefrontHost site preference — configure per-site in Business Manager: + * Merchant Tools > Custom Preferences > pwakit > Storefront Host. + * + * Returns null when no host can be resolved. Callers should treat null as a + * configuration gap and omit lookup-link CTAs from the email. + * + * @returns {string|null} Hostname without protocol or trailing slash, or null if unresolvable. + */ +function getStorefrontHost() { + // Future: return the platform-provided eCDN hostname when available. + // var mrtHost = Site.getCurrent().mrtExternalHostname; + // if (mrtHost) return mrtHost.replace(/^https?:\/\//, '').replace(/\/$/, ''); + + try { + var host = Site.getCurrent().getCustomPreferenceValue('pwakitStorefrontHost'); + if (host && host.trim()) { + return host.trim().replace(/^https?:\/\//, '').replace(/\/$/, ''); + } + } catch (e) { + log.warn('getStorefrontHost: could not read pwakitStorefrontHost preference: {0}', e.message); + } + + log.warn( + 'getStorefrontHost: pwakitStorefrontHost is not configured for site "{0}". ' + + 'Set it in Business Manager > Merchant Tools > Custom Preferences > pwakit.', + Site.getCurrent().ID + ); + return null; +} + +module.exports = getStorefrontHost; diff --git a/packages/template-retail-react-app/cartridges/app_pwakit_base/cartridge/scripts/helpers/sendNotification.js b/packages/template-retail-react-app/cartridges/app_pwakit_base/cartridge/scripts/helpers/sendNotification.js index 00506a0c4c..d78eb37973 100644 --- a/packages/template-retail-react-app/cartridges/app_pwakit_base/cartridge/scripts/helpers/sendNotification.js +++ b/packages/template-retail-react-app/cartridges/app_pwakit_base/cartridge/scripts/helpers/sendNotification.js @@ -21,6 +21,7 @@ var Template = require('dw/util/Template'); var HashMap = require('dw/util/HashMap'); var Site = require('dw/system/Site'); var Logger = require('dw/system/Logger'); +var Resource = require('dw/web/Resource'); var log = Logger.getLogger('pwakit-notify', 'pwakit-notify'); @@ -41,6 +42,24 @@ function renderTemplate(templateName, templateContext) { return template.render(map).text; } +/** + * Returns a minimal plain-text fallback for the given notification type and context. + * Screen readers and plain-mail clients need this; spam filters score multipart higher. + */ +function buildPlainText(templateName, context) { + if (context.magicLink) { + return Resource.msg('email.plaintext.useThisLink', 'email', 'Use this link to continue:') + '\n' + context.magicLink; + } + if (context.token) { + return Resource.msg('email.plaintext.verificationCode', 'email', 'Your verification code:') + ' ' + context.token; + } + if (context.accessCode) { + return Resource.msg('email.plaintext.orderAccessCode', 'email', 'Your order access code:') + ' ' + context.accessCode + + '\n' + Resource.msg('email.plaintext.orderNumber', 'email', 'Order:') + ' ' + (context.orderNo || ''); + } + return ''; +} + /** * Sends a transactional notification. * @@ -59,21 +78,15 @@ function renderTemplate(templateName, templateContext) { */ function send(recipient, subject, templateName, context) { try { - var site = Site.getCurrent(); - var senderEmail = site.getCustomPreferenceValue('customerServiceEmail') - || 'no-reply@' + site.httpsHostName; + var senderEmail = Site.current.getCustomPreferenceValue('customerServiceEmail') + || 'no-reply@' + Site.current.httpsHostName; log.info('Sending notification: template={0}, subject={1}, from={2}', templateName, subject, senderEmail); - var localeRaw = request.locale || site.defaultLocale || 'en'; - var localeId = String(localeRaw).replace(/_/g, '-'); - var ctx = context || {}; - var templateContext = { localeId: localeId, emailTitle: subject }; - var ctxKeys = Object.keys(ctx); - for (var k = 0; k < ctxKeys.length; k++) { - templateContext[ctxKeys[k]] = ctx[ctxKeys[k]]; - } + var localeId = (request.locale || Site.current.defaultLocale || 'en').replace(/_/g, '-'); + var templateContext = Object.assign({ localeId: localeId, emailTitle: subject }, context || {}); var htmlBody = renderTemplate(templateName, templateContext); + var plainBody = buildPlainText(templateName, context || {}); var mail = new Mail(); mail.addTo(recipient); diff --git a/packages/template-retail-react-app/cartridges/app_pwakit_base/cartridge/scripts/helpers/storefrontHostProvider.js b/packages/template-retail-react-app/cartridges/app_pwakit_base/cartridge/scripts/helpers/storefrontHostProvider.js deleted file mode 100644 index fa453a30b8..0000000000 --- a/packages/template-retail-react-app/cartridges/app_pwakit_base/cartridge/scripts/helpers/storefrontHostProvider.js +++ /dev/null @@ -1,81 +0,0 @@ -/** - * Copyright 2026 Salesforce, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -'use strict'; - -var System = require('dw/system/System'); -var Logger = require('dw/system/Logger'); - -var log = Logger.getLogger('pwakit-notify', 'pwakit-notify'); - -/** - * Resolves the public storefront hostname for magic-link URL construction. - * - * Validates a caller-supplied hostname against the comma-separated - * `pwakitStorefrontHosts` global preference allowlist. When no callerHost is - * provided, returns the first configured host. - * - * Returns null when: - * - callerHost is provided but not in the allowlist (including when the allowlist is empty) - * - callerHost is not provided and the allowlist is empty or unset - * - * Callers treat a null return as "no host available" — magic-link CTA buttons - * are omitted from the email but the email is still sent (e.g. with an inline - * access code). Templates must guard all magic-link usage with - * / lookupLink equivalent. - * - * @param {string|null} callerHost Hostname supplied by the API caller (no protocol, - * no trailing slash). Pass null when calling from a server-side hook that has no - * incoming host to validate (e.g. sendOrderAccessCode). - * @returns {string|null} Validated hostname, or null if unresolvable. - */ -function resolveStorefrontHost(callerHost) { - var allowedHosts = getAllowedHosts(); - - if (callerHost && callerHost.trim()) { - var normalizedHost = callerHost.trim().replace(/^https?:\/\//, '').replace(/\/$/, ''); - if (allowedHosts.indexOf(normalizedHost.toLowerCase()) !== -1) { - return normalizedHost.toLowerCase(); - } - log.warn( - 'storefrontHostProvider: caller-supplied host "{0}" is not in the pwakitStorefrontHosts allowlist — email sent without magic link.', - normalizedHost - ); - return null; - } - - if (allowedHosts.length > 0) { - return allowedHosts[0]; - } - - log.warn( - 'storefrontHostProvider: no callerHost provided and pwakitStorefrontHosts is not configured — email sent without magic link. ' + - 'Configure pwakitStorefrontHosts in Business Manager > Global Preferences > Custom Preferences > pwakit.' - ); - return null; -} - -function getAllowedHosts() { - try { - var pref = System.getPreferences().getCustom()['pwakitStorefrontHosts']; - if (!pref || !pref.trim()) return []; - return pref.split(',').map(function(h) { return h.trim().toLowerCase(); }).filter(Boolean); - } catch (e) { - return []; - } -} - -module.exports = resolveStorefrontHost; diff --git a/packages/template-retail-react-app/cartridges/app_pwakit_base/cartridge/scripts/hooks/sendOrderAccessCode.js b/packages/template-retail-react-app/cartridges/app_pwakit_base/cartridge/scripts/hooks/sendOrderAccessCode.js index 0118a366c5..62373ae8ee 100644 --- a/packages/template-retail-react-app/cartridges/app_pwakit_base/cartridge/scripts/hooks/sendOrderAccessCode.js +++ b/packages/template-retail-react-app/cartridges/app_pwakit_base/cartridge/scripts/hooks/sendOrderAccessCode.js @@ -22,7 +22,7 @@ var Resource = require('dw/web/Resource'); var System = require('dw/system/System'); var Site = require('dw/system/Site'); var sendNotification = require('*/cartridge/scripts/helpers/sendNotification'); -var resolveStorefrontHost = require('*/cartridge/scripts/helpers/storefrontHostProvider'); +var getStorefrontHost = require('*/cartridge/scripts/helpers/getStorefrontHost'); var buildOrderLookupUrl = require('*/cartridge/scripts/helpers/buildOrderLookupUrl'); var log = Logger.getLogger('pwakit-notify', 'pwakit-notify'); @@ -78,9 +78,9 @@ function sendOrderAccessCode(order, accessCode) { return new Status(Status.ERROR, 'NULL_ORDER', 'Order or customer info is null'); } - var storefrontHost = resolveStorefrontHost(null); + var storefrontHost = getStorefrontHost(); if (!storefrontHost) { - log.warn('sendOrderAccessCode: pwakitStorefrontHosts is not configured — sending email without lookup link'); + log.warn('sendOrderAccessCode: pwakitStorefrontHost is not configured — sending email without lookup link'); } // Note: uses siteId.toLowerCase() as the URL path segment. If your storefront diff --git a/packages/template-retail-react-app/cartridges/app_pwakit_base/staticfiles/cartridge/impex/default/meta/system-objecttype-extensions/OrganizationPreferences.xml b/packages/template-retail-react-app/cartridges/app_pwakit_base/staticfiles/cartridge/impex/default/meta/system-objecttype-extensions/OrganizationPreferences.xml index c23008a647..46b9726483 100644 --- a/packages/template-retail-react-app/cartridges/app_pwakit_base/staticfiles/cartridge/impex/default/meta/system-objecttype-extensions/OrganizationPreferences.xml +++ b/packages/template-retail-react-app/cartridges/app_pwakit_base/staticfiles/cartridge/impex/default/meta/system-objecttype-extensions/OrganizationPreferences.xml @@ -31,32 +31,12 @@ true - - Allowed Storefront Hosts - - Comma-separated list of allowed public-facing storefront hostnames (no protocol, no trailing slash). - Example: my-store.salesforcecommercecloudsites.com,staging.my-store.com - The REST API caller (PWA Kit) passes its own hostname with each request; the cartridge - validates it against this list before constructing magic-link URLs. If the caller supplies - no host the first entry in this list is used. If the list is empty, or the caller-supplied - host is not in the list, magic-link CTA buttons are omitted from the email — the email is - still delivered with an inline access code where applicable. - Note: when the B2C Script API gains native access to the MRT/eCDN hostname this preference - will be deprecated in favour of that API. - - string - false - false - 0 - - PWA Kit - diff --git a/packages/template-retail-react-app/cartridges/app_pwakit_base/staticfiles/cartridge/impex/default/meta/system-objecttype-extensions/SitePreferences.xml b/packages/template-retail-react-app/cartridges/app_pwakit_base/staticfiles/cartridge/impex/default/meta/system-objecttype-extensions/SitePreferences.xml new file mode 100644 index 0000000000..f793ae448b --- /dev/null +++ b/packages/template-retail-react-app/cartridges/app_pwakit_base/staticfiles/cartridge/impex/default/meta/system-objecttype-extensions/SitePreferences.xml @@ -0,0 +1,46 @@ + + + + + + + + + + Storefront Host + + Public-facing hostname of this storefront site (no protocol, no trailing slash). + Example: my-store.salesforcecommercecloudsites.com + Used to construct magic-link and order-lookup URLs in transactional emails. + Configure once per site. Will be superseded by a platform API in a future release. + + string + false + false + 0 + + + + + + + PWA Kit + + + + + + From b89e8c1c2bad7278517b308bfeadbfcdd087f937 Mon Sep 17 00:00:00 2001 From: Ben Chypak Date: Thu, 8 Oct 2026 11:45:37 -0700 Subject: [PATCH 48/55] fix: remove dead plainBody variable and unused Resource import from sendNotification --- .../scripts/helpers/sendNotification.js | 19 ------------------- 1 file changed, 19 deletions(-) diff --git a/packages/template-retail-react-app/cartridges/app_pwakit_base/cartridge/scripts/helpers/sendNotification.js b/packages/template-retail-react-app/cartridges/app_pwakit_base/cartridge/scripts/helpers/sendNotification.js index d78eb37973..15fc79d7d1 100644 --- a/packages/template-retail-react-app/cartridges/app_pwakit_base/cartridge/scripts/helpers/sendNotification.js +++ b/packages/template-retail-react-app/cartridges/app_pwakit_base/cartridge/scripts/helpers/sendNotification.js @@ -21,7 +21,6 @@ var Template = require('dw/util/Template'); var HashMap = require('dw/util/HashMap'); var Site = require('dw/system/Site'); var Logger = require('dw/system/Logger'); -var Resource = require('dw/web/Resource'); var log = Logger.getLogger('pwakit-notify', 'pwakit-notify'); @@ -42,23 +41,6 @@ function renderTemplate(templateName, templateContext) { return template.render(map).text; } -/** - * Returns a minimal plain-text fallback for the given notification type and context. - * Screen readers and plain-mail clients need this; spam filters score multipart higher. - */ -function buildPlainText(templateName, context) { - if (context.magicLink) { - return Resource.msg('email.plaintext.useThisLink', 'email', 'Use this link to continue:') + '\n' + context.magicLink; - } - if (context.token) { - return Resource.msg('email.plaintext.verificationCode', 'email', 'Your verification code:') + ' ' + context.token; - } - if (context.accessCode) { - return Resource.msg('email.plaintext.orderAccessCode', 'email', 'Your order access code:') + ' ' + context.accessCode - + '\n' + Resource.msg('email.plaintext.orderNumber', 'email', 'Order:') + ' ' + (context.orderNo || ''); - } - return ''; -} /** * Sends a transactional notification. @@ -86,7 +68,6 @@ function send(recipient, subject, templateName, context) { var localeId = (request.locale || Site.current.defaultLocale || 'en').replace(/_/g, '-'); var templateContext = Object.assign({ localeId: localeId, emailTitle: subject }, context || {}); var htmlBody = renderTemplate(templateName, templateContext); - var plainBody = buildPlainText(templateName, context || {}); var mail = new Mail(); mail.addTo(recipient); From b4e911cbd60d42676b208e73ba806ca9f467750b Mon Sep 17 00:00:00 2001 From: Ben Chypak Date: Thu, 8 Oct 2026 13:37:16 -0700 Subject: [PATCH 49/55] chore: revert sandbox credentials in config/default.js to zzrf-001 develop values --- .../template-retail-react-app/config/default.js | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/template-retail-react-app/config/default.js b/packages/template-retail-react-app/config/default.js index 7c2078e3c5..f4620ef854 100644 --- a/packages/template-retail-react-app/config/default.js +++ b/packages/template-retail-react-app/config/default.js @@ -170,7 +170,7 @@ module.exports = { callbackURI: '/registration-verification-callback' } }, - defaultSite: 'RefArch', + defaultSite: 'RefArchGlobal', siteAliases: { RefArch: 'us', RefArchGlobal: 'global' @@ -179,11 +179,11 @@ module.exports = { commerceAPI: { proxyPath: `/mobify/proxy/api`, parameters: { - clientId: '11deeece-0b48-4b60-bf1b-d96649faec43', - notifyClientId: 'cac0e789-e1db-4c39-805c-924574c78b79', - organizationId: 'f_ecom_zysg_001', - shortCode: 'sandbox-001', - siteId: 'RefArch' + clientId: 'c9c45bfd-0ed3-4aa2-9971-40f88962b836', + notifyClientId: '', + organizationId: 'f_ecom_zzrf_001', + shortCode: 'kv7kzm78', + siteId: 'RefArchGlobal' } // Optional: Set the domain for auth cookies to share them across subdomains. // If not set, cookies default to the current host. @@ -274,7 +274,7 @@ module.exports = { enableHttpOnlySessionCookies: false, proxyConfigs: [ { - host: 'sandbox-001.api.commercecloud.salesforce.com', + host: 'kv7kzm78.api.commercecloud.salesforce.com', path: 'api' }, { From bf88f8d8c410544d89bfb5b6ef4560771b1eb32c Mon Sep 17 00:00:00 2001 From: Ben Chypak Date: Thu, 8 Oct 2026 13:51:12 -0700 Subject: [PATCH 50/55] chore: rename pwakit preference group display name to PWA-Kit Configs --- .../system-objecttype-extensions/OrganizationPreferences.xml | 2 +- .../meta/system-objecttype-extensions/SitePreferences.xml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/template-retail-react-app/cartridges/app_pwakit_base/staticfiles/cartridge/impex/default/meta/system-objecttype-extensions/OrganizationPreferences.xml b/packages/template-retail-react-app/cartridges/app_pwakit_base/staticfiles/cartridge/impex/default/meta/system-objecttype-extensions/OrganizationPreferences.xml index 46b9726483..1633410aa5 100644 --- a/packages/template-retail-react-app/cartridges/app_pwakit_base/staticfiles/cartridge/impex/default/meta/system-objecttype-extensions/OrganizationPreferences.xml +++ b/packages/template-retail-react-app/cartridges/app_pwakit_base/staticfiles/cartridge/impex/default/meta/system-objecttype-extensions/OrganizationPreferences.xml @@ -35,7 +35,7 @@ - PWA Kit + PWA-Kit Configs diff --git a/packages/template-retail-react-app/cartridges/app_pwakit_base/staticfiles/cartridge/impex/default/meta/system-objecttype-extensions/SitePreferences.xml b/packages/template-retail-react-app/cartridges/app_pwakit_base/staticfiles/cartridge/impex/default/meta/system-objecttype-extensions/SitePreferences.xml index f793ae448b..0b40c00d0c 100644 --- a/packages/template-retail-react-app/cartridges/app_pwakit_base/staticfiles/cartridge/impex/default/meta/system-objecttype-extensions/SitePreferences.xml +++ b/packages/template-retail-react-app/cartridges/app_pwakit_base/staticfiles/cartridge/impex/default/meta/system-objecttype-extensions/SitePreferences.xml @@ -37,7 +37,7 @@ - PWA Kit + PWA-Kit Configs From c7b16669068e4f85c8d4f31ca4307584bc5191a8 Mon Sep 17 00:00:00 2001 From: Ben Chypak Date: Fri, 9 Oct 2026 13:27:45 -0700 Subject: [PATCH 51/55] chore: simplify href encoding in email templates and add pageTitle properties Replace redundant wrappers on href attributes with plain ${} expressions (ISML already HTML-encodes ${}). Remove !important from inline color styles (unnecessary in inline style context). Add pageTitle resource keys to email.properties to match storefront-next-email-reimpl parity; keys are defined for future wiring to the HTML element. --- .../cartridge/templates/default/email/guestOrderLookup.isml | 4 ++-- .../cartridge/templates/default/email/passwordReset.isml | 4 ++-- .../cartridge/templates/default/email/passwordlessLogin.isml | 4 ++-- .../cartridge/templates/resources/email.properties | 4 ++++ 4 files changed, 10 insertions(+), 6 deletions(-) diff --git a/packages/template-retail-react-app/cartridges/app_pwakit_base/cartridge/templates/default/email/guestOrderLookup.isml b/packages/template-retail-react-app/cartridges/app_pwakit_base/cartridge/templates/default/email/guestOrderLookup.isml index e405789240..572aed61e0 100644 --- a/packages/template-retail-react-app/cartridges/app_pwakit_base/cartridge/templates/default/email/guestOrderLookup.isml +++ b/packages/template-retail-react-app/cartridges/app_pwakit_base/cartridge/templates/default/email/guestOrderLookup.isml @@ -39,7 +39,7 @@ <table role="presentation" cellpadding="0" cellspacing="0" border="0"> <tr> <td style="background-color:#0176D3;"> - <a href="<isprint value="${pdict.lookupLink}" encoding="htmlcontent"/>" style="display:inline-block;padding:14px 32px;font-size:14px;font-weight:600;color:#FFFFFF;text-decoration:none;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Arial,sans-serif;">${Resource.msg('guestOrderLookup.buttonText', 'email', null)}</a> + <a href="${pdict.lookupLink}" style="display:inline-block;padding:14px 32px;font-size:14px;font-weight:600;color:#FFFFFF;text-decoration:none;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Arial,sans-serif;">${Resource.msg('guestOrderLookup.buttonText', 'email', null)}</a> </td> </tr> </table> @@ -59,7 +59,7 @@ <tr> <td> <p style="margin:0;font-size:12px;color:#747474;line-height:1.5;">${Resource.msg('guestOrderLookup.copyUrl', 'email', null)}<br> - <a href="<isprint value="${pdict.lookupLink}" encoding="htmlcontent"/>" style="color:#0176D3 !important;word-break:break-all;text-decoration:underline;"><span style="color:#0176D3 !important;"><isprint value="${pdict.lookupLink}" encoding="htmlcontent"/></span></a></p> + <a href="${pdict.lookupLink}" style="color:#0176D3;word-break:break-all;text-decoration:underline;"><span style="color:#0176D3;"><isprint value="${pdict.lookupLink}" encoding="htmlcontent"/></span></a></p> </td> </tr> </isif> diff --git a/packages/template-retail-react-app/cartridges/app_pwakit_base/cartridge/templates/default/email/passwordReset.isml b/packages/template-retail-react-app/cartridges/app_pwakit_base/cartridge/templates/default/email/passwordReset.isml index 5539be78b4..82c2261998 100644 --- a/packages/template-retail-react-app/cartridges/app_pwakit_base/cartridge/templates/default/email/passwordReset.isml +++ b/packages/template-retail-react-app/cartridges/app_pwakit_base/cartridge/templates/default/email/passwordReset.isml @@ -21,7 +21,7 @@ <table role="presentation" cellpadding="0" cellspacing="0" border="0"> <tr> <td style="background-color:#0176D3;"> - <a href="<isprint value="${pdict.magicLink}" encoding="htmlcontent"/>" style="display:inline-block;padding:14px 32px;font-size:14px;font-weight:600;color:#FFFFFF;text-decoration:none;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Arial,sans-serif;">${Resource.msg('passwordReset.buttonText', 'email', null)}</a> + <a href="${pdict.magicLink}" style="display:inline-block;padding:14px 32px;font-size:14px;font-weight:600;color:#FFFFFF;text-decoration:none;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Arial,sans-serif;">${Resource.msg('passwordReset.buttonText', 'email', null)}</a> </td> </tr> </table> @@ -41,7 +41,7 @@ <tr> <td> <p style="margin:0;font-size:12px;color:#747474;line-height:1.5;">${Resource.msg('passwordReset.copyUrl', 'email', null)}<br> - <a href="<isprint value="${pdict.magicLink}" encoding="htmlcontent"/>" style="color:#0176D3 !important;word-break:break-all;text-decoration:underline;"><span style="color:#0176D3 !important;"><isprint value="${pdict.magicLink}" encoding="htmlcontent"/></span></a></p> + <a href="${pdict.magicLink}" style="color:#0176D3;word-break:break-all;text-decoration:underline;"><span style="color:#0176D3;"><isprint value="${pdict.magicLink}" encoding="htmlcontent"/></span></a></p> </td> </tr> </isif> diff --git a/packages/template-retail-react-app/cartridges/app_pwakit_base/cartridge/templates/default/email/passwordlessLogin.isml b/packages/template-retail-react-app/cartridges/app_pwakit_base/cartridge/templates/default/email/passwordlessLogin.isml index a8fddbca5e..c3fce2a495 100644 --- a/packages/template-retail-react-app/cartridges/app_pwakit_base/cartridge/templates/default/email/passwordlessLogin.isml +++ b/packages/template-retail-react-app/cartridges/app_pwakit_base/cartridge/templates/default/email/passwordlessLogin.isml @@ -41,7 +41,7 @@ <table role="presentation" cellpadding="0" cellspacing="0" border="0"> <tr> <td style="background-color:#0176D3;"> - <a href="<isprint value="${pdict.magicLink}" encoding="htmlcontent"/>" style="display:inline-block;padding:14px 32px;font-size:14px;font-weight:600;color:#FFFFFF;text-decoration:none;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Arial,sans-serif;">${Resource.msg('passwordlessLogin.buttonText', 'email', null)}</a> + <a href="${pdict.magicLink}" style="display:inline-block;padding:14px 32px;font-size:14px;font-weight:600;color:#FFFFFF;text-decoration:none;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Arial,sans-serif;">${Resource.msg('passwordlessLogin.buttonText', 'email', null)}</a> </td> </tr> </table> @@ -61,7 +61,7 @@ <tr> <td> <p style="margin:0;font-size:12px;color:#747474;line-height:1.5;">${Resource.msg('passwordlessLogin.copyUrl', 'email', null)}<br> - <a href="<isprint value="${pdict.magicLink}" encoding="htmlcontent"/>" style="color:#0176D3 !important;word-break:break-all;text-decoration:underline;"><span style="color:#0176D3 !important;"><isprint value="${pdict.magicLink}" encoding="htmlcontent"/></span></a></p> + <a href="${pdict.magicLink}" style="color:#0176D3;word-break:break-all;text-decoration:underline;"><span style="color:#0176D3;"><isprint value="${pdict.magicLink}" encoding="htmlcontent"/></span></a></p> </td> </tr> </isif> diff --git a/packages/template-retail-react-app/cartridges/app_pwakit_base/cartridge/templates/resources/email.properties b/packages/template-retail-react-app/cartridges/app_pwakit_base/cartridge/templates/resources/email.properties index 674999495b..dc7027a4c1 100644 --- a/packages/template-retail-react-app/cartridges/app_pwakit_base/cartridge/templates/resources/email.properties +++ b/packages/template-retail-react-app/cartridges/app_pwakit_base/cartridge/templates/resources/email.properties @@ -3,6 +3,7 @@ email.allRightsReserved=All rights reserved. # passwordlessLogin passwordlessLogin.subject=Your Magic Sign-In Link +passwordlessLogin.pageTitle=Your Sign-In Link passwordlessLogin.heading=Sign in to your account passwordlessLogin.instructions=Click the link below to sign in. This link expires in 20 minutes and can only be used once. passwordlessLogin.accessCodeLabel=Your sign-in code: @@ -12,6 +13,7 @@ passwordlessLogin.copyUrl=Or copy this URL into your browser: # passwordReset passwordReset.subject=Reset Your Password +passwordReset.pageTitle=Reset your password passwordReset.heading=Reset your password passwordReset.instructions=Click the button below to set a new password. This link expires in 15 minutes. passwordReset.buttonText=Reset password @@ -20,12 +22,14 @@ passwordReset.copyUrl=Or copy and paste this link into your browser: # registrationVerification registrationVerification.subject=Your Verification Code +registrationVerification.pageTitle=Your verification code registrationVerification.heading=Your verification code registrationVerification.instructions=Use the code below to verify your identity. It expires in 15 minutes. registrationVerification.disclaimer=If you did not request this code, you can safely ignore this email. # guestOrderLookup guestOrderLookup.subject=Your Order Access Code +guestOrderLookup.pageTitle=Your order access code guestOrderLookup.heading=Your order access code guestOrderLookup.instructionsPrefix=Use the code below to view your order guestOrderLookup.buttonText=View Order From e7084ee48829e96c7acc6beba8511ecb38ef4f7a Mon Sep 17 00:00:00 2001 From: Ben Chypak <bchypak@salesforce.com> Date: Fri, 9 Oct 2026 14:32:06 -0700 Subject: [PATCH 52/55] fix: address pre-merge review findings and restore notifyClientId default - extractLocaleFromUrl: extend regex to match locale as first OR second path segment so retail URLs (/{site}/{locale}/...) are handled correctly - registration-verification callback: add mode !== 'callback' guard, consistent with passwordless and reset-password callback routes - verify endpoint: map 401/403 from SCAPI to 401 instead of 502 - sendNotification/sendOrderAccessCode: treat request.locale === 'default' as unset and fall through to Site defaultLocale - validateSlasCallbackToken: add TODO comment about missing aud validation - config/default.js: restore notifyClientId blanked by b4e911cbd --- packages/template-retail-react-app/app/ssr.js | 18 ++++++++++++++++-- .../scripts/helpers/sendNotification.js | 2 +- .../scripts/hooks/sendOrderAccessCode.js | 2 +- .../config/default.js | 2 +- 4 files changed, 19 insertions(+), 5 deletions(-) diff --git a/packages/template-retail-react-app/app/ssr.js b/packages/template-retail-react-app/app/ssr.js index c7cfa98e42..6699c70138 100644 --- a/packages/template-retail-react-app/app/ssr.js +++ b/packages/template-retail-react-app/app/ssr.js @@ -337,7 +337,7 @@ export function extractLocaleFromUrl(urlString) { if (!urlString) return null try { const path = new URL(urlString, 'https://placeholder').pathname - const match = path.match(/^\/([a-z]{2}-[A-Z]{2})(\/|$)/) + const match = path.match(/^(?:\/[^/]*)?\/([a-z]{2}-[A-Z]{2})(\/|$)/) return match ? match[1] : null } catch (_) { return null @@ -426,6 +426,9 @@ export const validateSlasCallbackToken = async (token) => { } else { jwks = createRemoteJWKSet(tenantId) } + // TODO: add `audience` validation once SLAS documents the expected aud claim value. + // The SLAS callback token's aud claim is not yet publicly documented, so we can only + // verify signature and issuer for now. Track in: W-XXXXXXX const {payload: validatedPayload} = await jwtVerify(token, jwks, { algorithms: ['RS256', 'ES256'] }) @@ -728,6 +731,10 @@ const {handler} = runtime.createHandler(options, (app) => { getConfig()?.app?.login?.registrationVerification?.callbackURI ?? '/registration-verification-callback' app.post(registrationVerificationCallback, async (req, res) => { + const appConfig = getConfig()?.app + if (appConfig?.login?.passwordless?.mode !== 'callback') { + return res.status(400).json({error: 'Registration verification callback mode not enabled'}) + } const slasCallbackToken = req.headers['x-slas-callback-token'] if (!slasCallbackToken) { return res.status(400).json({error: 'Missing x-slas-callback-token header'}) @@ -915,7 +922,12 @@ const {handler} = runtime.createHandler(options, (app) => { res.json(filtered) } catch (err) { const scapiStatus = err?.response?.status || 500 - const errorKind = scapiStatus === 404 ? 'invalid_code' : 'scapi_error' + const errorKind = + scapiStatus === 404 + ? 'invalid_code' + : scapiStatus === 401 || scapiStatus === 403 + ? 'auth_error' + : 'scapi_error' logger.warn('guest-order-lookup verify error', { namespace: 'guest-order-lookup', additionalProperties: { @@ -928,6 +940,8 @@ const {handler} = runtime.createHandler(options, (app) => { }) if (scapiStatus === 404) return res.status(404).json({error: 'Invalid or expired access code'}) + if (scapiStatus === 401 || scapiStatus === 403) + return res.status(401).json({error: 'Unauthorized'}) res.status(502).json({error: 'Service error'}) } }) diff --git a/packages/template-retail-react-app/cartridges/app_pwakit_base/cartridge/scripts/helpers/sendNotification.js b/packages/template-retail-react-app/cartridges/app_pwakit_base/cartridge/scripts/helpers/sendNotification.js index 15fc79d7d1..3e731f33ed 100644 --- a/packages/template-retail-react-app/cartridges/app_pwakit_base/cartridge/scripts/helpers/sendNotification.js +++ b/packages/template-retail-react-app/cartridges/app_pwakit_base/cartridge/scripts/helpers/sendNotification.js @@ -65,7 +65,7 @@ function send(recipient, subject, templateName, context) { log.info('Sending notification: template={0}, subject={1}, from={2}', templateName, subject, senderEmail); - var localeId = (request.locale || Site.current.defaultLocale || 'en').replace(/_/g, '-'); + var localeId = ((request.locale !== 'default' && request.locale) || Site.current.defaultLocale || 'en').replace(/_/g, '-'); var templateContext = Object.assign({ localeId: localeId, emailTitle: subject }, context || {}); var htmlBody = renderTemplate(templateName, templateContext); diff --git a/packages/template-retail-react-app/cartridges/app_pwakit_base/cartridge/scripts/hooks/sendOrderAccessCode.js b/packages/template-retail-react-app/cartridges/app_pwakit_base/cartridge/scripts/hooks/sendOrderAccessCode.js index 62373ae8ee..0eb588edea 100644 --- a/packages/template-retail-react-app/cartridges/app_pwakit_base/cartridge/scripts/hooks/sendOrderAccessCode.js +++ b/packages/template-retail-react-app/cartridges/app_pwakit_base/cartridge/scripts/hooks/sendOrderAccessCode.js @@ -88,7 +88,7 @@ function sendOrderAccessCode(order, accessCode) { // will 404 unless you add a redirect at the CDN/router level or override this // hook with an alias-aware implementation. var siteId = Site.getCurrent().ID.toLowerCase(); - var locale = (request.locale || Site.getCurrent().defaultLocale || 'en_US').replace(/_/g, '-'); + var locale = ((request.locale !== 'default' && request.locale) || Site.getCurrent().defaultLocale || 'en_US').replace(/_/g, '-'); var lookupLink = storefrontHost ? buildOrderLookupUrl(storefrontHost, siteId, locale, order.orderNo, accessCode) : null; diff --git a/packages/template-retail-react-app/config/default.js b/packages/template-retail-react-app/config/default.js index f4620ef854..309d4fd6ae 100644 --- a/packages/template-retail-react-app/config/default.js +++ b/packages/template-retail-react-app/config/default.js @@ -180,7 +180,7 @@ module.exports = { proxyPath: `/mobify/proxy/api`, parameters: { clientId: 'c9c45bfd-0ed3-4aa2-9971-40f88962b836', - notifyClientId: '', + notifyClientId: 'cdaa0bde-f866-4bf9-941a-e0fe5076db42', organizationId: 'f_ecom_zzrf_001', shortCode: 'kv7kzm78', siteId: 'RefArchGlobal' From b34417af3d3b3adb9a040df083af3466f8761043 Mon Sep 17 00:00:00 2001 From: Ben Chypak <bchypak@salesforce.com> Date: Fri, 9 Oct 2026 14:35:25 -0700 Subject: [PATCH 53/55] fix: prettier formatting on registration-verification callback mode guard --- packages/template-retail-react-app/app/ssr.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/template-retail-react-app/app/ssr.js b/packages/template-retail-react-app/app/ssr.js index 6699c70138..4dafc4f574 100644 --- a/packages/template-retail-react-app/app/ssr.js +++ b/packages/template-retail-react-app/app/ssr.js @@ -733,7 +733,9 @@ const {handler} = runtime.createHandler(options, (app) => { app.post(registrationVerificationCallback, async (req, res) => { const appConfig = getConfig()?.app if (appConfig?.login?.passwordless?.mode !== 'callback') { - return res.status(400).json({error: 'Registration verification callback mode not enabled'}) + return res + .status(400) + .json({error: 'Registration verification callback mode not enabled'}) } const slasCallbackToken = req.headers['x-slas-callback-token'] if (!slasCallbackToken) { From a6eadaacfb53591a587b0a1e5ee2ba77dacd19b2 Mon Sep 17 00:00:00 2001 From: Ben Chypak <bchypak@salesforce.com> Date: Tue, 13 Oct 2026 14:01:37 -0700 Subject: [PATCH 54/55] =?UTF-8?q?fix:=20address=20sf-jie-dai=20review=20fi?= =?UTF-8?q?ndings=20=E2=80=94=20sync=20ssr.js.hbs=20templates=20and=20fix?= =?UTF-8?q?=20release=20notes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CHANGELOG (template-retail-react-app): replace old public-client notify setup instructions with dedicated private notifyClientId requirement; rename pwakitStorefrontHosts → pwakitStorefrontHost; explicitly warn against adding c_pwakit_notify to the shopper client - CHANGELOG (pwa-kit-create-app): remove stale public-client fallback description; document that getNotifyToken() requires notifyClientId + PWA_KIT_NOTIFY_SLAS_CLIENT_SECRET - Both ssr.js.hbs templates: fix extractLocaleFromUrl regex to match locale as first OR second URL segment (handles /{site}/{locale}/... retail URLs) - Both ssr.js.hbs templates: add registration-verification callback mode gate (mirrors canonical app/ssr.js:735-741) so the route rejects requests when passwordless mode ≠ callback - Both ssr.js.hbs templates: map SCAPI 401/403 on GLO /verify to HTTP 401 instead of 502 - Both default.js.hbs templates: fix comments — mode defaults to 'callback', not 'email' --- packages/pwa-kit-create-app/CHANGELOG.md | 2 +- .../assets/bootstrap/js/config/default.js.hbs | 4 ++-- .../assets/bootstrap/js/overrides/app/ssr.js.hbs | 10 +++++++++- .../@salesforce/retail-react-app/app/ssr.js.hbs | 10 +++++++++- .../@salesforce/retail-react-app/config/default.js.hbs | 4 ++-- packages/template-retail-react-app/CHANGELOG.md | 2 +- 6 files changed, 24 insertions(+), 8 deletions(-) diff --git a/packages/pwa-kit-create-app/CHANGELOG.md b/packages/pwa-kit-create-app/CHANGELOG.md index 2b6dea47ec..86cea442c0 100644 --- a/packages/pwa-kit-create-app/CHANGELOG.md +++ b/packages/pwa-kit-create-app/CHANGELOG.md @@ -1,6 +1,6 @@ ## v3.21.0 (Oct 08, 2026) - [Bugfix] Keep the generated `ssr.js.hbs` templates in sync with `template-retail-react-app` for Commerce Client image upload (`cc_enableImageUpload`): add `blob:` and `data:` to `img-src` in both SSR template variants so the widget's image preview and sent-image thumbnail are not blocked by CSP in generated projects. [#4053](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/4053) -- Keep the generated `ssr.js.hbs` templates in sync with `template-retail-react-app`: fix GLO endpoints to read the SLAS access token from `req.headers.authorization` (sent by the client via `useAccessToken().getTokenWhenReady()`) instead of the `cc-at_*` cookie, which is never written in standard SLAS setups. Remove the unused `parseCookieValue` helper. Add public-client fallback for `pwakit-notify` token acquisition: use `loginGuestUserPrivate` when `PWA_KIT_SLAS_CLIENT_SECRET` is set, otherwise fall back to `loginGuestUser` (PKCE guest flow) so notification delivery works without a private SLAS client. [#4012](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/4012) +- Keep the generated `ssr.js.hbs` templates in sync with `template-retail-react-app`: fix GLO endpoints to read the SLAS access token from `req.headers.authorization` (sent by the client via `useAccessToken().getTokenWhenReady()`) instead of the `cc-at_*` cookie, which is never written in standard SLAS setups. Remove the unused `parseCookieValue` helper. Add `pwakit-notify` token acquisition via a dedicated private SLAS client: `getNotifyToken()` requires `notifyClientId` in `commerceAPI.parameters` and `PWA_KIT_NOTIFY_SLAS_CLIENT_SECRET`; there is no public-client fallback. **Do not add the `c_pwakit_notify` scope to the shopper client.** See `cartridges/app_pwakit_base/README.md` for the required notify-client setup. [#4012](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/4012) - Keep the generated `ssr.js.hbs` templates in sync with `template-retail-react-app` for the `dw_attribution` first-touch marketing-attribution cookie (W-23493129): both SSR template variants now import `createAttributionCookieMiddleware`, construct it with the configured `cookieDomain`, and mount it on the catch-all render route, so generated projects write the attribution cookie instead of shipping the utility as dead code. - Keep the generated `ssr.js.hbs` templates in sync with `template-retail-react-app` for Guest Order Lookup (GLO): add all `/api/order-lookup/*` Express route handlers (verify, order, oms-meta, cancel, return), helper functions (`makeShopperOrders`, `filterGuestOrderFields`, `parseGuestOrderCookie`, `evictIfNeeded`, `createVerifyThrottle`), and the `commerce-sdk-isomorphic` `ShopperOrders` import so generated projects compile and run the feature when `guestOrderLookup.enabled` is set to `true`. [#3982](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3982) - Keep the generated Commerce Client auth-link templates in sync with `template-retail-react-app`: the browser reads the deployment-scoped `cim_af_ct_<orgId>_<embeddedServiceName>` storage value and calls SCRT2's `/iamessage/api/v2/authorization/authlink` endpoint directly with the Commerce Client JWT to obtain an `auth_link_key` for Token Bridge use. The generated `ssr.js.hbs` no longer registers a `/api/agent/authlink` proxy route. [#3981](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3981) diff --git a/packages/pwa-kit-create-app/assets/bootstrap/js/config/default.js.hbs b/packages/pwa-kit-create-app/assets/bootstrap/js/config/default.js.hbs index f9fd2ce6ae..5c05f7937d 100644 --- a/packages/pwa-kit-create-app/assets/bootstrap/js/config/default.js.hbs +++ b/packages/pwa-kit-create-app/assets/bootstrap/js/config/default.js.hbs @@ -84,7 +84,7 @@ module.exports = { {{else}} enabled: false, {{/if}} - // The mode for passwordless login. Valid values include 'email|callback'. Defaults to: 'email' + // The mode for passwordless login. Valid values include 'email|callback'. Defaults to: 'callback' mode: 'callback', // The callback URI, which can be an absolute URL (including third-party URIs) or a relative path set up by the developer. // Required when mode is set to 'callback'. If the env var `PASSWORDLESS_LOGIN_CALLBACK_URI` is set, it will override the config value. @@ -109,7 +109,7 @@ module.exports = { redirectURI: process.env.SOCIAL_LOGIN_REDIRECT_URI || '/social-callback' }, resetPassword: { - // The mode for reset password. Valid values include 'email|callback'. Defaults to: 'email' + // The mode for reset password. Valid values include 'email|callback'. Defaults to: 'callback' mode: 'callback', // The callback URI, which can be an absolute URL (including third-party URIs) or a relative path set up by the developer. // Required when mode is set to 'callback'. If the env var `RESET_PASSWORD_CALLBACK_URI` is set, it will override the config value. diff --git a/packages/pwa-kit-create-app/assets/bootstrap/js/overrides/app/ssr.js.hbs b/packages/pwa-kit-create-app/assets/bootstrap/js/overrides/app/ssr.js.hbs index 0ef073b8f5..3147b051b8 100644 --- a/packages/pwa-kit-create-app/assets/bootstrap/js/overrides/app/ssr.js.hbs +++ b/packages/pwa-kit-create-app/assets/bootstrap/js/overrides/app/ssr.js.hbs @@ -344,7 +344,7 @@ export function extractLocaleFromUrl(urlString) { if (!urlString) return null try { const path = new URL(urlString, 'https://placeholder').pathname - const match = path.match(/^\/([a-z]{2}-[A-Z]{2})(\/|$)/) + const match = path.match(/^(?:\/[^/]*)?\/([a-z]{2}-[A-Z]{2})(\/|$)/) return match ? match[1] : null } catch (_) { return null @@ -665,6 +665,12 @@ const {handler} = runtime.createHandler(options, (app) => { getConfig()?.app?.login?.registrationVerification?.callbackURI ?? '/registration-verification-callback' app.post(registrationVerificationCallback, async (req, res) => { + const appConfig = getConfig()?.app + if (appConfig?.login?.passwordless?.mode !== 'callback') { + return res + .status(400) + .json({error: 'Registration verification callback mode not enabled'}) + } const slasCallbackToken = req.headers['x-slas-callback-token'] if (!slasCallbackToken) { return res.status(400).json({error: 'Missing x-slas-callback-token header'}) @@ -788,6 +794,8 @@ const {handler} = runtime.createHandler(options, (app) => { const scapiStatus = err?.response?.status || 500 if (scapiStatus === 404) return res.status(404).json({error: 'Invalid or expired access code'}) + if (scapiStatus === 401 || scapiStatus === 403) + return res.status(401).json({error: 'Unauthorized'}) res.status(502).json({error: 'Service error'}) } }) diff --git a/packages/pwa-kit-create-app/assets/templates/@salesforce/retail-react-app/app/ssr.js.hbs b/packages/pwa-kit-create-app/assets/templates/@salesforce/retail-react-app/app/ssr.js.hbs index 1fcf8a15d5..17a5565e4b 100644 --- a/packages/pwa-kit-create-app/assets/templates/@salesforce/retail-react-app/app/ssr.js.hbs +++ b/packages/pwa-kit-create-app/assets/templates/@salesforce/retail-react-app/app/ssr.js.hbs @@ -344,7 +344,7 @@ export function extractLocaleFromUrl(urlString) { if (!urlString) return null try { const path = new URL(urlString, 'https://placeholder').pathname - const match = path.match(/^\/([a-z]{2}-[A-Z]{2})(\/|$)/) + const match = path.match(/^(?:\/[^/]*)?\/([a-z]{2}-[A-Z]{2})(\/|$)/) return match ? match[1] : null } catch (_) { return null @@ -665,6 +665,12 @@ const {handler} = runtime.createHandler(options, (app) => { getConfig()?.app?.login?.registrationVerification?.callbackURI ?? '/registration-verification-callback' app.post(registrationVerificationCallback, async (req, res) => { + const appConfig = getConfig()?.app + if (appConfig?.login?.passwordless?.mode !== 'callback') { + return res + .status(400) + .json({error: 'Registration verification callback mode not enabled'}) + } const slasCallbackToken = req.headers['x-slas-callback-token'] if (!slasCallbackToken) { return res.status(400).json({error: 'Missing x-slas-callback-token header'}) @@ -788,6 +794,8 @@ const {handler} = runtime.createHandler(options, (app) => { const scapiStatus = err?.response?.status || 500 if (scapiStatus === 404) return res.status(404).json({error: 'Invalid or expired access code'}) + if (scapiStatus === 401 || scapiStatus === 403) + return res.status(401).json({error: 'Unauthorized'}) res.status(502).json({error: 'Service error'}) } }) diff --git a/packages/pwa-kit-create-app/assets/templates/@salesforce/retail-react-app/config/default.js.hbs b/packages/pwa-kit-create-app/assets/templates/@salesforce/retail-react-app/config/default.js.hbs index 2b7af13bdf..f361abec25 100644 --- a/packages/pwa-kit-create-app/assets/templates/@salesforce/retail-react-app/config/default.js.hbs +++ b/packages/pwa-kit-create-app/assets/templates/@salesforce/retail-react-app/config/default.js.hbs @@ -84,7 +84,7 @@ module.exports = { {{else}} enabled: false, {{/if}} - // The mode for passwordless login. Valid values include 'email|callback'. Defaults to: 'email' + // The mode for passwordless login. Valid values include 'email|callback'. Defaults to: 'callback' mode: 'callback', // The callback URI, which can be an absolute URL (including third-party URIs) or a relative path set up by the developer. // Required when mode is set to 'callback'. If the env var `PASSWORDLESS_LOGIN_CALLBACK_URI` is set, it will override the config value. @@ -109,7 +109,7 @@ module.exports = { redirectURI: process.env.SOCIAL_LOGIN_REDIRECT_URI || '/social-callback' }, resetPassword: { - // The mode for reset password. Valid values include 'email|callback'. Defaults to: 'email' + // The mode for reset password. Valid values include 'email|callback'. Defaults to: 'callback' mode: 'callback', // The callback URI, which can be an absolute URL (including third-party URIs) or a relative path set up by the developer. // Required when mode is set to 'callback'. If the env var `RESET_PASSWORD_CALLBACK_URI` is set, it will override the config value. diff --git a/packages/template-retail-react-app/CHANGELOG.md b/packages/template-retail-react-app/CHANGELOG.md index 12ca1df342..cb9f2adfb1 100644 --- a/packages/template-retail-react-app/CHANGELOG.md +++ b/packages/template-retail-react-app/CHANGELOG.md @@ -1,6 +1,6 @@ ## v10.4.0-dev (Oct 09, 2026) - [Enhancement] Update inline agent widget UMD bundle: replace the text-prefix product-context hack with the proper SCRT2 v2 session-context API. Product context is now sent as a `commerce_agent_context` StructuredValue in the SessionContextSet envelope, leaving the shopper's message text unchanged. Default `capabilitiesVersion` bumped to `"65"`. Built from [inline-agent-widget#3](https://github.com/SalesforceCommerceCloud/inline-agent-widget/pull/3). [#4054](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/4054) -- [Feature] Add `app_pwakit_base` B2C cartridge for transactional email delivery via ISML templates and `dw/net/Mail`. GLO access-code emails use the `sfcc.app.order.sendOrderAccessCode` hook; passwordless login, OTP verification, and password reset use the `pwakit-notify` Custom REST API (`POST .../organizations/{orgId}/notify?siteId={siteId}`, guarded by the `c_pwakit_notify` SLAS scope). Deploy with `npm run deploy:cartridge` from `packages/template-retail-react-app` (uses `b2c-cli` with `--reload` to upload the cartridge and re-register the SCAPI endpoint in one step), then import `OrganizationPreferences.xml` to enable the `pwakitNotifyEnabled` and `pwakitStorefrontHosts` preferences, and add `c_pwakit_notify` to your SLAS client's **default Scopes** (not just Allowed Scopes) in Account Manager — the `commerce-sdk-isomorphic` guest-login helpers don't accept a `scope` parameter, so the scope must be configured as a default on the client. See `cartridges/app_pwakit_base/README.md` for full setup steps. [#4012](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/4012) +- [Feature] Add `app_pwakit_base` B2C cartridge for transactional email delivery via ISML templates and `dw/net/Mail`. GLO access-code emails use the `sfcc.app.order.sendOrderAccessCode` hook; passwordless login, OTP verification, and password reset use the `pwakit-notify` Custom REST API (`POST .../organizations/{orgId}/notify?siteId={siteId}`, guarded by the `c_pwakit_notify` SLAS scope). Deploy with `npm run deploy:cartridge` from `packages/template-retail-react-app` (uses `b2c-cli` with `--reload` to upload the cartridge and re-register the SCAPI endpoint in one step), then import `OrganizationPreferences.xml` to enable the `pwakitNotifyEnabled` and `pwakitStorefrontHost` site preferences. **Required: create a dedicated private SLAS client** (`notifyClientId`) scoped only to `c_pwakit_notify`, set `PWA_KIT_NOTIFY_SLAS_CLIENT_SECRET` to its secret, and add `notifyClientId` to `commerceAPI.parameters` in your config. **Do not add `c_pwakit_notify` to the shopper/public client** — doing so makes browser-minted guest tokens capable of calling the notification API directly, reopening the email-relay risk this design is intended to close. See `cartridges/app_pwakit_base/README.md` for full setup steps. [#4012](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/4012) - [Breaking change] `config/default.js` now sets `mode: 'callback'` for both `passwordless` and `resetPassword` flows (previously `'email'`). This routes password-reset confirmation through the SSR `/callback/resetPassword` route and the new `pwakit-notify` cartridge. **Projects that adopt this config without deploying the cartridge will have broken password resets** — there is no `resetPassword.enabled` guard (unlike `passwordless.enabled`). Either deploy the cartridge before adopting the config change, or explicitly override `resetPassword.mode` back to `'email'`. [#4012](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/4012) - [Bugfix] Fix GLO `/api/guestOrderLookup/verify` and related server endpoints returning 401 when `enablePWAKitPrivateClient` is enabled. The `cc-nx-g_` cookie stores the SLAS refresh token, not the access token — the access token is never stored in any cookie. All GLO server endpoints now read the SLAS JWT from the `Authorization` header sent by the client (via `useAccessToken().getTokenWhenReady()`), which works correctly in both public and private client modes. [#4012](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/4012) From b1dcee6d01cabc12428d81ad134cfebd736976e2 Mon Sep 17 00:00:00 2001 From: Ben Chypak <bchypak@salesforce.com> Date: Fri, 16 Oct 2026 11:14:21 -0700 Subject: [PATCH 55/55] docs: fix reset-password route name in CHANGELOG The route is /reset-password-callback, not /callback/resetPassword. --- packages/template-retail-react-app/CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/template-retail-react-app/CHANGELOG.md b/packages/template-retail-react-app/CHANGELOG.md index ff97d3bd9f..8138d14079 100644 --- a/packages/template-retail-react-app/CHANGELOG.md +++ b/packages/template-retail-react-app/CHANGELOG.md @@ -3,7 +3,7 @@ - [Bugfix] Route Commerce Client widget product/checkout/markdown clicks through the storefront `useNavigation` router via the widget's `onNavigate` callback, so same-tab same-origin links stay in the SPA instead of triggering a full page load. Requires Commerce Client widget `1.48.0+`. `cc_openLinksInNewTab: "true"` is unchanged and still opens a new tab. Also fixes `buildUrl` (`createUrlTemplate`) when site/locale are configured as `query_param`: paths that already have a query string or hash now get site/locale merged into the existing query instead of a second `?` appended (which previously corrupted params like `q`/`pid` and swallowed the hash). - [Enhancement] Update inline agent widget UMD bundle: replace the text-prefix product-context hack with the proper SCRT2 v2 session-context API. Product context is now sent as a `commerce_agent_context` StructuredValue in the SessionContextSet envelope, leaving the shopper's message text unchanged. Default `capabilitiesVersion` bumped to `"65"`. Built from [inline-agent-widget#3](https://github.com/SalesforceCommerceCloud/inline-agent-widget/pull/3). [#4054](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/4054) - [Feature] Add `app_pwakit_base` B2C cartridge for transactional email delivery via ISML templates and `dw/net/Mail`. GLO access-code emails use the `sfcc.app.order.sendOrderAccessCode` hook; passwordless login, OTP verification, and password reset use the `pwakit-notify` Custom REST API (`POST .../organizations/{orgId}/notify?siteId={siteId}`, guarded by the `c_pwakit_notify` SLAS scope). Deploy with `npm run deploy:cartridge` from `packages/template-retail-react-app` (uses `b2c-cli` with `--reload` to upload the cartridge and re-register the SCAPI endpoint in one step), then import `OrganizationPreferences.xml` to enable the `pwakitNotifyEnabled` and `pwakitStorefrontHost` site preferences. **Required: create a dedicated private SLAS client** (`notifyClientId`) scoped only to `c_pwakit_notify`, set `PWA_KIT_NOTIFY_SLAS_CLIENT_SECRET` to its secret, and add `notifyClientId` to `commerceAPI.parameters` in your config. **Do not add `c_pwakit_notify` to the shopper/public client** — doing so makes browser-minted guest tokens capable of calling the notification API directly, reopening the email-relay risk this design is intended to close. See `cartridges/app_pwakit_base/README.md` for full setup steps. [#4012](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/4012) -- [Breaking change] `config/default.js` now sets `mode: 'callback'` for both `passwordless` and `resetPassword` flows (previously `'email'`). This routes password-reset confirmation through the SSR `/callback/resetPassword` route and the new `pwakit-notify` cartridge. **Projects that adopt this config without deploying the cartridge will have broken password resets** — there is no `resetPassword.enabled` guard (unlike `passwordless.enabled`). Either deploy the cartridge before adopting the config change, or explicitly override `resetPassword.mode` back to `'email'`. [#4012](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/4012) +- [Breaking change] `config/default.js` now sets `mode: 'callback'` for both `passwordless` and `resetPassword` flows (previously `'email'`). This routes password-reset confirmation through the SSR `/reset-password-callback` route and the new `pwakit-notify` cartridge. **Projects that adopt this config without deploying the cartridge will have broken password resets** — there is no `resetPassword.enabled` guard (unlike `passwordless.enabled`). Either deploy the cartridge before adopting the config change, or explicitly override `resetPassword.mode` back to `'email'`. [#4012](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/4012) - [Bugfix] Fix GLO `/api/guestOrderLookup/verify` and related server endpoints returning 401 when `enablePWAKitPrivateClient` is enabled. The `cc-nx-g_` cookie stores the SLAS refresh token, not the access token — the access token is never stored in any cookie. All GLO server endpoints now read the SLAS JWT from the `Authorization` header sent by the client (via `useAccessToken().getTokenWhenReady()`), which works correctly in both public and private client modes. [#4012](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/4012) - [Enhancement] End the Commerce Client shopper-agent conversation when a registered shopper logs out, instead of re-linking it to the guest. Uses the widget's `eventHandlers.session.endConversation()` (`cc_cdnVersion` >= `1.39.0`), falls back to `resetConversation()` on `1.36.0`–`1.38.x`, and to re-linking before `1.36.0`. Also keeps the widget mounted through the login/logout basket reload so the bundle is not injected twice. [#4051](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/4051) (W-24207318)