| import { Ratelimit, type Duration } from '@upstash/ratelimit'; |
| import { Redis } from '@upstash/redis'; |
| import { getClientIp } from './client-ip'; |
| |
| import { captureSilentError } from '../../api/_sentry-edge.js'; |
| |
| import { durationToSeconds, limitWithFallback, resetRateLimitFallbackForTest } from '../../api/_rate-limit-fallback.js'; |
|
|
| |
| |
| |
| |
| |
| |
| export { getClientIp, hasCloudflareTransitProof, UNKNOWN_CLIENT_IP } from './client-ip'; |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| const REDIS_TEST_RETRY_OPTS: { retry?: false } = process.env.NODE_TEST_CONTEXT ? { retry: false } : {}; |
|
|
| let ratelimit: Ratelimit | null = null; |
| const GLOBAL_RATE_LIMIT = 600; |
| const GLOBAL_RATE_WINDOW: Duration = '60 s'; |
| const GLOBAL_RATE_WINDOW_SECONDS = durationToSeconds(GLOBAL_RATE_WINDOW); |
|
|
| function getRatelimit(): Ratelimit | null { |
| if (ratelimit) return ratelimit; |
| const url = process.env.UPSTASH_REDIS_REST_URL; |
| const token = process.env.UPSTASH_REDIS_REST_TOKEN; |
| if (!url || !token) return null; |
|
|
| ratelimit = new Ratelimit({ |
| redis: new Redis({ url, token, ...REDIS_TEST_RETRY_OPTS }), |
| limiter: Ratelimit.slidingWindow(GLOBAL_RATE_LIMIT, GLOBAL_RATE_WINDOW), |
| prefix: 'rl', |
| analytics: false, |
| }); |
| return ratelimit; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| function rateLimitErrorLevel(stage: string, msg: string): 'warning' | 'error' { |
| if (stage.includes('missing-config')) return 'error'; |
| if (/Error running script|execution timed out|Command failed|ETIMEDOUT|ECONNRESET|ENOTFOUND|fetch failed|network|timed out|socket hang up|Redis unavailable|Redis unreachable/i.test(msg)) { |
| return 'warning'; |
| } |
| return 'error'; |
| } |
|
|
| function logRateLimitDegraded(stage: string, err: unknown): void { |
| const msg = err instanceof Error ? err.message : String(err); |
| console.error(`[rate-limit] redis-error stage=${stage} msg=${msg}`); |
| captureSilentError(err, { |
| tags: { surface: 'server', component: 'rate-limit', stage }, |
| fingerprint: ['rate-limit', 'redis-error', stage], |
| level: rateLimitErrorLevel(stage, msg), |
| }); |
| } |
|
|
| const scopedMissingConfigStages = new Set<string>(); |
|
|
| function logScopedRateLimitMissingConfig(scope: string): void { |
| const stage = `checkScopedRateLimit:${scope}:missing-config`; |
| if (scopedMissingConfigStages.has(stage)) return; |
| scopedMissingConfigStages.add(stage); |
| logRateLimitDegraded(stage, new Error('UPSTASH_REDIS_REST_URL or UPSTASH_REDIS_REST_TOKEN missing')); |
| } |
|
|
| |
| |
| |
| export const RATE_LIMIT_DEGRADED_HEADERS = { |
| 'X-RateLimit-Mode': 'degraded', |
| |
| |
| 'Retry-After': '5', |
| } as const; |
|
|
| function tooManyRequestsResponse(limit: number, reset: number, corsHeaders: Record<string, string>, windowSeconds: number): Response { |
| |
| |
| |
| const resetSeconds = Math.max(0, Math.ceil((reset - Date.now()) / 1000)); |
| return new Response(JSON.stringify({ error: 'Too many requests' }), { |
| status: 429, |
| headers: { |
| 'Content-Type': 'application/json', |
| |
| |
| |
| |
| 'RateLimit-Policy': `"default";q=${limit};w=${windowSeconds}`, |
| 'RateLimit-Limit': String(limit), |
| 'RateLimit-Remaining': '0', |
| 'RateLimit-Reset': String(resetSeconds), |
| RateLimit: `"default";r=0;t=${resetSeconds}`, |
| |
| 'X-RateLimit-Limit': String(limit), |
| 'X-RateLimit-Remaining': '0', |
| 'X-RateLimit-Reset': String(reset), |
| 'Retry-After': String(resetSeconds), |
| ...corsHeaders, |
| }, |
| }); |
| } |
|
|
| function rateLimitDegradedResponse(corsHeaders: Record<string, string>): Response { |
| return new Response(JSON.stringify({ error: 'Rate-limit service temporarily unavailable' }), { |
| status: 503, |
| headers: { |
| 'Content-Type': 'application/json', |
| ...RATE_LIMIT_DEGRADED_HEADERS, |
| ...corsHeaders, |
| }, |
| }); |
| } |
|
|
| export interface RateLimitOptions { |
| |
| |
| |
| |
| |
| |
| |
| |
| failClosed?: boolean; |
| |
| |
| |
| |
| |
| |
| principalUserId?: string; |
| } |
|
|
| export type EndpointRateLimitOptions = RateLimitOptions; |
|
|
| function getPrincipalRateLimitIdentifier(principalUserId?: string): string | null { |
| return principalUserId ? `user:${principalUserId}` : null; |
| } |
|
|
| export async function checkRateLimit(request: Request, corsHeaders: Record<string, string>, opts: RateLimitOptions = {}): Promise<Response | null> { |
| const rl = getRatelimit(); |
| if (!rl) { |
| if (opts.failClosed) { |
| logRateLimitDegraded('checkRateLimit:missing-config', new Error('Upstash Redis is not configured')); |
| return rateLimitDegradedResponse(corsHeaders); |
| } |
| return null; |
| } |
|
|
| |
| |
| |
| const identifier = |
| getPrincipalRateLimitIdentifier(opts.principalUserId) ?? |
| getClientIp(request); |
|
|
| try { |
| const { success, limit, reset } = await limitWithFallback( |
| rl, |
| identifier, |
| `rl:fw:${identifier}`, |
| GLOBAL_RATE_LIMIT, |
| GLOBAL_RATE_WINDOW_SECONDS, |
| ); |
|
|
| if (!success) { |
| return tooManyRequestsResponse(limit, reset, corsHeaders, GLOBAL_RATE_WINDOW_SECONDS); |
| } |
|
|
| return null; |
| } catch (err) { |
| logRateLimitDegraded('checkRateLimit', err); |
| if (opts.failClosed) return rateLimitDegradedResponse(corsHeaders); |
| return null; |
| } |
| } |
|
|
| |
|
|
| interface EndpointRatePolicy { |
| limit: number; |
| window: Duration; |
| } |
|
|
| |
| |
| |
| |
| export const ENDPOINT_RATE_POLICIES: Record<string, EndpointRatePolicy> = { |
| |
| |
| |
| '/api/news/v1/summarize-article': { limit: 30, window: '60 s' }, |
| '/api/news/v1/summarize-article-cache': { limit: 3000, window: '60 s' }, |
| '/api/intelligence/v1/classify-event': { limit: 600, window: '60 s' }, |
| |
| |
| |
| |
| |
| '/api/intelligence/v1/deduct-situation': { limit: 600, window: '60 s' }, |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| '/api/intelligence/v1/search-intel-history': { limit: 30, window: '60 s' }, |
| '/api/intelligence/v1/get-similar-events': { limit: 30, window: '60 s' }, |
| '/api/intelligence/v1/get-intel-timeline': { limit: 120, window: '60 s' }, |
| |
| |
| |
| |
| |
| |
| |
| '/api/conflict/v1/get-humanitarian-summary-batch': { limit: 30, window: '60 s' }, |
| '/api/military/v1/get-aircraft-details-batch': { limit: 30, window: '60 s' }, |
| |
| |
| '/api/batch/v1/execute': { limit: 30, window: '60 s' }, |
| |
| |
| '/api/sanctions/v1/lookup-sanction-entity': { limit: 30, window: '60 s' }, |
| |
| |
| |
| |
| |
| '/api/intelligence/v1/get-company-enrichment': { limit: 30, window: '60 s' }, |
| '/api/intelligence/v1/list-company-signals': { limit: 30, window: '60 s' }, |
| '/api/intelligence/v1/search-sec-filings': { limit: 30, window: '60 s' }, |
| |
| |
| |
| '/api/leads/v1/submit-contact': { limit: 3, window: '1 h' }, |
| '/api/leads/v1/register-interest': { limit: 5, window: '1 h' }, |
| |
| |
| |
| '/api/scenario/v1/run-scenario': { limit: 10, window: '60 s' }, |
| |
| |
| '/api/forecast/v1/trigger-simulation': { limit: 10, window: '60 s' }, |
| |
| |
| |
| '/api/maritime/v1/get-vessel-snapshot': { limit: 60, window: '60 s' }, |
| |
| |
| '/api/resilience/v1/get-resilience-ranking': { limit: 30, window: '60 s' }, |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| '/api/mcp-proxy': { limit: 30, window: '60 s' }, |
| |
| |
| |
| |
| |
| |
| |
| '/api/docs-mcp': { limit: 60, window: '60 s' }, |
| |
| |
| |
| |
| |
| |
| '/api/a2a': { limit: 60, window: '60 s' }, |
| |
| |
| |
| '/api/ask': { limit: 60, window: '60 s' }, |
| }; |
|
|
| interface RateLimitPolicyDecision { |
| reason: string; |
| } |
|
|
| |
| |
| |
| export const FAIL_CLOSED_ENDPOINT_RATE_POLICY_REQUIRED: Record<string, RateLimitPolicyDecision> = { |
| '/api/news/v1/summarize-article': { |
| reason: 'LLM-backed summarization can drive provider spend on cache misses.', |
| }, |
| '/api/intelligence/v1/classify-event': { |
| reason: 'AI classification performs expensive provider-backed analysis.', |
| }, |
| '/api/intelligence/v1/deduct-situation': { |
| reason: 'LLM-backed situational deduction can drive provider spend on cache misses.', |
| }, |
| '/api/intelligence/v1/search-intel-history': { |
| reason: 'Semantic history search embeds the caller\'s query through a paid embeddings provider on every request.', |
| }, |
| '/api/intelligence/v1/get-similar-events': { |
| reason: 'Precedent lookup embeds the caller\'s situation text through a paid embeddings provider on every request.', |
| }, |
| '/api/conflict/v1/get-humanitarian-summary-batch': { |
| reason: 'Batch summary fans out to the external HAPI (humdata) provider on cache miss.', |
| }, |
| '/api/intelligence/v1/get-company-enrichment': { |
| reason: 'Per-company composite fans out to SEC EDGAR and Finnhub on cache miss.', |
| }, |
| '/api/intelligence/v1/list-company-signals': { |
| reason: 'Per-company signal discovery fans out to SEC EDGAR and Finnhub on cache miss.', |
| }, |
| '/api/intelligence/v1/search-sec-filings': { |
| reason: 'Full-text filing search proxies SEC EDGAR on cache miss with unbounded query cardinality.', |
| }, |
| '/api/military/v1/get-aircraft-details-batch': { |
| reason: 'Batch enrichment fans out to the external Wingbits provider on cache miss.', |
| }, |
| '/api/batch/v1/execute': { |
| reason: 'Generic batch fan-out multiplies one request into up to 20 gateway sub-requests.', |
| }, |
| '/api/sanctions/v1/lookup-sanction-entity': { |
| reason: 'Live sanctions lookup proxies an external provider.', |
| }, |
| '/api/leads/v1/submit-contact': { |
| reason: 'Lead capture writes to Convex and sends email.', |
| }, |
| '/api/leads/v1/register-interest': { |
| reason: 'Lead capture writes to Convex and sends email.', |
| }, |
| '/api/scenario/v1/run-scenario': { |
| reason: 'Scenario runs are mutation-like jobs with a historical 10/min cap.', |
| }, |
| '/api/forecast/v1/trigger-simulation': { |
| reason: 'Forecast simulation trigger starts expensive backend work.', |
| }, |
| '/api/maritime/v1/get-vessel-snapshot': { |
| reason: 'Live vessel snapshots can generate high-frequency upstream load.', |
| }, |
| '/api/resilience/v1/get-resilience-ranking': { |
| reason: 'Cold/stale cache paths can synchronously warm the full country table.', |
| }, |
| }; |
|
|
| |
| |
| |
| |
| export const GLOBAL_RATE_LIMIT_FALLBACK_READ_ROUTES: Record<string, RateLimitPolicyDecision> = { |
| '/api/aviation/v1/list-airport-delays': { |
| reason: 'Read-only cache-backed airport delay listing; availability-first fallback is acceptable.', |
| }, |
| '/api/intelligence/v1/list-material-events': { |
| reason: 'Read-only Redis read of the seeded 8-K stream; no upstream fetch on miss, so availability-first fallback carries no spend risk.', |
| }, |
| }; |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| export const RATE_LIMIT_MUTATION_FALLBACK_EXEMPT: Record<string, RateLimitPolicyDecision> = { |
| '/api/economic/v1/get-fred-series-batch': { |
| reason: |
| 'Read-only despite POST shape: reads seeded FRED data from the Redis seed cache only; all external FRED API calls happen in the Railway seed job, so a cache miss never fans out to an external provider.', |
| }, |
| '/api/infrastructure/v1/record-baseline-snapshot': { |
| reason: |
| 'Redis-only write (setCachedJson) with no external provider or LLM call; if Redis is degraded the write itself cannot land, so the fail-open fallback carries no spend/abuse risk.', |
| }, |
| '/api/v2/shipping/webhooks': { |
| reason: |
| 'Webhook registration is API-key authenticated (validateApiKey) and premium-gated before any work, so unauthenticated abuse is already blocked; the handler only writes to Redis, with no external provider or LLM spend.', |
| }, |
| }; |
|
|
| const endpointLimiters = new Map<string, Ratelimit>(); |
|
|
| function getEndpointRatelimit(pathname: string): Ratelimit | null { |
| const policy = ENDPOINT_RATE_POLICIES[pathname]; |
| if (!policy) return null; |
|
|
| const cached = endpointLimiters.get(pathname); |
| if (cached) return cached; |
|
|
| const url = process.env.UPSTASH_REDIS_REST_URL; |
| const token = process.env.UPSTASH_REDIS_REST_TOKEN; |
| if (!url || !token) return null; |
|
|
| const rl = new Ratelimit({ |
| redis: new Redis({ url, token, ...REDIS_TEST_RETRY_OPTS }), |
| limiter: Ratelimit.slidingWindow(policy.limit, policy.window), |
| prefix: 'rl:ep', |
| analytics: false, |
| }); |
| endpointLimiters.set(pathname, rl); |
| return rl; |
| } |
|
|
| export function hasEndpointRatePolicy(pathname: string): boolean { |
| return pathname in ENDPOINT_RATE_POLICIES; |
| } |
|
|
| export async function checkEndpointRateLimit(request: Request, pathname: string, corsHeaders: Record<string, string>, opts: EndpointRateLimitOptions = {}): Promise<Response | null> { |
| if (!hasEndpointRatePolicy(pathname)) return null; |
|
|
| const rl = getEndpointRatelimit(pathname); |
| if (!rl) { |
| const failClosed = opts.failClosed ?? true; |
| if (failClosed) { |
| logRateLimitDegraded(`checkEndpointRateLimit:${pathname}:missing-config`, new Error('Upstash Redis is not configured')); |
| return rateLimitDegradedResponse(corsHeaders); |
| } |
| return null; |
| } |
|
|
| const identifier = |
| getPrincipalRateLimitIdentifier(opts.principalUserId) ?? |
| `ip:${getClientIp(request)}`; |
| const policy = ENDPOINT_RATE_POLICIES[pathname]; |
| |
| |
| |
| if (!policy) return null; |
|
|
| try { |
| const { success, limit, reset } = await limitWithFallback(rl, `${pathname}:${identifier}`, `rl:ep:fw:${pathname}:${identifier}`, policy.limit, durationToSeconds(policy.window)); |
|
|
| if (!success) { |
| return tooManyRequestsResponse(limit, reset, corsHeaders, durationToSeconds(policy.window)); |
| } |
|
|
| return null; |
| } catch (err) { |
| logRateLimitDegraded(`checkEndpointRateLimit:${pathname}`, err); |
| |
| |
| |
| |
| const failClosed = opts.failClosed ?? true; |
| if (failClosed) return rateLimitDegradedResponse(corsHeaders); |
| return null; |
| } |
| } |
|
|
| |
| |
| |
| |
| |
| |
|
|
| const scopedLimiters = new Map<string, Ratelimit>(); |
|
|
| function getScopedRatelimit(scope: string, limit: number, window: Duration): Ratelimit | null { |
| const cacheKey = `${scope}|${limit}|${window}`; |
| const cached = scopedLimiters.get(cacheKey); |
| if (cached) return cached; |
|
|
| const url = process.env.UPSTASH_REDIS_REST_URL; |
| const token = process.env.UPSTASH_REDIS_REST_TOKEN; |
| if (!url || !token) return null; |
|
|
| const rl = new Ratelimit({ |
| redis: new Redis({ url, token, ...REDIS_TEST_RETRY_OPTS }), |
| limiter: Ratelimit.slidingWindow(limit, window), |
| prefix: 'rl:scope', |
| analytics: false, |
| }); |
| scopedLimiters.set(cacheKey, rl); |
| return rl; |
| } |
|
|
| export interface ScopedRateLimitResult { |
| allowed: boolean; |
| limit: number; |
| reset: number; |
| |
| |
| |
| |
| |
| |
| degraded: boolean; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| export async function checkScopedRateLimit(scope: string, limit: number, window: Duration, identifier: string): Promise<ScopedRateLimitResult> { |
| const rl = getScopedRatelimit(scope, limit, window); |
| if (!rl) { |
| logScopedRateLimitMissingConfig(scope); |
| return { allowed: true, limit, reset: 0, degraded: true }; |
| } |
| try { |
| const result = await limitWithFallback(rl, `${scope}:${identifier}`, `rl:scope:fw:${scope}:${identifier}`, limit, durationToSeconds(window)); |
| return { |
| allowed: result.success, |
| limit: result.limit, |
| reset: result.reset, |
| degraded: false, |
| }; |
| } catch (err) { |
| logRateLimitDegraded(`checkScopedRateLimit:${scope}`, err); |
| return { allowed: true, limit, reset: 0, degraded: true }; |
| } |
| } |
|
|
| |
| |
| |
| |
| |
| |
| export async function checkFailClosedScopedIpRateLimit( |
| request: Request, |
| scope: string, |
| limit: number, |
| window: Duration, |
| corsHeaders: Record<string, string>, |
| ): Promise<Response | null> { |
| const result = await checkScopedRateLimit(scope, limit, window, getClientIp(request)); |
| if (result.degraded) return rateLimitDegradedResponse(corsHeaders); |
| if (!result.allowed) { |
| return tooManyRequestsResponse(result.limit, result.reset, corsHeaders, durationToSeconds(window)); |
| } |
| return null; |
| } |
|
|
| export function __resetRateLimitForTest(): void { |
| ratelimit = null; |
| endpointLimiters.clear(); |
| scopedLimiters.clear(); |
| scopedMissingConfigStages.clear(); |
| resetRateLimitFallbackForTest(); |
| } |
|
|