feat(security): rate-limit cost-bearing endpoints (sandbox, LLM, STT)#24
Conversation
Adds a Redis-backed sliding-window rate limiter (uses existing ioredis + lib/redis.ts infra). Applied to every endpoint that triggers a paid third-party API: Vercel sandbox provisioning, ElevenLabs transcription, LLM-gated title/commit-message generation. Per-user buckets via Privy session id; falls back to client IP when unauthenticated. Fails open with logged errors when Redis is unreachable so a Redis blip doesn't take down the API — pair with observability. Limits are conservative defaults (e.g. 20 sandbox creates / hour / user); tune up after observing real traffic.
|
@Juanpaulo1128 is attempting to deploy a commit to the Recoupable Team on Vercel. A member of the Team first needs to authorize it. |
📝 WalkthroughWalkthroughThis PR introduces Redis-backed rate limiting across multiple API routes. A new ChangesRate-Limiting Infrastructure & Route Adoption
Sequence Diagram(s)(Skipped: Changes are primarily infrastructure adoption—rate-limit library applied consistently across routes without introducing new multi-component control flow. Individual route changes are simple addition of rate-limiting checks and header wrapping, not complex sequential interactions between 3+ distinct components.) Estimated code review effort🎯 4 (Complex) | ⏱️ ~45 minutes Rationale: The PR spans 6 API routes with non-trivial heterogeneous edits. The rate-limit library itself is moderately complex (Redis pipeline logic, client lifecycle, fail-open semantics). Most route changes follow a consistent pattern (enforce → wrap responses) but require careful verification across each route to ensure all code paths are wrapped correctly. The Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
Tip 💬 Introducing Slack Agent: The best way for teams to turn conversations into code.Slack Agent is built on CodeRabbit's deep understanding of your code, so your team can collaborate across the entire SDLC without losing context.
Built for teams:
One agent for your entire SDLC. Right inside Slack. Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
| } | ||
|
|
||
| let body: unknown; | ||
| const limit = await enforceRateLimit( |
There was a problem hiding this comment.
Why are rate limits necessary in this codebase?
What specific problem is being solved?
There was a problem hiding this comment.
Actionable comments posted: 5
🧹 Nitpick comments (2)
apps/web/app/api/generate-title/route.ts (1)
1-93: 💤 Low valueVerify: This endpoint now duplicates
transcribe/route.ts.The AI summary notes this was "rewritten to perform audio transcription instead of generating title." The implementation is now functionally identical to
/api/transcribe:
- Same
TranscribeRequestBodyinterface- Same ElevenLabs
scribe_v1model- Same
providerOptions- Same validation and error handling
Is this intentional (e.g., different clients use different paths), or should this route retain its original title-generation behavior? If transcription was the intent, consider whether one endpoint could be deprecated to avoid maintaining duplicate code.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@apps/web/app/api/generate-title/route.ts` around lines 1 - 93, The route apps/web/app/api/generate-title/route.ts now duplicates the transcription logic (TranscribeRequestBody, transcribe call, ElevenLabs options) instead of generating titles; either restore the original title-generation behavior in the POST handler (replace the transcribe flow with the original generate-title logic: accept input audio/text, call the title-generation function or model, and return the generated title) or explicitly delegate to the existing transcribe endpoint to avoid duplication (have POST call the transcribe API or import a shared transcribe helper and then run the title-generation step), updating or removing TranscribeRequestBody, the elevenlabs.transcription("scribe_v1") usage, and any providerOptions accordingly; ensure the exported POST function name/signature remains and update tests/routes that expect /api/generate-title to either return titles or be redirected/deprecated.apps/web/app/api/sandbox/extend/route.ts (1)
33-111: ⚡ Quick winCode duplication: DELETE handler is identical to
sandbox/route.tsDELETE.Lines 33-111 are functionally identical to the DELETE handler in
apps/web/app/api/sandbox/route.ts. Consider extracting the shared stop-sandbox logic to a helper function to avoid maintaining duplicate code.Suggested approach
Extract to a shared helper in
@/lib/sandbox/:// In `@/lib/sandbox/stop-sandbox-handler.ts` export async function handleStopSandboxRequest( req: Request, authResult: AuthResult, limitHeaders: RateLimitHeaders, ): Promise<Response> { // Shared validation, stop, and state-clearing logic }Then both route files can delegate:
export async function DELETE(req: Request) { // auth + rate limit return handleStopSandboxRequest(req, authResult, limit.headers); }🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@apps/web/app/api/sandbox/extend/route.ts` around lines 33 - 111, The DELETE handler in this file duplicates the DELETE in apps/web/app/api/sandbox/route.ts; extract the shared stop-sandbox flow (validation of JSON+sessionId, requireOwnedSession, canOperateOnSandbox check, connectSandbox/stop, clearSandboxState and updateSession calls, and building the final Response with withRateLimitHeaders) into a single helper (e.g., export async function handleStopSandboxRequest(req: Request, authResult: AuthResult, limitHeaders: RateLimitHeaders): Promise<Response>) placed under a shared module like `@/lib/sandbox/stop-sandbox-handler.ts`, then have both route DELETE functions perform authentication + enforceRateLimit and simply return handleStopSandboxRequest(req, authResult, limit.headers); keep the same referenced helpers (requireAuthenticatedUser, enforceRateLimit, requireOwnedSession, canOperateOnSandbox, connectSandbox, clearSandboxState, updateSession, withRateLimitHeaders) so behavior remains identical.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@apps/web/app/api/sandbox/extend/route.ts`:
- Around line 20-31: The POST handler currently calls
handleCreateSandboxRequest, which creates new sandboxes (breaking the intended
"extend" semantics); change POST in this file to call the extension logic (e.g.,
a handleExtendSandboxRequest or an updated extendSandbox function) and pass the
sandbox identifier/params required to extend timeouts rather than
repoUrl/default creation params, preserving existing rate-limit and session
logic (symbols: POST, handleCreateSandboxRequest, enforceRateLimit,
withRateLimitHeaders). Also remove or consolidate duplicated DELETE logic
between this file and sandbox/route.ts by extracting the shared stop/teardown
logic into a single function (e.g., stopSandbox or handleStopSandbox) and
importing/using it from both routes or keeping it only in the canonical sandbox
route to avoid duplicate behavior.
In `@apps/web/app/api/sessions/`[sessionId]/generate-commit-message/route.ts:
- Around line 23-28: The code references RATE_LIMITS.generateCommitMessage which
doesn't exist; add a new entry named generateCommitMessage to the RATE_LIMITS
export in the rate-limit module (rate-limit.ts) so enforceRateLimit(req,
RATE_LIMITS.generateCommitMessage, session.user.id) compiles; define sensible
rate values consistent with other entries (same shape/keys as other RATE_LIMITS
items) and export it so the route.ts call to enforceRateLimit can use
RATE_LIMITS.generateCommitMessage without TypeScript errors.
In `@apps/web/app/lib/rate-limit.ts`:
- Around line 40-50: Update the enforceRateLimit function signature to accept
three parameters (req: Request, preset: { limit: number; windowSec: number },
userId: string | null) instead of a single RateLimitOptions object, and change
the RateLimitResult success variant to include a headers property (e.g.,
headers: Headers) so callers can read limit.headers; update any internal uses
inside enforceRateLimit (e.g., where you build rate-limit metadata and resetAt)
to populate and return the headers along with ok, limit, remaining and resetAt,
and ensure the failure variant still returns { ok: false, response: Response }
unchanged.
- Around line 179-186: Add a missing RATE_LIMITS entry for
generateCommitMessage: update the exported RATE_LIMITS constant to include a
generateCommitMessage key with limit 60 and windowSec 60 * 60 (3600) so that
references to RATE_LIMITS.generateCommitMessage in
generate-commit-message/route.ts resolve; ensure the new entry follows the same
shape as other entries and preserves the as const satisfies Record<string, {
limit: number; windowSec: number }> typing.
- Around line 7-10: Add and export the missing helper function
withRateLimitHeaders: implement a function named withRateLimitHeaders that
accepts a Response (or NextResponse/Response-like) and a headers object (or
values) and attaches standard rate-limit headers (e.g., X-RateLimit-Limit,
X-RateLimit-Remaining, X-RateLimit-Reset) to the returned response; locate where
rate-limit state is computed (references to cachedClient and
warnedClientFailure) and read the header/value sources used by route handlers,
set those headers on the response, then export the function (export function
withRateLimitHeaders...) from this module so the six route files can import it
without compile errors.
---
Nitpick comments:
In `@apps/web/app/api/generate-title/route.ts`:
- Around line 1-93: The route apps/web/app/api/generate-title/route.ts now
duplicates the transcription logic (TranscribeRequestBody, transcribe call,
ElevenLabs options) instead of generating titles; either restore the original
title-generation behavior in the POST handler (replace the transcribe flow with
the original generate-title logic: accept input audio/text, call the
title-generation function or model, and return the generated title) or
explicitly delegate to the existing transcribe endpoint to avoid duplication
(have POST call the transcribe API or import a shared transcribe helper and then
run the title-generation step), updating or removing TranscribeRequestBody, the
elevenlabs.transcription("scribe_v1") usage, and any providerOptions
accordingly; ensure the exported POST function name/signature remains and update
tests/routes that expect /api/generate-title to either return titles or be
redirected/deprecated.
In `@apps/web/app/api/sandbox/extend/route.ts`:
- Around line 33-111: The DELETE handler in this file duplicates the DELETE in
apps/web/app/api/sandbox/route.ts; extract the shared stop-sandbox flow
(validation of JSON+sessionId, requireOwnedSession, canOperateOnSandbox check,
connectSandbox/stop, clearSandboxState and updateSession calls, and building the
final Response with withRateLimitHeaders) into a single helper (e.g., export
async function handleStopSandboxRequest(req: Request, authResult: AuthResult,
limitHeaders: RateLimitHeaders): Promise<Response>) placed under a shared module
like `@/lib/sandbox/stop-sandbox-handler.ts`, then have both route DELETE
functions perform authentication + enforceRateLimit and simply return
handleStopSandboxRequest(req, authResult, limit.headers); keep the same
referenced helpers (requireAuthenticatedUser, enforceRateLimit,
requireOwnedSession, canOperateOnSandbox, connectSandbox, clearSandboxState,
updateSession, withRateLimitHeaders) so behavior remains identical.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 14171596-e974-4b4e-b0ae-65692bb6cb22
📒 Files selected for processing (7)
apps/web/app/api/generate-title/route.tsapps/web/app/api/sandbox/extend/route.tsapps/web/app/api/sandbox/route.tsapps/web/app/api/sandbox/snapshot/route.tsapps/web/app/api/sessions/[sessionId]/generate-commit-message/route.tsapps/web/app/api/transcribe/route.tsapps/web/app/lib/rate-limit.ts
| export async function POST(req: Request): Promise<Response> { | ||
| // Rate-limit by user identity when known; the underlying handler | ||
| // performs its own auth check, so this is purely additive. | ||
| const session = await getServerSession(); | ||
| const userId = session?.user?.id ?? null; | ||
|
|
||
| const limit = await enforceRateLimit(req, RATE_LIMITS.sandboxCreate, userId); | ||
| if (!limit.ok) return limit.response; | ||
|
|
||
| interface ExtendRequest { | ||
| sessionId: string; | ||
| const response = await handleCreateSandboxRequest(req); | ||
| return withRateLimitHeaders(response, limit.headers); | ||
| } |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
# First, let's examine the full route.ts file at the specified path
find . -type f -name "route.ts" | grep -i "sandbox" | head -20Repository: recoupable/open-agents
Length of output: 324
🏁 Script executed:
# Check if the file exists and read it
cat -n apps/web/app/api/sandbox/extend/route.ts 2>/dev/null || echo "File not found at specified path"Repository: recoupable/open-agents
Length of output: 3982
🏁 Script executed:
# Search for handleCreateSandboxRequest definition
rg "handleCreateSandboxRequest" --type ts --type tsx -A 5 | head -50Repository: recoupable/open-agents
Length of output: 94
🏁 Script executed:
# Also check the sandbox/route.ts for comparison
cat -n apps/web/app/api/sandbox/route.ts 2>/dev/null | head -100Repository: recoupable/open-agents
Length of output: 3698
🏁 Script executed:
# Find and examine handleCreateSandboxRequest
find . -type f -name "*create-sandbox*" | grep -v node_modulesRepository: recoupable/open-agents
Length of output: 211
🏁 Script executed:
# Search for handleCreateSandboxRequest definition and usage
rg "handleCreateSandboxRequest" -A 3 | head -40Repository: recoupable/open-agents
Length of output: 1485
🏁 Script executed:
# Compare the DELETE handlers in both sandbox routes
echo "=== sandbox/extend/route.ts DELETE ===" && \
sed -n '33,111p' apps/web/app/api/sandbox/extend/route.ts && \
echo -e "\n=== sandbox/route.ts ===" && \
cat -n apps/web/app/api/sandbox/route.ts | head -80Repository: recoupable/open-agents
Length of output: 5014
🏁 Script executed:
# Search for API client code that references /sandbox/extend
rg "sandbox/extend" --type ts --type js | head -20Repository: recoupable/open-agents
Length of output: 48
🏁 Script executed:
# Check the full sandbox/route.ts to see if DELETE is identical
diff <(sed -n '33,111p' apps/web/app/api/sandbox/extend/route.ts) <(sed -n '33,111p' apps/web/app/api/sandbox/route.ts) || echo "Files differ or one doesn't exist"Repository: recoupable/open-agents
Length of output: 206
🏁 Script executed:
# Check the handleCreateSandboxHandler to understand what it creates
head -50 apps/web/lib/sandbox/create-sandbox-handler.tsRepository: recoupable/open-agents
Length of output: 1830
🏁 Script executed:
# Search for any documentation or comments about the extend endpoint
rg "extend" apps/web/app/api/sandbox/ --type ts -B 2 -A 2 | head -30Repository: recoupable/open-agents
Length of output: 48
🏁 Script executed:
# Look for git history or comments about what extend endpoint should do
rg "timeout|extend" apps/web/lib/sandbox/ --type ts -B 1 -A 1 | head -50Repository: recoupable/open-agents
Length of output: 2089
🏁 Script executed:
# Search for any timeout extension logic in the codebase
rg "timeout.*extend|extend.*timeout" --type ts -B 2 -A 2 | head -60Repository: recoupable/open-agents
Length of output: 1845
🏁 Script executed:
# Look at the full sandbox/route.ts to confirm DELETE handlers are identical
wc -l apps/web/app/api/sandbox/route.ts && tail -100 apps/web/app/api/sandbox/route.tsRepository: recoupable/open-agents
Length of output: 2931
🏁 Script executed:
# Search for client code that calls the extend endpoint
rg "api/sandbox/extend" --type ts --type js -B 3 -A 3Repository: recoupable/open-agents
Length of output: 48
🏁 Script executed:
# Check if there's any other extend-related functionality
find apps/web -type f \( -name "*.ts" -o -name "*.tsx" \) -exec grep -l "extend" {} \; | head -20Repository: recoupable/open-agents
Length of output: 779
🏁 Script executed:
# Search for any timeout extension logic in API routes
rg "extendTimeout" apps/web/app/api/ --type tsRepository: recoupable/open-agents
Length of output: 48
🏁 Script executed:
# Check if there's an actual extend handler anywhere
find apps/web/app/api -name "*extend*" -type fRepository: recoupable/open-agents
Length of output: 48
🏁 Script executed:
# Look for references to the extend route in the codebase
rg "sandbox.*extend|extend.*sandbox" apps/web --type ts -lRepository: recoupable/open-agents
Length of output: 124
🏁 Script executed:
# Check git log for recent changes to the extend/route.ts file
git log --oneline apps/web/app/api/sandbox/extend/route.ts 2>/dev/null | head -10Repository: recoupable/open-agents
Length of output: 138
🏁 Script executed:
# Search for any frontend references to extend endpoint
rg "sandbox/extend|/extend" apps/web/app --type ts --type tsx -B 2 -A 2 | head -40Repository: recoupable/open-agents
Length of output: 94
🏁 Script executed:
# Check what handleCreateSandboxRequest actually validates/requires
cat -n apps/web/lib/sandbox/validate-create-sandbox-body.tsRepository: recoupable/open-agents
Length of output: 1086
🏁 Script executed:
# Check if there's any documentation about the extend endpoint's purpose
find . -name "README*" -o -name "*.md" | xargs grep -l "extend" 2>/dev/null | head -5Repository: recoupable/open-agents
Length of output: 226
🏁 Script executed:
# Verify the DELETE handler line-by-line comparison between both routes
python3 << 'EOF'
import difflib
# Read sandbox/extend/route.ts DELETE handler
with open('apps/web/app/api/sandbox/extend/route.ts') as f:
extend_lines = f.readlines()[32:111] # Lines 33-111 (0-indexed)
# Read sandbox/route.ts DELETE handler
with open('apps/web/app/api/sandbox/route.ts') as f:
sandbox_lines = f.readlines()[32:111] # Lines 33-111 (0-indexed)
# Compare
if extend_lines == sandbox_lines:
print("DELETE handlers are IDENTICAL")
else:
print("DELETE handlers differ:")
for line in difflib.unified_diff(sandbox_lines, extend_lines, lineterm=''):
print(line)
EOFRepository: recoupable/open-agents
Length of output: 96
🏁 Script executed:
# Check if there are other files with "extend" in sandbox/extend directory
ls -la apps/web/app/api/sandbox/extend/Repository: recoupable/open-agents
Length of output: 235
🏁 Script executed:
# Look at SANDBOX-LIFECYCLE.md to understand the intended flow
cat apps/web/SANDBOX-LIFECYCLE.md | head -100Repository: recoupable/open-agents
Length of output: 4318
🏁 Script executed:
# Check if there's any POST handler that takes timeout/extend parameters
rg "extendTimeout|extend.*timeout" apps/web/app --type ts -B 5 -A 5 | head -50Repository: recoupable/open-agents
Length of output: 48
🏁 Script executed:
# Final check: does handleCreateSandboxRequest require a repoUrl parameter (sandbox creation)?
rg "repoUrl" apps/web/lib/sandbox/ --type ts -B 2 -A 2 | head -30Repository: recoupable/open-agents
Length of output: 1926
🏁 Script executed:
# Check if there are any test files or examples showing how extend endpoint should work
find . -name "*.test.ts" -o -name "*.spec.ts" | xargs grep -l "sandbox.*extend\|extend.*sandbox" 2>/dev/null | head -5Repository: recoupable/open-agents
Length of output: 48
The POST /api/sandbox/extend endpoint creates new sandboxes instead of extending timeouts.
The handler delegates to handleCreateSandboxRequest, which requires a repoUrl parameter and creates new sandboxes with default timeouts. The endpoint path implies timeout extension, but the implementation is sandbox creation—a breaking semantic change. Existing clients expecting timeout extension will get unexpected behavior.
Additionally, the DELETE handler is duplicated identically in both sandbox/extend/route.ts and sandbox/route.ts, stopping sandboxes with identical logic. Consider consolidating or clarifying the endpoint's purpose.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@apps/web/app/api/sandbox/extend/route.ts` around lines 20 - 31, The POST
handler currently calls handleCreateSandboxRequest, which creates new sandboxes
(breaking the intended "extend" semantics); change POST in this file to call the
extension logic (e.g., a handleExtendSandboxRequest or an updated extendSandbox
function) and pass the sandbox identifier/params required to extend timeouts
rather than repoUrl/default creation params, preserving existing rate-limit and
session logic (symbols: POST, handleCreateSandboxRequest, enforceRateLimit,
withRateLimitHeaders). Also remove or consolidate duplicated DELETE logic
between this file and sandbox/route.ts by extracting the shared stop/teardown
logic into a single function (e.g., stopSandbox or handleStopSandbox) and
importing/using it from both routes or keeping it only in the canonical sandbox
route to avoid duplicate behavior.
| const limit = await enforceRateLimit( | ||
| req, | ||
| RATE_LIMITS.generateCommitMessage, | ||
| session.user.id, | ||
| ); | ||
| if (!limit.ok) return limit.response; |
There was a problem hiding this comment.
Blocked: RATE_LIMITS.generateCommitMessage doesn't exist.
This references RATE_LIMITS.generateCommitMessage which is not defined in rate-limit.ts. This will cause a TypeScript error. The entry needs to be added to RATE_LIMITS in the rate-limit library (flagged in that file's review).
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@apps/web/app/api/sessions/`[sessionId]/generate-commit-message/route.ts
around lines 23 - 28, The code references RATE_LIMITS.generateCommitMessage
which doesn't exist; add a new entry named generateCommitMessage to the
RATE_LIMITS export in the rate-limit module (rate-limit.ts) so
enforceRateLimit(req, RATE_LIMITS.generateCommitMessage, session.user.id)
compiles; define sensible rate values consistent with other entries (same
shape/keys as other RATE_LIMITS items) and export it so the route.ts call to
enforceRateLimit can use RATE_LIMITS.generateCommitMessage without TypeScript
errors.
|
|
||
| let cachedClient: Redis | null = null; | ||
| let warnedClientFailure = false; | ||
|
|
There was a problem hiding this comment.
Critical: withRateLimitHeaders is not exported but imported by all route handlers.
All 6 route files import withRateLimitHeaders from this module, but no such function is defined or exported. This will cause compilation failures.
Add the missing helper:
Proposed implementation
+/**
+ * Attach rate-limit headers to an existing Response.
+ */
+export function withRateLimitHeaders(
+ response: Response,
+ headers: RateLimitHeaders,
+): Response {
+ const newHeaders = new Headers(response.headers);
+ for (const [key, value] of Object.entries(headers)) {
+ newHeaders.set(key, value);
+ }
+ return new Response(response.body, {
+ status: response.status,
+ statusText: response.statusText,
+ headers: newHeaders,
+ });
+}📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| let cachedClient: Redis | null = null; | |
| let warnedClientFailure = false; | |
| let cachedClient: Redis | null = null; | |
| let warnedClientFailure = false; | |
| /** | |
| * Attach rate-limit headers to an existing Response. | |
| */ | |
| export function withRateLimitHeaders( | |
| response: Response, | |
| headers: RateLimitHeaders, | |
| ): Response { | |
| const newHeaders = new Headers(response.headers); | |
| for (const [key, value] of Object.entries(headers)) { | |
| newHeaders.set(key, value); | |
| } | |
| return new Response(response.body, { | |
| status: response.status, | |
| statusText: response.statusText, | |
| headers: newHeaders, | |
| }); | |
| } |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@apps/web/app/lib/rate-limit.ts` around lines 7 - 10, Add and export the
missing helper function withRateLimitHeaders: implement a function named
withRateLimitHeaders that accepts a Response (or NextResponse/Response-like) and
a headers object (or values) and attaches standard rate-limit headers (e.g.,
X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset) to the returned
response; locate where rate-limit state is computed (references to cachedClient
and warnedClientFailure) and read the header/value sources used by route
handlers, set those headers on the response, then export the function (export
function withRateLimitHeaders...) from this module so the six route files can
import it without compile errors.
| export type RateLimitResult = | ||
| | { | ||
| ok: true; | ||
| limit: number; | ||
| remaining: number; | ||
| resetAt: Date; | ||
| } | ||
| | { | ||
| ok: false; | ||
| response: Response; | ||
| }; |
There was a problem hiding this comment.
Critical: enforceRateLimit signature and return type don't match how callers use it.
All route handlers call enforceRateLimit(req, RATE_LIMITS.xxx, userId) with 3 arguments and access limit.headers, but:
- The function signature accepts a single
RateLimitOptionsobject RateLimitResultdoesn't include aheadersproperty
The function needs to be updated to accept (req: Request, preset: { limit: number; windowSec: number }, userId: string | null) and return headers in the success case.
Proposed signature fix
+export interface RateLimitHeaders {
+ "x-ratelimit-limit": string;
+ "x-ratelimit-remaining": string;
+ "x-ratelimit-reset": string;
+}
+
export type RateLimitResult =
| {
ok: true;
limit: number;
remaining: number;
resetAt: Date;
+ headers: RateLimitHeaders;
}
| {
ok: false;
response: Response;
};
-export async function enforceRateLimit(
- opts: RateLimitOptions,
-): Promise<RateLimitResult> {
+export async function enforceRateLimit(
+ req: Request,
+ preset: { limit: number; windowSec: number },
+ userId: string | null,
+): Promise<RateLimitResult> {
+ const identifier = userId ?? getClientIp(req);
+ const opts: RateLimitOptions = {
+ key: `${identifier}`, // caller should provide endpoint prefix
+ limit: preset.limit,
+ windowSec: preset.windowSec,
+ };Also applies to: 122-157
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@apps/web/app/lib/rate-limit.ts` around lines 40 - 50, Update the
enforceRateLimit function signature to accept three parameters (req: Request,
preset: { limit: number; windowSec: number }, userId: string | null) instead of
a single RateLimitOptions object, and change the RateLimitResult success variant
to include a headers property (e.g., headers: Headers) so callers can read
limit.headers; update any internal uses inside enforceRateLimit (e.g., where you
build rate-limit metadata and resetAt) to populate and return the headers along
with ok, limit, remaining and resetAt, and ensure the failure variant still
returns { ok: false, response: Response } unchanged.
| export const RATE_LIMITS = { | ||
| sandboxCreate: { limit: 10, windowSec: 60 * 60 }, | ||
| sandboxExtend: { limit: 30, windowSec: 60 * 60 }, | ||
| sandboxSnapshot: { limit: 60, windowSec: 60 * 60 }, | ||
| transcribe: { limit: 30, windowSec: 60 * 60 }, | ||
| llmCheap: { limit: 60, windowSec: 60 * 60 }, | ||
| llmExpensive: { limit: 30, windowSec: 60 * 60 }, | ||
| } as const satisfies Record<string, { limit: number; windowSec: number }>; No newline at end of file |
There was a problem hiding this comment.
Missing generateCommitMessage entry in RATE_LIMITS.
The route at generate-commit-message/route.ts references RATE_LIMITS.generateCommitMessage, but it's not defined here. Based on the PR summary (60 requests/hour for commit message generation), add it:
Proposed fix
export const RATE_LIMITS = {
sandboxCreate: { limit: 10, windowSec: 60 * 60 },
sandboxExtend: { limit: 30, windowSec: 60 * 60 },
sandboxSnapshot: { limit: 60, windowSec: 60 * 60 },
transcribe: { limit: 30, windowSec: 60 * 60 },
llmCheap: { limit: 60, windowSec: 60 * 60 },
llmExpensive: { limit: 30, windowSec: 60 * 60 },
+ generateCommitMessage: { limit: 60, windowSec: 60 * 60 },
} as const satisfies Record<string, { limit: number; windowSec: number }>;🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@apps/web/app/lib/rate-limit.ts` around lines 179 - 186, Add a missing
RATE_LIMITS entry for generateCommitMessage: update the exported RATE_LIMITS
constant to include a generateCommitMessage key with limit 60 and windowSec 60 *
60 (3600) so that references to RATE_LIMITS.generateCommitMessage in
generate-commit-message/route.ts resolve; ensure the new entry follows the same
shape as other entries and preserves the as const satisfies Record<string, {
limit: number; windowSec: number }> typing.
There was a problem hiding this comment.
2 issues found across 7 files
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="apps/web/app/api/generate-title/route.ts">
<violation number="1" location="apps/web/app/api/generate-title/route.ts:30">
P2: Missing runtime validation allows malformed request bodies to reach `transcribe()` and be reported as 500s.</violation>
</file>
<file name="apps/web/app/api/sandbox/extend/route.ts">
<violation number="1" location="apps/web/app/api/sandbox/extend/route.ts:29">
P1: `/api/sandbox/extend` no longer performs timeout extension; it now creates a sandbox, breaking the documented API contract.</violation>
</file>
You're on the cubic free plan with 10 free PR reviews remaining this month. Upgrade for unlimited reviews.
|
|
||
| interface ExtendRequest { | ||
| sessionId: string; | ||
| const response = await handleCreateSandboxRequest(req); |
There was a problem hiding this comment.
P1: /api/sandbox/extend no longer performs timeout extension; it now creates a sandbox, breaking the documented API contract.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/web/app/api/sandbox/extend/route.ts, line 29:
<comment>`/api/sandbox/extend` no longer performs timeout extension; it now creates a sandbox, breaking the documented API contract.</comment>
<file context>
@@ -1,92 +1,111 @@
-interface ExtendRequest {
- sessionId: string;
+ const response = await handleCreateSandboxRequest(req);
+ return withRateLimitHeaders(response, limit.headers);
}
</file context>
| let body: TranscribeRequestBody; | ||
| try { | ||
| body = await req.json(); | ||
| body = (await req.json()) as TranscribeRequestBody; |
There was a problem hiding this comment.
P2: Missing runtime validation allows malformed request bodies to reach transcribe() and be reported as 500s.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/web/app/api/generate-title/route.ts, line 30:
<comment>Missing runtime validation allows malformed request bodies to reach `transcribe()` and be reported as 500s.</comment>
<file context>
@@ -1,75 +1,93 @@
+ let body: TranscribeRequestBody;
try {
- body = await req.json();
+ body = (await req.json()) as TranscribeRequestBody;
} catch {
- return Response.json({ error: "Invalid JSON body" }, { status: 400 });
</file context>
Why
open-agentscurrently has no rate limiting on any endpoint, including those that trigger paid third-party APIs. With this codebase becoming the customer-facing path, one shared sandbox preview URL or one prompt-injected demo can spike Vercel Sandbox / ElevenLabs / LLM gateway costs without a circuit breaker. Adding limits before enterprise sign-up is a ship-blocker.This PR introduces a Redis-backed limiter (the repo already depends on
ioredisvialib/redis.ts) and applies it to the cost-bearing endpoints. Limits are conservative defaults — tune up after observing real traffic.What changed
New
apps/web/lib/rate-limit.ts— atomic fixed-window counter viaINCR+EXPIRE. ExportsenforceRateLimit(),withRateLimitHeaders(), and a centralisedRATE_LIMITSbudget table.Updated (rate-limit applied; no behavioural change beyond 429 + headers)
app/api/sandbox/route.ts— POST + DELETEapp/api/sandbox/extend/route.ts— POSTapp/api/sandbox/snapshot/route.ts— POST + PUTapp/api/transcribe/route.ts— POSTapp/api/generate-title/route.ts— POSTapp/api/sessions/[sessionId]/generate-commit-message/route.ts— POSTDefault budgets
sandbox:createsandbox:snapshotsandbox:extendtranscribescribe_v1generate-titleclaude-haiku-4.5generate-commit-messageclaude-haiku-4.5Per-user identity is derived from the Privy session id (correct under shared corporate IPs); falls back to
x-forwarded-forwhen unauthenticated.Design notes
MULTI(INCR, EXPIRE)round-trip per request. No read-then-write race.REDIS_URL/KV_URLis unset, the limiter logs once and lets the request through. Rationale: a Redis blip should not take down the whole API. Pair with observability (the[rate-limit] redis_errorlog line) so cost spikes during outages are still visible.X-RateLimit-Limit,X-RateLimit-Remaining,X-RateLimit-Reseton every response (success and 429);Retry-Afteron 429.RATE_LIMITSso future adjustments are one diff.Test plan
pnpm install && pnpm buildpassesREDIS_URLset: 21 sequentialPOST /api/sandboxcalls — last returns429withRetry-AfterREDIS_URL(local dev): one[redis] rate-limit:sandbox:create is disabled…warning, requests pass throughX-RateLimit-Limit,X-RateLimit-Remaining,X-RateLimit-Resetpresent on success and 429 responses[rate-limit] … redis_error — failing openappears in logsOut of scope (intentional, separate PRs)
app/api/sandbox/reconnect/route.tsandapp/api/sessions/[sessionId]/checks/fix/route.ts— same pattern applies; happy to add in a follow-up once the reviewer is comfortable with the approach here.lib/managed-template-trial.ts:64—isManagedTemplateTrialUseralways returnsfalse, so existing "trial" gates in/api/chat,/api/sessions, code-editor, etc. are dead. Tracked separately; complementary to rate limits, not a substitute.{ error: \Failed to extend sandbox: ${message}` }`). Tightened opportunistically on the touched routes; a full audit is a separate PR.Operational notes
REDIS_URLorKV_URLset for limits to take effect. Add a deploy preflight check if you want hard-fail behaviour.RATE_LIMITSare deliberately conservative. After 1–2 weeks of real traffic, raise the buckets that show legitimate429s in logs; lower the ones that don't.api/chatcodebases as part of the Phase 2.2 sandbox refactor,lib/rate-limit.tsshould move with it. The route-level integration is one import + two lines per handler.Summary by cubic
Adds a Redis-backed fixed-window rate limiter to cost-bearing endpoints (sandbox lifecycle, transcription, and LLM helpers) to prevent cost spikes. Requests over budget return 429 with standard rate limit headers; otherwise behavior is unchanged.
New Features
apps/web/app/lib/rate-limit.tswithenforceRateLimit,withRateLimitHeaders, and centralizedRATE_LIMITS(built on existingioredisclient).POST/DELETE /api/sandbox,POST /api/sandbox/extend,POST/PUT /api/sandbox/snapshot,POST /api/transcribe,POST /api/sessions/[sessionId]/generate-commit-message,POST /api/generate-title.x-forwarded-forfor unauthenticated requests.X-RateLimit-Limit,X-RateLimit-Remaining,X-RateLimit-Reseton success and 429; addsRetry-Afteron 429.Migration
REDIS_URLorKV_URLin production to enforce limits.RATE_LIMITSafter observing real traffic.Written for commit d95149e. Summary will update on new commits.
Summary by CodeRabbit
New Features
Bug Fixes