fix: standardize rate limit headers and remove dead code

- Use rateLimitHeaders() spread on /api/posts success response
  to include X-RateLimit-Limit, X-RateLimit-Reset, Retry-After
  matching /api/search and /api/search/suggestions behavior
- Remove legacy checkRateLimit, RateLimitResult, rateLimitStore,
  and cleanup interval from src/lib/search.ts (dead code after
  migration to central searchLimiter)
- Update tests to use searchLimiter from @/lib/security instead
  of the removed checkRateLimit

All integration tests pass (20 passed, 12 skipped).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Martin Porwoll 2025-12-08 00:00:19 +00:00
parent 2fae62eaf3
commit 82b4a4e558
3 changed files with 8 additions and 56 deletions

View file

@ -134,7 +134,7 @@ export async function GET(request: NextRequest) {
return NextResponse.json(response, {
headers: {
'X-RateLimit-Remaining': String(rateLimit.remaining),
...rateLimitHeaders(rateLimit, POSTS_RATE_LIMIT),
'Cache-Control': 'public, max-age=60, s-maxage=60',
},
})

View file

@ -79,12 +79,6 @@ export interface PaginatedPosts {
hasPrevPage: boolean
}
export interface RateLimitResult {
allowed: boolean
remaining: number
retryAfter?: number
}
// ============================================================================
// TTL Cache Implementation
// ============================================================================
@ -154,48 +148,6 @@ class TTLCache<K, V> {
export const searchCache = new TTLCache<string, SearchResult>(60)
export const suggestionCache = new TTLCache<string, Suggestion[]>(60)
// ============================================================================
// Rate Limiting
// ============================================================================
interface RateLimitEntry {
count: number
windowStart: number
}
const rateLimitStore = new Map<string, RateLimitEntry>()
const RATE_LIMIT_WINDOW_MS = 60000 // 1 minute
const RATE_LIMIT_MAX_REQUESTS = 30 // 30 requests per minute
// Cleanup rate limit entries every 5 minutes
setInterval(() => {
const now = Date.now()
for (const [key, entry] of rateLimitStore.entries()) {
if (now - entry.windowStart > RATE_LIMIT_WINDOW_MS * 2) {
rateLimitStore.delete(key)
}
}
}, 300000)
export function checkRateLimit(ip: string): RateLimitResult {
const now = Date.now()
const entry = rateLimitStore.get(ip)
if (!entry || now - entry.windowStart > RATE_LIMIT_WINDOW_MS) {
// New window
rateLimitStore.set(ip, { count: 1, windowStart: now })
return { allowed: true, remaining: RATE_LIMIT_MAX_REQUESTS - 1 }
}
if (entry.count >= RATE_LIMIT_MAX_REQUESTS) {
const retryAfter = Math.ceil((entry.windowStart + RATE_LIMIT_WINDOW_MS - now) / 1000)
return { allowed: false, remaining: 0, retryAfter }
}
entry.count++
return { allowed: true, remaining: RATE_LIMIT_MAX_REQUESTS - entry.count }
}
// ============================================================================
// Helper Functions
// ============================================================================

View file

@ -5,11 +5,11 @@ import {
searchPosts,
getSearchSuggestions,
getPostsByCategory,
checkRateLimit,
searchCache,
suggestionCache,
extractTextFromLexical,
} from '@/lib/search'
import { searchLimiter } from '@/lib/security'
let payload: Payload
@ -78,24 +78,24 @@ describe('Search Library', () => {
})
})
describe('checkRateLimit', () => {
it('allows requests within limit', () => {
describe('searchLimiter (central rate limiter)', () => {
it('allows requests within limit', async () => {
const testIp = `test-${Date.now()}-1`
const result = checkRateLimit(testIp)
const result = await searchLimiter.check(testIp)
expect(result.allowed).toBe(true)
expect(result.remaining).toBe(29) // 30 - 1
})
it('blocks requests exceeding limit', () => {
it('blocks requests exceeding limit', async () => {
const testIp = `test-${Date.now()}-2`
// Use up all requests
for (let i = 0; i < 30; i++) {
checkRateLimit(testIp)
await searchLimiter.check(testIp)
}
const result = checkRateLimit(testIp)
const result = await searchLimiter.check(testIp)
expect(result.allowed).toBe(false)
expect(result.remaining).toBe(0)
expect(result.retryAfter).toBeGreaterThan(0)