From 9dae3f27df371103b6a9f96924980d2d8d7ba14e Mon Sep 17 00:00:00 2001 From: Muhammad Mustafa Date: Sat, 20 Feb 2021 00:24:29 +0500 Subject: [PATCH 1/5] email verification added --- .vscode/launch.json | 17 ++++ package.json | 3 +- src/config/config.js | 1 + src/config/roles.js | 2 +- src/config/tokens.js | 1 + src/controllers/auth.controller.js | 13 +++ src/models/token.model.js | 2 +- src/models/user.model.js | 4 + src/routes/v1/auth.route.js | 8 ++ src/services/auth.service.js | 19 +++++ src/services/email.service.js | 11 ++- src/services/token.service.js | 12 ++- src/validations/auth.validation.js | 14 +++ tests/integration/auth.test.js | 133 ++++++++++++++++++++++++++++- tests/integration/user.test.js | 13 ++- 15 files changed, 242 insertions(+), 11 deletions(-) create mode 100644 .vscode/launch.json diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 00000000..265b8053 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,17 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "type": "node", + "request": "launch", + "name": "Launch via Yarn", + "runtimeExecutable": "yarn", + "cwd": "${workspaceFolder}", + "runtimeArgs": ["start:debug"], + "port": 5858 + } + ] +} \ No newline at end of file diff --git a/package.json b/package.json index 0371f5da..1ff09628 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,8 @@ "scripts": { "start": "pm2 start ecosystem.config.json --no-daemon", "dev": "cross-env NODE_ENV=development nodemon src/index.js", - "test": "jest -i", + "dev:debug": "nodemon --inspect-brk=5858 src/index.js", + "test": "jest -i --colors --testEnvironment=node --verbose --coverage --detectOpenHandles", "test:watch": "jest -i --watchAll", "coverage": "jest -i --coverage", "coverage:coveralls": "jest -i --coverage --coverageReporters=text-lcov | coveralls", diff --git a/src/config/config.js b/src/config/config.js index 23dc1faf..d70b436b 100644 --- a/src/config/config.js +++ b/src/config/config.js @@ -42,6 +42,7 @@ module.exports = { accessExpirationMinutes: envVars.JWT_ACCESS_EXPIRATION_MINUTES, refreshExpirationDays: envVars.JWT_REFRESH_EXPIRATION_DAYS, resetPasswordExpirationMinutes: 10, + verificationEmailExpirationMinutes: 10, }, email: { smtp: { diff --git a/src/config/roles.js b/src/config/roles.js index fb76ba61..6cfc6d57 100644 --- a/src/config/roles.js +++ b/src/config/roles.js @@ -1,7 +1,7 @@ const roles = ['user', 'admin']; const roleRights = new Map(); -roleRights.set(roles[0], []); +roleRights.set(roles[0], ['verifyEmail']); roleRights.set(roles[1], ['getUsers', 'manageUsers']); module.exports = { diff --git a/src/config/tokens.js b/src/config/tokens.js index 495cbf35..bcc1f59d 100644 --- a/src/config/tokens.js +++ b/src/config/tokens.js @@ -2,6 +2,7 @@ const tokenTypes = { ACCESS: 'access', REFRESH: 'refresh', RESET_PASSWORD: 'resetPassword', + VERIFICATION_EMAIL: 'verificationEmail', }; module.exports = { diff --git a/src/controllers/auth.controller.js b/src/controllers/auth.controller.js index 7bd2064b..262ba399 100644 --- a/src/controllers/auth.controller.js +++ b/src/controllers/auth.controller.js @@ -36,6 +36,17 @@ const resetPassword = catchAsync(async (req, res) => { res.status(httpStatus.NO_CONTENT).send(); }); +const verificationEmail = catchAsync(async (req, res) => { + const verifyEmailToken = await tokenService.generateVerificationEmailToken(req.body.email); + await emailService.sendVerificationEmail(req.body.email, verifyEmailToken); + res.status(httpStatus.NO_CONTENT).send(); +}); + +const verifyEmail = catchAsync(async (req, res) => { + await authService.verifyEmail(req.query.token); + res.status(httpStatus.NO_CONTENT).send(); +}); + module.exports = { register, login, @@ -43,4 +54,6 @@ module.exports = { refreshTokens, forgotPassword, resetPassword, + verificationEmail, + verifyEmail, }; diff --git a/src/models/token.model.js b/src/models/token.model.js index fd8fb9b9..b0f7a31a 100644 --- a/src/models/token.model.js +++ b/src/models/token.model.js @@ -16,7 +16,7 @@ const tokenSchema = mongoose.Schema( }, type: { type: String, - enum: [tokenTypes.REFRESH, tokenTypes.RESET_PASSWORD], + enum: [tokenTypes.REFRESH, tokenTypes.RESET_PASSWORD, tokenTypes.VERIFICATION_EMAIL], required: true, }, expires: { diff --git a/src/models/user.model.js b/src/models/user.model.js index df877935..605479ef 100644 --- a/src/models/user.model.js +++ b/src/models/user.model.js @@ -40,6 +40,10 @@ const userSchema = mongoose.Schema( enum: roles, default: 'user', }, + isEmailVarified: { + type: Boolean, + default: false, + }, }, { timestamps: true, diff --git a/src/routes/v1/auth.route.js b/src/routes/v1/auth.route.js index dbd2cee2..5c778df0 100644 --- a/src/routes/v1/auth.route.js +++ b/src/routes/v1/auth.route.js @@ -2,6 +2,7 @@ const express = require('express'); const validate = require('../../middlewares/validate'); const authValidation = require('../../validations/auth.validation'); const authController = require('../../controllers/auth.controller'); +const auth = require('../../middlewares/auth'); const router = express.Router(); @@ -11,6 +12,13 @@ router.post('/logout', validate(authValidation.logout), authController.logout); router.post('/refresh-tokens', validate(authValidation.refreshTokens), authController.refreshTokens); router.post('/forgot-password', validate(authValidation.forgotPassword), authController.forgotPassword); router.post('/reset-password', validate(authValidation.resetPassword), authController.resetPassword); +router.post( + '/verification-email', + auth('verifyEmail'), + validate(authValidation.verificationEmail), + authController.verificationEmail +); +router.post('/verify-email', validate(authValidation.verifyEmail), authController.verifyEmail); module.exports = router; diff --git a/src/services/auth.service.js b/src/services/auth.service.js index 1652e1d0..a78975e0 100644 --- a/src/services/auth.service.js +++ b/src/services/auth.service.js @@ -70,10 +70,29 @@ const resetPassword = async (resetPasswordToken, newPassword) => { throw new ApiError(httpStatus.UNAUTHORIZED, 'Password reset failed'); } }; +/** + * Verify Email + * @param {string} EmailVerificationToken + * @returns {Promise} + */ +const verifyEmail = async (emailVarificationToken) => { + try { + const emailVarificationTokenDoc = await tokenService.verifyToken(emailVarificationToken, tokenTypes.VERIFICATION_EMAIL); + const user = await userService.getUserById(emailVarificationTokenDoc.user); + if (!user) { + throw new Error(); + } + await Token.deleteMany({ user: user.id, type: tokenTypes.VERIFICATION_EMAIL }); + await userService.updateUserById(user.id, { isEmailVarified: true }); + } catch (error) { + throw new ApiError(httpStatus.UNAUTHORIZED, 'email verification failed'); + } +}; module.exports = { loginUserWithEmailAndPassword, logout, refreshAuth, resetPassword, + verifyEmail, }; diff --git a/src/services/email.service.js b/src/services/email.service.js index a2a0472a..e34f9186 100644 --- a/src/services/email.service.js +++ b/src/services/email.service.js @@ -38,9 +38,18 @@ To reset your password, click on this link: ${resetPasswordUrl} If you did not request any password resets, then ignore this email.`; await sendEmail(to, subject, text); }; - +const sendVerificationEmail = async (to, token) => { + const subject = 'Email Verification'; + // replace this url with the link to the reset password page of your front-end app + const verificationEmailUrl = `http://link-to-app/reset-password?token=${token}`; + const text = `Dear user, + To verify your email, click on this link: ${verificationEmailUrl} + If you did not request any email verification, then ignore this email.`; + await sendEmail(to, subject, text); +}; module.exports = { transport, sendEmail, sendResetPasswordEmail, + sendVerificationEmail, }; diff --git a/src/services/token.service.js b/src/services/token.service.js index f8c207af..3b29874f 100644 --- a/src/services/token.service.js +++ b/src/services/token.service.js @@ -99,11 +99,21 @@ const generateResetPasswordToken = async (email) => { await saveToken(resetPasswordToken, user.id, expires, tokenTypes.RESET_PASSWORD); return resetPasswordToken; }; - +const generateVerificationEmailToken = async (email) => { + const user = await userService.getUserByEmail(email); + if (!user) { + throw new ApiError(httpStatus.NOT_FOUND, 'No users found with this email'); + } + const expires = moment().add(config.jwt.verificationEmailExpirationMinutes, 'minutes'); + const verificationEmailToken = generateToken(user.id, expires); + await saveToken(verificationEmailToken, user.id, expires, tokenTypes.VERIFICATION_EMAIL); + return verificationEmailToken; +}; module.exports = { generateToken, saveToken, verifyToken, generateAuthTokens, generateResetPasswordToken, + generateVerificationEmailToken, }; diff --git a/src/validations/auth.validation.js b/src/validations/auth.validation.js index be688df3..44933b08 100644 --- a/src/validations/auth.validation.js +++ b/src/validations/auth.validation.js @@ -1,3 +1,5 @@ +/* eslint-disable no-unused-vars */ +/* eslint-disable prettier/prettier */ const Joi = require('joi'); const { password } = require('./custom.validation'); @@ -43,6 +45,16 @@ const resetPassword = { }), }; +const verificationEmail = { + body: Joi.object().keys({ + email: Joi.string().required(), + }) +}; +const verifyEmail = { + query: Joi.object().keys({ + token: Joi.string().required(), + }) +}; module.exports = { register, login, @@ -50,4 +62,6 @@ module.exports = { refreshTokens, forgotPassword, resetPassword, + verificationEmail, + verifyEmail, }; diff --git a/tests/integration/auth.test.js b/tests/integration/auth.test.js index b06ea178..02fd5b3a 100644 --- a/tests/integration/auth.test.js +++ b/tests/integration/auth.test.js @@ -13,7 +13,7 @@ const setupTestDB = require('../utils/setupTestDB'); const { User, Token } = require('../../src/models'); const { roleRights } = require('../../src/config/roles'); const { tokenTypes } = require('../../src/config/tokens'); -const { userOne, admin, insertUsers } = require('../fixtures/user.fixture'); +const { userOne, admin, insertUsers, userTwo } = require('../fixtures/user.fixture'); const { userOneAccessToken, adminAccessToken } = require('../fixtures/token.fixture'); setupTestDB(); @@ -33,12 +33,18 @@ describe('Auth routes', () => { const res = await request(app).post('/v1/auth/register').send(newUser).expect(httpStatus.CREATED); expect(res.body.user).not.toHaveProperty('password'); - expect(res.body.user).toEqual({ id: expect.anything(), name: newUser.name, email: newUser.email, role: 'user' }); + expect(res.body.user).toEqual({ + id: expect.anything(), + name: newUser.name, + email: newUser.email, + role: 'user', + isEmailVarified: false, + }); const dbUser = await User.findById(res.body.user.id); expect(dbUser).toBeDefined(); expect(dbUser.password).not.toBe(newUser.password); - expect(dbUser).toMatchObject({ name: newUser.name, email: newUser.email, role: 'user' }); + expect(dbUser).toMatchObject({ name: newUser.name, email: newUser.email, role: 'user', isEmailVarified: false }); expect(res.body.tokens).toEqual({ access: { token: expect.anything(), expires: expect.anything() }, @@ -91,6 +97,7 @@ describe('Auth routes', () => { name: userOne.name, email: userOne.email, role: userOne.role, + isEmailVarified: false, }); expect(res.body.tokens).toEqual({ @@ -236,7 +243,11 @@ describe('Auth routes', () => { await insertUsers([userOne]); const sendResetPasswordEmailSpy = jest.spyOn(emailService, 'sendResetPasswordEmail'); - await request(app).post('/v1/auth/forgot-password').send({ email: userOne.email }).expect(httpStatus.NO_CONTENT); + await request(app) + .post('/v1/auth/forgot-password') + .set('Authorization', `Bearer ${userOneAccessToken}`) + .send({ email: userOne.email }) + .expect(httpStatus.NO_CONTENT); expect(sendResetPasswordEmailSpy).toHaveBeenCalledWith(userOne.email, expect.any(String)); const resetPasswordToken = sendResetPasswordEmailSpy.mock.calls[0][1]; @@ -347,6 +358,120 @@ describe('Auth routes', () => { .expect(httpStatus.BAD_REQUEST); }); }); + describe('POST /v1/auth/verification-email', () => { + beforeEach(() => { + jest.spyOn(emailService.transport, 'sendMail').mockResolvedValue(); + }); + + test('should return 204 and send verification email to the user', async () => { + await insertUsers([userOne]); + const sendVerificationEmaillSpy = jest.spyOn(emailService, 'sendVerificationEmail'); + + await request(app) + .post('/v1/auth/verification-email') + .set('Authorization', `Bearer ${userOneAccessToken}`) + .send({ email: userOne.email }) + .expect(httpStatus.NO_CONTENT); + + expect(sendVerificationEmaillSpy).toHaveBeenCalledWith(userOne.email, expect.any(String)); + const VerificationEmailToken = sendVerificationEmaillSpy.mock.calls[0][1]; + const dbVerificationEmailTokenDoc = await Token.findOne({ token: VerificationEmailToken, user: userOne._id }); + + expect(dbVerificationEmailTokenDoc).toBeDefined(); + }); + + test('should return 400 if email is missing', async () => { + await insertUsers([userOne]); + + await request(app) + .post('/v1/auth/verification-email') + .set('Authorization', `Bearer ${userOneAccessToken}`) + .send() + .expect(httpStatus.BAD_REQUEST); + }); + test('should return 404 if email does not belong to any user', async () => { + await insertUsers([userOne]); + await request(app) + .post('/v1/auth/verification-email') + .set('Authorization', `Bearer ${userOneAccessToken}`) + .send({ email: userTwo.email }) + .expect(httpStatus.NOT_FOUND); + }); + test('should return 401 error if access token is missing', async () => { + await insertUsers([userOne]); + + await request(app).post('/v1/auth/verification-email').send({ email: userOne.email }).expect(httpStatus.UNAUTHORIZED); + }); + }); + + describe('POST /v1/auth/verify-email', () => { + test('should return 204 and verify the email', async () => { + await insertUsers([userOne]); + const expires = moment().add(config.jwt.verificationEmailExpirationMinutes, 'minutes'); + const verificationEmailToken = tokenService.generateToken(userOne._id, expires); + await tokenService.saveToken(verificationEmailToken, userOne._id, expires, tokenTypes.VERIFICATION_EMAIL); + + await request(app) + .post('/v1/auth/verify-email') + .query({ token: verificationEmailToken }) + .send() + .expect(httpStatus.NO_CONTENT); + + const dbUser = await User.findById(userOne._id); + + expect(dbUser.isEmailVarified).toBe(true); + + const dbVerificationEmailTokenCount = await Token.countDocuments({ + user: userOne._id, + type: tokenTypes.VERIFICATION_EMAIL, + }); + expect(dbVerificationEmailTokenCount).toBe(0); + }); + + test('should return 400 if email verification token is missing', async () => { + await insertUsers([userOne]); + + await request(app).post('/v1/auth/verify-email').send().expect(httpStatus.BAD_REQUEST); + }); + + test('should return 401 if verification email token is blacklisted', async () => { + await insertUsers([userOne]); + const expires = moment().add(config.jwt.verificationEmailExpirationMinutes, 'minutes'); + const verificationEmailToken = tokenService.generateToken(userOne._id, expires); + await tokenService.saveToken(verificationEmailToken, userOne._id, expires, tokenTypes.VERIFICATION_EMAIL, true); + + await request(app) + .post('/v1/auth/verify-email') + .query({ token: verificationEmailToken }) + .send() + .expect(httpStatus.UNAUTHORIZED); + }); + + test('should return 401 if email verification token is expired', async () => { + await insertUsers([userOne]); + const expires = moment().subtract(1, 'minutes'); + const verificationEmailToken = tokenService.generateToken(userOne._id, expires); + await tokenService.saveToken(verificationEmailToken, userOne._id, expires, tokenTypes.VERIFICATION_EMAIL); + + await request(app) + .post('/v1/auth/verify-email') + .query({ token: verificationEmailToken }) + .send() + .expect(httpStatus.UNAUTHORIZED); + }); + + test('should return 401 if user is not found', async () => { + const expires = moment().add(config.jwt.verificationEmailExpirationMinutes, 'minutes'); + const verificationEmailToken = tokenService.generateToken(userOne._id, expires); + await tokenService.saveToken(verificationEmailToken, userOne._id, expires, tokenTypes.VERIFICATION_EMAIL); + + await request(app) + .post('/v1/auth/verify-email') + .query({ token: verificationEmailToken }) + .send() + .expect(httpStatus.UNAUTHORIZED); + }); + }); }); describe('Auth middleware', () => { diff --git a/tests/integration/user.test.js b/tests/integration/user.test.js index 9fe5fc52..99be1ca7 100644 --- a/tests/integration/user.test.js +++ b/tests/integration/user.test.js @@ -32,12 +32,18 @@ describe('User routes', () => { .expect(httpStatus.CREATED); expect(res.body).not.toHaveProperty('password'); - expect(res.body).toEqual({ id: expect.anything(), name: newUser.name, email: newUser.email, role: newUser.role }); + expect(res.body).toEqual({ + id: expect.anything(), + name: newUser.name, + email: newUser.email, + role: newUser.role, + isEmailVarified: false, + }); const dbUser = await User.findById(res.body.id); expect(dbUser).toBeDefined(); expect(dbUser.password).not.toBe(newUser.password); - expect(dbUser).toMatchObject({ name: newUser.name, email: newUser.email, role: newUser.role }); + expect(dbUser).toMatchObject({ name: newUser.name, email: newUser.email, role: newUser.role, isEmailVarified: false }); }); test('should be able to create an admin as well', async () => { @@ -157,6 +163,7 @@ describe('User routes', () => { name: userOne.name, email: userOne.email, role: userOne.role, + isEmailVarified: false, }); }); @@ -359,6 +366,7 @@ describe('User routes', () => { email: userOne.email, name: userOne.name, role: userOne.role, + isEmailVarified: false, }); }); @@ -491,6 +499,7 @@ describe('User routes', () => { name: updateBody.name, email: updateBody.email, role: 'user', + isEmailVarified: false, }); const dbUser = await User.findById(userOne._id); From 8afebead340e18253f843f4471889dee72cfd86b Mon Sep 17 00:00:00 2001 From: Muhammad Mustafa Date: Sat, 20 Feb 2021 00:31:46 +0500 Subject: [PATCH 2/5] verify-email link fixed --- src/services/email.service.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/services/email.service.js b/src/services/email.service.js index e34f9186..fd736aa5 100644 --- a/src/services/email.service.js +++ b/src/services/email.service.js @@ -32,7 +32,7 @@ const sendEmail = async (to, subject, text) => { const sendResetPasswordEmail = async (to, token) => { const subject = 'Reset password'; // replace this url with the link to the reset password page of your front-end app - const resetPasswordUrl = `http://link-to-app/reset-password?token=${token}`; + const resetPasswordUrl = `http://link-to-app/verify-email?token=${token}`; const text = `Dear user, To reset your password, click on this link: ${resetPasswordUrl} If you did not request any password resets, then ignore this email.`; @@ -41,7 +41,7 @@ If you did not request any password resets, then ignore this email.`; const sendVerificationEmail = async (to, token) => { const subject = 'Email Verification'; // replace this url with the link to the reset password page of your front-end app - const verificationEmailUrl = `http://link-to-app/reset-password?token=${token}`; + const verificationEmailUrl = `http://link-to-app/verify-email?token=${token}`; const text = `Dear user, To verify your email, click on this link: ${verificationEmailUrl} If you did not request any email verification, then ignore this email.`; From 11b616f2999c2cb21f2cf49a1b0a5ea9d5b7e353 Mon Sep 17 00:00:00 2001 From: Muhammad Mustafa Date: Sat, 20 Feb 2021 02:46:35 +0500 Subject: [PATCH 3/5] Swagger docs for email verification --- .vscode/launch.json | 29 ++++++++++++++---- package.json | 1 - src/routes/v1/auth.route.js | 59 +++++++++++++++++++++++++++++++++++++ 3 files changed, 82 insertions(+), 7 deletions(-) diff --git a/.vscode/launch.json b/.vscode/launch.json index 265b8053..099c97b4 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -7,11 +7,28 @@ { "type": "node", "request": "launch", - "name": "Launch via Yarn", - "runtimeExecutable": "yarn", - "cwd": "${workspaceFolder}", - "runtimeArgs": ["start:debug"], - "port": 5858 - } + "name": "nodemon", + "runtimeExecutable": "nodemon", + "program": "${workspaceFolder}/app.js", + "restart": true, + "console": "integratedTerminal", + "internalConsoleOptions": "neverOpen", + "env": { + "NODE_ENV": "development" + }, + "skipFiles": [ + "/**" + ] + }, + // { + // "type": "node", + // "request": "launch", + // "name": "Launch via Yarn", + // "runtimeExecutable": "yarn", + // "cwd": "${workspaceFolder}", + // "runtimeArgs": ["start:debug"], + // "port": 5858 + // } + ] } \ No newline at end of file diff --git a/package.json b/package.json index 1ff09628..57906a09 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,6 @@ "scripts": { "start": "pm2 start ecosystem.config.json --no-daemon", "dev": "cross-env NODE_ENV=development nodemon src/index.js", - "dev:debug": "nodemon --inspect-brk=5858 src/index.js", "test": "jest -i --colors --testEnvironment=node --verbose --coverage --detectOpenHandles", "test:watch": "jest -i --watchAll", "coverage": "jest -i --coverage", diff --git a/src/routes/v1/auth.route.js b/src/routes/v1/auth.route.js index 5c778df0..385b091a 100644 --- a/src/routes/v1/auth.route.js +++ b/src/routes/v1/auth.route.js @@ -257,3 +257,62 @@ module.exports = router; * code: 401 * message: Password reset failed */ + +/** + * @swagger + * path: + * /auth/verification-email: + * post: + * summary: verification-email Email + * description: An email will be sent to verify email. + * tags: [Auth] + * security: + * - bearerAuth: [] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: + * - email + * properties: + * email: + * type: string + * format: email + * example: + * email: fake@example.com + * responses: + * "204": + * description: No content + * "404": + * $ref: '#/components/responses/NotFound' + */ + +/** + * @swagger + * path: + * /auth/verify-email: + * post: + * summary: verify email + * tags: [Auth] + * parameters: + * - in: query + * name: token + * required: true + * schema: + * type: string + * description: The verify email token + * responses: + * "204": + * description: No content + * "401": + * description: verify email failed + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + * example: + * code: 401 + * message: verify email failed + */ From 7aca58d3d0fb3a016350b08e8c0df6d3e0793d38 Mon Sep 17 00:00:00 2001 From: Muhammad Mustafa Date: Sat, 20 Feb 2021 09:44:41 +0500 Subject: [PATCH 4/5] email verification added From bac7cb30fe706f298487bdc75cf865fb7d0feff0 Mon Sep 17 00:00:00 2001 From: Muhammad Mustafa Date: Sat, 20 Feb 2021 09:55:06 +0500 Subject: [PATCH 5/5] package version --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 57906a09..d70ecec7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "create-nodejs-express-app", - "version": "1.6.0", + "version": "1.6.1", "description": "Create a Node.js app for building production-ready RESTful APIs using Express, by running one command", "bin": "bin/createNodejsApp.js", "main": "src/index.js",