Browse Source

Merge branch 'minor' into major

Michael Bromley 3 years ago
parent
commit
82281aae07
26 changed files with 691 additions and 73 deletions
  1. 4 4
      docs/content/plugins/extending-the-admin-ui/_index.md
  2. 49 13
      docs/content/plugins/extending-the-admin-ui/add-actions-to-pages/_index.md
  3. 0 0
      docs/content/plugins/extending-the-admin-ui/add-actions-to-pages/bulk-actions-screenshot.png
  4. 0 0
      docs/content/plugins/extending-the-admin-ui/add-actions-to-pages/ui-extensions-actionbar.jpg
  5. 3 36
      docs/content/plugins/extending-the-admin-ui/modifying-navigation-items/_index.md
  6. 0 0
      docs/content/plugins/extending-the-admin-ui/modifying-navigation-items/ui-extensions-navbar.jpg
  7. 1 1
      docs/content/plugins/extending-the-admin-ui/using-angular/_index.md
  8. 8 8
      docs/content/plugins/extending-the-admin-ui/using-other-frameworks/_index.md
  9. 0 3
      packages/core/src/api/schema/admin-api/asset-admin.type.graphql
  10. 1 0
      packages/core/src/api/schema/common/asset.type.graphql
  11. 125 2
      packages/core/src/event-bus/event-bus.spec.ts
  12. 23 5
      packages/core/src/event-bus/event-bus.ts
  13. 4 0
      packages/harden-plugin/.gitignore
  14. 7 0
      packages/harden-plugin/README.md
  15. 3 0
      packages/harden-plugin/index.ts
  16. 27 0
      packages/harden-plugin/package.json
  17. 2 0
      packages/harden-plugin/src/constants.ts
  18. 170 0
      packages/harden-plugin/src/harden.plugin.ts
  19. 26 0
      packages/harden-plugin/src/middleware/hide-validation-errors-plugin.ts
  20. 127 0
      packages/harden-plugin/src/middleware/query-complexity-plugin.ts
  21. 82 0
      packages/harden-plugin/src/types.ts
  22. 9 0
      packages/harden-plugin/tsconfig.build.json
  23. 10 0
      packages/harden-plugin/tsconfig.json
  24. 1 0
      scripts/changelogs/generate-changelog.ts
  25. 1 0
      scripts/docs/generate-typescript-docs.ts
  26. 8 1
      yarn.lock

+ 4 - 4
docs/content/plugins/extending-the-admin-ui/_index.md

@@ -7,7 +7,7 @@ weight: 5
 
 When creating a plugin, you may wish to extend the Admin UI in order to expose a graphical interface to the plugin's functionality.
 
-This is possible by defining [AdminUiExtensions]({{< ref "admin-ui-extension" >}}). 
+This is possible by defining [AdminUiExtensions]({{< ref "admin-ui-extension" >}}).
 
 {{< alert "primary" >}}
 For a complete working example of a Vendure plugin which extends the Admin UI, see the [real-world-vendure Reviews plugin](https://github.com/vendure-ecommerce/real-world-vendure/tree/master/src/plugins/reviews)
@@ -15,7 +15,7 @@ For a complete working example of a Vendure plugin which extends the Admin UI, s
 
 ## How It Works
 
-A UI extension is an [Angular module](https://angular.io/guide/ngmodules) which gets compiled into the Admin UI application bundle by the [`compileUiExtensions`]({{< relref "compile-ui-extensions" >}}) function exported by the `@vendure/ui-devkit` package. Internally, the ui-devkit package makes use of the Angular CLI to compile an optimized set of JavaScript bundles containing your extensions. 
+A UI extension is an [Angular module](https://angular.io/guide/ngmodules) which gets compiled into the Admin UI application bundle by the [`compileUiExtensions`]({{< relref "compile-ui-extensions" >}}) function exported by the `@vendure/ui-devkit` package. Internally, the ui-devkit package makes use of the Angular CLI to compile an optimized set of JavaScript bundles containing your extensions.
 
 ## Use Your Favourite Framework
 
@@ -28,9 +28,9 @@ The Vendure Admin UI is built with Angular, and writing UI extensions in Angular
 
 Angular uses the concept of modules ([NgModules](https://angular.io/guide/ngmodules)) for organizing related code. These modules can be lazily-loaded, which means that the code is not loaded when the app starts, but only later once that code is required. This keeps the main bundle small and improves performance.
 
-When creating your UI extensions, you can set your module to be either `lazy` or `shared`. Shared modules are loaded _eagerly_, i.e. their code is bundled up with the main app and loaded as soon as the app loads. 
+When creating your UI extensions, you can set your module to be either `lazy` or `shared`. Shared modules are loaded _eagerly_, i.e. their code is bundled up with the main app and loaded as soon as the app loads.
 
-As a rule, modules defining new routes should be lazily loaded (so that the code is only loaded once that route is activated), and modules defining [new navigations items]({{< relref "adding-navigation-items" >}}) and [custom form input]({{< relref "custom-form-inputs" >}}) should be set to `shared`.
+As a rule, modules defining new routes should be lazily loaded (so that the code is only loaded once that route is activated), and modules defining [new navigations items]({{< relref "modifying-navigation-items" >}}) and [custom form input]({{< relref "custom-form-inputs" >}}) should be set to `shared`.
 
 ## Dev mode
 

+ 49 - 13
docs/content/plugins/extending-the-admin-ui/bulk-actions/_index.md → docs/content/plugins/extending-the-admin-ui/add-actions-to-pages/_index.md

@@ -1,22 +1,58 @@
 ---
-title: 'Bulk Actions'
-weight: 7
+title: 'Add Actions To Pages'
+weight: 5
 ---
 
-# Bulk Actions
+# Add Actions To Pages
+
+
+## Adding ActionBar buttons
+
+It may not always make sense to navigate to your extension view from the main nav menu. For example, a "product reviews" extension that shows reviews for a particular product. In this case, you can add new buttons to the "ActionBar", which is the horizontal section at the top of each screen containing the primary actions for that view. This is done using the [addActionBarItem function]({{< relref "add-action-bar-item" >}}).
+
+### ActionBar Example
+
+```TypeScript
+import { NgModule } from '@angular/core';
+import { SharedModule, addActionBarItem } from '@vendure/admin-ui/core';
+
+@NgModule({
+  imports: [SharedModule],
+  providers: [
+    addActionBarItem({
+      id: 'product-reviews',
+      label: 'Product reviews',
+      locationId: 'product-detail',
+      buttonStyle: 'outline',
+      routerLink: route => {
+          const id = route.snapshot.params.id;
+          return ['./extensions/reviews', id];
+      },
+      requiresPermission: 'SuperAdmin'
+    }),
+  ],
+})
+export class SharedExtensionModule {}
+```
+
+{{< figure src="./ui-extensions-actionbar.jpg" >}}
+
+In each list or detail view in the app, the ActionBar has a unique `locationId` which is how the app knows in which view to place your button. The complete list of available locations into which you can add new ActionBar can be found in the [ActionBarLocationId docs]({{< relref "action-bar-location-id" >}}).
+
+## Adding Bulk Actions
 
 Certain list views in the Admin UI support bulk actions. There are a default set of bulk actions that are defined by the Admin UI itself (e.g. delete, assign to channels), but using the `@vendure/ui-devit` package
 you are also able to define your own bulk actions.
 
-{{< figure src="./bulk-actions-screenshot.png" >}} 
+{{< figure src="./bulk-actions-screenshot.png" >}}
 
 Use cases for bulk actions include things like:
 
 - Sending multiple products to a 3rd-party localization service
-- Exporting selected products to csv 
+- Exporting selected products to csv
 - Bulk-updating custom field data
 
-## Bulk Action Example
+### Bulk Action Example
 
 A bulk action must be provided to a [ui-extension shared module]({{< relref "extending-the-admin-ui" >}}#lazy-vs-shared-modules) using the [`registerBulkAction` function]({{< relref "register-bulk-action" >}})
 
@@ -28,18 +64,18 @@ import { ModalService, registerBulkAction, SharedModule } from '@vendure/admin-u
   imports: [SharedModule],
   providers: [
     ProductDataTranslationService,
-      
+
     // Here is where we define our bulk action
-    // for sending the selected products to a 3rd-party 
-    // translation API  
+    // for sending the selected products to a 3rd-party
+    // translation API
     registerBulkAction({
       // This tells the Admin UI that this bulk action should be made
-      // available on the product list view.  
+      // available on the product list view.
       location: 'product-list',
       label: 'Send to translation service',
       icon: 'language',
       // Here is the logic that is executed when the bulk action menu item
-      // is clicked.  
+      // is clicked.
       onClick: ({ injector, selection }) => {
         const modalService = injector.get(ModalService);
         const translationService = injector.get(ProductDataTranslationService);
@@ -63,7 +99,7 @@ import { ModalService, registerBulkAction, SharedModule } from '@vendure/admin-u
 export class MyUiExtensionModule {}
 ```
 
-## Conditionally displaying bulk actions
+### Conditionally displaying bulk actions
 
 Sometimes a bulk action only makes sense in certain circumstances. For example, the "assign to channel" action only makes sense when your server has multiple channels set up.
 
@@ -80,7 +116,7 @@ registerBulkAction({
     .userStatus()
     .mapSingle(({ userStatus }) => 1 < userStatus.channels.length)
     .toPromise(),
-  // ...  
+  // ...
 });
 ```
 

+ 0 - 0
docs/content/plugins/extending-the-admin-ui/bulk-actions/bulk-actions-screenshot.png → docs/content/plugins/extending-the-admin-ui/add-actions-to-pages/bulk-actions-screenshot.png


+ 0 - 0
docs/content/plugins/extending-the-admin-ui/adding-navigation-items/ui-extensions-actionbar.jpg → docs/content/plugins/extending-the-admin-ui/add-actions-to-pages/ui-extensions-actionbar.jpg


+ 3 - 36
docs/content/plugins/extending-the-admin-ui/adding-navigation-items/_index.md → docs/content/plugins/extending-the-admin-ui/modifying-navigation-items/_index.md

@@ -1,5 +1,5 @@
 ---
-title: 'Adding Navigation Items'
+title: 'Modify Navigation Items'
 weight: 5
 ---
 
@@ -7,7 +7,7 @@ weight: 5
 
 ## Extending the NavMenu
 
-Once you have defined some custom routes in a lazy extension module, you need some way for the administrator to access them. For this you will use the [addNavMenuItem]({{< relref "add-nav-menu-item" >}}) and [addNavMenuSection]({{< relref "add-nav-menu-item" >}}) functions. 
+Once you have defined some custom routes in a lazy extension module, you need some way for the administrator to access them. For this you will use the [addNavMenuItem]({{< relref "add-nav-menu-item" >}}) and [addNavMenuSection]({{< relref "add-nav-menu-item" >}}) functions.
 
 Let's add a new section to the Admin UI main nav bar containing a link to the "greeter" module from the [Using Angular]({{< relref "../using-angular" >}}) example:
 
@@ -63,40 +63,7 @@ Running the server will compile our new shared module into the app, and the resu
 
 ## Overriding existing items
 
-It is also possible to override one of the default (built-in) nav menu sections or items. This can be useful for example if you wish to provide a completely different implementation of the product list view. 
+It is also possible to override one of the default (built-in) nav menu sections or items. This can be useful for example if you wish to provide a completely different implementation of the product list view.
 
 This is done by setting the `id` property to that of an existing nav menu section or item.
 
-
-## Adding new ActionBar buttons
-
-It may not always make sense to navigate to your extension view from the main nav menu. For example, a "product reviews" extension that shows reviews for a particular product. In this case, you can add new buttons to the "ActionBar", which is the horizontal section at the top of each screen containing the primary actions for that view. This is done using the [addActionBarItem function]({{< relref "add-action-bar-item" >}}).
-
-Here's an example of how this is done:
-
-```TypeScript
-import { NgModule } from '@angular/core';
-import { SharedModule, addActionBarItem } from '@vendure/admin-ui/core';
-
-@NgModule({
-  imports: [SharedModule],
-  providers: [
-    addActionBarItem({
-      id: 'product-reviews',
-      label: 'Product reviews',
-      locationId: 'product-detail',
-      buttonStyle: 'outline',
-      routerLink: route => {
-          const id = route.snapshot.params.id;
-          return ['./extensions/reviews', id];
-      },
-      requiresPermission: 'SuperAdmin'
-    }),
-  ],
-})
-export class SharedExtensionModule {}
-```
-
-{{< figure src="./ui-extensions-actionbar.jpg" >}}
-
-In each list or detail view in the app, the ActionBar has a unique `locationId` which is how the app knows in which view to place your button. The complete list of available locations into which you can add new ActionBar can be found in the [ActionBarLocationId docs]({{< relref "action-bar-location-id" >}}).

+ 0 - 0
docs/content/plugins/extending-the-admin-ui/adding-navigation-items/ui-extensions-navbar.jpg → docs/content/plugins/extending-the-admin-ui/modifying-navigation-items/ui-extensions-navbar.jpg


+ 1 - 1
docs/content/plugins/extending-the-admin-ui/using-angular/_index.md

@@ -132,4 +132,4 @@ Now go to the Admin UI app in your browser and log in. You should now be able to
 
 ## Next Steps
 
-Now you have created your new route, you need a way for your admin to access it. See [Adding Navigation Items]({{< relref "../adding-navigation-items" >}})
+Now you have created your new route, you need a way for your admin to access it. See [Adding Navigation Items]({{< relref "../modifying-navigation-items" >}})

+ 8 - 8
docs/content/plugins/extending-the-admin-ui/using-other-frameworks/_index.md

@@ -54,14 +54,14 @@ import { hostExternalFrame } from '@vendure/admin-ui/core';
     RouterModule.forChild([
       hostExternalFrame({
         path: '',
-          
+
         // You can also use parameters which allow the app
         // to have dynamic routing, e.g.
         // path: ':slug'
         // Then you can use the getActivatedRoute() function from the
         // UiDevkitClient in order to access the value of the "slug"
         // parameter.
-          
+
         breadcrumbLabel: 'React App',
         // This is the URL to the compiled React app index.
         // The next step will explain the "assets/react-app" path.
@@ -105,10 +105,10 @@ export const config: VendureConfig = {
             {
               // We want to lazy-load our extension...
               type: 'lazy',
-              // ...when the `/admin/extensions/react-ui` 
-              // route is activated 
+              // ...when the `/admin/extensions/react-ui`
+              // route is activated
               route: 'react-ui',
-              // The filename of the extension module 
+              // The filename of the extension module
               // relative to the `extensionPath` above
               ngModuleFileName: 'react-extension.module.ts',
               // The name of the extension module class exported
@@ -213,7 +213,7 @@ const disableProduct = (id: string) => {
 
 If your extension does not have a build step, you can still include the UiDevkitClient as a local resource, which will expose a `VendureUiClient` global object:
 
-```HTML 
+```HTML
 <!-- src/ui-extension/plain-js-app/index.html -->
 <head>
   <script src="../devkit/ui-devkit.js"></script>
@@ -231,10 +231,10 @@ If your extension does not have a build step, you can still include the UiDevkit
          message: 'Updated Product',
        });
     })
-  } 
+  }
 </script>
 ```
 
 ## Next Steps
 
-Now you have created your extension, you need a way for your admin to access it. See [Adding Navigation Items]({{< relref "../adding-navigation-items" >}})
+Now you have created your extension, you need a way for your admin to access it. See [Adding Navigation Items]({{< relref "../modifying-navigation-items" >}})

+ 0 - 3
packages/core/src/api/schema/admin-api/asset-admin.type.graphql

@@ -1,3 +0,0 @@
-type Asset {
-    tags: [Tag!]!
-}

+ 1 - 0
packages/core/src/api/schema/common/asset.type.graphql

@@ -11,6 +11,7 @@ type Asset implements Node {
     source: String!
     preview: String!
     focalPoint: Coordinate
+    tags: [Tag!]!
 }
 
 type Coordinate {

+ 125 - 2
packages/core/src/event-bus/event-bus.spec.ts

@@ -1,7 +1,5 @@
 import { QueryRunner } from 'typeorm';
 
-import { TransactionSubscriber } from '../connection/transaction-subscriber';
-
 import { EventBus } from './event-bus';
 import { VendureEvent } from './vendure-event';
 
@@ -125,6 +123,125 @@ describe('EventBus', () => {
             expect(handler2).toHaveBeenCalledTimes(3);
         });
     });
+
+    describe('filter()', () => {
+        it('single handler is called once', async () => {
+            const handler = jest.fn();
+            const event = new TestEvent('foo');
+            eventBus.filter(vendureEvent => vendureEvent instanceof TestEvent).subscribe(handler);
+
+            eventBus.publish(event);
+            await new Promise(resolve => setImmediate(resolve));
+
+            expect(handler).toHaveBeenCalledTimes(1);
+            expect(handler).toHaveBeenCalledWith(event);
+        });
+
+        it('single handler is called on multiple events', async () => {
+            const handler = jest.fn();
+            const event1 = new TestEvent('foo');
+            const event2 = new TestEvent('bar');
+            const event3 = new TestEvent('baz');
+            eventBus.filter(vendureEvent => vendureEvent instanceof TestEvent).subscribe(handler);
+
+            eventBus.publish(event1);
+            eventBus.publish(event2);
+            eventBus.publish(event3);
+            await new Promise(resolve => setImmediate(resolve));
+
+            expect(handler).toHaveBeenCalledTimes(3);
+            expect(handler).toHaveBeenCalledWith(event1);
+            expect(handler).toHaveBeenCalledWith(event2);
+            expect(handler).toHaveBeenCalledWith(event3);
+        });
+
+        it('multiple handlers are called', async () => {
+            const handler1 = jest.fn();
+            const handler2 = jest.fn();
+            const handler3 = jest.fn();
+            const event = new TestEvent('foo');
+            eventBus.filter(vendureEvent => vendureEvent instanceof TestEvent).subscribe(handler1);
+            eventBus.filter(vendureEvent => vendureEvent instanceof TestEvent).subscribe(handler2);
+            eventBus.filter(vendureEvent => vendureEvent instanceof TestEvent).subscribe(handler3);
+
+            eventBus.publish(event);
+            await new Promise(resolve => setImmediate(resolve));
+
+            expect(handler1).toHaveBeenCalledWith(event);
+            expect(handler2).toHaveBeenCalledWith(event);
+            expect(handler3).toHaveBeenCalledWith(event);
+        });
+
+        it('handler is not called for other events', async () => {
+            const handler = jest.fn();
+            const event = new OtherTestEvent('foo');
+            eventBus.filter(vendureEvent => vendureEvent instanceof TestEvent).subscribe(handler);
+
+            eventBus.publish(event);
+            await new Promise(resolve => setImmediate(resolve));
+
+            expect(handler).not.toHaveBeenCalled();
+        });
+
+        it('handler is called for instance of child classes', async () => {
+            const handler = jest.fn();
+            const event = new ChildTestEvent('bar', 'foo');
+            eventBus.filter(vendureEvent => vendureEvent instanceof TestEvent).subscribe(handler);
+
+            eventBus.publish(event);
+            await new Promise(resolve => setImmediate(resolve));
+
+            expect(handler).toHaveBeenCalled();
+        });
+
+        it('filter() returns a subscription', async () => {
+            const handler = jest.fn();
+            const event = new TestEvent('foo');
+            const subscription = eventBus
+                .filter(vendureEvent => vendureEvent instanceof TestEvent)
+                .subscribe(handler);
+
+            eventBus.publish(event);
+            await new Promise(resolve => setImmediate(resolve));
+
+            expect(handler).toHaveBeenCalledTimes(1);
+
+            subscription.unsubscribe();
+
+            eventBus.publish(event);
+            eventBus.publish(event);
+            await new Promise(resolve => setImmediate(resolve));
+
+            expect(handler).toHaveBeenCalledTimes(1);
+        });
+
+        it('unsubscribe() only unsubscribes own handler', async () => {
+            const handler1 = jest.fn();
+            const handler2 = jest.fn();
+            const event = new TestEvent('foo');
+            const subscription1 = eventBus
+                .filter(vendureEvent => vendureEvent instanceof TestEvent)
+                .subscribe(handler1);
+            const subscription2 = eventBus
+                .filter(vendureEvent => vendureEvent instanceof TestEvent)
+                .subscribe(handler2);
+
+            eventBus.publish(event);
+            await new Promise(resolve => setImmediate(resolve));
+
+            expect(handler1).toHaveBeenCalledTimes(1);
+            expect(handler2).toHaveBeenCalledTimes(1);
+
+            subscription1.unsubscribe();
+
+            eventBus.publish(event);
+            eventBus.publish(event);
+            await new Promise(resolve => setImmediate(resolve));
+
+            expect(handler1).toHaveBeenCalledTimes(1);
+            expect(handler2).toHaveBeenCalledTimes(3);
+        });
+    });
 });
 
 class TestEvent extends VendureEvent {
@@ -133,6 +250,12 @@ class TestEvent extends VendureEvent {
     }
 }
 
+class ChildTestEvent extends TestEvent {
+    constructor(public childPayload: string, payload: string) {
+        super(payload);
+    }
+}
+
 class OtherTestEvent extends VendureEvent {
     constructor(public payload: string) {
         super();

+ 23 - 5
packages/core/src/event-bus/event-bus.ts

@@ -3,8 +3,8 @@ import { Type } from '@vendure/common/lib/shared-types';
 import { Observable, Subject } from 'rxjs';
 import { filter, mergeMap, takeUntil } from 'rxjs/operators';
 import { EntityManager } from 'typeorm';
-import { notNullOrUndefined } from '../../../common/lib/shared-utils';
 
+import { notNullOrUndefined } from '../../../common/lib/shared-utils';
 import { RequestContext } from '../api/common/request-context';
 import { TRANSACTION_MANAGER_KEY } from '../common/constants';
 import { TransactionSubscriber, TransactionSubscriberError } from '../connection/transaction-subscriber';
@@ -81,9 +81,27 @@ export class EventBus implements OnModuleDestroy {
     ofType<T extends VendureEvent>(type: Type<T>): Observable<T> {
         return this.eventStream.asObservable().pipe(
             takeUntil(this.destroy$),
-            filter(e => (e as any).constructor === type),
+            filter(e => e.constructor === type),
             mergeMap(event => this.awaitActiveTransactions(event)),
-            filter(notNullOrUndefined)
+            filter(notNullOrUndefined),
+        ) as Observable<T>;
+    }
+
+    /**
+     * @description
+     * Returns an RxJS Observable stream of events filtered by a custom predicate.
+     * If the event contains a {@link RequestContext} object, the subscriber
+     * will only get called after any active database transactions are complete.
+     *
+     * This means that the subscriber function can safely access all updated
+     * data related to the event.
+     */
+    filter<T extends VendureEvent>(predicate: (event: VendureEvent) => boolean): Observable<T> {
+        return this.eventStream.asObservable().pipe(
+            takeUntil(this.destroy$),
+            filter(e => predicate(e)),
+            mergeMap(event => this.awaitActiveTransactions(event)),
+            filter(notNullOrUndefined),
         ) as Observable<T>;
     }
 
@@ -119,7 +137,7 @@ export class EventBus implements OnModuleDestroy {
         }
 
         const [key, ctx]: [string, RequestContext] = entry;
-        
+
         const transactionManager: EntityManager | undefined = (ctx as any)[TRANSACTION_MANAGER_KEY];
         if (!transactionManager?.queryRunner) {
             return event;
@@ -134,7 +152,7 @@ export class EventBus implements OnModuleDestroy {
             delete (newContext as any)[TRANSACTION_MANAGER_KEY];
 
             // Reassign new context
-            (event as any)[key] = newContext
+            (event as any)[key] = newContext;
 
             return event;
         } catch (e: any) {

+ 4 - 0
packages/harden-plugin/.gitignore

@@ -0,0 +1,4 @@
+preview/output
+yarn-error.log
+lib
+e2e/__data__/*.sqlite

+ 7 - 0
packages/harden-plugin/README.md

@@ -0,0 +1,7 @@
+# Vendure Harden Plugin
+
+Hardens your Vendure GraphQL APIs against attacks.
+
+`npm install @vendure/harden-plugin`
+
+For documentation, see [www.vendure.io/docs/typescript-api/harden-plugin/](https://www.vendure.io/docs/typescript-api/harden-plugin/)

+ 3 - 0
packages/harden-plugin/index.ts

@@ -0,0 +1,3 @@
+export * from './src/harden.plugin';
+export * from './src/types';
+export * from './src/middleware/query-complexity-plugin';

+ 27 - 0
packages/harden-plugin/package.json

@@ -0,0 +1,27 @@
+{
+  "name": "@vendure/harden-plugin",
+  "version": "1.9.1",
+  "license": "MIT",
+  "main": "lib/index.js",
+  "types": "lib/index.d.ts",
+  "files": [
+    "lib/**/*"
+  ],
+  "scripts": {
+    "watch": "tsc -p ./tsconfig.build.json --watch",
+    "build": "rimraf lib && tsc -p ./tsconfig.build.json",
+    "lint": "tslint --fix --project ./"
+  },
+  "homepage": "https://www.vendure.io/",
+  "funding": "https://github.com/sponsors/michaelbromley",
+  "publishConfig": {
+    "access": "public"
+  },
+  "dependencies": {
+    "graphql-query-complexity": "^0.12.0"
+  },
+  "devDependencies": {
+    "@vendure/common": "^1.9.1",
+    "@vendure/core": "^1.9.1"
+  }
+}

+ 2 - 0
packages/harden-plugin/src/constants.ts

@@ -0,0 +1,2 @@
+export const loggerCtx = 'HardenPlugin';
+export const HARDEN_PLUGIN_OPTIONS = Symbol('HARDEN_PLUGIN_OPTIONS');

+ 170 - 0
packages/harden-plugin/src/harden.plugin.ts

@@ -0,0 +1,170 @@
+import { Logger, VendurePlugin } from '@vendure/core';
+
+import { HARDEN_PLUGIN_OPTIONS, loggerCtx } from './constants';
+import { HideValidationErrorsPlugin } from './middleware/hide-validation-errors-plugin';
+import { QueryComplexityPlugin } from './middleware/query-complexity-plugin';
+import { HardenPluginOptions } from './types';
+
+/**
+ * @description
+ * The HardenPlugin hardens the Shop and Admin GraphQL APIs against attacks and abuse.
+ *
+ * - It analyzes the complexity on incoming graphql queries and rejects queries that are too complex and
+ *   could be used to overload the resources of the server.
+ * - It disables dev-mode API features such as introspection and the GraphQL playground app.
+ * - It removes field name suggestions to prevent trial-and-error schema sniffing.
+ *
+ * It is a recommended plugin for all production configurations.
+ *
+ * ## Installation
+ *
+ * `yarn add \@vendure/harden-plugin`
+ *
+ * or
+ *
+ * `npm install \@vendure/harden-plugin`
+ *
+ * Then add the `HardenPlugin`, calling the `.init()` method with {@link HardenPluginOptions}:
+ *
+ * @example
+ * ```ts
+ * import { HardenPlugin } from '\@vendure/harden-plugin';
+ *
+ * const config: VendureConfig = {
+ *   // Add an instance of the plugin to the plugins array
+ *   plugins: [
+ *      HardenPlugin.init({
+ *        maxQueryComplexity: 650,
+ *        apiMode: process.env.NODE_ENV === 'development' ? 'dev' : 'prod',
+ *      }),
+ *   ],
+ * };
+ * ```
+ *
+ * ## Setting the max query complexity
+ *
+ * The `maxQueryComplexity` option determines how complex a query can be. The complexity of a query relates to how many, and how
+ * deeply-nested are the fields being selected, and is intended to roughly correspond to the amount of server resources that would
+ * be required to resolve that query.
+ *
+ * The goal of this setting is to prevent attacks in which a malicious actor crafts a very complex query in order to overwhelm your
+ * server resources. Here's an example of a request which would likely overwhelm a Vendure server:
+ *
+ * ```GraphQL
+ * query EvilQuery {
+ *   products {
+ *     items {
+ *       collections {
+ *         productVariants {
+ *           items {
+ *             product {
+ *               collections {
+ *                 productVariants {
+ *                   items {
+ *                     product {
+ *                       variants {
+ *                         name
+ *                       }
+ *                     }
+ *                   }
+ *                 }
+ *               }
+ *             }
+ *           }
+ *         }
+ *       }
+ *     }
+ *   }
+ * }
+ * ```
+ *
+ * This evil query has a complexity score of 2,443,203 - much greater than the default of 1,000!
+ *
+ * The complexity score is calculated by the [graphql-query-complexity library](https://www.npmjs.com/package/graphql-query-complexity),
+ * and by default uses the {@link defaultVendureComplexityEstimator}, which is tuned specifically to the Vendure Shop API.
+ *
+ * The optimal max complexity score will vary depending on:
+ *
+ * - The requirements of your storefront and other clients using the Shop API
+ * - The resources available to your server
+ *
+ * You should aim to set the maximum as low as possible while still being able to service all the requests required. This will take some manual tuning.
+ * While tuning the max, you can turn on the `logComplexityScore` to get a detailed breakdown of the complexity of each query, as well as how
+ * that total score is derived from its child fields:
+ *
+ * @example
+ * ```ts
+ * import { HardenPlugin } from '\@vendure/harden-plugin';
+ *
+ * const config: VendureConfig = {
+ *   // A detailed summary is logged at the "debug" level
+ *   logger: new DefaultLogger({ level: LogLevel.Debug }),
+ *   plugins: [
+ *      HardenPlugin.init({
+ *        maxQueryComplexity: 650,
+ *        logComplexityScore: true,
+ *      }),
+ *   ],
+ * };
+ * ```
+ *
+ * With logging configured as above, the following query:
+ *
+ * ```GraphQL
+ * query ProductList {
+ *   products(options: { take: 5 }) {
+ *     items {
+ *       id
+ *       name
+ *       featuredAsset {
+ *         preview
+ *       }
+ *     }
+ *   }
+ * }
+ * ```
+ * will log the following breakdown:
+ *
+ * ```sh
+ * debug 16/12/22, 14:12 - [HardenPlugin] Calculating complexity of [ProductList]
+ * debug 16/12/22, 14:12 - [HardenPlugin] Product.id: ID!     childComplexity: 0, score: 1
+ * debug 16/12/22, 14:12 - [HardenPlugin] Product.name: String!       childComplexity: 0, score: 1
+ * debug 16/12/22, 14:12 - [HardenPlugin] Asset.preview: String!      childComplexity: 0, score: 1
+ * debug 16/12/22, 14:12 - [HardenPlugin] Product.featuredAsset: Asset        childComplexity: 1, score: 2
+ * debug 16/12/22, 14:12 - [HardenPlugin] ProductList.items: [Product!]!      childComplexity: 4, score: 20
+ * debug 16/12/22, 14:12 - [HardenPlugin] Query.products: ProductList!        childComplexity: 20, score: 35
+ * verbose 16/12/22, 14:12 - [HardenPlugin] Query complexity [ProductList]: 35
+ * ```
+ *
+ * @docsCategory HardenPlugin
+ */
+@VendurePlugin({
+    providers: [
+        {
+            provide: HARDEN_PLUGIN_OPTIONS,
+            useFactory: () => HardenPlugin.options,
+        },
+    ],
+    configuration: config => {
+        if (HardenPlugin.options.hideFieldSuggestions !== false) {
+            Logger.verbose(`Configuring HideValidationErrorsPlugin`, loggerCtx);
+            config.apiOptions.apolloServerPlugins.push(new HideValidationErrorsPlugin());
+        }
+        config.apiOptions.apolloServerPlugins.push(new QueryComplexityPlugin(HardenPlugin.options));
+        if (HardenPlugin.options.apiMode !== 'dev') {
+            config.apiOptions.adminApiDebug = false;
+            config.apiOptions.shopApiDebug = false;
+            config.apiOptions.introspection = false;
+        }
+
+        return config;
+    },
+})
+export class HardenPlugin {
+    static options: HardenPluginOptions;
+
+    static init(options: HardenPluginOptions) {
+        this.options = options;
+        return HardenPlugin;
+    }
+}

+ 26 - 0
packages/harden-plugin/src/middleware/hide-validation-errors-plugin.ts

@@ -0,0 +1,26 @@
+import { ValidationError } from 'apollo-server-core';
+import { ApolloServerPlugin, GraphQLRequestListener, GraphQLServiceContext } from 'apollo-server-plugin-base';
+
+/**
+ * @description
+ * Hides graphql-js suggestions when invalid field names are given.
+ * Based on ideas discussed in https://github.com/apollographql/apollo-server/issues/3919
+ */
+export class HideValidationErrorsPlugin implements ApolloServerPlugin {
+    requestDidStart(): GraphQLRequestListener {
+        return {
+            willSendResponse: async requestContext => {
+                const { errors, context } = requestContext;
+                if (errors) {
+                    (requestContext.response as any).errors = errors.map(err => {
+                        if (err.message.includes('Did you mean')) {
+                            return new ValidationError('Invalid request');
+                        } else {
+                            return err;
+                        }
+                    });
+                }
+            },
+        };
+    }
+}

+ 127 - 0
packages/harden-plugin/src/middleware/query-complexity-plugin.ts

@@ -0,0 +1,127 @@
+import { InternalServerError, Logger } from '@vendure/core';
+import { ApolloServerPlugin, GraphQLRequestListener } from 'apollo-server-plugin-base';
+import { GraphQLRequestContext } from 'apollo-server-types';
+import {
+    getNamedType,
+    getNullableType,
+    GraphQLSchema,
+    isListType,
+    isObjectType,
+    separateOperations,
+} from 'graphql';
+import { ComplexityEstimatorArgs, getComplexity, simpleEstimator } from 'graphql-query-complexity';
+
+import { loggerCtx } from '../constants';
+import { HardenPluginOptions } from '../types';
+
+/**
+ * @description
+ * Implements query complexity analysis on Shop API requests.
+ */
+export class QueryComplexityPlugin implements ApolloServerPlugin {
+    constructor(private options: HardenPluginOptions) {}
+
+    requestDidStart({ schema }: GraphQLRequestContext): GraphQLRequestListener {
+        const maxQueryComplexity = this.options.maxQueryComplexity ?? 1000;
+        return {
+            didResolveOperation: async ({ request, document }) => {
+                if (isAdminApi(schema)) {
+                    // We don't want to apply the cost analysis on the
+                    // Admin API, since any expensive operations would require
+                    // an authenticated session.
+                    return;
+                }
+                const query = request.operationName
+                    ? separateOperations(document)[request.operationName]
+                    : document;
+
+                if (this.options.logComplexityScore === true) {
+                    Logger.debug(
+                        `Calculating complexity of "${request.operationName ?? 'anonymous'}"`,
+                        loggerCtx,
+                    );
+                }
+                const complexity = getComplexity({
+                    schema,
+                    query,
+                    variables: request.variables,
+                    estimators: this.options.queryComplexityEstimators ?? [
+                        defaultVendureComplexityEstimator(
+                            this.options.customComplexityFactors ?? {},
+                            this.options.logComplexityScore ?? false,
+                        ),
+                        simpleEstimator({ defaultComplexity: 1 }),
+                    ],
+                });
+
+                if (this.options.logComplexityScore === true) {
+                    Logger.verbose(
+                        `Query complexity "${request.operationName ?? 'anonymous'}": ${complexity}`,
+                        loggerCtx,
+                    );
+                }
+                if (complexity >= maxQueryComplexity) {
+                    Logger.error(
+                        `Query complexity of "${
+                            request.operationName ?? 'anonymous'
+                        }" is ${complexity}, which exceeds the maximum of ${maxQueryComplexity}`,
+                        loggerCtx,
+                    );
+                    throw new InternalServerError(`Query is too complex`);
+                }
+            },
+        };
+    }
+}
+
+function isAdminApi(schema: GraphQLSchema): boolean {
+    const queryType = schema.getQueryType();
+    if (queryType) {
+        return !!queryType.getFields().administrators;
+    }
+    return false;
+}
+
+/**
+ * @description
+ * A complexity estimator which takes into account List and PaginatedList types and can
+ * be further configured by providing a customComplexityFactors object.
+ *
+ * When selecting PaginatedList types, the "take" argument is used to estimate a complexity
+ * factor. If the "take" argument is omitted, a default factor of 1000 is applied.
+ *
+ * @docsCategory HardenPlugin
+ */
+export function defaultVendureComplexityEstimator(
+    customComplexityFactors: { [path: string]: number },
+    logFieldScores: boolean,
+) {
+    return (options: ComplexityEstimatorArgs): number | void => {
+        const { type, args, childComplexity, field } = options;
+        const namedType = getNamedType(field.type);
+        const path = `${type.name}.${field.name}`;
+        let result = childComplexity + 1;
+        const customFactor = customComplexityFactors[path];
+        if (customFactor != null) {
+            result = Math.max(childComplexity, 1) * customFactor;
+        } else {
+            if (isObjectType(namedType)) {
+                const isPaginatedList = !!namedType.getInterfaces().find(i => i.name === 'PaginatedList');
+                if (isPaginatedList) {
+                    const take = args.options?.take ?? 1000;
+                    result = childComplexity + Math.round(Math.log(childComplexity) * take);
+                }
+            }
+            if (isListType(getNullableType(field.type))) {
+                result = childComplexity * 5;
+            }
+        }
+        if (logFieldScores) {
+            Logger.debug(
+                `${path}: ${field.type.toString()}\tchildComplexity: ${childComplexity}, score: ${result}`,
+                loggerCtx,
+            );
+        }
+        return result;
+    };
+}

+ 82 - 0
packages/harden-plugin/src/types.ts

@@ -0,0 +1,82 @@
+import { ComplexityEstimator } from 'graphql-query-complexity/dist/cjs/QueryComplexity';
+
+/**
+ * @description
+ * Options that can be passed to the `.init()` static method of the HardenPlugin.
+ *
+ * @docsCategory HardenPlugin
+ */
+export interface HardenPluginOptions {
+    /**
+     * @description
+     * Defines the maximum permitted complexity score of a query. The complexity score is based
+     * on the number of fields being selected as well as other factors like whether there are nested
+     * lists.
+     *
+     * A query which exceeds the maximum score will result in an error.
+     *
+     * @default 1000
+     */
+    maxQueryComplexity?: number;
+    /**
+     * @description
+     * An array of custom estimator functions for calculating the complexity of a query. By default,
+     * the plugin will use the {@link defaultVendureComplexityEstimator} which is specifically
+     * tuned to accurately estimate Vendure queries.
+     */
+    queryComplexityEstimators?: ComplexityEstimator[];
+    /**
+     * @description
+     * When set to `true`, the complexity score of each query will be logged at the Verbose
+     * log level, and a breakdown of the calculation for each field will be logged at the Debug level.
+     *
+     * This is very useful for tuning your complexity scores.
+     *
+     * @default false
+     */
+    logComplexityScore?: boolean;
+
+    /**
+     * @description
+     * This object allows you to tune the complexity weight of specific fields. For example,
+     * if you have a custom `stockLocations` field defined on the `ProductVariant` type, and
+     * you know that it is a particularly expensive operation to execute, you can increase
+     * its complexity like this:
+     *
+     * @example
+     * ```TypeScript
+     * HardenPlugin.init({
+     *   maxQueryComplexity: 650,
+     *   customComplexityFactors: {
+     *     'ProductVariant.stockLocations': 10
+     *   }
+     * }),
+     * ```
+     */
+    customComplexityFactors?: {
+        [path: string]: number;
+    };
+
+    /**
+     * @description
+     * Graphql-js will make suggestions about the names of fields if an invalid field name is provided.
+     * This would allow an attacker to find out the available fields by brute force even if introspection
+     * is disabled.
+     *
+     * Setting this option to `true` will prevent these suggestion error messages from being returned,
+     * instead replacing the message with a generic "Invalid request" message.
+     *
+     * @default true
+     */
+    hideFieldSuggestions?: boolean;
+    /**
+     * @description
+     * When set to `'prod'`, the plugin will disable dev-mode features of the GraphQL APIs:
+     *
+     * - introspection
+     * - GraphQL playground
+     *
+     * @default 'prod'
+     */
+    apiMode?: 'dev' | 'prod';
+}

+ 9 - 0
packages/harden-plugin/tsconfig.build.json

@@ -0,0 +1,9 @@
+{
+  "extends": "./tsconfig.json",
+  "compilerOptions": {
+    "outDir": "./lib"
+  },
+  "files": [
+    "./index.ts"
+  ]
+}

+ 10 - 0
packages/harden-plugin/tsconfig.json

@@ -0,0 +1,10 @@
+{
+  "extends": "../../tsconfig.json",
+  "compilerOptions": {
+    "declaration": true,
+    "removeComments": false,
+    "noLib": false,
+    "skipLibCheck": true,
+    "sourceMap": true
+  }
+}

+ 1 - 0
scripts/changelogs/generate-changelog.ts

@@ -33,6 +33,7 @@ const VALID_SCOPES: string[] = [
     'payments-plugin',
     'testing',
     'ui-devkit',
+    'harden-plugin',
 ];
 
 const mainTemplate = fs.readFileSync(path.join(__dirname, 'template.hbs'), 'utf-8');

+ 1 - 0
scripts/docs/generate-typescript-docs.ts

@@ -26,6 +26,7 @@ const sections: DocsSectionConfig[] = [
             'packages/job-queue-plugin/src/',
             'packages/payments-plugin/src/',
             'packages/testing/src/',
+            'packages/harden-plugin/src/',
         ],
         exclude: [/generated-shop-types/],
         outputPath: 'typescript-api',

+ 8 - 1
yarn.lock

@@ -9316,6 +9316,13 @@ graphql-fields@^2.0.3:
   resolved "https://registry.npmjs.org/graphql-fields/-/graphql-fields-2.0.3.tgz#5e68dff7afbb202be4f4f40623e983b22c96ab8f"
   integrity sha512-x3VE5lUcR4XCOxPIqaO4CE+bTK8u6gVouOdpQX9+EKHr+scqtK5Pp/l8nIGqIpN1TUlkKE6jDCCycm/WtLRAwA==
 
+graphql-query-complexity@^0.12.0:
+  version "0.12.0"
+  resolved "https://registry.yarnpkg.com/graphql-query-complexity/-/graphql-query-complexity-0.12.0.tgz#5f636ccc54da82225f31e898e7f27192fe074b4c"
+  integrity sha512-fWEyuSL6g/+nSiIRgIipfI6UXTI7bAxrpPlCY1c0+V3pAEUo1ybaKmSBgNr1ed2r+agm1plJww8Loig9y6s2dw==
+  dependencies:
+    lodash.get "^4.4.2"
+
 graphql-request@^3.3.0:
   version "3.7.0"
   resolved "https://registry.npmjs.org/graphql-request/-/graphql-request-3.7.0.tgz#c7406e537084f8b9788541e3e6704340ca13055b"
@@ -11928,7 +11935,7 @@ lodash.flatten@^4.4.0:
   resolved "https://registry.npmjs.org/lodash.flatten/-/lodash.flatten-4.4.0.tgz#f31c22225a9632d2bbf8e4addbef240aa765a61f"
   integrity sha1-8xwiIlqWMtK7+OSt2+8kCqdlph8=
 
-lodash.get@^4:
+lodash.get@^4, lodash.get@^4.4.2:
   version "4.4.2"
   resolved "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz#2d177f652fa31e939b4438d5341499dfa3825e99"
   integrity sha1-LRd/ZS+jHpObRDjVNBSZ36OCXpk=