From f26a1a9130c1bdd29269280d21cdb036f2373d5d Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Tue, 2 Dec 2025 12:47:19 -0600 Subject: [PATCH 1/3] add rate limits --- app/api/ai/generate/route.ts | 10 ++ .../workflows/[workflowId]/webhook/route.ts | 13 +++ lib/rate-limit.ts | 108 ++++++++++++++++++ 3 files changed, 131 insertions(+) create mode 100644 lib/rate-limit.ts diff --git a/app/api/ai/generate/route.ts b/app/api/ai/generate/route.ts index d79f483b..b9d50bfc 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 = 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..678b6ad2 --- /dev/null +++ b/lib/rate-limit.ts @@ -0,0 +1,108 @@ +import { and, count, eq, gte } from "drizzle-orm"; +import { db } from "@/lib/db"; +import { workflowExecutions } from "@/lib/db/schema"; + +type RateLimitConfig = { + maxRequests: number; + windowInHours: number; +}; + +const RATE_LIMITS: Record = { + // AI generation: 50 requests per hour + aiGenerate: { maxRequests: 50, windowInHours: 1 }, + // Webhook execution: 1000 requests per hour + webhookExecute: { maxRequests: 1000, windowInHours: 1 }, +}; + +type RateLimitResult = { + allowed: boolean; + remaining: number; + resetAt: Date; +}; + +/** + * Check rate limit for workflow executions + */ +export async function checkExecutionRateLimit( + userId: string, + limitKey: "webhookExecute" = "webhookExecute" +): Promise { + const config = RATE_LIMITS[limitKey]; + const windowStart = new Date( + Date.now() - config.windowInHours * 60 * 60 * 1000 + ); + const resetAt = new Date(Date.now() + config.windowInHours * 60 * 60 * 1000); + + const [result] = await db + .select({ count: count(workflowExecutions.id) }) + .from(workflowExecutions) + .where( + and( + eq(workflowExecutions.userId, userId), + gte(workflowExecutions.startedAt, windowStart) + ) + ); + + const currentCount = result?.count ?? 0; + const remaining = Math.max(0, config.maxRequests - currentCount); + + return { + allowed: currentCount < config.maxRequests, + remaining, + resetAt, + }; +} + +// In-memory rate limit for AI generation (doesn't persist to DB) +const aiRequestCounts = new Map(); + +/** + * Check rate limit for AI generation requests (in-memory) + */ +export function checkAIRateLimit( + userId: string, + limitKey: "aiGenerate" = "aiGenerate" +): RateLimitResult { + const config = RATE_LIMITS[limitKey]; + const now = Date.now(); + const windowMs = config.windowInHours * 60 * 60 * 1000; + + const existing = aiRequestCounts.get(userId); + + // Reset if window has passed + if (!existing || now > existing.resetAt) { + aiRequestCounts.set(userId, { count: 1, resetAt: now + windowMs }); + return { + allowed: true, + remaining: config.maxRequests - 1, + resetAt: new Date(now + windowMs), + }; + } + + // Check if within limit + if (existing.count >= config.maxRequests) { + return { + allowed: false, + remaining: 0, + resetAt: new Date(existing.resetAt), + }; + } + + // Increment count + existing.count += 1; + return { + allowed: true, + remaining: config.maxRequests - existing.count, + resetAt: new Date(existing.resetAt), + }; +} + +/** + * 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(), + }; +} From c37bbc31e6e4c998d4cff4f1c8944ab17692964c Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Tue, 2 Dec 2025 13:32:53 -0600 Subject: [PATCH 2/3] better limit handling --- app/api/ai/generate/route.ts | 2 +- drizzle/0004_lowly_madelyne_pryor.sql | 8 + drizzle/meta/0004_snapshot.json | 772 ++++++++++++++++++++++++++ drizzle/meta/_journal.json | 7 + lib/db/schema.ts | 12 + lib/rate-limit.ts | 56 +- 6 files changed, 830 insertions(+), 27 deletions(-) create mode 100644 drizzle/0004_lowly_madelyne_pryor.sql create mode 100644 drizzle/meta/0004_snapshot.json diff --git a/app/api/ai/generate/route.ts b/app/api/ai/generate/route.ts index b9d50bfc..6fe369ba 100644 --- a/app/api/ai/generate/route.ts +++ b/app/api/ai/generate/route.ts @@ -258,7 +258,7 @@ export async function POST(request: Request) { } // Check rate limit - const rateLimit = checkAIRateLimit(session.user.id); + const rateLimit = await checkAIRateLimit(session.user.id); if (!rateLimit.allowed) { return NextResponse.json( { error: "Rate limit exceeded. Please try again later." }, diff --git a/drizzle/0004_lowly_madelyne_pryor.sql b/drizzle/0004_lowly_madelyne_pryor.sql new file mode 100644 index 00000000..9ed0e992 --- /dev/null +++ b/drizzle/0004_lowly_madelyne_pryor.sql @@ -0,0 +1,8 @@ +CREATE TABLE "rate_limit_events" ( + "id" text PRIMARY KEY NOT NULL, + "user_id" text NOT NULL, + "event_type" text NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "rate_limit_events" ADD CONSTRAINT "rate_limit_events_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action; \ No newline at end of file diff --git a/drizzle/meta/0004_snapshot.json b/drizzle/meta/0004_snapshot.json new file mode 100644 index 00000000..980a286c --- /dev/null +++ b/drizzle/meta/0004_snapshot.json @@ -0,0 +1,772 @@ +{ + "id": "cc99cf2d-ef72-4825-8e68-0f2dac996026", + "prevId": "725be3c3-851b-481a-91d1-080feccc324d", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.accounts": { + "name": "accounts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "accounts_user_id_users_id_fk": { + "name": "accounts_user_id_users_id_fk", + "tableFrom": "accounts", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.api_keys": { + "name": "api_keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "key_hash": { + "name": "key_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key_prefix": { + "name": "key_prefix", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "api_keys_user_id_users_id_fk": { + "name": "api_keys_user_id_users_id_fk", + "tableFrom": "api_keys", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.integrations": { + "name": "integrations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "integrations_user_id_users_id_fk": { + "name": "integrations_user_id_users_id_fk", + "tableFrom": "integrations", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.rate_limit_events": { + "name": "rate_limit_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "rate_limit_events_user_id_users_id_fk": { + "name": "rate_limit_events_user_id_users_id_fk", + "tableFrom": "rate_limit_events", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sessions": { + "name": "sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "sessions_token_unique": { + "name": "sessions_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "is_anonymous": { + "name": "is_anonymous", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verifications": { + "name": "verifications", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_execution_logs": { + "name": "workflow_execution_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "node_id": { + "name": "node_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "node_name": { + "name": "node_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "node_type": { + "name": "node_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "input": { + "name": "input", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "output": { + "name": "output", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "duration": { + "name": "duration", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "timestamp": { + "name": "timestamp", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "workflow_execution_logs_execution_id_workflow_executions_id_fk": { + "name": "workflow_execution_logs_execution_id_workflow_executions_id_fk", + "tableFrom": "workflow_execution_logs", + "tableTo": "workflow_executions", + "columnsFrom": ["execution_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_executions": { + "name": "workflow_executions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "input": { + "name": "input", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "output": { + "name": "output", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "duration": { + "name": "duration", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "workflow_executions_workflow_id_workflows_id_fk": { + "name": "workflow_executions_workflow_id_workflows_id_fk", + "tableFrom": "workflow_executions", + "tableTo": "workflows", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "workflow_executions_user_id_users_id_fk": { + "name": "workflow_executions_user_id_users_id_fk", + "tableFrom": "workflow_executions", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflows": { + "name": "workflows", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "nodes": { + "name": "nodes", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "edges": { + "name": "edges", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "visibility": { + "name": "visibility", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'private'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "workflows_user_id_users_id_fk": { + "name": "workflows_user_id_users_id_fk", + "tableFrom": "workflows", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index c760c27f..a59250de 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -29,6 +29,13 @@ "when": 1764694021611, "tag": "0003_clammy_tusk", "breakpoints": true + }, + { + "idx": 4, + "version": "7", + "when": 1764703917123, + "tag": "0004_lowly_madelyne_pryor", + "breakpoints": true } ] } diff --git a/lib/db/schema.ts b/lib/db/schema.ts index 8f1dad64..c1a86bfd 100644 --- a/lib/db/schema.ts +++ b/lib/db/schema.ts @@ -184,3 +184,15 @@ export type WorkflowExecutionLog = typeof workflowExecutionLogs.$inferSelect; export type NewWorkflowExecutionLog = typeof workflowExecutionLogs.$inferInsert; export type ApiKey = typeof apiKeys.$inferSelect; export type NewApiKey = typeof apiKeys.$inferInsert; + +// Rate limit events table for tracking requests +export const rateLimitEvents = pgTable("rate_limit_events", { + id: text("id") + .primaryKey() + .$defaultFn(() => generateId()), + userId: text("user_id") + .notNull() + .references(() => users.id), + eventType: text("event_type").notNull(), // e.g., "ai_generate", "webhook_execute" + createdAt: timestamp("created_at").notNull().defaultNow(), +}); diff --git a/lib/rate-limit.ts b/lib/rate-limit.ts index 678b6ad2..af919eb6 100644 --- a/lib/rate-limit.ts +++ b/lib/rate-limit.ts @@ -1,6 +1,6 @@ import { and, count, eq, gte } from "drizzle-orm"; import { db } from "@/lib/db"; -import { workflowExecutions } from "@/lib/db/schema"; +import { rateLimitEvents, workflowExecutions } from "@/lib/db/schema"; type RateLimitConfig = { maxRequests: number; @@ -53,47 +53,51 @@ export async function checkExecutionRateLimit( }; } -// In-memory rate limit for AI generation (doesn't persist to DB) -const aiRequestCounts = new Map(); - /** - * Check rate limit for AI generation requests (in-memory) + * Check rate limit for AI generation requests (database-backed) */ -export function checkAIRateLimit( +export async function checkAIRateLimit( userId: string, limitKey: "aiGenerate" = "aiGenerate" -): RateLimitResult { +): Promise { const config = RATE_LIMITS[limitKey]; - const now = Date.now(); - const windowMs = config.windowInHours * 60 * 60 * 1000; + const windowStart = new Date( + Date.now() - config.windowInHours * 60 * 60 * 1000 + ); + const resetAt = new Date(Date.now() + config.windowInHours * 60 * 60 * 1000); - const existing = aiRequestCounts.get(userId); + const [result] = await db + .select({ count: count(rateLimitEvents.id) }) + .from(rateLimitEvents) + .where( + and( + eq(rateLimitEvents.userId, userId), + eq(rateLimitEvents.eventType, "ai_generate"), + gte(rateLimitEvents.createdAt, windowStart) + ) + ); - // Reset if window has passed - if (!existing || now > existing.resetAt) { - aiRequestCounts.set(userId, { count: 1, resetAt: now + windowMs }); - return { - allowed: true, - remaining: config.maxRequests - 1, - resetAt: new Date(now + windowMs), - }; - } + const currentCount = result?.count ?? 0; + const remaining = Math.max(0, config.maxRequests - currentCount); - // Check if within limit - if (existing.count >= config.maxRequests) { + if (currentCount >= config.maxRequests) { return { allowed: false, remaining: 0, - resetAt: new Date(existing.resetAt), + resetAt, }; } - // Increment count - existing.count += 1; + // Record this request + await db.insert(rateLimitEvents).values({ + userId, + eventType: "ai_generate", + }); + return { allowed: true, - remaining: config.maxRequests - existing.count, - resetAt: new Date(existing.resetAt), + remaining: remaining - 1, + resetAt, }; } From cb5ee9047052a90d02f532046044b3cc82fa7270 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Tue, 2 Dec 2025 14:18:44 -0600 Subject: [PATCH 3/3] better rate limit --- drizzle/0004_lowly_madelyne_pryor.sql | 8 - drizzle/meta/0004_snapshot.json | 772 -------------------------- drizzle/meta/_journal.json | 7 - lib/db/schema.ts | 12 - lib/rate-limit.ts | 114 ++-- package.json | 2 + pnpm-lock.yaml | 36 +- 7 files changed, 71 insertions(+), 880 deletions(-) delete mode 100644 drizzle/0004_lowly_madelyne_pryor.sql delete mode 100644 drizzle/meta/0004_snapshot.json diff --git a/drizzle/0004_lowly_madelyne_pryor.sql b/drizzle/0004_lowly_madelyne_pryor.sql deleted file mode 100644 index 9ed0e992..00000000 --- a/drizzle/0004_lowly_madelyne_pryor.sql +++ /dev/null @@ -1,8 +0,0 @@ -CREATE TABLE "rate_limit_events" ( - "id" text PRIMARY KEY NOT NULL, - "user_id" text NOT NULL, - "event_type" text NOT NULL, - "created_at" timestamp DEFAULT now() NOT NULL -); ---> statement-breakpoint -ALTER TABLE "rate_limit_events" ADD CONSTRAINT "rate_limit_events_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action; \ No newline at end of file diff --git a/drizzle/meta/0004_snapshot.json b/drizzle/meta/0004_snapshot.json deleted file mode 100644 index 980a286c..00000000 --- a/drizzle/meta/0004_snapshot.json +++ /dev/null @@ -1,772 +0,0 @@ -{ - "id": "cc99cf2d-ef72-4825-8e68-0f2dac996026", - "prevId": "725be3c3-851b-481a-91d1-080feccc324d", - "version": "7", - "dialect": "postgresql", - "tables": { - "public.accounts": { - "name": "accounts", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "account_id": { - "name": "account_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "provider_id": { - "name": "provider_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "user_id": { - "name": "user_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "access_token": { - "name": "access_token", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "refresh_token": { - "name": "refresh_token", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "id_token": { - "name": "id_token", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "access_token_expires_at": { - "name": "access_token_expires_at", - "type": "timestamp", - "primaryKey": false, - "notNull": false - }, - "refresh_token_expires_at": { - "name": "refresh_token_expires_at", - "type": "timestamp", - "primaryKey": false, - "notNull": false - }, - "scope": { - "name": "scope", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "password": { - "name": "password", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true - } - }, - "indexes": {}, - "foreignKeys": { - "accounts_user_id_users_id_fk": { - "name": "accounts_user_id_users_id_fk", - "tableFrom": "accounts", - "tableTo": "users", - "columnsFrom": ["user_id"], - "columnsTo": ["id"], - "onDelete": "no action", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.api_keys": { - "name": "api_keys", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "user_id": { - "name": "user_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "key_hash": { - "name": "key_hash", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "key_prefix": { - "name": "key_prefix", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "last_used_at": { - "name": "last_used_at", - "type": "timestamp", - "primaryKey": false, - "notNull": false - } - }, - "indexes": {}, - "foreignKeys": { - "api_keys_user_id_users_id_fk": { - "name": "api_keys_user_id_users_id_fk", - "tableFrom": "api_keys", - "tableTo": "users", - "columnsFrom": ["user_id"], - "columnsTo": ["id"], - "onDelete": "no action", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.integrations": { - "name": "integrations", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "user_id": { - "name": "user_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "type": { - "name": "type", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "config": { - "name": "config", - "type": "jsonb", - "primaryKey": false, - "notNull": true - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": { - "integrations_user_id_users_id_fk": { - "name": "integrations_user_id_users_id_fk", - "tableFrom": "integrations", - "tableTo": "users", - "columnsFrom": ["user_id"], - "columnsTo": ["id"], - "onDelete": "no action", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.rate_limit_events": { - "name": "rate_limit_events", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "user_id": { - "name": "user_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "event_type": { - "name": "event_type", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": { - "rate_limit_events_user_id_users_id_fk": { - "name": "rate_limit_events_user_id_users_id_fk", - "tableFrom": "rate_limit_events", - "tableTo": "users", - "columnsFrom": ["user_id"], - "columnsTo": ["id"], - "onDelete": "no action", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.sessions": { - "name": "sessions", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "expires_at": { - "name": "expires_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true - }, - "token": { - "name": "token", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true - }, - "ip_address": { - "name": "ip_address", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "user_agent": { - "name": "user_agent", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "user_id": { - "name": "user_id", - "type": "text", - "primaryKey": false, - "notNull": true - } - }, - "indexes": {}, - "foreignKeys": { - "sessions_user_id_users_id_fk": { - "name": "sessions_user_id_users_id_fk", - "tableFrom": "sessions", - "tableTo": "users", - "columnsFrom": ["user_id"], - "columnsTo": ["id"], - "onDelete": "no action", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": { - "sessions_token_unique": { - "name": "sessions_token_unique", - "nullsNotDistinct": false, - "columns": ["token"] - } - }, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.users": { - "name": "users", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "email": { - "name": "email", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "email_verified": { - "name": "email_verified", - "type": "boolean", - "primaryKey": false, - "notNull": true, - "default": false - }, - "image": { - "name": "image", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true - }, - "is_anonymous": { - "name": "is_anonymous", - "type": "boolean", - "primaryKey": false, - "notNull": false, - "default": false - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": { - "users_email_unique": { - "name": "users_email_unique", - "nullsNotDistinct": false, - "columns": ["email"] - } - }, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.verifications": { - "name": "verifications", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "identifier": { - "name": "identifier", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "value": { - "name": "value", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "expires_at": { - "name": "expires_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": false - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp", - "primaryKey": false, - "notNull": false - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.workflow_execution_logs": { - "name": "workflow_execution_logs", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "execution_id": { - "name": "execution_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "node_id": { - "name": "node_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "node_name": { - "name": "node_name", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "node_type": { - "name": "node_type", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "status": { - "name": "status", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "input": { - "name": "input", - "type": "jsonb", - "primaryKey": false, - "notNull": false - }, - "output": { - "name": "output", - "type": "jsonb", - "primaryKey": false, - "notNull": false - }, - "error": { - "name": "error", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "started_at": { - "name": "started_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "completed_at": { - "name": "completed_at", - "type": "timestamp", - "primaryKey": false, - "notNull": false - }, - "duration": { - "name": "duration", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "timestamp": { - "name": "timestamp", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": { - "workflow_execution_logs_execution_id_workflow_executions_id_fk": { - "name": "workflow_execution_logs_execution_id_workflow_executions_id_fk", - "tableFrom": "workflow_execution_logs", - "tableTo": "workflow_executions", - "columnsFrom": ["execution_id"], - "columnsTo": ["id"], - "onDelete": "no action", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.workflow_executions": { - "name": "workflow_executions", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "workflow_id": { - "name": "workflow_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "user_id": { - "name": "user_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "status": { - "name": "status", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "input": { - "name": "input", - "type": "jsonb", - "primaryKey": false, - "notNull": false - }, - "output": { - "name": "output", - "type": "jsonb", - "primaryKey": false, - "notNull": false - }, - "error": { - "name": "error", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "started_at": { - "name": "started_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "completed_at": { - "name": "completed_at", - "type": "timestamp", - "primaryKey": false, - "notNull": false - }, - "duration": { - "name": "duration", - "type": "text", - "primaryKey": false, - "notNull": false - } - }, - "indexes": {}, - "foreignKeys": { - "workflow_executions_workflow_id_workflows_id_fk": { - "name": "workflow_executions_workflow_id_workflows_id_fk", - "tableFrom": "workflow_executions", - "tableTo": "workflows", - "columnsFrom": ["workflow_id"], - "columnsTo": ["id"], - "onDelete": "no action", - "onUpdate": "no action" - }, - "workflow_executions_user_id_users_id_fk": { - "name": "workflow_executions_user_id_users_id_fk", - "tableFrom": "workflow_executions", - "tableTo": "users", - "columnsFrom": ["user_id"], - "columnsTo": ["id"], - "onDelete": "no action", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.workflows": { - "name": "workflows", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "description": { - "name": "description", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "user_id": { - "name": "user_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "nodes": { - "name": "nodes", - "type": "jsonb", - "primaryKey": false, - "notNull": true - }, - "edges": { - "name": "edges", - "type": "jsonb", - "primaryKey": false, - "notNull": true - }, - "visibility": { - "name": "visibility", - "type": "text", - "primaryKey": false, - "notNull": true, - "default": "'private'" - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": { - "workflows_user_id_users_id_fk": { - "name": "workflows_user_id_users_id_fk", - "tableFrom": "workflows", - "tableTo": "users", - "columnsFrom": ["user_id"], - "columnsTo": ["id"], - "onDelete": "no action", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - } - }, - "enums": {}, - "schemas": {}, - "sequences": {}, - "roles": {}, - "policies": {}, - "views": {}, - "_meta": { - "columns": {}, - "schemas": {}, - "tables": {} - } -} diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index a59250de..c760c27f 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -29,13 +29,6 @@ "when": 1764694021611, "tag": "0003_clammy_tusk", "breakpoints": true - }, - { - "idx": 4, - "version": "7", - "when": 1764703917123, - "tag": "0004_lowly_madelyne_pryor", - "breakpoints": true } ] } diff --git a/lib/db/schema.ts b/lib/db/schema.ts index c1a86bfd..8f1dad64 100644 --- a/lib/db/schema.ts +++ b/lib/db/schema.ts @@ -184,15 +184,3 @@ export type WorkflowExecutionLog = typeof workflowExecutionLogs.$inferSelect; export type NewWorkflowExecutionLog = typeof workflowExecutionLogs.$inferInsert; export type ApiKey = typeof apiKeys.$inferSelect; export type NewApiKey = typeof apiKeys.$inferInsert; - -// Rate limit events table for tracking requests -export const rateLimitEvents = pgTable("rate_limit_events", { - id: text("id") - .primaryKey() - .$defaultFn(() => generateId()), - userId: text("user_id") - .notNull() - .references(() => users.id), - eventType: text("event_type").notNull(), // e.g., "ai_generate", "webhook_execute" - createdAt: timestamp("created_at").notNull().defaultNow(), -}); diff --git a/lib/rate-limit.ts b/lib/rate-limit.ts index af919eb6..219f7c88 100644 --- a/lib/rate-limit.ts +++ b/lib/rate-limit.ts @@ -1,18 +1,5 @@ -import { and, count, eq, gte } from "drizzle-orm"; -import { db } from "@/lib/db"; -import { rateLimitEvents, workflowExecutions } from "@/lib/db/schema"; - -type RateLimitConfig = { - maxRequests: number; - windowInHours: number; -}; - -const RATE_LIMITS: Record = { - // AI generation: 50 requests per hour - aiGenerate: { maxRequests: 50, windowInHours: 1 }, - // Webhook execution: 1000 requests per hour - webhookExecute: { maxRequests: 1000, windowInHours: 1 }, -}; +import { Ratelimit } from "@upstash/ratelimit"; +import { Redis } from "@upstash/redis"; type RateLimitResult = { allowed: boolean; @@ -20,84 +7,53 @@ type RateLimitResult = { 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 workflow executions + * Check rate limit for AI generation requests */ -export async function checkExecutionRateLimit( - userId: string, - limitKey: "webhookExecute" = "webhookExecute" +export async function checkAIRateLimit( + userId: string ): Promise { - const config = RATE_LIMITS[limitKey]; - const windowStart = new Date( - Date.now() - config.windowInHours * 60 * 60 * 1000 - ); - const resetAt = new Date(Date.now() + config.windowInHours * 60 * 60 * 1000); - - const [result] = await db - .select({ count: count(workflowExecutions.id) }) - .from(workflowExecutions) - .where( - and( - eq(workflowExecutions.userId, userId), - gte(workflowExecutions.startedAt, windowStart) - ) - ); - - const currentCount = result?.count ?? 0; - const remaining = Math.max(0, config.maxRequests - currentCount); + const { success, remaining, reset } = await aiRatelimit.limit(userId); return { - allowed: currentCount < config.maxRequests, + allowed: success, remaining, - resetAt, + resetAt: new Date(reset), }; } /** - * Check rate limit for AI generation requests (database-backed) + * Check rate limit for workflow executions */ -export async function checkAIRateLimit( - userId: string, - limitKey: "aiGenerate" = "aiGenerate" +export async function checkExecutionRateLimit( + userId: string ): Promise { - const config = RATE_LIMITS[limitKey]; - const windowStart = new Date( - Date.now() - config.windowInHours * 60 * 60 * 1000 - ); - const resetAt = new Date(Date.now() + config.windowInHours * 60 * 60 * 1000); - - const [result] = await db - .select({ count: count(rateLimitEvents.id) }) - .from(rateLimitEvents) - .where( - and( - eq(rateLimitEvents.userId, userId), - eq(rateLimitEvents.eventType, "ai_generate"), - gte(rateLimitEvents.createdAt, windowStart) - ) - ); - - const currentCount = result?.count ?? 0; - const remaining = Math.max(0, config.maxRequests - currentCount); - - if (currentCount >= config.maxRequests) { - return { - allowed: false, - remaining: 0, - resetAt, - }; - } - - // Record this request - await db.insert(rateLimitEvents).values({ - userId, - eventType: "ai_generate", - }); + const { success, remaining, reset } = await webhookRatelimit.limit(userId); return { - allowed: true, - remaining: remaining - 1, - resetAt, + allowed: success, + remaining, + resetAt: new Date(reset), }; } 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