diff --git a/backend/dependencies.py b/backend/dependencies.py index 6ad37ef..0a2e93b 100644 --- a/backend/dependencies.py +++ b/backend/dependencies.py @@ -17,6 +17,7 @@ from services.rate_limiter import RateLimiter, APIKeyManager from services.supabase_service import get_supabase_service from services.input_validator import InputValidator, CostController +from services.user_limits import init_user_limits_service, get_user_limits_service # Service instances (singleton pattern) indexer = OptimizedCodeIndexer() @@ -31,6 +32,12 @@ api_key_manager = APIKeyManager(get_supabase_service().client) cost_controller = CostController(get_supabase_service().client) +# User tier and limits management +user_limits = init_user_limits_service( + supabase_client=get_supabase_service().client, + redis_client=cache.redis if cache.redis else None +) + def get_repo_or_404(repo_id: str, user_id: str) -> dict: """ diff --git a/backend/main.py b/backend/main.py index a901345..6eccc59 100644 --- a/backend/main.py +++ b/backend/main.py @@ -25,6 +25,7 @@ from routes.search import router as search_router from routes.analysis import router as analysis_router from routes.api_keys import router as api_keys_router +from routes.users import router as users_router # Lifespan context manager for startup/shutdown @@ -86,6 +87,7 @@ async def dispatch(self, request: Request, call_next): app.include_router(search_router, prefix=API_PREFIX) app.include_router(analysis_router, prefix=API_PREFIX) app.include_router(api_keys_router, prefix=API_PREFIX) +app.include_router(users_router, prefix=API_PREFIX) # WebSocket endpoint (versioned) app.add_api_websocket_route(f"{API_PREFIX}/ws/index/{{repo_id}}", websocket_index) diff --git a/backend/routes/users.py b/backend/routes/users.py new file mode 100644 index 0000000..544a223 --- /dev/null +++ b/backend/routes/users.py @@ -0,0 +1,74 @@ +"""User routes - profile and usage information.""" +from fastapi import APIRouter, Depends + +from dependencies import user_limits +from middleware.auth import require_auth, AuthContext +from services.observability import logger +from services.user_limits import TIER_LIMITS, UserTier + +router = APIRouter(prefix="/users", tags=["Users"]) + + +@router.get("/usage") +def get_user_usage(auth: AuthContext = Depends(require_auth)): + """ + Get current user's usage and limits. + + Returns: + - tier: Current subscription tier + - repositories: Current count and limit + - limits: All tier limits + - features: Available features for tier + """ + user_id = auth.user_id + + if not user_id: + logger.warning("Usage check without user_id", identifier=auth.identifier) + # Return free tier defaults for API key users + free_limits = TIER_LIMITS[UserTier.FREE] + return { + "tier": "free", + "repositories": { + "current": 0, + "limit": free_limits.max_repos, + "display": f"0/{free_limits.max_repos}" + }, + "limits": { + "max_files_per_repo": free_limits.max_files_per_repo, + "max_functions_per_repo": free_limits.max_functions_per_repo, + "playground_searches_per_day": free_limits.playground_searches_per_day, + }, + "features": { + "priority_indexing": free_limits.priority_indexing, + "mcp_access": free_limits.mcp_access, + } + } + + usage = user_limits.get_usage_summary(user_id) + logger.info("Usage retrieved", user_id=user_id, tier=usage.get("tier")) + + return usage + + +@router.get("/limits/check-repo-add") +def check_can_add_repo(auth: AuthContext = Depends(require_auth)): + """ + Check if user can add another repository. + + Call this before showing "Add Repository" button to + disable it if limit reached. + """ + user_id = auth.user_id + + if not user_id: + # API key users - return allowed with free tier info + free_limits = TIER_LIMITS[UserTier.FREE] + return { + "allowed": True, + "message": "OK", + "tier": "free", + "limit": free_limits.max_repos + } + + result = user_limits.check_repo_count(user_id) + return result.to_dict() diff --git a/backend/services/user_limits.py b/backend/services/user_limits.py new file mode 100644 index 0000000..61a2404 --- /dev/null +++ b/backend/services/user_limits.py @@ -0,0 +1,454 @@ +""" +User Tier & Limits Service +Centralized system for managing user tiers and resource limits. + +Tiers: +- free: Default tier for new users +- pro: Paid tier with higher limits +- enterprise: Custom limits for large organizations + +Used by: +- #93: Playground rate limiting +- #94: Repo size limits +- #95: Repo count limits +""" +from dataclasses import dataclass +from typing import Optional, Dict, Any +from enum import Enum + +from services.observability import logger, metrics +from services.sentry import capture_exception + + +class UserTier(str, Enum): + """User subscription tiers""" + FREE = "free" + PRO = "pro" + ENTERPRISE = "enterprise" + + +@dataclass(frozen=True) +class TierLimits: + """Resource limits for a tier""" + # Repository limits + max_repos: Optional[int] # None = unlimited + max_files_per_repo: int + max_functions_per_repo: int + + # Playground limits (anti-abuse, not business gate) + playground_searches_per_day: Optional[int] # None = unlimited + + # Future limits (placeholders) + max_team_members: Optional[int] = None + priority_indexing: bool = False + mcp_access: bool = True + + +# Tier definitions - Single source of truth +TIER_LIMITS: Dict[UserTier, TierLimits] = { + UserTier.FREE: TierLimits( + max_repos=3, + max_files_per_repo=500, + max_functions_per_repo=2000, + playground_searches_per_day=50, # Generous, anti-abuse only + max_team_members=1, + priority_indexing=False, + mcp_access=True, + ), + UserTier.PRO: TierLimits( + max_repos=20, + max_files_per_repo=5000, + max_functions_per_repo=20000, + playground_searches_per_day=None, # Unlimited + max_team_members=10, + priority_indexing=True, + mcp_access=True, + ), + UserTier.ENTERPRISE: TierLimits( + max_repos=None, # Unlimited + max_files_per_repo=50000, + max_functions_per_repo=200000, + playground_searches_per_day=None, + max_team_members=None, + priority_indexing=True, + mcp_access=True, + ), +} + + +@dataclass +class LimitCheckResult: + """Result of a limit check""" + allowed: bool + current: int + limit: Optional[int] + message: str + tier: str = "free" # Include tier for frontend upgrade prompts + error_code: Optional[str] = None # e.g., "REPO_LIMIT_REACHED" + + @property + def limit_display(self) -> str: + """Display limit as string (handles unlimited)""" + return str(self.limit) if self.limit is not None else "unlimited" + + def to_dict(self) -> Dict[str, Any]: + result = { + "allowed": self.allowed, + "current": self.current, + "limit": self.limit, + "limit_display": self.limit_display, + "message": self.message, + "tier": self.tier, + } + if self.error_code: + result["error_code"] = self.error_code + return result + + +class LimitCheckError(Exception): + """Raised when limit check fails due to system error (not limit exceeded)""" + pass + + +class UserLimitsService: + """ + Service for checking and enforcing user tier limits. + + Usage: + limits = UserLimitsService(supabase_client, redis_client) + + # Check if user can add another repo + result = limits.check_repo_count(user_id) + if not result.allowed: + raise HTTPException(403, result.to_dict()) + + # Check if repo size is within limits + result = limits.check_repo_size(user_id, file_count, function_count) + if not result.allowed: + raise HTTPException(400, result.to_dict()) + """ + + def __init__(self, supabase_client, redis_client=None): + self.supabase = supabase_client + self.redis = redis_client + self._tier_cache_ttl = 300 # Cache tier for 5 minutes + + def _validate_user_id(self, user_id: str) -> bool: + """Validate user_id is not empty""" + if not user_id or not isinstance(user_id, str) or not user_id.strip(): + return False + return True + + # ===== TIER MANAGEMENT ===== + + def get_user_tier(self, user_id: str) -> UserTier: + """ + Get user's current tier. + + Checks Redis cache first, then Supabase. + Defaults to FREE if not found. + """ + if not self._validate_user_id(user_id): + logger.warning("Invalid user_id provided to get_user_tier", user_id=user_id) + return UserTier.FREE + + # Try cache first + if self.redis: + try: + cache_key = f"user:tier:{user_id}" + cached = self.redis.get(cache_key) + if cached: + tier_value = cached.decode() if isinstance(cached, bytes) else cached + return UserTier(tier_value) + except Exception as e: + logger.warning("Redis cache read failed", error=str(e)) + # Continue to DB lookup + + # Query Supabase + tier = self._get_tier_from_db(user_id) + + # Cache the result + if self.redis: + try: + cache_key = f"user:tier:{user_id}" + self.redis.setex(cache_key, self._tier_cache_ttl, tier.value) + except Exception as e: + logger.warning("Redis cache write failed", error=str(e)) + + return tier + + def _get_tier_from_db(self, user_id: str) -> UserTier: + """Get tier from Supabase user_profiles table""" + try: + result = self.supabase.table("user_profiles").select("tier").eq("user_id", user_id).execute() + + if result.data and result.data[0].get("tier"): + tier_value = result.data[0]["tier"] + return UserTier(tier_value) + except Exception as e: + logger.warning("Failed to get user tier from DB", user_id=user_id, error=str(e)) + capture_exception(e) + + # Default to FREE - this is safe because FREE has the most restrictive limits + return UserTier.FREE + + def get_limits(self, tier: UserTier) -> TierLimits: + """Get limits for a tier""" + return TIER_LIMITS.get(tier, TIER_LIMITS[UserTier.FREE]) + + def get_user_limits(self, user_id: str) -> TierLimits: + """Get limits for a specific user""" + tier = self.get_user_tier(user_id) + return self.get_limits(tier) + + def invalidate_tier_cache(self, user_id: str) -> None: + """Invalidate cached tier (call after tier upgrade)""" + if self.redis and self._validate_user_id(user_id): + try: + cache_key = f"user:tier:{user_id}" + self.redis.delete(cache_key) + logger.info("Tier cache invalidated", user_id=user_id) + except Exception as e: + logger.warning("Failed to invalidate tier cache", error=str(e)) + + # ===== REPO COUNT LIMITS (#95) ===== + + def get_user_repo_count(self, user_id: str, raise_on_error: bool = False) -> int: + """ + Get current repo count for user. + + Args: + user_id: The user ID + raise_on_error: If True, raise LimitCheckError on DB failure + If False, return 0 (fail-open for reads, fail-closed for writes) + """ + if not self._validate_user_id(user_id): + return 0 + + try: + result = self.supabase.table("repositories").select("id", count="exact").eq("user_id", user_id).execute() + return result.count or 0 + except Exception as e: + logger.error("Failed to get repo count", user_id=user_id, error=str(e)) + capture_exception(e) + if raise_on_error: + raise LimitCheckError(f"Failed to check repository count: {str(e)}") + return 0 + + def check_repo_count(self, user_id: str) -> LimitCheckResult: + """ + Check if user can add another repository. + + Returns: + LimitCheckResult with allowed=True if under limit + + Note: Fails CLOSED - if we can't check, we don't allow. + """ + if not self._validate_user_id(user_id): + return LimitCheckResult( + allowed=False, + current=0, + limit=0, + message="Invalid user ID", + tier="unknown", + error_code="INVALID_USER" + ) + + tier = self.get_user_tier(user_id) + limits = self.get_limits(tier) + + try: + current_count = self.get_user_repo_count(user_id, raise_on_error=True) + except LimitCheckError as e: + # Fail CLOSED - don't allow if we can't verify + return LimitCheckResult( + allowed=False, + current=0, + limit=limits.max_repos, + message="Unable to verify repository limit. Please try again.", + tier=tier.value, + error_code="SYSTEM_ERROR" + ) + + # Unlimited repos + if limits.max_repos is None: + return LimitCheckResult( + allowed=True, + current=current_count, + limit=None, + message=f"OK ({current_count} repos)", + tier=tier.value + ) + + # Check limit + if current_count >= limits.max_repos: + metrics.increment("user_limit_exceeded", tags={"limit": "repo_count", "tier": tier.value}) + logger.info("Repo count limit reached", user_id=user_id, current=current_count, limit=limits.max_repos) + return LimitCheckResult( + allowed=False, + current=current_count, + limit=limits.max_repos, + message=f"Repository limit reached ({current_count}/{limits.max_repos}). Upgrade to add more repositories.", + tier=tier.value, + error_code="REPO_LIMIT_REACHED" + ) + + return LimitCheckResult( + allowed=True, + current=current_count, + limit=limits.max_repos, + message=f"OK ({current_count}/{limits.max_repos} repos)", + tier=tier.value + ) + + # ===== REPO SIZE LIMITS (#94) ===== + + def check_repo_size( + self, + user_id: str, + file_count: int, + function_count: int + ) -> LimitCheckResult: + """ + Check if repo size is within user's tier limits. + + Args: + user_id: The user attempting to index + file_count: Number of code files in repo + function_count: Number of functions/classes detected + + Returns: + LimitCheckResult with allowed=True if within limits + """ + if not self._validate_user_id(user_id): + return LimitCheckResult( + allowed=False, + current=0, + limit=0, + message="Invalid user ID", + tier="unknown", + error_code="INVALID_USER" + ) + + tier = self.get_user_tier(user_id) + limits = self.get_limits(tier) + + # Check file count + if file_count > limits.max_files_per_repo: + metrics.increment("user_limit_exceeded", tags={"limit": "file_count", "tier": tier.value}) + logger.info( + "Repo file count exceeds limit", + user_id=user_id, + file_count=file_count, + limit=limits.max_files_per_repo + ) + return LimitCheckResult( + allowed=False, + current=file_count, + limit=limits.max_files_per_repo, + message=f"Repository too large ({file_count:,} files). {tier.value.title()} tier allows up to {limits.max_files_per_repo:,} files.", + tier=tier.value, + error_code="REPO_TOO_LARGE" + ) + + # Check function count + if function_count > limits.max_functions_per_repo: + metrics.increment("user_limit_exceeded", tags={"limit": "function_count", "tier": tier.value}) + logger.info( + "Repo function count exceeds limit", + user_id=user_id, + function_count=function_count, + limit=limits.max_functions_per_repo + ) + return LimitCheckResult( + allowed=False, + current=function_count, + limit=limits.max_functions_per_repo, + message=f"Repository has too many functions ({function_count:,}). {tier.value.title()} tier allows up to {limits.max_functions_per_repo:,} functions.", + tier=tier.value, + error_code="REPO_TOO_LARGE" + ) + + return LimitCheckResult( + allowed=True, + current=file_count, + limit=limits.max_files_per_repo, + message=f"OK ({file_count:,} files, {function_count:,} functions)", + tier=tier.value + ) + + # ===== PLAYGROUND RATE LIMITS (#93) ===== + + def get_playground_limit(self, tier: UserTier = UserTier.FREE) -> Optional[int]: + """Get playground search limit for tier""" + return self.get_limits(tier).playground_searches_per_day + + # ===== USAGE SUMMARY ===== + + def get_usage_summary(self, user_id: str) -> Dict[str, Any]: + """ + Get complete usage summary for user. + Useful for dashboard display. + """ + if not self._validate_user_id(user_id): + # Return free tier defaults for invalid user + limits = TIER_LIMITS[UserTier.FREE] + return { + "tier": "free", + "repositories": { + "current": 0, + "limit": limits.max_repos, + "display": f"0/{limits.max_repos}" + }, + "limits": { + "max_files_per_repo": limits.max_files_per_repo, + "max_functions_per_repo": limits.max_functions_per_repo, + "playground_searches_per_day": limits.playground_searches_per_day, + }, + "features": { + "priority_indexing": limits.priority_indexing, + "mcp_access": limits.mcp_access, + } + } + + tier = self.get_user_tier(user_id) + limits = self.get_limits(tier) + repo_count = self.get_user_repo_count(user_id) + + return { + "tier": tier.value, + "repositories": { + "current": repo_count, + "limit": limits.max_repos, + "display": f"{repo_count}/{limits.max_repos if limits.max_repos else 'unlimited'}" + }, + "limits": { + "max_files_per_repo": limits.max_files_per_repo, + "max_functions_per_repo": limits.max_functions_per_repo, + "playground_searches_per_day": limits.playground_searches_per_day, + }, + "features": { + "priority_indexing": limits.priority_indexing, + "mcp_access": limits.mcp_access, + } + } + + +# Singleton instance (initialized in dependencies.py) +_user_limits_service: Optional[UserLimitsService] = None + + +def get_user_limits_service() -> UserLimitsService: + """Get or create UserLimitsService instance""" + global _user_limits_service + if _user_limits_service is None: + raise RuntimeError("UserLimitsService not initialized. Call init_user_limits_service first.") + return _user_limits_service + + +def init_user_limits_service(supabase_client, redis_client=None) -> UserLimitsService: + """Initialize the UserLimitsService singleton""" + global _user_limits_service + _user_limits_service = UserLimitsService(supabase_client, redis_client) + logger.info("UserLimitsService initialized") + return _user_limits_service diff --git a/docs/TIER_SYSTEM_DESIGN.md b/docs/TIER_SYSTEM_DESIGN.md new file mode 100644 index 0000000..d5b093f --- /dev/null +++ b/docs/TIER_SYSTEM_DESIGN.md @@ -0,0 +1,387 @@ +# User Tier & Limits System - Design Document + +> **Issues**: #93, #94, #95, #96, #97 +> **Author**: Devanshu +> **Status**: Implemented +> **Last Updated**: 2025-12-13 + +--- + +## 1. Problem Statement + +CodeIntel needs a tiered system to: +1. **Protect costs** - Indexing is expensive ($0.02-$50/repo depending on size) +2. **Enable growth** - Freemium model with upgrade path +3. **Prevent abuse** - Rate limit anonymous playground users + +**Key Insight**: Searching is nearly free ($0.000001/query). Indexing is the real cost driver. + +--- + +## 2. Tier Definitions + +| Tier | Max Repos | Files/Repo | Functions/Repo | Playground/Day | +|------|-----------|------------|----------------|----------------| +| **Free** | 3 | 500 | 2,000 | 50 | +| **Pro** | 20 | 5,000 | 20,000 | Unlimited | +| **Enterprise** | Unlimited | 50,000 | 200,000 | Unlimited | + +**Rationale**: +- Free tier: Enough for personal projects, not enterprise codebases +- Playground limit: 50/day is generous (anti-abuse, not business gate) +- File/function limits: Prevent expensive indexing jobs + +--- + +## 3. Current API Endpoints + +### 3.1 Authentication (`/api/v1/auth`) +| Method | Endpoint | Auth | Description | +|--------|----------|------|-------------| +| POST | `/signup` | None | Create account | +| POST | `/login` | None | Get JWT | +| POST | `/refresh` | JWT | Refresh token | +| POST | `/logout` | JWT | Invalidate session | +| GET | `/me` | JWT | Get current user | + +### 3.2 Repositories (`/api/v1/repos`) +| Method | Endpoint | Auth | Description | **Limits Check** | +|--------|----------|------|-------------|------------------| +| GET | `/` | JWT | List user repos | - | +| POST | `/` | JWT | Add repo | **#95: Check repo count** | +| POST | `/{id}/index` | JWT | Index repo | **#94: Check file/function count** | + +### 3.3 Search (`/api/v1/search`) +| Method | Endpoint | Auth | Description | **Limits Check** | +|--------|----------|------|-------------|------------------| +| POST | `/search` | JWT | Search code | - | +| POST | `/explain` | JWT | Explain code | - | + +### 3.4 Playground (`/api/v1/playground`) - **Anonymous** +| Method | Endpoint | Auth | Description | **Limits Check** | +|--------|----------|------|-------------|------------------| +| GET | `/repos` | None | List demo repos | - | +| POST | `/search` | None | Search demo repos | **#93: Rate limit 50/day** | + +### 3.5 Analysis (`/api/v1/analysis`) +| Method | Endpoint | Auth | Description | +|--------|----------|------|-------------| +| GET | `/{id}/dependencies` | JWT | Dependency graph | +| POST | `/{id}/impact` | JWT | Impact analysis | +| GET | `/{id}/insights` | JWT | Repo insights | +| GET | `/{id}/style-analysis` | JWT | Code style | + +### 3.6 Users (`/api/v1/users`) - **NEW** +| Method | Endpoint | Auth | Description | +|--------|----------|------|-------------| +| GET | `/usage` | JWT | Get tier, limits, current usage | +| GET | `/limits/check-repo-add` | JWT | Pre-check before adding repo | + +--- + +## 4. Implementation Plan by Issue + +### Issue #96: User Tier System (Foundation) ✅ DONE +**Files Created**: +- `backend/services/user_limits.py` - Core service +- `backend/routes/users.py` - API endpoints +- `supabase/migrations/001_user_profiles.sql` - DB schema + +**Service Methods**: +```python +class UserLimitsService: + def get_user_tier(user_id) -> UserTier + def get_user_limits(user_id) -> TierLimits + def get_user_repo_count(user_id) -> int + def check_repo_count(user_id) -> LimitCheckResult + def check_repo_size(user_id, file_count, func_count) -> LimitCheckResult + def get_usage_summary(user_id) -> dict + def invalidate_tier_cache(user_id) -> None # Call after tier upgrade +``` + +### Issue #95: Repo Count Limits +**Where**: `POST /api/v1/repos` + +**Changes to `routes/repos.py`**: +```python +@router.post("") +def add_repository(request, auth): + # NEW: Check repo count limit + result = user_limits.check_repo_count(auth.user_id) + if not result.allowed: + raise HTTPException( + status_code=403, + detail=result.to_dict() + ) + # ... existing code +``` + +**Frontend Integration**: +- Call `GET /users/limits/check-repo-add` before showing Add Repo button +- Show "2/3 repos used" in sidebar +- Show upgrade prompt when limit reached + +### Issue #94: Repo Size Limits +**Where**: `POST /api/v1/repos/{id}/index` + +**Changes to `routes/repos.py`**: +```python +@router.post("/{repo_id}/index") +def index_repository(repo_id, auth): + repo = get_repo_or_404(repo_id, auth.user_id) + + # Count files and estimate functions BEFORE indexing + file_count = count_code_files(repo["local_path"]) + estimated_functions = file_count * 25 # Conservative estimate + + # NEW: Check size limits + result = user_limits.check_repo_size( + auth.user_id, file_count, estimated_functions + ) + if not result.allowed: + raise HTTPException( + status_code=400, + detail=result.to_dict() + ) + # ... existing indexing code +``` + +### Issue #93: Playground Rate Limiting +**Where**: `POST /api/v1/playground/search` + +**New File**: `backend/services/playground_rate_limiter.py` +```python +class PlaygroundRateLimiter: + def __init__(self, redis_client): + self.redis = redis_client + self.daily_limit = 50 + + def check_and_increment(self, ip: str) -> tuple[bool, dict]: + """Returns (allowed, headers_dict)""" + key = f"playground:rate:{ip}" + + # Atomic increment + count = self.redis.incr(key) + if count == 1: + self.redis.expire(key, 86400) # 24h TTL + + ttl = self.redis.ttl(key) + reset_time = int(time.time()) + ttl + + headers = { + "X-RateLimit-Limit": str(self.daily_limit), + "X-RateLimit-Remaining": str(max(0, self.daily_limit - count)), + "X-RateLimit-Reset": str(reset_time) + } + + if count > self.daily_limit: + headers["Retry-After"] = str(ttl) + return False, headers + + return True, headers +``` + +**Changes to `routes/playground.py`**: +```python +from fastapi import Request, Response + +@router.post("/search") +def playground_search(request: Request, response: Response, body: SearchRequest): + # Get client IP + ip = request.client.host + forwarded = request.headers.get("X-Forwarded-For") + if forwarded: + ip = forwarded.split(",")[0].strip() + + # Check rate limit + allowed, headers = playground_rate_limiter.check_and_increment(ip) + + # Always add headers + for key, value in headers.items(): + response.headers[key] = value + + if not allowed: + raise HTTPException( + status_code=429, + detail={ + "error": "RATE_LIMIT_EXCEEDED", + "message": "Daily search limit reached. Sign up for unlimited searches!", + "limit": 50, + "reset": headers["X-RateLimit-Reset"] + } + ) + + # ... existing search code +``` + +### Issue #97: Progressive Signup CTAs +**Where**: Frontend only + +**Implementation**: +```typescript +// hooks/usePlaygroundUsage.ts +const usePlaygroundUsage = () => { + const [searchCount, setSearchCount] = useState(0); + + // Read from response headers after each search + const trackSearch = (response: Response) => { + const remaining = response.headers.get('X-RateLimit-Remaining'); + const limit = response.headers.get('X-RateLimit-Limit'); + if (remaining && limit) { + setSearchCount(parseInt(limit) - parseInt(remaining)); + } + }; + + return { searchCount, trackSearch }; +}; + +// Show CTAs at thresholds +// 10 searches: Subtle "Want to search YOUR codebase?" +// 25 searches: More prominent with feature list +// 40 searches: Final "You clearly love this" +``` + +--- + +## 5. Error Response Format + +All limit-related errors use `LimitCheckResult.to_dict()`: + +```json +{ + "detail": { + "allowed": false, + "current": 3, + "limit": 3, + "limit_display": "3", + "message": "Repository limit reached (3/3). Upgrade to add more repositories.", + "tier": "free", + "error_code": "REPO_LIMIT_REACHED" + } +} +``` + +**Error Codes**: +| Code | HTTP Status | Description | +|------|-------------|-------------| +| `REPO_LIMIT_REACHED` | 403 | Max repos for tier | +| `REPO_TOO_LARGE` | 400 | File/function count exceeds tier | +| `RATE_LIMIT_EXCEEDED` | 429 | Playground daily limit | +| `INVALID_USER` | 400 | Invalid or missing user_id | +| `SYSTEM_ERROR` | 500 | Database/system failure | + +--- + +## 6. Database Schema + +### user_profiles (NEW) +```sql +CREATE TABLE user_profiles ( + id UUID PRIMARY KEY, + user_id UUID REFERENCES auth.users(id), + tier TEXT DEFAULT 'free', -- 'free', 'pro', 'enterprise' + created_at TIMESTAMPTZ, + updated_at TIMESTAMPTZ +); +``` + +**Security Notes:** +- RLS enabled with SELECT/INSERT for authenticated users +- NO UPDATE policy for users (prevents self-upgrade) +- Tier updates only via service role key (payment webhooks) + +### repositories (existing, no changes needed) +Already has `user_id` column for ownership. + +--- + +## 7. Fail-Safe Behavior + +| Scenario | Behavior | Reason | +|----------|----------|--------| +| DB down during `check_repo_count` | **DENY** (fail-closed) | Prevent unlimited repos | +| DB down during `get_usage_summary` | Return defaults | Read-only, safe to fail-open | +| Redis cache miss | Query DB | Graceful degradation | +| Redis down | Continue without cache | Non-critical | +| Invalid user_id | Return FREE limits | Safe default | + +--- + +## 8. Redis Keys + +| Key Pattern | TTL | Description | +|-------------|-----|-------------| +| `playground:rate:{ip}` | 24h | Playground search count | +| `user:tier:{user_id}` | 5min | Cached user tier | + +--- + +## 9. Frontend Integration Points + +### Dashboard +- Show usage bar: "2/3 repositories" +- Show tier badge: "Free Tier" +- Upgrade CTA when near limits + +### Add Repository Flow +1. Call `GET /users/limits/check-repo-add` +2. If `allowed: false`, show upgrade modal +3. If `allowed: true`, proceed with add + +### Playground +1. Read rate limit headers from search responses +2. Show remaining searches: "47/50 searches today" +3. Show progressive CTAs at thresholds +4. On 429, show signup modal + +--- + +## 10. Migration Path + +### Existing Users +All existing users default to `free` tier. Migration auto-creates profile on first API call. + +### Existing Repos +No changes needed. Limit checks only apply to NEW repos. + +--- + +## 11. Implementation Order + +| Phase | Issue | Priority | Depends On | +|-------|-------|----------|------------| +| 1 | #96 User tier system | P0 | - | ✅ DONE | +| 2 | #94 Repo size limits | P0 | #96 | +| 2 | #95 Repo count limits | P0 | #96 | +| 3 | #93 Playground rate limit | P1 | Redis | +| 4 | #97 Progressive CTAs | P2 | #93 | + +--- + +## 12. Open Questions + +1. **Upgrade Flow**: Stripe integration? Manual for now? +2. **Existing Large Repos**: Grandfather them or enforce limits? +3. **Team/Org Support**: Future consideration for enterprise? +4. **API Key Users**: Same limits as JWT users? + +--- + +## 13. Files to Create/Modify + +### Create +- [x] `backend/services/user_limits.py` +- [x] `backend/routes/users.py` +- [x] `supabase/migrations/001_user_profiles.sql` +- [ ] `backend/services/playground_rate_limiter.py` +- [ ] `frontend/src/hooks/usePlaygroundUsage.ts` +- [ ] `frontend/src/components/PlaygroundCTA.tsx` +- [ ] `frontend/src/components/UsageBar.tsx` + +### Modify +- [x] `backend/dependencies.py` +- [x] `backend/main.py` +- [ ] `backend/routes/repos.py` - Add limit checks +- [ ] `backend/routes/playground.py` - Add rate limiting +- [ ] `frontend/src/pages/Dashboard.tsx` - Show usage +- [ ] `frontend/src/pages/LandingPage.tsx` - Show CTAs diff --git a/supabase/README.md b/supabase/README.md new file mode 100644 index 0000000..9b50179 --- /dev/null +++ b/supabase/README.md @@ -0,0 +1,49 @@ +# Supabase Migrations + +This folder contains SQL migrations for the Supabase database. + +## Running Migrations + +### Option 1: Supabase Dashboard (Recommended) + +1. Go to your Supabase project dashboard +2. Navigate to **SQL Editor** +3. Open the migration file and copy contents +4. Run the SQL + +### Option 2: Supabase CLI + +```bash +# Install Supabase CLI +npm install -g supabase + +# Link to your project +supabase link --project-ref YOUR_PROJECT_REF + +# Run migrations +supabase db push +``` + +## Migration Files + +| File | Description | Issue | +|------|-------------|-------| +| `001_user_profiles.sql` | User profiles with tier (free/pro/enterprise) | #96 | + +## Schema Overview + +### user_profiles +Stores user subscription tier information. + +| Column | Type | Description | +|--------|------|-------------| +| id | UUID | Primary key | +| user_id | UUID | Foreign key to auth.users | +| tier | TEXT | 'free', 'pro', or 'enterprise' | +| created_at | TIMESTAMPTZ | Profile creation time | +| updated_at | TIMESTAMPTZ | Last update time | + +**Features:** +- Auto-creates profile on user signup (trigger) +- RLS enabled - users can only read their own profile +- Tier updates only via service role (prevents self-upgrade) diff --git a/supabase/migrations/001_user_profiles.sql b/supabase/migrations/001_user_profiles.sql new file mode 100644 index 0000000..7f74ba8 --- /dev/null +++ b/supabase/migrations/001_user_profiles.sql @@ -0,0 +1,81 @@ +-- Migration: Add user_profiles table for tier management +-- Issue: #96 - User tier system +-- Run this in Supabase SQL Editor + +-- Create user_profiles table +CREATE TABLE IF NOT EXISTS public.user_profiles ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + tier TEXT NOT NULL DEFAULT 'free' CHECK (tier IN ('free', 'pro', 'enterprise')), + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + + -- Ensure one profile per user + CONSTRAINT unique_user_profile UNIQUE (user_id) +); + +-- Create index for fast lookups +CREATE INDEX IF NOT EXISTS idx_user_profiles_user_id ON public.user_profiles(user_id); + +-- Enable RLS +ALTER TABLE public.user_profiles ENABLE ROW LEVEL SECURITY; + +-- RLS Policies + +-- Users can read their own profile +CREATE POLICY "Users can view own profile" + ON public.user_profiles + FOR SELECT + USING (auth.uid() = user_id); + +-- Users can insert their own profile (on signup) +CREATE POLICY "Users can create own profile" + ON public.user_profiles + FOR INSERT + WITH CHECK (auth.uid() = user_id); + +-- IMPORTANT: No UPDATE policy for regular users! +-- Tier updates ONLY happen via service role key (bypasses RLS) +-- This prevents users from upgrading themselves +-- Payment webhooks use service role to update tier + +-- Auto-update updated_at timestamp +CREATE OR REPLACE FUNCTION update_updated_at_column() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = now(); + RETURN NEW; +END; +$$ language 'plpgsql'; + +DROP TRIGGER IF EXISTS update_user_profiles_updated_at ON public.user_profiles; +CREATE TRIGGER update_user_profiles_updated_at + BEFORE UPDATE ON public.user_profiles + FOR EACH ROW + EXECUTE FUNCTION update_updated_at_column(); + +-- Function to auto-create profile on user signup +CREATE OR REPLACE FUNCTION public.handle_new_user() +RETURNS TRIGGER AS $$ +BEGIN + INSERT INTO public.user_profiles (user_id, tier) + VALUES (NEW.id, 'free') + ON CONFLICT (user_id) DO NOTHING; + RETURN NEW; +END; +$$ LANGUAGE plpgsql SECURITY DEFINER; + +-- Trigger to create profile when new user signs up +DROP TRIGGER IF EXISTS on_auth_user_created ON auth.users; +CREATE TRIGGER on_auth_user_created + AFTER INSERT ON auth.users + FOR EACH ROW + EXECUTE FUNCTION public.handle_new_user(); + +-- Grant permissions (SELECT and INSERT only, no UPDATE for users) +GRANT SELECT ON public.user_profiles TO authenticated; +GRANT INSERT ON public.user_profiles TO authenticated; + +-- Comments +COMMENT ON TABLE public.user_profiles IS 'User profiles with subscription tier information'; +COMMENT ON COLUMN public.user_profiles.tier IS 'Subscription tier: free, pro, or enterprise. Updated only via service role (payment webhooks).';