diff --git a/app/api/ai/generate/route.ts b/app/api/ai/generate/route.ts index d79f483b..6fe369ba 100644 --- a/app/api/ai/generate/route.ts +++ b/app/api/ai/generate/route.ts @@ -1,6 +1,7 @@ import { streamText } from "ai"; import { NextResponse } from "next/server"; import { auth } from "@/lib/auth"; +import { checkAIRateLimit, getRateLimitHeaders } from "@/lib/rate-limit"; import { generateAIActionPrompts } from "@/plugins"; // Simple type for operations @@ -256,6 +257,15 @@ export async function POST(request: Request) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } + // Check rate limit + const rateLimit = await checkAIRateLimit(session.user.id); + if (!rateLimit.allowed) { + return NextResponse.json( + { error: "Rate limit exceeded. Please try again later." }, + { status: 429, headers: getRateLimitHeaders(rateLimit) } + ); + } + const body = await request.json(); const { prompt, existingWorkflow } = body; diff --git a/app/api/workflows/[workflowId]/webhook/route.ts b/app/api/workflows/[workflowId]/webhook/route.ts index 2c80cca9..f16275e9 100644 --- a/app/api/workflows/[workflowId]/webhook/route.ts +++ b/app/api/workflows/[workflowId]/webhook/route.ts @@ -5,6 +5,7 @@ import { start } from "workflow/api"; import { db } from "@/lib/db"; import { validateWorkflowIntegrations } from "@/lib/db/integrations"; import { apiKeys, workflowExecutions, workflows } from "@/lib/db/schema"; +import { checkExecutionRateLimit, getRateLimitHeaders } from "@/lib/rate-limit"; import { executeWorkflow } from "@/lib/workflow-executor.workflow"; import type { WorkflowEdge, WorkflowNode } from "@/lib/workflow-store"; @@ -149,6 +150,18 @@ export async function POST( ); } + // Check rate limit + const rateLimit = await checkExecutionRateLimit(workflow.userId); + if (!rateLimit.allowed) { + return NextResponse.json( + { error: "Rate limit exceeded. Please try again later." }, + { + status: 429, + headers: { ...corsHeaders, ...getRateLimitHeaders(rateLimit) }, + } + ); + } + // Verify this is a webhook-triggered workflow const triggerNode = (workflow.nodes as WorkflowNode[]).find( (node) => node.data.type === "trigger" diff --git a/lib/rate-limit.ts b/lib/rate-limit.ts new file mode 100644 index 00000000..219f7c88 --- /dev/null +++ b/lib/rate-limit.ts @@ -0,0 +1,68 @@ +import { Ratelimit } from "@upstash/ratelimit"; +import { Redis } from "@upstash/redis"; + +type RateLimitResult = { + allowed: boolean; + remaining: number; + resetAt: Date; +}; + +// Create Redis client with KV_ prefix +const redis = new Redis({ + url: process.env.KV_REST_API_URL ?? "", + token: process.env.KV_REST_API_TOKEN ?? "", +}); + +// AI generation: 50 requests per hour +const aiRatelimit = new Ratelimit({ + redis, + limiter: Ratelimit.slidingWindow(50, "1 h"), + prefix: "ratelimit:ai", +}); + +// Webhook execution: 1000 requests per hour +const webhookRatelimit = new Ratelimit({ + redis, + limiter: Ratelimit.slidingWindow(1000, "1 h"), + prefix: "ratelimit:webhook", +}); + +/** + * Check rate limit for AI generation requests + */ +export async function checkAIRateLimit( + userId: string +): Promise { + const { success, remaining, reset } = await aiRatelimit.limit(userId); + + return { + allowed: success, + remaining, + resetAt: new Date(reset), + }; +} + +/** + * Check rate limit for workflow executions + */ +export async function checkExecutionRateLimit( + userId: string +): Promise { + const { success, remaining, reset } = await webhookRatelimit.limit(userId); + + return { + allowed: success, + remaining, + resetAt: new Date(reset), + }; +} + +/** + * Get rate limit headers for response + */ +export function getRateLimitHeaders(result: RateLimitResult): HeadersInit { + return { + "X-RateLimit-Remaining": result.remaining.toString(), + "X-RateLimit-Reset": result.resetAt.toISOString(), + }; +} diff --git a/package.json b/package.json index b8d2c87f..fb5dd038 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,8 @@ "@radix-ui/react-context-menu": "^2.2.16", "@radix-ui/react-dialog": "^1.1.15", "@slack/web-api": "^7.12.0", + "@upstash/ratelimit": "^2.0.7", + "@upstash/redis": "^1.35.7", "@vercel/analytics": "^1.5.0", "@vercel/og": "^0.8.5", "@vercel/sdk": "^1.17.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c09e4d01..3f1a42a2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -29,6 +29,12 @@ importers: '@slack/web-api': specifier: ^7.12.0 version: 7.12.0 + '@upstash/ratelimit': + specifier: ^2.0.7 + version: 2.0.7(@upstash/redis@1.35.7) + '@upstash/redis': + specifier: ^1.35.7 + version: 1.35.7 '@vercel/analytics': specifier: ^1.5.0 version: 1.5.0(next@16.0.1(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react@19.2.0) @@ -61,7 +67,7 @@ importers: version: 17.2.3 drizzle-orm: specifier: ^0.44.7 - version: 0.44.7(@opentelemetry/api@1.9.0)(kysely@0.28.8)(postgres@3.4.7) + version: 0.44.7(@opentelemetry/api@1.9.0)(@upstash/redis@1.35.7)(kysely@0.28.8)(postgres@3.4.7) jotai: specifier: ^2.15.1 version: 2.15.1(@babel/core@7.28.5)(@babel/template@7.27.2)(@types/react@19.2.2)(react@19.2.0) @@ -2445,6 +2451,18 @@ packages: '@types/retry@0.12.0': resolution: {integrity: sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==} + '@upstash/core-analytics@0.0.10': + resolution: {integrity: sha512-7qJHGxpQgQr9/vmeS1PktEwvNAF7TI4iJDi8Pu2CFZ9YUGHZH4fOP5TfYlZ4aVxfopnELiE4BS4FBjyK7V1/xQ==} + engines: {node: '>=16.0.0'} + + '@upstash/ratelimit@2.0.7': + resolution: {integrity: sha512-qNQW4uBPKVk8c4wFGj2S/vfKKQxXx1taSJoSGBN36FeiVBBKHQgsjPbKUijZ9Xu5FyVK+pfiXWKIsQGyoje8Fw==} + peerDependencies: + '@upstash/redis': ^1.34.3 + + '@upstash/redis@1.35.7': + resolution: {integrity: sha512-bdCdKhke+kYUjcLLuGWSeQw7OLuWIx3eyKksyToLBAlGIMX9qiII0ptp8E0y7VFE1yuBxBd/3kSzJ8774Q4g+A==} + '@vercel/analytics@1.5.0': resolution: {integrity: sha512-MYsBzfPki4gthY5HnYN7jgInhAZ7Ac1cYDoRWFomwGHWEX7odTEzbtg9kf/QSo7XEsEAqlQugA6gJ2WS2DEa3g==} peerDependencies: @@ -7191,6 +7209,19 @@ snapshots: '@types/retry@0.12.0': {} + '@upstash/core-analytics@0.0.10': + dependencies: + '@upstash/redis': 1.35.7 + + '@upstash/ratelimit@2.0.7(@upstash/redis@1.35.7)': + dependencies: + '@upstash/core-analytics': 0.0.10 + '@upstash/redis': 1.35.7 + + '@upstash/redis@1.35.7': + dependencies: + uncrypto: 0.1.3 + '@vercel/analytics@1.5.0(next@16.0.1(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react@19.2.0)': optionalDependencies: next: 16.0.1(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) @@ -7797,9 +7828,10 @@ snapshots: transitivePeerDependencies: - supports-color - drizzle-orm@0.44.7(@opentelemetry/api@1.9.0)(kysely@0.28.8)(postgres@3.4.7): + drizzle-orm@0.44.7(@opentelemetry/api@1.9.0)(@upstash/redis@1.35.7)(kysely@0.28.8)(postgres@3.4.7): optionalDependencies: '@opentelemetry/api': 1.9.0 + '@upstash/redis': 1.35.7 kysely: 0.28.8 postgres: 3.4.7