diff --git a/frontend/package-lock.json b/frontend/package-lock.json index fb9c8e6..b45677a 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -59,7 +59,6 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -1224,7 +1223,6 @@ "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -1277,7 +1275,6 @@ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -1403,7 +1400,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -1857,7 +1853,6 @@ "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -3000,7 +2995,6 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -3070,7 +3064,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -3080,7 +3073,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -3100,7 +3092,6 @@ "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", "license": "MIT", - "peer": true, "dependencies": { "@types/use-sync-external-store": "^0.0.6", "use-sync-external-store": "^1.4.0" @@ -3191,8 +3182,7 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/redux-thunk": { "version": "3.1.0", @@ -3476,7 +3466,6 @@ "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.3.tgz", "integrity": "sha512-B9ifbFudT1TFhfltfaIPgjo9Z3mDynBTJSUYxTjOQruf/zHH+ezCQKcoqO+h7a9Pw9Nm/OtlXAiGT1axBgwqrQ==", "license": "MIT", - "peer": true, "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", @@ -3601,7 +3590,6 @@ "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", "dev": true, "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/frontend/src/hooks/useCodeforces.js b/frontend/src/hooks/useCodeforces.js index c715ab5..d91592c 100644 --- a/frontend/src/hooks/useCodeforces.js +++ b/frontend/src/hooks/useCodeforces.js @@ -30,22 +30,42 @@ export const useCodeforces = (dashboardOnly = false) => { const [connectLoading, setConnectLoading] = useState(false); const [connectError, setConnectError] = useState(null); + const unwrapApiData = (response) => { + if (!response) { + return null; + } + + if ( + response.data && + typeof response.data === "object" && + Object.prototype.hasOwnProperty.call(response.data, "data") + ) { + return response.data.data; + } + + if (Object.prototype.hasOwnProperty.call(response, "data")) { + return response.data; + } + + return null; + }; + const fetchAll = useCallback(async () => { setLoading(true); setError(null); try { if (dashboardOnly) { - const { data } = await cfGetDashboardSummary(); - setDashboardSummary(data.data); + const response = await cfGetDashboardSummary(); + setDashboardSummary(unwrapApiData(response)); } else { const [profileRes, ratingRes, submissionsRes] = await Promise.all([ cfGetProfile(), cfGetRatingHistory(), cfGetSubmissions(50), ]); - setProfile(profileRes.data.data); - setRatingHistory(ratingRes.data.data || []); - setSubmissions(submissionsRes.data.data || []); + setProfile(unwrapApiData(profileRes)); + setRatingHistory(unwrapApiData(ratingRes) || []); + setSubmissions(unwrapApiData(submissionsRes) || []); } } catch (err) { setError(err.response?.data?.message || "Failed to load Codeforces data"); @@ -63,10 +83,11 @@ export const useCodeforces = (dashboardOnly = false) => { setConnectLoading(true); setConnectError(null); try { - const { data } = await cfInitiateConnection(handle); - setVerificationCode(data.data.verificationCode); + const response = await cfInitiateConnection(handle); + const payload = unwrapApiData(response); + setVerificationCode(payload?.verificationCode); setPendingHandle(handle); - return data.data; + return payload; } catch (err) { const msg = err.response?.data?.message || "Connection failed"; setConnectError(msg); @@ -82,11 +103,12 @@ export const useCodeforces = (dashboardOnly = false) => { setConnectLoading(true); setConnectError(null); try { - const { data } = await cfVerifyConnection(pendingHandle); + const response = await cfVerifyConnection(pendingHandle); + const payload = unwrapApiData(response); setVerificationCode(null); setPendingHandle(null); await fetchAll(); - return data.data; + return payload; } catch (err) { const msg = err.response?.data?.message || "Verification failed"; setConnectError(msg); diff --git a/frontend/src/pages/AccountCenterPage.jsx b/frontend/src/pages/AccountCenterPage.jsx index b206356..5be2394 100644 --- a/frontend/src/pages/AccountCenterPage.jsx +++ b/frontend/src/pages/AccountCenterPage.jsx @@ -2,8 +2,9 @@ import { useAuth } from "../context/AuthContext"; import { useState, useEffect } from "react"; import { Link, useSearchParams } from "react-router-dom"; import { getProfile, deleteAccount } from "../services/userService"; +import { getGithubConnectUrl } from "../services/authService"; + -const API_BASE = import.meta.env.VITE_API_BASE_URL; // ── Helpers ────────────────────────────────────────────────────────────────── @@ -21,9 +22,6 @@ const RANK_COLORS = { unrated: "text-gray-400", }; -const rankColor = (rank = "") => - RANK_COLORS[(rank || "").toLowerCase()] || "text-black"; - // ── Sub-components ──────────────────────────────────────────────────────────── function SectionLabel({ text }) { @@ -103,11 +101,29 @@ function GitHubCard({ user }) { const ghUsername = ghIdentity?.username || user?.handles?.github; const ghAvatar = user?.profile?.avatar; const [msg, setMsg] = useState(""); + const [connecting, setConnecting] = useState(false); - const handleConnect = () => { - // Encode current path so backend redirects back here after connect - const redirectPath = encodeURIComponent("/account-center"); - window.location.href = `${API_BASE}/auth/github/connect?redirectPath=${redirectPath}`; + const handleConnect = async () => { + setMsg(""); + setConnecting(true); + try { + const response = await getGithubConnectUrl("/account-center"); + const authUrl = response?.authUrl; + + if (!authUrl) { + throw new Error("Failed to initiate GitHub connect flow"); + } + + window.location.href = authUrl; + } catch (error) { + setMsg( + error?.response?.data?.message || + error?.message || + "Unable to connect GitHub right now. Please try again." + ); + } finally { + setConnecting(false); + } }; const handleDisconnect = () => { @@ -196,12 +212,13 @@ function GitHubCard({ user }) { )} @@ -273,7 +290,7 @@ function DangerZone({ onLogout }) { try { await deleteAccount(); onLogout(); // Logs the user out and redirects to login - } catch (err) { + } catch { alert("Failed to delete account. Please try again later."); setLoading(false); setConfirm(false); @@ -329,15 +346,17 @@ function DangerZone({ onLogout }) { export default function AccountCenterPage() { const { user, setUser, logout } = useAuth(); const [searchParams] = useSearchParams(); - const [banner, setBanner] = useState(""); + const githubStatus = searchParams.get("githubStatus"); + const githubUsername = searchParams.get("githubUsername"); + const [banner, setBanner] = useState( + githubStatus === "connected" + ? `GitHub account @${githubUsername || "connected"} linked successfully!` + : "" + ); // Backend redirects here after GitHub connect: /account-center?githubStatus=connected useEffect(() => { - const status = searchParams.get("githubStatus"); - const username = searchParams.get("githubUsername"); - - if (status === "connected") { - setBanner(`GitHub account @${username || "connected"} linked successfully!`); + if (githubStatus === "connected") { // Refresh user profile so the card shows the new GitHub identity getProfile() .then((res) => setUser(res.data)) @@ -345,7 +364,7 @@ export default function AccountCenterPage() { // Clean URL window.history.replaceState({}, "", "/account-center"); } - }, [searchParams]); + }, [githubStatus, setUser]); return (
diff --git a/frontend/src/pages/GitHubIntelligencePage.jsx b/frontend/src/pages/GitHubIntelligencePage.jsx index f013d15..b332315 100644 --- a/frontend/src/pages/GitHubIntelligencePage.jsx +++ b/frontend/src/pages/GitHubIntelligencePage.jsx @@ -153,7 +153,7 @@ export default function GitHubIntelligencePage() { useEffect(() => { getGitHubDashboard() - .then(res => setData(res.data.data)) + .then((res) => setData(res?.data?.data ?? res?.data ?? null)) .catch(err => setError(err.response?.data?.message || err.message)) .finally(() => setLoading(false)); }, []); diff --git a/frontend/src/pages/SignupPage.jsx b/frontend/src/pages/SignupPage.jsx index 9ab95b2..032e1df 100644 --- a/frontend/src/pages/SignupPage.jsx +++ b/frontend/src/pages/SignupPage.jsx @@ -5,6 +5,7 @@ import * as authService from "../services/authService"; const API_BASE = import.meta.env.VITE_API_BASE_URL; + export default function SignupPage() { const [step, setStep] = useState(1); const [name, setName] = useState(""); diff --git a/frontend/src/services/authService.js b/frontend/src/services/authService.js index 2eb577a..20923a2 100644 --- a/frontend/src/services/authService.js +++ b/frontend/src/services/authService.js @@ -29,3 +29,10 @@ export const resendOtp = async (email, purpose) => { const response = await api.post("/auth/resend-otp", { email, purpose }); return response.data; }; + +export const getGithubConnectUrl = async (redirectPath = "/account-center") => { + const response = await api.get("/auth/github/connect-url", { + params: { redirectPath } + }); + return response?.data?.data ?? response?.data; +}; diff --git a/gitubAuthCrationJourney.md b/gitubAuthCrationJourney.md index 2658e95..3c13836 100644 --- a/gitubAuthCrationJourney.md +++ b/gitubAuthCrationJourney.md @@ -82,7 +82,7 @@ Implemented end-to-end logic: Added controller methods: - `startGithubAuth` -- `startGithubConnect` +- `getGithubConnectUrl` - `githubCallback` Behavior: @@ -99,10 +99,10 @@ Behavior: Added endpoints: - `GET /api/auth/github/start` (public login/signup via GitHub) -- `GET /api/auth/github/connect` (protected account-link flow) +- `GET /api/auth/github/connect-url` (protected account-link flow, returns OAuth URL) - `GET /api/auth/github/callback` (GitHub callback) -`/github/connect` is protected using existing `authMiddleware`. +`/github/connect-url` is protected using existing `authMiddleware`. --- @@ -175,7 +175,7 @@ Added GitHub id-based lookup + sparse unique index + explicit connect mode flow. 1. Frontend redirects user to: - `GET /api/auth/github/start` for GitHub signup/login - - `GET /api/auth/github/connect` for linking from signed-in account + - `GET /api/auth/github/connect-url` for linking from signed-in account (frontend then redirects to returned GitHub URL) 2. Backend redirects user to GitHub OAuth consent. 3. GitHub redirects back to: - `GET /api/auth/github/callback?code=...&state=...` diff --git a/server/config/redis.js b/server/config/redis.js new file mode 100644 index 0000000..214dd42 --- /dev/null +++ b/server/config/redis.js @@ -0,0 +1,31 @@ +import { createClient } from "redis"; + +let redisClient; +let connectPromise; + +export const getRedisClient = async () => { + const redisUrl = process.env.REDIS_URL; + + if (!redisUrl) { + throw new Error("REDIS_URL is not configured"); + } + + if (!redisClient) { + redisClient = createClient({ url: redisUrl }); + redisClient.on("error", (error) => { + console.error("Redis client error:", error); + }); + } + + if (!redisClient.isOpen) { + if (!connectPromise) { + connectPromise = redisClient.connect().finally(() => { + connectPromise = null; + }); + } + + await connectPromise; + } + + return redisClient; +}; \ No newline at end of file diff --git a/server/modules/auth/controller.js b/server/modules/auth/controller.js index 9143673..07d481f 100644 --- a/server/modules/auth/controller.js +++ b/server/modules/auth/controller.js @@ -63,10 +63,10 @@ class AuthController { } } - static async startGithubAuth(req, res, next) { + static async startGithubAuth(req, res, next) { try { const { redirectPath } = req.validatedQuery || req.query; - const authUrl = AuthService.getGithubAuthorizationUrl({ + const authUrl = await AuthService.getGithubAuthorizationUrl({ mode: "login", redirectPath }); @@ -77,16 +77,23 @@ class AuthController { } } - static async startGithubConnect(req, res, next) { + static async getGithubConnectUrl(req, res, next) { try { + const authenticatedUserId = req.user?._id; + if (!authenticatedUserId) { + throw new ApiError(401, "Authentication required for GitHub connect"); + } + const { redirectPath } = req.validatedQuery || req.query; - const authUrl = AuthService.getGithubAuthorizationUrl({ + const authUrl = await AuthService.getGithubAuthorizationUrl({ mode: "connect", - userId: req.user?._id, + userId: authenticatedUserId, redirectPath }); - return res.redirect(authUrl); + return res.status(200).json( + ApiResponse.success("GitHub connect URL generated", { authUrl }) + ); } catch (error) { next(error instanceof ApiError ? error : new ApiError(500, error.message)); } diff --git a/server/modules/auth/routes.js b/server/modules/auth/routes.js index 2920816..cbb005e 100644 --- a/server/modules/auth/routes.js +++ b/server/modules/auth/routes.js @@ -7,6 +7,7 @@ import { resetPasswordSchema, resendOtpSchema, githubStartSchema, + githubConnectSchema, githubCallbackSchema, validate, validateQuery @@ -19,6 +20,7 @@ const router = Router(); const githubConnectRateLimit = rateLimit({ windowMs: 60 * 1000, max: 20, + keyGenerator: (req) => String(req.user?._id || req.ip), standardHeaders: true, legacyHeaders: false, message: { @@ -35,11 +37,11 @@ router.post("/reset-password", validate(resetPasswordSchema), AuthController.res router.post("/resend-otp", validate(resendOtpSchema), AuthController.resendOtp); router.get("/github/start", validateQuery(githubStartSchema), AuthController.startGithubAuth); router.get( - "/github/connect", - githubConnectRateLimit, + "/github/connect-url", authMiddleware, - validateQuery(githubStartSchema), - AuthController.startGithubConnect + githubConnectRateLimit, + validateQuery(githubConnectSchema), + AuthController.getGithubConnectUrl ); router.get("/github/callback", validateQuery(githubCallbackSchema), AuthController.githubCallback); diff --git a/server/modules/auth/service.js b/server/modules/auth/service.js index 2e71973..90309d3 100644 --- a/server/modules/auth/service.js +++ b/server/modules/auth/service.js @@ -7,12 +7,24 @@ import AuthRepository from "./repository.js"; import { generateAccessToken } from "../../utils/tokenHelper.js"; import { generateOTP } from "../../utils/otpHelper.js"; import { sendVerificationOTP, sendPasswordResetOTP } from "../../utils/emailService.js"; +import { getRedisClient } from "../../config/redis.js"; const GITHUB_OAUTH_URL = "https://github.com/login/oauth/authorize"; const GITHUB_ACCESS_TOKEN_URL = "https://github.com/login/oauth/access_token"; const GITHUB_USER_URL = "https://api.github.com/user"; const GITHUB_USER_EMAILS_URL = "https://api.github.com/user/emails"; const GITHUB_SCOPE = "read:user user:email"; +const OAUTH_STATE_TTL_SECONDS = 10 * 60; +const GITHUB_OAUTH_STATE_NONCE_PREFIX = "oauth:github:state:"; +const GITHUB_OAUTH_STATE_CONSUME_SCRIPT = ` + local value = redis.call("GET", KEYS[1]) + if not value then + return nil + end + + redis.call("DEL", KEYS[1]) + return value +`; class AuthService { static async register({ name, email, password }) { @@ -233,15 +245,15 @@ class AuthService { }; } - static getGithubAuthorizationUrl({ mode = "login", userId = null, redirectPath }) { + static async getGithubAuthorizationUrl({ mode = "login", userId = null, redirectPath }) { this.#assertGithubOAuthConfig(); const safeMode = mode === "connect" ? "connect" : "login"; - const defaultRedirectPath = safeMode === "connect" ? "/account-center" : "/login"; - const state = this.#buildGithubStateToken({ + const sanitizedRedirectPath = this.#sanitizeRedirectPath(redirectPath, safeMode); + const state = await this.#buildGithubStateToken({ mode: safeMode, userId, - redirectPath: redirectPath || defaultRedirectPath + redirectPath: sanitizedRedirectPath }); const query = new URLSearchParams({ @@ -323,20 +335,95 @@ class AuthService { } } - static #buildGithubStateToken(payload) { + static async #buildGithubStateToken(payload) { const secret = process.env.GITHUB_STATE_SECRET || process.env.JWT_SECRET; - return jwt.sign(payload, secret, { expiresIn: "10m" }); + const nonce = crypto.randomUUID(); + const token = jwt.sign({ ...payload, nonce }, secret, { expiresIn: "10m" }); + + await this.#storeGithubStateNonce(nonce); + return token; } - static #verifyGithubStateToken(token) { + static async #verifyGithubStateToken(token) { try { const secret = process.env.GITHUB_STATE_SECRET || process.env.JWT_SECRET; - return jwt.verify(token, secret); - } catch { + const decoded = jwt.verify(token, secret); + + if (!decoded?.nonce) { + throw new ApiError(400, "Invalid GitHub OAuth state nonce"); + } + + const consumedNonce = await this.#consumeGithubStateNonce(decoded.nonce); + if (!consumedNonce) { + throw new ApiError(400, "GitHub OAuth state was already used"); + } + + return decoded; + } catch (error) { + if (error instanceof ApiError) { + throw error; + } + throw new ApiError(400, "Invalid or expired GitHub OAuth state"); } } + static async #storeGithubStateNonce(nonce) { + try { + const client = await getRedisClient(); + const key = `${GITHUB_OAUTH_STATE_NONCE_PREFIX}${nonce}`; + const result = await client.set(key, "1", { + NX: true, + EX: OAUTH_STATE_TTL_SECONDS + }); + + if (result !== "OK") { + throw new Error("Failed to reserve GitHub OAuth state nonce"); + } + } catch (error) { + if (error instanceof ApiError) { + throw error; + } + + throw new ApiError(503, "Unable to reserve GitHub OAuth state"); + } + } + + static async #consumeGithubStateNonce(nonce) { + try { + const client = await getRedisClient(); + const key = `${GITHUB_OAUTH_STATE_NONCE_PREFIX}${nonce}`; + + if (typeof client.getDel === "function") { + return await client.getDel(key); + } + + return await client.eval(GITHUB_OAUTH_STATE_CONSUME_SCRIPT, { + keys: [key] + }); + } catch (error) { + if (error instanceof ApiError) { + throw error; + } + + throw new ApiError(503, "Unable to verify GitHub OAuth state"); + } + } + + static #sanitizeRedirectPath(path, mode) { + const defaultPath = mode === "connect" ? "/account-center" : "/login"; + if (!path || typeof path !== "string") { + return defaultPath; + } + + const trimmed = path.trim(); + if (!trimmed || !trimmed.startsWith("/") || trimmed.startsWith("//") || trimmed.includes("://")) { + return defaultPath; + } + + return trimmed; + } + static async #exchangeGithubCodeForToken(code) { try { const response = await axios.post( @@ -508,7 +595,7 @@ class AuthService { static #buildFrontendRedirectUrl(path, query = {}, fragment = {}) { const baseUrl = process.env.CLIENT_URL || "http://localhost:5173"; - const redirect = new URL(path || "/login", baseUrl); + const redirect = new URL(this.#sanitizeRedirectPath(path, "login"), baseUrl); Object.entries(query).forEach(([key, value]) => { if (value !== undefined && value !== null && value !== "") { diff --git a/server/modules/auth/validation.js b/server/modules/auth/validation.js index e43e7e4..2b63888 100644 --- a/server/modules/auth/validation.js +++ b/server/modules/auth/validation.js @@ -1,5 +1,15 @@ import { z } from "zod"; +const safeRedirectPathSchema = z + .string() + .trim() + .max(200, "redirectPath is too long") + .regex(/^\/[^\\]*$/, "redirectPath must be a safe relative path") + .refine((value) => !value.startsWith("//"), { + message: "redirectPath cannot start with //" + }) + .optional(); + export const registerSchema = z.object({ name: z.string().min(2, "Name must be at least 2 characters"), email: z.string().email("Invalid email address"), @@ -32,7 +42,11 @@ export const resendOtpSchema = z.object({ }); export const githubStartSchema = z.object({ - redirectPath: z.string().optional() + redirectPath: safeRedirectPathSchema +}); + +export const githubConnectSchema = z.object({ + redirectPath: safeRedirectPathSchema }); export const githubCallbackSchema = z.object({ diff --git a/server/package-lock.json b/server/package-lock.json index f315bd4..6fff900 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -22,6 +22,7 @@ "nodemailer": "^8.0.4", "nodemon": "^3.1.14", "openai": "^6.33.0", + "redis": "^5.12.1", "zod": "^4.3.6" } }, @@ -130,6 +131,78 @@ "integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==", "license": "BSD-3-Clause" }, + "node_modules/@redis/bloom": { + "version": "5.12.1", + "resolved": "https://registry.npmjs.org/@redis/bloom/-/bloom-5.12.1.tgz", + "integrity": "sha512-PUUfv+ms7jgPSBVoo/DN4AkPHj4D5TZSd6SbJX7egzBplkYUcKmHRE8RKia7UtZ8bSQbLguLvxVO+asKtQfZWA==", + "license": "MIT", + "engines": { + "node": ">= 18.19.0" + }, + "peerDependencies": { + "@redis/client": "^5.12.1" + } + }, + "node_modules/@redis/client": { + "version": "5.12.1", + "resolved": "https://registry.npmjs.org/@redis/client/-/client-5.12.1.tgz", + "integrity": "sha512-7aPGWeqA3uFm43o19umzdl16CEjK/JQGtSXVPevplTaOU3VJA/rseBC1QvYUz9lLDIMBimc4SW/zrW4S89BaCA==", + "license": "MIT", + "dependencies": { + "cluster-key-slot": "1.1.2" + }, + "engines": { + "node": ">= 18.19.0" + }, + "peerDependencies": { + "@node-rs/xxhash": "^1.1.0", + "@opentelemetry/api": ">=1 <2" + }, + "peerDependenciesMeta": { + "@node-rs/xxhash": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + } + } + }, + "node_modules/@redis/json": { + "version": "5.12.1", + "resolved": "https://registry.npmjs.org/@redis/json/-/json-5.12.1.tgz", + "integrity": "sha512-eOze75esLve4vfqDel7aMX08CNaiLLQS2fV8mpRN9NxPe1rVR4vQyYiW/OgtGUysF6QOr9ANhfxABKNOJfXdKg==", + "license": "MIT", + "engines": { + "node": ">= 18.19.0" + }, + "peerDependencies": { + "@redis/client": "^5.12.1" + } + }, + "node_modules/@redis/search": { + "version": "5.12.1", + "resolved": "https://registry.npmjs.org/@redis/search/-/search-5.12.1.tgz", + "integrity": "sha512-ItlxbxC9cKI6IU1TLWoczwJCRb6TdmkEpWv05UrPawqaAnWGRu3rcIqsc5vN483T2fSociuyV1UkWIL5I4//2w==", + "license": "MIT", + "engines": { + "node": ">= 18.19.0" + }, + "peerDependencies": { + "@redis/client": "^5.12.1" + } + }, + "node_modules/@redis/time-series": { + "version": "5.12.1", + "resolved": "https://registry.npmjs.org/@redis/time-series/-/time-series-5.12.1.tgz", + "integrity": "sha512-c6JL6E3EcZJuNqKFz+KM+l9l5mpcQiKvTwgA3blt5glWJ8hjDk0yeHN3beE/MpqYIQ8UEX44ItQzgkE/gCBELQ==", + "license": "MIT", + "engines": { + "node": ">= 18.19.0" + }, + "peerDependencies": { + "@redis/client": "^5.12.1" + } + }, "node_modules/@types/node": { "version": "25.5.2", "resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.2.tgz", @@ -396,6 +469,15 @@ "fsevents": "~2.3.2" } }, + "node_modules/cluster-key-slot": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz", + "integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/combined-stream": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", @@ -624,7 +706,6 @@ "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", "license": "MIT", - "peer": true, "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", @@ -1744,6 +1825,22 @@ "node": ">=8.10.0" } }, + "node_modules/redis": { + "version": "5.12.1", + "resolved": "https://registry.npmjs.org/redis/-/redis-5.12.1.tgz", + "integrity": "sha512-LDsoVvb/CpoV9EN3FXvgvSHNJWuCIzl9MiO3ppOevuGLpSGJhwfQjpEwfFJcQvNSddHADDdZaWx0HnmMxRXG7g==", + "license": "MIT", + "dependencies": { + "@redis/bloom": "5.12.1", + "@redis/client": "5.12.1", + "@redis/json": "5.12.1", + "@redis/search": "5.12.1", + "@redis/time-series": "5.12.1" + }, + "engines": { + "node": ">= 18.19.0" + } + }, "node_modules/retry": { "version": "0.13.1", "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", @@ -2127,7 +2224,6 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/server/package.json b/server/package.json index 951eac1..f3f6e8e 100644 --- a/server/package.json +++ b/server/package.json @@ -25,6 +25,7 @@ "nodemailer": "^8.0.4", "nodemon": "^3.1.14", "openai": "^6.33.0", + "redis": "^5.12.1", "zod": "^4.3.6" } }