@salesforce/pwa-kit-runtime 3.12.0-preview.0 → 3.12.0-preview.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@salesforce/pwa-kit-runtime",
3
- "version": "3.12.0-preview.0",
3
+ "version": "3.12.0-preview.2",
4
4
  "description": "The PWAKit Runtime",
5
5
  "homepage": "https://github.com/SalesforceCommerceCloud/pwa-kit/tree/develop/packages/pwa-kit-runtime#readme",
6
6
  "bugs": {
@@ -46,11 +46,11 @@
46
46
  },
47
47
  "devDependencies": {
48
48
  "@loadable/component": "^5.15.3",
49
- "@salesforce/pwa-kit-dev": "3.12.0-preview.0",
49
+ "@salesforce/pwa-kit-dev": "3.12.0-preview.2",
50
50
  "@serverless/event-mocks": "^1.1.1",
51
51
  "aws-lambda-mock-context": "^3.2.1",
52
52
  "fs-extra": "^11.1.1",
53
- "internal-lib-build": "3.12.0-preview.0",
53
+ "internal-lib-build": "3.12.0-preview.2",
54
54
  "nock": "^13.3.0",
55
55
  "nodemon": "^2.0.22",
56
56
  "sinon": "^13.0.2",
@@ -58,7 +58,7 @@
58
58
  "supertest": "^4.0.2"
59
59
  },
60
60
  "peerDependencies": {
61
- "@salesforce/pwa-kit-dev": "3.12.0-preview.0"
61
+ "@salesforce/pwa-kit-dev": "3.12.0-preview.2"
62
62
  },
63
63
  "peerDependenciesMeta": {
64
64
  "@salesforce/pwa-kit-dev": {
@@ -72,5 +72,5 @@
72
72
  "publishConfig": {
73
73
  "directory": "dist"
74
74
  },
75
- "gitHead": "f79699d763ecdf8e0733e2d6c08b71487af8b15c"
75
+ "gitHead": "c0d7ff4673e54ecb119ca4aff5b919f0064b6539"
76
76
  }
@@ -27,6 +27,7 @@ var _awsServerlessExpress = _interopRequireDefault(require("aws-serverless-expre
27
27
  var _morgan = _interopRequireDefault(require("morgan"));
28
28
  var _loggerInstance = _interopRequireDefault(require("../../utils/logger-instance"));
29
29
  var _httpProxyMiddleware = require("http-proxy-middleware");
30
+ var _convertExpressRoute = require("../../utils/ssr-server/convert-express-route");
30
31
  function _interopRequireDefault(e) { return e && e.__esModule ? e : { default: e }; }
31
32
  function ownKeys(e, r) { var t = Object.keys(e); if (Object.getOwnPropertySymbols) { var o = Object.getOwnPropertySymbols(e); r && (o = o.filter(function (r) { return Object.getOwnPropertyDescriptor(e, r).enumerable; })), t.push.apply(t, o); } return t; }
32
33
  function _objectSpread(e) { for (var r = 1; r < arguments.length; r++) { var t = null != arguments[r] ? arguments[r] : {}; r % 2 ? ownKeys(Object(t), !0).forEach(function (r) { _defineProperty(e, r, t[r]); }) : Object.getOwnPropertyDescriptors ? Object.defineProperties(e, Object.getOwnPropertyDescriptors(t)) : ownKeys(Object(t)).forEach(function (r) { Object.defineProperty(e, r, Object.getOwnPropertyDescriptor(t, r)); }); } return e; }
@@ -179,7 +180,7 @@ const RemoteServerFactory = exports.RemoteServerFactory = {
179
180
  * @private
180
181
  */
181
182
  _isBundleOrProxyPath(url) {
182
- return url.includes(_ssrNamespacePaths.proxyBasePath) || url.includes(_ssrNamespacePaths.bundleBasePath);
183
+ return url.startsWith(_ssrNamespacePaths.proxyBasePath) || url.startsWith(_ssrNamespacePaths.bundleBasePath);
183
184
  },
184
185
  /**
185
186
  * @private
@@ -300,7 +301,12 @@ const RemoteServerFactory = exports.RemoteServerFactory = {
300
301
  // We want to remove any base paths that have made it this far.
301
302
  // Base paths are used to route requests to the correct server.
302
303
  // If the request has reached the server, it is no longer needed.
303
- this._setupRemoveBasePathFromPathMiddleware(app);
304
+ // Note: We use envBasePath as the feature flag for this middleware
305
+ // If envBasePath is `/`, '', or undefined when the server starts, we don't need to
306
+ // initialize this middleware.
307
+ if ((0, _ssrNamespacePaths.getEnvBasePath)()) {
308
+ this._setupBasePathMiddleware(app);
309
+ }
304
310
 
305
311
  // Ordering of the next two calls are vital - we don't
306
312
  // want request-processors applied to development views.
@@ -402,79 +408,118 @@ const RemoteServerFactory = exports.RemoteServerFactory = {
402
408
  /**
403
409
  * @private
404
410
  */
405
- _setupRemoveBasePathFromPathMiddleware(app) {
411
+ _setupBasePathMiddleware(app) {
412
+ // Cache the express route regexes to avoid re-calculating them on every request.
413
+ let expressRouteRegexes;
414
+
415
+ /**
416
+ * Remove the base path from a path.
417
+ *
418
+ * If path is like '/basepath/something', this returns '/something'
419
+ * If path is exactly '/basepath', this returns '/'
420
+ * If path doesn't start with base path or if there is no base path defined,
421
+ * returns the unmodified path
422
+ *
423
+ * @param path {string} the path to remove the base path from
424
+ * @returns {string} the path with the base path removed
425
+ */
406
426
  const removeBasePathFromPath = path => {
407
- if (!(0, _ssrNamespacePaths.getEnvBasePath)()) return path;
408
- const regex = new RegExp(`^${(0, _ssrNamespacePaths.getEnvBasePath)()}(/|$)`);
409
- return path.replace(regex, '/');
427
+ const basePath = (0, _ssrNamespacePaths.getEnvBasePath)();
428
+ if (!basePath) {
429
+ return path;
430
+ }
431
+ if (path.startsWith(basePath + '/')) {
432
+ return path.substring(basePath.length);
433
+ } else if (path === basePath) {
434
+ return '/';
435
+ }
436
+ return path;
410
437
  };
411
- const _convertExpressRouteToRegex = routePattern => {
412
- if (!routePattern) return null;
413
438
 
414
- // Replace route parameters like :id with regex capture groups
415
- let regexPattern = routePattern.replace(/:[^/]+/g, '[^/]+').replace(/\/\*/g, '/.*').replace(/\*/g, '.*');
416
-
417
- // Escape other regex special characters except those we just handled
418
- regexPattern = regexPattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
419
-
420
- // Unescape the patterns we want to keep
421
- regexPattern = regexPattern.replace(/\\\[\\\^\/\\\]\\\+/g, '[^/]+').replace(/\\\/\\\.\\\*/g, '/.*').replace(/\\\.\\\*/g, '.*').replace(/\\\(/g, '(').replace(/\\\)/g, ')').replace(/\\\?/g, '?');
422
- return new RegExp(`^${regexPattern}$`);
439
+ /**
440
+ * Initializes a cache of regexes that correspond to the registered express routes
441
+ *
442
+ * This specifically omits the generic wildcard from the express routes where we
443
+ * want to remove the base path from since it is mapped to the app render. This is
444
+ * because routes sent to the render are handled by React Router
445
+ */
446
+ const initializeExpressRouteRegexes = () => {
447
+ const expressRoutes = app._router.stack.filter(layer => layer.route && layer.route.path && layer.route.path !== '*').map(layer => layer.route.path);
448
+ expressRouteRegexes = expressRoutes.map(route => (0, _convertExpressRoute.convertExpressRouteToRegex)(route));
423
449
  };
424
450
 
425
451
  /**
426
452
  * Very early request processing.
427
453
  *
428
- * If the server receives a request containing the base path, remove it before allowing it through
454
+ * If the server receives a request containing the base path, remove it before allowing
455
+ * the request through to the other express endpoints
456
+ *
457
+ * We scope base path removal to /mobify routes and routes defined by the express app
458
+ * (For example /callback or /worker.js)
459
+ * This is to avoid affecting React Router routes where a site id or locale might be present
460
+ * that is equal to the base path.
461
+ *
462
+ * For example, if you have a base path of /us and a site id of /us we don't want
463
+ * to remove the /us from www.example.com/us/en-US/category/... as this route is handled by
464
+ * React Router and the PWA multisite implementation.
429
465
  *
430
466
  * @param req {express.req} the incoming request - modified in-place
431
467
  * @private
432
468
  */
433
- const removeBasePathFromPathMiddleware = (req, res, next) => {
434
- // Scope base path removal to /mobify routes and routes defined by the express app (ie. worker.js)
435
- // This is to avoid affecting other paths where a base path might be present if it happens to
436
- // be equal to a site id.
437
- // For example, if you have a base path of /us and a site id of /us we don't want
438
- // to remove the /us from www.example.com/us/en-US/category/...
439
-
469
+ const removeBasePathMiddleware = (req, res, next) => {
440
470
  const basePath = (0, _ssrNamespacePaths.getEnvBasePath)();
441
- let shouldRemoveBasePath = false;
442
- if (basePath) {
443
- if (req.path.startsWith(`${basePath}/mobify`)) {
444
- shouldRemoveBasePath = true;
445
- }
446
471
 
447
- // Check if path matches any existing express route with base path prepended
448
- if (!shouldRemoveBasePath) {
449
- // Routes are dynamically checked since we want to ensure that any express route
450
- // defined after the app is created, such as routes defined in ssr.js are included.
451
- const expressRoutes = app._router.stack
452
- // specifically omit the generic wildcard from the express routes we want to
453
- // remove the base path from since it is mapped to the app render
454
- .filter(layer => layer.route && layer.route.path && layer.route.path !== '*').map(layer => layer.route.path);
455
- for (const route of expressRoutes) {
456
- if (route) {
457
- const routeRegex = _convertExpressRouteToRegex(route);
458
- if (routeRegex) {
459
- const pathWithoutBase = req.path.replace(new RegExp(`^${basePath}`), '');
460
- if (routeRegex.test(pathWithoutBase)) {
461
- shouldRemoveBasePath = true;
462
- break;
463
- }
464
- }
472
+ // Fast path: /mobify routes always get the base path removed
473
+ if (req.path.startsWith(`${basePath}/mobify`)) {
474
+ const cleanPath = removeBasePathFromPath(req.path);
475
+ const parsed = _url.default.parse(req.url);
476
+ parsed.pathname = cleanPath;
477
+ req.url = _url.default.format(parsed);
478
+ return next();
479
+ }
480
+
481
+ // For other routes, only proceed if path actually starts with base path
482
+ if (!req.path.startsWith(basePath)) {
483
+ return next();
484
+ }
485
+
486
+ // Now we know path starts with base path, so we can remove it
487
+ const cleanPath = removeBasePathFromPath(req.path);
488
+
489
+ // Initialize express route regexes if needed
490
+ // We do this here since we want to ensure that any express route defined
491
+ // after the app is created, such as routes defined in ssr.js, are included.
492
+ if (!expressRouteRegexes) {
493
+ initializeExpressRouteRegexes();
494
+ }
495
+
496
+ // Next we check if the clean path matches any existing express routes
497
+ // (like /callback or /worker.js)
498
+ const matchesExpressRoute = expressRouteRegexes.some(routeRegex => {
499
+ try {
500
+ return routeRegex.test(cleanPath);
501
+ } catch (error) {
502
+ _loggerInstance.default.warn(`Invalid express route pattern: ${routeRegex}`, /* istanbul ignore next */
503
+ {
504
+ namespace: 'removeBasePathMiddleware',
505
+ additionalProperties: {
506
+ error: error
465
507
  }
466
- }
508
+ });
509
+ return false;
467
510
  }
468
- }
469
- if (shouldRemoveBasePath) {
470
- const updatedPath = removeBasePathFromPath(req.path);
511
+ });
512
+
513
+ // Only update URL if our clean path matches an Express route
514
+ // This leaves React Router paths (like /en-US/category) unchanged
515
+ if (matchesExpressRoute) {
471
516
  const parsed = _url.default.parse(req.url);
472
- parsed.pathname = updatedPath;
517
+ parsed.pathname = cleanPath;
473
518
  req.url = _url.default.format(parsed);
474
519
  }
475
520
  next();
476
521
  };
477
- app.use(removeBasePathFromPathMiddleware);
522
+ app.use(removeBasePathMiddleware);
478
523
  },
479
524
  /**
480
525
  * @private
@@ -674,6 +719,15 @@ const RemoteServerFactory = exports.RemoteServerFactory = {
674
719
  if (!options.useSLASPrivateClient) {
675
720
  return;
676
721
  }
722
+
723
+ // This is the full path to the SLAS trusted-system endpoint
724
+ // We want to throw an error if the regex defined options.applySLASPrivateClientToEndpoints
725
+ // matches this path as an early warning to developers that they should update their regex
726
+ // in ssr.js to exclude this path.
727
+ const trustedSystemPath = '/shopper/auth/v1/oauth2/trusted-system/token';
728
+ if (trustedSystemPath.match(options.applySLASPrivateClientToEndpoints)) {
729
+ throw new Error('It is not allowed to include /oauth2/trusted-system endpoints in `applySLASPrivateClientToEndpoints`');
730
+ }
677
731
  (0, _ssrServer.localDevLog)(`Proxying ${_ssrNamespacePaths.slasPrivateProxyPath} to ${options.slasTarget}`);
678
732
  const clientId = (_options$mobify2 = options.mobify) === null || _options$mobify2 === void 0 ? void 0 : (_options$mobify2$app = _options$mobify2.app) === null || _options$mobify2$app === void 0 ? void 0 : (_options$mobify2$app$ = _options$mobify2$app.commerceAPI) === null || _options$mobify2$app$ === void 0 ? void 0 : (_options$mobify2$app$2 = _options$mobify2$app$.parameters) === null || _options$mobify2$app$2 === void 0 ? void 0 : _options$mobify2$app$2.clientId;
679
733
  const clientSecret = process.env.PWA_KIT_SLAS_CLIENT_SECRET;
@@ -682,7 +736,20 @@ const RemoteServerFactory = exports.RemoteServerFactory = {
682
736
  return;
683
737
  }
684
738
  const encodedSlasCredentials = Buffer.from(`${clientId}:${clientSecret}`).toString('base64');
685
- app.use(_ssrNamespacePaths.slasPrivateProxyPath, (0, _httpProxyMiddleware.createProxyMiddleware)({
739
+ app.use(_ssrNamespacePaths.slasPrivateProxyPath, (req, res, next) => {
740
+ var _req$path, _req$path2;
741
+ // Check if the request should be blocked before it reaches the proxy
742
+ // We run this outside of the proxy middleware because modifying the response
743
+ // to send a 403 in the proxy causes issues with the response interceptor.
744
+ if (!((_req$path = req.path) !== null && _req$path !== void 0 && _req$path.match(options.slasApiPath)) || (_req$path2 = req.path) !== null && _req$path2 !== void 0 && _req$path2.match(/\/oauth2\/trusted-system/)) {
745
+ const message = `Request to ${req.path} is not allowed through the SLAS Private Client Proxy`;
746
+ _loggerInstance.default.error(message);
747
+ return res.status(403).json({
748
+ message: message
749
+ });
750
+ }
751
+ next();
752
+ }, (0, _httpProxyMiddleware.createProxyMiddleware)({
686
753
  target: options.slasTarget,
687
754
  changeOrigin: true,
688
755
  // http-proxy-middleware uses the original incoming request path to determine
@@ -694,8 +761,9 @@ const RemoteServerFactory = exports.RemoteServerFactory = {
694
761
  const regex = new RegExp(`^${basePathRegexEntry}${_ssrNamespacePaths.slasPrivateProxyPath}`);
695
762
  return path.replace(regex, '');
696
763
  },
764
+ selfHandleResponse: true,
697
765
  onProxyReq: (proxyRequest, incomingRequest, res) => {
698
- var _incomingRequest$path, _incomingRequest$path2, _incomingRequest$path3;
766
+ var _incomingRequest$path, _incomingRequest$path2;
699
767
  (0, _configureProxy.applyProxyRequestHeaders)({
700
768
  proxyRequest,
701
769
  incomingRequest,
@@ -703,28 +771,41 @@ const RemoteServerFactory = exports.RemoteServerFactory = {
703
771
  targetHost: options.slasHostName,
704
772
  targetProtocol: 'https'
705
773
  });
706
-
707
- // We pattern match and add client secrets only to endpoints that
708
- // match the regex specified by options.applySLASPrivateClientToEndpoints
709
- // (see option defaults at the top of this file).
710
- // Other SLAS endpoints, ie. SLAS authenticate (/oauth2/login) and
711
- // SLAS logout (/oauth2/logout), use the Authorization header for a different
712
- // purpose so we don't want to overwrite the header for those calls.
713
- if ((_incomingRequest$path = incomingRequest.path) !== null && _incomingRequest$path !== void 0 && _incomingRequest$path.match(options.applySLASPrivateClientToEndpoints)) {
774
+ if ((_incomingRequest$path = incomingRequest.path) !== null && _incomingRequest$path !== void 0 && _incomingRequest$path.match(/\/oauth2\/trusted-agent\/token/)) {
775
+ // /oauth2/trusted-agent/token endpoint auth header comes from Account Manager
776
+ // so the SLAS private client is sent via this special header
777
+ proxyRequest.setHeader('_sfdc_client_auth', encodedSlasCredentials);
778
+ } else if ((_incomingRequest$path2 = incomingRequest.path) !== null && _incomingRequest$path2 !== void 0 && _incomingRequest$path2.match(options.applySLASPrivateClientToEndpoints)) {
779
+ // We pattern match and add client secrets only to endpoints that
780
+ // match the regex specified by options.applySLASPrivateClientToEndpoints.
781
+ //
782
+ // Other SLAS endpoints, ie. SLAS authenticate (/oauth2/login) and
783
+ // SLAS logout (/oauth2/logout), use the Authorization header for a different
784
+ // purpose so we don't want to overwrite the header for those calls.
714
785
  proxyRequest.setHeader('Authorization', `Basic ${encodedSlasCredentials}`);
715
- } else if (!((_incomingRequest$path2 = incomingRequest.path) !== null && _incomingRequest$path2 !== void 0 && _incomingRequest$path2.match(options.slasApiPath))) {
716
- const message = `Request to ${incomingRequest.path} is not allowed through the SLAS Private Client Proxy`;
717
- _loggerInstance.default.error(message);
718
- return res.status(403).json({
719
- message: message
720
- });
721
786
  }
722
-
723
- // /oauth2/trusted-agent/token endpoint requires a different auth header
724
- if ((_incomingRequest$path3 = incomingRequest.path) !== null && _incomingRequest$path3 !== void 0 && _incomingRequest$path3.match(/\/oauth2\/trusted-agent\/token/)) {
725
- proxyRequest.setHeader('_sfdc_client_auth', encodedSlasCredentials);
787
+ },
788
+ onProxyRes: (0, _httpProxyMiddleware.responseInterceptor)((responseBuffer, proxyRes, req, res) => {
789
+ try {
790
+ var _req$path3;
791
+ // If the passwordless login endpoint returns a 404, which corresponds to a user
792
+ // email not being found, we mask it with a 200 OK response so that it is not
793
+ // obvious that the user does not exist.
794
+ // We do this to prevent user enumeration.
795
+ if ((_req$path3 = req.path) !== null && _req$path3 !== void 0 && _req$path3.match(/\/oauth2\/passwordless\/login/) && proxyRes.statusCode === 404) {
796
+ res.statusCode = 200;
797
+ res.statusMessage = 'OK';
798
+
799
+ // When a /passwordless/login endpoint response returns 200, it has no body
800
+ // so we return an empty body here to match an actual 200 response.
801
+ return Buffer.from('', 'utf8');
802
+ }
803
+ return responseBuffer;
804
+ } catch (error) {
805
+ console.error('There is an error processing the response from SLAS. Returning original response.', error);
806
+ return responseBuffer;
726
807
  }
727
- }
808
+ })
728
809
  }));
729
810
  },
730
811
  /**
@@ -14,7 +14,7 @@ const APPLICATION_OCTET_STREAM = exports.APPLICATION_OCTET_STREAM = 'application
14
14
  const BUILD = exports.BUILD = 'build';
15
15
  const STATIC_ASSETS = exports.STATIC_ASSETS = 'static_assets';
16
16
 
17
- /** * @deprecated Use ssr-namespace-paths.proxyBasePath instead */
17
+ /** * @deprecated Use ssr-namespace-paths proxyBasePath instead */
18
18
  const PROXY_PATH_PREFIX = exports.PROXY_PATH_PREFIX = '/mobify/proxy';
19
19
 
20
20
  // All these values MUST be lower case
@@ -918,9 +918,24 @@ describe('DevServer middleware', () => {
918
918
  describe('SLAS private client proxy', () => {
919
919
  const savedEnvironment = _extends({}, process.env);
920
920
  let proxyApp;
921
+ let proxyServer;
921
922
  const proxyPort = 12345;
922
923
  const proxyPath = '/shopper/auth/responseHeaders';
923
924
  const slasTarget = `http://localhost:${proxyPort}${proxyPath}`;
925
+ const appConfig = {
926
+ mobify: {
927
+ app: {
928
+ commerceAPI: {
929
+ parameters: {
930
+ clientId: 'clientId',
931
+ shortCode: 'shortCode'
932
+ }
933
+ }
934
+ }
935
+ },
936
+ useSLASPrivateClient: true,
937
+ slasTarget: slasTarget
938
+ };
924
939
  beforeAll(() => {
925
940
  jest.spyOn(ssrConfig, 'getConfig').mockReturnValue({});
926
941
  // by setting slasTarget, rather than forwarding the request to SLAS,
@@ -929,14 +944,38 @@ describe('SLAS private client proxy', () => {
929
944
  proxyApp.use(proxyPath, (req, res) => {
930
945
  res.send(req.headers);
931
946
  });
932
- proxyApp.listen(proxyPort);
947
+ proxyServer = proxyApp.listen(proxyPort);
933
948
  });
934
949
  afterEach(() => {
935
950
  process.env = savedEnvironment;
936
951
  });
937
- afterAll(() => {
938
- proxyApp.close();
939
- });
952
+
953
+ // There is a lot of cleanup done here to ensure the proxy server is closed
954
+ // after these tests.
955
+ afterAll(/*#__PURE__*/_asyncToGenerator(function* () {
956
+ if (proxyServer) {
957
+ // Close the server and wait for it to fully close
958
+ yield new Promise(resolve => {
959
+ proxyServer.close(() => {
960
+ resolve();
961
+ });
962
+ });
963
+
964
+ // Additional cleanup to ensure all connections are closed
965
+ proxyServer.unref();
966
+
967
+ // Force close any remaining connections
968
+ if (proxyServer._handle) {
969
+ proxyServer._handle.close();
970
+ }
971
+
972
+ // Clear any remaining event listeners
973
+ proxyServer.removeAllListeners();
974
+ }
975
+
976
+ // Clear any remaining timers or intervals
977
+ jest.clearAllTimers();
978
+ }));
940
979
  test('should not create proxy by default', () => {
941
980
  const app = _buildRemoteServer.RemoteServerFactory._createApp(opts());
942
981
  return (0, _supertest.default)(app).get('/mobify/slas/private').expect(404);
@@ -950,20 +989,7 @@ describe('SLAS private client proxy', () => {
950
989
  });
951
990
  test('does not insert client secret if request not for /oauth2/token', /*#__PURE__*/_asyncToGenerator(function* () {
952
991
  process.env.PWA_KIT_SLAS_CLIENT_SECRET = 'a secret';
953
- const app = _buildRemoteServer.RemoteServerFactory._createApp(opts({
954
- mobify: {
955
- app: {
956
- commerceAPI: {
957
- parameters: {
958
- clientId: 'clientId',
959
- shortCode: 'shortCode'
960
- }
961
- }
962
- }
963
- },
964
- useSLASPrivateClient: true,
965
- slasTarget: slasTarget
966
- }));
992
+ const app = _buildRemoteServer.RemoteServerFactory._createApp(opts(appConfig));
967
993
  return yield (0, _supertest.default)(app).get('/mobify/slas/private/shopper/auth/v1/somePath').then(response => {
968
994
  expect(response.body.authorization).toBeUndefined();
969
995
  expect(response.body.host).toBe('shortCode.api.commercecloud.salesforce.com');
@@ -973,20 +999,7 @@ describe('SLAS private client proxy', () => {
973
999
  test('inserts client secret if request is for /oauth2/token', /*#__PURE__*/_asyncToGenerator(function* () {
974
1000
  process.env.PWA_KIT_SLAS_CLIENT_SECRET = 'a secret';
975
1001
  const encodedCredentials = Buffer.from('clientId:a secret').toString('base64');
976
- const app = _buildRemoteServer.RemoteServerFactory._createApp(opts({
977
- mobify: {
978
- app: {
979
- commerceAPI: {
980
- parameters: {
981
- clientId: 'clientId',
982
- shortCode: 'shortCode'
983
- }
984
- }
985
- }
986
- },
987
- useSLASPrivateClient: true,
988
- slasTarget: slasTarget
989
- }));
1002
+ const app = _buildRemoteServer.RemoteServerFactory._createApp(opts(appConfig));
990
1003
  return yield (0, _supertest.default)(app).get('/mobify/slas/private/shopper/auth/v1/oauth2/token').then(response => {
991
1004
  expect(response.body.authorization).toBe(`Basic ${encodedCredentials}`);
992
1005
  expect(response.body.host).toBe('shortCode.api.commercecloud.salesforce.com');
@@ -995,21 +1008,7 @@ describe('SLAS private client proxy', () => {
995
1008
  }), 15000);
996
1009
  test('does not add _sfdc_client_auth header if request not for /oauth2/trusted-agent/token', /*#__PURE__*/_asyncToGenerator(function* () {
997
1010
  process.env.PWA_KIT_SLAS_CLIENT_SECRET = 'a secret';
998
- const encodedCredentials = Buffer.from('clientId:a secret').toString('base64');
999
- const app = _buildRemoteServer.RemoteServerFactory._createApp(opts({
1000
- mobify: {
1001
- app: {
1002
- commerceAPI: {
1003
- parameters: {
1004
- clientId: 'clientId',
1005
- shortCode: 'shortCode'
1006
- }
1007
- }
1008
- }
1009
- },
1010
- useSLASPrivateClient: true,
1011
- slasTarget: slasTarget
1012
- }));
1011
+ const app = _buildRemoteServer.RemoteServerFactory._createApp(opts(appConfig));
1013
1012
  return yield (0, _supertest.default)(app).get('/mobify/slas/private/shopper/auth/v1/oauth2/other-path').then(response => {
1014
1013
  expect(response.body._sfdc_client_auth).toBeUndefined();
1015
1014
  });
@@ -1034,28 +1033,71 @@ describe('SLAS private client proxy', () => {
1034
1033
  }));
1035
1034
  return yield (0, _supertest.default)(app).get('/mobify/slas/private/shopper/auth/v1/oauth2/trusted-agent/token').then(response => {
1036
1035
  expect(response.body['_sfdc_client_auth']).toBe(encodedCredentials);
1036
+ expect(response.body.authorization).toBeUndefined();
1037
1037
  expect(response.body.host).toBe('shortCode.api.commercecloud.salesforce.com');
1038
1038
  expect(response.body['x-mobify']).toBe('true');
1039
1039
  });
1040
1040
  }), 15000);
1041
1041
  test('returns 403 if request is not for /shopper/auth endpoints', /*#__PURE__*/_asyncToGenerator(function* () {
1042
1042
  process.env.PWA_KIT_SLAS_CLIENT_SECRET = 'a secret';
1043
- const app = _buildRemoteServer.RemoteServerFactory._createApp(opts({
1044
- mobify: {
1045
- app: {
1046
- commerceAPI: {
1047
- parameters: {
1048
- clientId: 'clientId',
1049
- shortCode: 'shortCode'
1043
+ const app = _buildRemoteServer.RemoteServerFactory._createApp(opts(appConfig));
1044
+ return yield (0, _supertest.default)(app).get('/mobify/slas/private/shopper/auth-admin/v1/other-path').expect(403);
1045
+ }), 15000);
1046
+ test('returns 403 if request is for /oauth2/trusted-system/* endpoint', /*#__PURE__*/_asyncToGenerator(function* () {
1047
+ process.env.PWA_KIT_SLAS_CLIENT_SECRET = 'a secret';
1048
+ const app = _buildRemoteServer.RemoteServerFactory._createApp(opts(appConfig));
1049
+ return yield (0, _supertest.default)(app).get('/mobify/slas/private/shopper/auth/v1/oauth2/trusted-system/token').expect(403);
1050
+ }), 15000);
1051
+ test('throws an error if /oauth2/trusted-system/* is included in applySLASPrivateClientToEndpoints', /*#__PURE__*/_asyncToGenerator(function* () {
1052
+ process.env.PWA_KIT_SLAS_CLIENT_SECRET = 'a secret';
1053
+ expect(() => {
1054
+ _buildRemoteServer.RemoteServerFactory._createApp(opts({
1055
+ mobify: {
1056
+ app: {
1057
+ commerceAPI: {
1058
+ parameters: {
1059
+ clientId: 'clientId',
1060
+ shortCode: 'shortCode'
1061
+ }
1050
1062
  }
1051
1063
  }
1052
- }
1053
- },
1054
- useSLASPrivateClient: true,
1055
- slasTarget: slasTarget
1056
- }));
1057
- return yield (0, _supertest.default)(app).get('/mobify/slas/private/shopper/auth-admin/v1/other-path').expect(403);
1064
+ },
1065
+ useSLASPrivateClient: true,
1066
+ slasTarget: slasTarget,
1067
+ applySLASPrivateClientToEndpoints: /\/oauth2\/trusted-system/
1068
+ }));
1069
+ }).toThrow('It is not allowed to include /oauth2/trusted-system endpoints in `applySLASPrivateClientToEndpoints`');
1058
1070
  }), 15000);
1071
+ test('proxy returns a 200 OK masking a user not found error', /*#__PURE__*/_asyncToGenerator(function* () {
1072
+ process.env.PWA_KIT_SLAS_CLIENT_SECRET = 'a secret';
1073
+
1074
+ // Create a new mock server specifically for this test so we can mock a response from SLAS
1075
+ const testProxyApp = (0, _express.default)();
1076
+ const testProxyPort = 12346;
1077
+ const testSlasTarget = `http://localhost:${testProxyPort}/shopper/auth/responseHeaders`;
1078
+
1079
+ // Set up the mock server to return a 404 for passwordless login
1080
+ testProxyApp.use('/shopper/auth/responseHeaders', (req, res) => {
1081
+ if (req.url.includes('/oauth2/passwordless/login')) {
1082
+ res.status(404).send();
1083
+ } else {
1084
+ res.send(req.headers);
1085
+ }
1086
+ });
1087
+ const testProxyServer = testProxyApp.listen(testProxyPort);
1088
+ try {
1089
+ const testAppConfig = _objectSpread(_objectSpread({}, appConfig), {}, {
1090
+ slasTarget: testSlasTarget
1091
+ });
1092
+ const app = _buildRemoteServer.RemoteServerFactory._createApp(opts(testAppConfig));
1093
+ return yield (0, _supertest.default)(app).get('/mobify/slas/private/shopper/auth/v1/oauth2/passwordless/login').expect(200).then(response => {
1094
+ expect(response.text).toBe('');
1095
+ });
1096
+ } finally {
1097
+ // Clean up the test server
1098
+ testProxyServer.close();
1099
+ }
1100
+ }));
1059
1101
  });
1060
1102
  describe('Base path tests', () => {
1061
1103
  test('Base path is removed from /mobify request path and still gets through to /mobify endpoint', /*#__PURE__*/_asyncToGenerator(function* () {
@@ -1068,20 +1110,23 @@ describe('Base path tests', () => {
1068
1110
  });
1069
1111
  }), 15000);
1070
1112
  test('should not remove base path from non /mobify non-express routes', /*#__PURE__*/_asyncToGenerator(function* () {
1113
+ // Set base path to something that might also be a site id used by react router routes
1071
1114
  jest.spyOn(ssrConfig, 'getConfig').mockReturnValue({
1072
- envBasePath: '/basepath'
1115
+ envBasePath: '/us'
1073
1116
  });
1074
1117
  const app = _buildRemoteServer.RemoteServerFactory._createApp(opts());
1075
1118
 
1076
- // Add a route that doesn't match the request
1077
- app.get('/api/other', (req, res) => {
1078
- res.status(200).json({
1079
- message: 'other'
1080
- });
1119
+ // Add a middleware to capture the request path after base path processing
1120
+ let capturedPath = null;
1121
+ app.use((req, res, next) => {
1122
+ capturedPath = req.path;
1123
+ next();
1081
1124
  });
1082
- return (0, _supertest.default)(app).get('/basepath/api/unknown').then(response => {
1083
- // Should get a 404 since the route doesn't exist
1084
- expect(response.status).toBe(404);
1125
+ return (0, _supertest.default)(app).get('/us/products/123').then(response => {
1126
+ expect(response.status).toBe(404); // 404 because the route doesn't exist in express
1127
+
1128
+ // Verify that the base path was not removed from the request path
1129
+ expect(capturedPath).toBe('/us/products/123');
1085
1130
  });
1086
1131
  }), 15000);
1087
1132
  test('should remove base path from routes with path parameters', /*#__PURE__*/_asyncToGenerator(function* () {
@@ -1099,7 +1144,25 @@ describe('Base path tests', () => {
1099
1144
  expect(response.body.userId).toBe('123');
1100
1145
  });
1101
1146
  }), 15000);
1102
- test('remove base path can handle complex base paths', /*#__PURE__*/_asyncToGenerator(function* () {
1147
+ test('should remove base path from routes defined with regex', /*#__PURE__*/_asyncToGenerator(function* () {
1148
+ jest.spyOn(ssrConfig, 'getConfig').mockReturnValue({
1149
+ envBasePath: '/basepath'
1150
+ });
1151
+ const app = _buildRemoteServer.RemoteServerFactory._createApp(opts());
1152
+ app.get(/\/api\/users\/\d+/, (req, res) => {
1153
+ // Extract the user ID from the URL path since regex routes don't create req.params automatically
1154
+ const match = req.path.match(/\/api\/users\/(\d+)/);
1155
+ const userId = match ? match[1] : 'unknown';
1156
+ res.status(200).json({
1157
+ userId: userId
1158
+ });
1159
+ });
1160
+ return (0, _supertest.default)(app).get('/basepath/api/users/123').then(response => {
1161
+ expect(response.status).toBe(200);
1162
+ expect(response.body.userId).toBe('123');
1163
+ });
1164
+ }), 15000);
1165
+ test('remove base path can handle multi-part base paths', /*#__PURE__*/_asyncToGenerator(function* () {
1103
1166
  jest.spyOn(ssrConfig, 'getConfig').mockReturnValue({
1104
1167
  envBasePath: '/my/base/path'
1105
1168
  });
@@ -1114,4 +1177,25 @@ describe('Base path tests', () => {
1114
1177
  expect(response.body.message).toBe('test');
1115
1178
  });
1116
1179
  }), 15000);
1180
+ test('should handle optional characters in route pattern', /*#__PURE__*/_asyncToGenerator(function* () {
1181
+ jest.spyOn(ssrConfig, 'getConfig').mockReturnValue({
1182
+ envBasePath: '/basepath'
1183
+ });
1184
+ const app = _buildRemoteServer.RemoteServerFactory._createApp(opts());
1185
+
1186
+ // This route is intentionally made complex to test the following:
1187
+ // 1. Optional characters in route pattern ie. 'k?'
1188
+ // 2. Optional characters in route pattern with groups ie. (c)?
1189
+ // 3. Optional characters in route pattern with path parameters ie. (:param?)
1190
+ // 4. Wildcards ie. '*'
1191
+ app.get('/callba(c)?k?*/:param?', (req, res) => {
1192
+ res.status(200).json({
1193
+ message: 'test'
1194
+ });
1195
+ });
1196
+ return (0, _supertest.default)(app).get('/basepath/callback').then(response => {
1197
+ expect(response.status).toBe(200);
1198
+ expect(response.body.message).toBe('test');
1199
+ });
1200
+ }), 15000);
1117
1201
  });
@@ -32,15 +32,37 @@ const SLAS_PRIVATE_CLIENT_PROXY_PATH = `${MOBIFY_PATH}/slas/private`;
32
32
 
33
33
  /*
34
34
  * Returns the base path. This is prepended to a /mobify path.
35
+ * Returns an empty string if the base path is not set or is '/'.
35
36
  */
36
37
  const getEnvBasePath = () => {
37
38
  const config = (0, _ssrConfig.getConfig)();
38
39
  let basePath = (config === null || config === void 0 ? void 0 : config.envBasePath) || '';
39
40
  if (typeof basePath !== 'string') {
40
- _loggerInstance.default.warn('Invalid envBasePath configuration. No base path is applied.');
41
+ _loggerInstance.default.warn('Invalid envBasePath configuration. No base path is applied.', {
42
+ namespace: 'ssr-namespace-paths.getEnvBasePath'
43
+ });
41
44
  return '';
42
45
  }
43
- return basePath.replace(/\/$/, '');
46
+
47
+ // Normalize the base path
48
+ basePath = basePath.trim().replace(/^\/?/, '/') // Ensure leading slash
49
+ .replace(/\/+/g, '/') // Normalize multiple slashes
50
+ .replace(/\/$/, ''); // Remove trailing slash
51
+
52
+ // Return empty string for root path or empty result
53
+ if (basePath === '/' || !basePath) {
54
+ return '';
55
+ }
56
+
57
+ // only allow simple, safe characters
58
+ // eslint-disable-next-line no-useless-escape
59
+ if (!/^\/[a-zA-Z0-9\-_\/]*$/.test(basePath)) {
60
+ _loggerInstance.default.warn('Invalid envBasePath configuration. Only letters, numbers, hyphens, underscores, and slashes allowed. No base path is applied.', {
61
+ namespace: 'ssr-namespace-paths.getEnvBasePath'
62
+ });
63
+ return '';
64
+ }
65
+ return basePath;
44
66
  };
45
67
  exports.getEnvBasePath = getEnvBasePath;
46
68
  const proxyBasePath = exports.proxyBasePath = PROXY_PATH_BASE;
@@ -29,4 +29,28 @@ describe('ssr-namespace-paths tests', () => {
29
29
  });
30
30
  expect((0, _ssrNamespacePaths.getEnvBasePath)()).toBe('');
31
31
  });
32
+ test('getEnvBasePath removes trailing slash', () => {
33
+ jest.spyOn(ssrConfig, 'getConfig').mockReturnValue({
34
+ envBasePath: '/sample/'
35
+ });
36
+ expect((0, _ssrNamespacePaths.getEnvBasePath)()).toBe('/sample');
37
+ });
38
+ test('getEnvBasePath returns empty string if invalid cahracters are detected in envBasePath', () => {
39
+ jest.spyOn(ssrConfig, 'getConfig').mockReturnValue({
40
+ envBasePath: '/sample.*'
41
+ });
42
+ expect((0, _ssrNamespacePaths.getEnvBasePath)()).toBe('');
43
+ });
44
+ test('getEnvBasePath normalizes envBasePath', () => {
45
+ jest.spyOn(ssrConfig, 'getConfig').mockReturnValue({
46
+ envBasePath: ' //sample/ '
47
+ });
48
+ expect((0, _ssrNamespacePaths.getEnvBasePath)()).toBe('/sample');
49
+ });
50
+ test('getEnvBasePath works with multiple part base path', () => {
51
+ jest.spyOn(ssrConfig, 'getConfig').mockReturnValue({
52
+ envBasePath: '//test/sample/ '
53
+ });
54
+ expect((0, _ssrNamespacePaths.getEnvBasePath)()).toBe('/test/sample');
55
+ });
32
56
  });
@@ -0,0 +1,126 @@
1
+ "use strict";
2
+
3
+ Object.defineProperty(exports, "__esModule", {
4
+ value: true
5
+ });
6
+ exports.convertExpressRouteToRegex = void 0;
7
+ /*
8
+ * Copyright (c) 2025, Salesforce, Inc.
9
+ * All rights reserved.
10
+ * SPDX-License-Identifier: BSD-3-Clause
11
+ * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause
12
+ */
13
+
14
+ /**
15
+ * Utillty function that converts an express route pattern into a regex.
16
+ *
17
+ * This is used in build-remote-server's removeBasePathMiddleware to check
18
+ * whether an incoming request path will match a registered Express endpoint.
19
+ *
20
+ * @param {string} routePattern - The express route pattern to convert
21
+ * @returns {RegExp} - The regex that corresponds to the express route pattern
22
+ * @throws {Error} - If the route pattern is invalid
23
+ */
24
+ const convertExpressRouteToRegex = routePattern => {
25
+ if (!routePattern) return null;
26
+ if (routePattern instanceof RegExp) return routePattern;
27
+ if (typeof routePattern !== 'string') return null;
28
+ try {
29
+ // If it's a string, it's an Express route pattern that needs conversion
30
+ // Express route patterns can contain:
31
+ // - Static paths: /users, /about
32
+ // - Route parameters: :id, :userId
33
+ // - Optional parameters: :id?
34
+ // - Regex constraints: :id(\d+)
35
+ // - Wildcards: *, /*
36
+ // - Optional characters: abc?
37
+ // - Optional groups: (abc)?
38
+
39
+ let regexPattern = routePattern;
40
+
41
+ // Step 1: Handle regex constraints in parameters like :param(regex)
42
+ // Example: /search/:query(\d+) -> /search/(\d+)
43
+ // Store the constraints to prevent them from being escaped later
44
+ const constraints = [];
45
+ regexPattern = regexPattern.replace(/:([^(/]+)\(([^)]+)\)/g, (match, paramName, constraint) => {
46
+ const constraintId = `__CONSTRAINT_${constraints.length}__`;
47
+ constraints.push(constraint);
48
+ return constraintId;
49
+ });
50
+
51
+ // Step 2: Handle complex optional parameter sequences first
52
+ // For patterns like /api/:version?/users/:id?/posts/:postId?
53
+ // We need to make literal segments optional when they're followed by optional parameters
54
+ regexPattern = regexPattern.replace(/\/([a-zA-Z0-9_-]+)\/:([^(/]+)\?/g, (match, segment, param) => `(?:/${segment}(?:/[^/]+)?)?`);
55
+
56
+ // Step 3: Handle remaining optional parameters :param?
57
+ // For /users/:id?, we want to match both /users and /users/123
58
+ // So we need to replace the entire pattern, not just the parameter
59
+ regexPattern = regexPattern.replace(/\/:([^(/]+)\?/g, '(?:/[^/]+)?');
60
+
61
+ // Step 4: Handle regular parameters :param
62
+ regexPattern = regexPattern.replace(/:([^(/]+)/g, '[^/]+');
63
+
64
+ // Step 5: Handle wildcards
65
+ // Express wildcards * should be converted to .* which matches everything including slashes
66
+ // Handle /* first, then handle standalone * (but not if it's already been converted)
67
+ regexPattern = regexPattern.replace(/\/\*/g, '/.*');
68
+ // Handle standalone * that hasn't been converted yet
69
+ regexPattern = regexPattern.replace(/(?<!\.)\*(?!\*)/g, '.*');
70
+
71
+ // Step 6: Handle wildcard + optional parameter combinations
72
+ // For example /users*/:id?
73
+ regexPattern = regexPattern.replace(
74
+ // eslint-disable-next-line no-useless-escape
75
+ /\.\*\/\(\?\:\/\[(\^\/)\]\+\)\?/g, '.*(?:/(?:[^/]+)?)?');
76
+
77
+ // Step 7: Handle optional groups (user|admin) -> (?:user|admin)
78
+ regexPattern = regexPattern.replace(/\(([^)]*\|[^)]*)\)/g, '(?:$1)');
79
+
80
+ // Step 8: Handle optional characters in literal strings
81
+ // For patterns like /favori?te, /colou?r, /analy?se
82
+ // The ? makes the preceding character optional
83
+ regexPattern = regexPattern.replace(/([a-zA-Z0-9])\?/g, (match, char) => `(?:${char})?`);
84
+
85
+ // Step 9: Fix double slashes that may have been created
86
+ regexPattern = regexPattern.replace(/\/\//g, '/');
87
+
88
+ // Step 10: Restore regex constraints without escaping
89
+ constraints.forEach((constraint, index) => {
90
+ const constraintId = `__CONSTRAINT_${index}__`;
91
+ regexPattern = regexPattern.replace(constraintId, `(${constraint})`);
92
+ });
93
+
94
+ // Step 10.5: Handle optional parameters with regex constraints
95
+ // For patterns like /users/:id(\d+)?, we need to make the entire constraint group optional
96
+ // This step must be applied after the constraints are restored but before root path handling
97
+ // Only apply to actual constraint groups, not already processed optional groups
98
+ regexPattern = regexPattern.replace(/\(([^)]+)\)\?/g, (match, content) => {
99
+ // Only convert if this looks like a regex constraint (contains regex patterns like \d, \w, etc.)
100
+ // and is not already an optional group (doesn't start with ?:)
101
+ if ((/\\[dwDsS]/.test(content) || /[^a-zA-Z0-9\s]/.test(content)) && !content.startsWith('?:')) {
102
+ return `(?:${content})?`;
103
+ }
104
+ return match;
105
+ });
106
+
107
+ // Step 11: Only escape literal characters that need escaping, but not regex constraints
108
+ // Don't escape curly braces {} as they're used in regex quantifiers
109
+ // eslint-disable-next-line no-useless-escape
110
+ regexPattern = regexPattern.replace(/[\$]/g, '\\$&');
111
+
112
+ // Step 12: Handle root path optional parameters
113
+ // For patterns like /:id? or /*, we need to handle the root path correctly
114
+ if (regexPattern === '^(?:/[^/]+)?$') {
115
+ // This is a root optional parameter, should match both / and /something
116
+ regexPattern = '^(?:/|/[^/]+)$';
117
+ } else if (regexPattern === '^/.*$') {
118
+ // This is a root wildcard, should match everything including /
119
+ regexPattern = '^/.*$';
120
+ }
121
+ return new RegExp(`^${regexPattern}$`);
122
+ } catch (error) {
123
+ throw new Error(`Invalid route pattern: ${routePattern}`);
124
+ }
125
+ };
126
+ exports.convertExpressRouteToRegex = convertExpressRouteToRegex;