diff --git a/.env.example b/.env.example index d1ce0818..77646df0 100644 --- a/.env.example +++ b/.env.example @@ -101,3 +101,31 @@ AUTH_SECRET=CHANGE-ME-IN-PRODUCTION # Target repository in format "owner/repo" # Example: frankbria/codeframe # GITHUB_REPO=owner/repo + +# ============================================================================ +# Rate Limiting Configuration +# ============================================================================ + +# Enable/disable rate limiting (default: true) +# Set to false to disable rate limiting entirely +RATE_LIMIT_ENABLED=true + +# Rate limit for authentication endpoints (default: 10/minute) +# Format: "N/period" where period is second, minute, hour, or day +RATE_LIMIT_AUTH=10/minute + +# Rate limit for standard API endpoints (default: 100/minute) +RATE_LIMIT_STANDARD=100/minute + +# Rate limit for AI/expensive operations like chat (default: 20/minute) +RATE_LIMIT_AI=20/minute + +# Rate limit for WebSocket connections (default: 30/minute) +RATE_LIMIT_WEBSOCKET=30/minute + +# Storage backend for rate limiting (default: memory) +# Options: memory (single instance) or redis (distributed) +RATE_LIMIT_STORAGE=memory + +# Redis URL for distributed rate limiting (required if RATE_LIMIT_STORAGE=redis) +# REDIS_URL=redis://localhost:6379/0 diff --git a/codeframe/config/rate_limits.py b/codeframe/config/rate_limits.py new file mode 100644 index 00000000..d44c3e34 --- /dev/null +++ b/codeframe/config/rate_limits.py @@ -0,0 +1,165 @@ +"""Rate limiting configuration for CodeFRAME API. + +This module provides configuration for API rate limiting using slowapi. +It delegates to GlobalConfig in core/config.py as the single source of truth +for environment variable handling. + +Environment Variables (via GlobalConfig): + RATE_LIMIT_ENABLED: Enable/disable rate limiting (default: true) + RATE_LIMIT_AUTH: Rate limit for authentication endpoints (default: 10/minute) + RATE_LIMIT_STANDARD: Rate limit for standard API endpoints (default: 100/minute) + RATE_LIMIT_AI: Rate limit for AI/expensive operations (default: 20/minute) + RATE_LIMIT_WEBSOCKET: Rate limit for WebSocket connections (default: 30/minute) + RATE_LIMIT_STORAGE: Storage backend - memory or redis (default: memory) + RATE_LIMIT_TRUSTED_PROXIES: Comma-separated trusted proxy IPs/CIDRs + REDIS_URL: Redis connection URL for distributed rate limiting (optional) +""" + +import ipaddress +import logging +from dataclasses import dataclass, field +from functools import lru_cache +from typing import Optional + +logger = logging.getLogger(__name__) + + +@dataclass +class RateLimitConfig: + """Configuration for API rate limiting. + + Attributes: + auth_limit: Rate limit for authentication endpoints + standard_limit: Rate limit for standard API endpoints + ai_limit: Rate limit for AI/expensive operations + websocket_limit: Rate limit for WebSocket connections + enabled: Whether rate limiting is enabled + storage: Storage backend ('memory' or 'redis') + redis_url: Redis connection URL for distributed rate limiting + trusted_proxies: List of trusted proxy IP addresses/networks + """ + + auth_limit: str = "10/minute" + standard_limit: str = "100/minute" + ai_limit: str = "20/minute" + websocket_limit: str = "30/minute" + enabled: bool = True + storage: str = "memory" + redis_url: Optional[str] = None + trusted_proxies: list = field(default_factory=list) + + def is_trusted_proxy(self, ip: str) -> bool: + """Check if an IP address is from a trusted proxy. + + Args: + ip: IP address to check + + Returns: + True if IP is in trusted_proxies list or matches a trusted network + """ + if not self.trusted_proxies: + return False + + try: + client_ip = ipaddress.ip_address(ip) + for proxy in self.trusted_proxies: + try: + # Check if it's a network (CIDR notation) + if "/" in proxy: + network = ipaddress.ip_network(proxy, strict=False) + if client_ip in network: + return True + else: + # Check exact IP match + if client_ip == ipaddress.ip_address(proxy): + return True + except ValueError: + # Invalid proxy entry, skip it + continue + return False + except ValueError: + # Invalid IP address + return False + + @classmethod + def from_global_config(cls) -> "RateLimitConfig": + """Create RateLimitConfig from GlobalConfig. + + Uses core/config.py as the single source of truth for + environment variable handling. + + Returns: + RateLimitConfig instance with values from GlobalConfig + """ + # Import here to avoid circular imports + from codeframe.core.config import get_global_config + + global_config = get_global_config() + + enabled = global_config.rate_limit_enabled + storage = global_config.rate_limit_storage + redis_url = global_config.redis_url + + # Parse trusted proxies from comma-separated string + trusted_proxies_str = global_config.rate_limit_trusted_proxies.strip() + trusted_proxies = [] + if trusted_proxies_str: + trusted_proxies = [ + p.strip() for p in trusted_proxies_str.split(",") if p.strip() + ] + + # Validate storage type (already validated by Pydantic, but double-check) + if storage not in ("memory", "redis"): + logger.warning( + f"Invalid RATE_LIMIT_STORAGE: {storage}. " + f"Must be 'memory' or 'redis'. Defaulting to 'memory'." + ) + storage = "memory" + + # Warn if redis storage is requested but no URL provided + if storage == "redis" and not redis_url: + logger.warning( + "RATE_LIMIT_STORAGE is 'redis' but REDIS_URL is not set. " + "Falling back to in-memory storage." + ) + storage = "memory" + + return cls( + auth_limit=global_config.rate_limit_auth, + standard_limit=global_config.rate_limit_standard, + ai_limit=global_config.rate_limit_ai, + websocket_limit=global_config.rate_limit_websocket, + enabled=enabled, + storage=storage, + redis_url=redis_url, + trusted_proxies=trusted_proxies, + ) + + +@lru_cache(maxsize=1) +def get_rate_limit_config() -> RateLimitConfig: + """Get the global rate limit configuration. + + Loads from GlobalConfig on first call, cached thereafter. + Thread-safe via lru_cache. + + Returns: + RateLimitConfig instance + """ + config = RateLimitConfig.from_global_config() + logger.info( + f"Rate limit config initialized: " + f"enabled={config.enabled}, " + f"storage={config.storage}, " + f"standard={config.standard_limit}, " + f"trusted_proxies={len(config.trusted_proxies)} configured" + ) + return config + + +def _reset_rate_limit_config() -> None: + """Reset the global rate limit configuration. + + Useful for testing to ensure clean state between tests. + """ + get_rate_limit_config.cache_clear() diff --git a/codeframe/core/config.py b/codeframe/core/config.py index 0ae53313..4545b96c 100644 --- a/codeframe/core/config.py +++ b/codeframe/core/config.py @@ -395,6 +395,17 @@ class GlobalConfig(BaseSettings): github_token: Optional[str] = Field(None, alias="GITHUB_TOKEN") github_repo: Optional[str] = Field(None, alias="GITHUB_REPO") # Format: "owner/repo" + # Rate Limiting Configuration + rate_limit_enabled: bool = Field(True, alias="RATE_LIMIT_ENABLED") + rate_limit_storage: str = Field("memory", alias="RATE_LIMIT_STORAGE") + redis_url: Optional[str] = Field(None, alias="REDIS_URL") + rate_limit_auth: str = Field("10/minute", alias="RATE_LIMIT_AUTH") + rate_limit_standard: str = Field("100/minute", alias="RATE_LIMIT_STANDARD") + rate_limit_ai: str = Field("20/minute", alias="RATE_LIMIT_AI") + rate_limit_websocket: str = Field("30/minute", alias="RATE_LIMIT_WEBSOCKET") + # Comma-separated list of trusted proxy IPs/CIDRs (e.g., "10.0.0.0/8,172.16.0.0/12") + rate_limit_trusted_proxies: str = Field("", alias="RATE_LIMIT_TRUSTED_PROXIES") + model_config = SettingsConfigDict( env_file=".env", env_file_encoding="utf-8", case_sensitive=False, extra="ignore" ) @@ -417,6 +428,15 @@ def validate_port(cls, v: int) -> int: raise ValueError(f"API_PORT must be between 1 and 65535, got: {v}") return v + @field_validator("rate_limit_storage") + @classmethod + def validate_rate_limit_storage(cls, v: str) -> str: + """Validate rate limit storage is valid.""" + allowed = ["memory", "redis"] + if v not in allowed: + raise ValueError(f"RATE_LIMIT_STORAGE must be one of {allowed}, got: {v}") + return v + def get_cors_origins_list(self) -> list[str]: """Parse CORS origins from comma-separated string.""" return [origin.strip() for origin in self.cors_origins.split(",") if origin.strip()] @@ -580,3 +600,31 @@ def get(self, key: str) -> Any: current = current[k] return current + + +# Module-level singleton for GlobalConfig +_global_config: Optional[GlobalConfig] = None + + +def get_global_config() -> GlobalConfig: + """Get the global configuration singleton. + + Loads from environment variables on first call, cached thereafter. + This is the recommended way to access GlobalConfig for most use cases. + + Returns: + GlobalConfig instance with values from environment + """ + global _global_config + if _global_config is None: + _global_config = GlobalConfig() + return _global_config + + +def reset_global_config() -> None: + """Reset the global configuration singleton. + + Useful for testing to ensure clean state between tests. + """ + global _global_config + _global_config = None diff --git a/codeframe/lib/audit_logger.py b/codeframe/lib/audit_logger.py index 620f272a..adda05a7 100644 --- a/codeframe/lib/audit_logger.py +++ b/codeframe/lib/audit_logger.py @@ -44,6 +44,10 @@ class AuditEventType(Enum): USER_DELETED = "user.deleted" USER_ROLE_CHANGED = "user.role.changed" + # Rate limiting events + RATE_LIMIT_EXCEEDED = "rate_limit.exceeded" + RATE_LIMIT_WARNING = "rate_limit.warning" + class AuditLogger: """Centralized audit logger for security events. @@ -182,6 +186,38 @@ def log_user_event( metadata=metadata, ) + def log_rate_limit_event( + self, + event_type: AuditEventType, + user_id: Optional[int] = None, + ip_address: Optional[str] = None, + endpoint: Optional[str] = None, + limit_category: Optional[str] = None, + metadata: Optional[Dict[str, Any]] = None, + ) -> None: + """Log rate limiting event. + + Args: + event_type: Type of rate limit event (exceeded or warning) + user_id: User ID (if authenticated) + ip_address: Client IP address + endpoint: API endpoint path + limit_category: Rate limit category (auth, standard, ai, websocket) + metadata: Additional event metadata + """ + self._log_event( + event_type=event_type, + user_id=user_id, + resource_type="rate_limit", + resource_id=None, + ip_address=ip_address, + metadata={ + **(metadata or {}), + "endpoint": endpoint, + "limit_category": limit_category, + }, + ) + def _log_event( self, event_type: AuditEventType, diff --git a/codeframe/lib/rate_limiter.py b/codeframe/lib/rate_limiter.py new file mode 100644 index 00000000..44d9d682 --- /dev/null +++ b/codeframe/lib/rate_limiter.py @@ -0,0 +1,312 @@ +"""Rate limiting middleware for CodeFRAME API. + +This module provides rate limiting functionality using slowapi, with support +for different rate limits per endpoint category and proper 429 responses. + +Rate limit categories: +- auth: Authentication endpoints (login, register) +- standard: Standard API endpoints (CRUD operations) +- ai: AI/expensive operations (chat, generation) +- websocket: WebSocket connections + +Key extraction: +- Authenticated requests: User ID from token +- Unauthenticated requests: Client IP address + +Security: +- X-Forwarded-For is only trusted when request comes from a configured trusted proxy +- "unknown" IPs are logged and tracked for security monitoring +- Configure RATE_LIMIT_TRUSTED_PROXIES to define trusted proxy networks +""" + +import logging +from typing import Callable, Optional + +from fastapi import Request +from fastapi.responses import JSONResponse +from slowapi import Limiter +from slowapi.errors import RateLimitExceeded + +from codeframe.config.rate_limits import get_rate_limit_config + +logger = logging.getLogger(__name__) + +# Global limiter instance +_limiter: Optional[Limiter] = None +# Track if we've logged the disabled message to avoid duplicate logs +_logged_disabled: bool = False + + +def get_client_ip(request: Request) -> str: + """Extract client IP address from request with trusted proxy validation. + + Only trusts X-Forwarded-For and X-Real-IP headers when the direct + connection is from a configured trusted proxy. This prevents header + spoofing attacks. + + Security Note: + - If RATE_LIMIT_TRUSTED_PROXIES is not configured, proxy headers are ignored + - Configure trusted proxies when running behind a reverse proxy (nginx, ALB, etc.) + + Args: + request: FastAPI request object + + Returns: + Client IP address string, or "unknown" if not determinable + """ + config = get_rate_limit_config() + + # Get the direct connection IP + direct_ip = None + if request.client and request.client.host: + direct_ip = request.client.host + + # Only trust proxy headers if direct connection is from a trusted proxy + if direct_ip and config.is_trusted_proxy(direct_ip): + # Check X-Forwarded-For header (may contain multiple IPs) + forwarded_for = request.headers.get("X-Forwarded-For") + if forwarded_for: + # Return first IP in the chain (real client) + client_ip = forwarded_for.split(",")[0].strip() + if client_ip: + return client_ip + + # Check X-Real-IP header + real_ip = request.headers.get("X-Real-IP") + if real_ip: + return real_ip.strip() + elif direct_ip: + # Not from trusted proxy - check if headers were spoofed + if request.headers.get("X-Forwarded-For") or request.headers.get("X-Real-IP"): + logger.warning( + f"Proxy headers present from non-trusted IP {direct_ip}. " + f"Headers ignored. Configure RATE_LIMIT_TRUSTED_PROXIES if " + f"running behind a reverse proxy." + ) + + # Use direct connection IP + if direct_ip: + return direct_ip + + # Unable to determine IP - log for security monitoring + logger.warning( + "Unable to determine client IP address. " + "This may indicate proxy misconfiguration. " + f"Path: {request.url.path}" + ) + return "unknown" + + +def get_rate_limit_key(request: Request) -> str: + """Generate rate limit key for a request. + + Uses user ID for authenticated requests, IP address for unauthenticated. + Applies stricter rate limiting for "unknown" IPs to mitigate DoS risk. + + Args: + request: FastAPI request object + + Returns: + Rate limit key string in format "user:{id}" or "ip:{address}" + """ + # Check if user is authenticated (set by auth middleware) + user = getattr(getattr(request, "state", None), "user", None) + + if user and hasattr(user, "id") and user.id: + return f"user:{user.id}" + + # Fall back to IP address + client_ip = get_client_ip(request) + + # Mark "unknown" IPs specially for potential stricter handling + if client_ip == "unknown": + # Use a unique key per request for unknown IPs + # This effectively gives each "unknown" request its own bucket + # preventing the shared bucket DoS attack + request_id = id(request) + return f"ip:unknown:{request_id}" + + return f"ip:{client_ip}" + + +def get_rate_limiter() -> Optional[Limiter]: + """Get or create the rate limiter instance. + + Returns: + Limiter instance if rate limiting is enabled, None otherwise + """ + global _limiter + global _logged_disabled + + config = get_rate_limit_config() + + if not config.enabled: + if not _logged_disabled: + logger.info("Rate limiting is disabled") + _logged_disabled = True + return None + + if _limiter is None: + # Create limiter with appropriate storage + if config.storage == "redis" and config.redis_url: + try: + _limiter = Limiter( + key_func=get_rate_limit_key, + storage_uri=config.redis_url, + ) + logger.info("Rate limiter initialized with Redis storage") + except ImportError as e: + logger.error(f"Redis storage requested but redis module not available: {e}. Falling back to memory.") + _limiter = Limiter(key_func=get_rate_limit_key) + else: + _limiter = Limiter(key_func=get_rate_limit_key) + logger.info("Rate limiter initialized with in-memory storage") + + return _limiter + + +async def rate_limit_exceeded_handler(request: Request, exc: RateLimitExceeded) -> JSONResponse: + """Custom exception handler for rate limit exceeded errors. + + Returns a proper 429 response with standard rate limit headers. + Also logs the event to the audit log for security monitoring. + + Args: + request: FastAPI request object + exc: RateLimitExceeded exception + + Returns: + JSONResponse with 429 status and rate limit headers + """ + # Extract request info + client_ip = get_client_ip(request) + user = getattr(getattr(request, "state", None), "user", None) + user_id = user.id if user and hasattr(user, "id") and user.id else None + endpoint = request.url.path + + # Log to standard logger + logger.warning( + f"Rate limit exceeded: path={endpoint}, " + f"ip={client_ip}, user_id={user_id}" + ) + + # Log to audit log for security monitoring + try: + db = getattr(getattr(request, "app", None), "state", None) + db = getattr(db, "db", None) if db else None + if db: + from codeframe.lib.audit_logger import AuditLogger, AuditEventType + + audit = AuditLogger(db) + audit.log_rate_limit_event( + event_type=AuditEventType.RATE_LIMIT_EXCEEDED, + user_id=user_id, + ip_address=client_ip, + endpoint=endpoint, + limit_category=None, # Not easily determinable from exception + metadata={ + "limit": str(exc.limit) if hasattr(exc, "limit") else None, + "retry_after": str(exc.detail) if hasattr(exc, "detail") else "60", + }, + ) + except Exception as e: + # Don't let audit logging failure affect the rate limit response + logger.debug(f"Failed to log rate limit event to audit log: {e}") + + # Build response headers + headers = { + "Retry-After": str(exc.detail) if hasattr(exc, "detail") else "60", + } + + # Add rate limit info headers if available + if hasattr(exc, "limit"): + headers["X-RateLimit-Limit"] = str(exc.limit) + + response = JSONResponse( + status_code=429, + content={ + "error": "rate_limit_exceeded", + "detail": "Too many requests. Please try again later.", + "retry_after": headers.get("Retry-After", "60"), + }, + headers=headers, + ) + + return response + + +def _create_rate_limit_decorator(limit_key: str) -> Callable: + """Create a rate limit decorator for a specific limit category. + + Args: + limit_key: Configuration key for the limit (e.g., 'standard_limit') + + Returns: + Decorator function that applies the rate limit + """ + + def decorator(): + """Rate limit decorator that reads limit from config.""" + + def wrapper(func: Callable) -> Callable: + # Get the limiter (returns None when rate limiting is disabled) + limiter = get_rate_limiter() + + if limiter is None: + # Rate limiting globally disabled, return function as-is + return func + + # Get the limit value from config + config = get_rate_limit_config() + limit_value = getattr(config, limit_key, "100/minute") + + # Always apply the slowapi decorator, let slowapi handle the logic + return limiter.limit(limit_value)(func) + + return wrapper + + return decorator + + +# Rate limit decorators for each category +def rate_limit_auth() -> Callable: + """Decorator for authentication endpoint rate limits. + + Default: 10 requests/minute (configurable via RATE_LIMIT_AUTH) + """ + return _create_rate_limit_decorator("auth_limit")() + + +def rate_limit_standard() -> Callable: + """Decorator for standard API endpoint rate limits. + + Default: 100 requests/minute (configurable via RATE_LIMIT_STANDARD) + """ + return _create_rate_limit_decorator("standard_limit")() + + +def rate_limit_ai() -> Callable: + """Decorator for AI/expensive operation rate limits. + + Default: 20 requests/minute (configurable via RATE_LIMIT_AI) + """ + return _create_rate_limit_decorator("ai_limit")() + + +def rate_limit_websocket() -> Callable: + """Decorator for WebSocket connection rate limits. + + Default: 30 connections/minute (configurable via RATE_LIMIT_WEBSOCKET) + """ + return _create_rate_limit_decorator("websocket_limit")() + + +def reset_rate_limiter() -> None: + """Reset the global rate limiter instance. + + Useful for testing to ensure clean state between tests. + """ + global _limiter + global _logged_disabled + _limiter = None + _logged_disabled = False diff --git a/codeframe/ui/routers/agents.py b/codeframe/ui/routers/agents.py index 0af9faa7..620b97e5 100644 --- a/codeframe/ui/routers/agents.py +++ b/codeframe/ui/routers/agents.py @@ -11,7 +11,7 @@ import time from typing import List -from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks, Query +from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks, Query, Request from fastapi.responses import JSONResponse from codeframe.core.models import ProjectStatus @@ -28,6 +28,7 @@ AgentAssignmentResponse, ProjectAssignmentResponse, ) +from codeframe.lib.rate_limiter import rate_limit_standard, rate_limit_ai logger = logging.getLogger(__name__) @@ -37,7 +38,9 @@ @router.post("/projects/{project_id}/start", status_code=202) +@rate_limit_ai() async def start_project_agent( + request: Request, project_id: int, background_tasks: BackgroundTasks, db: Database = Depends(get_db), @@ -158,7 +161,8 @@ async def start_project_agent( @router.post("/projects/{project_id}/pause") -async def pause_project(project_id: int, db: Database = Depends(get_db), current_user: User = Depends(get_current_user)): +@rate_limit_standard() +async def pause_project(request: Request, project_id: int, db: Database = Depends(get_db), current_user: User = Depends(get_current_user)): """Pause project execution. Args: @@ -193,7 +197,8 @@ async def pause_project(project_id: int, db: Database = Depends(get_db), current @router.post("/projects/{project_id}/resume") -async def resume_project(project_id: int, db: Database = Depends(get_db), current_user: User = Depends(get_current_user)): +@rate_limit_ai() +async def resume_project(request: Request, project_id: int, db: Database = Depends(get_db), current_user: User = Depends(get_current_user)): """Resume project execution. Args: @@ -228,7 +233,9 @@ async def resume_project(project_id: int, db: Database = Depends(get_db), curren @router.get("/projects/{project_id}/agents", response_model=List[AgentAssignmentResponse]) +@rate_limit_standard() async def get_project_agents( + request: Request, project_id: int, active_only: bool = Query(True, alias="is_active"), db: Database = Depends(get_db), @@ -288,9 +295,11 @@ async def get_project_agents( @router.post("/projects/{project_id}/agents", status_code=201, response_model=dict) +@rate_limit_standard() async def assign_agent_to_project( + request: Request, project_id: int, - request: AgentAssignmentRequest, + body: AgentAssignmentRequest, db: Database = Depends(get_db), current_user: User = Depends(get_current_user), ): """Assign an agent to a project. @@ -319,30 +328,30 @@ async def assign_agent_to_project( raise HTTPException(status_code=403, detail="Access denied") # Verify agent exists - agent = db.get_agent(request.agent_id) + agent = db.get_agent(body.agent_id) if not agent: - raise HTTPException(status_code=404, detail=f"Agent {request.agent_id} not found") + raise HTTPException(status_code=404, detail=f"Agent {body.agent_id} not found") # Check if agent is already assigned (active) - existing = db.get_agent_assignment(project_id, request.agent_id) + existing = db.get_agent_assignment(project_id, body.agent_id) if existing and existing.get("is_active"): raise HTTPException( status_code=400, - detail=f"Agent {request.agent_id} is already assigned to project {project_id}", + detail=f"Agent {body.agent_id} is already assigned to project {project_id}", ) # Assign agent to project assignment_id = db.assign_agent_to_project( - project_id=project_id, agent_id=request.agent_id, role=request.role + project_id=project_id, agent_id=body.agent_id, role=body.role ) logger.info( - f"Assigned agent {request.agent_id} to project {project_id} with role {request.role}" + f"Assigned agent {body.agent_id} to project {project_id} with role {body.role}" ) return { "assignment_id": assignment_id, - "message": f"Agent {request.agent_id} assigned to project {project_id} with role {request.role}", + "message": f"Agent {body.agent_id} assigned to project {project_id} with role {body.role}", } except HTTPException: raise @@ -352,7 +361,9 @@ async def assign_agent_to_project( @router.delete("/projects/{project_id}/agents/{agent_id}", status_code=204) +@rate_limit_standard() async def remove_agent_from_project( + request: Request, project_id: int, agent_id: str, db: Database = Depends(get_db), current_user: User = Depends(get_current_user), @@ -402,10 +413,12 @@ async def remove_agent_from_project( @router.put("/projects/{project_id}/agents/{agent_id}/role", response_model=AgentAssignmentResponse) +@rate_limit_standard() async def update_agent_role( + request: Request, project_id: int, agent_id: str, - request: AgentRoleUpdateRequest, + body: AgentRoleUpdateRequest, db: Database = Depends(get_db), current_user: User = Depends(get_current_user), ): """Update an agent's role on a project. @@ -436,7 +449,7 @@ async def update_agent_role( # Update agent role rows_affected = db.reassign_agent_role( - project_id=project_id, agent_id=agent_id, new_role=request.role + project_id=project_id, agent_id=agent_id, new_role=body.role ) if rows_affected == 0: @@ -445,7 +458,7 @@ async def update_agent_role( detail=f"No active assignment found for agent {agent_id} on project {project_id}", ) - logger.info(f"Updated agent {agent_id} role to {request.role} on project {project_id}") + logger.info(f"Updated agent {agent_id} role to {body.role} on project {project_id}") # Fetch assignment details (junction table fields only) assignment = db.get_agent_assignment(project_id, agent_id) @@ -487,10 +500,12 @@ async def update_agent_role( @router.patch("/projects/{project_id}/agents/{agent_id}") +@rate_limit_standard() async def patch_agent_role( + request: Request, project_id: int, agent_id: str, - request: AgentRoleUpdateRequest, + body: AgentRoleUpdateRequest, db: Database = Depends(get_db), current_user: User = Depends(get_current_user), ): """Update an agent's role on a project (PATCH variant). @@ -500,7 +515,7 @@ async def patch_agent_role( Args: project_id: Project ID agent_id: Agent ID - request: New role for the agent + body: New role for the agent db: Database connection Returns: @@ -521,7 +536,7 @@ async def patch_agent_role( # Update agent role rows_affected = db.reassign_agent_role( - project_id=project_id, agent_id=agent_id, new_role=request.role + project_id=project_id, agent_id=agent_id, new_role=body.role ) if rows_affected == 0: @@ -530,10 +545,10 @@ async def patch_agent_role( detail=f"No active assignment found for agent {agent_id} on project {project_id}", ) - logger.info(f"Updated agent {agent_id} role to {request.role} on project {project_id}") + logger.info(f"Updated agent {agent_id} role to {body.role} on project {project_id}") return { - "message": f"Agent {agent_id} role updated to {request.role} on project {project_id}" + "message": f"Agent {agent_id} role updated to {body.role} on project {project_id}" } except HTTPException: raise @@ -543,7 +558,9 @@ async def patch_agent_role( @router.get("/agents/{agent_id}/projects", response_model=List[ProjectAssignmentResponse]) +@rate_limit_standard() async def get_agent_projects( + request: Request, agent_id: str, active_only: bool = Query(True), db: Database = Depends(get_db), diff --git a/codeframe/ui/routers/batches_v2.py b/codeframe/ui/routers/batches_v2.py index 1e5a93e8..f577d497 100644 --- a/codeframe/ui/routers/batches_v2.py +++ b/codeframe/ui/routers/batches_v2.py @@ -13,10 +13,11 @@ import logging from typing import Optional -from fastapi import APIRouter, Depends, HTTPException, Query +from fastapi import APIRouter, Depends, HTTPException, Query, Request from pydantic import BaseModel, Field from codeframe.core.workspace import Workspace +from codeframe.lib.rate_limiter import rate_limit_standard from codeframe.core import conductor from codeframe.core.conductor import BatchStatus from codeframe.ui.dependencies import get_v2_workspace @@ -94,7 +95,9 @@ def _batch_to_response(batch: conductor.BatchRun) -> BatchResponse: @router.get("", response_model=BatchListResponse) +@rate_limit_standard() async def list_batches( + request: Request, status: Optional[str] = Query(None, description="Filter by status (PENDING, RUNNING, COMPLETED, PARTIAL, FAILED, CANCELLED)"), limit: int = Query(20, ge=1, le=100), workspace: Workspace = Depends(get_v2_workspace), @@ -142,7 +145,9 @@ async def list_batches( @router.get("/{batch_id}", response_model=BatchResponse) +@rate_limit_standard() async def get_batch( + request: Request, batch_id: str, workspace: Workspace = Depends(get_v2_workspace), ) -> BatchResponse: @@ -170,9 +175,11 @@ async def get_batch( @router.post("/{batch_id}/stop", response_model=BatchResponse) +@rate_limit_standard() async def stop_batch( + request: Request, batch_id: str, - request: StopBatchRequest = None, + body: StopBatchRequest = None, workspace: Workspace = Depends(get_v2_workspace), ) -> BatchResponse: """Stop a running batch. @@ -186,8 +193,9 @@ async def stop_batch( - Immediate termination Args: + request: HTTP request for rate limiting batch_id: Batch to stop - request: Stop options + body: Stop options workspace: v2 Workspace Returns: @@ -198,7 +206,7 @@ async def stop_batch( - 404: Batch not found - 400: Batch not in stoppable state """ - force = request.force if request else False + force = body.force if body else False try: batch = conductor.stop_batch(workspace, batch_id, force=force) @@ -218,16 +226,19 @@ async def stop_batch( @router.post("/{batch_id}/resume", response_model=BatchResponse) +@rate_limit_standard() async def resume_batch( + request: Request, batch_id: str, - request: ResumeBatchRequest = None, + body: ResumeBatchRequest = None, workspace: Workspace = Depends(get_v2_workspace), ) -> BatchResponse: """Resume a batch by re-running failed/blocked tasks. Args: + request: HTTP request for rate limiting batch_id: Batch to resume - request: Resume options + body: Resume options workspace: v2 Workspace Returns: @@ -238,7 +249,7 @@ async def resume_batch( - 404: Batch not found - 400: Batch not in resumable state """ - force = request.force if request else False + force = body.force if body else False try: batch = conductor.resume_batch(workspace, batch_id, force=force) @@ -265,7 +276,9 @@ async def resume_batch( @router.post("/{batch_id}/cancel", response_model=BatchResponse) +@rate_limit_standard() async def cancel_batch( + request: Request, batch_id: str, workspace: Workspace = Depends(get_v2_workspace), ) -> BatchResponse: diff --git a/codeframe/ui/routers/blockers_v2.py b/codeframe/ui/routers/blockers_v2.py index 1934e3d4..72d6b0c8 100644 --- a/codeframe/ui/routers/blockers_v2.py +++ b/codeframe/ui/routers/blockers_v2.py @@ -14,10 +14,11 @@ import logging from typing import Optional -from fastapi import APIRouter, Depends, HTTPException, Query +from fastapi import APIRouter, Depends, HTTPException, Query, Request from pydantic import BaseModel, Field from codeframe.core.workspace import Workspace +from codeframe.lib.rate_limiter import rate_limit_standard from codeframe.core import blockers from codeframe.core.blockers import BlockerStatus from codeframe.ui.dependencies import get_v2_workspace @@ -92,7 +93,9 @@ def _blocker_to_response(blocker: blockers.Blocker) -> BlockerResponse: @router.get("", response_model=BlockerListResponse) +@rate_limit_standard() async def list_blockers( + request: Request, status: Optional[str] = Query(None, description="Filter by status (OPEN, ANSWERED, RESOLVED)"), task_id: Optional[str] = Query(None, description="Filter by task ID"), limit: int = Query(100, ge=1, le=1000), @@ -143,7 +146,9 @@ async def list_blockers( @router.get("/{blocker_id}", response_model=BlockerResponse) +@rate_limit_standard() async def get_blocker( + request: Request, blocker_id: str, workspace: Workspace = Depends(get_v2_workspace), ) -> BlockerResponse: @@ -180,14 +185,17 @@ async def get_blocker( @router.post("", response_model=BlockerResponse, status_code=201) +@rate_limit_standard() async def create_blocker( - request: CreateBlockerRequest, + request: Request, + body: CreateBlockerRequest, workspace: Workspace = Depends(get_v2_workspace), ) -> BlockerResponse: """Create a new blocker. Args: - request: Blocker creation request + request: HTTP request for rate limiting + body: Blocker creation request workspace: v2 Workspace Returns: @@ -196,8 +204,8 @@ async def create_blocker( try: blocker = blockers.create( workspace, - question=request.question, - task_id=request.task_id, + question=body.question, + task_id=body.task_id, ) return _blocker_to_response(blocker) @@ -210,9 +218,11 @@ async def create_blocker( @router.post("/{blocker_id}/answer", response_model=BlockerResponse) +@rate_limit_standard() async def answer_blocker( + request: Request, blocker_id: str, - request: AnswerBlockerRequest, + body: AnswerBlockerRequest, workspace: Workspace = Depends(get_v2_workspace), ) -> BlockerResponse: """Answer a blocker. @@ -221,8 +231,9 @@ async def answer_blocker( so it can be restarted with `cf work start --execute`. Args: + request: HTTP request for rate limiting blocker_id: Blocker to answer (can be partial ID) - request: Answer request + body: Answer request workspace: v2 Workspace Returns: @@ -234,7 +245,7 @@ async def answer_blocker( - 400: Blocker already resolved or ambiguous ID """ try: - blocker = blockers.answer(workspace, blocker_id, request.answer) + blocker = blockers.answer(workspace, blocker_id, body.answer) return _blocker_to_response(blocker) except ValueError as e: @@ -257,7 +268,9 @@ async def answer_blocker( @router.post("/{blocker_id}/resolve", response_model=BlockerResponse) +@rate_limit_standard() async def resolve_blocker( + request: Request, blocker_id: str, workspace: Workspace = Depends(get_v2_workspace), ) -> BlockerResponse: diff --git a/codeframe/ui/routers/chat.py b/codeframe/ui/routers/chat.py index 054cd6e4..57173e88 100644 --- a/codeframe/ui/routers/chat.py +++ b/codeframe/ui/routers/chat.py @@ -12,20 +12,23 @@ from datetime import datetime, UTC from typing import Dict -from fastapi import APIRouter, Depends, HTTPException +from fastapi import APIRouter, Depends, HTTPException, Request from codeframe.persistence.database import Database from codeframe.ui.dependencies import get_db from codeframe.auth.dependencies import get_current_user from codeframe.auth.models import User from codeframe.ui.shared import manager, running_agents +from codeframe.lib.rate_limiter import rate_limit_ai, rate_limit_standard # Create router for chat endpoints router = APIRouter(prefix="/api/projects/{project_id}/chat", tags=["chat"]) @router.post("") +@rate_limit_ai() async def chat_with_lead( + request: Request, project_id: int, message: Dict[str, str], db: Database = Depends(get_db), @@ -104,7 +107,9 @@ async def chat_with_lead( @router.get("/history") +@rate_limit_standard() async def get_chat_history( + request: Request, project_id: int, limit: int = 100, offset: int = 0, diff --git a/codeframe/ui/routers/checkpoints_v2.py b/codeframe/ui/routers/checkpoints_v2.py index 4b9f2db6..938e96c8 100644 --- a/codeframe/ui/routers/checkpoints_v2.py +++ b/codeframe/ui/routers/checkpoints_v2.py @@ -9,10 +9,11 @@ import logging from typing import Any, Optional -from fastapi import APIRouter, Depends, HTTPException, Query +from fastapi import APIRouter, Depends, HTTPException, Query, Request from pydantic import BaseModel, Field from codeframe.core.workspace import Workspace +from codeframe.lib.rate_limiter import rate_limit_standard from codeframe.core import checkpoints from codeframe.ui.dependencies import get_v2_workspace @@ -77,8 +78,10 @@ class CheckpointDiffResponse(BaseModel): @router.post("", response_model=CheckpointResponse) +@rate_limit_standard() async def create_checkpoint( - request: CreateCheckpointRequest, + request: Request, + body: CreateCheckpointRequest, workspace: Workspace = Depends(get_v2_workspace), ) -> CheckpointResponse: """Create a new checkpoint. @@ -88,7 +91,8 @@ async def create_checkpoint( This is the v2 equivalent of `cf checkpoint create`. Args: - request: Checkpoint creation request + request: HTTP request for rate limiting + body: Checkpoint creation request workspace: v2 Workspace Returns: @@ -97,8 +101,8 @@ async def create_checkpoint( try: checkpoint = checkpoints.create( workspace, - name=request.name, - include_git_ref=request.include_git_ref, + name=body.name, + include_git_ref=body.include_git_ref, ) return CheckpointResponse( @@ -114,7 +118,9 @@ async def create_checkpoint( @router.get("", response_model=CheckpointListResponse) +@rate_limit_standard() async def list_checkpoints( + request: Request, limit: int = Query(50, ge=1, le=200), workspace: Workspace = Depends(get_v2_workspace), ) -> CheckpointListResponse: @@ -146,7 +152,9 @@ async def list_checkpoints( @router.get("/{checkpoint_id}", response_model=CheckpointResponse) +@rate_limit_standard() async def get_checkpoint( + request: Request, checkpoint_id: str, workspace: Workspace = Depends(get_v2_workspace), ) -> CheckpointResponse: @@ -178,7 +186,9 @@ async def get_checkpoint( @router.post("/{checkpoint_id}/restore") +@rate_limit_standard() async def restore_checkpoint( + request: Request, checkpoint_id: str, workspace: Workspace = Depends(get_v2_workspace), ) -> dict[str, Any]: @@ -214,7 +224,9 @@ async def restore_checkpoint( @router.delete("/{checkpoint_id}") +@rate_limit_standard() async def delete_checkpoint( + request: Request, checkpoint_id: str, workspace: Workspace = Depends(get_v2_workspace), ) -> dict[str, Any]: @@ -244,7 +256,9 @@ async def delete_checkpoint( @router.get("/{checkpoint_id_a}/diff/{checkpoint_id_b}", response_model=CheckpointDiffResponse) +@rate_limit_standard() async def diff_checkpoints( + request: Request, checkpoint_id_a: str, checkpoint_id_b: str, workspace: Workspace = Depends(get_v2_workspace), diff --git a/codeframe/ui/routers/diagnose_v2.py b/codeframe/ui/routers/diagnose_v2.py index 007935d7..ed55aa84 100644 --- a/codeframe/ui/routers/diagnose_v2.py +++ b/codeframe/ui/routers/diagnose_v2.py @@ -11,10 +11,11 @@ import logging from typing import Optional -from fastapi import APIRouter, Depends, HTTPException +from fastapi import APIRouter, Depends, HTTPException, Request from pydantic import BaseModel, Field from codeframe.core.workspace import Workspace +from codeframe.lib.rate_limiter import rate_limit_ai, rate_limit_standard from codeframe.core import tasks, runtime from codeframe.core.diagnostics import ( DiagnosticReport, @@ -103,9 +104,11 @@ def _report_to_response(report: DiagnosticReport) -> DiagnosticReportResponse: @router.post("/{task_id}/diagnose", response_model=DiagnosticReportResponse) +@rate_limit_ai() async def diagnose_task( + request: Request, task_id: str, - request: DiagnoseRequest = None, + body: DiagnoseRequest = None, workspace: Workspace = Depends(get_v2_workspace), ) -> DiagnosticReportResponse: """Diagnose a failed task and generate recommendations. @@ -126,7 +129,7 @@ async def diagnose_task( - 404: Task not found or no failed run - 400: Task has no failed runs """ - force = request.force if request else False + force = body.force if body else False try: # Find task @@ -176,7 +179,9 @@ async def diagnose_task( @router.get("/{task_id}/diagnose", response_model=DiagnosticReportResponse) +@rate_limit_standard() async def get_diagnostic_report( + request: Request, task_id: str, workspace: Workspace = Depends(get_v2_workspace), ) -> DiagnosticReportResponse: diff --git a/codeframe/ui/routers/discovery_v2.py b/codeframe/ui/routers/discovery_v2.py index d9d72bb7..033a1815 100644 --- a/codeframe/ui/routers/discovery_v2.py +++ b/codeframe/ui/routers/discovery_v2.py @@ -17,10 +17,11 @@ import logging from typing import Any, Optional -from fastapi import APIRouter, Depends, HTTPException, Query +from fastapi import APIRouter, Depends, HTTPException, Query, Request from pydantic import BaseModel, Field from codeframe.core.workspace import Workspace +from codeframe.lib.rate_limiter import rate_limit_ai, rate_limit_standard from codeframe.core import prd_discovery, prd, tasks from codeframe.core.prd_discovery import ( NoApiKeyError, @@ -104,7 +105,9 @@ class GenerateTasksResponse(BaseModel): @router.post("/start", response_model=StartDiscoveryResponse) +@rate_limit_ai() async def start_discovery( + request: Request, workspace: Workspace = Depends(get_v2_workspace), ) -> StartDiscoveryResponse: """Start a new PRD discovery session. @@ -161,7 +164,9 @@ async def start_discovery( @router.get("/status", response_model=StatusResponse) +@rate_limit_standard() async def get_status( + request: Request, session_id: Optional[str] = Query(None, description="Specific session ID"), workspace: Workspace = Depends(get_v2_workspace), ) -> StatusResponse: @@ -185,9 +190,11 @@ async def get_status( @router.post("/{session_id}/answer", response_model=AnswerResponse) +@rate_limit_ai() async def submit_answer( + request: Request, session_id: str, - request: AnswerRequest, + body: AnswerRequest, workspace: Workspace = Depends(get_v2_workspace), ) -> AnswerResponse: """Submit an answer to the current discovery question. @@ -197,8 +204,9 @@ async def submit_answer( adequate, feedback is provided with optional follow-up question. Args: + request: HTTP request for rate limiting session_id: Discovery session ID - request: Answer request with answer text + body: Answer request with answer text workspace: v2 Workspace Returns: @@ -214,7 +222,7 @@ async def submit_answer( result = prd_discovery.process_discovery_answer( workspace, session_id, - request.answer, + body.answer, ) return AnswerResponse(**result) @@ -230,9 +238,11 @@ async def submit_answer( @router.post("/{session_id}/generate-prd", response_model=GeneratePrdResponse) +@rate_limit_ai() async def generate_prd( + request: Request, session_id: str, - request: GeneratePrdRequest = None, + body: GeneratePrdRequest = None, workspace: Workspace = Depends(get_v2_workspace), ) -> GeneratePrdResponse: """Generate a PRD from a completed discovery session. @@ -241,8 +251,9 @@ async def generate_prd( using the specified template (or default). Args: + request: HTTP request for rate limiting session_id: Discovery session ID (must be complete) - request: Optional template selection + body: Optional template selection workspace: v2 Workspace Returns: @@ -255,7 +266,7 @@ async def generate_prd( - 500: Generation error """ try: - template_id = request.template_id if request else None + template_id = body.template_id if body else None prd_record = prd_discovery.generate_prd_from_discovery( workspace, session_id, @@ -287,7 +298,9 @@ async def generate_prd( @router.post("/reset") +@rate_limit_standard() async def reset_discovery( + request: Request, session_id: Optional[str] = Query(None, description="Specific session to reset"), workspace: Workspace = Depends(get_v2_workspace), ) -> dict[str, Any]: @@ -323,7 +336,9 @@ async def reset_discovery( @router.post("/generate-tasks", response_model=GenerateTasksResponse) +@rate_limit_ai() async def generate_tasks_from_prd( + request: Request, prd_id: Optional[str] = Query( None, description="PRD ID to generate tasks from (defaults to latest)", diff --git a/codeframe/ui/routers/environment_v2.py b/codeframe/ui/routers/environment_v2.py index ce3d4571..294a9267 100644 --- a/codeframe/ui/routers/environment_v2.py +++ b/codeframe/ui/routers/environment_v2.py @@ -12,10 +12,11 @@ import logging from typing import Optional -from fastapi import APIRouter, Depends, HTTPException +from fastapi import APIRouter, Depends, HTTPException, Request from pydantic import BaseModel, Field from codeframe.core.workspace import Workspace +from codeframe.lib.rate_limiter import rate_limit_standard from codeframe.core.environment import EnvironmentValidator, ValidationResult, ToolInfo from codeframe.core.installer import ToolInstaller from codeframe.ui.dependencies import get_v2_workspace @@ -112,7 +113,9 @@ def _result_to_response(result: ValidationResult) -> ValidationResultResponse: @router.get("/check", response_model=ValidationResultResponse) +@rate_limit_standard() async def check_environment( + request: Request, workspace: Workspace = Depends(get_v2_workspace), ) -> ValidationResultResponse: """Quick environment validation. @@ -140,7 +143,9 @@ async def check_environment( @router.get("/doctor", response_model=ValidationResultResponse) +@rate_limit_standard() async def run_doctor( + request: Request, workspace: Workspace = Depends(get_v2_workspace), ) -> ValidationResultResponse: """Comprehensive environment diagnostics. @@ -175,8 +180,10 @@ async def run_doctor( @router.post("/install", response_model=InstallResultResponse) +@rate_limit_standard() async def install_tool( - request: InstallToolRequest, + request: Request, + body: InstallToolRequest, workspace: Workspace = Depends(get_v2_workspace), ) -> InstallResultResponse: """Install a missing tool. @@ -198,18 +205,18 @@ async def install_tool( installer = ToolInstaller() # Check if tool is known - if not installer.can_install(request.tool_name): + if not installer.can_install(body.tool_name): raise HTTPException( status_code=400, detail=api_error( "Unknown tool", ErrorCodes.INVALID_REQUEST, - f"Don't know how to install '{request.tool_name}'", + f"Don't know how to install '{body.tool_name}'", ), ) # Attempt installation (confirm=False for non-interactive server usage) - result = installer.install_tool(request.tool_name, confirm=False) + result = installer.install_tool(body.tool_name, confirm=False) return InstallResultResponse( success=result.success, @@ -221,7 +228,7 @@ async def install_tool( except HTTPException: raise except Exception as e: - logger.error(f"Failed to install {request.tool_name}: {e}", exc_info=True) + logger.error(f"Failed to install {body.tool_name}: {e}", exc_info=True) raise HTTPException( status_code=500, detail=api_error("Installation failed", ErrorCodes.EXECUTION_FAILED, str(e)), @@ -229,7 +236,9 @@ async def install_tool( @router.get("/tools") +@rate_limit_standard() async def list_available_tools( + request: Request, workspace: Workspace = Depends(get_v2_workspace), ) -> dict: """List tools that can be automatically installed. diff --git a/codeframe/ui/routers/gates_v2.py b/codeframe/ui/routers/gates_v2.py index a83e8550..140d3036 100644 --- a/codeframe/ui/routers/gates_v2.py +++ b/codeframe/ui/routers/gates_v2.py @@ -11,10 +11,11 @@ import logging from typing import Optional -from fastapi import APIRouter, Depends, HTTPException +from fastapi import APIRouter, Depends, HTTPException, Request from pydantic import BaseModel, Field from codeframe.core.workspace import Workspace +from codeframe.lib.rate_limiter import rate_limit_standard from codeframe.core import gates from codeframe.core.gates import GateResult, GateCheck from codeframe.ui.dependencies import get_v2_workspace @@ -90,8 +91,10 @@ def _result_to_response(result: GateResult) -> GateResultResponse: @router.post("/run", response_model=GateResultResponse) +@rate_limit_standard() async def run_gates( - request: RunGatesRequest = None, + request: Request, + body: RunGatesRequest = None, workspace: Workspace = Depends(get_v2_workspace), ) -> GateResultResponse: """Run verification gates. @@ -99,14 +102,15 @@ async def run_gates( Runs automated checks (tests, lint) on the workspace code. Args: - request: Gate run options + request: HTTP request for rate limiting + body: Gate run options workspace: v2 Workspace Returns: Gate results with pass/fail status for each check """ - gate_list = request.gates if request else None - verbose = request.verbose if request else False + gate_list = body.gates if body else None + verbose = body.verbose if body else False try: result = gates.run(workspace, gates=gate_list, verbose=verbose) @@ -121,7 +125,9 @@ async def run_gates( @router.get("", response_model=dict) +@rate_limit_standard() async def list_gates( + request: Request, workspace: Workspace = Depends(get_v2_workspace), ) -> dict: """List available verification gates. diff --git a/codeframe/ui/routers/git_v2.py b/codeframe/ui/routers/git_v2.py index 236efd9c..10af5dc2 100644 --- a/codeframe/ui/routers/git_v2.py +++ b/codeframe/ui/routers/git_v2.py @@ -9,10 +9,11 @@ import logging from typing import Optional -from fastapi import APIRouter, Depends, HTTPException +from fastapi import APIRouter, Depends, HTTPException, Request from pydantic import BaseModel, Field from codeframe.core.workspace import Workspace +from codeframe.lib.rate_limiter import rate_limit_standard from codeframe.core import git from codeframe.ui.dependencies import get_v2_workspace @@ -80,7 +81,9 @@ class DiffResponse(BaseModel): @router.get("/status", response_model=GitStatusResponse) +@rate_limit_standard() async def get_git_status( + request: Request, workspace: Workspace = Depends(get_v2_workspace), ) -> GitStatusResponse: """Get git working tree status. @@ -113,7 +116,9 @@ async def get_git_status( @router.get("/commits", response_model=CommitListResponse) +@rate_limit_standard() async def list_commits( + request: Request, branch: Optional[str] = None, limit: int = 50, workspace: Workspace = Depends(get_v2_workspace), @@ -156,8 +161,10 @@ async def list_commits( @router.post("/commit", response_model=CommitResultResponse, status_code=201) +@rate_limit_standard() async def create_commit( - request: CommitRequest, + request: Request, + body: CommitRequest, workspace: Workspace = Depends(get_v2_workspace), ) -> CommitResultResponse: """Create a git commit. @@ -165,7 +172,8 @@ async def create_commit( Stages the specified files and creates a commit. Args: - request: CommitRequest with files and message + request: HTTP request for rate limiting + body: CommitRequest with files and message workspace: v2 Workspace Returns: @@ -174,8 +182,8 @@ async def create_commit( try: result = git.create_commit( workspace, - files=request.files, - message=request.message, + files=body.files, + message=body.message, ) return CommitResultResponse( @@ -193,7 +201,9 @@ async def create_commit( @router.get("/diff", response_model=DiffResponse) +@rate_limit_standard() async def get_diff( + request: Request, staged: bool = False, workspace: Workspace = Depends(get_v2_workspace), ) -> DiffResponse: @@ -223,7 +233,9 @@ async def get_diff( @router.get("/branch") +@rate_limit_standard() async def get_current_branch( + request: Request, workspace: Workspace = Depends(get_v2_workspace), ) -> dict: """Get current branch name. @@ -247,7 +259,9 @@ async def get_current_branch( @router.get("/clean") +@rate_limit_standard() async def check_clean( + request: Request, workspace: Workspace = Depends(get_v2_workspace), ) -> dict: """Check if working tree is clean. diff --git a/codeframe/ui/routers/pr_v2.py b/codeframe/ui/routers/pr_v2.py index 75411071..975c3a09 100644 --- a/codeframe/ui/routers/pr_v2.py +++ b/codeframe/ui/routers/pr_v2.py @@ -14,10 +14,11 @@ import logging from typing import Optional -from fastapi import APIRouter, Depends, HTTPException, Query +from fastapi import APIRouter, Depends, HTTPException, Query, Request from pydantic import BaseModel, Field from codeframe.core.workspace import Workspace +from codeframe.lib.rate_limiter import rate_limit_standard from codeframe.git.github_integration import GitHubIntegration, GitHubAPIError, PRDetails from codeframe.ui.dependencies import get_v2_workspace from codeframe.ui.response_models import api_error, ErrorCodes @@ -124,7 +125,9 @@ def _get_github_client() -> GitHubIntegration: @router.get("", response_model=PRListResponse) +@rate_limit_standard() async def list_pull_requests( + request: Request, state: str = Query("open", description="Filter by state: open, closed, all"), workspace: Workspace = Depends(get_v2_workspace), ) -> PRListResponse: @@ -162,7 +165,9 @@ async def list_pull_requests( @router.get("/{pr_number}", response_model=PRResponse) +@rate_limit_standard() async def get_pull_request( + request: Request, pr_number: int, workspace: Workspace = Depends(get_v2_workspace), ) -> PRResponse: @@ -202,14 +207,17 @@ async def get_pull_request( @router.post("", response_model=PRResponse, status_code=201) +@rate_limit_standard() async def create_pull_request( - request: CreatePRRequest, + request: Request, + body: CreatePRRequest, workspace: Workspace = Depends(get_v2_workspace), ) -> PRResponse: """Create a new pull request. Args: - request: PR creation request + request: HTTP request for rate limiting + body: PR creation request workspace: v2 Workspace (for context) Returns: @@ -218,10 +226,10 @@ async def create_pull_request( try: client = _get_github_client() pr = await client.create_pull_request( - branch=request.branch, - title=request.title, - body=request.body, - base=request.base, + branch=body.branch, + title=body.title, + body=body.body, + base=body.base, ) return _pr_to_response(pr) @@ -242,22 +250,25 @@ async def create_pull_request( @router.post("/{pr_number}/merge", response_model=MergeResponse) +@rate_limit_standard() async def merge_pull_request( + request: Request, pr_number: int, - request: MergePRRequest = None, + body: MergePRRequest = None, workspace: Workspace = Depends(get_v2_workspace), ) -> MergeResponse: """Merge a pull request. Args: + request: HTTP request for rate limiting pr_number: PR number to merge - request: Merge options + body: Merge options workspace: v2 Workspace (for context) Returns: Merge result """ - method = request.method if request else "squash" + method = body.method if body else "squash" try: client = _get_github_client() @@ -295,7 +306,9 @@ async def merge_pull_request( @router.post("/{pr_number}/close") +@rate_limit_standard() async def close_pull_request( + request: Request, pr_number: int, workspace: Workspace = Depends(get_v2_workspace), ) -> dict: diff --git a/codeframe/ui/routers/prd_v2.py b/codeframe/ui/routers/prd_v2.py index 62fdd6fe..b9d1165a 100644 --- a/codeframe/ui/routers/prd_v2.py +++ b/codeframe/ui/routers/prd_v2.py @@ -17,10 +17,11 @@ import logging from typing import Optional -from fastapi import APIRouter, Depends, HTTPException, Query +from fastapi import APIRouter, Depends, HTTPException, Query, Request from pydantic import BaseModel, Field from codeframe.core.workspace import Workspace +from codeframe.lib.rate_limiter import rate_limit_standard from codeframe.core import prd from codeframe.core.prd import PrdHasDependentTasksError from codeframe.ui.dependencies import get_v2_workspace @@ -131,7 +132,9 @@ def _prd_to_summary(record: prd.PrdRecord) -> PrdSummaryResponse: @router.get("", response_model=PrdListResponse) +@rate_limit_standard() async def list_prds( + request: Request, latest_only: bool = Query(False, description="If true, return only latest version per chain"), workspace: Workspace = Depends(get_v2_workspace), ) -> PrdListResponse: @@ -156,7 +159,9 @@ async def list_prds( @router.get("/latest", response_model=PrdResponse) +@rate_limit_standard() async def get_latest_prd( + request: Request, workspace: Workspace = Depends(get_v2_workspace), ) -> PrdResponse: """Get the most recently added PRD. @@ -182,7 +187,9 @@ async def get_latest_prd( @router.get("/{prd_id}", response_model=PrdResponse) +@rate_limit_standard() async def get_prd( + request: Request, prd_id: str, workspace: Workspace = Depends(get_v2_workspace), ) -> PrdResponse: @@ -210,14 +217,17 @@ async def get_prd( @router.post("", response_model=PrdResponse, status_code=201) +@rate_limit_standard() async def create_prd( - request: CreatePrdRequest, + request: Request, + body: CreatePrdRequest, workspace: Workspace = Depends(get_v2_workspace), ) -> PrdResponse: """Store a new PRD. Args: - request: PRD creation request + request: HTTP request for rate limiting + body: PRD creation request workspace: v2 Workspace Returns: @@ -226,9 +236,9 @@ async def create_prd( try: record = prd.store( workspace, - content=request.content, - title=request.title, - metadata=request.metadata, + content=body.content, + title=body.title, + metadata=body.metadata, ) return _prd_to_response(record) @@ -241,7 +251,9 @@ async def create_prd( @router.delete("/{prd_id}") +@rate_limit_standard() async def delete_prd( + request: Request, prd_id: str, force: bool = Query(False, description="Force delete even if tasks depend on this PRD"), workspace: Workspace = Depends(get_v2_workspace), @@ -294,7 +306,9 @@ async def delete_prd( @router.get("/{prd_id}/versions", response_model=list[PrdResponse]) +@rate_limit_standard() async def get_prd_versions( + request: Request, prd_id: str, workspace: Workspace = Depends(get_v2_workspace), ) -> list[PrdResponse]: @@ -322,9 +336,11 @@ async def get_prd_versions( @router.post("/{prd_id}/versions", response_model=PrdResponse, status_code=201) +@rate_limit_standard() async def create_prd_version( + request: Request, prd_id: str, - request: CreateVersionRequest, + body: CreateVersionRequest, workspace: Workspace = Depends(get_v2_workspace), ) -> PrdResponse: """Create a new version of a PRD. @@ -344,8 +360,8 @@ async def create_prd_version( record = prd.create_new_version( workspace, parent_prd_id=prd_id, - new_content=request.content, - change_summary=request.change_summary, + new_content=body.content, + change_summary=body.change_summary, ) if not record: @@ -367,7 +383,9 @@ async def create_prd_version( @router.get("/{prd_id}/diff", response_model=PrdDiffResponse) +@rate_limit_standard() async def diff_prd_versions( + request: Request, prd_id: str, v1: int = Query(..., ge=1, description="First version number"), v2: int = Query(..., ge=1, description="Second version number"), diff --git a/codeframe/ui/routers/projects.py b/codeframe/ui/routers/projects.py index 67bc2ac2..6b843aae 100644 --- a/codeframe/ui/routers/projects.py +++ b/codeframe/ui/routers/projects.py @@ -13,7 +13,7 @@ import sqlite3 from datetime import datetime, UTC -from fastapi import APIRouter, Depends, HTTPException, Query +from fastapi import APIRouter, Depends, HTTPException, Query, Request from codeframe.core.models import TaskStatus from codeframe.core.session_manager import SessionManager @@ -26,6 +26,7 @@ ProjectResponse, SourceType, ) +from codeframe.lib.rate_limiter import rate_limit_standard # Valid task status values for API validation VALID_TASK_STATUSES = {s.value for s in TaskStatus} @@ -49,7 +50,9 @@ def is_hosted_mode() -> bool: @router.get("") +@rate_limit_standard() async def list_projects( + request: Request, db: Database = Depends(get_db), current_user: User = Depends(get_current_user), ): @@ -62,8 +65,10 @@ async def list_projects( @router.post("", status_code=201, response_model=ProjectResponse) +@rate_limit_standard() async def create_project( - request: ProjectCreateRequest, + request: Request, + body: ProjectCreateRequest, db: Database = Depends(get_db), workspace_manager: WorkspaceManager = Depends(get_workspace_manager), current_user: User = Depends(get_current_user), @@ -71,7 +76,7 @@ async def create_project( """Create a new project. Args: - request: Project creation request with name, description, source config + body: Project creation request with name, description, source config db: Database connection workspace_manager: Workspace manager current_user: Authenticated user creating the project @@ -80,7 +85,7 @@ async def create_project( Created project details """ # Security: Hosted mode cannot access user's local filesystem - if is_hosted_mode() and request.source_type == SourceType.LOCAL_PATH: + if is_hosted_mode() and body.source_type == SourceType.LOCAL_PATH: raise HTTPException( status_code=403, detail="source_type='local_path' not available in hosted mode" ) @@ -94,19 +99,19 @@ async def create_project( status_code=500, detail="Database error occurred. Please try again later." ) - if any(p["name"] == request.name for p in existing_projects): + if any(p["name"] == body.name for p in existing_projects): raise HTTPException( - status_code=409, detail=f"Project with name '{request.name}' already exists" + status_code=409, detail=f"Project with name '{body.name}' already exists" ) # Create project record first (to get ID) try: project_id = db.create_project( - name=request.name, - description=request.description, - source_type=request.source_type.value, - source_location=request.source_location, - source_branch=request.source_branch, + name=body.name, + description=body.description, + source_type=body.source_type.value, + source_location=body.source_location, + source_branch=body.source_branch, workspace_path="", # Will be updated after workspace creation user_id=current_user.id, # Assign project to current user ) @@ -120,9 +125,9 @@ async def create_project( try: workspace_path = workspace_manager.create_workspace( project_id=project_id, - source_type=request.source_type, - source_location=request.source_location, - source_branch=request.source_branch, + source_type=body.source_type, + source_location=body.source_location, + source_branch=body.source_branch, ) # Update project with workspace path and git status @@ -207,7 +212,9 @@ async def create_project( @router.get("/{project_id}") +@rate_limit_standard() async def get_project( + request: Request, project_id: int, db: Database = Depends(get_db), current_user: User = Depends(get_current_user), @@ -248,7 +255,9 @@ async def get_project( @router.get("/{project_id}/status") +@rate_limit_standard() async def get_project_status( + request: Request, project_id: int, db: Database = Depends(get_db), current_user: User = Depends(get_current_user), @@ -278,7 +287,9 @@ async def get_project_status( @router.get("/{project_id}/tasks") +@rate_limit_standard() async def get_tasks( + request: Request, project_id: int, status: str | None = None, limit: int = Query(default=50, ge=1, le=1000, description="Max tasks to return (1-1000)"), @@ -360,7 +371,8 @@ async def get_tasks( @router.get("/{project_id}/activity") -async def get_activity(project_id: int, limit: int = 50, db: Database = Depends(get_db), current_user: User = Depends(get_current_user)): +@rate_limit_standard() +async def get_activity(request: Request, project_id: int, limit: int = 50, db: Database = Depends(get_db), current_user: User = Depends(get_current_user)): """Get recent activity log.""" try: # Authorization check @@ -380,7 +392,8 @@ async def get_activity(project_id: int, limit: int = 50, db: Database = Depends( @router.get("/{project_id}/prd") -async def get_project_prd(project_id: int, db: Database = Depends(get_db), current_user: User = Depends(get_current_user)): +@rate_limit_standard() +async def get_project_prd(request: Request, project_id: int, db: Database = Depends(get_db), current_user: User = Depends(get_current_user)): """Get PRD for a project (cf-26). Sprint 2 Foundation Contract: @@ -434,7 +447,8 @@ async def get_project_prd(project_id: int, db: Database = Depends(get_db), curre @router.get("/{project_id}/issues") -async def get_project_issues(project_id: int, include: str = None, db: Database = Depends(get_db), current_user: User = Depends(get_current_user)): +@rate_limit_standard() +async def get_project_issues(request: Request, project_id: int, include: str = None, db: Database = Depends(get_db), current_user: User = Depends(get_current_user)): """Get issues for a project (cf-26). Sprint 2 Foundation Contract: @@ -490,7 +504,8 @@ async def get_project_issues(project_id: int, include: str = None, db: Database @router.get("/{project_id}/session", tags=["session"]) -async def get_session_state(project_id: int, db: Database = Depends(get_db), current_user: User = Depends(get_current_user)): +@rate_limit_standard() +async def get_session_state(request: Request, project_id: int, db: Database = Depends(get_db), current_user: User = Depends(get_current_user)): """Get current session state for project (T028). Args: diff --git a/codeframe/ui/routers/projects_v2.py b/codeframe/ui/routers/projects_v2.py index c5dd9fb8..e0227967 100644 --- a/codeframe/ui/routers/projects_v2.py +++ b/codeframe/ui/routers/projects_v2.py @@ -9,10 +9,11 @@ import logging from typing import Optional -from fastapi import APIRouter, Depends, HTTPException +from fastapi import APIRouter, Depends, HTTPException, Request from pydantic import BaseModel from codeframe.core.workspace import Workspace +from codeframe.lib.rate_limiter import rate_limit_standard from codeframe.core import project_status from codeframe.ui.dependencies import get_v2_workspace @@ -76,7 +77,9 @@ class SessionStateResponse(BaseModel): @router.get("/status", response_model=WorkspaceStatusResponse) +@rate_limit_standard() async def get_workspace_status( + request: Request, workspace: Workspace = Depends(get_v2_workspace), ) -> WorkspaceStatusResponse: """Get comprehensive status for a workspace. @@ -86,6 +89,7 @@ async def get_workspace_status( This is the v2 equivalent of `cf status`. Args: + request: HTTP request for rate limiting workspace: v2 Workspace Returns: @@ -123,7 +127,9 @@ async def get_workspace_status( @router.get("/progress", response_model=ProgressMetricsResponse) +@rate_limit_standard() async def get_progress( + request: Request, workspace: Workspace = Depends(get_v2_workspace), ) -> ProgressMetricsResponse: """Get progress metrics for a workspace. @@ -152,7 +158,9 @@ async def get_progress( @router.get("/task-counts", response_model=TaskCountsResponse) +@rate_limit_standard() async def get_task_counts( + request: Request, workspace: Workspace = Depends(get_v2_workspace), ) -> TaskCountsResponse: """Get task count statistics for a workspace. @@ -182,7 +190,9 @@ async def get_task_counts( @router.get("/session", response_model=SessionStateResponse) +@rate_limit_standard() async def get_session_state( + request: Request, workspace: Workspace = Depends(get_v2_workspace), ) -> SessionStateResponse: """Get current session state for a workspace. @@ -215,7 +225,9 @@ async def get_session_state( @router.delete("/session") +@rate_limit_standard() async def clear_session_state( + request: Request, workspace: Workspace = Depends(get_v2_workspace), ) -> dict: """Clear session state for a workspace. diff --git a/codeframe/ui/routers/review_v2.py b/codeframe/ui/routers/review_v2.py index 9d5b9656..feff3f9b 100644 --- a/codeframe/ui/routers/review_v2.py +++ b/codeframe/ui/routers/review_v2.py @@ -9,10 +9,11 @@ import logging from typing import Literal, Optional -from fastapi import APIRouter, Depends, HTTPException +from fastapi import APIRouter, Depends, HTTPException, Request from pydantic import BaseModel, Field from codeframe.core.workspace import Workspace +from codeframe.lib.rate_limiter import rate_limit_standard from codeframe.core import review from codeframe.ui.dependencies import get_v2_workspace @@ -76,8 +77,10 @@ class ReviewTaskRequest(BaseModel): @router.post("/files", response_model=ReviewResultResponse) +@rate_limit_standard() async def review_files( - request: ReviewFilesRequest, + request: Request, + body: ReviewFilesRequest, workspace: Workspace = Depends(get_v2_workspace), ) -> ReviewResultResponse: """Run code review on specified files. @@ -86,14 +89,15 @@ async def review_files( detection on the given files. Args: - request: ReviewFilesRequest with file list + request: HTTP request for rate limiting + body: ReviewFilesRequest with file list workspace: v2 Workspace Returns: ReviewResultResponse with findings and score """ try: - result = review.review_files(workspace, request.files) + result = review.review_files(workspace, body.files) return ReviewResultResponse( status=result.status, @@ -118,8 +122,10 @@ async def review_files( @router.post("/task", response_model=ReviewResultResponse) +@rate_limit_standard() async def review_task( - request: ReviewTaskRequest, + request: Request, + body: ReviewTaskRequest, workspace: Workspace = Depends(get_v2_workspace), ) -> ReviewResultResponse: """Run code review for a task's modified files. @@ -127,7 +133,8 @@ async def review_task( Convenience endpoint that wraps file review with task context. Args: - request: ReviewTaskRequest with task ID and modified files + request: HTTP request for rate limiting + body: ReviewTaskRequest with task ID and modified files workspace: v2 Workspace Returns: @@ -136,8 +143,8 @@ async def review_task( try: result = review.review_task( workspace, - task_id=request.task_id, - files_modified=request.files_modified, + task_id=body.task_id, + files_modified=body.files_modified, ) return ReviewResultResponse( @@ -163,8 +170,10 @@ async def review_task( @router.post("/files/summary", response_model=ReviewSummaryResponse) +@rate_limit_standard() async def review_files_summary( - request: ReviewFilesRequest, + request: Request, + body: ReviewFilesRequest, workspace: Workspace = Depends(get_v2_workspace), ) -> ReviewSummaryResponse: """Run code review and return summary only. @@ -173,14 +182,15 @@ async def review_files_summary( individual findings. Useful for quick status checks. Args: - request: ReviewFilesRequest with file list + request: HTTP request for rate limiting + body: ReviewFilesRequest with file list workspace: v2 Workspace Returns: ReviewSummaryResponse with aggregated metrics """ try: - result = review.review_files(workspace, request.files) + result = review.review_files(workspace, body.files) summary = review.get_review_summary(result) return ReviewSummaryResponse( diff --git a/codeframe/ui/routers/schedule_v2.py b/codeframe/ui/routers/schedule_v2.py index 2ac10764..b550da07 100644 --- a/codeframe/ui/routers/schedule_v2.py +++ b/codeframe/ui/routers/schedule_v2.py @@ -9,10 +9,11 @@ import logging from typing import Optional -from fastapi import APIRouter, Depends, HTTPException, Query +from fastapi import APIRouter, Depends, HTTPException, Query, Request from pydantic import BaseModel from codeframe.core.workspace import Workspace +from codeframe.lib.rate_limiter import rate_limit_standard from codeframe.core import schedule from codeframe.ui.dependencies import get_v2_workspace from codeframe.ui.response_models import api_error, ErrorCodes @@ -70,7 +71,9 @@ class BottleneckResponse(BaseModel): @router.get("", response_model=ScheduleResponse) +@rate_limit_standard() async def get_schedule( + request: Request, agents: int = Query(1, ge=1, le=10, description="Number of parallel agents/workers"), workspace: Workspace = Depends(get_v2_workspace), ) -> ScheduleResponse: @@ -120,7 +123,9 @@ async def get_schedule( @router.get("/predict", response_model=CompletionPredictionResponse) +@rate_limit_standard() async def predict_completion( + request: Request, hours_per_day: float = Query(8.0, gt=0, le=24, description="Working hours per day"), workspace: Workspace = Depends(get_v2_workspace), ) -> CompletionPredictionResponse: @@ -162,7 +167,9 @@ async def predict_completion( @router.get("/bottlenecks", response_model=list[BottleneckResponse]) +@rate_limit_standard() async def get_bottlenecks( + request: Request, workspace: Workspace = Depends(get_v2_workspace), ) -> list[BottleneckResponse]: """Identify scheduling bottlenecks for a workspace. diff --git a/codeframe/ui/routers/tasks.py b/codeframe/ui/routers/tasks.py index 22df86e8..bcbde1b8 100644 --- a/codeframe/ui/routers/tasks.py +++ b/codeframe/ui/routers/tasks.py @@ -14,7 +14,7 @@ from datetime import datetime, UTC from typing import Any, List, Optional -from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException +from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Request from pydantic import BaseModel, Field from codeframe.core.models import Task, TaskStatus @@ -30,6 +30,7 @@ from codeframe.auth.dependencies import get_current_user from codeframe.auth.models import User from codeframe.agents.lead_agent import LeadAgent +from codeframe.lib.rate_limiter import rate_limit_standard logger = logging.getLogger(__name__) @@ -153,15 +154,17 @@ class TaskCreateRequest(BaseModel): @router.post("", status_code=201) +@rate_limit_standard() async def create_task( - request: TaskCreateRequest, + request: Request, + body: TaskCreateRequest, db: Database = Depends(get_db), current_user: User = Depends(get_current_user), ): """Create a new task. Args: - request: Task creation request + body: Task creation request db: Database connection current_user: Authenticated user @@ -174,29 +177,29 @@ async def create_task( - 404: Project not found """ # Verify project exists - project = db.get_project(request.project_id) + project = db.get_project(body.project_id) if not project: raise HTTPException( status_code=404, - detail=f"Project {request.project_id} not found" + detail=f"Project {body.project_id} not found" ) # Authorization check - user must have access to the project - if not db.user_has_project_access(current_user.id, request.project_id): + if not db.user_has_project_access(current_user.id, body.project_id): raise HTTPException(status_code=403, detail="Access denied") # Create task try: task = Task( id=None, # Will be assigned by database - project_id=request.project_id, - title=request.title, - description=request.description, - status=TaskStatus(request.status), - priority=request.priority, - workflow_step=request.workflow_step, - depends_on=request.depends_on, - requires_mcp=request.requires_mcp, + project_id=body.project_id, + title=body.title, + description=body.description, + status=TaskStatus(body.status), + priority=body.priority, + workflow_step=body.workflow_step, + depends_on=body.depends_on, + requires_mcp=body.requires_mcp, ) task_id = db.create_task(task) @@ -243,9 +246,11 @@ class TaskApprovalResponse(BaseModel): @project_router.post("/{project_id}/tasks/approve") +@rate_limit_standard() async def approve_tasks( + request: Request, project_id: int, - request: TaskApprovalRequest, + body: TaskApprovalRequest, background_tasks: BackgroundTasks, db: Database = Depends(get_db), current_user: User = Depends(get_current_user), @@ -286,7 +291,7 @@ async def approve_tasks( raise HTTPException(status_code=403, detail="Access denied") # Check if user is rejecting - if not request.approved: + if not body.approved: return TaskApprovalResponse( success=False, phase=project.get("phase", "planning"), @@ -314,7 +319,7 @@ async def approve_tasks( # Separate approved and excluded tasks # Note: Excluded tasks remain unchanged in the database for audit trail. # They are not deleted or modified - users can re-include them later if needed. - excluded_ids = set(request.excluded_task_ids) + excluded_ids = set(body.excluded_task_ids) approved_tasks = [t for t in tasks if t.id not in excluded_ids] excluded_tasks = [t for t in tasks if t.id in excluded_ids] @@ -391,7 +396,9 @@ class TaskAssignmentResponse(BaseModel): @project_router.post("/{project_id}/tasks/assign") +@rate_limit_standard() async def assign_pending_tasks( + request: Request, project_id: int, background_tasks: BackgroundTasks, db: Database = Depends(get_db), diff --git a/codeframe/ui/routers/tasks_v2.py b/codeframe/ui/routers/tasks_v2.py index 7946948f..e1c23c0e 100644 --- a/codeframe/ui/routers/tasks_v2.py +++ b/codeframe/ui/routers/tasks_v2.py @@ -17,11 +17,12 @@ import logging from typing import Any, Optional -from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query +from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query, Request from fastapi.responses import StreamingResponse from pydantic import BaseModel, Field from codeframe.core.workspace import Workspace +from codeframe.lib.rate_limiter import rate_limit_ai, rate_limit_standard from codeframe.core import runtime, tasks, conductor, streaming from codeframe.core.state_machine import TaskStatus from codeframe.ui.dependencies import get_v2_workspace @@ -140,7 +141,9 @@ class UpdateTaskRequest(BaseModel): @router.get("", response_model=TaskListResponse) +@rate_limit_standard() async def list_tasks( + request: Request, status: Optional[str] = Query(None, description="Filter by status (BACKLOG, READY, IN_PROGRESS, DONE, BLOCKED, FAILED)"), limit: int = Query(100, ge=1, le=1000), workspace: Workspace = Depends(get_v2_workspace), @@ -194,7 +197,9 @@ async def list_tasks( @router.get("/{task_id}", response_model=TaskResponse) +@rate_limit_standard() async def get_task( + request: Request, task_id: str, workspace: Workspace = Depends(get_v2_workspace), ) -> TaskResponse: @@ -228,9 +233,11 @@ async def get_task( @router.patch("/{task_id}", response_model=TaskResponse) +@rate_limit_standard() async def update_task( + request: Request, task_id: str, - request: UpdateTaskRequest, + body: UpdateTaskRequest, workspace: Workspace = Depends(get_v2_workspace), ) -> TaskResponse: """Update a task's title, description, priority, or status. @@ -238,8 +245,9 @@ async def update_task( Only provided fields are updated; others are left unchanged. Args: + request: HTTP request for rate limiting task_id: Task ID to update - request: Update request with fields to change + body: Update request with fields to change workspace: v2 Workspace Returns: @@ -252,16 +260,16 @@ async def update_task( """ try: # Handle status update separately if provided - if request.status: + if body.status: try: - new_status = TaskStatus(request.status.upper()) + new_status = TaskStatus(body.status.upper()) tasks.update_status(workspace, task_id, new_status) except ValueError as e: if "Invalid status" in str(e) or "not a valid" in str(e).lower(): raise HTTPException( status_code=400, detail=api_error( - f"Invalid status: {request.status}", + f"Invalid status: {body.status}", ErrorCodes.VALIDATION_ERROR, f"Valid values: {[s.value for s in TaskStatus]}", ), @@ -276,9 +284,9 @@ async def update_task( task = tasks.update( workspace, task_id, - title=request.title, - description=request.description, - priority=request.priority, + title=body.title, + description=body.description, + priority=body.priority, ) return TaskResponse( @@ -312,7 +320,9 @@ async def update_task( @router.delete("/{task_id}") +@rate_limit_standard() async def delete_task( + request: Request, task_id: str, workspace: Workspace = Depends(get_v2_workspace), ) -> dict: @@ -348,8 +358,10 @@ async def delete_task( @router.post("/approve", response_model=ApproveTasksResponse) +@rate_limit_standard() async def approve_tasks_endpoint( - request: ApproveTasksRequest, + request: Request, + body: ApproveTasksRequest, background_tasks: BackgroundTasks, workspace: Workspace = Depends(get_v2_workspace), ) -> ApproveTasksResponse: @@ -372,7 +384,7 @@ async def approve_tasks_endpoint( # Approve tasks (transition BACKLOG → READY) result = runtime.approve_tasks( workspace, - excluded_task_ids=request.excluded_task_ids, + excluded_task_ids=body.excluded_task_ids, ) batch_id = None @@ -390,7 +402,7 @@ async def approve_tasks_endpoint( ) # Optionally start execution - if request.start_execution: + if body.start_execution: batch = conductor.start_batch( workspace, task_ids=result.approved_task_ids, @@ -420,7 +432,9 @@ async def approve_tasks_endpoint( @router.get("/assignment-status", response_model=AssignmentStatusResponse) +@rate_limit_standard() async def get_assignment_status( + request: Request, workspace: Workspace = Depends(get_v2_workspace), ) -> AssignmentStatusResponse: """Check if tasks can be assigned for execution. @@ -450,8 +464,10 @@ async def get_assignment_status( @router.post("/execute", response_model=StartExecutionResponse) +@rate_limit_ai() async def start_execution( - request: StartExecutionRequest, + request: Request, + body: StartExecutionRequest, workspace: Workspace = Depends(get_v2_workspace), ) -> StartExecutionResponse: """Start task execution. @@ -461,7 +477,8 @@ async def start_execution( This is the v2 equivalent of POST /api/projects/{id}/tasks/assign. Args: - request: Execution request with task IDs and strategy + request: HTTP request for rate limiting + body: Execution request with task IDs and strategy workspace: v2 Workspace Returns: @@ -482,7 +499,7 @@ async def start_execution( ) # Get task IDs - task_ids = request.task_ids or runtime.get_ready_task_ids(workspace) + task_ids = body.task_ids or runtime.get_ready_task_ids(workspace) if not task_ids: raise HTTPException( status_code=400, @@ -497,9 +514,9 @@ async def start_execution( batch = conductor.start_batch( workspace, task_ids=task_ids, - strategy=request.strategy, - max_parallel=request.max_parallel, - retry_count=request.retry_count, + strategy=body.strategy, + max_parallel=body.max_parallel, + retry_count=body.retry_count, on_failure="continue", ) @@ -507,7 +524,7 @@ async def start_execution( success=True, batch_id=batch.id, task_count=len(task_ids), - strategy=request.strategy, + strategy=body.strategy, message=f"Started execution for {len(task_ids)} task(s) (batch {batch.id[:8]}).", ) @@ -522,7 +539,9 @@ async def start_execution( @router.post("/{task_id}/start") +@rate_limit_ai() async def start_single_task( + request: Request, task_id: str, execute: bool = Query(False, description="Run agent execution (requires ANTHROPIC_API_KEY)"), dry_run: bool = Query(False, description="Preview changes without making them"), @@ -596,7 +615,9 @@ async def start_single_task( @router.post("/{task_id}/stop") +@rate_limit_standard() async def stop_task( + request: Request, task_id: str, workspace: Workspace = Depends(get_v2_workspace), ) -> dict[str, Any]: @@ -641,7 +662,9 @@ async def stop_task( @router.post("/{task_id}/resume") +@rate_limit_ai() async def resume_task( + request: Request, task_id: str, workspace: Workspace = Depends(get_v2_workspace), ) -> dict[str, Any]: @@ -689,7 +712,9 @@ async def resume_task( @router.get("/{task_id}/stream") +@rate_limit_standard() async def stream_task_output( + request: Request, task_id: str, tail: int = Query(0, ge=0, le=1000, description="Show last N lines before streaming"), workspace: Workspace = Depends(get_v2_workspace), @@ -791,7 +816,9 @@ def generate_events(): @router.get("/{task_id}/run") +@rate_limit_standard() async def get_task_run( + request: Request, task_id: str, workspace: Workspace = Depends(get_v2_workspace), ) -> dict[str, Any]: diff --git a/codeframe/ui/routers/templates_v2.py b/codeframe/ui/routers/templates_v2.py index a7f52ecf..84e2c508 100644 --- a/codeframe/ui/routers/templates_v2.py +++ b/codeframe/ui/routers/templates_v2.py @@ -9,10 +9,11 @@ import logging from typing import Any, Literal, Optional -from fastapi import APIRouter, Depends, HTTPException, Query +from fastapi import APIRouter, Depends, HTTPException, Query, Request from pydantic import BaseModel, Field from codeframe.core.workspace import Workspace +from codeframe.lib.rate_limiter import rate_limit_standard from codeframe.core import templates from codeframe.ui.dependencies import get_v2_workspace @@ -90,7 +91,9 @@ class CategoryListResponse(BaseModel): @router.get("", response_model=list[TemplateListResponse]) +@rate_limit_standard() async def list_templates( + request: Request, category: Optional[str] = Query(None, description="Optional category filter"), ) -> list[TemplateListResponse]: """List all available task templates. @@ -123,7 +126,8 @@ async def list_templates( @router.get("/categories", response_model=CategoryListResponse) -async def list_categories() -> CategoryListResponse: +@rate_limit_standard() +async def list_categories(request: Request) -> CategoryListResponse: """List all template categories. Returns: @@ -139,7 +143,9 @@ async def list_categories() -> CategoryListResponse: @router.get("/{template_id}", response_model=TemplateResponse) +@rate_limit_standard() async def get_template( + request: Request, template_id: str, ) -> TemplateResponse: """Get details for a specific template. @@ -187,8 +193,10 @@ async def get_template( @router.post("/apply", response_model=ApplyTemplateResponse) +@rate_limit_standard() async def apply_template( - request: ApplyTemplateRequest, + request: Request, + body: ApplyTemplateRequest, workspace: Workspace = Depends(get_v2_workspace), ) -> ApplyTemplateResponse: """Apply a template to create tasks for a workspace. @@ -196,7 +204,8 @@ async def apply_template( This is the v2 equivalent of `cf templates apply`. Args: - request: Template application request + request: HTTP request for rate limiting + body: Template application request workspace: v2 Workspace Returns: @@ -205,9 +214,9 @@ async def apply_template( try: result = templates.apply_template( workspace=workspace, - template_id=request.template_id, - issue_number=request.issue_number, - context=request.context, + template_id=body.template_id, + issue_number=body.issue_number, + context=body.context, ) return ApplyTemplateResponse( diff --git a/codeframe/ui/routers/workspace_v2.py b/codeframe/ui/routers/workspace_v2.py index 8ec8cbd3..b9499aae 100644 --- a/codeframe/ui/routers/workspace_v2.py +++ b/codeframe/ui/routers/workspace_v2.py @@ -13,10 +13,11 @@ from pathlib import Path from typing import Optional -from fastapi import APIRouter, Depends, HTTPException, Query +from fastapi import APIRouter, Depends, HTTPException, Query, Request from pydantic import BaseModel, Field from codeframe.core import workspace as ws +from codeframe.lib.rate_limiter import rate_limit_standard from codeframe.ui.dependencies import get_v2_workspace from codeframe.core.workspace import Workspace from codeframe.ui.response_models import api_error, ErrorCodes @@ -126,8 +127,10 @@ def _detect_tech_stack(repo_path: Path) -> Optional[str]: @router.post("", response_model=WorkspaceResponse, status_code=201) +@rate_limit_standard() async def init_workspace( - request: InitWorkspaceRequest, + request: Request, + body: InitWorkspaceRequest, ) -> WorkspaceResponse: """Initialize a new workspace for a repository. @@ -135,7 +138,8 @@ async def init_workspace( This is idempotent - safe to call multiple times on the same repo. Args: - request: Workspace initialization request + request: HTTP request for rate limiting + body: Workspace initialization request Returns: Created or existing workspace @@ -146,7 +150,7 @@ async def init_workspace( - 404: Repository path not found """ try: - repo_path = Path(request.repo_path).resolve() + repo_path = Path(body.repo_path).resolve() # Validate path exists if not repo_path.exists(): @@ -170,8 +174,8 @@ async def init_workspace( ) # Determine tech stack - tech_stack = request.tech_stack - if request.detect and not tech_stack: + tech_stack = body.tech_stack + if body.detect and not tech_stack: tech_stack = _detect_tech_stack(repo_path) # Check if workspace already exists @@ -198,7 +202,9 @@ async def init_workspace( @router.get("/current", response_model=WorkspaceResponse) +@rate_limit_standard() async def get_current_workspace( + request: Request, workspace: Workspace = Depends(get_v2_workspace), ) -> WorkspaceResponse: """Get information about the current workspace. @@ -216,8 +222,10 @@ async def get_current_workspace( @router.patch("/current", response_model=WorkspaceResponse) +@rate_limit_standard() async def update_current_workspace( - request: UpdateWorkspaceRequest, + request: Request, + body: UpdateWorkspaceRequest, workspace: Workspace = Depends(get_v2_workspace), ) -> WorkspaceResponse: """Update the current workspace. @@ -226,7 +234,8 @@ async def update_current_workspace( - tech_stack: Natural language description of the project's technology Args: - request: Update request + request: HTTP request for rate limiting + body: Update request workspace: v2 Workspace (resolved by dependency) Returns: @@ -238,8 +247,8 @@ async def update_current_workspace( try: updated = workspace - if request.tech_stack is not None: - updated = ws.update_workspace_tech_stack(workspace.repo_path, request.tech_stack) + if body.tech_stack is not None: + updated = ws.update_workspace_tech_stack(workspace.repo_path, body.tech_stack) return _workspace_to_response(updated) @@ -252,12 +261,15 @@ async def update_current_workspace( @router.get("/exists") +@rate_limit_standard() async def check_workspace_exists( + request: Request, repo_path: str = Query(..., description="Path to check for workspace"), ) -> dict: """Check if a workspace exists at a given path. Args: + request: HTTP request for rate limiting repo_path: Path to check Returns: diff --git a/codeframe/ui/server.py b/codeframe/ui/server.py index fab76acd..fdeeae21 100644 --- a/codeframe/ui/server.py +++ b/codeframe/ui/server.py @@ -13,6 +13,8 @@ # Third-party imports from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware +from slowapi.errors import RateLimitExceeded +from slowapi.middleware import SlowAPIMiddleware # Local imports from codeframe.persistence.database import Database @@ -54,6 +56,11 @@ workspace_v2, # v2 workspace router (delegates to core) ) from codeframe.auth import router as auth_router +from codeframe.lib.rate_limiter import ( + get_rate_limiter, + rate_limit_exceeded_handler, +) +from codeframe.config.rate_limits import get_rate_limit_config # ============================================================================ # Configuration and Setup @@ -202,6 +209,20 @@ async def lifespan(app: FastAPI): # Log that authentication is now always required logger.info("🔒 Authentication: ENABLED (always required)") + # Initialize rate limiting + rate_limit_config = get_rate_limit_config() + if rate_limit_config.enabled: + limiter = get_rate_limiter() + if limiter: + app.state.limiter = limiter + logger.info( + f"🚦 Rate limiting: ENABLED " + f"(storage={rate_limit_config.storage}, " + f"standard={rate_limit_config.standard_limit})" + ) + else: + logger.info("🚦 Rate limiting: DISABLED") + # Start background session cleanup task cleanup_task = asyncio.create_task(_cleanup_expired_sessions_task(app.state.db)) @@ -230,6 +251,23 @@ async def lifespan(app: FastAPI): ) +# ============================================================================ +# Rate Limiting Setup +# ============================================================================ + +# Add rate limiting exception handler +app.add_exception_handler(RateLimitExceeded, rate_limit_exceeded_handler) + +# Add rate limiting middleware if enabled +# Initialize limiter immediately so it's available before lifespan runs +rate_limit_config = get_rate_limit_config() +if rate_limit_config.enabled: + limiter = get_rate_limiter() + if limiter: + app.state.limiter = limiter + app.add_middleware(SlowAPIMiddleware) + + # ============================================================================ # CORS Middleware # ============================================================================ diff --git a/pyproject.toml b/pyproject.toml index 0bdeca05..299613b6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -56,6 +56,7 @@ dependencies = [ "passlib[argon2]>=1.7.4", "keyring>=24.0.0", "jinja2>=3.1.6", + "slowapi>=0.1.9", ] [project.optional-dependencies] diff --git a/tests/config/__init__.py b/tests/config/__init__.py new file mode 100644 index 00000000..92806de6 --- /dev/null +++ b/tests/config/__init__.py @@ -0,0 +1 @@ +"""Tests for codeframe.config modules.""" diff --git a/tests/config/test_rate_limits.py b/tests/config/test_rate_limits.py new file mode 100644 index 00000000..14358962 --- /dev/null +++ b/tests/config/test_rate_limits.py @@ -0,0 +1,234 @@ +"""Tests for rate limiting configuration module. + +TDD tests written before implementation to define the expected behavior +of the RateLimitConfig class. +""" + +import os +from unittest.mock import patch + + +class TestRateLimitConfig: + """Tests for RateLimitConfig dataclass.""" + + def test_default_values(self): + """RateLimitConfig should have sensible defaults.""" + from codeframe.config.rate_limits import RateLimitConfig + + config = RateLimitConfig() + + assert config.auth_limit == "10/minute" + assert config.standard_limit == "100/minute" + assert config.ai_limit == "20/minute" + assert config.websocket_limit == "30/minute" + assert config.enabled is True + assert config.storage == "memory" + assert config.redis_url is None + assert config.trusted_proxies == [] + + def test_from_global_config_with_defaults(self): + """from_global_config should use defaults when env vars not set.""" + from codeframe.config.rate_limits import RateLimitConfig, _reset_rate_limit_config + from codeframe.core.config import reset_global_config + + # Reset caches to ensure clean state + _reset_rate_limit_config() + reset_global_config() + + # Clear any rate limit env vars + env_vars = [ + "RATE_LIMIT_ENABLED", + "RATE_LIMIT_AUTH", + "RATE_LIMIT_STANDARD", + "RATE_LIMIT_AI", + "RATE_LIMIT_WEBSOCKET", + "RATE_LIMIT_STORAGE", + "RATE_LIMIT_TRUSTED_PROXIES", + "REDIS_URL", + ] + clean_env = {k: v for k, v in os.environ.items() if k not in env_vars} + + with patch.dict(os.environ, clean_env, clear=True): + reset_global_config() # Reset after clearing env + config = RateLimitConfig.from_global_config() + + assert config.auth_limit == "10/minute" + assert config.standard_limit == "100/minute" + assert config.ai_limit == "20/minute" + assert config.websocket_limit == "30/minute" + assert config.enabled is True + assert config.storage == "memory" + + # Cleanup + _reset_rate_limit_config() + reset_global_config() + + def test_from_global_config_custom_values(self): + """from_global_config should read custom values from GlobalConfig.""" + from codeframe.config.rate_limits import RateLimitConfig, _reset_rate_limit_config + from codeframe.core.config import reset_global_config + + # Reset caches + _reset_rate_limit_config() + reset_global_config() + + custom_env = { + "RATE_LIMIT_ENABLED": "true", + "RATE_LIMIT_AUTH": "5/minute", + "RATE_LIMIT_STANDARD": "200/minute", + "RATE_LIMIT_AI": "10/minute", + "RATE_LIMIT_WEBSOCKET": "50/minute", + "RATE_LIMIT_STORAGE": "redis", + "REDIS_URL": "redis://localhost:6379/0", + "RATE_LIMIT_TRUSTED_PROXIES": "10.0.0.1,172.16.0.0/12", + } + + with patch.dict(os.environ, custom_env, clear=True): + reset_global_config() # Reset after setting env + config = RateLimitConfig.from_global_config() + + assert config.auth_limit == "5/minute" + assert config.standard_limit == "200/minute" + assert config.ai_limit == "10/minute" + assert config.websocket_limit == "50/minute" + assert config.enabled is True + assert config.storage == "redis" + assert config.redis_url == "redis://localhost:6379/0" + assert config.trusted_proxies == ["10.0.0.1", "172.16.0.0/12"] + + # Cleanup + _reset_rate_limit_config() + reset_global_config() + + def test_from_global_config_disabled(self): + """from_global_config should handle disabled rate limiting.""" + from codeframe.config.rate_limits import RateLimitConfig, _reset_rate_limit_config + from codeframe.core.config import reset_global_config + + # Reset caches + _reset_rate_limit_config() + reset_global_config() + + with patch.dict(os.environ, {"RATE_LIMIT_ENABLED": "false"}, clear=True): + reset_global_config() + config = RateLimitConfig.from_global_config() + + assert config.enabled is False + + # Cleanup + _reset_rate_limit_config() + reset_global_config() + + def test_storage_validation(self): + """storage should only accept 'memory' or 'redis'.""" + from codeframe.config.rate_limits import RateLimitConfig + + # Valid values + config = RateLimitConfig(storage="memory") + assert config.storage == "memory" + + config = RateLimitConfig(storage="redis") + assert config.storage == "redis" + + def test_get_rate_limit_config_singleton(self): + """get_rate_limit_config should return cached instance.""" + from codeframe.config.rate_limits import ( + get_rate_limit_config, + _reset_rate_limit_config, + ) + from codeframe.core.config import reset_global_config + + # Reset to ensure clean state + _reset_rate_limit_config() + reset_global_config() + + config1 = get_rate_limit_config() + config2 = get_rate_limit_config() + + assert config1 is config2 + + # Clean up + _reset_rate_limit_config() + reset_global_config() + + +class TestRateLimitConfigParsing: + """Tests for rate limit string parsing.""" + + def test_parse_various_rate_formats(self): + """Configuration should accept various rate limit formats.""" + from codeframe.config.rate_limits import RateLimitConfig + + # These are formats supported by slowapi + valid_formats = [ + "10/minute", + "100/hour", + "1000/day", + "5/second", + "10 per minute", + "100 per hour", + ] + + for fmt in valid_formats: + config = RateLimitConfig(standard_limit=fmt) + assert config.standard_limit == fmt + + +class TestTrustedProxyValidation: + """Tests for trusted proxy IP validation.""" + + def test_is_trusted_proxy_exact_ip_match(self): + """is_trusted_proxy should match exact IP addresses.""" + from codeframe.config.rate_limits import RateLimitConfig + + config = RateLimitConfig(trusted_proxies=["10.0.0.1", "192.168.1.1"]) + + assert config.is_trusted_proxy("10.0.0.1") is True + assert config.is_trusted_proxy("192.168.1.1") is True + assert config.is_trusted_proxy("10.0.0.2") is False + + def test_is_trusted_proxy_cidr_match(self): + """is_trusted_proxy should match CIDR network ranges.""" + from codeframe.config.rate_limits import RateLimitConfig + + config = RateLimitConfig(trusted_proxies=["10.0.0.0/8", "172.16.0.0/12"]) + + # IPs in 10.0.0.0/8 range + assert config.is_trusted_proxy("10.0.0.1") is True + assert config.is_trusted_proxy("10.255.255.255") is True + + # IPs in 172.16.0.0/12 range + assert config.is_trusted_proxy("172.16.0.1") is True + assert config.is_trusted_proxy("172.31.255.255") is True + + # IPs outside the ranges + assert config.is_trusted_proxy("11.0.0.1") is False + assert config.is_trusted_proxy("172.32.0.1") is False + + def test_is_trusted_proxy_empty_list(self): + """is_trusted_proxy should return False with empty list.""" + from codeframe.config.rate_limits import RateLimitConfig + + config = RateLimitConfig(trusted_proxies=[]) + + assert config.is_trusted_proxy("10.0.0.1") is False + assert config.is_trusted_proxy("127.0.0.1") is False + + def test_is_trusted_proxy_invalid_ip(self): + """is_trusted_proxy should handle invalid IPs gracefully.""" + from codeframe.config.rate_limits import RateLimitConfig + + config = RateLimitConfig(trusted_proxies=["10.0.0.1"]) + + assert config.is_trusted_proxy("not-an-ip") is False + assert config.is_trusted_proxy("") is False + + def test_is_trusted_proxy_invalid_config(self): + """is_trusted_proxy should skip invalid proxy entries.""" + from codeframe.config.rate_limits import RateLimitConfig + + config = RateLimitConfig(trusted_proxies=["invalid", "10.0.0.1"]) + + # Should still match valid entries + assert config.is_trusted_proxy("10.0.0.1") is True + assert config.is_trusted_proxy("10.0.0.2") is False diff --git a/tests/lib/test_rate_limiter.py b/tests/lib/test_rate_limiter.py new file mode 100644 index 00000000..6fcafd3f --- /dev/null +++ b/tests/lib/test_rate_limiter.py @@ -0,0 +1,408 @@ +"""Tests for rate limiter middleware module. + +TDD tests written before implementation to define the expected behavior +of the rate limiting middleware and decorators. +""" + +import pytest +from unittest.mock import MagicMock, patch +from fastapi import FastAPI, Request +from fastapi.testclient import TestClient + + +class TestRateLimiterKeyFunctions: + """Tests for rate limiter key extraction functions.""" + + @pytest.fixture(autouse=True) + def reset_caches(self): + """Reset rate limit caches before and after each test.""" + from codeframe.config.rate_limits import _reset_rate_limit_config + from codeframe.core.config import reset_global_config + from codeframe.lib.rate_limiter import reset_rate_limiter + + _reset_rate_limit_config() + reset_global_config() + reset_rate_limiter() + yield + _reset_rate_limit_config() + reset_global_config() + reset_rate_limiter() + + def test_get_client_ip_from_request(self): + """get_client_ip should extract IP from request when no proxy headers.""" + from codeframe.lib.rate_limiter import get_client_ip + + # Mock request with direct client and no proxy headers + mock_request = MagicMock(spec=Request) + mock_request.client.host = "192.168.1.1" + mock_request.headers = {} + mock_request.url.path = "/test" + + ip = get_client_ip(mock_request) + assert ip == "192.168.1.1" + + def test_get_client_ip_ignores_x_forwarded_for_from_untrusted(self): + """get_client_ip should ignore X-Forwarded-For from non-trusted source.""" + from codeframe.lib.rate_limiter import get_client_ip + + # No trusted proxies configured - should ignore X-Forwarded-For + mock_request = MagicMock(spec=Request) + mock_request.client.host = "127.0.0.1" + mock_request.headers = {"X-Forwarded-For": "203.0.113.195, 70.41.3.18"} + mock_request.url.path = "/test" + + ip = get_client_ip(mock_request) + # Should use direct connection IP since 127.0.0.1 isn't trusted + assert ip == "127.0.0.1" + + def test_get_client_ip_from_x_forwarded_for_trusted_proxy(self): + """get_client_ip should trust X-Forwarded-For from configured trusted proxy.""" + from codeframe.lib.rate_limiter import get_client_ip + from codeframe.config.rate_limits import _reset_rate_limit_config + from codeframe.core.config import reset_global_config + + # Configure trusted proxy + with patch.dict("os.environ", {"RATE_LIMIT_TRUSTED_PROXIES": "127.0.0.1"}, clear=True): + _reset_rate_limit_config() + reset_global_config() + + mock_request = MagicMock(spec=Request) + mock_request.client.host = "127.0.0.1" + mock_request.headers = {"X-Forwarded-For": "203.0.113.195, 70.41.3.18, 150.172.238.178"} + mock_request.url.path = "/test" + + ip = get_client_ip(mock_request) + # Should return first IP in the chain (real client) + assert ip == "203.0.113.195" + + def test_get_client_ip_ignores_x_real_ip_from_untrusted(self): + """get_client_ip should ignore X-Real-IP from non-trusted source.""" + from codeframe.lib.rate_limiter import get_client_ip + + mock_request = MagicMock(spec=Request) + mock_request.client.host = "192.168.1.1" + mock_request.headers = {"X-Real-IP": "203.0.113.50"} + mock_request.url.path = "/test" + + ip = get_client_ip(mock_request) + # Should use direct connection IP + assert ip == "192.168.1.1" + + def test_get_client_ip_from_x_real_ip_trusted_proxy(self): + """get_client_ip should use X-Real-IP from trusted proxy.""" + from codeframe.lib.rate_limiter import get_client_ip + from codeframe.config.rate_limits import _reset_rate_limit_config + from codeframe.core.config import reset_global_config + + with patch.dict("os.environ", {"RATE_LIMIT_TRUSTED_PROXIES": "10.0.0.0/8"}, clear=True): + _reset_rate_limit_config() + reset_global_config() + + mock_request = MagicMock(spec=Request) + mock_request.client.host = "10.0.0.1" + mock_request.headers = {"X-Real-IP": "203.0.113.50"} + mock_request.url.path = "/test" + + ip = get_client_ip(mock_request) + assert ip == "203.0.113.50" + + def test_get_client_ip_fallback_to_client_host(self): + """get_client_ip should fall back to client.host when no headers.""" + from codeframe.lib.rate_limiter import get_client_ip + + mock_request = MagicMock(spec=Request) + mock_request.client.host = "10.0.0.5" + mock_request.headers = {} + mock_request.url.path = "/test" + + ip = get_client_ip(mock_request) + assert ip == "10.0.0.5" + + def test_get_client_ip_handles_none_client(self): + """get_client_ip should handle None client gracefully.""" + from codeframe.lib.rate_limiter import get_client_ip + + mock_request = MagicMock(spec=Request) + mock_request.client = None + mock_request.headers = {} + mock_request.url.path = "/test" + + ip = get_client_ip(mock_request) + assert ip == "unknown" + + +class TestRateLimiterKeyGeneration: + """Tests for rate limiter key generation function.""" + + @pytest.fixture(autouse=True) + def reset_caches(self): + """Reset rate limit caches before and after each test.""" + from codeframe.config.rate_limits import _reset_rate_limit_config + from codeframe.core.config import reset_global_config + from codeframe.lib.rate_limiter import reset_rate_limiter + + _reset_rate_limit_config() + reset_global_config() + reset_rate_limiter() + yield + _reset_rate_limit_config() + reset_global_config() + reset_rate_limiter() + + def test_key_for_unauthenticated_request(self): + """Key function should use IP for unauthenticated requests.""" + from codeframe.lib.rate_limiter import get_rate_limit_key + + mock_request = MagicMock(spec=Request) + mock_request.client.host = "192.168.1.100" + mock_request.headers = {} + mock_request.state = MagicMock() + mock_request.state.user = None # No authenticated user + mock_request.url.path = "/test" + + key = get_rate_limit_key(mock_request) + assert key == "ip:192.168.1.100" + + def test_key_for_authenticated_request(self): + """Key function should use user ID for authenticated requests.""" + from codeframe.lib.rate_limiter import get_rate_limit_key + + mock_request = MagicMock(spec=Request) + mock_request.client.host = "192.168.1.100" + mock_request.headers = {} + mock_request.state = MagicMock() + mock_request.state.user = MagicMock() + mock_request.state.user.id = "user_12345" + mock_request.url.path = "/test" + + key = get_rate_limit_key(mock_request) + assert key == "user:user_12345" + + def test_key_for_unknown_ip_is_unique(self): + """Unknown IPs should get unique keys to prevent shared bucket DoS.""" + from codeframe.lib.rate_limiter import get_rate_limit_key + + mock_request1 = MagicMock(spec=Request) + mock_request1.client = None + mock_request1.headers = {} + mock_request1.state = MagicMock() + mock_request1.state.user = None + mock_request1.url.path = "/test" + + mock_request2 = MagicMock(spec=Request) + mock_request2.client = None + mock_request2.headers = {} + mock_request2.state = MagicMock() + mock_request2.state.user = None + mock_request2.url.path = "/test" + + key1 = get_rate_limit_key(mock_request1) + key2 = get_rate_limit_key(mock_request2) + + # Both should start with ip:unknown but have unique suffixes + assert key1.startswith("ip:unknown:") + assert key2.startswith("ip:unknown:") + assert key1 != key2 # Each request gets a unique key + + +class TestRateLimiterIntegration: + """Integration tests for rate limiter with FastAPI.""" + + @pytest.fixture(autouse=True) + def reset_caches(self): + """Reset rate limit caches before and after each test.""" + from codeframe.config.rate_limits import _reset_rate_limit_config + from codeframe.core.config import reset_global_config + from codeframe.lib.rate_limiter import reset_rate_limiter + + _reset_rate_limit_config() + reset_global_config() + reset_rate_limiter() + yield + _reset_rate_limit_config() + reset_global_config() + reset_rate_limiter() + + @pytest.fixture + def app_with_rate_limiting(self): + """Create a test FastAPI app with rate limiting.""" + from codeframe.lib.rate_limiter import ( + get_rate_limiter, + rate_limit_exceeded_handler, + rate_limit_standard, + ) + from slowapi.errors import RateLimitExceeded + + app = FastAPI() + + # Initialize rate limiter + limiter = get_rate_limiter() + app.state.limiter = limiter + + # Add exception handler + app.add_exception_handler(RateLimitExceeded, rate_limit_exceeded_handler) + + @app.get("/test") + @rate_limit_standard() + async def test_endpoint(request: Request): + return {"status": "ok"} + + return app + + def test_rate_limit_headers_in_response(self, app_with_rate_limiting): + """Response should include rate limit headers.""" + client = TestClient(app_with_rate_limiting) + + response = client.get("/test") + + # Check that rate limit headers are present + assert "X-RateLimit-Limit" in response.headers or response.status_code == 200 + # Note: slowapi adds headers on limit exceeded, not on every request by default + + @pytest.fixture + def app_with_low_limit(self): + """Create a test app with very low rate limit for testing.""" + from codeframe.lib.rate_limiter import ( + rate_limit_exceeded_handler, + ) + from codeframe.config.rate_limits import _reset_rate_limit_config + from codeframe.core.config import reset_global_config + from slowapi import Limiter + from slowapi.util import get_remote_address + from slowapi.errors import RateLimitExceeded + + # Reset config to apply new limits + _reset_rate_limit_config() + reset_global_config() + + app = FastAPI() + + # Create limiter with very low limit for testing + limiter = Limiter(key_func=get_remote_address, default_limits=["2/minute"]) + app.state.limiter = limiter + + app.add_exception_handler(RateLimitExceeded, rate_limit_exceeded_handler) + + @app.get("/limited") + @limiter.limit("2/minute") + async def limited_endpoint(request: Request): + return {"status": "ok"} + + return app + + def test_rate_limit_exceeded_returns_429(self, app_with_low_limit): + """Exceeding rate limit should return 429 status.""" + client = TestClient(app_with_low_limit) + + # Make requests up to the limit + for _ in range(2): + response = client.get("/limited") + assert response.status_code == 200 + + # Next request should be rate limited + response = client.get("/limited") + assert response.status_code == 429 + + def test_rate_limit_exceeded_response_format(self, app_with_low_limit): + """429 response should have proper format and headers.""" + client = TestClient(app_with_low_limit) + + # Exhaust the limit + for _ in range(2): + client.get("/limited") + + response = client.get("/limited") + + assert response.status_code == 429 + + # Check response body + data = response.json() + assert "error" in data or "detail" in data + + # Check headers + assert "Retry-After" in response.headers + + +class TestRateLimitDecorators: + """Tests for rate limit decorator functions.""" + + @pytest.fixture(autouse=True) + def reset_caches(self): + """Reset rate limit caches before and after each test.""" + from codeframe.config.rate_limits import _reset_rate_limit_config + from codeframe.core.config import reset_global_config + from codeframe.lib.rate_limiter import reset_rate_limiter + + _reset_rate_limit_config() + reset_global_config() + reset_rate_limiter() + yield + _reset_rate_limit_config() + reset_global_config() + reset_rate_limiter() + + def test_rate_limit_standard_decorator_exists(self): + """rate_limit_standard decorator should exist and be callable.""" + from codeframe.lib.rate_limiter import rate_limit_standard + + decorator = rate_limit_standard() + assert callable(decorator) + + def test_rate_limit_ai_decorator_exists(self): + """rate_limit_ai decorator should exist and be callable.""" + from codeframe.lib.rate_limiter import rate_limit_ai + + decorator = rate_limit_ai() + assert callable(decorator) + + def test_rate_limit_auth_decorator_exists(self): + """rate_limit_auth decorator should exist and be callable.""" + from codeframe.lib.rate_limiter import rate_limit_auth + + decorator = rate_limit_auth() + assert callable(decorator) + + def test_rate_limit_websocket_decorator_exists(self): + """rate_limit_websocket decorator should exist and be callable.""" + from codeframe.lib.rate_limiter import rate_limit_websocket + + decorator = rate_limit_websocket() + assert callable(decorator) + + +class TestRateLimiterDisabled: + """Tests for disabled rate limiting behavior.""" + + @pytest.fixture(autouse=True) + def reset_caches(self): + """Reset rate limit caches before and after each test.""" + from codeframe.config.rate_limits import _reset_rate_limit_config + from codeframe.core.config import reset_global_config + from codeframe.lib.rate_limiter import reset_rate_limiter + + _reset_rate_limit_config() + reset_global_config() + reset_rate_limiter() + yield + _reset_rate_limit_config() + reset_global_config() + reset_rate_limiter() + + def test_disabled_rate_limiting_returns_none(self): + """When rate limiting is disabled, get_rate_limiter should return None.""" + from codeframe.config.rate_limits import _reset_rate_limit_config, get_rate_limit_config + from codeframe.core.config import reset_global_config + from codeframe.lib.rate_limiter import get_rate_limiter, reset_rate_limiter + + with patch.dict("os.environ", {"RATE_LIMIT_ENABLED": "false"}, clear=True): + _reset_rate_limit_config() + reset_global_config() + reset_rate_limiter() + + # Verify config shows disabled + config = get_rate_limit_config() + assert config.enabled is False + + # When disabled, limiter should be None + limiter = get_rate_limiter() + assert limiter is None diff --git a/tests/ui/test_assign_pending_tasks.py b/tests/ui/test_assign_pending_tasks.py index 9a8f4cfa..c5f13eda 100644 --- a/tests/ui/test_assign_pending_tasks.py +++ b/tests/ui/test_assign_pending_tasks.py @@ -9,11 +9,23 @@ import pytest from unittest.mock import AsyncMock, MagicMock, patch -from fastapi import BackgroundTasks, HTTPException +from fastapi import BackgroundTasks, HTTPException, Request from codeframe.core.models import Task, TaskStatus +@pytest.fixture +def mock_request(): + """Create mock starlette Request for rate limiter.""" + request = MagicMock(spec=Request) + request.client = MagicMock() + request.client.host = "127.0.0.1" + request.headers = {} + request.state = MagicMock() + request.state.user = None + return request + + @pytest.fixture def mock_background_tasks(monkeypatch): """Create mock BackgroundTasks. @@ -62,7 +74,7 @@ class TestAssignPendingTasksEndpoint: @pytest.mark.asyncio async def test_assign_pending_tasks_with_pending_tasks( - self, mock_db, mock_user, mock_manager, mock_background_tasks + self, mock_db, mock_user, mock_manager, mock_background_tasks, mock_request ): """Test that endpoint triggers execution when pending tasks exist.""" from codeframe.ui.routers.tasks import assign_pending_tasks @@ -77,6 +89,7 @@ async def test_assign_pending_tasks_with_pending_tasks( with patch("codeframe.ui.routers.tasks.manager", mock_manager), \ patch.dict(os.environ, {"ANTHROPIC_API_KEY": "test-key"}): response = await assign_pending_tasks( + request=mock_request, project_id=1, background_tasks=mock_background_tasks, db=mock_db, @@ -90,7 +103,7 @@ async def test_assign_pending_tasks_with_pending_tasks( @pytest.mark.asyncio async def test_assign_pending_tasks_no_pending_tasks( - self, mock_db, mock_user, mock_manager, mock_background_tasks + self, mock_db, mock_user, mock_manager, mock_background_tasks, mock_request ): """Test that endpoint returns success but doesn't trigger execution when no pending tasks.""" from codeframe.ui.routers.tasks import assign_pending_tasks @@ -104,6 +117,7 @@ async def test_assign_pending_tasks_no_pending_tasks( with patch("codeframe.ui.routers.tasks.manager", mock_manager), \ patch.dict(os.environ, {"ANTHROPIC_API_KEY": "test-key"}): response = await assign_pending_tasks( + request=mock_request, project_id=1, background_tasks=mock_background_tasks, db=mock_db, @@ -117,7 +131,7 @@ async def test_assign_pending_tasks_no_pending_tasks( @pytest.mark.asyncio async def test_assign_pending_tasks_wrong_phase( - self, mock_db, mock_user, mock_manager, mock_background_tasks + self, mock_db, mock_user, mock_manager, mock_background_tasks, mock_request ): """Test that endpoint returns 400 when project is not in active phase.""" from codeframe.ui.routers.tasks import assign_pending_tasks @@ -132,6 +146,7 @@ async def test_assign_pending_tasks_wrong_phase( with patch("codeframe.ui.routers.tasks.manager", mock_manager), \ pytest.raises(HTTPException) as exc_info: await assign_pending_tasks( + request=mock_request, project_id=1, background_tasks=mock_background_tasks, db=mock_db, @@ -143,7 +158,7 @@ async def test_assign_pending_tasks_wrong_phase( @pytest.mark.asyncio async def test_assign_pending_tasks_project_not_found( - self, mock_db, mock_user, mock_manager, mock_background_tasks + self, mock_db, mock_user, mock_manager, mock_background_tasks, mock_request ): """Test that endpoint returns 404 when project doesn't exist.""" from codeframe.ui.routers.tasks import assign_pending_tasks @@ -153,6 +168,7 @@ async def test_assign_pending_tasks_project_not_found( with patch("codeframe.ui.routers.tasks.manager", mock_manager), \ pytest.raises(HTTPException) as exc_info: await assign_pending_tasks( + request=mock_request, project_id=999, background_tasks=mock_background_tasks, db=mock_db, @@ -163,7 +179,7 @@ async def test_assign_pending_tasks_project_not_found( @pytest.mark.asyncio async def test_assign_pending_tasks_access_denied( - self, mock_db, mock_user, mock_manager, mock_background_tasks + self, mock_db, mock_user, mock_manager, mock_background_tasks, mock_request ): """Test that endpoint returns 403 when user doesn't have access.""" from codeframe.ui.routers.tasks import assign_pending_tasks @@ -173,6 +189,7 @@ async def test_assign_pending_tasks_access_denied( with patch("codeframe.ui.routers.tasks.manager", mock_manager), \ pytest.raises(HTTPException) as exc_info: await assign_pending_tasks( + request=mock_request, project_id=1, background_tasks=mock_background_tasks, db=mock_db, @@ -183,7 +200,7 @@ async def test_assign_pending_tasks_access_denied( @pytest.mark.asyncio async def test_assign_pending_tasks_without_api_key( - self, mock_db, mock_user, mock_manager, mock_background_tasks, caplog + self, mock_db, mock_user, mock_manager, mock_background_tasks, mock_request, caplog ): """Test that endpoint warns when API key is missing.""" from codeframe.ui.routers.tasks import assign_pending_tasks @@ -200,6 +217,7 @@ async def test_assign_pending_tasks_without_api_key( with patch("codeframe.ui.routers.tasks.manager", mock_manager), \ patch.dict(os.environ, env_without_key, clear=True): response = await assign_pending_tasks( + request=mock_request, project_id=1, background_tasks=mock_background_tasks, db=mock_db, @@ -219,7 +237,7 @@ async def test_assign_pending_tasks_without_api_key( @pytest.mark.asyncio async def test_assign_pending_tasks_only_counts_unassigned( - self, mock_db, mock_user, mock_manager, mock_background_tasks + self, mock_db, mock_user, mock_manager, mock_background_tasks, mock_request ): """Test that only pending AND unassigned tasks are counted.""" from codeframe.ui.routers.tasks import assign_pending_tasks @@ -234,6 +252,7 @@ async def test_assign_pending_tasks_only_counts_unassigned( with patch("codeframe.ui.routers.tasks.manager", mock_manager), \ patch.dict(os.environ, {"ANTHROPIC_API_KEY": "test-key"}): response = await assign_pending_tasks( + request=mock_request, project_id=1, background_tasks=mock_background_tasks, db=mock_db, @@ -247,7 +266,7 @@ async def test_assign_pending_tasks_only_counts_unassigned( @pytest.mark.asyncio async def test_assign_pending_tasks_blocked_when_execution_in_progress( - self, mock_db, mock_user, mock_manager, mock_background_tasks + self, mock_db, mock_user, mock_manager, mock_background_tasks, mock_request ): """Test that assignment is blocked when tasks are already in progress.""" from codeframe.ui.routers.tasks import assign_pending_tasks @@ -262,6 +281,7 @@ async def test_assign_pending_tasks_blocked_when_execution_in_progress( with patch("codeframe.ui.routers.tasks.manager", mock_manager), \ patch.dict(os.environ, {"ANTHROPIC_API_KEY": "test-key"}): response = await assign_pending_tasks( + request=mock_request, project_id=1, background_tasks=mock_background_tasks, db=mock_db, @@ -277,7 +297,7 @@ async def test_assign_pending_tasks_blocked_when_execution_in_progress( @pytest.mark.asyncio async def test_assign_pending_tasks_allowed_when_no_execution_in_progress( - self, mock_db, mock_user, mock_manager, mock_background_tasks + self, mock_db, mock_user, mock_manager, mock_background_tasks, mock_request ): """Test that assignment proceeds when no tasks are in progress.""" from codeframe.ui.routers.tasks import assign_pending_tasks @@ -292,6 +312,7 @@ async def test_assign_pending_tasks_allowed_when_no_execution_in_progress( with patch("codeframe.ui.routers.tasks.manager", mock_manager), \ patch.dict(os.environ, {"ANTHROPIC_API_KEY": "test-key"}): response = await assign_pending_tasks( + request=mock_request, project_id=1, background_tasks=mock_background_tasks, db=mock_db, @@ -306,7 +327,7 @@ async def test_assign_pending_tasks_allowed_when_no_execution_in_progress( @pytest.mark.asyncio async def test_assign_pending_tasks_blocked_when_tasks_assigned( - self, mock_db, mock_user, mock_manager, mock_background_tasks + self, mock_db, mock_user, mock_manager, mock_background_tasks, mock_request ): """Test that assignment is blocked when tasks are in ASSIGNED status.""" from codeframe.ui.routers.tasks import assign_pending_tasks @@ -321,6 +342,7 @@ async def test_assign_pending_tasks_blocked_when_tasks_assigned( with patch("codeframe.ui.routers.tasks.manager", mock_manager), \ patch.dict(os.environ, {"ANTHROPIC_API_KEY": "test-key"}): response = await assign_pending_tasks( + request=mock_request, project_id=1, background_tasks=mock_background_tasks, db=mock_db, @@ -363,6 +385,7 @@ def test_endpoint_accepts_required_parameters(self): sig = inspect.signature(assign_pending_tasks) params = list(sig.parameters.keys()) + assert "request" in params # Required for rate limiting assert "project_id" in params assert "background_tasks" in params assert "db" in params diff --git a/tests/ui/test_task_approval.py b/tests/ui/test_task_approval.py index b1afdcf3..ce381e19 100644 --- a/tests/ui/test_task_approval.py +++ b/tests/ui/test_task_approval.py @@ -12,12 +12,24 @@ import pytest from unittest.mock import AsyncMock, MagicMock, patch -from fastapi import BackgroundTasks +from fastapi import BackgroundTasks, Request from codeframe.core.models import Task, TaskStatus from codeframe.ui.routers.tasks import approve_tasks, TaskApprovalRequest +@pytest.fixture +def mock_request(): + """Create mock starlette Request for rate limiter.""" + request = MagicMock(spec=Request) + request.client = MagicMock() + request.client.host = "127.0.0.1" + request.headers = {} + request.state = MagicMock() + request.state.user = None + return request + + @pytest.fixture def mock_background_tasks(monkeypatch): """Create mock BackgroundTasks. @@ -79,16 +91,17 @@ class TestTaskApprovalEndpoint: @pytest.mark.asyncio async def test_approve_tasks_returns_success_response( - self, mock_db, mock_user, mock_manager, mock_background_tasks + self, mock_db, mock_user, mock_manager, mock_background_tasks, mock_request ): """Test that approving tasks returns success response with summary.""" - request = TaskApprovalRequest(approved=True, excluded_task_ids=[]) + body = TaskApprovalRequest(approved=True, excluded_task_ids=[]) with patch("codeframe.ui.routers.tasks.manager", mock_manager), \ patch("codeframe.ui.routers.tasks.PhaseManager"): response = await approve_tasks( + request=mock_request, project_id=1, - request=request, + body=body, background_tasks=mock_background_tasks, db=mock_db, current_user=mock_user @@ -101,16 +114,17 @@ async def test_approve_tasks_returns_success_response( @pytest.mark.asyncio async def test_approve_tasks_with_exclusions( - self, mock_db, mock_user, mock_manager, mock_background_tasks + self, mock_db, mock_user, mock_manager, mock_background_tasks, mock_request ): """Test that excluded tasks are not approved.""" - request = TaskApprovalRequest(approved=True, excluded_task_ids=[2, 3]) + body = TaskApprovalRequest(approved=True, excluded_task_ids=[2, 3]) with patch("codeframe.ui.routers.tasks.manager", mock_manager), \ patch("codeframe.ui.routers.tasks.PhaseManager"): response = await approve_tasks( + request=mock_request, project_id=1, - request=request, + body=body, background_tasks=mock_background_tasks, db=mock_db, current_user=mock_user @@ -121,16 +135,17 @@ async def test_approve_tasks_with_exclusions( @pytest.mark.asyncio async def test_approve_tasks_updates_task_status_to_pending( - self, mock_db, mock_user, mock_manager, mock_background_tasks + self, mock_db, mock_user, mock_manager, mock_background_tasks, mock_request ): """Test that approved tasks are updated to pending status.""" - request = TaskApprovalRequest(approved=True, excluded_task_ids=[]) + body = TaskApprovalRequest(approved=True, excluded_task_ids=[]) with patch("codeframe.ui.routers.tasks.manager", mock_manager), \ patch("codeframe.ui.routers.tasks.PhaseManager"): await approve_tasks( + request=mock_request, project_id=1, - request=request, + body=body, background_tasks=mock_background_tasks, db=mock_db, current_user=mock_user @@ -145,16 +160,17 @@ async def test_approve_tasks_updates_task_status_to_pending( @pytest.mark.asyncio async def test_approve_tasks_transitions_phase_to_active( - self, mock_db, mock_user, mock_manager, mock_background_tasks + self, mock_db, mock_user, mock_manager, mock_background_tasks, mock_request ): """Test that project phase is transitioned to active.""" - request = TaskApprovalRequest(approved=True, excluded_task_ids=[]) + body = TaskApprovalRequest(approved=True, excluded_task_ids=[]) with patch("codeframe.ui.routers.tasks.manager", mock_manager), \ patch("codeframe.ui.routers.tasks.PhaseManager") as mock_phase_manager: await approve_tasks( + request=mock_request, project_id=1, - request=request, + body=body, background_tasks=mock_background_tasks, db=mock_db, current_user=mock_user @@ -165,16 +181,17 @@ async def test_approve_tasks_transitions_phase_to_active( @pytest.mark.asyncio async def test_approve_tasks_broadcasts_development_started( - self, mock_db, mock_user, mock_manager, mock_background_tasks + self, mock_db, mock_user, mock_manager, mock_background_tasks, mock_request ): """Test that development_started event is broadcast.""" - request = TaskApprovalRequest(approved=True, excluded_task_ids=[]) + body = TaskApprovalRequest(approved=True, excluded_task_ids=[]) with patch("codeframe.ui.routers.tasks.manager", mock_manager), \ patch("codeframe.ui.routers.tasks.PhaseManager"): await approve_tasks( + request=mock_request, project_id=1, - request=request, + body=body, background_tasks=mock_background_tasks, db=mock_db, current_user=mock_user @@ -196,15 +213,16 @@ async def test_approve_tasks_broadcasts_development_started( @pytest.mark.asyncio async def test_reject_tasks_returns_rejection_message( - self, mock_db, mock_user, mock_manager, mock_background_tasks + self, mock_db, mock_user, mock_manager, mock_background_tasks, mock_request ): """Test that rejecting tasks returns rejection response.""" - request = TaskApprovalRequest(approved=False, excluded_task_ids=[]) + body = TaskApprovalRequest(approved=False, excluded_task_ids=[]) with patch("codeframe.ui.routers.tasks.manager", mock_manager): response = await approve_tasks( + request=mock_request, project_id=1, - request=request, + body=body, background_tasks=mock_background_tasks, db=mock_db, current_user=mock_user @@ -219,7 +237,7 @@ class TestTaskApprovalValidation: @pytest.mark.asyncio async def test_approve_tasks_wrong_phase_returns_400( - self, mock_db, mock_user, mock_manager, mock_background_tasks + self, mock_db, mock_user, mock_manager, mock_background_tasks, mock_request ): """Test that approving tasks in wrong phase returns 400.""" from fastapi import HTTPException @@ -230,13 +248,14 @@ async def test_approve_tasks_wrong_phase_returns_400( "phase": "discovery" # Wrong phase } - request = TaskApprovalRequest(approved=True, excluded_task_ids=[]) + body = TaskApprovalRequest(approved=True, excluded_task_ids=[]) with patch("codeframe.ui.routers.tasks.manager", mock_manager), \ pytest.raises(HTTPException) as exc_info: await approve_tasks( + request=mock_request, project_id=1, - request=request, + body=body, background_tasks=mock_background_tasks, db=mock_db, current_user=mock_user @@ -247,20 +266,21 @@ async def test_approve_tasks_wrong_phase_returns_400( @pytest.mark.asyncio async def test_approve_tasks_no_tasks_returns_404( - self, mock_db, mock_user, mock_manager, mock_background_tasks + self, mock_db, mock_user, mock_manager, mock_background_tasks, mock_request ): """Test that approving with no tasks returns 404.""" from fastapi import HTTPException mock_db.get_project_tasks.return_value = [] # No tasks - request = TaskApprovalRequest(approved=True, excluded_task_ids=[]) + body = TaskApprovalRequest(approved=True, excluded_task_ids=[]) with patch("codeframe.ui.routers.tasks.manager", mock_manager), \ pytest.raises(HTTPException) as exc_info: await approve_tasks( + request=mock_request, project_id=1, - request=request, + body=body, background_tasks=mock_background_tasks, db=mock_db, current_user=mock_user @@ -271,20 +291,21 @@ async def test_approve_tasks_no_tasks_returns_404( @pytest.mark.asyncio async def test_approve_tasks_project_not_found_returns_404( - self, mock_db, mock_user, mock_manager, mock_background_tasks + self, mock_db, mock_user, mock_manager, mock_background_tasks, mock_request ): """Test that approving for non-existent project returns 404.""" from fastapi import HTTPException mock_db.get_project.return_value = None - request = TaskApprovalRequest(approved=True, excluded_task_ids=[]) + body = TaskApprovalRequest(approved=True, excluded_task_ids=[]) with patch("codeframe.ui.routers.tasks.manager", mock_manager), \ pytest.raises(HTTPException) as exc_info: await approve_tasks( + request=mock_request, project_id=999, - request=request, + body=body, background_tasks=mock_background_tasks, db=mock_db, current_user=mock_user @@ -294,20 +315,21 @@ async def test_approve_tasks_project_not_found_returns_404( @pytest.mark.asyncio async def test_approve_tasks_access_denied_returns_403( - self, mock_db, mock_user, mock_manager, mock_background_tasks + self, mock_db, mock_user, mock_manager, mock_background_tasks, mock_request ): """Test that approving without access returns 403.""" from fastapi import HTTPException mock_db.user_has_project_access.return_value = False - request = TaskApprovalRequest(approved=True, excluded_task_ids=[]) + body = TaskApprovalRequest(approved=True, excluded_task_ids=[]) with patch("codeframe.ui.routers.tasks.manager", mock_manager), \ pytest.raises(HTTPException) as exc_info: await approve_tasks( + request=mock_request, project_id=1, - request=request, + body=body, background_tasks=mock_background_tasks, db=mock_db, current_user=mock_user @@ -494,7 +516,7 @@ def update_task(task_id, updates): @pytest.mark.asyncio async def test_end_to_end_planning_to_approval_flow( - self, mock_db_with_state, mock_user, mock_manager, mock_background_tasks + self, mock_db_with_state, mock_user, mock_manager, mock_background_tasks, mock_request ): """Test complete flow: planning phase → task approval → development phase.""" # Setup: Create tasks as if generated by planning automation @@ -507,7 +529,7 @@ async def test_end_to_end_planning_to_approval_flow( assert mock_db_with_state._project_phase == "planning" # Execute approval - request = TaskApprovalRequest(approved=True, excluded_task_ids=[]) + body = TaskApprovalRequest(approved=True, excluded_task_ids=[]) with patch("codeframe.ui.routers.tasks.manager", mock_manager), \ patch("codeframe.ui.routers.tasks.PhaseManager") as mock_pm: @@ -517,8 +539,9 @@ def transition_side_effect(pid, phase, db): mock_pm.transition.side_effect = transition_side_effect response = await approve_tasks( + request=mock_request, project_id=1, - request=request, + body=body, background_tasks=mock_background_tasks, db=mock_db_with_state, current_user=mock_user @@ -538,7 +561,7 @@ def transition_side_effect(pid, phase, db): @pytest.mark.asyncio async def test_approval_with_tasks_modified_during_review( - self, mock_db_with_state, mock_user, mock_manager, mock_background_tasks + self, mock_db_with_state, mock_user, mock_manager, mock_background_tasks, mock_request ): """Test approval when tasks are modified between generation and approval. @@ -561,13 +584,14 @@ async def test_approval_with_tasks_modified_during_review( # Task 3 was deleted ] - request = TaskApprovalRequest(approved=True, excluded_task_ids=[2, 3]) + body = TaskApprovalRequest(approved=True, excluded_task_ids=[2, 3]) with patch("codeframe.ui.routers.tasks.manager", mock_manager), \ patch("codeframe.ui.routers.tasks.PhaseManager"): response = await approve_tasks( + request=mock_request, project_id=1, - request=request, + body=body, background_tasks=mock_background_tasks, db=mock_db_with_state, current_user=mock_user @@ -583,7 +607,7 @@ class TestConcurrentApprovalAttempts: """Tests for race condition handling in task approval.""" @pytest.mark.asyncio - async def test_double_approval_second_fails(self, mock_db, mock_user, mock_manager, mock_background_tasks): + async def test_double_approval_second_fails(self, mock_db, mock_user, mock_manager, mock_background_tasks, mock_request): """Test that approving already-approved project fails gracefully. Scenario: Two users try to approve at the same time. First succeeds, @@ -602,13 +626,14 @@ def get_project_after_first_approval(project_id): mock_db.get_project.side_effect = get_project_after_first_approval - request = TaskApprovalRequest(approved=True, excluded_task_ids=[]) + body = TaskApprovalRequest(approved=True, excluded_task_ids=[]) with patch("codeframe.ui.routers.tasks.manager", mock_manager), \ pytest.raises(HTTPException) as exc_info: await approve_tasks( + request=mock_request, project_id=1, - request=request, + body=body, background_tasks=mock_background_tasks, db=mock_db, current_user=mock_user @@ -620,7 +645,7 @@ def get_project_after_first_approval(project_id): @pytest.mark.asyncio async def test_phase_transition_failure_leaves_tasks_unchanged( - self, mock_user, mock_manager, mock_background_tasks + self, mock_user, mock_manager, mock_background_tasks, mock_request ): """Test that if phase transition fails, tasks are not modified. @@ -638,7 +663,7 @@ async def test_phase_transition_failure_leaves_tasks_unchanged( Task(id=1, project_id=1, title="Task 1", status=TaskStatus.PENDING), ] - request = TaskApprovalRequest(approved=True, excluded_task_ids=[]) + body = TaskApprovalRequest(approved=True, excluded_task_ids=[]) with patch("codeframe.ui.routers.tasks.manager", mock_manager), \ patch("codeframe.ui.routers.tasks.PhaseManager") as mock_pm: @@ -649,8 +674,9 @@ async def test_phase_transition_failure_leaves_tasks_unchanged( with pytest.raises(HTTPException): await approve_tasks( + request=mock_request, project_id=1, - request=request, + body=body, background_tasks=mock_background_tasks, db=mock_db, current_user=mock_user @@ -661,7 +687,7 @@ async def test_phase_transition_failure_leaves_tasks_unchanged( @pytest.mark.asyncio async def test_tasks_deleted_between_fetch_and_update( - self, mock_user, mock_manager, mock_background_tasks + self, mock_user, mock_manager, mock_background_tasks, mock_request ): """Test handling when tasks are deleted during approval process. @@ -686,7 +712,7 @@ def update_task_with_deletion(task_id, updates): mock_db.update_task.side_effect = update_task_with_deletion - request = TaskApprovalRequest(approved=True, excluded_task_ids=[]) + body = TaskApprovalRequest(approved=True, excluded_task_ids=[]) with patch("codeframe.ui.routers.tasks.manager", mock_manager), \ patch("codeframe.ui.routers.tasks.PhaseManager"): @@ -694,8 +720,9 @@ def update_task_with_deletion(task_id, updates): # This test documents the current behavior with pytest.raises(Exception, match="Task not found"): await approve_tasks( + request=mock_request, project_id=1, - request=request, + body=body, background_tasks=mock_background_tasks, db=mock_db, current_user=mock_user diff --git a/tests/ui/test_task_approval_execution.py b/tests/ui/test_task_approval_execution.py index 8500338f..18f2af90 100644 --- a/tests/ui/test_task_approval_execution.py +++ b/tests/ui/test_task_approval_execution.py @@ -17,11 +17,23 @@ import pytest from unittest.mock import AsyncMock, MagicMock, patch -from fastapi import BackgroundTasks +from fastapi import BackgroundTasks, Request from codeframe.core.models import Task, TaskStatus +@pytest.fixture +def mock_request(): + """Create mock starlette Request for rate limiter.""" + request = MagicMock(spec=Request) + request.client = MagicMock() + request.client.host = "127.0.0.1" + request.headers = {} + request.state = MagicMock() + request.state.user = None + return request + + # ============================================================================ # Unit Tests for start_development_execution Background Task # ============================================================================ @@ -233,19 +245,20 @@ def mock_background_tasks(self): @pytest.mark.asyncio async def test_approve_tasks_schedules_background_execution( - self, mock_db, mock_user, mock_ws_manager, mock_background_tasks + self, mock_db, mock_user, mock_ws_manager, mock_background_tasks, mock_request ): """Test that approving tasks schedules multi-agent execution as background task.""" from codeframe.ui.routers.tasks import approve_tasks, TaskApprovalRequest - request = TaskApprovalRequest(approved=True, excluded_task_ids=[]) + body = TaskApprovalRequest(approved=True, excluded_task_ids=[]) with patch("codeframe.ui.routers.tasks.manager", mock_ws_manager), \ patch("codeframe.ui.routers.tasks.PhaseManager"), \ patch.dict(os.environ, {"ANTHROPIC_API_KEY": "test-key"}): await approve_tasks( + request=mock_request, project_id=1, - request=request, + body=body, background_tasks=mock_background_tasks, db=mock_db, current_user=mock_user @@ -264,13 +277,13 @@ async def test_approve_tasks_schedules_background_execution( @pytest.mark.asyncio async def test_approve_tasks_skips_execution_without_api_key( - self, mock_db, mock_user, mock_ws_manager, mock_background_tasks, caplog + self, mock_db, mock_user, mock_ws_manager, mock_background_tasks, mock_request, caplog ): """Test that execution is skipped when ANTHROPIC_API_KEY is not set.""" from codeframe.ui.routers.tasks import approve_tasks, TaskApprovalRequest import logging - request = TaskApprovalRequest(approved=True, excluded_task_ids=[]) + body = TaskApprovalRequest(approved=True, excluded_task_ids=[]) # Remove API key from environment env_without_key = {k: v for k, v in os.environ.items() if k != "ANTHROPIC_API_KEY"} @@ -280,8 +293,9 @@ async def test_approve_tasks_skips_execution_without_api_key( patch("codeframe.ui.routers.tasks.PhaseManager"), \ patch.dict(os.environ, env_without_key, clear=True): response = await approve_tasks( + request=mock_request, project_id=1, - request=request, + body=body, background_tasks=mock_background_tasks, db=mock_db, current_user=mock_user @@ -298,18 +312,19 @@ async def test_approve_tasks_skips_execution_without_api_key( @pytest.mark.asyncio async def test_approve_tasks_rejection_does_not_trigger_execution( - self, mock_db, mock_user, mock_ws_manager, mock_background_tasks + self, mock_db, mock_user, mock_ws_manager, mock_background_tasks, mock_request ): """Test that rejecting tasks does not trigger execution.""" from codeframe.ui.routers.tasks import approve_tasks, TaskApprovalRequest - request = TaskApprovalRequest(approved=False, excluded_task_ids=[]) + body = TaskApprovalRequest(approved=False, excluded_task_ids=[]) with patch("codeframe.ui.routers.tasks.manager", mock_ws_manager), \ patch.dict(os.environ, {"ANTHROPIC_API_KEY": "test-key"}): response = await approve_tasks( + request=mock_request, project_id=1, - request=request, + body=body, background_tasks=mock_background_tasks, db=mock_db, current_user=mock_user @@ -323,13 +338,13 @@ async def test_approve_tasks_rejection_does_not_trigger_execution( @pytest.mark.asyncio async def test_approve_tasks_returns_immediately( - self, mock_db, mock_user, mock_ws_manager, mock_background_tasks + self, mock_db, mock_user, mock_ws_manager, mock_background_tasks, mock_request ): """Test that approve_tasks returns immediately (doesn't wait for execution).""" from codeframe.ui.routers.tasks import approve_tasks, TaskApprovalRequest import time - request = TaskApprovalRequest(approved=True, excluded_task_ids=[]) + body = TaskApprovalRequest(approved=True, excluded_task_ids=[]) start_time = time.time() @@ -337,8 +352,9 @@ async def test_approve_tasks_returns_immediately( patch("codeframe.ui.routers.tasks.PhaseManager"), \ patch.dict(os.environ, {"ANTHROPIC_API_KEY": "test-key"}): response = await approve_tasks( + request=mock_request, project_id=1, - request=request, + body=body, background_tasks=mock_background_tasks, db=mock_db, current_user=mock_user @@ -494,19 +510,20 @@ async def test_lead_agent_instantiation_error_is_handled(self, mock_ws_manager): @pytest.mark.asyncio async def test_empty_api_key_treated_as_missing( - self, mock_db, mock_user, mock_ws_manager, mock_background_tasks + self, mock_db, mock_user, mock_ws_manager, mock_background_tasks, mock_request ): """Test that empty string API key is treated same as missing.""" from codeframe.ui.routers.tasks import approve_tasks, TaskApprovalRequest - request = TaskApprovalRequest(approved=True, excluded_task_ids=[]) + body = TaskApprovalRequest(approved=True, excluded_task_ids=[]) with patch("codeframe.ui.routers.tasks.manager", mock_ws_manager), \ patch("codeframe.ui.routers.tasks.PhaseManager"), \ patch.dict(os.environ, {"ANTHROPIC_API_KEY": ""}): await approve_tasks( + request=mock_request, project_id=1, - request=request, + body=body, background_tasks=mock_background_tasks, db=mock_db, current_user=mock_user diff --git a/tests/ui/test_v2_routers_integration.py b/tests/ui/test_v2_routers_integration.py index dee9a56d..a26ed62f 100644 --- a/tests/ui/test_v2_routers_integration.py +++ b/tests/ui/test_v2_routers_integration.py @@ -859,3 +859,133 @@ def test_task_uses_core_module(self, test_client): response = test_client.get(f"/api/v2/tasks/{task.id}") assert response.status_code == 200 assert response.json()["title"] == "Core Created Task" + + +# ============================================================================ +# Rate Limiting Integration Tests +# ============================================================================ + + +class TestRateLimitingIntegration: + """Tests for rate limiting on v2 endpoints.""" + + @pytest.fixture(autouse=True) + def reset_caches(self): + """Reset rate limit caches before and after each test.""" + from codeframe.config.rate_limits import _reset_rate_limit_config + from codeframe.core.config import reset_global_config + from codeframe.lib.rate_limiter import reset_rate_limiter + + _reset_rate_limit_config() + reset_global_config() + reset_rate_limiter() + yield + _reset_rate_limit_config() + reset_global_config() + reset_rate_limiter() + + @pytest.fixture + def rate_limited_client(self, test_workspace): + """Create a test client with rate limiting enabled at a low limit.""" + + from fastapi import Request + from slowapi import Limiter + from slowapi.errors import RateLimitExceeded + from slowapi.util import get_remote_address + + from codeframe.lib.rate_limiter import rate_limit_exceeded_handler + from codeframe.ui.dependencies import get_v2_workspace + from codeframe.ui.routers import blockers_v2 + + # Create app with rate limiting + app = FastAPI() + + # Create limiter with very low limit for testing (3 requests/minute) + limiter = Limiter(key_func=get_remote_address, default_limits=["3/minute"]) + app.state.limiter = limiter + + # Add rate limit exceeded handler + app.add_exception_handler(RateLimitExceeded, rate_limit_exceeded_handler) + + # Create a rate-limited endpoint for testing + @app.get("/api/v2/test/rate-limited") + @limiter.limit("3/minute") + async def rate_limited_endpoint(request: Request): + return {"status": "ok"} + + # Also include the blockers router for real endpoint testing + app.include_router(blockers_v2.router) + + # Override workspace dependency + def get_test_workspace(): + return test_workspace + + app.dependency_overrides[get_v2_workspace] = get_test_workspace + + client = TestClient(app) + client.workspace = test_workspace + + return client + + def test_rate_limit_allows_requests_within_limit(self, rate_limited_client): + """Requests within rate limit should succeed.""" + # Make 3 requests (at the limit) + for i in range(3): + response = rate_limited_client.get("/api/v2/test/rate-limited") + assert response.status_code == 200, f"Request {i+1} failed unexpectedly" + assert response.json()["status"] == "ok" + + def test_rate_limit_exceeded_returns_429(self, rate_limited_client): + """Exceeding rate limit should return 429 status.""" + # Make requests up to the limit + for i in range(3): + response = rate_limited_client.get("/api/v2/test/rate-limited") + assert response.status_code == 200, f"Request {i+1} should succeed" + + # Next request should be rate limited + response = rate_limited_client.get("/api/v2/test/rate-limited") + assert response.status_code == 429 + + def test_rate_limit_response_has_retry_after_header(self, rate_limited_client): + """429 response should include Retry-After header.""" + # Exhaust the limit + for _ in range(3): + rate_limited_client.get("/api/v2/test/rate-limited") + + # Get rate limited response + response = rate_limited_client.get("/api/v2/test/rate-limited") + + assert response.status_code == 429 + assert "Retry-After" in response.headers + + def test_rate_limit_response_body_format(self, rate_limited_client): + """429 response body should have proper error format.""" + # Exhaust the limit + for _ in range(3): + rate_limited_client.get("/api/v2/test/rate-limited") + + # Get rate limited response + response = rate_limited_client.get("/api/v2/test/rate-limited") + + assert response.status_code == 429 + data = response.json() + assert data["error"] == "rate_limit_exceeded" + assert "detail" in data + assert "retry_after" in data + + def test_rate_limit_on_real_endpoint(self, rate_limited_client): + """Test rate limiting behavior on actual v2 endpoint. + + Note: This test uses the standard blockers endpoint which in production + has a 100/minute limit. The test verifies the endpoint is accessible + and the rate limiting infrastructure is integrated correctly. + """ + # The blockers endpoint uses rate_limit_standard (100/min in production) + # With our test client, we're just verifying the infrastructure works + response = rate_limited_client.get("/api/v2/blockers") + + # Should succeed (within any configured limit) + assert response.status_code == 200 + data = response.json() + assert "blockers" in data + assert "total" in data diff --git a/uv.lock b/uv.lock index e68dd9fe..c45438c5 100644 --- a/uv.lock +++ b/uv.lock @@ -576,6 +576,7 @@ dependencies = [ { name = "requests" }, { name = "rich" }, { name = "ruff" }, + { name = "slowapi" }, { name = "sqlalchemy" }, { name = "tenacity" }, { name = "tiktoken" }, @@ -641,6 +642,7 @@ requires-dist = [ { name = "rich", specifier = ">=13.7.0" }, { name = "ruff", specifier = ">=0.14.0" }, { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.2.0" }, + { name = "slowapi", specifier = ">=0.1.9" }, { name = "sqlalchemy", specifier = ">=2.0.0" }, { name = "tenacity", specifier = ">=8.2.0" }, { name = "tiktoken", specifier = ">=0.12.0" }, @@ -820,6 +822,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0d/c3/e90f4a4feae6410f914f8ebac129b9ae7a8c92eb60a638012dde42030a9d/cryptography-46.0.3-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:6b5063083824e5509fdba180721d55909ffacccc8adbec85268b48439423d78c", size = 3438528, upload-time = "2025-10-15T23:18:26.227Z" }, ] +[[package]] +name = "deprecated" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "wrapt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/49/85/12f0a49a7c4ffb70572b6c2ef13c90c88fd190debda93b23f026b25f9634/deprecated-1.3.1.tar.gz", hash = "sha256:b1b50e0ff0c1fddaa5708a2c6b0a6588bb09b892825ab2b214ac9ea9d92a5223", size = 2932523, upload-time = "2025-10-30T08:19:02.757Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/84/d0/205d54408c08b13550c733c4b85429e7ead111c7f0014309637425520a9a/deprecated-1.3.1-py2.py3-none-any.whl", hash = "sha256:597bfef186b6f60181535a29fbe44865ce137a5079f295b479886c82729d5f3f", size = 11298, upload-time = "2025-10-30T08:19:00.758Z" }, +] + [[package]] name = "distlib" version = "0.4.0" @@ -1414,6 +1428,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/81/db/e655086b7f3a705df045bf0933bdd9c2f79bb3c97bfef1384598bb79a217/keyring-25.7.0-py3-none-any.whl", hash = "sha256:be4a0b195f149690c166e850609a477c532ddbfbaed96a404d4e43f8d5e2689f", size = 39160, upload-time = "2025-11-16T16:26:08.402Z" }, ] +[[package]] +name = "limits" +version = "5.6.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "deprecated" }, + { name = "packaging" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bb/e5/c968d43a65128cd54fb685f257aafb90cd5e4e1c67d084a58f0e4cbed557/limits-5.6.0.tar.gz", hash = "sha256:807fac75755e73912e894fdd61e2838de574c5721876a19f7ab454ae1fffb4b5", size = 182984, upload-time = "2025-09-29T17:15:22.689Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/40/96/4fcd44aed47b8fcc457653b12915fcad192cd646510ef3f29fd216f4b0ab/limits-5.6.0-py3-none-any.whl", hash = "sha256:b585c2104274528536a5b68864ec3835602b3c4a802cd6aa0b07419798394021", size = 60604, upload-time = "2025-09-29T17:15:18.419Z" }, +] + [[package]] name = "makefun" version = "1.16.0" @@ -2622,6 +2650,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, ] +[[package]] +name = "slowapi" +version = "0.1.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "limits" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a0/99/adfc7f94ca024736f061257d39118e1542bade7a52e86415a4c4ae92d8ff/slowapi-0.1.9.tar.gz", hash = "sha256:639192d0f1ca01b1c6d95bf6c71d794c3a9ee189855337b4821f7f457dddad77", size = 14028, upload-time = "2024-02-05T12:11:52.13Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2b/bb/f71c4b7d7e7eb3fc1e8c0458a8979b912f40b58002b9fbf37729b8cb464b/slowapi-0.1.9-py3-none-any.whl", hash = "sha256:cfad116cfb84ad9d763ee155c1e5c5cbf00b0d47399a769b227865f5df576e36", size = 14670, upload-time = "2024-02-05T12:11:50.898Z" }, +] + [[package]] name = "smmap" version = "5.0.2" @@ -3170,6 +3210,69 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fa/a8/5b41e0da817d64113292ab1f8247140aac61cbf6cfd085d6a0fa77f4984f/websockets-15.0.1-py3-none-any.whl", hash = "sha256:f7a866fbc1e97b5c617ee4116daaa09b722101d4a3c170c787450ba409f9736f", size = 169743, upload-time = "2025-03-05T20:03:39.41Z" }, ] +[[package]] +name = "wrapt" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/86/31/afb4cf08b9892430ec419a3f0f469fb978cb013f4432e0edb9c2cf06f081/wrapt-2.1.0.tar.gz", hash = "sha256:757ff1de7e1d8db1839846672aaecf4978af433cc57e808255b83980e9651914", size = 80924, upload-time = "2026-01-31T23:25:58.917Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/97/0a/de541b2543e33144043cd58da09bda8d837ba42e13ae90baca32b0553023/wrapt-2.1.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d877003dbc601e1365bd03f6a980965a20d585f90c056f33e1fc241b63a6f0e7", size = 60558, upload-time = "2026-01-31T23:25:27.784Z" }, + { url = "https://files.pythonhosted.org/packages/84/2e/7e48207420e6ca7e7a05c0e4ebe9464ec9965c8face256f3ef8cc2acd862/wrapt-2.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:771ec962fe3ccb078177c9b8f3529e204ffcbb11d62d509e0a438e6a83f7ca68", size = 61501, upload-time = "2026-01-31T23:26:46.477Z" }, + { url = "https://files.pythonhosted.org/packages/67/2b/639a4970ecdc7143acb69a1162c76b0f1620218ad502c33e1a88d28f00b1/wrapt-2.1.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:73e742368b52f9cf0921e1d2bcb8a6a44ede2e372e33df6e77caa136a942099f", size = 113954, upload-time = "2026-01-31T23:26:01.493Z" }, + { url = "https://files.pythonhosted.org/packages/81/5d/8d9177c8c0ecaf5313b462be63c5aa9672044b02bfd644dd65c6cb420d2a/wrapt-2.1.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0e9129d1b582c55ad0dfb9e29e221daa0e02b18c67d8642bc8d08dd7038b3aed", size = 115994, upload-time = "2026-01-31T23:25:57.118Z" }, + { url = "https://files.pythonhosted.org/packages/e3/e3/c5a514a0ed1dc463f5b6b4e31abbaa3b8df48b9fd391a6e8412608155a29/wrapt-2.1.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:cc9e37bfe67f6ea738851dd606640a87692ff81bcc76df313fb75d08e05e855f", size = 115245, upload-time = "2026-01-31T23:26:11.171Z" }, + { url = "https://files.pythonhosted.org/packages/35/9c/2fc6a31f5758266de2cf9dc6111d3bda7b7dd6cbdcabfd755103bbcda08f/wrapt-2.1.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:46583aae3c807aa76f96355c4943031225785ed160c84052612bba0e9d456639", size = 113679, upload-time = "2026-01-31T23:25:19.475Z" }, + { url = "https://files.pythonhosted.org/packages/6c/81/ce52694dc8184f4898c01c8af20e145b348fc7a0e4766a7345c45f0e9ce6/wrapt-2.1.0-cp311-cp311-win32.whl", hash = "sha256:e3958ba70aef2895d8c62c2d31f51ced188f60451212294677b92f4b32c12978", size = 57865, upload-time = "2026-01-31T23:25:50.947Z" }, + { url = "https://files.pythonhosted.org/packages/85/31/0df5d38243c2a538e7bd481e676d286b41f98a729e0d37cfed9f4421ad4d/wrapt-2.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:0ff9797e6e0b82b330ef80b0cdba7fcd0ca056d4c7af2ca44e3d05fd47929ede", size = 60227, upload-time = "2026-01-31T23:25:35.954Z" }, + { url = "https://files.pythonhosted.org/packages/a3/79/b587edbab21d6b8a7460234440c784e08344bcdf4fdfd9a6e9125ea14923/wrapt-2.1.0-cp311-cp311-win_arm64.whl", hash = "sha256:4b0a29509ef7b501abe47b693a3c91d1f21c9a948711f6ce7afa81eb274c7eae", size = 58648, upload-time = "2026-01-31T23:25:32.887Z" }, + { url = "https://files.pythonhosted.org/packages/f8/6f/c731b1fbbcdf9bd202809c6fa354c4237b663dd82a95035a7cbe899cfd25/wrapt-2.1.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a64c0fb29c89810973f312a04c067b63523e7303b9a2653820cbf16474c2e5cf", size = 61149, upload-time = "2026-01-31T23:25:29.092Z" }, + { url = "https://files.pythonhosted.org/packages/b2/da/7022458a1d99f0c59720a0b0fd4b1966f8df6d41e741aadfe43bc5350547/wrapt-2.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5509d9150ed01c4149e40020fa68e917d5c4bb77d311e79535565c2a0418afcb", size = 61743, upload-time = "2026-01-31T23:26:14.338Z" }, + { url = "https://files.pythonhosted.org/packages/b5/f4/57cc12c3fc6f4fe6ccfc15567cc1ac8aeb53a9946a675adc3df7a1ee4e6a/wrapt-2.1.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:52bb58b3207ace156b6134235fd43140994597704fd07d148cbcfb474ee084ea", size = 121331, upload-time = "2026-01-31T23:25:37.294Z" }, + { url = "https://files.pythonhosted.org/packages/5e/a4/a96ea114298f81f02c07313da85fd46a2a57bbe12389d0619ac3371f691c/wrapt-2.1.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7112cbf72fc4035afe1e3314a311654c41dd92c2932021ef76f5ca87583917b3", size = 122907, upload-time = "2026-01-31T23:26:49.604Z" }, + { url = "https://files.pythonhosted.org/packages/ac/43/df73362b6e47f92aaff0fc3fc459314025c795f75d61724c83232dee199c/wrapt-2.1.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e90656b433808a0ab68e95aaf9f588aea5c8c7a514e180849dfc638ba00ec449", size = 121337, upload-time = "2026-01-31T23:26:04.072Z" }, + { url = "https://files.pythonhosted.org/packages/51/4f/8147e3b9a7887cee4eeb3a3414265ad4649a156832a08063f55aa7842af0/wrapt-2.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e45f54903da38fc4f6f66397fd550fc0dac6164b4c5e721c1b4eb05664181821", size = 120461, upload-time = "2026-01-31T23:26:43.055Z" }, + { url = "https://files.pythonhosted.org/packages/35/b1/eea720fcca8a05dec848a6d11a47c20f59bdabdcc444ba3be0589350eb7a/wrapt-2.1.0-cp312-cp312-win32.whl", hash = "sha256:6653bf30dbbafd55cb4553195cc60b94920b6711a8835866c0e02aa9f22c5598", size = 58089, upload-time = "2026-01-31T23:26:47.773Z" }, + { url = "https://files.pythonhosted.org/packages/af/79/8a8f3f8c71ee3379191b69e47f32115fa25cdb6d5b581d74c64d5c897fa7/wrapt-2.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:d61238a072501ed071a9f4b9567d10c2eb3d2f1a0258ae79b47160871d8f29c3", size = 60330, upload-time = "2026-01-31T23:26:12.518Z" }, + { url = "https://files.pythonhosted.org/packages/08/4e/e992d05c3d2f7163883a65ead2620ff5fe7b3d44d7c2136ce981e40e453d/wrapt-2.1.0-cp312-cp312-win_arm64.whl", hash = "sha256:9e971000347f61271725e801ef44fa5d01b52720e59737f0d96280bffb98c5d1", size = 58727, upload-time = "2026-01-31T23:26:53.222Z" }, + { url = "https://files.pythonhosted.org/packages/30/93/b414826a5aaf2fdcfe73c2e649cbeb2e098fef4820d1217554ee64f45666/wrapt-2.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:875a10a6f3b667f90a39010af26acf684ba831d9b18a86b242899d57c74550fa", size = 61155, upload-time = "2026-01-31T23:26:24.462Z" }, + { url = "https://files.pythonhosted.org/packages/58/9e/8b21ea776bf2a3c858e3377ecde4b348893ec44dc1726baaf583ca22c56e/wrapt-2.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e00f8559ceac0fb45091daad5f15d37f2c22bdc28ed71521d47ff01aad8fff3d", size = 61747, upload-time = "2026-01-31T23:25:53.987Z" }, + { url = "https://files.pythonhosted.org/packages/da/ec/48cd2470ad09557dfe6fccfe9de98698cc0df3786a6d4d97e8edd574d67a/wrapt-2.1.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:ce0cf4c79c19904aaf2e822af280d7b3c23ad902f57e31c5a19433bc86e5d36d", size = 121342, upload-time = "2026-01-31T23:26:32.156Z" }, + { url = "https://files.pythonhosted.org/packages/3b/4e/e8447b31be27b6057cdfc904a38632a765c3407fb4d10d11e5c1d0c203d5/wrapt-2.1.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d3dd4f8c2256fcde1a85037a1837afc52e8d32d086fd669ae469455fd9a988d6", size = 122951, upload-time = "2026-01-31T23:25:08.936Z" }, + { url = "https://files.pythonhosted.org/packages/7e/b6/73a6c9277e844ffe11f3002ad27a84ff5418248def33af9435d24dfe6c5b/wrapt-2.1.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:737e1e491473047cb66944b8b8fd23f3f542019afd6cf0569d1356d18a7ea6d5", size = 121373, upload-time = "2026-01-31T23:26:18.322Z" }, + { url = "https://files.pythonhosted.org/packages/85/04/869384435fecf829dc05621ffa02dab0f2f830be5d42fa8d8ac7b0b4c9fa/wrapt-2.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:38de19e30e266c15d542ceb0603e657db4e82c53e7f47fd70674ae5da2b41180", size = 120468, upload-time = "2026-01-31T23:25:13.689Z" }, + { url = "https://files.pythonhosted.org/packages/80/ac/42a5378d9b5b486122ae0572c46ae8d69ab6486b9f13961e6b9706297ff5/wrapt-2.1.0-cp313-cp313-win32.whl", hash = "sha256:bc7d496b6e16bd2f77e37e8969b21a7b58d6954e46c6689986fb67b9078100e5", size = 58095, upload-time = "2026-01-31T23:26:33.481Z" }, + { url = "https://files.pythonhosted.org/packages/86/de/538fcef30f70a1aaadab4cab7d0396037518d7ec2b064557171147ce297f/wrapt-2.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:57df799e67b011847ef7ac64b05ed4633e56b64e7e7cab5eb83dc9689dbe0acf", size = 60344, upload-time = "2026-01-31T23:25:10.615Z" }, + { url = "https://files.pythonhosted.org/packages/08/13/27884668b21e9f0a625c13ebd6a8d70ad8371250ec8519881858404686bf/wrapt-2.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:01559d2961c29edc6263849fd9d32b29a20737da67648c7fd752a67bd96208c7", size = 58734, upload-time = "2026-01-31T23:26:00.099Z" }, + { url = "https://files.pythonhosted.org/packages/c9/a3/e558c5b8f3a097aa1e942e2d75923adebfdfafb5a51ec425d1d062e49ab0/wrapt-2.1.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:66f588c8b3a44863156cfaccb516f946a64b3b03a6880822ab0b878135ca1f5c", size = 62972, upload-time = "2026-01-31T23:26:08.576Z" }, + { url = "https://files.pythonhosted.org/packages/93/b6/7157e98107099fad846f1e79308cc0954e26b25b01c03f1624ba7f57ec54/wrapt-2.1.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:355779ff720c11a2a5cffd03332dbce1005cb4747dca65b0fc8cdd5f8bf1037e", size = 63610, upload-time = "2026-01-31T23:26:39.9Z" }, + { url = "https://files.pythonhosted.org/packages/e4/8e/b8992671e4b4d3ce2a53af930588c204bf37b66eb212bd1722f2a5a8cf62/wrapt-2.1.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7a0471df3fb4e85a9ff62f7142cdb169e31172467cdb79a713f9b1319c555903", size = 152538, upload-time = "2026-01-31T23:26:27.696Z" }, + { url = "https://files.pythonhosted.org/packages/8c/f6/79f9fd4b3c0a8715e651fff1cc1182a983fd971376d5688a06fa94e31acd/wrapt-2.1.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5bacf063143fa86f15b00a21259a81c95c527a18d504b8c820835366d361c879", size = 158702, upload-time = "2026-01-31T23:25:11.848Z" }, + { url = "https://files.pythonhosted.org/packages/9e/46/f88b52beb813eeb830d9134bc6eaf3e53cde4e3cfa1804e383754d4104fe/wrapt-2.1.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c87cd4f61a3b7cd65113e74006e1cd6352b74807fcc65d440e8342f001f8de5e", size = 155564, upload-time = "2026-01-31T23:25:15.033Z" }, + { url = "https://files.pythonhosted.org/packages/93/31/97145ea71e3e5a1b419af5c410b07b258155dc7cc1a6302791a93e991c83/wrapt-2.1.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:2893498fe898719ac8fb6b4fe36ca86892bec1e2480d94e3bd1bc592c00527ad", size = 150165, upload-time = "2026-01-31T23:26:09.848Z" }, + { url = "https://files.pythonhosted.org/packages/10/bd/f33551d5bfbb0ddab81296cffc15570570039a973c0f99bba474be0fadf2/wrapt-2.1.0-cp313-cp313t-win32.whl", hash = "sha256:cbc07f101f5f1e7c23ec06a07e45715f459de992108eeb381b21b76d94dbaf4f", size = 59785, upload-time = "2026-01-31T23:25:52.23Z" }, + { url = "https://files.pythonhosted.org/packages/5f/3a/9a76be7a36442f43841bb6336e262e09a915b2fb5dfc2822ffce1fb903d2/wrapt-2.1.0-cp313-cp313t-win_amd64.whl", hash = "sha256:2ccc89cd504fc29c32f0b24046e8edf3ef0fcbc5d5efe8c91b303c099863d2c8", size = 63085, upload-time = "2026-01-31T23:26:05.363Z" }, + { url = "https://files.pythonhosted.org/packages/7a/35/65a13c2df008d189ebca5fec534011c5dd69ab4f47e6923b403321816fbf/wrapt-2.1.0-cp313-cp313t-win_arm64.whl", hash = "sha256:0b660be1c9cdfb4c711baab4ccbd0e9d1b65a0480d38729ec8cdbf3b29cb7f15", size = 60254, upload-time = "2026-01-31T23:25:06.052Z" }, + { url = "https://files.pythonhosted.org/packages/6f/eb/7c9eb1ea9b10ea98d9983a147c877a2ae927acb4a86e2dc4a0b548f05ad1/wrapt-2.1.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:7f7bf95bae7ac5f2bbcb307464b3b0ff70569dd3b036a87b1cf7efb2c76e66e5", size = 61316, upload-time = "2026-01-31T23:25:20.739Z" }, + { url = "https://files.pythonhosted.org/packages/6d/c2/1c3d16d6b644f688913a00e2dc10f59adca817b5b3ee034ce4e9a692ab63/wrapt-2.1.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:be2f541a242818829526e5d08c716b6730970ed0dc1b76ba962a546947d0f005", size = 61813, upload-time = "2026-01-31T23:25:49.714Z" }, + { url = "https://files.pythonhosted.org/packages/8c/51/b6170084b6b771cc62374d924e328df2e81f687399a835f003497cad1110/wrapt-2.1.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:ad3aa174d06a14b4758d5a1678b9adde8b8e657c6695de9a3d4c223f4fcbbcce", size = 120309, upload-time = "2026-01-31T23:25:16.866Z" }, + { url = "https://files.pythonhosted.org/packages/f8/34/467829f0dd79f50878b2e67b67c67c816a6326a27d252d4192ef815b4a09/wrapt-2.1.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bffa584240d41bc3127510e07a752f94223d73bb1283ac2e99ac44235762efd2", size = 122690, upload-time = "2026-01-31T23:26:16.914Z" }, + { url = "https://files.pythonhosted.org/packages/df/5b/244c61a65e0bc9d4a18cfa2a2b3b05f8065290284fc60436a7ea5047ee10/wrapt-2.1.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9b2da9c8f1723994b335dbf9f496fbfabc76bcdd001f73772b8eb2118a714cea", size = 121115, upload-time = "2026-01-31T23:26:44.518Z" }, + { url = "https://files.pythonhosted.org/packages/86/7d/f9b5e103d3caf23a72c04a1baf2b61c4a14d1feb440d3c98c26725b4503a/wrapt-2.1.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:eabe95ea5fbe1524a53c0f3fc535c99f2aa376ec1451b0b79d943d2240d80e36", size = 119487, upload-time = "2026-01-31T23:25:34.186Z" }, + { url = "https://files.pythonhosted.org/packages/f8/49/b61fdc4680dd5cd6828977341b9fd729e2c623338bfe65647f5c0ff8195e/wrapt-2.1.0-cp314-cp314-win32.whl", hash = "sha256:2cd647097df1df78f027ac7d5d663f05daa1a117b69cf7f476cb299f90557747", size = 58519, upload-time = "2026-01-31T23:25:04.426Z" }, + { url = "https://files.pythonhosted.org/packages/6a/4f/42ab43e496d0d19caed9f69366d0f28f7f08c139297e78b17dab6ecbb6d5/wrapt-2.1.0-cp314-cp314-win_amd64.whl", hash = "sha256:c0fc3e388a14ef8101c685dc80b4d2932924a639a03e5c44b5ffabbda2f1f2dc", size = 60767, upload-time = "2026-01-31T23:25:21.954Z" }, + { url = "https://files.pythonhosted.org/packages/ef/15/0337768ac97a8758bc0fc1afdf5f656075a7facf198f62bbe8a22b789277/wrapt-2.1.0-cp314-cp314-win_arm64.whl", hash = "sha256:7c06653908a23a85c4b2455b9d37c085f9756c09058df87b4a2fce2b2f8d58c2", size = 59056, upload-time = "2026-01-31T23:26:25.814Z" }, + { url = "https://files.pythonhosted.org/packages/d6/f1/58f4674d1db44912003a51b34e8d9823a832fbbb39162e9dbe06e5f6424e/wrapt-2.1.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:c70b4829c6f2f4af4cdaa16442032fcaf882063304160555e4a19b43fd2c6c9d", size = 63061, upload-time = "2026-01-31T23:26:06.601Z" }, + { url = "https://files.pythonhosted.org/packages/02/c1/07f6bf6619285f39cd616314217170c6160da99a46ad6ae4a60044f6ab5a/wrapt-2.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d7fd4c4ee51ebdf245549d54a7c2181a4f39caac97c9dc8a050b5ba814067a29", size = 63620, upload-time = "2026-01-31T23:25:30.326Z" }, + { url = "https://files.pythonhosted.org/packages/46/82/f7df1648762260f60c4e22c066a17d95f20267c94bfe653fab4f08e2c297/wrapt-2.1.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a7b158558438874e5fd5cb505b5a635bd08c84857bc937973d9e12e1166cdf3b", size = 152546, upload-time = "2026-01-31T23:25:02.102Z" }, + { url = "https://files.pythonhosted.org/packages/78/b7/d953336e09bac13a9ffa9073e167c5dec8aaa4a717a8551bf64cb4683590/wrapt-2.1.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3e2e156fe2d41700b837be9b1d8d80ebab44e9891589bc7c41578ef110184e29", size = 158704, upload-time = "2026-01-31T23:25:43.269Z" }, + { url = "https://files.pythonhosted.org/packages/39/a1/2ed57e46b30af2a5a750c85a9dd30d2244ef10e2f8db150560126d8cbd24/wrapt-2.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:9f1e9bac6a6c1ba65e0ac50e32c575266734a07b6c17e718c4babd91e2faa69b", size = 155563, upload-time = "2026-01-31T23:25:39.17Z" }, + { url = "https://files.pythonhosted.org/packages/d0/8c/4f54f7ea5addf208be44459393185aaa193bd2d0b8ecf4683b159fcc5238/wrapt-2.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:12687e6271df7ae5706bee44cc1f77fecb7805976ec9f14f58381b30ae2aceb5", size = 150189, upload-time = "2026-01-31T23:25:44.654Z" }, + { url = "https://files.pythonhosted.org/packages/b7/cc/e8290a1cd94297fbc1e9fbad06481b5a7c918f2db6645c550f05ee47f359/wrapt-2.1.0-cp314-cp314t-win32.whl", hash = "sha256:38bbe336ee32f67eb99f886bd4f040d91310b7e660061bb03b9083d26e8cf915", size = 60431, upload-time = "2026-01-31T23:25:48.34Z" }, + { url = "https://files.pythonhosted.org/packages/d0/df/af5d244938853e3adb1251ca1397e9fa78d3e92adc808a0af0a8547585d3/wrapt-2.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:0fa64a9a07df7f85b352adc42b43e7f44085fb11191b8f5b9b77219f7aaf7e17", size = 63859, upload-time = "2026-01-31T23:26:23.2Z" }, + { url = "https://files.pythonhosted.org/packages/39/c4/28b6f2804e8bc05d17114dfed03a80bce5b83ca2113fd44eecbef12275d1/wrapt-2.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:da379cbdf3b7d97ace33a69a391b7a7e2130b1aca94dc447246217994233974c", size = 60446, upload-time = "2026-01-31T23:25:41.001Z" }, + { url = "https://files.pythonhosted.org/packages/57/e9/70983b75d4abd6f85cffc6df79c623220ec5a579ceaacabac35c904b7b52/wrapt-2.1.0-py3-none-any.whl", hash = "sha256:e035693a0d25ea5bf5826df3e203dff7d091b0d5442aaefec9ca8f2bab38417f", size = 43886, upload-time = "2026-01-31T23:25:07.22Z" }, +] + [[package]] name = "yarl" version = "1.22.0"