Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions apps/web/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,12 @@ NEXT_PUBLIC_POSTHOG_HOST=

# Upstash Redis (optional — enables cross-instance webhook rate limiting).
# Without these, the rate limiter falls back to an in-memory Map per function
# instance. Provision via Vercel Marketplace → Upstash Redis; both vars are
# populated automatically into your Vercel env.
# instance. Two Vercel Marketplace integrations work interchangeably; pick
# either and the code auto-selects:
# 1. "Upstash for Redis" → populates UPSTASH_REDIS_REST_URL/TOKEN
# 2. "Upstash KV" → populates KV_REST_API_URL/TOKEN
# Both point at the same Redis-compatible service. Only one pair needs values.
UPSTASH_REDIS_REST_URL=
UPSTASH_REDIS_REST_TOKEN=
# KV_REST_API_URL=
# KV_REST_API_TOKEN=
54 changes: 34 additions & 20 deletions apps/web/lib/utils/rate-limiter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,21 @@
*
* Two modes, auto-selected at module load:
*
* 1. **Upstash Redis** (preferred, production) — when both
* `UPSTASH_REDIS_REST_URL` and `UPSTASH_REDIS_REST_TOKEN` are set.
* Sliding-window limit shared across every Vercel function instance
* and region. Set these via the Upstash marketplace integration in
* Vercel or `vercel env add`.
* 1. **Upstash Redis** (preferred, production) — sliding-window limit
* shared across every Vercel function instance and region. Two env-var
* schemes are accepted so either Vercel Marketplace integration works
* without config:
*
* 2. **In-memory Map fallback** — when Upstash env vars are absent
* (local dev, preview deploys without the integration, or incident
* recovery). Per-instance state; resets on cold start. The HMAC
* signature check remains the real auth, so this is defense-in-depth.
* - `UPSTASH_REDIS_REST_URL` + `UPSTASH_REDIS_REST_TOKEN`
* (installed via the "Upstash for Redis" marketplace integration)
* - `KV_REST_API_URL` + `KV_REST_API_TOKEN`
* (installed via the "Upstash KV" marketplace integration, a rename
* of the legacy Vercel KV product)
*
* 2. **In-memory Map fallback** — when neither scheme is set (local dev,
* preview deploys without the integration, or incident recovery).
* Per-instance state; resets on cold start. The HMAC signature check
* remains the real auth, so this is defense-in-depth.
*
* The public API is `checkRateLimit(ip, limitPerMinute)` returning
* `Promise<boolean>`. Callers `await` it identically in both modes.
Expand All @@ -26,23 +31,32 @@ const WINDOW_MS = 60_000

// ── Upstash branch (only wired up when env vars are present) ──────────────

const hasUpstashEnv =
typeof process.env.UPSTASH_REDIS_REST_URL === 'string'
&& process.env.UPSTASH_REDIS_REST_URL.length > 0
&& typeof process.env.UPSTASH_REDIS_REST_TOKEN === 'string'
&& process.env.UPSTASH_REDIS_REST_TOKEN.length > 0
function resolveUpstashCreds(): { url: string; token: string } | null {
// Prefer the Upstash-native names when both are present.
const upstashUrl = process.env.UPSTASH_REDIS_REST_URL
const upstashToken = process.env.UPSTASH_REDIS_REST_TOKEN
if (upstashUrl && upstashToken) return { url: upstashUrl, token: upstashToken }

// Fall back to the KV-flavoured names from the "Upstash KV" integration.
const kvUrl = process.env.KV_REST_API_URL
const kvToken = process.env.KV_REST_API_TOKEN
if (kvUrl && kvToken) return { url: kvUrl, token: kvToken }

return null
}

// `Redis.fromEnv()` throws on malformed values (e.g. a typo when running
// `vercel env add`). Catch at module load so the route keeps working on the
// in-memory fallback instead of crashing every cold start.
// `new Redis({ url, token })` validates the URL synchronously and will throw
// on a typo (e.g. after `vercel env add`). Catch at module load so the route
// keeps working on the in-memory fallback instead of crashing every cold start.
let upstashRedis: Redis | null = null
if (hasUpstashEnv) {
const upstashCreds = resolveUpstashCreds()
if (upstashCreds) {
try {
upstashRedis = Redis.fromEnv()
upstashRedis = new Redis({ url: upstashCreds.url, token: upstashCreds.token })
} catch (err) {
logger.error(
{ err },
'rate-limiter: Upstash env vars are set but Redis.fromEnv() failed; falling back to in-memory',
'rate-limiter: Upstash env vars are set but Redis constructor failed; falling back to in-memory',
)
upstashRedis = null
}
Expand Down
2 changes: 2 additions & 0 deletions turbo.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@
"CRON_SECRET",
"UPSTASH_REDIS_REST_URL",
"UPSTASH_REDIS_REST_TOKEN",
"KV_REST_API_URL",
"KV_REST_API_TOKEN",
"PUSH_SELECT_CONCURRENCY",
"PUSH_DISPATCH_CONCURRENCY"
]
Expand Down
Loading