Files
property-management-network/lib/rate-limit.ts
T
Leon SerfatyandClaude Opus 5 1d02598786 chore: sync in-progress work across marketing, admin, API and tests
Snapshot of uncommitted work that had accumulated in the tree alongside
the Turnstile changes:

- marketing pages, SEO helpers (lib/seo.ts, lib/marketing/) and
  structured data
- admin billing actions and a per-user portfolio view, plus an admin
  error boundary
- rate limiting (lib/rate-limit.ts) applied across the /api/v1 surface
- CSP and proxy adjustments, accounting/webhook lib updates
- Playwright config and an e2e/unit test suite
- next bumped to ^16.3.4 with the lockfile regenerated
- generated AGENTS.md / CLAUDE.md

Authored by other sessions working in this tree; committed here so the
Turnstile work could be pushed without leaving the tree dirty.
Typecheck passes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-05 16:27:16 -04:00

108 lines
3.7 KiB
TypeScript

import { NextResponse } from "next/server"
// ============================================================================
// Fixed-window rate limiting for endpoints Better Auth does not cover.
//
// Better Auth throttles its own /api/auth/* endpoints (see lib/auth.ts). Nothing
// else was limited: the public v1 API, uploads, and the unauthenticated tenant
// portal maintenance submission were all unbounded. This closes that gap.
//
// SCOPE / LIMITATION: counters live in the process, so the limit is enforced
// PER INSTANCE. On a single container (the current Dokploy deployment) that is
// the real limit; if the app is ever scaled horizontally, an attacker's
// effective ceiling multiplies by the replica count. That is still a bounded,
// large improvement over "no limit at all", and the swap to a shared store
// (Postgres or Redis) only has to replace `hit()` below. Do NOT let the absence
// of a shared store be a reason to ship no limit.
// ============================================================================
type Counter = { count: number; resetAt: number }
const buckets = new Map<string, Counter>()
// Drop expired counters so a long-lived process doesn't accumulate a key per
// distinct IP forever. Runs opportunistically on write, not on a timer.
let lastSweep = 0
function sweep(now: number): void {
if (now - lastSweep < 60_000) return
lastSweep = now
for (const [key, c] of buckets) {
if (c.resetAt <= now) buckets.delete(key)
}
}
export type RateLimitResult = {
ok: boolean
/** Requests remaining in the current window. */
remaining: number
/** Seconds until the window resets — sent as Retry-After on a 429. */
retryAfter: number
limit: number
}
/**
* Record a hit against `key` and report whether it is within `limit` per
* `windowSeconds`. Callers pass a namespaced key (e.g. `v1:<userId>`) so
* different endpoints never share a bucket.
*/
export function hit(key: string, limit: number, windowSeconds: number): RateLimitResult {
const now = Date.now()
sweep(now)
const existing = buckets.get(key)
if (!existing || existing.resetAt <= now) {
const resetAt = now + windowSeconds * 1000
buckets.set(key, { count: 1, resetAt })
return { ok: true, remaining: limit - 1, retryAfter: windowSeconds, limit }
}
existing.count++
const retryAfter = Math.max(1, Math.ceil((existing.resetAt - now) / 1000))
return {
ok: existing.count <= limit,
remaining: Math.max(0, limit - existing.count),
retryAfter,
limit,
}
}
/**
* The caller's IP, taken from the proxy headers Traefik/Dokploy set. Falls back
* to a constant so a missing header degrades to one shared bucket (fail closed
* on volume) rather than to no limit at all.
*/
export function clientIp(request: Request): string {
const fwd = request.headers.get("x-forwarded-for")?.split(",")[0]?.trim()
return fwd || request.headers.get("x-real-ip") || "unknown"
}
/** Standard 429 with rate-limit headers, or null when the request is allowed. */
export function rateLimitResponse(result: RateLimitResult): NextResponse | null {
if (result.ok) return null
return NextResponse.json(
{ error: "Too many requests. Please slow down and try again shortly." },
{
status: 429,
headers: {
"Retry-After": String(result.retryAfter),
"X-RateLimit-Limit": String(result.limit),
"X-RateLimit-Remaining": "0",
},
}
)
}
/**
* Convenience wrapper: hit the bucket and return a ready 429 if over the limit.
*
* const limited = enforceRateLimit(`upload:${ownerId}`, 30, 60)
* if (limited) return limited
*/
export function enforceRateLimit(
key: string,
limit: number,
windowSeconds: number
): NextResponse | null {
return rateLimitResponse(hit(key, limit, windowSeconds))
}