diff --git a/fems-api/package.json b/fems-api/package.json index e208996..3898013 100644 --- a/fems-api/package.json +++ b/fems-api/package.json @@ -17,6 +17,7 @@ "dotenv": "^16.0.3", "express": "^4.18.2", "express-validator": "^7.0.1", + "ioredis": "^5.4.1", "jsonwebtoken": "^9.0.0", "multer": "^1.4.5-lts.1", "pg": "^8.11.0", diff --git a/fems-api/src/controllers/app/auth/auth.controller.js b/fems-api/src/controllers/app/auth/auth.controller.js index bbfdcad..169f041 100644 --- a/fems-api/src/controllers/app/auth/auth.controller.js +++ b/fems-api/src/controllers/app/auth/auth.controller.js @@ -5,6 +5,7 @@ const authService = require("../../../services/auth.service"); const { body } = require("express-validator"); const validate = require("../../../middleware/validator.middleware"); const authMiddleware = require("../../../middleware/auth.middleware"); +const PermissionUtils = require("../../../utils/permission.utils"); router.post( "/login", @@ -34,4 +35,16 @@ router.post("/logout", authMiddleware, async (req, res, next) => { } }); +// src/controllers/app/auth/auth.controller.js +router.get("/menu", authMiddleware, async (req, res, next) => { + try { + const accessibleMenus = await PermissionUtils.getUserAccessibleMenus( + req.user + ); + res.json(accessibleMenus); + } catch (error) { + next(error); + } +}); + module.exports = router; diff --git a/fems-api/src/middleware/auth.middleware.js b/fems-api/src/middleware/auth.middleware.js index 3c25f82..d0b439d 100644 --- a/fems-api/src/middleware/auth.middleware.js +++ b/fems-api/src/middleware/auth.middleware.js @@ -1,32 +1,208 @@ // src/middleware/auth.middleware.js -const jwt = require('jsonwebtoken'); -const config = require('../config/config'); -const { User } = require('../models'); +const jwt = require("jsonwebtoken"); +const { promisify } = require("util"); +const config = require("../config/config"); +const { User, Role, UserRole } = require("../models"); +const { AuthenticationError, AuthorizationError } = require("../utils/errors"); +const logger = require("../utils/logger"); +const redis = require("../config/redis"); +/** + * 토큰 검증 및 사용자 인증 미들웨어 + */ const authMiddleware = async (req, res, next) => { try { - const authHeader = req.headers.authorization; - if (!authHeader || !authHeader.startsWith('Bearer ')) { - return res.status(401).json({ message: 'Authentication token is required' }); + // 토큰 추출 + const token = extractTokenFromHeader(req); + if (!token) { + throw new AuthenticationError("Authentication token is required"); } - const token = authHeader.split(' ')[1]; - const decoded = jwt.verify(token, config.jwt.secret); + // 토큰 검증 + const decoded = await verifyToken(token); - const user = await User.findByPk(decoded.id, { - attributes: { exclude: ['password'] } - }); + // 세션 확인 + await validateSession(decoded.id, token); - if (!user || !user.isActive) { - return res.status(401).json({ message: 'User not found or inactive' }); - } + // 사용자 정보 조회 및 검증 + const user = await getUserWithRoles(decoded.id); + validateUser(user); + // 마지막 활동 시간 업데이트 + await updateLastActivity(user.id); + + // 요청 객체에 사용자 정보 추가 req.user = user; next(); } catch (error) { - return res.status(401).json({ message: 'Invalid token' }); + handleAuthError(error, res); } }; -module.exports = authMiddleware; +/** + * 특정 권한 필요한 엔드포인트용 미들웨어 + */ +const requireRole = (...roles) => { + return (req, res, next) => { + if (!req.user) { + return res.status(401).json({ message: "Authentication required" }); + } + const hasRequiredRole = req.user.Roles.some( + (role) => roles.includes(role.name) && role.isActive + ); + + if (!hasRequiredRole) { + return res.status(403).json({ + message: `Required role: ${roles.join(" or ")}`, + }); + } + + next(); + }; +}; + +/** + * 세부 권한 체크 미들웨어 + */ +const checkPermission = (resource, action) => { + return async (req, res, next) => { + try { + if (!req.user) { + throw new AuthenticationError("Authentication required"); + } + + const hasPermission = await validatePermission( + req.user.id, + resource, + action + ); + if (!hasPermission) { + throw new AuthorizationError( + `Permission denied: ${resource}:${action}` + ); + } + + next(); + } catch (error) { + handleAuthError(error, res); + } + }; +}; + +// 유틸리티 함수들 +const extractTokenFromHeader = (req) => { + const authHeader = req.headers.authorization; + if (!authHeader || !authHeader.startsWith("Bearer ")) { + return null; + } + return authHeader.split(" ")[1]; +}; + +const verifyToken = async (token) => { + try { + // 토큰 블랙리스트 체크 + const isBlacklisted = await redis.get(`blacklist:${token}`); + if (isBlacklisted) { + throw new AuthenticationError("Token has been revoked"); + } + + return await promisify(jwt.verify)(token, config.jwt.secret); + } catch (error) { + if (error.name === "TokenExpiredError") { + throw new AuthenticationError("Token has expired"); + } + throw new AuthenticationError("Invalid token"); + } +}; + +const validateSession = async (userId, token) => { + const currentSession = await redis.get(`session:${userId}`); + if (currentSession && currentSession !== token) { + throw new AuthenticationError("Session has been invalidated"); + } +}; + +const getUserWithRoles = async (userId) => { + return await User.findByPk(userId, { + attributes: { exclude: ["password"] }, + include: [ + { + model: Role, + through: UserRole, + attributes: ["id", "name", "permissions", "isActive"], + where: { isActive: true }, + }, + ], + }); +}; + +const validateUser = (user) => { + if (!user) { + throw new AuthenticationError("User not found"); + } + if (!user.isActive) { + throw new AuthenticationError("User account is inactive"); + } + if (user.Roles.length === 0) { + throw new AuthorizationError("User has no active roles"); + } +}; + +const validatePermission = async (userId, resource, action) => { + // Redis에서 캐시된 권한 확인 + const cacheKey = `permissions:${userId}`; + let permissions = await redis.get(cacheKey); + + if (!permissions) { + // DB에서 권한 조회 및 캐싱 + const user = await User.findByPk(userId, { + include: [ + { + model: Role, + attributes: ["permissions"], + where: { isActive: true }, + }, + ], + }); + + permissions = user.Roles.reduce((acc, role) => { + const rolePerms = role.permissions || {}; + Object.keys(rolePerms).forEach((res) => { + acc[res] = acc[res] || []; + acc[res].push(...rolePerms[res]); + }); + return acc; + }, {}); + + await redis.set(cacheKey, JSON.stringify(permissions), "EX", 3600); // 1시간 캐시 + } else { + permissions = JSON.parse(permissions); + } + + return permissions[resource]?.includes(action) || false; +}; + +const updateLastActivity = async (userId) => { + await redis.set(`lastActivity:${userId}`, Date.now()); +}; + +const handleAuthError = (error, res) => { + logger.error("Authentication error", { error }); + + if (error instanceof AuthenticationError) { + return res.status(401).json({ message: error.message }); + } + if (error instanceof AuthorizationError) { + return res.status(403).json({ message: error.message }); + } + + return res.status(500).json({ message: "Internal server error" }); +}; + +module.exports = { + authMiddleware, + requireRole, + checkPermission, + validatePermission, // Export for testing +}; diff --git a/fems-api/src/middleware/menu.middleware.js b/fems-api/src/middleware/menu.middleware.js new file mode 100644 index 0000000..087ec1e --- /dev/null +++ b/fems-api/src/middleware/menu.middleware.js @@ -0,0 +1,130 @@ +// src/middleware/menu.middleware.js +const menuConfig = require("../config/menuConfig"); +const { AuthorizationError } = require("../utils/errors"); +const logger = require("../config/logger"); + +/** + * 메뉴 접근 권한 검사 미들웨어 + */ +const menuAccessMiddleware = (menuPath) => { + return async (req, res, next) => { + try { + // 메뉴 설정 찾기 + const menuSettings = findMenuSettings(menuPath); + if (!menuSettings) { + logger.warn(`Menu configuration not found for path: ${menuPath}`); + return res.status(404).json({ + message: "Menu configuration not found", + }); + } + + const { user } = req; + const action = getActionFromMethod(req.method); + + // 권한 설정 확인 + if (!menuSettings.permissions || !menuSettings.permissions[action]) { + logger.warn( + `No permission configuration for action: ${action} in menu: ${menuPath}` + ); + return res.status(403).json({ + message: "Permission configuration not found", + }); + } + + // 사용자 권한 확인 + const allowedRoles = menuSettings.permissions[action]; + const hasAccess = checkUserAccess(user, allowedRoles); + + if (!hasAccess) { + throw new AuthorizationError( + `Access denied to menu: ${menuPath}, action: ${action}, user role: ${user.role}` + ); + } + + // 요청 객체에 메뉴 설정 추가 (후속 처리에서 사용 가능) + req.menuSettings = menuSettings; + next(); + } catch (error) { + logger.error("Menu access check failed", { + path: menuPath, + userId: req.user?.id, + error: error.message, + }); + next(error); + } + }; +}; + +/** + * 메뉴 설정 찾기 + */ +const findMenuSettings = (menuPath) => { + // admin 또는 app 영역 확인 + const [area, ...pathParts] = menuPath.split("/").filter(Boolean); + let currentConfig = menuConfig[area]; + + // 경로를 따라 메뉴 설정 탐색 + for (const part of pathParts) { + if (!currentConfig) return null; + + if (currentConfig[part]) { + currentConfig = currentConfig[part]; + } else if (currentConfig.subMenus?.[part]) { + currentConfig = currentConfig.subMenus[part]; + } else { + return null; + } + } + + return currentConfig; +}; + +/** + * 사용자 접근 권한 확인 + */ +const checkUserAccess = (user, allowedRoles) => { + // 슈퍼 관리자는 모든 접근 허용 + if (user.role === "super_admin") { + return true; + } + + // 회사 관리자는 자신의 회사 리소스만 접근 가능 + if ( + user.role === "company_admin" && + !allowedRoles.includes("company_admin") + ) { + return false; + } + + // 지점 관리자는 자신의 지점 리소스만 접근 가능 + if (user.role === "branch_admin" && !allowedRoles.includes("branch_admin")) { + return false; + } + + return allowedRoles.includes(user.role); +}; + +/** + * HTTP 메소드를 권한 액션으로 변환 + */ +const getActionFromMethod = (method) => { + switch (method.toUpperCase()) { + case "GET": + return "view"; + case "POST": + return "create"; + case "PUT": + case "PATCH": + return "update"; + case "DELETE": + return "delete"; + default: + return "view"; + } +}; + +module.exports = { + menuAccessMiddleware, + findMenuSettings, // Export for testing + checkUserAccess, // Export for testing +}; diff --git a/fems-api/src/routes/registerRoutes.js b/fems-api/src/routes/registerRoutes.js new file mode 100644 index 0000000..a1db7c5 --- /dev/null +++ b/fems-api/src/routes/registerRoutes.js @@ -0,0 +1,77 @@ +// src/routes/registerRoutes.js +const express = require("express"); +const menuConfig = require("../config/menuConfig"); +const menuAccessMiddleware = require("../middleware/menu.middleware"); +const logger = require("../config/logger"); + +function registerRoutes(app) { + // Admin 라우트 등록 + const adminRouter = express.Router(); + Object.entries(menuConfig.admin).forEach(([key, config]) => { + registerRouteWithPermissions(adminRouter, key, config); + }); + app.use("/api/v1/admin", adminRouter); + + // App 라우트 등록 + const appRouter = express.Router(); + Object.entries(menuConfig.app).forEach(([key, config]) => { + registerRouteWithPermissions(appRouter, key, config); + }); + app.use("/api/v1/app", appRouter); + + // 등록된 라우트 로깅 + logRegisteredRoutes(app); +} + +/** + * 권한이 적용된 라우트 등록 + */ +function registerRouteWithPermissions(router, key, config) { + const controller = require(`../controllers/${config.path.slice( + 1 + )}.controller`); + const path = `/${key}`; + + router.use(path, menuAccessMiddleware(config.path), controller); + + // 서브메뉴 등록 + if (config.subMenus) { + Object.entries(config.subMenus).forEach(([subKey, subConfig]) => { + const subController = require(`../controllers/${subConfig.path.slice( + 1 + )}.controller`); + router.use( + `${path}/${subKey}`, + menuAccessMiddleware(subConfig.path), + subController + ); + }); + } +} + +/** + * 등록된 라우트 로깅 + */ +function logRegisteredRoutes(app) { + const routes = []; + app._router.stack.forEach((middleware) => { + if (middleware.route) { + routes.push( + `${Object.keys(middleware.route.methods)} ${middleware.route.path}` + ); + } else if (middleware.name === "router") { + middleware.handle.stack.forEach((handler) => { + if (handler.route) { + routes.push( + `${Object.keys(handler.route.methods)} ${handler.route.path}` + ); + } + }); + } + }); + + logger.info("Registered routes:"); + routes.forEach((route) => logger.info(route)); +} + +module.exports = registerRoutes; diff --git a/fems-api/src/utils/permissions.js b/fems-api/src/utils/permissions.js new file mode 100644 index 0000000..fde31f9 --- /dev/null +++ b/fems-api/src/utils/permissions.js @@ -0,0 +1,194 @@ +// src/utils/permissions.js +const { Role, UserRole } = require("../models"); +const menuConfig = require("../config/menuConfig"); +const redis = require("../config/redis"); +const logger = require("../config/logger"); + +class PermissionUtils { + /** + * 사용자의 메뉴 권한 검사 + */ + static async checkUserPermissions(user, menuPath, action) { + try { + // 캐시된 권한 확인 + const permissions = await this.getUserPermissions(user.id); + + // 메뉴 설정에서 권한 정보 찾기 + const menuPermissions = this.findMenuPermissions(menuPath); + if (!menuPermissions) { + logger.warn(`No permission configuration found for menu: ${menuPath}`); + return false; + } + + // 사용자 역할이 해당 작업의 허용 목록에 있는지 확인 + const allowedRoles = menuPermissions.permissions[action] || []; + return this.hasRequiredRole(permissions.roles, allowedRoles); + } catch (error) { + logger.error("Permission check failed", { + userId: user.id, + menuPath, + action, + error, + }); + return false; + } + } + + /** + * 사용자의 권한 정보 조회 (캐시 활용) + */ + static async getUserPermissions(userId) { + const cacheKey = `permissions:${userId}`; + + // 캐시 확인 + let permissions = await redis.get(cacheKey); + if (permissions) { + return JSON.parse(permissions); + } + + // DB에서 권한 조회 + permissions = await this.loadUserPermissions(userId); + + // 캐시 저장 (1시간) + await redis.set(cacheKey, JSON.stringify(permissions), "EX", 3600); + + return permissions; + } + + /** + * DB에서 사용자 권한 로드 + */ + static async loadUserPermissions(userId) { + const userRoles = await UserRole.findAll({ + where: { userId }, + include: [ + { + model: Role, + where: { isActive: true }, + attributes: ["name", "permissions"], + }, + ], + }); + + const roles = userRoles.map((ur) => ur.Role.name); + const permissions = userRoles.reduce((acc, ur) => { + return this.mergePermissions(acc, ur.Role.permissions); + }, {}); + + return { roles, permissions }; + } + + /** + * 메뉴 설정에서 권한 정보 찾기 + */ + static findMenuPermissions(menuPath) { + const parts = menuPath.split("/").filter(Boolean); + let config = menuConfig; + + for (const part of parts) { + if (config[part]) { + config = config[part]; + } else if (config.subMenus?.[part]) { + config = config.subMenus[part]; + } else { + return null; + } + } + + return config; + } + + /** + * 권한 병합 + */ + static mergePermissions(base, additional) { + const merged = { ...base }; + + Object.entries(additional || {}).forEach(([key, value]) => { + if (Array.isArray(value)) { + merged[key] = [...new Set([...(merged[key] || []), ...value])]; + } else if (typeof value === "object") { + merged[key] = this.mergePermissions(merged[key] || {}, value); + } else { + merged[key] = value; + } + }); + + return merged; + } + + /** + * 필요한 역할 확인 + */ + static hasRequiredRole(userRoles, allowedRoles) { + return userRoles.some((role) => allowedRoles.includes(role)); + } + + /** + * 사용자의 권한 캐시 무효화 + */ + static async invalidateUserPermissions(userId) { + const cacheKey = `permissions:${userId}`; + await redis.del(cacheKey); + } + + /** + * 사용자의 접근 가능한 메뉴 목록 조회 + */ + static async getUserAccessibleMenus(user) { + const permissions = await this.getUserPermissions(user.id); + return this.filterAccessibleMenus(menuConfig, permissions.roles); + } + + /** + * 접근 가능한 메뉴 필터링 + */ + static filterAccessibleMenus(menuConfig, userRoles, parentPath = "") { + const accessibleMenus = {}; + + Object.entries(menuConfig).forEach(([key, config]) => { + const currentPath = `${parentPath}/${key}`; + + // 메뉴에 대한 조회 권한 확인 + const hasAccess = config.permissions?.view?.some((role) => + userRoles.includes(role) + ); + + if (hasAccess) { + accessibleMenus[key] = { + path: config.path || currentPath, + permissions: this.filterUserPermissions( + config.permissions, + userRoles + ), + }; + + // 서브메뉴 처리 + if (config.subMenus) { + accessibleMenus[key].subMenus = this.filterAccessibleMenus( + config.subMenus, + userRoles, + currentPath + ); + } + } + }); + + return accessibleMenus; + } + + /** + * 사용자 권한 필터링 + */ + static filterUserPermissions(permissions, userRoles) { + const filtered = {}; + + Object.entries(permissions || {}).forEach(([action, allowedRoles]) => { + filtered[action] = allowedRoles.some((role) => userRoles.includes(role)); + }); + + return filtered; + } +} + +module.exports = PermissionUtils; diff --git a/fems-api/yarn.lock b/fems-api/yarn.lock index a3ad1ee..9dac447 100644 --- a/fems-api/yarn.lock +++ b/fems-api/yarn.lock @@ -338,6 +338,11 @@ resolved "https://registry.yarnpkg.com/@humanwhocodes/object-schema/-/object-schema-2.0.3.tgz#4a2868d75d6d6963e423bcf90b7fd1be343409d3" integrity sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA== +"@ioredis/commands@^1.1.1": + version "1.2.0" + resolved "https://registry.yarnpkg.com/@ioredis/commands/-/commands-1.2.0.tgz#6d61b3097470af1fdbbe622795b8921d42018e11" + integrity sha512-Sx1pU8EM64o2BrqNpEO1CNLtKQwyhuXuqyfH7oGKCk+1a33d2r5saW8zNwm3j6BTExtjrv2BxTgzzkMwts6vGg== + "@istanbuljs/load-nyc-config@^1.0.0": version "1.1.0" resolved "https://registry.yarnpkg.com/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz#fd3db1d59ecf7cf121e80650bb86712f9b55eced" @@ -1073,6 +1078,11 @@ cliui@^8.0.1: strip-ansi "^6.0.1" wrap-ansi "^7.0.0" +cluster-key-slot@^1.1.0: + version "1.1.2" + resolved "https://registry.yarnpkg.com/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz#88ddaa46906e303b5de30d3153b7d9fe0a0c19ac" + integrity sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA== + co@^4.6.0: version "4.6.0" resolved "https://registry.yarnpkg.com/co/-/co-4.6.0.tgz#6ea6bdf3d853ae54ccb8e47bfa0bf3f9031fb184" @@ -1280,6 +1290,11 @@ delayed-stream@~1.0.0: resolved "https://registry.yarnpkg.com/delayed-stream/-/delayed-stream-1.0.0.tgz#df3ae199acadfb7d440aaae0b29e2272b24ec619" integrity sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ== +denque@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/denque/-/denque-2.1.0.tgz#e93e1a6569fb5e66f16a3c2a2964617d349d6ab1" + integrity sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw== + depd@2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/depd/-/depd-2.0.0.tgz#b696163cc757560d09cf22cc8fad1571b79e76df" @@ -1951,6 +1966,21 @@ inherits@2, inherits@2.0.4, inherits@^2.0.3, inherits@~2.0.3: resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c" integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ== +ioredis@^5.4.1: + version "5.4.1" + resolved "https://registry.yarnpkg.com/ioredis/-/ioredis-5.4.1.tgz#1c56b70b759f01465913887375ed809134296f40" + integrity sha512-2YZsvl7jopIa1gaePkeMtd9rAcSjOOjPtpcLlOeusyO+XH2SK5ZcT+UCrElPP+WVIInh2TzeI4XW9ENaSLVVHA== + dependencies: + "@ioredis/commands" "^1.1.1" + cluster-key-slot "^1.1.0" + debug "^4.3.4" + denque "^2.1.0" + lodash.defaults "^4.2.0" + lodash.isarguments "^3.1.0" + redis-errors "^1.2.0" + redis-parser "^3.0.0" + standard-as-callback "^2.1.0" + ipaddr.js@1.9.1: version "1.9.1" resolved "https://registry.yarnpkg.com/ipaddr.js/-/ipaddr.js-1.9.1.tgz#bff38543eeb8984825079ff3a2a8e6cbd46781b3" @@ -2570,11 +2600,21 @@ locate-path@^6.0.0: dependencies: p-locate "^5.0.0" +lodash.defaults@^4.2.0: + version "4.2.0" + resolved "https://registry.yarnpkg.com/lodash.defaults/-/lodash.defaults-4.2.0.tgz#d09178716ffea4dde9e5fb7b37f6f0802274580c" + integrity sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ== + lodash.includes@^4.3.0: version "4.3.0" resolved "https://registry.yarnpkg.com/lodash.includes/-/lodash.includes-4.3.0.tgz#60bb98a87cb923c68ca1e51325483314849f553f" integrity sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w== +lodash.isarguments@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz#2f573d85c6a24289ff00663b491c1d338ff3458a" + integrity sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg== + lodash.isboolean@^3.0.3: version "3.0.3" resolved "https://registry.yarnpkg.com/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz#6c2e171db2a257cd96802fd43b01b20d5f5870f6" @@ -3174,6 +3214,18 @@ readdirp@~3.6.0: dependencies: picomatch "^2.2.1" +redis-errors@^1.0.0, redis-errors@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/redis-errors/-/redis-errors-1.2.0.tgz#eb62d2adb15e4eaf4610c04afe1529384250abad" + integrity sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w== + +redis-parser@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/redis-parser/-/redis-parser-3.0.0.tgz#b66d828cdcafe6b4b8a428a7def4c6bcac31c8b4" + integrity sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A== + dependencies: + redis-errors "^1.0.0" + require-directory@^2.1.1: version "2.1.1" resolved "https://registry.yarnpkg.com/require-directory/-/require-directory-2.1.1.tgz#8c64ad5fd30dab1c976e2344ffe7f792a6a6df42" @@ -3433,6 +3485,11 @@ stack-utils@^2.0.3: dependencies: escape-string-regexp "^2.0.0" +standard-as-callback@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/standard-as-callback/-/standard-as-callback-2.1.0.tgz#8953fc05359868a77b5b9739a665c5977bb7df45" + integrity sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A== + statuses@2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/statuses/-/statuses-2.0.1.tgz#55cb000ccf1d48728bd23c685a063998cf1a1b63"