diff --git a/.env.example b/.env.example index c662737..80e0ea1 100644 --- a/.env.example +++ b/.env.example @@ -123,8 +123,14 @@ FLY_DATABASE_URL=postgresql://user:password@host:5432/database # ===================================== # REDIS (RATE LIMITING) # ===================================== -# Redis connection string for rate limiting -# Upstash Redis: https://console.upstash.com/ +# Upstash Redis for distributed rate limiting in production +# Get credentials from: https://console.upstash.com/ +# +# Option 1 (preferred): Upstash REST API credentials +UPSTASH_REDIS_REST_URL=https://your-redis-host.upstash.io +UPSTASH_REDIS_REST_TOKEN=your_upstash_rest_token_here +# +# Option 2 (legacy): Standard Redis URI — auto-converted to REST format LIMITER_STORAGE_URI=redis://default:your_redis_password@your-redis-host.upstash.io:6379 # ===================================== diff --git a/package-lock.json b/package-lock.json index e9da0ce..d8ac4bb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,8 @@ "@stripe/stripe-js": "^8.7.0", "@supabase/ssr": "^0.8.0", "@supabase/supabase-js": "^2.94.0", + "@upstash/ratelimit": "^2.0.8", + "@upstash/redis": "^1.36.2", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "date-fns": "^4.1.0", @@ -3848,6 +3850,39 @@ "win32" ] }, + "node_modules/@upstash/core-analytics": { + "version": "0.0.10", + "resolved": "https://registry.npmjs.org/@upstash/core-analytics/-/core-analytics-0.0.10.tgz", + "integrity": "sha512-7qJHGxpQgQr9/vmeS1PktEwvNAF7TI4iJDi8Pu2CFZ9YUGHZH4fOP5TfYlZ4aVxfopnELiE4BS4FBjyK7V1/xQ==", + "license": "MIT", + "dependencies": { + "@upstash/redis": "^1.28.3" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@upstash/ratelimit": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/@upstash/ratelimit/-/ratelimit-2.0.8.tgz", + "integrity": "sha512-YSTMBJ1YIxsoPkUMX/P4DDks/xV5YYCswWMamU8ZIfK9ly6ppjRnVOyBhMDXBmzjODm4UQKcxsJPvaeFAijp5w==", + "license": "MIT", + "dependencies": { + "@upstash/core-analytics": "^0.0.10" + }, + "peerDependencies": { + "@upstash/redis": "^1.34.3" + } + }, + "node_modules/@upstash/redis": { + "version": "1.36.2", + "resolved": "https://registry.npmjs.org/@upstash/redis/-/redis-1.36.2.tgz", + "integrity": "sha512-C0Yt8hc12vLaQYRG1fMci8iPrLtnTdbJG0HR5T8vKnvEP/1RdMMblsOJs5/jp0JXZJ1oSzMnQz4J9EVezNpI6A==", + "license": "MIT", + "dependencies": { + "uncrypto": "^0.1.3" + } + }, "node_modules/acorn": { "version": "8.15.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", @@ -8619,6 +8654,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/uncrypto": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/uncrypto/-/uncrypto-0.1.3.tgz", + "integrity": "sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q==", + "license": "MIT" + }, "node_modules/undici-types": { "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", diff --git a/package.json b/package.json index 426ee16..0f84a9c 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,8 @@ "@stripe/stripe-js": "^8.7.0", "@supabase/ssr": "^0.8.0", "@supabase/supabase-js": "^2.94.0", + "@upstash/ratelimit": "^2.0.8", + "@upstash/redis": "^1.36.2", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "date-fns": "^4.1.0", diff --git a/src/app/api/abn-lookup/route.ts b/src/app/api/abn-lookup/route.ts index 585cd05..dab3f0b 100644 --- a/src/app/api/abn-lookup/route.ts +++ b/src/app/api/abn-lookup/route.ts @@ -337,7 +337,7 @@ export async function GET(request: NextRequest) { try { // --- 0. Rate limiting --------------------------------------------------- const clientIP = getClientIP(request); - const rateLimit = checkRateLimit(`abn-lookup:${clientIP}`, ABN_LOOKUP_RATE_LIMIT, ABN_LOOKUP_WINDOW_MS); + const rateLimit = await checkRateLimit(`abn-lookup:${clientIP}`, ABN_LOOKUP_RATE_LIMIT, ABN_LOOKUP_WINDOW_MS); if (!rateLimit.allowed) { const retryAfter = Math.ceil((rateLimit.resetTime - Date.now()) / 1000); diff --git a/src/app/api/ai/generate-job/route.ts b/src/app/api/ai/generate-job/route.ts index 2b7bbfe..c42b69a 100644 --- a/src/app/api/ai/generate-job/route.ts +++ b/src/app/api/ai/generate-job/route.ts @@ -24,7 +24,7 @@ const openai = new OpenAI({ const handler = async (request: NextRequest) => { try { const clientIP = getClientIP(request); - const rateLimit = checkRateLimit(`ai-job:${clientIP}`, AI_RATE_LIMIT, AI_WINDOW_MS); + const rateLimit = await checkRateLimit(`ai-job:${clientIP}`, AI_RATE_LIMIT, AI_WINDOW_MS); if (!rateLimit.allowed) { const retryAfter = Math.ceil((rateLimit.resetTime - Date.now()) / 1000); diff --git a/src/app/api/ai/generate-profile/route.ts b/src/app/api/ai/generate-profile/route.ts index 3169413..6550b4a 100644 --- a/src/app/api/ai/generate-profile/route.ts +++ b/src/app/api/ai/generate-profile/route.ts @@ -30,7 +30,7 @@ const handler = async (request: NextRequest) => { try { // --- Rate limiting --- const clientIP = getClientIP(request); - const rateLimit = checkRateLimit(`ai-profile:${clientIP}`, AI_RATE_LIMIT, AI_WINDOW_MS); + const rateLimit = await checkRateLimit(`ai-profile:${clientIP}`, AI_RATE_LIMIT, AI_WINDOW_MS); if (!rateLimit.allowed) { const retryAfter = Math.ceil((rateLimit.resetTime - Date.now()) / 1000); diff --git a/src/app/api/ai/parse-profile-voice/route.ts b/src/app/api/ai/parse-profile-voice/route.ts index b06a813..c129dfe 100644 --- a/src/app/api/ai/parse-profile-voice/route.ts +++ b/src/app/api/ai/parse-profile-voice/route.ts @@ -58,7 +58,7 @@ async function handler(request: Request) { // Rate limit const clientIP = getClientIP(request); - const rateLimit = checkRateLimit(`parse-voice:${clientIP}`, AI_RATE_LIMIT, AI_WINDOW_MS); + const rateLimit = await checkRateLimit(`parse-voice:${clientIP}`, AI_RATE_LIMIT, AI_WINDOW_MS); if (!rateLimit.allowed) { const retryAfter = Math.ceil((rateLimit.resetTime - Date.now()) / 1000); return createRateLimitResponse(clientIP, retryAfter); diff --git a/src/app/api/ai/transcribe-voice/route.ts b/src/app/api/ai/transcribe-voice/route.ts index 4a89d5b..b35274c 100644 --- a/src/app/api/ai/transcribe-voice/route.ts +++ b/src/app/api/ai/transcribe-voice/route.ts @@ -49,7 +49,7 @@ async function handler(request: Request) { // Rate limit const clientIP = getClientIP(request); - const rateLimit = checkRateLimit(`transcribe:${clientIP}`, AI_RATE_LIMIT, AI_WINDOW_MS); + const rateLimit = await checkRateLimit(`transcribe:${clientIP}`, AI_RATE_LIMIT, AI_WINDOW_MS); if (!rateLimit.allowed) { const retryAfter = Math.ceil((rateLimit.resetTime - Date.now()) / 1000); return createRateLimitResponse(clientIP, retryAfter); diff --git a/src/app/api/ai/voice-complete/route.ts b/src/app/api/ai/voice-complete/route.ts index 2e9372b..a256759 100644 --- a/src/app/api/ai/voice-complete/route.ts +++ b/src/app/api/ai/voice-complete/route.ts @@ -48,7 +48,7 @@ const handler = async (request: NextRequest) => { try { // --- Rate limiting --- const clientIP = getClientIP(request); - const rateLimit = checkRateLimit(`ai-voice-complete:${clientIP}`, AI_RATE_LIMIT, AI_WINDOW_MS); + const rateLimit = await checkRateLimit(`ai-voice-complete:${clientIP}`, AI_RATE_LIMIT, AI_WINDOW_MS); if (!rateLimit.allowed) { const retryAfter = Math.ceil((rateLimit.resetTime - Date.now()) / 1000); diff --git a/src/app/api/auth/verify-email/route.ts b/src/app/api/auth/verify-email/route.ts index 2e056c4..d3713bc 100644 --- a/src/app/api/auth/verify-email/route.ts +++ b/src/app/api/auth/verify-email/route.ts @@ -17,7 +17,7 @@ const handler = async (request: NextRequest) => { try { // --- Rate limiting --- const clientIP = getClientIP(request); - const rateLimit = checkRateLimit(`verify-email:${clientIP}`, VERIFY_RATE_LIMIT, VERIFY_WINDOW_MS); + const rateLimit = await checkRateLimit(`verify-email:${clientIP}`, VERIFY_RATE_LIMIT, VERIFY_WINDOW_MS); if (!rateLimit.allowed) { const retryAfter = Math.ceil((rateLimit.resetTime - Date.now()) / 1000); diff --git a/src/app/api/company-logo/route.ts b/src/app/api/company-logo/route.ts index 1bd652c..030f4fc 100644 --- a/src/app/api/company-logo/route.ts +++ b/src/app/api/company-logo/route.ts @@ -33,7 +33,7 @@ export async function GET(request: NextRequest) { try { // --- Rate limiting --- const clientIP = getClientIP(request); - const rateLimit = checkRateLimit(`company-logo:${clientIP}`, LOGO_RATE_LIMIT, LOGO_WINDOW_MS); + const rateLimit = await checkRateLimit(`company-logo:${clientIP}`, LOGO_RATE_LIMIT, LOGO_WINDOW_MS); if (!rateLimit.allowed) { const retryAfter = Math.ceil((rateLimit.resetTime - Date.now()) / 1000); diff --git a/src/app/api/match/find-matches/route.ts b/src/app/api/match/find-matches/route.ts index fe5ca4c..3573e0a 100644 --- a/src/app/api/match/find-matches/route.ts +++ b/src/app/api/match/find-matches/route.ts @@ -30,7 +30,7 @@ const handler = async (request: NextRequest) => { try { // Rate limiting const clientIP = getClientIP(request); - const rateLimit = checkRateLimit(`match:${clientIP}`, RATE_LIMIT, WINDOW_MS); + const rateLimit = await checkRateLimit(`match:${clientIP}`, RATE_LIMIT, WINDOW_MS); if (!rateLimit.allowed) { const retryAfter = Math.ceil((rateLimit.resetTime - Date.now()) / 1000); return createRateLimitResponse(clientIP, retryAfter); diff --git a/src/app/api/messages/[id]/route.ts b/src/app/api/messages/[id]/route.ts index cdb575c..fffcfea 100644 --- a/src/app/api/messages/[id]/route.ts +++ b/src/app/api/messages/[id]/route.ts @@ -13,7 +13,7 @@ export async function GET( ) { try { const clientIP = getClientIP(request); - const rateLimit = checkRateLimit(`messages-get:${clientIP}`, GET_MESSAGES_RATE_LIMIT, GET_MESSAGES_WINDOW_MS); + const rateLimit = await checkRateLimit(`messages-get:${clientIP}`, GET_MESSAGES_RATE_LIMIT, GET_MESSAGES_WINDOW_MS); if (!rateLimit.allowed) { const retryAfter = Math.ceil((rateLimit.resetTime - Date.now()) / 1000); diff --git a/src/app/api/messages/[id]/send/route.ts b/src/app/api/messages/[id]/send/route.ts index 07a3256..c9f8209 100644 --- a/src/app/api/messages/[id]/send/route.ts +++ b/src/app/api/messages/[id]/send/route.ts @@ -32,7 +32,7 @@ export async function POST( ) { try { const clientIP = getClientIP(request); - const rateLimit = checkRateLimit(`messages-send:${clientIP}`, SEND_MESSAGE_RATE_LIMIT, SEND_MESSAGE_WINDOW_MS); + const rateLimit = await checkRateLimit(`messages-send:${clientIP}`, SEND_MESSAGE_RATE_LIMIT, SEND_MESSAGE_WINDOW_MS); if (!rateLimit.allowed) { const retryAfter = Math.ceil((rateLimit.resetTime - Date.now()) / 1000); diff --git a/src/app/api/messages/route.ts b/src/app/api/messages/route.ts index b820f06..fd12e2f 100644 --- a/src/app/api/messages/route.ts +++ b/src/app/api/messages/route.ts @@ -10,7 +10,7 @@ const MESSAGE_WINDOW_MS = 15 * 60 * 1000; export async function GET(request: NextRequest) { try { const clientIP = getClientIP(request); - const rateLimit = checkRateLimit(`messages-list:${clientIP}`, MESSAGE_RATE_LIMIT, MESSAGE_WINDOW_MS); + const rateLimit = await checkRateLimit(`messages-list:${clientIP}`, MESSAGE_RATE_LIMIT, MESSAGE_WINDOW_MS); if (!rateLimit.allowed) { const retryAfter = Math.ceil((rateLimit.resetTime - Date.now()) / 1000); diff --git a/src/app/api/messages/start/route.ts b/src/app/api/messages/start/route.ts index 0d82f50..44565ce 100644 --- a/src/app/api/messages/start/route.ts +++ b/src/app/api/messages/start/route.ts @@ -11,7 +11,7 @@ const START_WINDOW_MS = 15 * 60 * 1000; const handler = async (request: NextRequest) => { try { const clientIP = getClientIP(request); - const rateLimit = checkRateLimit(`messages-start:${clientIP}`, START_RATE_LIMIT, START_WINDOW_MS); + const rateLimit = await checkRateLimit(`messages-start:${clientIP}`, START_RATE_LIMIT, START_WINDOW_MS); if (!rateLimit.allowed) { const retryAfter = Math.ceil((rateLimit.resetTime - Date.now()) / 1000); diff --git a/src/app/api/payments/create/route.ts b/src/app/api/payments/create/route.ts index fbb2bc4..767b1e0 100644 --- a/src/app/api/payments/create/route.ts +++ b/src/app/api/payments/create/route.ts @@ -15,7 +15,7 @@ const handler = async (request: NextRequest) => { try { // --- Rate limiting (by IP as initial check) --- const clientIP = getClientIP(request); - const ipRateLimit = checkRateLimit(`payment-ip:${clientIP}`, PAYMENT_RATE_LIMIT, PAYMENT_WINDOW_MS); + const ipRateLimit = await checkRateLimit(`payment-ip:${clientIP}`, PAYMENT_RATE_LIMIT, PAYMENT_WINDOW_MS); if (!ipRateLimit.allowed) { const retryAfter = Math.ceil((ipRateLimit.resetTime - Date.now()) / 1000); @@ -32,7 +32,7 @@ const handler = async (request: NextRequest) => { } // --- Stricter rate limiting by user ID --- - const userRateLimit = checkRateLimit(`payment-user:${user.id}`, PAYMENT_RATE_LIMIT, PAYMENT_WINDOW_MS); + const userRateLimit = await checkRateLimit(`payment-user:${user.id}`, PAYMENT_RATE_LIMIT, PAYMENT_WINDOW_MS); if (!userRateLimit.allowed) { const retryAfter = Math.ceil((userRateLimit.resetTime - Date.now()) / 1000); diff --git a/src/lib/rate-limit.ts b/src/lib/rate-limit.ts index 24c007d..1c5ca62 100644 --- a/src/lib/rate-limit.ts +++ b/src/lib/rate-limit.ts @@ -1,64 +1,71 @@ /** - * Simple in-memory rate limiting for API endpoints - * In production, use Redis or a proper rate limiting service - * - * SECURITY NOTES: - * - This is an in-memory store, so rate limits are per-instance - * - For multi-instance deployments, use Redis or a shared cache - * - IP-based rate limiting can be bypassed by users with rotating IPs - * - Consider adding user ID-based limiting for authenticated routes + * Rate limiting for API endpoints + * + * Production: Uses Upstash Redis for distributed rate limiting across + * serverless invocations. Falls back to in-memory Map() if Redis is + * unavailable (local dev or connection failure). + * + * Env vars (pick one): + * UPSTASH_REDIS_REST_URL + UPSTASH_REDIS_REST_TOKEN (preferred) + * LIMITER_STORAGE_URI (legacy redis:// URL — auto-converted) */ +import { Ratelimit } from "@upstash/ratelimit"; +import { Redis } from "@upstash/redis"; + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- +const WINDOW_MS = 15 * 60 * 1000; // 15 minutes +const MAX_REQUESTS = 100; + +// --------------------------------------------------------------------------- +// In-memory fallback (local dev / Redis failure) +// --------------------------------------------------------------------------- + interface RateLimitEntry { count: number; resetTime: number; } const rateLimitStore = new Map(); -const WINDOW_MS = 15 * 60 * 1000; // 15 minutes -const MAX_REQUESTS = 100; // 100 requests per window (more generous for general use) -// Cleanup interval to prevent memory leaks (H14 fix) -const CLEANUP_INTERVAL_MS = 5 * 60 * 1000; // Run cleanup every 5 minutes +function memoryCheckRateLimit( + identifier: string, + maxRequests: number, + windowMs: number +): { allowed: boolean; remaining: number; resetTime: number; totalLimit: number } { + const now = Date.now(); + const entry = rateLimitStore.get(identifier); + + if (!entry || now > entry.resetTime) { + rateLimitStore.set(identifier, { count: 1, resetTime: now + windowMs }); + return { allowed: true, remaining: maxRequests - 1, resetTime: now + windowMs, totalLimit: maxRequests }; + } + + if (entry.count >= maxRequests) { + return { allowed: false, remaining: 0, resetTime: entry.resetTime, totalLimit: maxRequests }; + } + + entry.count++; + return { allowed: true, remaining: maxRequests - entry.count, resetTime: entry.resetTime, totalLimit: maxRequests }; +} + +// Cleanup interval to prevent memory leaks in long-running processes +const CLEANUP_INTERVAL_MS = 5 * 60 * 1000; let cleanupInterval: NodeJS.Timeout | null = null; -/** - * Start the cleanup interval to prevent memory leaks - * This should be called once when the app starts - * Automatically removes expired rate limit entries from memory - * - * @example - * // Called automatically when module loads - * startRateLimitCleanup(); - */ export function startRateLimitCleanup(): void { - if (cleanupInterval) return; // Already running - + if (cleanupInterval) return; cleanupInterval = setInterval(() => { const now = Date.now(); - let cleaned = 0; - for (const [key, entry] of rateLimitStore.entries()) { - if (now > entry.resetTime) { - rateLimitStore.delete(key); - cleaned++; - } + if (now > entry.resetTime) rateLimitStore.delete(key); } }, CLEANUP_INTERVAL_MS); - - // Ensure cleanup doesn't prevent process exit (for serverless) - if (cleanupInterval.unref) { - cleanupInterval.unref(); - } + if (cleanupInterval.unref) cleanupInterval.unref(); } -/** - * Stop the cleanup interval (useful for testing) - * - * @example - * stopRateLimitCleanup(); - * // Rate limit cleanup is now disabled - */ export function stopRateLimitCleanup(): void { if (cleanupInterval) { clearInterval(cleanupInterval); @@ -66,169 +73,154 @@ export function stopRateLimitCleanup(): void { } } -/** - * Get client IP address from request - * Handles various proxy scenarios and falls back to 'unknown' - * - * @param request - Request object to extract IP from - * @returns Client IP address string - * - * @example - * const ip = getClientIP(request); - * const isAllowed = checkRateLimit(ip); - */ -export function getClientIP(request: Request): string { - // Try X-Forwarded-For first (common proxy header) - const forwarded = request.headers.get('x-forwarded-for'); - if (forwarded) { - // X-Forwarded-For can be a comma-separated list; use the first (original client) - return forwarded.split(',')[0].trim(); +// --------------------------------------------------------------------------- +// Redis client (singleton, lazy-init) +// --------------------------------------------------------------------------- + +let redis: Redis | null = null; +let redisAvailable = true; // flipped to false on connection failure + +function getRedis(): Redis | null { + if (!redisAvailable) return null; + if (redis) return redis; + + const restUrl = process.env.UPSTASH_REDIS_REST_URL; + const restToken = process.env.UPSTASH_REDIS_REST_TOKEN; + + if (restUrl && restToken) { + redis = new Redis({ url: restUrl, token: restToken }); + return redis; + } + + // Parse legacy redis:// URI into Upstash REST format + // Expected: redis://default:@.upstash.io:6379 + const legacyUri = process.env.LIMITER_STORAGE_URI; + if (legacyUri) { + try { + const parsed = new URL(legacyUri); + const host = parsed.hostname; + const token = decodeURIComponent(parsed.password); + if (host && token) { + redis = new Redis({ url: `https://${host}`, token }); + return redis; + } + } catch { + // Parsing failed — fall through to memory + } } - - // Try other common headers - const realIP = request.headers.get('x-real-ip'); + + // No Redis config — use memory + redisAvailable = false; + return null; +} + +// --------------------------------------------------------------------------- +// Upstash Ratelimit instances (cached per unique window config) +// --------------------------------------------------------------------------- + +const limiterCache = new Map(); + +function getLimiter(maxRequests: number, windowMs: number): Ratelimit | null { + const client = getRedis(); + if (!client) return null; + + const key = `${maxRequests}:${windowMs}`; + let limiter = limiterCache.get(key); + if (limiter) return limiter; + + const windowSec = Math.ceil(windowMs / 1000); + const windowStr = `${windowSec} s` as `${number} s`; + + limiter = new Ratelimit({ + redis: client, + limiter: Ratelimit.slidingWindow(maxRequests, windowStr), + prefix: "rr:rl", + }); + + limiterCache.set(key, limiter); + return limiter; +} + +// --------------------------------------------------------------------------- +// Public API (unchanged signatures) +// --------------------------------------------------------------------------- + +export function getClientIP(request: Request): string { + const forwarded = request.headers.get("x-forwarded-for"); + if (forwarded) return forwarded.split(",")[0].trim(); + const realIP = request.headers.get("x-real-ip"); if (realIP) return realIP; - - // Fallback to a default (for serverless/edge environments) - return 'unknown'; + return "unknown"; } -/** - * Check if request is allowed based on rate limit - * - * @param identifier - Unique identifier (IP address, user ID, etc.) - * @param maxRequests - Maximum requests allowed in the window (optional, uses default if not provided) - * @param windowMs - Time window in milliseconds (optional, uses default if not provided) - * @returns Object with allowed status and remaining requests - * - * @example - * const result = checkRateLimit('192.168.1.1'); - * if (result.allowed) { - * // Process request - * } else { - * // Rate limit exceeded - * } - * - * @example - * const result = checkRateLimit('user-123', 10, 60000); // 10 requests per minute - */ -export function checkRateLimit( +export async function checkRateLimit( identifier: string, maxRequests?: number, windowMs?: number -): { +): Promise<{ allowed: boolean; remaining: number; resetTime: number; totalLimit: number; -} { +}> { const limit = maxRequests ?? MAX_REQUESTS; const window = windowMs ?? WINDOW_MS; - const now = Date.now(); - const entry = rateLimitStore.get(identifier); - - if (!entry || now > entry.resetTime) { - // New window or expired - rateLimitStore.set(identifier, { - count: 1, - resetTime: now + window, - }); - return { - allowed: true, - remaining: limit - 1, - resetTime: now + window, - totalLimit: limit, - }; - } - - if (entry.count >= limit) { - // Rate limit exceeded - return { - allowed: false, - remaining: 0, - resetTime: entry.resetTime, - totalLimit: limit, - }; + + const limiter = getLimiter(limit, window); + if (limiter) { + try { + const result = await limiter.limit(identifier); + return { + allowed: result.success, + remaining: result.remaining, + resetTime: result.reset, + totalLimit: result.limit, + }; + } catch { + // Redis failed — fall through to memory + } } - - // Within limit - entry.count++; - return { - allowed: true, - remaining: limit - entry.count, - resetTime: entry.resetTime, - totalLimit: limit, - }; + + return memoryCheckRateLimit(identifier, limit, window); } -/** - * Get rate limit headers for response - * - * @param identifier - Unique identifier (IP address, user ID, etc.) - * @param maxRequests - Maximum requests allowed in the window (optional) - * @param windowMs - Time window in milliseconds (optional) - * @returns Headers object with rate limit information - * - * @example - * const headers = getRateLimitHeaders('192.168.1.1'); - * // Returns: { - * // 'X-RateLimit-Limit': '100', - * // 'X-RateLimit-Remaining': '99', - * // 'X-RateLimit-Reset': '2024-01-01T00:15:00.000Z' - * // } - */ -export function getRateLimitHeaders( - identifier: string, - maxRequests?: number, - windowMs?: number -): Record { - const limit = checkRateLimit(identifier, maxRequests, windowMs); +export function getRateLimitHeaders(result: { + allowed: boolean; + remaining: number; + resetTime: number; + totalLimit: number; +}): Record { return { - 'X-RateLimit-Limit': limit.totalLimit.toString(), - 'X-RateLimit-Remaining': Math.max(0, limit.remaining).toString(), - 'X-RateLimit-Reset': new Date(limit.resetTime).toISOString(), + "X-RateLimit-Limit": result.totalLimit.toString(), + "X-RateLimit-Remaining": Math.max(0, result.remaining).toString(), + "X-RateLimit-Reset": new Date(result.resetTime).toISOString(), }; } -/** - * Create a rate limit response with proper headers - * - * @param identifier - Unique identifier (IP address, user ID, etc.) - * @param retryAfterSeconds - Optional retry-after time in seconds - * @returns Response object with 429 status and rate limit information - * - * @example - * const response = createRateLimitResponse('192.168.1.1', 60); - * // Returns 429 response with Retry-After: 60 header - */ export function createRateLimitResponse( - identifier: string, + _identifier: string, retryAfterSeconds?: number ): Response { const headers = new Headers({ - 'Content-Type': 'application/json', - 'X-RateLimit-Limit': MAX_REQUESTS.toString(), - 'X-RateLimit-Remaining': '0', + "Content-Type": "application/json", + "X-RateLimit-Limit": MAX_REQUESTS.toString(), + "X-RateLimit-Remaining": "0", }); - + if (retryAfterSeconds) { - headers.set('Retry-After', retryAfterSeconds.toString()); + headers.set("Retry-After", retryAfterSeconds.toString()); } - + return new Response( JSON.stringify({ - error: 'Rate limit exceeded. Please try again later.', + error: "Rate limit exceeded. Please try again later.", retryAfter: retryAfterSeconds, }), - { - status: 429, - headers, - } + { status: 429, headers } ); } -// Auto-start cleanup when module loads +// Auto-start memory cleanup startRateLimitCleanup(); -// Export constants for use in route handlers export { MAX_REQUESTS, WINDOW_MS }; diff --git a/supabase/migrations/20260209000001_add_abn_unique_constraint.sql b/supabase/migrations/20260209000001_add_abn_unique_constraint.sql index d21ad72..21a8dc3 100644 --- a/supabase/migrations/20260209000001_add_abn_unique_constraint.sql +++ b/supabase/migrations/20260209000001_add_abn_unique_constraint.sql @@ -11,7 +11,7 @@ SELECT json_build_object( 'id', id, 'name', name, - 'email', email + 'profile_id', profile_id ) ) as companies FROM companies @@ -25,10 +25,14 @@ ORDER BY duplicate_count DESC, abn; -- Note: This migration uses a partial unique index to exclude NULL and empty ABNs BEGIN; +-- Drop existing constraint/index if present (from prior migration 20260208000001) +ALTER TABLE companies DROP CONSTRAINT IF EXISTS companies_abn_unique; +DROP INDEX IF EXISTS companies_abn_unique; + -- Create a partial unique index on ABN -- This allows multiple NULL values and multiple empty strings, but enforces uniqueness on actual ABN values -CREATE UNIQUE INDEX companies_abn_unique -ON companies (abn) +CREATE UNIQUE INDEX companies_abn_unique +ON companies (abn) WHERE abn IS NOT NULL AND abn != ''; -- Add a comment to document the constraint diff --git a/supabase/migrations/20260209000002_deep_audit_fixes.sql b/supabase/migrations/20260209000002_deep_audit_fixes.sql index 5873631..7f7f256 100644 --- a/supabase/migrations/20260209000002_deep_audit_fixes.sql +++ b/supabase/migrations/20260209000002_deep_audit_fixes.sql @@ -22,6 +22,7 @@ -- ============================================================ DROP POLICY IF EXISTS "System can insert notifications" ON public.notifications; +DROP POLICY IF EXISTS "Authenticated users can insert notifications" ON public.notifications; CREATE POLICY "Authenticated users can insert notifications" ON public.notifications FOR INSERT TO authenticated WITH CHECK (profile_id != auth.uid()); @@ -34,18 +35,22 @@ CREATE POLICY "Authenticated users can insert notifications" ON public.notificat -- conversations, conversation_participants intentionally omitted) -- ============================================================ +DROP POLICY IF EXISTS "Users can delete their own notifications" ON public.notifications; CREATE POLICY "Users can delete their own notifications" ON public.notifications FOR DELETE TO authenticated USING (profile_id = auth.uid()); +DROP POLICY IF EXISTS "Users can delete their own messages" ON public.messages; CREATE POLICY "Users can delete their own messages" ON public.messages FOR DELETE TO authenticated USING (sender_id = auth.uid()); +DROP POLICY IF EXISTS "Contractors can delete their own jobs" ON public.jobs; CREATE POLICY "Contractors can delete their own jobs" ON public.jobs FOR DELETE TO authenticated USING (company_id IN (SELECT id FROM public.companies WHERE profile_id = auth.uid())); +DROP POLICY IF EXISTS "Workers can withdraw their own applications" ON public.matches; CREATE POLICY "Workers can withdraw their own applications" ON public.matches FOR DELETE TO authenticated USING (worker_id = auth.uid() AND status IN ('applied', 'suggested')); diff --git a/supabase/migrations/20260209000003_add_delete_policies.sql b/supabase/migrations/20260209000003_add_delete_policies.sql index 2e204bc..4eb69cf 100644 --- a/supabase/migrations/20260209000003_add_delete_policies.sql +++ b/supabase/migrations/20260209000003_add_delete_policies.sql @@ -8,6 +8,7 @@ BEGIN; -- ============================================ -- profiles: Users can delete their own profile -- ============================================ +DROP POLICY IF EXISTS "Users can delete their own profile" ON profiles; CREATE POLICY "Users can delete their own profile" ON profiles FOR DELETE USING (auth.uid() = id); @@ -15,6 +16,7 @@ CREATE POLICY "Users can delete their own profile" -- ============================================ -- companies: Contractors can delete their own company -- ============================================ +DROP POLICY IF EXISTS "Contractors can delete their own company" ON companies; CREATE POLICY "Contractors can delete their own company" ON companies FOR DELETE USING (profile_id = auth.uid()); @@ -22,6 +24,7 @@ CREATE POLICY "Contractors can delete their own company" -- ============================================ -- worker_profiles: Workers can delete their own profile -- ============================================ +DROP POLICY IF EXISTS "Workers can delete their own profile" ON worker_profiles; CREATE POLICY "Workers can delete their own profile" ON worker_profiles FOR DELETE USING (profile_id = auth.uid()); @@ -29,6 +32,7 @@ CREATE POLICY "Workers can delete their own profile" -- ============================================ -- jobs: Contractors can delete their own jobs -- ============================================ +DROP POLICY IF EXISTS "Contractors can delete their own jobs" ON jobs; CREATE POLICY "Contractors can delete their own jobs" ON jobs FOR DELETE USING (company_id IN ( @@ -39,6 +43,8 @@ CREATE POLICY "Contractors can delete their own jobs" -- matches: Involved parties can delete matches -- Worker who applied OR contractor who owns the job -- ============================================ +DROP POLICY IF EXISTS "Involved parties can delete matches" ON matches; +DROP POLICY IF EXISTS "Workers can withdraw their own applications" ON matches; CREATE POLICY "Involved parties can delete matches" ON matches FOR DELETE USING ( @@ -53,6 +59,7 @@ CREATE POLICY "Involved parties can delete matches" -- ============================================ -- ratings: Users can delete ratings they authored -- ============================================ +DROP POLICY IF EXISTS "Users can delete their own ratings" ON ratings; CREATE POLICY "Users can delete their own ratings" ON ratings FOR DELETE USING (from_id = auth.uid()); @@ -60,6 +67,7 @@ CREATE POLICY "Users can delete their own ratings" -- ============================================ -- payments: Company owners can delete their payments -- ============================================ +DROP POLICY IF EXISTS "Company owners can delete their payments" ON payments; CREATE POLICY "Company owners can delete their payments" ON payments FOR DELETE USING (company_id IN ( @@ -69,6 +77,7 @@ CREATE POLICY "Company owners can delete their payments" -- ============================================ -- notifications: Users can delete their own notifications -- ============================================ +DROP POLICY IF EXISTS "Users can delete their own notifications" ON notifications; CREATE POLICY "Users can delete their own notifications" ON notifications FOR DELETE USING (profile_id = auth.uid()); @@ -76,6 +85,7 @@ CREATE POLICY "Users can delete their own notifications" -- ============================================ -- conversations: Participants can delete conversations they belong to -- ============================================ +DROP POLICY IF EXISTS "Users can delete their own conversations" ON conversations; CREATE POLICY "Users can delete their own conversations" ON conversations FOR DELETE USING (id IN ( @@ -86,6 +96,7 @@ CREATE POLICY "Users can delete their own conversations" -- ============================================ -- conversation_participants: Users can remove themselves from conversations -- ============================================ +DROP POLICY IF EXISTS "Users can delete their own participation" ON conversation_participants; CREATE POLICY "Users can delete their own participation" ON conversation_participants FOR DELETE USING (profile_id = auth.uid()); @@ -93,6 +104,7 @@ CREATE POLICY "Users can delete their own participation" -- ============================================ -- messages: Users can delete their own messages -- ============================================ +DROP POLICY IF EXISTS "Users can delete their own messages" ON messages; CREATE POLICY "Users can delete their own messages" ON messages FOR DELETE USING (sender_id = auth.uid()); diff --git a/tsconfig.json b/tsconfig.json index 006cb6d..d556847 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -37,7 +37,9 @@ "**/*.mts", ".next/dev/dev/types/**/*.ts", ".next-build/types/**/*.ts", - ".next-build/dev/types/**/*.ts" + ".next-build/dev/types/**/*.ts", + ".next-build2/types/**/*.ts", + ".next-build2/dev/types/**/*.ts" ], "exclude": [ "node_modules",