Fixes: - Rate limiter now properly fails open when Supabase unavailable - Moved createClient() calls inside try/catch blocks - Prevents unhandled exceptions from reaching API handler - Improved error logging for debugging Impact: - isRateLimited() - Wrapped createClient (line 22) - getRemainingAttempts() - Wrapped createClient (line 93) - getTimeToReset() - Wrapped createClient (line 128) Context: - QA Session 2 found API returning 500 errors - Root cause: Database table doesn't exist (requires manual migration) - This fix ensures rate limiter fails gracefully when DB unavailable - With DB present, rate limiting will work as designed Verified: - TypeScript compiles without errors - Code follows fail-open pattern - Error logging improved QA Fix Session: 2 Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
161 lines
5.2 KiB
TypeScript
161 lines
5.2 KiB
TypeScript
import { createClient } from '@/lib/supabase/server';
|
|
|
|
interface RateLimitRecord {
|
|
identifier: string;
|
|
count: number;
|
|
window_start: string;
|
|
created_at?: string;
|
|
updated_at?: string;
|
|
}
|
|
|
|
const WINDOW_SIZE_MS = 3600000; // 1 hour
|
|
const MAX_REQUESTS = 5; // Maximum requests per hour
|
|
|
|
class SupabaseRateLimiter {
|
|
/**
|
|
* Check if an identifier (IP address) is rate limited
|
|
* @param identifier - The IP address or unique identifier to check
|
|
* @returns true if rate limited, false otherwise
|
|
*/
|
|
async isRateLimited(identifier: string): Promise<boolean> {
|
|
try {
|
|
const supabase = await createClient();
|
|
const now = Date.now();
|
|
const windowStart = new Date(now - WINDOW_SIZE_MS).toISOString();
|
|
// Get the current rate limit record for this identifier
|
|
const { data: existingRecord, error: fetchError } = await supabase
|
|
.from('rate_limits')
|
|
.select('*')
|
|
.eq('identifier', identifier)
|
|
.gte('window_start', windowStart)
|
|
.order('window_start', { ascending: false })
|
|
.limit(1)
|
|
.single();
|
|
|
|
if (fetchError && fetchError.code !== 'PGRST116') {
|
|
// PGRST116 is "no rows returned" which is fine
|
|
console.error('Error fetching rate limit:', fetchError);
|
|
return false; // Fail open - don't block on errors
|
|
}
|
|
|
|
// No existing record or window expired - create new record
|
|
if (!existingRecord || new Date(existingRecord.window_start).getTime() < now - WINDOW_SIZE_MS) {
|
|
const { error: insertError } = await supabase
|
|
.from('rate_limits')
|
|
.insert({
|
|
identifier,
|
|
count: 1,
|
|
window_start: new Date(now).toISOString()
|
|
});
|
|
|
|
if (insertError) {
|
|
console.error('Error inserting rate limit:', insertError);
|
|
return false; // Fail open
|
|
}
|
|
|
|
return false; // First request in new window
|
|
}
|
|
|
|
// Check if limit exceeded
|
|
if (existingRecord.count >= MAX_REQUESTS) {
|
|
return true; // Rate limited
|
|
}
|
|
|
|
// Increment counter
|
|
const { error: updateError } = await supabase
|
|
.from('rate_limits')
|
|
.update({
|
|
count: existingRecord.count + 1,
|
|
updated_at: new Date().toISOString()
|
|
})
|
|
.eq('identifier', identifier)
|
|
.eq('window_start', existingRecord.window_start);
|
|
|
|
if (updateError) {
|
|
console.error('Error updating rate limit:', updateError);
|
|
return false; // Fail open
|
|
}
|
|
|
|
return false; // Not rate limited yet
|
|
} catch (error) {
|
|
console.error('[RateLimiter] Unexpected error in isRateLimited - FAILING OPEN:', error);
|
|
return false; // Fail open on ALL errors
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get the number of remaining attempts for an identifier
|
|
* @param identifier - The IP address or unique identifier to check
|
|
* @returns Number of remaining attempts (0 if rate limited)
|
|
*/
|
|
async getRemainingAttempts(identifier: string): Promise<number> {
|
|
try {
|
|
const supabase = await createClient();
|
|
const now = Date.now();
|
|
const windowStart = new Date(now - WINDOW_SIZE_MS).toISOString();
|
|
const { data: record, error } = await supabase
|
|
.from('rate_limits')
|
|
.select('count')
|
|
.eq('identifier', identifier)
|
|
.gte('window_start', windowStart)
|
|
.order('window_start', { ascending: false })
|
|
.limit(1)
|
|
.single();
|
|
|
|
if (error && error.code !== 'PGRST116') {
|
|
console.error('Error fetching remaining attempts:', error);
|
|
return MAX_REQUESTS; // Fail open
|
|
}
|
|
|
|
if (!record) {
|
|
return MAX_REQUESTS; // No record yet
|
|
}
|
|
|
|
return Math.max(0, MAX_REQUESTS - record.count);
|
|
} catch (error) {
|
|
console.error('[RateLimiter] Unexpected error in getRemainingAttempts - FAILING OPEN:', error);
|
|
return MAX_REQUESTS; // Fail open on ALL errors
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get the time in milliseconds until the rate limit resets
|
|
* @param identifier - The IP address or unique identifier to check
|
|
* @returns Milliseconds until reset (0 if no active limit)
|
|
*/
|
|
async getTimeToReset(identifier: string): Promise<number> {
|
|
try {
|
|
const supabase = await createClient();
|
|
const now = Date.now();
|
|
const windowStart = new Date(now - WINDOW_SIZE_MS).toISOString();
|
|
const { data: record, error } = await supabase
|
|
.from('rate_limits')
|
|
.select('window_start')
|
|
.eq('identifier', identifier)
|
|
.gte('window_start', windowStart)
|
|
.order('window_start', { ascending: false })
|
|
.limit(1)
|
|
.single();
|
|
|
|
if (error && error.code !== 'PGRST116') {
|
|
console.error('Error fetching time to reset:', error);
|
|
return 0;
|
|
}
|
|
|
|
if (!record) {
|
|
return 0; // No active window
|
|
}
|
|
|
|
const windowStartTime = new Date(record.window_start).getTime();
|
|
const resetTime = windowStartTime + WINDOW_SIZE_MS;
|
|
return Math.max(0, resetTime - now);
|
|
} catch (error) {
|
|
console.error('[RateLimiter] Unexpected error in getTimeToReset - FAILING OPEN:', error);
|
|
return 0; // Fail open on ALL errors
|
|
}
|
|
}
|
|
}
|
|
|
|
export const supabaseRateLimiter = new SupabaseRateLimiter();
|
|
export { WINDOW_SIZE_MS, MAX_REQUESTS };
|