diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e4656b1..8e5a695 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -25,9 +25,6 @@ jobs: - name: Install dependencies run: bun install - - name: Run linter - run: bun run lint - test: runs-on: ubuntu-latest needs: lint diff --git a/qodana.yaml b/qodana.yaml new file mode 100644 index 0000000..29f8f8c --- /dev/null +++ b/qodana.yaml @@ -0,0 +1,29 @@ +#-------------------------------------------------------------------------------# +# Qodana analysis is configured by qodana.yaml file # +# https://www.jetbrains.com/help/qodana/qodana-yaml.html # +#-------------------------------------------------------------------------------# +version: "1.0" + +#Specify inspection profile for code analysis +profile: + name: qodana.starter + +#Enable inspections +#include: +# - name: + +#Disable inspections +#exclude: +# - name: +# paths: +# - + +#Execute shell command before Qodana execution (Applied in CI/CD pipeline) +#bootstrap: sh ./prepare-qodana.sh + +#Install IDE plugins before Qodana execution (Applied in CI/CD pipeline) +#plugins: +# - id: #(plugin id can be found at https://plugins.jetbrains.com) + +#Specify Qodana linter for analysis (Applied in CI/CD pipeline) +linter: jetbrains/qodana-js:latest diff --git a/src/index.ts b/src/index.ts index 81eb384..8a4ac17 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,82 +6,93 @@ import env from './config/config'; import logger from './config/logger'; import { initEnforcer, getEnforcer } from './rbac'; import { authenticate } from './middleware/auth'; -import { PluginLoader } from './plugins/plugin-loader'; +import PluginLoader from './plugins/plugin-loader'; import { isIntrospectionQuery } from './utils/introspection-check'; import { shouldBypassAuth } from './utils/should-bypass-auth'; import { bootstrap } from './plugins/auth-plugin/bootstrap'; -import sanitizeLog from './sanitize-log.ts'; +import sanitizeLog from './sanitize-log'; -const loggerCtx = 'index'; +const loggerCtx = { context: 'index' }; async function startServer() { - await connectToDatabase(); - await initEnforcer(); // Initialize Casbin - await bootstrap(); // Bootstrap the application with a superuser - - const pluginLoader = new PluginLoader(); - pluginLoader.loadPlugins(); - - const schema = await pluginLoader.createSchema(); - - const server = new ApolloServer({ - schema, - introspection: true, // Ensure introspection is enabled - context: async ({ req }) => ({ - user: req.user, // User object from middleware - enforcer: await getEnforcer(), // Casbin enforcer instance - }), - }); - - await server.start(); - - const app: Application = express(); - - app.use(express.json()); - - // Middleware to conditionally authenticate user and set user context - app.use('/graphql', (req: Request, res: Response, next: NextFunction) => { - const reqInfo = { - url: req.url, - method: req.method, - ip: req.ip, - headers: req.headers, - operation: {}, - }; - - if (req.body && req.body.query) { - if (isIntrospectionQuery(req.body.query)) { - logger.info('Bypassing authentication for introspection query', loggerCtx); - return next(); // Bypass authentication for introspection queries - } + try { + await connectToDatabase(); + await initEnforcer(); // Initialize Casbin + await bootstrap(); // Bootstrap the application with a superuser - if (shouldBypassAuth(req.body.query)) { - logger.info(`Bypassing authentication for due to excluded operation: ${req.body.query}`, loggerCtx); - return next(); // Bypass authentication for this request - } + const pluginLoader = new PluginLoader(); + pluginLoader.loadPlugins(); - try { - // If no operation bypasses authentication, apply authentication middleware - authenticate(req, res, next); - } catch (error) { - logger.error('Error parsing GraphQL query:', { error, query: req.body.query }); + // Register models before initializing plugins + pluginLoader.registerModels(); + + // Initialize plugins (extend models and resolvers) + pluginLoader.initializePlugins(); + + const schema = await pluginLoader.createSchema(); + + const server = new ApolloServer({ + schema, + introspection: true, // Ensure introspection is enabled + context: async ({ req }) => ({ + user: req.user, // User object from middleware + enforcer: await getEnforcer(), // Casbin enforcer instance + pluginsContext: pluginLoader.context, // Add the global context here + }), + }); + + await server.start(); + + const app: Application = express(); + + app.use(express.json()); + + // Middleware to conditionally authenticate user and set user context + app.use('/graphql', (req: Request, res: Response, next: NextFunction) => { + const reqInfo = { + url: req.url, + method: req.method, + ip: req.ip, + headers: req.headers, + operation: {}, + }; + + if (req.body && req.body.query) { + if (isIntrospectionQuery(req.body.query)) { + logger.info('Bypassing authentication for introspection query', loggerCtx); + return next(); // Bypass authentication for introspection queries + } + + if (shouldBypassAuth(req.body.query)) { + logger.info(`Bypassing authentication due to excluded operation: ${req.body.query}`, loggerCtx); + return next(); // Bypass authentication for this request + } + + try { + // If no operation bypasses authentication, apply authentication middleware + authenticate(req, res, next); + } catch (error) { + logger.error('Error parsing GraphQL query:', { error, query: req.body.query }); + authenticate(req, res, next); + } + } else { + // If there is no query in the request body, continue with authentication authenticate(req, res, next); } - } else { - // If there is no query in the request body, continue with authentication - authenticate(req, res, next); - } - const sanitizedReqInfo = sanitizeLog(reqInfo); - const logLine = JSON.stringify(sanitizedReqInfo, null, 0); - logger.verbose(`reqInfo: ${logLine}`, loggerCtx); - }); - - server.applyMiddleware({ app }); - - const port = env.PORT; - app.listen(port, () => { - logger.info(`Server is running at http://localhost:${port}${server.graphqlPath}`, { context: 'server' }); - }); + const sanitizedReqInfo = sanitizeLog(reqInfo); + const logLine = JSON.stringify(sanitizedReqInfo, null, 0); + logger.verbose(`reqInfo: ${logLine}`, loggerCtx); + }); + + server.applyMiddleware({ app }); + + const port = env.PORT; + app.listen(port, () => { + logger.info(`Server is running at http://localhost:${port}${server.graphqlPath}`, { context: 'index' }); + }); + } catch (error) { + logger.error('Failed to start server:', error, loggerCtx); + } } startServer(); diff --git a/src/middleware/auth.ts b/src/middleware/auth.ts index e973968..8aaa70b 100644 --- a/src/middleware/auth.ts +++ b/src/middleware/auth.ts @@ -2,7 +2,7 @@ import type { Request, Response, NextFunction } from 'express'; import jwt, { type JwtPayload } from 'jsonwebtoken'; import logger from '../config/logger.ts'; -const loggerCtx = 'auth-middleware'; +const loggerCtx = { context: 'auth-middleware' }; export const authenticate = (req: Request, res: Response, next: NextFunction) => { const authHeader = req.headers.authorization; @@ -13,7 +13,7 @@ export const authenticate = (req: Request, res: Response, next: NextFunction) => const token = authHeader.split(' ')[1]; if (!token) { - logger.error('Token missing'); + logger.error('Token missing', loggerCtx); return res.status(401).send('Token missing'); } @@ -27,7 +27,7 @@ export const authenticate = (req: Request, res: Response, next: NextFunction) => req.user = decoded; // Attach the user to the request object next(); } catch (err) { - console.log('Invalid token:', err); + logger.error('Invalid token:', err, loggerCtx); return res.status(401).send('Invalid token'); } }; diff --git a/src/plugins/auth-plugin/bootstrap.ts b/src/plugins/auth-plugin/bootstrap.ts index af2131a..9c70baa 100644 --- a/src/plugins/auth-plugin/bootstrap.ts +++ b/src/plugins/auth-plugin/bootstrap.ts @@ -1,13 +1,16 @@ import bcrypt from 'bcrypt'; -import { UserModel } from './entities/user'; +import { UserModel } from './models/user'; import { getEnforcer } from '../../rbac'; import logger from '../../config/logger'; +const loggerCtx = { context: 'auth-plugin-bootstrap' }; +const email = 'superuser@example.com'; + export const bootstrap = async () => { const userCount = await UserModel.countDocuments({}); if (userCount === 0) { const superuser = new UserModel({ - email: 'superuser@example.com', + email: email, password: await bcrypt.hash('superpassword', 10), // Use a secure password name: 'Super User', role: 'superadmin', @@ -33,8 +36,8 @@ export const bootstrap = async () => { } } - logger.info('Superuser created with email: superuser@phoenix.com'); + logger.info(`Superuser created with email: ${email}`); } else { - logger.info('Users already exist. No superuser created.'); + logger.info('Users already exist. No superuser created.', loggerCtx); } }; diff --git a/src/plugins/auth-plugin/index.ts b/src/plugins/auth-plugin/index.ts index e0f0ecf..c6dc1a6 100644 --- a/src/plugins/auth-plugin/index.ts +++ b/src/plugins/auth-plugin/index.ts @@ -1,12 +1,16 @@ import { AuthResolver } from './resolvers/auth-resolver'; import type { Plugin } from '../plugin-interface'; import FunctionRegistry from '../function-registry'; +import { type GlobalContext } from '../global-context'; const authPlugin: Plugin = { - name: 'AuthPlugin', + name: 'auth-plugin', type: 'authorization', resolvers: [AuthResolver], - register: (container: any) => { + register: (container: any, context: GlobalContext) => { + // Register resolvers + context.resolvers['Auth'] = authPlugin.resolvers ?? []; + // Perform any additional registration if necessary const functionRegistry = FunctionRegistry.getInstance(); functionRegistry.registerFunction('user', () => console.log('User function called')); diff --git a/src/plugins/auth-plugin/entities/user.ts b/src/plugins/auth-plugin/models/user.ts similarity index 100% rename from src/plugins/auth-plugin/entities/user.ts rename to src/plugins/auth-plugin/models/user.ts diff --git a/src/plugins/auth-plugin/resolvers/auth-resolver.ts b/src/plugins/auth-plugin/resolvers/auth-resolver.ts index a196cbe..13140ce 100644 --- a/src/plugins/auth-plugin/resolvers/auth-resolver.ts +++ b/src/plugins/auth-plugin/resolvers/auth-resolver.ts @@ -1,15 +1,12 @@ import { Resolver, Mutation, Arg, Query, Ctx } from 'type-graphql'; import bcrypt from 'bcrypt'; -import { UserModel } from '../entities/user'; -import logger from '../../../config/logger'; -import { User } from '../entities/user'; +import { UserModel } from '../models/user'; +import { User } from '../models/user'; import { UserService } from '../services/user-service.ts'; import { Service } from 'typedi'; import jwt from 'jsonwebtoken'; import { getEnforcer } from '../../../rbac'; -const loggerCtx = 'auth-resolver'; - @Service() // Register AuthResolver with Typedi @Resolver() export class AuthResolver { @@ -19,7 +16,6 @@ export class AuthResolver { this.userService = new UserService(); } - @Mutation(() => User) async register( @Arg('email') email: string, diff --git a/src/plugins/auth-plugin/services/user-service.test.ts b/src/plugins/auth-plugin/services/user-service.test.ts index 161393d..aa00b5f 100644 --- a/src/plugins/auth-plugin/services/user-service.test.ts +++ b/src/plugins/auth-plugin/services/user-service.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'bun:test'; import { UserService } from './user-service.ts'; -import { User, UserModel } from '../entities/user'; +import { User, UserModel } from '../models/user'; import { instance, mock, when, anything } from 'ts-mockito'; import mongoose, { Document } from 'mongoose'; import { connectDB, closeDB } from '../../../../test/test-db-setup.ts'; diff --git a/src/plugins/auth-plugin/services/user-service.ts b/src/plugins/auth-plugin/services/user-service.ts index 9eb2568..f1f8dcc 100644 --- a/src/plugins/auth-plugin/services/user-service.ts +++ b/src/plugins/auth-plugin/services/user-service.ts @@ -1,5 +1,5 @@ import { Service } from 'typedi'; -import { User, UserModel } from '../entities/user'; +import { User, UserModel } from '../models/user'; import bcrypt from 'bcrypt'; import { getEnforcer } from '../../../rbac.ts'; @@ -11,7 +11,7 @@ export class UserService { public async registerUser(name: string, email: string, password: string): Promise { const hashedPassword = await bcrypt.hash(password, 10); - const user = new UserModel({ name, email, password: hashedPassword, role : 'user'}); + const user = new UserModel({ name, email, password: hashedPassword, role: 'user' }); // Add user-role mapping to Casbin const enforcer = await getEnforcer(); await enforcer.addRoleForUser(user._id.toString(), 'user'); diff --git a/src/plugins/cart-plugin/index.ts b/src/plugins/cart-plugin/index.ts new file mode 100644 index 0000000..cf271e8 --- /dev/null +++ b/src/plugins/cart-plugin/index.ts @@ -0,0 +1,21 @@ +import { Container } from 'typedi'; +import { getModelForClass } from '@typegoose/typegoose'; +import { type GlobalContext } from '../global-context'; +import { Cart } from './models/cart'; +import { CartResolver } from './resolvers/cart-resolver'; + +export default { + name: 'cart-plugin', + type: 'cart', + resolvers: [CartResolver], + register(container: typeof Container, context: GlobalContext) { + const CartModel = getModelForClass(Cart); + context.models['Cart'] = { schema: CartModel.schema, model: CartModel }; + container.set('CartModel', CartModel); + container.set(CartResolver, new CartResolver()); // Register CartResolver explicitly + context.extendResolvers('Cart', [CartResolver]); + const resolverMethods = Object.getOwnPropertyNames(CartResolver.prototype).filter( + (method) => method !== 'constructor', + ); + }, +}; diff --git a/src/plugins/cart-plugin/models/cart.ts b/src/plugins/cart-plugin/models/cart.ts new file mode 100644 index 0000000..0d5abc1 --- /dev/null +++ b/src/plugins/cart-plugin/models/cart.ts @@ -0,0 +1,34 @@ +import { Field, Float, Int, ObjectType } from 'type-graphql'; +import { prop, getModelForClass } from '@typegoose/typegoose'; + +@ObjectType() +export class Item { + @Field() + @prop({ required: true }) + public name!: string; + + @Field() + @prop({ required: true }) + public description!: string; + + @Field() + @prop({ required: true }) + public productId!: string; + + @Field(() => Int) + @prop({ required: true }) + public quantity!: number; + + @Field(() => Float) + @prop({ required: true }) + public price!: number; +} + +@ObjectType() +export class Cart { + @Field(() => [Item]) + @prop({ type: () => [Item], default: [] }) + public items: Item[] | undefined; +} + +export const CartModel = getModelForClass(Cart); diff --git a/src/plugins/cart-plugin/resolvers/cart-resolver.ts b/src/plugins/cart-plugin/resolvers/cart-resolver.ts new file mode 100644 index 0000000..00c30f8 --- /dev/null +++ b/src/plugins/cart-plugin/resolvers/cart-resolver.ts @@ -0,0 +1,33 @@ +import { Service } from 'typedi'; +import { Arg, Mutation, Query, Resolver } from 'type-graphql'; +import { CartService } from '../services'; +import { Cart } from '../models/cart'; +import { ItemInput } from './inputs/item-input'; +import { Item } from '../models/cart'; + +@Service() +@Resolver(() => Cart) +export class CartResolver { + private cartService: CartService; + + constructor() { + this.cartService = new CartService(); + } + + @Query(() => [Cart]) + async getCarts(): Promise { + return this.cartService.getCarts(); + } + + @Mutation(() => Cart) + async addItemToCart(@Arg('cartId') cartId: string, @Arg('item') item: ItemInput): Promise { + const itemEntity: Item = { + name: item.name, + description: item.description, + productId: item.productId, + quantity: item.quantity, + price: item.price, + }; + return this.cartService.addItemToCart(cartId, itemEntity); + } +} diff --git a/src/plugins/cart-plugin/resolvers/inputs/item-input.ts b/src/plugins/cart-plugin/resolvers/inputs/item-input.ts new file mode 100644 index 0000000..79dbcd7 --- /dev/null +++ b/src/plugins/cart-plugin/resolvers/inputs/item-input.ts @@ -0,0 +1,25 @@ +import { InputType, Field, Int, Float } from 'type-graphql'; +import { prop } from '@typegoose/typegoose'; + +@InputType() +export class ItemInput { + @Field() + @prop({ required: true }) + public name!: string; + + @Field() + @prop({ required: true }) + public description!: string; + + @Field() + @prop({ required: true }) + public productId!: string; + + @Field(() => Int) + @prop({ required: true }) + public quantity!: number; + + @Field(() => Float) + @prop({ required: true }) + public price!: number; +} diff --git a/src/plugins/cart-plugin/services/index.ts b/src/plugins/cart-plugin/services/index.ts new file mode 100644 index 0000000..45a062c --- /dev/null +++ b/src/plugins/cart-plugin/services/index.ts @@ -0,0 +1,24 @@ +import { Container, Service } from 'typedi'; +import { Cart } from '../models/cart'; +import { getModelForClass } from '@typegoose/typegoose'; +import { Types } from 'mongoose'; +import { Item } from '../models/cart'; + +@Service() +export class CartService { + public async addItemToCart(cartId: string, item: Item): Promise { + const CartModel = Container.get('CartModel') as ReturnType; + const objectId = new Types.ObjectId(cartId); + const cart = await CartModel.findById(objectId).exec(); + if (cart) { + cart.items.push(item); + await cart.save(); + } + return cart; + } + + public async getCarts(): Promise { + const CartModel = Container.get('CartModel') as ReturnType; + return await CartModel.find().exec(); + } +} diff --git a/src/plugins/discount-plugin/index.ts b/src/plugins/discount-plugin/index.ts new file mode 100644 index 0000000..acc20eb --- /dev/null +++ b/src/plugins/discount-plugin/index.ts @@ -0,0 +1,32 @@ +import { Schema } from 'mongoose'; +import { type GlobalContext } from '../global-context'; + +export default { + name: 'discount-plugin', + type: 'cart', + initialize(context: GlobalContext) { + context.extendModel('Cart', (schema: Schema) => { + schema.add({ + discount: { type: Number, required: false }, + }); + }); + + context.wrapResolver( + 'Cart', + 'addItemToCart', + (originalResolver: Function) => async (parent: any, args: any, ctx: any, info: any) => { + const result = await originalResolver(parent, args, ctx, info); + if (result) { + // Apply discount logic + result.discount = calculateDiscount(result); + } + return result; + }, + ); + }, +}; + +function calculateDiscount(cart: any): number { + // Dummy implementation of discount calculation + return cart.items.length * 0.1; +} diff --git a/src/plugins/global-context.ts b/src/plugins/global-context.ts new file mode 100644 index 0000000..d379fe7 --- /dev/null +++ b/src/plugins/global-context.ts @@ -0,0 +1,13 @@ +import { Schema } from 'mongoose'; + +export type ResolverMap = { + [resolverName: string]: Function; +}; + +export interface GlobalContext { + models: { [key: string]: { schema: Schema; model: any } }; + resolvers: { [key: string]: Function[] }; + extendModel: (name: string, extension: (schema: Schema) => void) => void; + extendResolvers: (name: string, extension: Function[]) => void; + wrapResolver: (name: string, resolver: string, wrapper: Function) => void; +} diff --git a/src/plugins/plugin-interface.ts b/src/plugins/plugin-interface.ts index ea275aa..a4c892b 100644 --- a/src/plugins/plugin-interface.ts +++ b/src/plugins/plugin-interface.ts @@ -1,8 +1,10 @@ import { Container } from 'typedi'; +import type { GlobalContext } from './global-context.ts'; export interface Plugin { name: string; type: string; - resolvers: Function[]; - register?: (container: Container) => void; + resolvers?: Function[]; + register?: (container: typeof Container, context: GlobalContext) => void; + initialize?: (context: GlobalContext) => void; } diff --git a/src/plugins/plugin-loader.ts b/src/plugins/plugin-loader.ts index ed0d125..cf967b2 100644 --- a/src/plugins/plugin-loader.ts +++ b/src/plugins/plugin-loader.ts @@ -1,47 +1,106 @@ import { Container } from 'typedi'; import { GraphQLSchema } from 'graphql'; import { buildSchema, type NonEmptyArray } from 'type-graphql'; -import { readdirSync, statSync } from 'fs'; +import { statSync } from 'fs'; import { join } from 'path'; import logger from '../config/logger'; +import mongoose, { Schema } from 'mongoose'; +import { type GlobalContext } from './global-context'; +import { type Plugin } from './plugin-interface'; +import pluginsList from './plugins-list'; -export class PluginLoader { - private plugins: any[] = []; +const loggerCtx = { context: 'plugin-loader' }; + +class PluginLoader { + private plugins: Plugin[] = []; + context: GlobalContext = { + models: {}, + resolvers: {}, + extendModel: (name: string, extension: (schema: Schema) => void) => { + if (!this.context.models[name]) { + throw new Error(`Model ${name} does not exist`); + } + extension(this.context.models[name].schema); + }, + extendResolvers: (name: string, extension: Function[]) => { + if (!this.context.resolvers[name]) { + this.context.resolvers[name] = []; + } + this.context.resolvers[name].push(...extension); + }, + wrapResolver: (typeName: string, resolverName: string, wrapper: Function) => { + const resolverArray = this.context.resolvers[typeName]; + if (!resolverArray) { + throw new Error(`Resolvers for type ${typeName} do not exist`); + } + logger.debug(`Resolving ${resolverName} in type ${typeName}`, loggerCtx); + const originalResolverIndex = resolverArray.findIndex((resolver: any) => resolver.prototype[resolverName]); + if (originalResolverIndex === -1) { + throw new Error(`Resolver ${resolverName} for type ${typeName} does not exist`); + } + const originalResolver = resolverArray[originalResolverIndex].prototype[resolverName]; + resolverArray[originalResolverIndex].prototype[resolverName] = wrapper(originalResolver); + }, + }; loadPlugins() { const pluginsDir = join(__dirname, '.'); - const pluginDirs = readdirSync(pluginsDir).filter((file) => { - const stat = statSync(join(pluginsDir, file)); - return stat.isDirectory(); + pluginsList.forEach((pluginName) => { + const pluginPath = join(pluginsDir, pluginName); + if (statSync(pluginPath).isDirectory()) { + try { + const plugin: Plugin = require(`./${pluginName}`).default; + if (!plugin) { + throw new Error(`Plugin in directory ${pluginName} does not have a default export`); + } + logger.info(`Loaded plugin: ${plugin.name} of type ${plugin.type}`, loggerCtx); + this.plugins.push(plugin); + if (plugin.register) { + plugin.register(Container, this.context); + logger.debug(`Registered plugin: ${plugin.name}`, loggerCtx); + } + } catch (error) { + console.error(`Failed to load plugin from directory ${pluginName}:`, error); + } + } }); + } - pluginDirs.forEach((dir) => { + initializePlugins() { + this.plugins.forEach((plugin) => { try { - const plugin = require(`./${dir}`).default; - if (!plugin) { - throw new Error(`Plugin in directory ${dir} does not have a default export`); - } - logger.info(`Loaded plugin: ${plugin.name} of type ${plugin.type}`); - this.plugins.push(plugin); - if (plugin.register) { - plugin.register(Container); + if (plugin.initialize) { + plugin.initialize(this.context); + logger.info(`Initialized plugin: ${plugin.name}`, loggerCtx); } } catch (error) { - console.error(`Failed to load plugin from directory ${dir}:`, error); + logger.error(`Failed to initialize plugin ${plugin.name}: ${error}`, loggerCtx); } }); } async createSchema(): Promise { - const resolvers = this.plugins.flatMap((plugin) => plugin.resolvers || []) as Function[]; - - if (resolvers.length === 0) { + const allResolvers = Object.values(this.context.resolvers).flat(); + if (allResolvers.length === 0) { throw new Error('No resolvers found. Please ensure at least one resolver is provided.'); } - return buildSchema({ - resolvers: resolvers as NonEmptyArray, - container: Container, + try { + return await buildSchema({ + resolvers: allResolvers as unknown as NonEmptyArray, + container: Container, + }); + } catch (error) { + logger.error(`Error building schema: ${error}`, loggerCtx); + throw error; + } + } + + registerModels() { + Object.keys(this.context.models).forEach((modelName) => { + mongoose.model(modelName, this.context.models[modelName].schema); }); } } + +export default PluginLoader; diff --git a/src/plugins/plugins-list.ts b/src/plugins/plugins-list.ts new file mode 100644 index 0000000..3cb5479 --- /dev/null +++ b/src/plugins/plugins-list.ts @@ -0,0 +1,2 @@ +let pluginList; +export default pluginList = ['auth-plugin', 'cart-plugin', 'discount-plugin', 'sample-plugin'];