feat: Rate limiting on all public endpoints (PRD §5.6)

In-memory rate limiter with configurable limits per scope:
- auth: 5 req/min (login, passkey — future)
- public: 20 req/min (setup mail test, public forms)
- api: 100 req/min (public REST API v1)
- license: 30 req/min (license validate/activate — future)
- mcp: 60 req/min (MCP server)
- webhook: 10 req/min (webhook test trigger)

Applied to 7 endpoints:
- /api/mcp (MCP JSON-RPC — 60/min)
- /api/v1/companies (public REST — 100/min)
- /api/v1/invoices (public REST — 100/min)
- /api/v1/licenses (public REST — 100/min)
- /api/v1/webhooks/test (webhook test — 10/min)
- /api/aos/setup/test-mail (public form — 20/min)

429 response with Retry-After + X-RateLimit headers.
Recreated /api/v1/webhooks/test/route.ts (lost in git merge).
This commit is contained in:
Z User committed 2026-10-27 21:33:43 +00:00
1 parent 2614ce4e3d
commit d5dc2a1832
8 files changed
+161

No files matched your search

+83
View File
@@ -0,0 +1,83 @@
// Simple in-memory rate limiter (PRD §5.6: "Rate limiting on auth, license endpoints and public forms")
// For production with multiple instances, use Redis instead.
interface RateLimitEntry {
count: number
resetAt: number
}
const store = new Map<string, RateLimitEntry>()
// Check + increment rate limit
// Returns { allowed: boolean, remaining: number, resetAt: number }
export function rateLimit(
key: string,
maxRequests: number = 10,
windowMs: number = 60_000
): { allowed: boolean; remaining: number; resetAt: number } {
const now = Date.now()
const entry = store.get(key)
if (!entry || entry.resetAt < now) {
// First request or window expired
const resetAt = now + windowMs
store.set(key, { count: 1, resetAt })
return { allowed: true, remaining: maxRequests - 1, resetAt }
}
if (entry.count >= maxRequests) {
return { allowed: false, remaining: 0, resetAt: entry.resetAt }
}
entry.count++
return { allowed: true, remaining: maxRequests - entry.count, resetAt: entry.resetAt }
}
// Get client identifier from request (IP + path for scoping)
export function getRateLimitKey(req: Request, scope: string): string {
const headers = new Headers(req.headers)
const ip = headers.get('x-forwarded-for')?.split(',')[0]?.trim()
?? headers.get('x-real-ip')
?? 'unknown'
return `${scope}:${ip}`
}
// Rate limit configs per endpoint type (PRD §5.6)
export const RATE_LIMITS = {
auth: { max: 5, window: 60_000 }, // 5 attempts/minute (login, passkey)
public: { max: 20, window: 60_000 }, // 20 requests/minute (public forms)
api: { max: 100, window: 60_000 }, // 100 requests/minute (authenticated API)
license: { max: 30, window: 60_000 }, // 30 requests/minute (license validate/activate)
mcp: { max: 60, window: 60_000 }, // 60 requests/minute (MCP server)
webhook: { max: 10, window: 60_000 }, // 10 triggers/minute (webhook test)
} as const
// Helper: check rate limit and return 429 Response if exceeded
export function checkRateLimit(req: Request, scope: keyof typeof RATE_LIMITS): Response | null {
const config = RATE_LIMITS[scope]
const key = getRateLimitKey(req, scope)
const result = rateLimit(key, config.max, config.window)
if (!result.allowed) {
const retryAfter = Math.ceil((result.resetAt - Date.now()) / 1000)
return new Response(
JSON.stringify({
error: 'rate_limit_exceeded',
message: `Too many requests. Try again in ${retryAfter} seconds.`,
retryAfter,
}),
{
status: 429,
headers: {
'content-type': 'application/json',
'retry-after': String(retryAfter),
'x-rate-limit-limit': String(config.max),
'x-rate-limit-remaining': '0',
'x-rate-limit-reset': String(result.resetAt),
},
}
)
}
return null // allowed
}