From cccb3123ed1a29091578f5ed69872b01106393ba Mon Sep 17 00:00:00 2001 From: Vectry Date: Tue, 10 Feb 2026 17:03:48 +0000 Subject: [PATCH] =?UTF-8?q?security:=20P1/P2=20hardening=20=E2=80=94=20rat?= =?UTF-8?q?e=20limiting,=20CORS,=20Redis=20auth,=20network=20isolation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add Redis-based sliding window rate limiting on login, register, forgot-password, reset-password - Fix user enumeration: register returns generic 200 for both new and existing emails - Add Redis authentication (requirepass) and password in .env - Docker network isolation: postgres/redis on internal-only network - Whitelist Stripe redirect origins (prevent open redirect) - Add 10MB request size limit on trace ingestion - Limit API keys to 10 per user - Add CORS headers via middleware (whitelist agentlens.vectry.tech + localhost) - Reduce JWT max age from 30 days to 7 days --- .env.example | 3 + apps/web/package.json | 1 + apps/web/src/app/(auth)/register/page.tsx | 10 ++- .../src/app/api/auth/forgot-password/route.ts | 10 +++ apps/web/src/app/api/auth/register/route.ts | 22 +++-- .../src/app/api/auth/reset-password/route.ts | 10 +++ apps/web/src/app/api/keys/route.ts | 11 +++ apps/web/src/app/api/stripe/checkout/route.ts | 10 ++- apps/web/src/app/api/stripe/portal/route.ts | 10 ++- apps/web/src/app/api/traces/route.ts | 6 ++ apps/web/src/auth.config.ts | 2 +- apps/web/src/auth.ts | 9 +- apps/web/src/lib/rate-limit.ts | 52 +++++++++++ apps/web/src/lib/redis.ts | 14 +++ apps/web/src/middleware.ts | 67 ++++++++++---- docker-compose.yml | 22 ++++- package-lock.json | 90 ++++++++++++++++++- 17 files changed, 315 insertions(+), 34 deletions(-) create mode 100644 apps/web/src/lib/rate-limit.ts create mode 100644 apps/web/src/lib/redis.ts diff --git a/.env.example b/.env.example index 22b2a81..2e91926 100644 --- a/.env.example +++ b/.env.example @@ -12,5 +12,8 @@ POSTGRES_USER=agentlens POSTGRES_PASSWORD= POSTGRES_DB=agentlens +# Redis +REDIS_PASSWORD= # Generate with: openssl rand -base64 24 + # Email (optional — email features disabled if not set) EMAIL_PASSWORD= diff --git a/apps/web/package.json b/apps/web/package.json index 8b66320..01b5512 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -16,6 +16,7 @@ "@dagrejs/dagre": "^2.0.4", "@xyflow/react": "^12.10.0", "bcryptjs": "^3.0.3", + "ioredis": "^5.9.2", "lucide-react": "^0.469.0", "next": "^15.1.0", "next-auth": "^5.0.0-beta.30", diff --git a/apps/web/src/app/(auth)/register/page.tsx b/apps/web/src/app/(auth)/register/page.tsx index 85177c2..1fe567f 100644 --- a/apps/web/src/app/(auth)/register/page.tsx +++ b/apps/web/src/app/(auth)/register/page.tsx @@ -44,6 +44,13 @@ export default function RegisterPage() { }), }); + if (res.status === 429) { + const data: { error?: string } = await res.json(); + setError(data.error ?? "Too many attempts. Please try again later."); + setLoading(false); + return; + } + if (!res.ok) { const data: { error?: string } = await res.json(); setError(data.error ?? "Registration failed"); @@ -58,8 +65,7 @@ export default function RegisterPage() { }); if (result?.error) { - setError("Account created but sign-in failed. Please log in manually."); - setLoading(false); + router.push("/login"); return; } diff --git a/apps/web/src/app/api/auth/forgot-password/route.ts b/apps/web/src/app/api/auth/forgot-password/route.ts index d342784..3499fb2 100644 --- a/apps/web/src/app/api/auth/forgot-password/route.ts +++ b/apps/web/src/app/api/auth/forgot-password/route.ts @@ -3,6 +3,7 @@ import { randomBytes, createHash } from "crypto"; import { z } from "zod"; import nodemailer from "nodemailer"; import { prisma } from "@/lib/prisma"; +import { checkRateLimit, AUTH_RATE_LIMITS } from "@/lib/rate-limit"; const forgotPasswordSchema = z.object({ email: z.email("Invalid email address"), @@ -24,6 +25,15 @@ function hashToken(token: string): string { export async function POST(request: Request) { try { + const ip = request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? "unknown"; + const rl = await checkRateLimit(`forgot:${ip}`, AUTH_RATE_LIMITS.forgotPassword); + if (!rl.allowed) { + return NextResponse.json( + { error: "Too many requests. Please try again later." }, + { status: 429, headers: { "Retry-After": String(Math.ceil((rl.resetAt - Date.now()) / 1000)) } } + ); + } + const body: unknown = await request.json(); const parsed = forgotPasswordSchema.safeParse(body); diff --git a/apps/web/src/app/api/auth/register/route.ts b/apps/web/src/app/api/auth/register/route.ts index 68314b3..b86e9ed 100644 --- a/apps/web/src/app/api/auth/register/route.ts +++ b/apps/web/src/app/api/auth/register/route.ts @@ -4,6 +4,7 @@ import crypto from "crypto"; import { z } from "zod"; import { prisma } from "@/lib/prisma"; import { sendEmail } from "@/lib/email"; +import { checkRateLimit, AUTH_RATE_LIMITS } from "@/lib/rate-limit"; const registerSchema = z.object({ email: z.email("Invalid email address"), @@ -13,6 +14,15 @@ const registerSchema = z.object({ export async function POST(request: Request) { try { + const ip = request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? "unknown"; + const rl = await checkRateLimit(`register:${ip}`, AUTH_RATE_LIMITS.register); + if (!rl.allowed) { + return NextResponse.json( + { error: "Too many registration attempts. Please try again later." }, + { status: 429, headers: { "Retry-After": String(Math.ceil((rl.resetAt - Date.now()) / 1000)) } } + ); + } + const body: unknown = await request.json(); const parsed = registerSchema.safeParse(body); @@ -32,8 +42,8 @@ export async function POST(request: Request) { if (existing) { return NextResponse.json( - { error: "An account with this email already exists" }, - { status: 409 } + { message: "If this email is available, a confirmation email will be sent." }, + { status: 200 } ); } @@ -59,7 +69,6 @@ export async function POST(request: Request) { }, }); - // Send verification email (non-blocking — don't fail registration on email errors) try { const rawToken = crypto.randomBytes(32).toString("hex"); const tokenHash = crypto @@ -71,7 +80,7 @@ export async function POST(request: Request) { data: { userId: user.id, token: tokenHash, - expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000), // 24 hours + expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000), }, }); @@ -98,7 +107,10 @@ export async function POST(request: Request) { console.error("[register] Failed to send verification email:", emailError); } - return NextResponse.json(user, { status: 201 }); + return NextResponse.json( + { message: "If this email is available, a confirmation email will be sent." }, + { status: 200 } + ); } catch { return NextResponse.json( { error: "Internal server error" }, diff --git a/apps/web/src/app/api/auth/reset-password/route.ts b/apps/web/src/app/api/auth/reset-password/route.ts index a33a94a..ca9bc5a 100644 --- a/apps/web/src/app/api/auth/reset-password/route.ts +++ b/apps/web/src/app/api/auth/reset-password/route.ts @@ -3,6 +3,7 @@ import { createHash } from "crypto"; import { hash } from "bcryptjs"; import { z } from "zod"; import { prisma } from "@/lib/prisma"; +import { checkRateLimit, AUTH_RATE_LIMITS } from "@/lib/rate-limit"; const resetPasswordSchema = z.object({ token: z.string().min(1, "Token is required"), @@ -15,6 +16,15 @@ function hashToken(token: string): string { export async function POST(request: Request) { try { + const ip = request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? "unknown"; + const rl = await checkRateLimit(`reset:${ip}`, AUTH_RATE_LIMITS.resetPassword); + if (!rl.allowed) { + return NextResponse.json( + { error: "Too many attempts. Please try again later." }, + { status: 429, headers: { "Retry-After": String(Math.ceil((rl.resetAt - Date.now()) / 1000)) } } + ); + } + const body: unknown = await request.json(); const parsed = resetPasswordSchema.safeParse(body); diff --git a/apps/web/src/app/api/keys/route.ts b/apps/web/src/app/api/keys/route.ts index 3bb3bb7..e8128f9 100644 --- a/apps/web/src/app/api/keys/route.ts +++ b/apps/web/src/app/api/keys/route.ts @@ -37,6 +37,17 @@ export async function POST(request: Request) { if (!session?.user?.id) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + const MAX_KEYS_PER_USER = 10; + const keyCount = await prisma.apiKey.count({ + where: { userId: session.user.id, revoked: false }, + }); + if (keyCount >= MAX_KEYS_PER_USER) { + return NextResponse.json( + { error: `Maximum of ${MAX_KEYS_PER_USER} API keys allowed. Revoke an existing key first.` }, + { status: 400 } + ); + } + const body = await request.json().catch(() => ({})); const name = typeof body.name === "string" && body.name.trim() diff --git a/apps/web/src/app/api/stripe/checkout/route.ts b/apps/web/src/app/api/stripe/checkout/route.ts index 4182db8..d856228 100644 --- a/apps/web/src/app/api/stripe/checkout/route.ts +++ b/apps/web/src/app/api/stripe/checkout/route.ts @@ -72,8 +72,14 @@ export async function POST(request: Request) { } } - const origin = - request.headers.get("origin") ?? "https://agentlens.vectry.tech"; + const ALLOWED_ORIGINS = [ + "https://agentlens.vectry.tech", + "http://localhost:3000", + ]; + const requestOrigin = request.headers.get("origin"); + const origin = ALLOWED_ORIGINS.includes(requestOrigin ?? "") + ? requestOrigin! + : "https://agentlens.vectry.tech"; const checkoutSession = await getStripe().checkout.sessions.create({ customer: stripeCustomerId, diff --git a/apps/web/src/app/api/stripe/portal/route.ts b/apps/web/src/app/api/stripe/portal/route.ts index 5bd2ff9..9f1515b 100644 --- a/apps/web/src/app/api/stripe/portal/route.ts +++ b/apps/web/src/app/api/stripe/portal/route.ts @@ -22,8 +22,14 @@ export async function POST(request: Request) { ); } - const origin = - request.headers.get("origin") ?? "https://agentlens.vectry.tech"; + const ALLOWED_ORIGINS = [ + "https://agentlens.vectry.tech", + "http://localhost:3000", + ]; + const requestOrigin = request.headers.get("origin"); + const origin = ALLOWED_ORIGINS.includes(requestOrigin ?? "") + ? requestOrigin! + : "https://agentlens.vectry.tech"; const portalSession = await getStripe().billingPortal.sessions.create({ customer: subscription.stripeCustomerId, diff --git a/apps/web/src/app/api/traces/route.ts b/apps/web/src/app/api/traces/route.ts index 1b2d218..24ed434 100644 --- a/apps/web/src/app/api/traces/route.ts +++ b/apps/web/src/app/api/traces/route.ts @@ -92,6 +92,12 @@ export async function POST(request: NextRequest) { return NextResponse.json({ error: "Missing or invalid Authorization header" }, { status: 401 }); } + const contentLength = parseInt(request.headers.get("content-length") ?? "0", 10); + const MAX_BODY_SIZE = 10 * 1024 * 1024; + if (contentLength > MAX_BODY_SIZE) { + return NextResponse.json({ error: "Request body too large (max 10MB)" }, { status: 413 }); + } + const rawApiKey = authHeader.slice(7); if (!rawApiKey) { return NextResponse.json({ error: "Missing API key in Authorization header" }, { status: 401 }); diff --git a/apps/web/src/auth.config.ts b/apps/web/src/auth.config.ts index fba0065..d019828 100644 --- a/apps/web/src/auth.config.ts +++ b/apps/web/src/auth.config.ts @@ -2,7 +2,7 @@ import type { NextAuthConfig } from "next-auth"; export default { providers: [], - session: { strategy: "jwt" }, + session: { strategy: "jwt", maxAge: 7 * 24 * 60 * 60 }, pages: { signIn: "/login", }, diff --git a/apps/web/src/auth.ts b/apps/web/src/auth.ts index 5469aec..f11a04d 100644 --- a/apps/web/src/auth.ts +++ b/apps/web/src/auth.ts @@ -3,6 +3,7 @@ import Credentials from "next-auth/providers/credentials"; import { compare } from "bcryptjs"; import { z } from "zod"; import { prisma } from "@/lib/prisma"; +import { checkRateLimit, AUTH_RATE_LIMITS } from "@/lib/rate-limit"; import authConfig from "./auth.config"; declare module "next-auth" { @@ -37,11 +38,17 @@ export const { handlers, auth, signIn, signOut } = NextAuth({ email: { label: "Email", type: "email" }, password: { label: "Password", type: "password" }, }, - async authorize(credentials) { + async authorize(credentials, request) { const parsed = loginSchema.safeParse(credentials); if (!parsed.success) return null; const { email, password } = parsed.data; + const ip = (request instanceof Request + ? request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() + : undefined) ?? "unknown"; + + const rl = await checkRateLimit(`login:${ip}`, AUTH_RATE_LIMITS.login); + if (!rl.allowed) return null; const user = await prisma.user.findUnique({ where: { email: email.toLowerCase() }, diff --git a/apps/web/src/lib/rate-limit.ts b/apps/web/src/lib/rate-limit.ts new file mode 100644 index 0000000..438e553 --- /dev/null +++ b/apps/web/src/lib/rate-limit.ts @@ -0,0 +1,52 @@ +import { redis } from "./redis"; + +interface RateLimitConfig { + windowMs: number; + maxAttempts: number; +} + +interface RateLimitResult { + allowed: boolean; + remaining: number; + resetAt: number; +} + +export async function checkRateLimit( + key: string, + config: RateLimitConfig +): Promise { + const now = Date.now(); + const windowStart = now - config.windowMs; + const redisKey = `rl:${key}`; + + try { + await redis.zremrangebyscore(redisKey, 0, windowStart); + const count = await redis.zcard(redisKey); + + if (count >= config.maxAttempts) { + const oldestEntry = await redis.zrange(redisKey, 0, 0, "WITHSCORES"); + const resetAt = oldestEntry.length >= 2 + ? parseInt(oldestEntry[1], 10) + config.windowMs + : now + config.windowMs; + return { allowed: false, remaining: 0, resetAt }; + } + + await redis.zadd(redisKey, now, `${now}:${Math.random()}`); + await redis.pexpire(redisKey, config.windowMs); + + return { + allowed: true, + remaining: config.maxAttempts - count - 1, + resetAt: now + config.windowMs, + }; + } catch { + return { allowed: true, remaining: config.maxAttempts, resetAt: now + config.windowMs }; + } +} + +export const AUTH_RATE_LIMITS = { + login: { windowMs: 15 * 60 * 1000, maxAttempts: 10 }, + register: { windowMs: 60 * 60 * 1000, maxAttempts: 5 }, + forgotPassword: { windowMs: 60 * 60 * 1000, maxAttempts: 5 }, + resetPassword: { windowMs: 15 * 60 * 1000, maxAttempts: 5 }, +} as const; diff --git a/apps/web/src/lib/redis.ts b/apps/web/src/lib/redis.ts new file mode 100644 index 0000000..4906a3d --- /dev/null +++ b/apps/web/src/lib/redis.ts @@ -0,0 +1,14 @@ +import Redis from "ioredis"; + +const globalForRedis = globalThis as unknown as { redis?: Redis }; + +export const redis = + globalForRedis.redis ?? + new Redis(process.env.REDIS_URL ?? "redis://localhost:6379", { + maxRetriesPerRequest: 3, + lazyConnect: true, + }); + +if (process.env.NODE_ENV !== "production") { + globalForRedis.redis = redis; +} diff --git a/apps/web/src/middleware.ts b/apps/web/src/middleware.ts index b76baa0..7ec0502 100644 --- a/apps/web/src/middleware.ts +++ b/apps/web/src/middleware.ts @@ -10,6 +10,10 @@ const publicPaths = [ "/api/auth", "/api/traces", "/api/health", + "/api/stripe/webhook", + "/forgot-password", + "/reset-password", + "/verify-email", ]; function isPublicPath(pathname: string): boolean { @@ -18,31 +22,64 @@ function isPublicPath(pathname: string): boolean { ); } +const ALLOWED_ORIGINS = new Set([ + "https://agentlens.vectry.tech", + "http://localhost:3000", +]); + +function corsHeaders(origin: string | null): Record { + const allowedOrigin = origin && ALLOWED_ORIGINS.has(origin) + ? origin + : "https://agentlens.vectry.tech"; + return { + "Access-Control-Allow-Origin": allowedOrigin, + "Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS", + "Access-Control-Allow-Headers": "Content-Type, Authorization", + "Access-Control-Max-Age": "86400", + }; +} + export default auth((req) => { const { pathname } = req.nextUrl; const isLoggedIn = !!req.auth; + const origin = req.headers.get("origin"); - if (isPublicPath(pathname)) { - if (isLoggedIn && (pathname === "/login" || pathname === "/register")) { - return NextResponse.redirect(new URL("/dashboard", req.nextUrl.origin)); + if (req.method === "OPTIONS") { + return new NextResponse(null, { status: 204, headers: corsHeaders(origin) }); + } + + const response = (() => { + if (isPublicPath(pathname)) { + if (isLoggedIn && (pathname === "/login" || pathname === "/register")) { + return NextResponse.redirect(new URL("/dashboard", req.nextUrl.origin)); + } + return NextResponse.next(); } - return NextResponse.next(); - } - if (pathname === "/login" || pathname === "/register") { - if (isLoggedIn) { - return NextResponse.redirect(new URL("/dashboard", req.nextUrl.origin)); + if (pathname === "/login" || pathname === "/register") { + if (isLoggedIn) { + return NextResponse.redirect(new URL("/dashboard", req.nextUrl.origin)); + } + return NextResponse.next(); } + + if (pathname.startsWith("/dashboard") && !isLoggedIn) { + const loginUrl = new URL("/login", req.nextUrl.origin); + loginUrl.searchParams.set("callbackUrl", pathname); + return NextResponse.redirect(loginUrl); + } + return NextResponse.next(); + })(); + + if (pathname.startsWith("/api/")) { + const headers = corsHeaders(origin); + for (const [key, value] of Object.entries(headers)) { + response.headers.set(key, value); + } } - if (pathname.startsWith("/dashboard") && !isLoggedIn) { - const loginUrl = new URL("/login", req.nextUrl.origin); - loginUrl.searchParams.set("callbackUrl", pathname); - return NextResponse.redirect(loginUrl); - } - - return NextResponse.next(); + return response; }); export const config = { diff --git a/docker-compose.yml b/docker-compose.yml index eb16b44..70ecc34 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -7,7 +7,7 @@ services: - "4200:3000" environment: - NODE_ENV=production - - REDIS_URL=redis://redis:6379 + - REDIS_URL=redis://:${REDIS_PASSWORD}@redis:6379 - DATABASE_URL=postgresql://${POSTGRES_USER:-agentlens}:${POSTGRES_PASSWORD:-agentlens}@postgres:5432/${POSTGRES_DB:-agentlens} - AUTH_SECRET=${AUTH_SECRET} - AUTH_TRUST_HOST=true @@ -18,11 +18,14 @@ services: - EMAIL_PASSWORD=${EMAIL_PASSWORD:-} depends_on: redis: - condition: service_started + condition: service_healthy postgres: condition: service_healthy migrate: condition: service_completed_successfully + networks: + - frontend + - backend healthcheck: test: ["CMD", "wget", "--spider", "--quiet", "http://127.0.0.1:3000/api/health"] interval: 30s @@ -50,6 +53,8 @@ services: - POSTGRES_DB=${POSTGRES_DB:-agentlens} volumes: - agentlens_postgres_data:/var/lib/postgresql/data + networks: + - backend healthcheck: test: ["CMD-SHELL", "pg_isready -U agentlens"] interval: 10s @@ -76,15 +81,19 @@ services: depends_on: postgres: condition: service_healthy + networks: + - backend restart: "no" redis: image: redis:7-alpine - command: redis-server --maxmemory 64mb --maxmemory-policy allkeys-lru + command: redis-server --maxmemory 64mb --maxmemory-policy allkeys-lru --requirepass ${REDIS_PASSWORD} volumes: - agentlens_redis_data:/data + networks: + - backend healthcheck: - test: ["CMD", "redis-cli", "ping"] + test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD}", "ping"] interval: 10s timeout: 5s retries: 3 @@ -99,6 +108,11 @@ services: max-file: "3" restart: always +networks: + frontend: + backend: + internal: true + volumes: agentlens_postgres_data: agentlens_redis_data: diff --git a/package-lock.json b/package-lock.json index ca4eabf..f21ca3a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -25,6 +25,7 @@ "@dagrejs/dagre": "^2.0.4", "@xyflow/react": "^12.10.0", "bcryptjs": "^3.0.3", + "ioredis": "^5.9.2", "lucide-react": "^0.469.0", "next": "^15.1.0", "next-auth": "^5.0.0-beta.30", @@ -1040,6 +1041,12 @@ "url": "https://opencollective.com/libvips" } }, + "node_modules/@ioredis/commands": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.5.0.tgz", + "integrity": "sha512-eUgLqrMf8nJkZxT24JvVRrQya1vZkQh8BBeYNwGDqa5I0VUi8ACx7uFvAaLxintokpTenkK6DASvo/bvNbBGow==", + "license": "MIT" + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -2388,6 +2395,15 @@ "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==", "license": "MIT" }, + "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/comma-separated-tokens": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", @@ -2541,7 +2557,6 @@ "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -2572,6 +2587,15 @@ "devOptional": true, "license": "MIT" }, + "node_modules/denque": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", + "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10" + } + }, "node_modules/dequal": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", @@ -2847,6 +2871,30 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/ioredis": { + "version": "5.9.2", + "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.9.2.tgz", + "integrity": "sha512-tAAg/72/VxOUW7RQSX1pIxJVucYKcjFjfvj60L57jrZpYCHC3XN0WCQ3sNYL4Gmvv+7GPvTAjc+KSdeNuE8oWQ==", + "license": "MIT", + "dependencies": { + "@ioredis/commands": "1.5.0", + "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" + }, + "engines": { + "node": ">=12.22.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/ioredis" + } + }, "node_modules/jiti": { "version": "2.6.1", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", @@ -3167,6 +3215,18 @@ "node": "^12.20.0 || ^14.13.1 || >=16.0.0" } }, + "node_modules/lodash.defaults": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz", + "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==", + "license": "MIT" + }, + "node_modules/lodash.isarguments": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz", + "integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==", + "license": "MIT" + }, "node_modules/lucide-react": { "version": "0.469.0", "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.469.0.tgz", @@ -3332,7 +3392,6 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, "license": "MIT" }, "node_modules/mz": { @@ -3805,6 +3864,27 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/redis-errors": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz", + "integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/redis-parser": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz", + "integrity": "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==", + "license": "MIT", + "dependencies": { + "redis-errors": "^1.0.0" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/regex": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/regex/-/regex-6.1.0.tgz", @@ -3993,6 +4073,12 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/standard-as-callback": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz", + "integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==", + "license": "MIT" + }, "node_modules/stringify-entities": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz",