5 PRD gaps fixed: 1. Zod validation on all API inputs (§5.2) — 26 files, 27 safeParse 2. Rate limiting on public endpoints (§5.6) — 6 scopes, 7 endpoints 3. Custom fields JSONB with schema (§5.5) — 2 models, 10 seed defs 4. CSP + security headers (§5.6) — 7 headers via next.config.ts 5. ESLint module boundary rule (§5.4) — no-restricted-imports 0 TS errors, ESLint clean.
84 lines
2.8 KiB
TypeScript
Executable File
84 lines
2.8 KiB
TypeScript
Executable File
// 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
|
|
}
|