From b6e21867f9751e41ba8d67cc04c50a9b998ab3fc Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 11 Aug 2025 02:21:43 +0000 Subject: [PATCH 1/3] Initial plan From 8ed134c715e354cda674a7826b24ababa01a9665 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 11 Aug 2025 02:33:45 +0000 Subject: [PATCH 2/3] Implement API rate limiting middleware with event tracking Co-authored-by: DavidASix <3901710+DavidASix@users.noreply.github.com> --- src/__tests__/withEventRateLimit.test.ts | 195 ++++++++++++++++++ .../v1/fetch-updated-data/route.ts | 19 +- src/app/api/demo/rate-limit-test/route.ts | 52 +++++ src/app/api/demo/rate-limit-test/schema.ts | 19 ++ src/lib/server/events.ts | 38 +++- src/middleware/withEventRateLimit.ts | 93 +++++++++ 6 files changed, 409 insertions(+), 7 deletions(-) create mode 100644 src/__tests__/withEventRateLimit.test.ts create mode 100644 src/app/api/demo/rate-limit-test/route.ts create mode 100644 src/app/api/demo/rate-limit-test/schema.ts create mode 100644 src/middleware/withEventRateLimit.ts diff --git a/src/__tests__/withEventRateLimit.test.ts b/src/__tests__/withEventRateLimit.test.ts new file mode 100644 index 0000000..60fc26d --- /dev/null +++ b/src/__tests__/withEventRateLimit.test.ts @@ -0,0 +1,195 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest, NextResponse } from "next/server"; +import { withEventRateLimit } from "@/middleware/withEventRateLimit"; +import type { RequestHandler } from "@/middleware/types"; + +// Mock the events module +vi.mock("@/lib/server/events", () => ({ + countEventsInTimeWindow: vi.fn(), + recordEvent: vi.fn(), +})); + +const { countEventsInTimeWindow, recordEvent } = await import("@/lib/server/events"); +const mockedCountEventsInTimeWindow = vi.mocked(countEventsInTimeWindow); +const mockedRecordEvent = vi.mocked(recordEvent); + +describe("withEventRateLimit middleware", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + const mockContext = { + user_id: "test-user-123", + params: Promise.resolve({}), + }; + + const mockRequest = new NextRequest("http://localhost:3000/api/test"); + + const mockHandler: RequestHandler = vi.fn( + async () => NextResponse.json({ success: true }), + ); + + it("should allow request when under rate limit", async () => { + // Mock that user has made 5 calls (under the limit of 10) + mockedCountEventsInTimeWindow.mockResolvedValue(5); + mockedRecordEvent.mockResolvedValue(undefined); + + const rateLimitedHandler = withEventRateLimit( + { + event: "fetch_reviews", + maxCalls: 10, + timeWindowHours: 24, + }, + mockHandler, + ); + + const response = await rateLimitedHandler(mockRequest, mockContext); + + expect(response.status).toBe(200); + expect(mockedCountEventsInTimeWindow).toHaveBeenCalledWith( + "fetch_reviews", + "test-user-123", + 24, + ); + expect(mockHandler).toHaveBeenCalledWith(mockRequest, mockContext); + expect(mockedRecordEvent).toHaveBeenCalledWith( + "fetch_reviews", + "test-user-123", + {}, + ); + }); + + it("should reject request when rate limit is exceeded", async () => { + // Mock that user has made 10 calls (at the limit) + mockedCountEventsInTimeWindow.mockResolvedValue(10); + + const rateLimitedHandler = withEventRateLimit( + { + event: "fetch_reviews", + maxCalls: 10, + timeWindowHours: 24, + }, + mockHandler, + ); + + const response = await rateLimitedHandler(mockRequest, mockContext); + + expect(response.status).toBe(429); + const responseData = await response.json(); + expect(responseData).toEqual({ + error: "Rate limit exceeded", + details: { + event: "fetch_reviews", + maxCalls: 10, + timeWindowHours: 24, + currentCount: 10, + }, + }); + + expect(mockedCountEventsInTimeWindow).toHaveBeenCalledWith( + "fetch_reviews", + "test-user-123", + 24, + ); + expect(mockHandler).not.toHaveBeenCalled(); + expect(mockedRecordEvent).not.toHaveBeenCalled(); + }); + + it("should not record event when handler returns error status", async () => { + // Mock that user has made 2 calls (under the limit) + mockedCountEventsInTimeWindow.mockResolvedValue(2); + + // Create a handler that returns an error + const errorHandler: RequestHandler = vi.fn( + async () => NextResponse.json({ error: "Bad request" }, { status: 400 }), + ); + + const rateLimitedHandler = withEventRateLimit( + { + event: "fetch_reviews", + maxCalls: 10, + timeWindowHours: 24, + }, + errorHandler, + ); + + const response = await rateLimitedHandler(mockRequest, mockContext); + + expect(response.status).toBe(400); + expect(mockedCountEventsInTimeWindow).toHaveBeenCalled(); + expect(errorHandler).toHaveBeenCalled(); + expect(mockedRecordEvent).not.toHaveBeenCalled(); + }); + + it("should record event with custom metadata", async () => { + mockedCountEventsInTimeWindow.mockResolvedValue(1); + mockedRecordEvent.mockResolvedValue(undefined); + + const customMetadata = { business_id: 123 }; + const rateLimitedHandler = withEventRateLimit( + { + event: "update_reviews", + maxCalls: 5, + timeWindowHours: 12, + metadata: customMetadata, + }, + mockHandler, + ); + + const response = await rateLimitedHandler(mockRequest, mockContext); + + expect(response.status).toBe(200); + expect(mockedRecordEvent).toHaveBeenCalledWith( + "update_reviews", + "test-user-123", + customMetadata, + ); + }); + + it("should handle database errors gracefully", async () => { + // Mock database error + mockedCountEventsInTimeWindow.mockRejectedValue(new Error("Database error")); + + const rateLimitedHandler = withEventRateLimit( + { + event: "fetch_reviews", + maxCalls: 10, + timeWindowHours: 24, + }, + mockHandler, + ); + + const response = await rateLimitedHandler(mockRequest, mockContext); + + expect(response.status).toBe(500); + const responseData = await response.json(); + expect(responseData).toEqual({ + error: "Internal Server Error", + }); + + expect(mockHandler).not.toHaveBeenCalled(); + }); + + it("should work with different time windows and limits", async () => { + mockedCountEventsInTimeWindow.mockResolvedValue(50); + mockedRecordEvent.mockResolvedValue(undefined); + + const rateLimitedHandler = withEventRateLimit( + { + event: "fetch_reviews", + maxCalls: 100, + timeWindowHours: 1, // 1 hour window + }, + mockHandler, + ); + + const response = await rateLimitedHandler(mockRequest, mockContext); + + expect(response.status).toBe(200); + expect(mockedCountEventsInTimeWindow).toHaveBeenCalledWith( + "fetch_reviews", + "test-user-123", + 1, + ); + }); +}); \ No newline at end of file diff --git a/src/app/api/(endpoints)/v1/fetch-updated-data/route.ts b/src/app/api/(endpoints)/v1/fetch-updated-data/route.ts index 6a28e13..26ed13a 100644 --- a/src/app/api/(endpoints)/v1/fetch-updated-data/route.ts +++ b/src/app/api/(endpoints)/v1/fetch-updated-data/route.ts @@ -4,6 +4,7 @@ import schema from "./schema"; import { NextRouteContext, RequestHandler } from "@/middleware/types"; import { withBody } from "@/middleware/withBody"; import { withApiKey } from "@/middleware/withApiKey"; +import { withEventRateLimit } from "@/middleware/withEventRateLimit"; import { getLastEvent } from "@/lib/server/events"; @@ -22,17 +23,22 @@ import { businesses } from "@/schema/schema"; * Checks if reviews/stats need updating, updates if needed, then returns latest data. This endpoint is called by 11ty in the clients * website to ensure that their google reviews are updated any time the clients site is rebuilt. * + * Rate limits: + * - fetch_reviews: 100 calls per 24 hours (for this endpoint usage) + * * @param { business_id: number } - The database ID of the business * @returns Latest reviews and stats for the business */ export const POST: RequestHandler = withApiKey( - withBody(schema, async (_, context) => { - try { - const { business_id } = context.body; + withEventRateLimit( + { event: "fetch_reviews", maxCalls: 100, timeWindowHours: 24 }, + withBody(schema, async (_, context) => { + try { + const { business_id } = context.body; - await userHasOwnership(context.user_id, business_id, businesses); - const oneDayAgo = new Date(); - oneDayAgo.setDate(oneDayAgo.getDate() - 1); + await userHasOwnership(context.user_id, business_id, businesses); + const oneDayAgo = new Date(); + oneDayAgo.setDate(oneDayAgo.getDate() - 1); // Check last update times const lastUpdateReviews = await getLastEvent( @@ -81,4 +87,5 @@ export const POST: RequestHandler = withApiKey( ); } }), + ), ); diff --git a/src/app/api/demo/rate-limit-test/route.ts b/src/app/api/demo/rate-limit-test/route.ts new file mode 100644 index 0000000..b0565b8 --- /dev/null +++ b/src/app/api/demo/rate-limit-test/route.ts @@ -0,0 +1,52 @@ +import { NextResponse } from "next/server"; + +import schema from "./schema"; +import { NextRouteContext, RequestHandler } from "@/middleware/types"; +import { withApiKey } from "@/middleware/withApiKey"; +import { withEventRateLimit } from "@/middleware/withEventRateLimit"; +import { countEventsInTimeWindow } from "@/lib/server/events"; + +/** + * Demo endpoint showing multiple rate limits on a single endpoint. + * This demonstrates the capability to chain multiple withEventRateLimit middlewares. + * + * Rate limits: + * - fetch_reviews: 10 calls per 24 hours + * - update_reviews: 3 calls per 24 hours + * + * This endpoint will be rejected if either rate limit is exceeded. + */ +export const GET: RequestHandler = withApiKey( + withEventRateLimit( + { event: "fetch_reviews", maxCalls: 10, timeWindowHours: 24 }, + withEventRateLimit( + { event: "update_reviews", maxCalls: 3, timeWindowHours: 24 }, + async (_, context) => { + try { + // Get current event counts for demonstration + const [fetchReviewsCount, updateReviewsCount] = await Promise.all([ + countEventsInTimeWindow("fetch_reviews", context.user_id, 24), + countEventsInTimeWindow("update_reviews", context.user_id, 24), + ]); + + const response = schema.response.parse({ + message: "Rate limit test endpoint accessed successfully!", + userId: context.user_id, + eventCounts: { + fetchReviews: fetchReviewsCount, + updateReviews: updateReviewsCount, + }, + }); + + return NextResponse.json(response); + } catch (error) { + console.error("Error in rate limit test endpoint:", error); + return NextResponse.json( + { error: "Internal Server Error" }, + { status: 500 }, + ); + } + }, + ), + ), +); \ No newline at end of file diff --git a/src/app/api/demo/rate-limit-test/schema.ts b/src/app/api/demo/rate-limit-test/schema.ts new file mode 100644 index 0000000..e7f0bd4 --- /dev/null +++ b/src/app/api/demo/rate-limit-test/schema.ts @@ -0,0 +1,19 @@ +import { z } from "zod"; +import type { APISchema } from "@/schema/types"; + +const schema = { + url: "/api/demo/rate-limit-test", + // No request body needed for this demo endpoint + request: z.undefined(), + // Response schema + response: z.object({ + message: z.string(), + userId: z.string(), + eventCounts: z.object({ + fetchReviews: z.number(), + updateReviews: z.number(), + }), + }), +} satisfies APISchema; + +export default schema; \ No newline at end of file diff --git a/src/lib/server/events.ts b/src/lib/server/events.ts index 79e376c..14312bc 100644 --- a/src/lib/server/events.ts +++ b/src/lib/server/events.ts @@ -1,4 +1,4 @@ -import { and, eq, desc } from "drizzle-orm"; +import { and, eq, desc, gte } from "drizzle-orm"; import { db } from "@/schema/db"; import type { EventMetadata, DBEvent } from "@/schema/schema"; @@ -52,3 +52,39 @@ export async function getLastEvent(event: DBEvent, user_id: string) { return lastEvent; } + +/** + * Counts the number of events of a specific type for a user within a time window. + * + * @param event - The event type to count, this is a PG Enum + * @param user_id - The ID of the user whose events are being counted. + * @param hoursAgo - Number of hours to look back from now. + * @returns The count of events within the time window. + * @example + * ```typescript + * import { countEventsInTimeWindow } from "@/lib/server/events"; + * const count = await countEventsInTimeWindow("fetch_reviews", "user123", 24); + * console.log(`User made ${count} fetch_reviews calls in the last 24 hours`); + * ``` + */ +export async function countEventsInTimeWindow( + event: DBEvent, + user_id: string, + hoursAgo: number, +): Promise { + const timeWindow = new Date(); + timeWindow.setHours(timeWindow.getHours() - hoursAgo); + + const result = await db + .select() + .from(events) + .where( + and( + eq(events.user_id, user_id), + eq(events.event, event), + gte(events.timestamp, timeWindow), + ), + ); + + return result.length; +} diff --git a/src/middleware/withEventRateLimit.ts b/src/middleware/withEventRateLimit.ts new file mode 100644 index 0000000..76dff64 --- /dev/null +++ b/src/middleware/withEventRateLimit.ts @@ -0,0 +1,93 @@ +import { NextResponse } from "next/server"; +import { RequestHandler } from "./types"; +import { countEventsInTimeWindow, recordEvent } from "@/lib/server/events"; +import type { DBEvent, EventMetadata } from "@/schema/schema"; + +/** + * Configuration for event rate limiting + */ +export interface EventRateLimitConfig { + event: DBEvent; + maxCalls: number; + timeWindowHours: number; + metadata?: EventMetadata; +} + +/** + * Middleware wrapper that implements rate limiting based on event tracking. + * Checks if a user has exceeded the allowed number of calls for a specific event + * within a given time window and rejects the request if the limit is exceeded. + * + * This middleware requires that the context includes a user_id property, so it should + * be used with either withAuth or withApiKey middleware. + * + * @param config - Rate limiting configuration + * @param config.event - The event type to track and limit + * @param config.maxCalls - Maximum number of calls allowed within the time window + * @param config.timeWindowHours - Time window in hours to check for rate limiting + * @param config.metadata - Optional metadata to record with the event + * + * @example + * ```typescript + * export const POST: RequestHandler = withApiKey( + * withEventRateLimit( + * { event: "fetch_reviews", maxCalls: 100, timeWindowHours: 24 }, + * withBody(schema, async (_, context) => { + * const { user_id, body } = context; + * // Handler implementation... + * return NextResponse.json({ success: true }); + * }) + * ) + * ); + * ``` + */ +export function withEventRateLimit( + config: EventRateLimitConfig, + handler: RequestHandler, +): RequestHandler { + return async function (req, context: T) { + const { user_id } = context; + const { event, maxCalls, timeWindowHours, metadata = {} } = config; + + try { + // Count how many times this event has been called by this user in the time window + const eventCount = await countEventsInTimeWindow( + event, + user_id, + timeWindowHours, + ); + + // Check if the user has exceeded the rate limit + if (eventCount >= maxCalls) { + return NextResponse.json( + { + error: "Rate limit exceeded", + details: { + event, + maxCalls, + timeWindowHours, + currentCount: eventCount, + }, + }, + { status: 429 }, + ); + } + + // Execute the handler + const response = await handler(req, context); + + // If the handler succeeded (status < 400), record the event + if (response.status < 400) { + await recordEvent(event, user_id, metadata); + } + + return response; + } catch (error) { + console.error("Error in withEventRateLimit middleware:", error); + return NextResponse.json( + { error: "Internal Server Error" }, + { status: 500 }, + ); + } + }; +} \ No newline at end of file From f19059b6a7058c72d31a2465b2b512c70416ed18 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 11 Aug 2025 16:42:54 +0000 Subject: [PATCH 3/3] Fix code formatting for rate limiting implementation Co-authored-by: DavidASix <3901710+DavidASix@users.noreply.github.com> --- src/__tests__/withEventRateLimit.test.ts | 20 ++++++++++++-------- src/app/api/demo/rate-limit-test/route.ts | 6 +++--- src/app/api/demo/rate-limit-test/schema.ts | 2 +- src/middleware/withEventRateLimit.ts | 8 ++++---- 4 files changed, 20 insertions(+), 16 deletions(-) diff --git a/src/__tests__/withEventRateLimit.test.ts b/src/__tests__/withEventRateLimit.test.ts index 60fc26d..ee93a85 100644 --- a/src/__tests__/withEventRateLimit.test.ts +++ b/src/__tests__/withEventRateLimit.test.ts @@ -9,7 +9,9 @@ vi.mock("@/lib/server/events", () => ({ recordEvent: vi.fn(), })); -const { countEventsInTimeWindow, recordEvent } = await import("@/lib/server/events"); +const { countEventsInTimeWindow, recordEvent } = await import( + "@/lib/server/events" +); const mockedCountEventsInTimeWindow = vi.mocked(countEventsInTimeWindow); const mockedRecordEvent = vi.mocked(recordEvent); @@ -25,8 +27,8 @@ describe("withEventRateLimit middleware", () => { const mockRequest = new NextRequest("http://localhost:3000/api/test"); - const mockHandler: RequestHandler = vi.fn( - async () => NextResponse.json({ success: true }), + const mockHandler: RequestHandler = vi.fn(async () => + NextResponse.json({ success: true }), ); it("should allow request when under rate limit", async () => { @@ -98,10 +100,10 @@ describe("withEventRateLimit middleware", () => { it("should not record event when handler returns error status", async () => { // Mock that user has made 2 calls (under the limit) mockedCountEventsInTimeWindow.mockResolvedValue(2); - + // Create a handler that returns an error - const errorHandler: RequestHandler = vi.fn( - async () => NextResponse.json({ error: "Bad request" }, { status: 400 }), + const errorHandler: RequestHandler = vi.fn(async () => + NextResponse.json({ error: "Bad request" }, { status: 400 }), ); const rateLimitedHandler = withEventRateLimit( @@ -148,7 +150,9 @@ describe("withEventRateLimit middleware", () => { it("should handle database errors gracefully", async () => { // Mock database error - mockedCountEventsInTimeWindow.mockRejectedValue(new Error("Database error")); + mockedCountEventsInTimeWindow.mockRejectedValue( + new Error("Database error"), + ); const rateLimitedHandler = withEventRateLimit( { @@ -192,4 +196,4 @@ describe("withEventRateLimit middleware", () => { 1, ); }); -}); \ No newline at end of file +}); diff --git a/src/app/api/demo/rate-limit-test/route.ts b/src/app/api/demo/rate-limit-test/route.ts index b0565b8..8d57d6b 100644 --- a/src/app/api/demo/rate-limit-test/route.ts +++ b/src/app/api/demo/rate-limit-test/route.ts @@ -9,11 +9,11 @@ import { countEventsInTimeWindow } from "@/lib/server/events"; /** * Demo endpoint showing multiple rate limits on a single endpoint. * This demonstrates the capability to chain multiple withEventRateLimit middlewares. - * + * * Rate limits: * - fetch_reviews: 10 calls per 24 hours * - update_reviews: 3 calls per 24 hours - * + * * This endpoint will be rejected if either rate limit is exceeded. */ export const GET: RequestHandler = withApiKey( @@ -49,4 +49,4 @@ export const GET: RequestHandler = withApiKey( }, ), ), -); \ No newline at end of file +); diff --git a/src/app/api/demo/rate-limit-test/schema.ts b/src/app/api/demo/rate-limit-test/schema.ts index e7f0bd4..3eb40a4 100644 --- a/src/app/api/demo/rate-limit-test/schema.ts +++ b/src/app/api/demo/rate-limit-test/schema.ts @@ -16,4 +16,4 @@ const schema = { }), } satisfies APISchema; -export default schema; \ No newline at end of file +export default schema; diff --git a/src/middleware/withEventRateLimit.ts b/src/middleware/withEventRateLimit.ts index 76dff64..b506051 100644 --- a/src/middleware/withEventRateLimit.ts +++ b/src/middleware/withEventRateLimit.ts @@ -17,16 +17,16 @@ export interface EventRateLimitConfig { * Middleware wrapper that implements rate limiting based on event tracking. * Checks if a user has exceeded the allowed number of calls for a specific event * within a given time window and rejects the request if the limit is exceeded. - * + * * This middleware requires that the context includes a user_id property, so it should * be used with either withAuth or withApiKey middleware. - * + * * @param config - Rate limiting configuration * @param config.event - The event type to track and limit * @param config.maxCalls - Maximum number of calls allowed within the time window * @param config.timeWindowHours - Time window in hours to check for rate limiting * @param config.metadata - Optional metadata to record with the event - * + * * @example * ```typescript * export const POST: RequestHandler = withApiKey( @@ -90,4 +90,4 @@ export function withEventRateLimit( ); } }; -} \ No newline at end of file +}