From 53a33c68fcb33bc40d365e2d5088834b58a38e9d Mon Sep 17 00:00:00 2001 From: Henry <30578846+hjball@users.noreply.github.com> Date: Sun, 25 Jan 2026 22:28:41 +0000 Subject: [PATCH] feat: rate limit api routes (#336) * feat: install rate-limiter-flexible and ioredis * feat: setup redis client * feat: add withRateLimit wrapper * feat: wrap trpc endpoint in withRateLimit * feat: wrap withRateLimit on remaining routes * feat: exclude webhook route from rate limiting * chore: update compose files and readme * refactor: move into api/db packages --- .env.example | 4 + README.md | 1 + apps/web/src/env.ts | 1 + apps/web/src/pages/api/auth/[...all].ts | 10 +- .../web/src/pages/api/download/attatchment.ts | 12 ++- apps/web/src/pages/api/oss-friends.ts | 12 ++- .../api/stripe/create_billing_session.ts | 11 ++- .../api/stripe/create_checkout_session.ts | 11 ++- apps/web/src/pages/api/trello/authenticate.ts | 11 ++- apps/web/src/pages/api/trpc/[trpc].ts | 23 +++-- apps/web/src/pages/api/unsubscribe.ts | 11 ++- apps/web/src/pages/api/upload/image.ts | 11 ++- apps/web/src/pages/api/v1/[...trpc].ts | 39 ++++---- apps/web/src/pages/api/v1/openapi.json.ts | 12 ++- cloud/docker-compose.yml | 1 + docker-compose.yml | 3 + packages/api/package.json | 5 + packages/api/src/utils/rateLimit.ts | 99 +++++++++++++++++++ packages/db/package.json | 5 + packages/db/src/redis.ts | 31 ++++++ pnpm-lock.yaml | 75 ++++++++++++++ turbo.json | 3 +- 22 files changed, 322 insertions(+), 69 deletions(-) create mode 100644 packages/api/src/utils/rateLimit.ts create mode 100644 packages/db/src/redis.ts diff --git a/.env.example b/.env.example index 17659420..da315b3b 100644 --- a/.env.example +++ b/.env.example @@ -44,6 +44,10 @@ NEXT_API_BODY_SIZE_LIMIT= # e.g. 50mb (defaults to 1mb) TRELLO_APP_API_KEY= TRELLO_APP_SECRET= +# Redis (optional - for rate limiting) +# If not provided, rate limiting will use in-memory storage +REDIS_URL= # e.g. redis://default:your_password@your_host:6379 + # OAuth providers (optional) BETTER_AUTH_TRUSTED_ORIGINS= # Optional: Restrict OIDC/Social sign-ins to specific email domains (comma-separated) diff --git a/README.md b/README.md index 139f21c9..ba935725 100644 --- a/README.md +++ b/README.md @@ -141,6 +141,7 @@ pnpm dev | Variable | Description | Required | Example | | ----------------------------------------- | --------------------------------------------------------- | ------------------------------------- | ----------------------------------------------------------- | | `POSTGRES_URL` | PostgreSQL connection URL | To use external database | `postgres://user:pass@localhost:5432/db` | +| `REDIS_URL` | Redis connection URL | For rate limiting (optional) | `redis://localhost:6379` or `redis://redis:6379` (Docker) | | `EMAIL_FROM` | Sender email address | For Email | `"Kan "` | | `SMTP_HOST` | SMTP server hostname | For Email | `smtp.resend.com` | | `SMTP_PORT` | SMTP server port | For Email | `465` | diff --git a/apps/web/src/env.ts b/apps/web/src/env.ts index d8848e8f..d5465f52 100644 --- a/apps/web/src/env.ts +++ b/apps/web/src/env.ts @@ -78,6 +78,7 @@ export const env = createEnv({ S3_ENDPOINT: z.string().optional(), S3_FORCE_PATH_STYLE: z.string().optional(), EMAIL_FROM: z.string().optional(), + REDIS_URL: z.string().url().optional(), }, /** diff --git a/apps/web/src/pages/api/auth/[...all].ts b/apps/web/src/pages/api/auth/[...all].ts index f81033aa..7b4ecb52 100644 --- a/apps/web/src/pages/api/auth/[...all].ts +++ b/apps/web/src/pages/api/auth/[...all].ts @@ -2,9 +2,17 @@ import { toNodeHandler } from "better-auth/node"; import { initAuth } from "@kan/auth/server"; import { createDrizzleClient } from "@kan/db/client"; +import { withRateLimit } from "@kan/api/utils/rateLimit"; export const config = { api: { bodyParser: false } }; export const auth = initAuth(createDrizzleClient()); -export default toNodeHandler(auth.handler); +const authHandler = toNodeHandler(auth.handler); + +export default withRateLimit( + { points: 100, duration: 60 }, + async (req, res) => { + return await authHandler(req, res); + }, +); diff --git a/apps/web/src/pages/api/download/attatchment.ts b/apps/web/src/pages/api/download/attatchment.ts index d174565e..b697fb2d 100644 --- a/apps/web/src/pages/api/download/attatchment.ts +++ b/apps/web/src/pages/api/download/attatchment.ts @@ -1,9 +1,10 @@ import type { NextApiRequest, NextApiResponse } from "next"; -export default async function handler( - req: NextApiRequest, - res: NextApiResponse, -) { +import { withRateLimit } from "@kan/api/utils/rateLimit"; + +export default withRateLimit( + { points: 100, duration: 60 }, + async (req: NextApiRequest, res: NextApiResponse) => { if (req.method !== "GET") { return res.status(405).json({ message: "Method not allowed" }); } @@ -44,4 +45,5 @@ export default async function handler( console.error("Error downloading attachment:", error); return res.status(500).json({ message: "Failed to download attachment" }); } -} + }, +); diff --git a/apps/web/src/pages/api/oss-friends.ts b/apps/web/src/pages/api/oss-friends.ts index 7ad34ea4..5a61f545 100644 --- a/apps/web/src/pages/api/oss-friends.ts +++ b/apps/web/src/pages/api/oss-friends.ts @@ -1,9 +1,10 @@ import type { NextApiRequest, NextApiResponse } from "next"; -export default async function handler( - req: NextApiRequest, - res: NextApiResponse, -) { +import { withRateLimit } from "@kan/api/utils/rateLimit"; + +export default withRateLimit( + { points: 100, duration: 60 }, + async (req: NextApiRequest, res: NextApiResponse) => { if (req.method !== "GET") { return res.status(405).json({ message: "Method not allowed" }); } @@ -20,4 +21,5 @@ export default async function handler( console.error("Error fetching OSS friends:", error); return res.status(500).json({ message: "Failed to fetch OSS friends" }); } -} + }, +); diff --git a/apps/web/src/pages/api/stripe/create_billing_session.ts b/apps/web/src/pages/api/stripe/create_billing_session.ts index 3738703b..e6363506 100644 --- a/apps/web/src/pages/api/stripe/create_billing_session.ts +++ b/apps/web/src/pages/api/stripe/create_billing_session.ts @@ -3,11 +3,11 @@ import { env } from "next-runtime-env"; import { createNextApiContext } from "@kan/api/trpc"; import { createStripeClient } from "@kan/stripe"; +import { withRateLimit } from "@kan/api/utils/rateLimit"; -export default async function handler( - req: NextApiRequest, - res: NextApiResponse, -) { +export default withRateLimit( + { points: 100, duration: 60 }, + async (req: NextApiRequest, res: NextApiResponse) => { const stripe = createStripeClient(); if (req.method !== "POST") { @@ -31,4 +31,5 @@ export default async function handler( console.error("Error:", error); return res.status(500).json({ error: "Error creating portal session" }); } -} + }, +); diff --git a/apps/web/src/pages/api/stripe/create_checkout_session.ts b/apps/web/src/pages/api/stripe/create_checkout_session.ts index 79606154..78cac3ee 100644 --- a/apps/web/src/pages/api/stripe/create_checkout_session.ts +++ b/apps/web/src/pages/api/stripe/create_checkout_session.ts @@ -6,6 +6,7 @@ import { createNextApiContext } from "@kan/api/trpc"; import * as subscriptionRepo from "@kan/db/repository/subscription.repo"; import * as workspaceRepo from "@kan/db/repository/workspace.repo"; import { createStripeClient } from "@kan/stripe"; +import { withRateLimit } from "@kan/api/utils/rateLimit"; const workspaceSlugSchema = z .string() @@ -21,10 +22,9 @@ interface CheckoutSessionRequest { stripeCustomerId: string; } -export default async function handler( - req: NextApiRequest, - res: NextApiResponse, -) { +export default withRateLimit( + { points: 100, duration: 60 }, + async (req: NextApiRequest, res: NextApiResponse) => { const stripe = createStripeClient(); if (req.method !== "POST") { @@ -115,4 +115,5 @@ export default async function handler( console.error("Error:", error); return res.status(500).json({ error: "Error creating checkout session" }); } -} + }, +); diff --git a/apps/web/src/pages/api/trello/authenticate.ts b/apps/web/src/pages/api/trello/authenticate.ts index 50cbb9b9..048efdbf 100644 --- a/apps/web/src/pages/api/trello/authenticate.ts +++ b/apps/web/src/pages/api/trello/authenticate.ts @@ -3,11 +3,11 @@ import type { NextApiRequest, NextApiResponse } from "next"; import { createNextApiContext } from "@kan/api/trpc"; import { integrations } from "@kan/db/schema"; import { addYears } from "date-fns"; +import { withRateLimit } from "@kan/api/utils/rateLimit"; -export default async function handler( - req: NextApiRequest, - res: NextApiResponse, -) { +export default withRateLimit( + { points: 100, duration: 60 }, + async (req: NextApiRequest, res: NextApiResponse) => { if (req.method !== "POST") { return res.status(405).json({ message: "Method not allowed" }); } @@ -48,4 +48,5 @@ export default async function handler( console.error("Trello authentication error:", err); return res.status(400).json({ message: "Trello authentication failed" }); } -} \ No newline at end of file + }, +); \ No newline at end of file diff --git a/apps/web/src/pages/api/trpc/[trpc].ts b/apps/web/src/pages/api/trpc/[trpc].ts index 8403e9cb..de4b9b59 100644 --- a/apps/web/src/pages/api/trpc/[trpc].ts +++ b/apps/web/src/pages/api/trpc/[trpc].ts @@ -3,12 +3,14 @@ import { createNextApiHandler } from "@trpc/server/adapters/next"; import { appRouter } from "@kan/api/root"; import { createTRPCContext } from "@kan/api/trpc"; +import { env } from "~/env"; +import { withRateLimit } from "@kan/api/utils/rateLimit"; const nextApiHandler = createNextApiHandler({ router: appRouter, createContext: createTRPCContext, onError: - process.env.NODE_ENV === "development" + env.NODE_ENV === "development" ? ({ path, error }) => { console.error( `❌ tRPC failed on ${path ?? ""}: ${error.message}`, @@ -17,11 +19,16 @@ const nextApiHandler = createNextApiHandler({ : undefined, }); -export default function handler(req: NextApiRequest, res: NextApiResponse) { - if (req.method === "OPTIONS") { - res.writeHead(200); - return res.end(); - } +export default withRateLimit( + { points: 100, duration: 60 }, + async (req: NextApiRequest, res: NextApiResponse) => { + if (req.method === "OPTIONS") { + res.writeHead(200); + res.end(); + return; + } - return nextApiHandler(req, res); -} + const result = await nextApiHandler(req, res); + return result; + }, +); diff --git a/apps/web/src/pages/api/unsubscribe.ts b/apps/web/src/pages/api/unsubscribe.ts index 1631266c..a4a4430e 100644 --- a/apps/web/src/pages/api/unsubscribe.ts +++ b/apps/web/src/pages/api/unsubscribe.ts @@ -4,6 +4,7 @@ import { jwtVerify } from "jose"; import { z } from "zod"; import { env } from "~/env"; +import { withRateLimit } from "@kan/api/utils/rateLimit"; const requestSchema = z.object({ token: z.string().min(1), @@ -19,10 +20,9 @@ type ResponseData = const textEncoder = new TextEncoder(); -export default async function handler( - req: NextApiRequest, - res: NextApiResponse, -) { +export default withRateLimit( + { points: 100, duration: 60 }, + async (req: NextApiRequest, res: NextApiResponse) => { if (process.env.NEXT_PUBLIC_KAN_ENV !== "cloud") { return res.status(404).json({ success: false, @@ -101,4 +101,5 @@ export default async function handler( } return res.status(200).json({ success: true }); -} + }, +); diff --git a/apps/web/src/pages/api/upload/image.ts b/apps/web/src/pages/api/upload/image.ts index e250bbd6..1ead915c 100644 --- a/apps/web/src/pages/api/upload/image.ts +++ b/apps/web/src/pages/api/upload/image.ts @@ -6,13 +6,13 @@ import { env as nextRuntimeEnv } from "next-runtime-env"; import { createNextApiContext } from "@kan/api/trpc"; import { env } from "~/env"; +import { withRateLimit } from "@kan/api/utils/rateLimit"; const allowedContentTypes = ["image/jpeg", "image/png"]; -export default async function handler( - req: NextApiRequest, - res: NextApiResponse, -) { +export default withRateLimit( + { points: 100, duration: 60 }, + async (req: NextApiRequest, res: NextApiResponse) => { if (req.method !== "POST") { return res.status(405).json({ error: "Method not allowed" }); } @@ -71,4 +71,5 @@ export default async function handler( } catch (error) { return res.status(500).json({ error: (error as Error).message }); } -} + }, +); diff --git a/apps/web/src/pages/api/v1/[...trpc].ts b/apps/web/src/pages/api/v1/[...trpc].ts index 4fe7ac6f..3b4bc8d0 100644 --- a/apps/web/src/pages/api/v1/[...trpc].ts +++ b/apps/web/src/pages/api/v1/[...trpc].ts @@ -6,25 +6,26 @@ import { appRouter } from "@kan/api"; import { createRESTContext } from "@kan/api/trpc"; import { env } from "~/env"; +import { withRateLimit } from "@kan/api/utils/rateLimit"; -export default async function handler( - req: NextApiRequest, - res: NextApiResponse, -) { - await cors(req, res); +export default withRateLimit( + { points: 100, duration: 60 }, + async (req: NextApiRequest, res: NextApiResponse) => { + await cors(req, res); - const openApiHandler = createOpenApiNextHandler({ - router: appRouter, - createContext: createRESTContext, - onError: - env.NODE_ENV === "development" - ? ({ path, error }) => { - console.error( - `❌ REST failed on ${path ?? ""}: ${error.message}`, - ); - } - : undefined, - }); + const openApiHandler = createOpenApiNextHandler({ + router: appRouter, + createContext: createRESTContext, + onError: + env.NODE_ENV === "development" + ? ({ path, error }) => { + console.error( + `❌ REST failed on ${path ?? ""}: ${error.message}`, + ); + } + : undefined, + }); - return await openApiHandler(req, res); -} + return await openApiHandler(req, res); + }, +); diff --git a/apps/web/src/pages/api/v1/openapi.json.ts b/apps/web/src/pages/api/v1/openapi.json.ts index 36cd3a57..db5e4ea7 100644 --- a/apps/web/src/pages/api/v1/openapi.json.ts +++ b/apps/web/src/pages/api/v1/openapi.json.ts @@ -1,9 +1,11 @@ import type { NextApiRequest, NextApiResponse } from "next"; import { openApiDocument } from "@kan/api/openapi"; +import { withRateLimit } from "@kan/api/utils/rateLimit"; -const handler = (req: NextApiRequest, res: NextApiResponse) => { - res.status(200).send(openApiDocument); -}; - -export default handler; +export default withRateLimit( + { points: 100, duration: 60 }, + (req: NextApiRequest, res: NextApiResponse) => { + res.status(200).send(openApiDocument); + }, +); diff --git a/cloud/docker-compose.yml b/cloud/docker-compose.yml index 02705d35..e0133d4b 100644 --- a/cloud/docker-compose.yml +++ b/cloud/docker-compose.yml @@ -21,6 +21,7 @@ services: - BETTER_AUTH_SECRET=${BETTER_AUTH_SECRET} - POSTGRES_URL=${POSTGRES_URL} - NEXT_PUBLIC_USE_STANDALONE_OUTPUT=${NEXT_PUBLIC_USE_STANDALONE_OUTPUT} + - REDIS_URL=${REDIS_URL} # Stripe - STRIPE_SECRET_KEY=${STRIPE_SECRET_KEY} diff --git a/docker-compose.yml b/docker-compose.yml index ec96cf0b..1edc5e8c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -18,6 +18,9 @@ services: - BETTER_AUTH_SECRET=${BETTER_AUTH_SECRET} - POSTGRES_URL=${POSTGRES_URL} + # Redis (optional - for rate limiting) + - REDIS_URL=${REDIS_URL} + # Admin API key (optional) - KAN_ADMIN_API_KEY=${KAN_ADMIN_API_KEY} diff --git a/packages/api/package.json b/packages/api/package.json index 7c6e3a0e..292fad98 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -23,6 +23,10 @@ "./openapi": { "types": "./dist/openapi.d.ts", "default": "./src/openapi.ts" + }, + "./utils/rateLimit": { + "types": "./dist/utils/rateLimit.d.ts", + "default": "./src/utils/rateLimit.ts" } }, "license": "GPL-3.0", @@ -43,6 +47,7 @@ "@kan/shared": "workspace:^", "@kan/stripe": "workspace:^", "@trpc/server": "catalog:", + "rate-limiter-flexible": "^9.0.1", "superjson": "2.2.1", "trpc-to-openapi": "^2.3.2", "zod": "catalog:" diff --git a/packages/api/src/utils/rateLimit.ts b/packages/api/src/utils/rateLimit.ts new file mode 100644 index 00000000..7459f534 --- /dev/null +++ b/packages/api/src/utils/rateLimit.ts @@ -0,0 +1,99 @@ +import type { NextApiRequest, NextApiResponse } from "next"; +import { + RateLimiterRedis, + RateLimiterMemory, +} from "rate-limiter-flexible"; + +import { getRedisClient } from "@kan/db/redis"; + +export interface RateLimitOptions { + points?: number; + duration?: number; + identifier?: (req: NextApiRequest) => string | Promise; + errorMessage?: string; +} + +const defaultIdentifier = (req: NextApiRequest): string => { + // Try to identify the IP address of the request + const forwardedFor = req.headers["x-forwarded-for"]; + const realIp = req.headers["x-real-ip"]; + const cfConnectingIp = req.headers["cf-connecting-ip"]; + + const ip = + (typeof forwardedFor === "string" + ? forwardedFor.split(",")[0]?.trim() + : null) ?? + (typeof realIp === "string" ? realIp : null) ?? + (typeof cfConnectingIp === "string" ? cfConnectingIp : null) ?? + req.socket.remoteAddress ?? + "unknown"; + + return ip; +}; + +const DEFAULT_OPTIONS = { + points: 100, + duration: 60, + errorMessage: "Too many requests, please try again later.", + identifier: defaultIdentifier, +} as const; + +function createRateLimiter(options: RateLimitOptions = {}) { + const redis = getRedisClient(); + const points = options.points ?? DEFAULT_OPTIONS.points; + const duration = options.duration ?? DEFAULT_OPTIONS.duration; + + // Use Redis if available, otherwise fall back to in-memory storage + if (redis) { + console.log("Using Redis for rate limiting"); + return new RateLimiterRedis({ + storeClient: redis, + points, + duration, + }); + } + + console.log("Using in-memory for rate limiting"); + return new RateLimiterMemory({ + points, + duration, + }); +} + +export function withRateLimit( + options: RateLimitOptions, + handler: ( + req: NextApiRequest, + res: NextApiResponse, + ) => Promise | unknown, +) { + const rateLimiter = createRateLimiter(options); + const identifier = options.identifier ?? DEFAULT_OPTIONS.identifier; + const errorMessage = options.errorMessage ?? DEFAULT_OPTIONS.errorMessage; + + return async (req: NextApiRequest, res: NextApiResponse) => { + try { + const id = await identifier(req); + const key = `ratelimit_${id}`; + + await rateLimiter.consume(key); + + return await handler(req, res); + } catch (error) { + // rate-limiter-flexible throws an error with msBeforeNext or remainingPoints + // when limit is exceeded. Check for these properties directly. + if ( + error && + typeof error === "object" && + ("msBeforeNext" in error || "remainingPoints" in error) + ) { + return res.status(429).json({ + message: errorMessage, + }); + } + + return await handler(req, res); + } + }; +} + diff --git a/packages/db/package.json b/packages/db/package.json index 4bfa25c9..1363da56 100644 --- a/packages/db/package.json +++ b/packages/db/package.json @@ -23,6 +23,10 @@ "./repository/*": { "types": "./dist/repository/*.d.ts", "default": "./src/repository/*.ts" + }, + "./redis": { + "types": "./dist/redis.d.ts", + "default": "./src/redis.ts" } }, "license": "GPL-3.0", @@ -43,6 +47,7 @@ "@kan/shared": "workspace:^", "drizzle-orm": "^0.42.0", "drizzle-zod": "^0.5.1", + "ioredis": "^5.9.2", "pg": "^8.11.3", "uuid": "^11.1.0", "zod": "catalog:" diff --git a/packages/db/src/redis.ts b/packages/db/src/redis.ts new file mode 100644 index 00000000..098f2caf --- /dev/null +++ b/packages/db/src/redis.ts @@ -0,0 +1,31 @@ +import Redis from "ioredis"; + +let redisClient: Redis | null = null; + +export function getRedisClient(): Redis | null { + if (redisClient) { + return redisClient; + } + + const redisUrl = process.env.REDIS_URL; + + if (!redisUrl) { + return null; + } + + redisClient = new Redis(redisUrl, { + maxRetriesPerRequest: 3, + enableReadyCheck: true, + lazyConnect: true, + }); + + return redisClient; +} + +export async function closeRedisClient(): Promise { + if (redisClient) { + await redisClient.quit(); + redisClient = null; + } +} + diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6015912e..fec9b156 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -314,6 +314,9 @@ importers: '@trpc/server': specifier: 'catalog:' version: 11.5.0(typescript@5.9.2) + rate-limiter-flexible: + specifier: ^9.0.1 + version: 9.0.1 superjson: specifier: 2.2.1 version: 2.2.1 @@ -397,6 +400,9 @@ importers: drizzle-zod: specifier: ^0.5.1 version: 0.5.1(drizzle-orm@0.42.0(@electric-sql/pglite@0.3.7)(@types/pg@8.15.5)(kysely@0.28.8)(pg@8.16.3))(zod@3.25.76) + ioredis: + specifier: ^5.9.2 + version: 5.9.2 pg: specifier: ^8.11.3 version: 8.16.3 @@ -2568,6 +2574,9 @@ packages: resolution: {integrity: sha512-lGPVU3yO9ZNqA7vTYz26jny41lE7yoQansmqdMLBEfqaGsmdg7V3W9mK9Pvb5IL4EVZ9GnSDGMO/cJXud5dMaw==} engines: {node: '>=18'} + '@ioredis/commands@1.5.0': + resolution: {integrity: sha512-eUgLqrMf8nJkZxT24JvVRrQya1vZkQh8BBeYNwGDqa5I0VUi8ACx7uFvAaLxintokpTenkK6DASvo/bvNbBGow==} + '@isaacs/balanced-match@4.0.1': resolution: {integrity: sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==} engines: {node: 20 || >=22} @@ -4631,6 +4640,10 @@ packages: resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} engines: {node: '>=6'} + cluster-key-slot@1.1.2: + resolution: {integrity: sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==} + engines: {node: '>=0.10.0'} + co-body@6.2.0: resolution: {integrity: sha512-Kbpv2Yd1NdL1V/V4cwLVxraHDV6K8ayohr2rmH0J87Er8+zJjcTa6dAn9QMPC9CRgU8+aNajKbSf1TzDB1yKPA==} engines: {node: '>=8.0.0'} @@ -4901,6 +4914,10 @@ packages: resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} engines: {node: '>=0.4.0'} + denque@2.1.0: + resolution: {integrity: sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==} + engines: {node: '>=0.10'} + depd@2.0.0: resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} engines: {node: '>= 0.8'} @@ -5743,6 +5760,10 @@ packages: resolution: {integrity: sha512-agE4QfB2Lkp9uICn7BAqoscw4SZP9kTE2hxiFI3jBPmXJfdqiahTbUuKGsMoN2GtqL9AxhYioAcVvgsb1HvRbA==} engines: {node: '>= 0.10'} + ioredis@5.9.2: + resolution: {integrity: sha512-tAAg/72/VxOUW7RQSX1pIxJVucYKcjFjfvj60L57jrZpYCHC3XN0WCQ3sNYL4Gmvv+7GPvTAjc+KSdeNuE8oWQ==} + engines: {node: '>=12.22.0'} + ip-address@10.0.1: resolution: {integrity: sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA==} engines: {node: '>= 12'} @@ -6128,10 +6149,16 @@ packages: lodash.debounce@4.0.8: resolution: {integrity: sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==} + lodash.defaults@4.2.0: + resolution: {integrity: sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==} + lodash.get@4.4.2: resolution: {integrity: sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ==} deprecated: This package is deprecated. Use the optional chaining (?.) operator instead. + lodash.isarguments@3.1.0: + resolution: {integrity: sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==} + lodash.isplainobject@4.0.6: resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==} @@ -7226,6 +7253,9 @@ packages: randombytes@2.1.0: resolution: {integrity: sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==} + rate-limiter-flexible@9.0.1: + resolution: {integrity: sha512-sO+QdoGPCxroi4VkO2FIVjfUGuexhRkBc9ROHqu5eVEEz+oPHzQqvCc25ajFfMUBosbNGb6qpNa8xmxH9YNZsg==} + raw-body@2.5.2: resolution: {integrity: sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==} engines: {node: '>= 0.8'} @@ -7332,6 +7362,14 @@ packages: resolution: {integrity: sha512-HFM8rkZ+i3zrV+4LQjwQ0W+ez98pApMGM3HUrN04j3CqzPOzl9nmP15Y8YXNm8QHGv/eacOVEjqhmWpkRV0NAw==} engines: {node: '>= 0.10'} + redis-errors@1.2.0: + resolution: {integrity: sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==} + engines: {node: '>=4'} + + redis-parser@3.0.0: + resolution: {integrity: sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==} + engines: {node: '>=4'} + redux@4.2.1: resolution: {integrity: sha512-LAUYz4lc+Do8/g7aeRa8JkyDErK6ekstQaqWQrNRW//MY1TvCEpMtpTWvlQ+FPbWCx+Xixu/6SHt5N0HR+SB4w==} @@ -7669,6 +7707,9 @@ packages: stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + standard-as-callback@2.1.0: + resolution: {integrity: sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==} + statuses@2.0.1: resolution: {integrity: sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==} engines: {node: '>= 0.8'} @@ -10543,6 +10584,8 @@ snapshots: '@inquirer/figures@1.0.13': {} + '@ioredis/commands@1.5.0': {} + '@isaacs/balanced-match@4.0.1': {} '@isaacs/brace-expansion@5.0.0': @@ -12893,6 +12936,8 @@ snapshots: clsx@2.1.1: {} + cluster-key-slot@1.1.2: {} + co-body@6.2.0: dependencies: '@hapi/bourne': 3.0.0 @@ -13143,6 +13188,8 @@ snapshots: delayed-stream@1.0.0: {} + denque@2.1.0: {} + depd@2.0.0: {} deprecation@2.3.1: {} @@ -14292,6 +14339,20 @@ snapshots: interpret@1.4.0: {} + ioredis@5.9.2: + dependencies: + '@ioredis/commands': 1.5.0 + cluster-key-slot: 1.1.2 + debug: 4.4.3 + 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 + transitivePeerDependencies: + - supports-color + ip-address@10.0.1: {} iron-webcrypto@1.2.1: {} @@ -14629,8 +14690,12 @@ snapshots: lodash.debounce@4.0.8: {} + lodash.defaults@4.2.0: {} + lodash.get@4.4.2: {} + lodash.isarguments@3.1.0: {} + lodash.isplainobject@4.0.6: {} lodash.merge@4.6.2: {} @@ -16126,6 +16191,8 @@ snapshots: dependencies: safe-buffer: 5.2.1 + rate-limiter-flexible@9.0.1: {} + raw-body@2.5.2: dependencies: bytes: 3.1.2 @@ -16273,6 +16340,12 @@ snapshots: dependencies: resolve: 1.22.10 + redis-errors@1.2.0: {} + + redis-parser@3.0.0: + dependencies: + redis-errors: 1.2.0 + redux@4.2.1: dependencies: '@babel/runtime': 7.28.3 @@ -16770,6 +16843,8 @@ snapshots: stackback@0.0.2: {} + standard-as-callback@2.1.0: {} + statuses@2.0.1: {} std-env@3.10.0: {} diff --git a/turbo.json b/turbo.json index d495494a..009a9ed2 100644 --- a/turbo.json +++ b/turbo.json @@ -126,7 +126,8 @@ "BETTER_AUTH_SECRET", "BETTER_AUTH_TRUSTED_ORIGINS", "NOVU_API_KEY", - "EMAIL_UNSUBSCRIBE_SECRET" + "EMAIL_UNSUBSCRIBE_SECRET", + "REDIS_URL" ], "globalPassThroughEnv": [ "NODE_ENV",