diff --git a/apps/challenges/.env.example b/apps/challenges/.env.example index 907bb9fb..96805044 100644 --- a/apps/challenges/.env.example +++ b/apps/challenges/.env.example @@ -2,4 +2,14 @@ MONGO_URI=mongodb://localhost:27017/ MONGO_PORT=27017 MONGO_DATABSE_NAME=challenges -DO_RANKING_CALCULATION=true \ No newline at end of file +BASE_URL=http://localhost:3001 +PORT=3001 +DO_RANKING_CALCULATION=true + +// fill in the following for supabase (used in register and login) +SUPABASE_URL= +SUPABASE_ANON_KEY= + +// set the secret to something (hey dont peek these are secrets! ba..baka!) +JWT_SECRET=abc +COOKIE_SECRET=abc \ No newline at end of file diff --git a/apps/challenges/mock/mongo-init.js b/apps/challenges/mock/mongo-init.js index f0895ed4..2fa899d7 100644 --- a/apps/challenges/mock/mongo-init.js +++ b/apps/challenges/mock/mongo-init.js @@ -2,4 +2,5 @@ db.createCollection("seasons"); db.createCollection("rankings"); db.createCollection("questions"); db.createCollection("submissions"); -db.createCollection("users"); \ No newline at end of file +db.createCollection("users"); +db.createCollection("tokens"); \ No newline at end of file diff --git a/apps/challenges/package.json b/apps/challenges/package.json index 8bcfac8b..f1ec150c 100644 --- a/apps/challenges/package.json +++ b/apps/challenges/package.json @@ -6,8 +6,10 @@ "dependencies": { "@babel/preset-env": "^7.23.8", "@babel/preset-typescript": "^7.23.3", + "@supabase/supabase-js": "^2.39.7", "@types/express": "^4.17.21", "babel-jest": "^29.7.0", + "cookie-parser": "^1.4.6", "cron": "^3.1.6", "cross-env": "^7.0.3", "dotenv": "^16.3.1", @@ -15,6 +17,7 @@ "express-async-handler": "^1.1.4", "jest": "^29.7.0", "jsonwebtoken": "^9.0.2", + "jwt-decode": "^4.0.0", "prettier": "^2.8.1", "supertest": "^6.3.4", "ts-jest": "^29.1.1", @@ -25,6 +28,7 @@ "devDependencies": { "@types/express": "^4.17.21", "@types/jest": "^29.5.11", + "@types/jsonwebtoken": "^9.0.6", "@types/node": "^20.10.6", "@types/supertest": "^6.0.2", "jest": "^29.7.0", diff --git a/apps/challenges/src/controllers/auth.ts b/apps/challenges/src/controllers/auth.ts new file mode 100644 index 00000000..3430feff --- /dev/null +++ b/apps/challenges/src/controllers/auth.ts @@ -0,0 +1,39 @@ +import asyncHandler from "express-async-handler"; +import { Request, Response } from "express"; +import { z } from "zod"; +import AuthService from "../service/authService"; +import { accessTokenMaxAgeSeconds, refreshCookieMaxAgeSeconds, secondInMilliseconds } from "../model/constants"; + + +const oauthSignIn = asyncHandler(async (req: Request, res: Response) => { + const { access_token } = req.body; + + try { + const { accessToken, refreshToken, createNewUser } = await AuthService.oauthSignIn(access_token); + res.status(createNewUser ? 200 : 201).json({ + "access_token": accessToken, + "refresh_token": refreshToken + }) + } catch (error) { + console.log("AuthService.oauthSignIn", error); + res.status(500).json({ message: 'Internal Server Error' }); + } +}); + +const refreshToken = asyncHandler(async (req: Request, res: Response) => { + try { + const userID = req.params.userID; + const token = await AuthService.refreshToken(userID); + res.status(200).json(token); + } catch (err) { + console.error(err); + res.status(500).json({ message: "Internal Server Error" }) + } +}) + +const AuthController = { + oauthSignIn, + refreshToken, +} + +export { AuthController as default }; diff --git a/apps/challenges/src/controllers/questionaire.ts b/apps/challenges/src/controllers/questionaire.ts index 3bd1f8ee..1f2e652f 100644 --- a/apps/challenges/src/controllers/questionaire.ts +++ b/apps/challenges/src/controllers/questionaire.ts @@ -1,9 +1,12 @@ import { Request, Response } from "express"; const asyncHandler = require('express-async-handler'); -import Question from '../model/question'; +import Question, { CreateQuestionReq } from '../model/question'; import Submission from '../model/submission'; import Season from "../model/season"; import { isValidObjectId } from "../utils/db"; +import QuestionService from "../service/questionService"; +import { isValidCreateQuestionRequest } from "../utils/validator"; +import { z } from "zod"; // @desc Get questions // @route GET /api/question @@ -35,7 +38,7 @@ const getQuestion = asyncHandler(async (req: Request, res: Response) => { } try { - const question = await Question.findById(questionId); + const question = await QuestionService.getQuestionByID(questionId); if (!question) { return res.status(404).json({ message: 'Question not found' }); @@ -54,21 +57,20 @@ const getQuestion = asyncHandler(async (req: Request, res: Response) => { // @access Private const setQuestion = asyncHandler(async (req: Request, res: Response) => { try { - const question = await Question.create({ - question_no: req.body.question_no, - question_title: req.body.question_title, - question_desc: req.body.question_desc, - question_date: req.body.question_date, - seasonID: req.body.season_id, - expiry: req.body.expiry, - points: req.body.points, - answer: req.body.answer, - }); - - res.status(201).json(question); - } catch (error) { - if ((error as Error).name === 'ValidationError') { - return res.status(400).json({ message: (error as Error).message }); + const question = isValidCreateQuestionRequest.parse(req.body); + + const createQuestionReq: CreateQuestionReq = { + ...question, + question_date: new Date(question.question_date), + expiry: new Date(question.expiry), + } + + const resp = await QuestionService.createQuestion(createQuestionReq); + + res.status(resp.status).json(resp); + } catch (err) { + if (err instanceof z.ZodError) { + return res.status(400).json({ message: (err as Error).message }); } res.status(500).json({ message: 'Internal Server Error' }); diff --git a/apps/challenges/src/controllers/submission.ts b/apps/challenges/src/controllers/submission.ts index 5846e06e..469af500 100644 --- a/apps/challenges/src/controllers/submission.ts +++ b/apps/challenges/src/controllers/submission.ts @@ -15,7 +15,7 @@ import mongoose from 'mongoose'; // @access Public const getSubmissions = asyncHandler(async (req: Request, res: Response) => { const submissions = await Submission.find({}) - res.status(200).json(submissions) + res.status(200).json(submissions) }) // @desc Get submission @@ -43,96 +43,36 @@ const getSubmission = asyncHandler(async (req: Request, res: Response) => { }) // @desc Set submission -// @route POST /api/submission +// @route POST /api/submission/ // @access Private - const setSubmission = asyncHandler(async (req: Request, res: Response) => { - const questionId = req.body.question; + const questionID = req.body.question; - if (!isValidObjectId(questionId)) { + if (!isValidObjectId(questionID)) { return res.status(400).json({ message: 'Invalid question ID' }); } try { const submission = isValidCreateSubmissionRequest.parse(req.body); const createSubmissionReq = { - question: new mongoose.Types.ObjectId(submission.question), user: new mongoose.Types.ObjectId(submission.user), + question: new mongoose.Types.ObjectId(submission.question), answer: submission.answer }; - await SubmissionService.createSubmission(createSubmissionReq); - - res.status(201).json({ message: 'Answer submitted' }); - - } catch (error) { - res.status(500).json({ message: 'Internal Server Error' }); - } -}) - -// @desc Update submission -// @route PUT /api/submission/:id -// @access Private -const updateSubmission = asyncHandler(async (req: Request, res: Response) => { - const submissionId = req.params.id; - - if (!isValidObjectId(submissionId)) { - return res.status(400).json({ message: 'Invalid submission ID' }); - } - - try { - const submission = await Submission.findById(submissionId); - - if (!submission) { - return res.status(404).json({ message: 'Submission not found' }); - } - - const updatedSubmission = await Submission.findByIdAndUpdate(submissionId, req.body, { new: true }); + const resp = await SubmissionService.createSubmission(createSubmissionReq); - // Re-evaluate the points awarded - + res.status(resp.status).json(resp); - - res.status(200).json(updatedSubmission); - } catch (error) { - console.error(error); - res.status(500).json({ message: 'Internal Server Error' }); - } -}) - -// @desc Delete submission -// @route DELETE /api/submission/:id -// @access Private -const deleteSubmission = asyncHandler(async (req: Request, res: Response) => { - const submissionId = req.params.id; - - if (!isValidObjectId(submissionId)) { - return res.status(400).json({ message: 'Invalid submission ID' }); - } - - try { - const submission = await Submission.findById(submissionId); - - if (!submission) { - return res.status(404).json({ message: 'Submission not found' }); - } - - await submission.remove() - - res.status(200).json({message: 'Submission deleted'}); } catch (error) { - console.error(error); res.status(500).json({ message: 'Internal Server Error' }); } }) - const SubmissionController = { getSubmissions, getSubmission, setSubmission, - updateSubmission, - deleteSubmission }; export { SubmissionController as default }; \ No newline at end of file diff --git a/apps/challenges/src/controllers/user.ts b/apps/challenges/src/controllers/user.ts index 163c0136..2bc7219b 100644 --- a/apps/challenges/src/controllers/user.ts +++ b/apps/challenges/src/controllers/user.ts @@ -1,20 +1,34 @@ import UserService from "../service/userService"; import asyncHandler from "express-async-handler"; import { Request, Response } from "express"; +import { StatusCodeError } from "../types/types"; -const createUser = asyncHandler(async (req: Request, res: Response) => { - const { name, email } = req.body; +const getUser = asyncHandler(async (req: Request, res: Response) => { + const { userID } = req.params; try { - const user = await UserService.createUser(name, email); - res.status(201).json(user); - } catch (error) { - res.status(500).json({ message: 'Internal Server Error' }); + const user = await UserService.getUserByID(userID); + res.status(200).json(user); + } catch (err) { + if (err instanceof StatusCodeError) { + res.status(err.status).json({ message: err.message }); + } else { + res.status(500).json({ message: "Internal Server Error" }) + } + } }); +const checkTokens = asyncHandler(async (req: Request, res: Response) => { + const token = req.signedCookies; + + console.log(token); + + res.status(200); +}); const UserController = { - createUser + getUser, + checkTokens, } export { UserController as default }; diff --git a/apps/challenges/src/index.ts b/apps/challenges/src/index.ts index a12288ad..6c060e83 100644 --- a/apps/challenges/src/index.ts +++ b/apps/challenges/src/index.ts @@ -4,17 +4,21 @@ import SeasonRouter from "./routes/seasons"; import QuestionaireRouter from "./routes/questionaire"; import SubmissionRouter from "./routes/submission"; import UserRouter from "./routes/user"; +import AuthRouter from "./routes/auth"; import { connectDB } from "./config/db"; import { CronJob } from "cron"; import { rankingCalculation } from "./tasks/rankingCalculation"; +import { SupabaseService } from "./utils/supabase"; +import cookieParser from "cookie-parser"; dotenv.config({ path: "../.env"}); // Database connectDB(); +SupabaseService.initClient(); const app: Express = express(); const port = process.env.PORT || 3000; - +const cookieSecret = process.env.COOKIE_SECRET || ""; // Middleware app.use(express.json()); app.use(function(req, res, next) { @@ -23,6 +27,8 @@ app.use(function(req, res, next) { res.header("Access-Control-Allow-Headers", "*"); next(); }); +app.use(cookieParser(cookieSecret)); + // Routes app.get("/ping", (req: Request, res: Response) => { res.status(200).json({ message: "pong" }); @@ -31,7 +37,10 @@ app.use("/api/seasons", SeasonRouter); app.use('/api/question', QuestionaireRouter); app.use('/api/submission', SubmissionRouter); app.use('/api/user', UserRouter); +app.use('/api/auth', AuthRouter); +// the check is needed for testing +// refer to https://stackoverflow.com/a/63299022 if (process.env.NODE_ENV !== 'test') { app.listen(port, () => { console.log(`[server]: Server is running at http://localhost:${port}`); diff --git a/apps/challenges/src/middleware/jwtMiddleware.ts b/apps/challenges/src/middleware/jwtMiddleware.ts new file mode 100644 index 00000000..b0aa45e6 --- /dev/null +++ b/apps/challenges/src/middleware/jwtMiddleware.ts @@ -0,0 +1,25 @@ +// jwt middleware for express +import jwt from "jsonwebtoken"; +import { Request, Response, NextFunction } from "express"; + +const jwtMiddleware = (req: Request, res: Response, next: NextFunction) => { + const token = req.signedCookies.access_token; + + if (token == null) { + return res.sendStatus(401); + } + + jwt.verify(token, process.env.JWT_SECRET || "", (err, tokenContent: any) => { + if (err) { + return res.sendStatus(401); + } + + req.params.userID = tokenContent.id; + req.params.email = tokenContent.email; + + + next(); + }); +} + +export default jwtMiddleware; diff --git a/apps/challenges/src/middleware/jwtRefreshMiddleware.ts b/apps/challenges/src/middleware/jwtRefreshMiddleware.ts new file mode 100644 index 00000000..b7007ac6 --- /dev/null +++ b/apps/challenges/src/middleware/jwtRefreshMiddleware.ts @@ -0,0 +1,38 @@ +// jwt middleware for express +import jwt from "jsonwebtoken"; +import { Request, Response, NextFunction } from "express"; +import TokenRepo from "../repo/tokenRepo"; +import { refreshCookieMaxAgeSeconds, secondInMilliseconds } from "../model/constants"; + +const jwtRefreshMiddleware = (req: Request, res: Response, next: NextFunction) => { + const token = req.signedCookies.refreshToken; + + if (token == null) { + return res.sendStatus(401); + } + + jwt.verify(token, process.env.JWT_SECRET || "", (err, tokenContent: any) => { + if (err) { + return res.sendStatus(401); + } + + const token = TokenRepo.extendRefreshToken(tokenContent.id) + if (token == null) { + res.status(401).json({message: "Invalid refresh token"}) + return; + } + + res.cookie('refresh_token', token, { + httpOnly: true, + maxAge: refreshCookieMaxAgeSeconds * secondInMilliseconds, + signed: true, + }); + + req.params.userID = tokenContent.id; + req.params.email = tokenContent.email; + + next(); + }); +} + +export default jwtRefreshMiddleware; diff --git a/apps/challenges/src/model/constants.ts b/apps/challenges/src/model/constants.ts new file mode 100644 index 00000000..642bbff9 --- /dev/null +++ b/apps/challenges/src/model/constants.ts @@ -0,0 +1,6 @@ +export const secondInMilliseconds = 1000; +const minuteInSeconds = 60; +const dayInSeconds = 86400; + +export const accessTokenMaxAgeSeconds = 10 * minuteInSeconds; +export const refreshCookieMaxAgeSeconds = 7 * dayInSeconds; \ No newline at end of file diff --git a/apps/challenges/src/model/question.ts b/apps/challenges/src/model/question.ts index b712cc9b..2ec614a6 100644 --- a/apps/challenges/src/model/question.ts +++ b/apps/challenges/src/model/question.ts @@ -1,6 +1,31 @@ import mongoose, { Schema, Document } from 'mongoose'; +export interface CreateQuestionReq { + question_no: string; + question_title: string; + question_desc: string; + question_date: Date; + season_id: string; + expiry: Date; + points: number; + validation_function: string; + generate_input_function: string; +} + +export interface GetUserSpecificQuestionResp { + id: string; + question_no: string; + question_title: string; + question_desc: string; + question_date: Date; + seasonID: string; + question_input: string[]; + expiry: Date; + points: number; +} + export interface QuestionModel { + _id: mongoose.Types.ObjectId; question_no: string; question_title: string; question_desc: string; @@ -8,11 +33,12 @@ export interface QuestionModel { seasonID: mongoose.Types.ObjectId; expiry: Date; points: number; - answer: string; submissions: Array; submissions_count: number; correct_submissions_count: number; active: boolean; + validation_function: string; + generate_input_function: string; } const questionSchema: Schema = new Schema({ @@ -45,10 +71,6 @@ const questionSchema: Schema = new Schema({ type: Number, required: [true, 'Please add a points value'] }, - answer: { - type: String, - required: [true, 'Please add an answer'] - }, submissions: { type: [mongoose.Types.ObjectId], ref: 'Submission' @@ -65,6 +87,14 @@ const questionSchema: Schema = new Schema({ type: Boolean, default: true }, + validation_function: { + type: String, + required: [true, 'Please add a validation function'] + }, + generate_input_function: { + type: String, + required: [true, 'Please add a generate input function'] + } }, { timestamps: true }); diff --git a/apps/challenges/src/model/questionInput.ts b/apps/challenges/src/model/questionInput.ts new file mode 100644 index 00000000..cde2b763 --- /dev/null +++ b/apps/challenges/src/model/questionInput.ts @@ -0,0 +1,34 @@ +import mongoose, { Schema, model } from 'mongoose'; +export interface QuestionInputModel { + userID: mongoose.Types.ObjectId; + seasonID: mongoose.Types.ObjectId; + questionID: mongoose.Types.ObjectId; + input: string[]; +} + +const questionInputSchema: Schema = new Schema({ + userID: { + type: mongoose.Schema.Types.ObjectId, + required: true, + ref: 'User', + }, + seasonID: { + type: mongoose.Schema.Types.ObjectId, + required: true, + ref: 'Season', + }, + questionID: { + type: mongoose.Schema.Types.ObjectId, + required: true, + ref: 'Question', + }, + input: { + type: [String], + } +}, { + timestamps: true, +}); + +const QuestionInput = mongoose.model('QuestionInput', questionInputSchema); + +export { QuestionInput as default } \ No newline at end of file diff --git a/apps/challenges/src/model/ranking.ts b/apps/challenges/src/model/ranking.ts deleted file mode 100644 index 02168cc8..00000000 --- a/apps/challenges/src/model/ranking.ts +++ /dev/null @@ -1,34 +0,0 @@ -import mongoose, {Schema} from 'mongoose'; - -export interface RankingModel { - _id: mongoose.Types.ObjectId; - userID: mongoose.Types.ObjectId; - seasonID: mongoose.Types.ObjectId; - points: number; - createdAt: Date; - updatedAt: Date; -} - -const seasonSchema: Schema = new Schema({ - userID: { - type: mongoose.Schema.Types.ObjectId, - required: true, - ref: 'User' - }, - seasonID: { - type: mongoose.Schema.Types.ObjectId, - required: true, - ref: 'Season' - }, - points: { - type: Number, - required: [true, 'Please add a points value'] - } -}, { - timestamps: true -}); - - -const Ranking = mongoose.model('Ranking', seasonSchema); - -export { Ranking as default } diff --git a/apps/challenges/src/model/season.ts b/apps/challenges/src/model/season.ts index 458b605f..3a5ae5c2 100644 --- a/apps/challenges/src/model/season.ts +++ b/apps/challenges/src/model/season.ts @@ -9,11 +9,12 @@ export interface GetSeasonResp { questions: QuestionModel[]; } +// TODO: season should store simple question model for faster retrieval export interface SeasonModel { _id: mongoose.Types.ObjectId; - title: string; - startDate: Date; - endDate: Date; + title: string; + startDate: Date; + endDate: Date; createdAt: Date; updatedAt: Date; } diff --git a/apps/challenges/src/model/token.ts b/apps/challenges/src/model/token.ts new file mode 100644 index 00000000..8a5ee162 --- /dev/null +++ b/apps/challenges/src/model/token.ts @@ -0,0 +1,30 @@ +import mongoose, { Schema } from 'mongoose'; + +export interface TokenModel { + _id: mongoose.Types.ObjectId; + jwt: string; + userID: mongoose.Types.ObjectId; + expiry: Date; +} + +const tokenSchema: Schema = new Schema({ + jwt: { + type: String, + unique: true, + }, + expiry: { + type: Date, + required: [true, 'Please add a expiry date'] + }, + userID: { + type: mongoose.Schema.Types.ObjectId, + ref: 'User', + required: [true, 'Please add a user ID'] + }, +}, { + timestamps: true +}) + +const Token = mongoose.model('Token', tokenSchema); + +export { Token as default } \ No newline at end of file diff --git a/apps/challenges/src/repo/questionRepo.ts b/apps/challenges/src/repo/questionRepo.ts index 054f103b..6305df0b 100644 --- a/apps/challenges/src/repo/questionRepo.ts +++ b/apps/challenges/src/repo/questionRepo.ts @@ -1,5 +1,6 @@ -import Question, { QuestionModel } from "../model/question" +import Question, { CreateQuestionReq, QuestionModel } from "../model/question" import mongoose from 'mongoose'; +import QuestionInput, { QuestionInputModel } from "../model/questionInput"; const getQuestionByID = async ( questionID: mongoose.Types.ObjectId, @@ -10,6 +11,32 @@ const getQuestionByID = async ( return question; } +const createQuestionByReq = async ( + req: CreateQuestionReq +): Promise => { + const questionModel = { + _id: new mongoose.Types.ObjectId(), + question_no: req.question_no, + question_title: req.question_title, + question_desc: req.question_desc, + question_date: req.question_date, + seasonID: new mongoose.Types.ObjectId(req.season_id), + expiry: req.expiry, + points: req.points, + submissions: [], + submissions_count: 0, + correct_submissions_count: 0, + active: true, + validation_function: req.validation_function + } + + const question = await Question.create(questionModel); + + await question.save(); + + return question; +} + const updateQuestionByID = async ( questionID: mongoose.Types.ObjectId, questionModel: QuestionModel @@ -29,7 +56,7 @@ const updateQuestionSubmissions = async ( _id: questionID }, { $push: { submissions: submissionID }, - $inc: { + $inc: { submissions_count: 1, correct_submissions_count: isCorrect ? 1 : 0 } @@ -37,10 +64,25 @@ const updateQuestionSubmissions = async ( return question; } +const getQuestionInput = async ( + userID: mongoose.Types.ObjectId, + seasonID: mongoose.Types.ObjectId, + questionID: mongoose.Types.ObjectId, +): Promise => { + const question = await QuestionInput.findOne({ + userID: userID, + seasonID: seasonID, + questionID: questionID, + }); + return question; +} + const QuestionRepo = { getQuestionByID, + createQuestionByReq, updateQuestionByID, - updateQuestionSubmissions + updateQuestionSubmissions, + getQuestionInput } export { QuestionRepo as default } \ No newline at end of file diff --git a/apps/challenges/src/repo/tokenRepo.ts b/apps/challenges/src/repo/tokenRepo.ts new file mode 100644 index 00000000..612a0ba0 --- /dev/null +++ b/apps/challenges/src/repo/tokenRepo.ts @@ -0,0 +1,41 @@ +import { refreshCookieMaxAgeSeconds, secondInMilliseconds } from "../model/constants"; +import Token, { TokenModel } from "../model/token" +import mongoose from 'mongoose'; + +const getRefreshToken = async (userID: mongoose.Types.ObjectId): Promise => { + const token = await Token.findOne({ + userID: userID, + expiry: { $gt: new Date() } + }); + return token; +} + +const saveRefreshToken = async ( + token: TokenModel +): Promise => { + const dbToken = new Token(token); + dbToken.save(); + return dbToken; +} + + +const extendRefreshToken = async (userID: mongoose.Types.ObjectId): Promise => { + const now = new Date(); + const newExpiry = new Date(now.getTime() + refreshCookieMaxAgeSeconds * secondInMilliseconds); + + const token = await Token.findOneAndUpdate({ + userID: userID, + expiry : { $gt: new Date() } + }, { + $set: { "expiry": newExpiry } + }, { new: true }) + return token +} + +const TokenRepo = { + getRefreshToken, + saveRefreshToken, + extendRefreshToken, +} + +export { TokenRepo as default } \ No newline at end of file diff --git a/apps/challenges/src/repo/userRepo.ts b/apps/challenges/src/repo/userRepo.ts index 66fe00e6..2e687fb1 100644 --- a/apps/challenges/src/repo/userRepo.ts +++ b/apps/challenges/src/repo/userRepo.ts @@ -9,6 +9,13 @@ const getUserByID = async (id: mongoose.Types.ObjectId) => { return user; } +const getUserByEmail = async (email: string) => { + const user = await User.findOne({ + email: email + }); + return user; +} + const createUser = async ( name: string, email: string, @@ -23,7 +30,8 @@ const createUser = async ( const UserRepo = { getUserByID, - createUser + getUserByEmail, + createUser, } export { UserRepo as default }; \ No newline at end of file diff --git a/apps/challenges/src/routes/auth.ts b/apps/challenges/src/routes/auth.ts new file mode 100644 index 00000000..e9edaf75 --- /dev/null +++ b/apps/challenges/src/routes/auth.ts @@ -0,0 +1,11 @@ +import Express from "express"; +import AuthController from "../controllers/auth"; +import jwtRefreshMiddleware from "../middleware/jwtRefreshMiddleware"; + +const router = Express.Router(); + +router.post("/refresh", jwtRefreshMiddleware, AuthController.refreshToken); + +router.post("/oauth/signin", AuthController.oauthSignIn); + +export { router as default }; \ No newline at end of file diff --git a/apps/challenges/src/routes/questionaire.ts b/apps/challenges/src/routes/questionaire.ts index ab928f0c..ec17dea4 100644 --- a/apps/challenges/src/routes/questionaire.ts +++ b/apps/challenges/src/routes/questionaire.ts @@ -4,7 +4,6 @@ import QuestionController from "../controllers/questionaire"; import SubmissionController from "../controllers/submission"; router.route('/').get(QuestionController.getQuestions).post(QuestionController.setQuestion); -router.route('/active').get(QuestionController.getActiveQuestions); router.route('/:id').get(QuestionController.getQuestion).delete(QuestionController.deleteQuestion).put(QuestionController.updateQuestion); export { router as default }; \ No newline at end of file diff --git a/apps/challenges/src/routes/submission.ts b/apps/challenges/src/routes/submission.ts index 290bce8f..3726980f 100644 --- a/apps/challenges/src/routes/submission.ts +++ b/apps/challenges/src/routes/submission.ts @@ -3,6 +3,6 @@ const router = express.Router(); import SubmissionController from "../controllers/submission"; router.route('/').get(SubmissionController.getSubmissions).post(SubmissionController.setSubmission); -router.route('/:id').get(SubmissionController.getSubmission).delete(SubmissionController.deleteSubmission).put(SubmissionController.updateSubmission); +router.route('/:id').get(SubmissionController.getSubmission); export { router as default }; \ No newline at end of file diff --git a/apps/challenges/src/routes/user.ts b/apps/challenges/src/routes/user.ts index bca6ada1..fb678086 100644 --- a/apps/challenges/src/routes/user.ts +++ b/apps/challenges/src/routes/user.ts @@ -1,10 +1,10 @@ import Express from "express"; import UserController from "../controllers/user"; -import SeasonController from "../controllers/season"; +import jwtMiddleware from "../middleware/jwtMiddleware"; const router = Express.Router(); -router.post("/", UserController.createUser); -// router.get("/:userID/rankings", SeasonController.getUserAllSeasonRankings); +router.get("/", jwtMiddleware, UserController.getUser); +router.get("/token", UserController.checkTokens); export { router as default }; \ No newline at end of file diff --git a/apps/challenges/src/service/authService.ts b/apps/challenges/src/service/authService.ts new file mode 100644 index 00000000..9ddf08ec --- /dev/null +++ b/apps/challenges/src/service/authService.ts @@ -0,0 +1,99 @@ +import { SupabaseService } from '../utils/supabase'; +import mongoose from 'mongoose'; +import { jwtDecode } from "jwt-decode"; +import UserService from "../service/userService"; +import { isValidEmail, getEmailPrefix, zodIsValidObjectId } from "../utils/validator"; +import supabase from "../utils/supabase"; +import { z } from 'zod'; +import TokenService from './tokenService'; +import { GeneralResp, OauthcallbackResp } from '../types/types'; +import TokenRepo from '../repo/tokenRepo'; +import { StatusCodeError } from "../types/types" +import jwt from "jsonwebtoken"; +import UserRepo from '../repo/userRepo'; +import { refreshCookieMaxAgeSeconds, secondInMilliseconds } from "../model/constants"; + +const oauthSignIn = async ( + supabaseAccessToken: string +) => { + jwt.verify(supabaseAccessToken, process.env.SUPABASE_JWT_SECRET || ""); + + const resp = await supabase.auth.getUser(supabaseAccessToken); + + if (resp.error) { + throw new Error("Failed to exchange supabaseAccessToken for session."); + } + + // const decodedJWTToken = jwtDecode(code); + // const decodedJWTObj = decodedJWTToken as { + // aud: string; + // exp: number; + // iat: number; + // iss: string; + // sub: string; + // email: string; + // role: string; + // session_id: string; + // } + // console.log(decodedJWTObj); + + const email = isValidEmail.parse(resp.data.user.email); + + var createNewUser: boolean = false; + let user = await UserService.getUserByEmail(email); + + if (!user) { + const userName = getEmailPrefix(email); + user = await UserService.createUser(userName, email); + createNewUser = true; + } + + const accessToken = await TokenService.generateAccessToken(user._id.toString(), user.email); + const refreshToken = await TokenService.generateRefreshToken(user._id.toString(), user.email); + const now = new Date(); + const newExpiry = new Date(now.getTime() + refreshCookieMaxAgeSeconds * secondInMilliseconds); + + const tokenModel = await TokenRepo.saveRefreshToken({ + _id: new mongoose.Types.ObjectId(), + jwt: refreshToken, + userID: user._id, + expiry: newExpiry + }) + + if (!tokenModel) { + throw new Error("token not saved") + } + + return { + accessToken: accessToken, + refreshToken: refreshToken, + createNewUser: createNewUser + } +} + +const refreshToken = async (userID: string) => { + const _id = zodIsValidObjectId.parse(userID); + const mongoUserID = new mongoose.Types.ObjectId(_id) + const token = await TokenRepo.getRefreshToken(mongoUserID); + + if (token == null) { + throw new StatusCodeError(500, "Token not found") + } + + // verify jwt token + const decoded = jwt.verify(token.jwt, process.env.JWT_SECRET || "") + const user = await UserRepo.getUserByID(mongoUserID); + + if (!user) { + throw new StatusCodeError(500, "User not found") + } + + return TokenService.generateAccessToken(user._id.toString(), user.email) +} + +const AuthService = { + oauthSignIn, + refreshToken, +} + +export { AuthService as default }; \ No newline at end of file diff --git a/apps/challenges/src/service/questionService.ts b/apps/challenges/src/service/questionService.ts index c250cde2..fc033ab4 100644 --- a/apps/challenges/src/service/questionService.ts +++ b/apps/challenges/src/service/questionService.ts @@ -1,7 +1,10 @@ import mongoose from 'mongoose'; import QuestionRepo from '../repo/questionRepo'; +import { CreateQuestionReq, GetUserSpecificQuestionResp } from '../model/question'; +import ValidationService from './validationService'; +import { GeneralResp, StatusCodeError } from '../types/types'; -const getQuestionByID = async( +const getQuestionByID = async ( questionID: string, ) => { if (!mongoose.isValidObjectId(questionID)) { @@ -12,7 +15,37 @@ const getQuestionByID = async( return question; } -const updateQuestionSubmissions = async( +const createQuestion = async ( + req: CreateQuestionReq, +): Promise => { + if (!ValidationService.getValidationFunction(req.validation_function)) { + console.log('Invalid validation function'); + return { + status: 400, + message: 'Invalid validation function', + data: null, + }; + } + + if (!ValidationService.getGenerateInputFunction(req.generate_input_function)) { + console.log('Invalid generate input function'); + return { + status: 400, + message: 'Invalid generate input function', + data: null, + }; + } + + const question = await QuestionRepo.createQuestionByReq(req); + + return { + status: 201, + message: 'Question created', + data: question, + }; +} + +const updateQuestionSubmissions = async ( questionID: string, submissionID: string, isCorrect: boolean @@ -25,10 +58,43 @@ const updateQuestionSubmissions = async( return await QuestionRepo.updateQuestionSubmissions(_questionID, _submissionID, isCorrect); } +const getUserSpecificQuestion = async ( + userID: string, + seasonID: string, + questionID: string, +): Promise => { + if (!mongoose.isValidObjectId(userID) || !mongoose.isValidObjectId(seasonID) || !mongoose.isValidObjectId(questionID)) { + throw new Error('Invalid user, question or season ID'); + } + + const question = await QuestionService.getQuestionByID(questionID); + if (!question) { + throw new StatusCodeError(404, 'Question not found'); + } + + const _userID = new mongoose.Types.ObjectId(userID); + const _seasonID = new mongoose.Types.ObjectId(seasonID); + const _questionID = new mongoose.Types.ObjectId(questionID); + const questionInput = await QuestionRepo.getQuestionInput(_userID, _seasonID, _questionID) + let input = questionInput?.input + if (!input) { + input = await ValidationService.generateInput(questionID) + } + const resp: GetUserSpecificQuestionResp = { + ...question, + ...questionInput, + id: question._id.toString(), + seasonID: questionInput!.seasonID.toString(), + question_input: input + } + return resp +} const QuestionService = { getQuestionByID, - updateQuestionSubmissions + updateQuestionSubmissions, + createQuestion, + getUserSpecificQuestion } export { QuestionService as default } \ No newline at end of file diff --git a/apps/challenges/src/service/submissionService.ts b/apps/challenges/src/service/submissionService.ts index d74fdffa..cec4478d 100644 --- a/apps/challenges/src/service/submissionService.ts +++ b/apps/challenges/src/service/submissionService.ts @@ -1,7 +1,8 @@ import { CreateSubmissionReq, SubmissionModel } from "../model/submission"; import SubmissionRepo from "../repo/submissionRepo"; +import { GeneralResp } from "../types/types"; import QuestionService from "./questionService"; -import { GeneralResp } from "../model/response"; +import ValidationService from "./validationService"; const createSubmission = async ( submission: CreateSubmissionReq @@ -28,14 +29,21 @@ const createSubmission = async ( } } + let isCorrect = false; + try { + isCorrect = await ValidationService.validateAnswer(submission.question.toString(), submission.answer); + } catch (err) { + console.log("submissionService createSubmission fail to validate answer: ", err); + } + try { const dbSubmission = { user: submission.user, seasonID: question.seasonID, question: submission.question, answer: submission.answer, - correct: submission.answer === question.answer, - points_awarded: submission.answer === question.answer ? question.points : 0 + correct: isCorrect, + points_awarded: isCorrect ? question.points : 0 } const result = await SubmissionRepo.createSubmission(dbSubmission); @@ -43,7 +51,7 @@ const createSubmission = async ( question = await QuestionService.updateQuestionSubmissions( submission.question.toString(), result._id.toString(), - submission.answer === question.answer + isCorrect ); if (!question) { @@ -63,6 +71,8 @@ const createSubmission = async ( } } + + const SubmissionService = { createSubmission, } diff --git a/apps/challenges/src/service/tokenService.ts b/apps/challenges/src/service/tokenService.ts new file mode 100644 index 00000000..f5ef7513 --- /dev/null +++ b/apps/challenges/src/service/tokenService.ts @@ -0,0 +1,33 @@ +import jwt from "jsonwebtoken"; +import { accessTokenMaxAgeSeconds, refreshCookieMaxAgeSeconds } from "../model/constants"; +import { isValidObjectId } from "../utils/db"; + +const generateAccessToken = async ( + id: string, + email: string +) => { + const secret = process.env.JWT_SECRET || ""; + const token = jwt.sign({ id, email }, secret, { + expiresIn: accessTokenMaxAgeSeconds + }); + return token; +} + +const generateRefreshToken = async ( + id: string, + email: string +) => { + const secret = process.env.JWT_SECRET || ""; + const token = jwt.sign({ id, email }, secret, { + expiresIn: refreshCookieMaxAgeSeconds, + }); + return token; +} + + +const TokenService = { + generateAccessToken, + generateRefreshToken, +} + +export { TokenService as default }; \ No newline at end of file diff --git a/apps/challenges/src/service/userService.ts b/apps/challenges/src/service/userService.ts index b17ce14b..61ccbe72 100644 --- a/apps/challenges/src/service/userService.ts +++ b/apps/challenges/src/service/userService.ts @@ -1,12 +1,39 @@ import mongoose from 'mongoose'; import UserRepo from '../repo/userRepo'; +import { z } from 'zod'; +import { isValidEmail, zodIsValidObjectId } from '../utils/validator'; +import { StatusCodeError } from '../types/types'; +import { UserModel } from '../model/user'; const getUserByID = async (id: string) => { - if (!mongoose.isValidObjectId(id)) { - throw new Error('Invalid user ID'); + let _id: string + let user: UserModel | null + try { + _id = zodIsValidObjectId.parse(id); + } catch (err) { + throw new StatusCodeError(400, "Invalid id"); } - const _id = new mongoose.Types.ObjectId(id); - const user = await UserRepo.getUserByID(_id); + + const mongoUserID = new mongoose.Types.ObjectId(_id); + + try { + user = await UserRepo.getUserByID(mongoUserID); + } catch (err) { + throw new StatusCodeError(500, "Internal Server Error") + } + + if (!user) { + throw new StatusCodeError(404, "User not found") + } + + return user; +} + +const getUserByEmail = async (email: string) => { + const _email = isValidEmail.parse(email); + + const user = await UserRepo.getUserByEmail(_email); + return user; } @@ -20,7 +47,8 @@ const createUser = async ( const UserService = { getUserByID, - createUser + getUserByEmail, + createUser, } export { UserService as default }; \ No newline at end of file diff --git a/apps/challenges/src/service/validationService.ts b/apps/challenges/src/service/validationService.ts new file mode 100644 index 00000000..65f8a2fa --- /dev/null +++ b/apps/challenges/src/service/validationService.ts @@ -0,0 +1,94 @@ +import { z } from "zod"; +import QuestionService from "./questionService"; + +const isHello = (input: any) => { + // use zod to confirm input is string + z.string().parse(input); + + return input === 'hello'; +} + +// DO NOT EXPOSE this map. Add new validation function by directly adding to the map +const validationFunctionMap: Map = new Map([ + ['isHello', isHello], +]); + +const getValidationFunction = (functionName: string) => { + if (validationFunctionMap.has(functionName)) { + return validationFunctionMap.get(functionName); + } + return null; +} + +const validateAnswer = async ( + questionID: string, + submission: any, +): Promise => { + + const question = await QuestionService.getQuestionByID(questionID); + if (!question) { + throw new Error('Question not found'); + } + + //check if validation function exists as keys + const invokeFunction = getValidationFunction(question.validation_function); + if (!invokeFunction) { + throw new Error('Validation function not found'); + } + + try { + const isCorrect = invokeFunction(submission); + return isCorrect; + } catch (e) { + console.log(`validationService validateAnswer fail to validate answer: ${e}`) + return false; + } +} + +const generateOneTwo = (): string[] => { + return ["1", "2", "1, 2"]; +} + +// DO NOT EXPOSE this map. Add new validation function by directly adding to the map +const generateInputFunctionMap: Map = new Map([ + ['generateOneTwo', generateOneTwo], +]); + +const getGenerateInputFunction = (functionName: string) => { + if (generateInputFunctionMap.has(functionName)) { + return generateInputFunctionMap.get(functionName); + } + return null; +} + +const generateInput = async ( + questionID: string, +): Promise => { + const question = await QuestionService.getQuestionByID(questionID); + if (!question) { + throw new Error('Question not found'); + } + + //check if validation function exists as keys + const invokeFunction = getGenerateInputFunction(question.validation_function); + if (!invokeFunction) { + throw new Error('Validation function not found'); + } + + try { + const input = invokeFunction(); + return input; + } catch (e) { + console.log(`validationService validateAnswer fail to validate answer: ${e}`) + throw new Error('fail to generate input'); + } +} + +const ValidationService = { + validateAnswer, + getValidationFunction, + generateInput, + getGenerateInputFunction, +} + +export { ValidationService as default } \ No newline at end of file diff --git a/apps/challenges/src/types/types.ts b/apps/challenges/src/types/types.ts new file mode 100644 index 00000000..20563b1b --- /dev/null +++ b/apps/challenges/src/types/types.ts @@ -0,0 +1,18 @@ +export interface GeneralResp { + status: number; + message: string; + data?: any; +} + +export class StatusCodeError extends Error { + status: number; + constructor(status: number, message: string) { + super(message); + this.status = status; + } +} + +export interface OauthcallbackResp { + accessToken: string; + refreshToken: string; +} \ No newline at end of file diff --git a/apps/challenges/src/utils/supabase.ts b/apps/challenges/src/utils/supabase.ts new file mode 100644 index 00000000..14b81452 --- /dev/null +++ b/apps/challenges/src/utils/supabase.ts @@ -0,0 +1,28 @@ +import { SupabaseClient, createClient } from '@supabase/supabase-js' +import dotenv from "dotenv"; +dotenv.config({ path: "../.env" }); + + +let supabase: SupabaseClient; + +const initClient = () => { + supabase = createClient( + process.env.SUPABASE_URL || "", + process.env.SUPABASE_ANON_KEY || "", + { + auth: { + autoRefreshToken: false, + persistSession: false, + detectSessionInUrl: false, + flowType: "pkce" + }, + } + ) + console.log("Supabase client initialized"); +} + +const SupabaseService = { + initClient, +} + +export { SupabaseService, supabase as default } \ No newline at end of file diff --git a/apps/challenges/src/utils/validator.ts b/apps/challenges/src/utils/validator.ts index b57ee50f..40139aa6 100644 --- a/apps/challenges/src/utils/validator.ts +++ b/apps/challenges/src/utils/validator.ts @@ -1,6 +1,10 @@ import mongoose from 'mongoose'; import { z } from 'zod'; +export const getEmailPrefix = (email: string) => { + return email.replace(/@.*$/, ""); +} + export const isValidDate = (d: Date) => { return d instanceof Date && !isNaN(d.valueOf()) } @@ -11,12 +15,37 @@ export const zodIsValidObjectId = z.string().refine( { message: 'Invalid ObjectId' } ); +// Helper zod function to validate ObjectId +export const zodGetValidObjectId = z.string().refine( + (val) => mongoose.Types.ObjectId.isValid(val), + { message: 'Invalid ObjectId' } +).transform((val) => new mongoose.Types.ObjectId(val)); + +export const zodIsValidRFC3339 = z.string().refine( + (val) => new Date(val).toISOString() === val, + { message: 'Invalid RFC3339 date' } +); + export const isValidCreateSubmissionRequest = z.object({ user: zodIsValidObjectId, question: zodIsValidObjectId, answer: z.string(), }); +export const isValidEmail = z.string().min(1).email(); + +export const isValidCreateQuestionRequest = z.object({ + question_no: z.string(), + question_title: z.string(), + question_desc: z.string(), + question_date: zodIsValidRFC3339, + season_id: zodIsValidObjectId, + expiry: zodIsValidRFC3339, + points: z.number().int(), + validation_function: z.string(), + generate_input_function: z.string(), +}); + export const isPositiveInteger = z.number().int().min(1); export const isNonNegativeInteger = z.number().int().min(0); \ No newline at end of file diff --git a/yarn.lock b/yarn.lock index bb5b5d6b..0d2122f0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7105,6 +7105,63 @@ resolved "https://registry.yarnpkg.com/@stripe/stripe-js/-/stripe-js-1.54.0.tgz#f92f6b646533776a41bc62b650d2a03f1e282af8" integrity sha512-nElTXkS+nMfDNMkWfLmyeqHQfMGJ1JjrjAVMibV61Oc/rYdUv0cKRYCi1l4ivZ5SySB3vQLcLolxbKBkbNznZA== +"@supabase/functions-js@2.1.5": + version "2.1.5" + resolved "https://registry.yarnpkg.com/@supabase/functions-js/-/functions-js-2.1.5.tgz#ed1b85f499dfda21d40fe39b86ab923117cb572b" + integrity sha512-BNzC5XhCzzCaggJ8s53DP+WeHHGT/NfTsx2wUSSGKR2/ikLFQTBCDzMvGz/PxYMqRko/LwncQtKXGOYp1PkPaw== + dependencies: + "@supabase/node-fetch" "^2.6.14" + +"@supabase/gotrue-js@2.62.2": + version "2.62.2" + resolved "https://registry.yarnpkg.com/@supabase/gotrue-js/-/gotrue-js-2.62.2.tgz#9f15a451559d71475c953aa0027e1248b0210196" + integrity sha512-AP6e6W9rQXFTEJ7sTTNYQrNf0LCcnt1hUW+RIgUK+Uh3jbWvcIST7wAlYyNZiMlS9+PYyymWQ+Ykz/rOYSO0+A== + dependencies: + "@supabase/node-fetch" "^2.6.14" + +"@supabase/node-fetch@2.6.15", "@supabase/node-fetch@^2.6.14": + version "2.6.15" + resolved "https://registry.yarnpkg.com/@supabase/node-fetch/-/node-fetch-2.6.15.tgz#731271430e276983191930816303c44159e7226c" + integrity sha512-1ibVeYUacxWYi9i0cf5efil6adJ9WRyZBLivgjs+AUpewx1F3xPi7gLgaASI2SmIQxPoCEjAsLAzKPgMJVgOUQ== + dependencies: + whatwg-url "^5.0.0" + +"@supabase/postgrest-js@1.9.2": + version "1.9.2" + resolved "https://registry.yarnpkg.com/@supabase/postgrest-js/-/postgrest-js-1.9.2.tgz#39c839022ce73f4eb5da6fb7724fb6a6392150c7" + integrity sha512-I6yHo8CC9cxhOo6DouDMy9uOfW7hjdsnCxZiaJuIVZm1dBGTFiQPgfMa9zXCamEWzNyWRjZvupAUuX+tqcl5Sw== + dependencies: + "@supabase/node-fetch" "^2.6.14" + +"@supabase/realtime-js@2.9.3": + version "2.9.3" + resolved "https://registry.yarnpkg.com/@supabase/realtime-js/-/realtime-js-2.9.3.tgz#f822401aed70883dca5d538179b11089d6d1b6ed" + integrity sha512-lAp50s2n3FhGJFq+wTSXLNIDPw5Y0Wxrgt44eM5nLSA3jZNUUP3Oq2Ccd1CbZdVntPCWLZvJaU//pAd2NE+QnQ== + dependencies: + "@supabase/node-fetch" "^2.6.14" + "@types/phoenix" "^1.5.4" + "@types/ws" "^8.5.10" + ws "^8.14.2" + +"@supabase/storage-js@2.5.5": + version "2.5.5" + resolved "https://registry.yarnpkg.com/@supabase/storage-js/-/storage-js-2.5.5.tgz#2958e2a2cec8440e605bb53bd36649288c4dfa01" + integrity sha512-OpLoDRjFwClwc2cjTJZG8XviTiQH4Ik8sCiMK5v7et0MDu2QlXjCAW3ljxJB5+z/KazdMOTnySi+hysxWUPu3w== + dependencies: + "@supabase/node-fetch" "^2.6.14" + +"@supabase/supabase-js@^2.39.7": + version "2.39.7" + resolved "https://registry.yarnpkg.com/@supabase/supabase-js/-/supabase-js-2.39.7.tgz#61c3277a94bd9fd0574b39ecdf4fecffd73a139c" + integrity sha512-1vxsX10Uhc2b+Dv9pRjBjHfqmw2N2h1PyTg9LEfICR3x2xwE24By1MGCjDZuzDKH5OeHCsf4it6K8KRluAAEXA== + dependencies: + "@supabase/functions-js" "2.1.5" + "@supabase/gotrue-js" "2.62.2" + "@supabase/node-fetch" "2.6.15" + "@supabase/postgrest-js" "1.9.2" + "@supabase/realtime-js" "2.9.3" + "@supabase/storage-js" "2.5.5" + "@swc/core-darwin-arm64@1.3.37": version "1.3.37" resolved "https://registry.yarnpkg.com/@swc/core-darwin-arm64/-/core-darwin-arm64-1.3.37.tgz#a92e075ae35f18a64aaf3823ea175f03564f8da1" @@ -7682,6 +7739,13 @@ resolved "https://registry.yarnpkg.com/@types/json5/-/json5-0.0.29.tgz#ee28707ae94e11d2b827bcbe5270bcea7f3e71ee" integrity sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ== +"@types/jsonwebtoken@^9.0.6": + version "9.0.6" + resolved "https://registry.yarnpkg.com/@types/jsonwebtoken/-/jsonwebtoken-9.0.6.tgz#d1af3544d99ad992fb6681bbe60676e06b032bd3" + integrity sha512-/5hndP5dCjloafCXns6SZyESp3Ldq7YjH3zwzwczYnjxIT0Fqzk5ROSYVGfFyczIue7IUEj8hkvLbPoLQ18vQw== + dependencies: + "@types/node" "*" + "@types/lodash.mergewith@4.6.6": version "4.6.6" resolved "https://registry.yarnpkg.com/@types/lodash.mergewith/-/lodash.mergewith-4.6.6.tgz#c4698f5b214a433ff35cb2c75ee6ec7f99d79f10" @@ -7786,6 +7850,11 @@ resolved "https://registry.yarnpkg.com/@types/parse-json/-/parse-json-4.0.0.tgz#2f8bb441434d163b35fb8ffdccd7138927ffb8c0" integrity sha512-//oorEZjL6sbPcKUaCdIGlIUeH26mgzimjBB77G6XRgnDl/L5wOnpyBGRe/Mmf5CVW3PwEBE1NjiMZ/ssFh4wA== +"@types/phoenix@^1.5.4": + version "1.6.4" + resolved "https://registry.yarnpkg.com/@types/phoenix/-/phoenix-1.6.4.tgz#cceac93a827555473ad38057d1df7d06eef1ed71" + integrity sha512-B34A7uot1Cv0XtaHRYDATltAdKx0BvVKNgYNqE4WjtPUa4VQJM7kxeXcVKaH+KS+kCmZ+6w+QaUdcljiheiBJA== + "@types/prettier@^2.1.5", "@types/prettier@^2.6.1": version "2.7.2" resolved "https://registry.yarnpkg.com/@types/prettier/-/prettier-2.7.2.tgz#6c2324641cc4ba050a8c710b2b251b377581fbf0" @@ -7997,6 +8066,13 @@ dependencies: winston "*" +"@types/ws@^8.5.10": + version "8.5.10" + resolved "https://registry.yarnpkg.com/@types/ws/-/ws-8.5.10.tgz#4acfb517970853fa6574a3a6886791d04a396787" + integrity sha512-vmQSUcfalpIq0R9q7uTo2lXs6eGIpt9wtnLdMv9LVpIjCA/+ufZRozlVoVelIYixx1ugCBKDhn89vnsEGOCx9A== + dependencies: + "@types/node" "*" + "@types/yargs-parser@*": version "21.0.0" resolved "https://registry.yarnpkg.com/@types/yargs-parser/-/yargs-parser-21.0.0.tgz#0c60e537fa790f5f9472ed2776c2b71ec117351b" @@ -14409,6 +14485,11 @@ jwt-decode@^3.1.2: resolved "https://registry.yarnpkg.com/jwt-decode/-/jwt-decode-3.1.2.tgz#3fb319f3675a2df0c2895c8f5e9fa4b67b04ed59" integrity sha512-UfpWE/VZn0iP50d8cz9NrZLM9lSWhcJ+0Gt/nm4by88UL+J1SiKN8/5dkjMmbEzwL2CAe+67GsegCbIKtbp75A== +jwt-decode@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/jwt-decode/-/jwt-decode-4.0.0.tgz#2270352425fd413785b2faf11f6e755c5151bd4b" + integrity sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA== + kareem@2.4.1: version "2.4.1" resolved "https://registry.yarnpkg.com/kareem/-/kareem-2.4.1.tgz#7d81ec518204a48c1cb16554af126806c3cd82b0" @@ -20052,6 +20133,11 @@ ws@^8.11.0: resolved "https://registry.yarnpkg.com/ws/-/ws-8.11.0.tgz#6a0d36b8edfd9f96d8b25683db2f8d7de6e8e143" integrity sha512-HPG3wQd9sNQoT9xHyNCXoDUa+Xw/VevmY9FoHyQ+g+rrMn4j6FB4np7Z0OhdTgjx6MgQLK7jwSy1YecU1+4Asg== +ws@^8.14.2: + version "8.16.0" + resolved "https://registry.yarnpkg.com/ws/-/ws-8.16.0.tgz#d1cd774f36fbc07165066a60e40323eab6446fd4" + integrity sha512-HS0c//TP7Ina87TfiPUz1rQzMhHrl/SG2guqRcTOIUYD2q8uhUdNHZYJUaQ8aTGPzCh+c6oawMKW35nFl1dxyQ== + ws@^8.2.3: version "8.13.0" resolved "https://registry.yarnpkg.com/ws/-/ws-8.13.0.tgz#9a9fb92f93cf41512a0735c8f4dd09b8a1211cd0"