From 54042e268183da0108026fe3e54e427fe79998b3 Mon Sep 17 00:00:00 2001 From: Devanshu Rajesh Chicholikar Date: Fri, 12 Dec 2025 19:24:23 -0500 Subject: [PATCH 1/6] feat(backend): Add user tier/limits system (#96) - Create UserLimitsService for centralized tier management - Define tier limits (free/pro/enterprise) - Add methods: check_repo_count, check_repo_size, get_usage_summary - Add Supabase migration for user_profiles table - Initialize service in dependencies.py Tiers: - Free: 3 repos, 500 files, 2000 functions - Pro: 20 repos, 5000 files, 20000 functions - Enterprise: Unlimited Used by #93, #94, #95 for enforcing limits. --- backend/dependencies.py | 7 + backend/services/user_limits.py | 338 ++++++++++++++++++++++ supabase/README.md | 49 ++++ supabase/migrations/001_user_profiles.sql | 83 ++++++ 4 files changed, 477 insertions(+) create mode 100644 backend/services/user_limits.py create mode 100644 supabase/README.md create mode 100644 supabase/migrations/001_user_profiles.sql 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/services/user_limits.py b/backend/services/user_limits.py new file mode 100644 index 0000000..45b5b30 --- /dev/null +++ b/backend/services/user_limits.py @@ -0,0 +1,338 @@ +""" +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 + + +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 +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 + + @property + def limit_display(self) -> str: + """Display limit as string (handles unlimited)""" + return str(self.limit) if self.limit is not None else "∞" + + def to_dict(self) -> Dict[str, Any]: + return { + "allowed": self.allowed, + "current": self.current, + "limit": self.limit, + "limit_display": self.limit_display, + "message": self.message, + } + + +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 = await limits.check_repo_count(user_id) + if not result.allowed: + raise HTTPException(403, result.message) + + # Check if repo size is within limits + result = await limits.check_repo_size(user_id, file_count, function_count) + if not result.allowed: + raise HTTPException(400, result.message) + """ + + 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 + + # ===== TIER MANAGEMENT ===== + + async 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. + """ + # Try cache first + if self.redis: + cache_key = f"user:tier:{user_id}" + cached = self.redis.get(cache_key) + if cached: + try: + return UserTier(cached.decode() if isinstance(cached, bytes) else cached) + except ValueError: + pass + + # Query Supabase + tier = await self._get_tier_from_db(user_id) + + # Cache the result + if self.redis: + cache_key = f"user:tier:{user_id}" + self.redis.setex(cache_key, self._tier_cache_ttl, tier.value) + + return tier + + async 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)) + + return UserTier.FREE + + def get_limits(self, tier: UserTier) -> TierLimits: + """Get limits for a tier""" + return TIER_LIMITS.get(tier, TIER_LIMITS[UserTier.FREE]) + + async def get_user_limits(self, user_id: str) -> TierLimits: + """Get limits for a specific user""" + tier = await self.get_user_tier(user_id) + return self.get_limits(tier) + + # ===== REPO COUNT LIMITS (#95) ===== + + async def get_user_repo_count(self, user_id: str) -> int: + """Get current repo count for user""" + 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)) + return 0 + + async def check_repo_count(self, user_id: str) -> LimitCheckResult: + """ + Check if user can add another repository. + + Returns: + LimitCheckResult with allowed=True if under limit + """ + tier = await self.get_user_tier(user_id) + limits = self.get_limits(tier) + current_count = await self.get_user_repo_count(user_id) + + # Unlimited repos + if limits.max_repos is None: + return LimitCheckResult( + allowed=True, + current=current_count, + limit=None, + message=f"OK ({current_count}/∞ repos)" + ) + + # 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 for more repos." + ) + + return LimitCheckResult( + allowed=True, + current=current_count, + limit=limits.max_repos, + message=f"OK ({current_count}/{limits.max_repos} repos)" + ) + + # ===== REPO SIZE LIMITS (#94) ===== + + async 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 + """ + tier = await 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." + ) + + # 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." + ) + + return LimitCheckResult( + allowed=True, + current=file_count, + limit=limits.max_files_per_repo, + message=f"OK ({file_count:,} files, {function_count:,} functions)" + ) + + # ===== 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 ===== + + async def get_usage_summary(self, user_id: str) -> Dict[str, Any]: + """ + Get complete usage summary for user. + Useful for dashboard display. + """ + tier = await self.get_user_tier(user_id) + limits = self.get_limits(tier) + repo_count = await 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 '∞'}" + }, + "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/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..c5ac15e --- /dev/null +++ b/supabase/migrations/001_user_profiles.sql @@ -0,0 +1,83 @@ +-- 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); + +-- Only service role can update tier (prevents users upgrading themselves) +-- Users update through payment flow which uses service role key +CREATE POLICY "Service role can update profiles" + ON public.user_profiles + FOR UPDATE + USING (true) + WITH CHECK (true); + +-- 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'; + +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 +GRANT SELECT ON public.user_profiles TO authenticated; +GRANT INSERT ON public.user_profiles TO authenticated; + +-- Comment +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'; From d20c7f562d124997f9d099d22f2405350d05d22e Mon Sep 17 00:00:00 2001 From: Devanshu Rajesh Chicholikar Date: Fri, 12 Dec 2025 19:31:31 -0500 Subject: [PATCH 2/6] feat(api): Add /users/usage endpoint (#96) - Create routes/users.py with usage and limit check endpoints - GET /users/usage - returns tier, repo count, limits - GET /users/limits/check-repo-add - pre-check before adding repo - Register users_router in main.py --- backend/main.py | 2 ++ backend/routes/users.py | 65 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 67 insertions(+) create mode 100644 backend/routes/users.py 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..5b2845a --- /dev/null +++ b/backend/routes/users.py @@ -0,0 +1,65 @@ +"""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 + +router = APIRouter(prefix="/users", tags=["Users"]) + + +@router.get("/usage") +async 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 + return { + "tier": "free", + "repositories": { + "current": 0, + "limit": 3, + "display": "0/3" + }, + "limits": { + "max_files_per_repo": 500, + "max_functions_per_repo": 2000, + "playground_searches_per_day": 50, + }, + "features": { + "priority_indexing": False, + "mcp_access": True, + } + } + + usage = await 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") +async 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: + return {"allowed": True, "message": "OK"} + + result = await user_limits.check_repo_count(user_id) + return result.to_dict() From a7c94aea5ab45c876ae7181fa01fab2dd90ec302 Mon Sep 17 00:00:00 2001 From: Devanshu Rajesh Chicholikar Date: Fri, 12 Dec 2025 19:41:28 -0500 Subject: [PATCH 3/6] docs: Add tier system design document Comprehensive design covering: - All API endpoints and which need limit checks - Error response format - Redis key patterns - Frontend integration points - Implementation order - Files to create/modify checklist --- docs/TIER_SYSTEM_DESIGN.md | 378 +++++++++++++++++++++++++++++++++++++ 1 file changed, 378 insertions(+) create mode 100644 docs/TIER_SYSTEM_DESIGN.md diff --git a/docs/TIER_SYSTEM_DESIGN.md b/docs/TIER_SYSTEM_DESIGN.md new file mode 100644 index 0000000..722f515 --- /dev/null +++ b/docs/TIER_SYSTEM_DESIGN.md @@ -0,0 +1,378 @@ +# User Tier & Limits System - Design Document + +> **Issues**: #93, #94, #95, #96, #97 +> **Author**: Devanshu +> **Status**: Draft +> **Last Updated**: 2024-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: + async def get_user_tier(user_id) -> UserTier + async def get_user_limits(user_id) -> TierLimits + async def get_user_repo_count(user_id) -> int + async def check_repo_count(user_id) -> LimitCheckResult + async def check_repo_size(user_id, file_count, func_count) -> LimitCheckResult + async def get_usage_summary(user_id) -> dict +``` + +### Issue #95: Repo Count Limits +**Where**: `POST /api/v1/repos` + +**Changes to `routes/repos.py`**: +```python +@router.post("") +async def add_repository(request, auth): + # NEW: Check repo count limit + result = await user_limits.check_repo_count(auth.user_id) + if not result.allowed: + raise HTTPException( + status_code=403, + detail={ + "error": "REPO_LIMIT_REACHED", + "message": result.message, + "current": result.current, + "limit": result.limit, + "upgrade_url": "/pricing" # Frontend can use this + } + ) + # ... 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") +async 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 = await user_limits.check_repo_size( + auth.user_id, file_count, estimated_functions + ) + if not result.allowed: + raise HTTPException( + status_code=400, + detail={ + "error": "REPO_TOO_LARGE", + "message": result.message, + "file_count": file_count, + "limit": result.limit, + "tier": (await user_limits.get_user_tier(auth.user_id)).value + } + ) + # ... 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 + + async 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") +async 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 = await 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 follow this format: + +```json +{ + "detail": { + "error": "ERROR_CODE", + "message": "Human readable message", + "current": 3, + "limit": 3, + "tier": "free", + "upgrade_url": "/pricing" + } +} +``` + +**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 | + +--- + +## 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 +); +``` + +### repositories (existing, no changes needed) +Already has `user_id` column for ownership. + +--- + +## 7. Redis Keys + +| Key Pattern | TTL | Description | +|-------------|-----|-------------| +| `playground:rate:{ip}` | 24h | Playground search count | +| `user:tier:{user_id}` | 5min | Cached user tier | + +--- + +## 8. 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 + +--- + +## 9. 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. + +--- + +## 10. 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 | + +--- + +## 11. 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? + +--- + +## 12. 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 From 29b4d6153000bb40dca0ba2557b01dace7c9791b Mon Sep 17 00:00:00 2001 From: Devanshu Rajesh Chicholikar Date: Fri, 12 Dec 2025 19:43:06 -0500 Subject: [PATCH 4/6] fix: Remove async from user_limits (Supabase client is sync) - Changed all async def to def in user_limits.py - Removed await calls in routes/users.py - Matches codebase pattern (supabase_service.py uses sync) --- backend/routes/users.py | 8 ++++---- backend/services/user_limits.py | 32 ++++++++++++++++---------------- 2 files changed, 20 insertions(+), 20 deletions(-) diff --git a/backend/routes/users.py b/backend/routes/users.py index 5b2845a..9967981 100644 --- a/backend/routes/users.py +++ b/backend/routes/users.py @@ -9,7 +9,7 @@ @router.get("/usage") -async def get_user_usage(auth: AuthContext = Depends(require_auth)): +def get_user_usage(auth: AuthContext = Depends(require_auth)): """ Get current user's usage and limits. @@ -42,14 +42,14 @@ async def get_user_usage(auth: AuthContext = Depends(require_auth)): } } - usage = await user_limits.get_usage_summary(user_id) + 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") -async def check_can_add_repo(auth: AuthContext = Depends(require_auth)): +def check_can_add_repo(auth: AuthContext = Depends(require_auth)): """ Check if user can add another repository. @@ -61,5 +61,5 @@ async def check_can_add_repo(auth: AuthContext = Depends(require_auth)): if not user_id: return {"allowed": True, "message": "OK"} - result = await user_limits.check_repo_count(user_id) + 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 index 45b5b30..3570da3 100644 --- a/backend/services/user_limits.py +++ b/backend/services/user_limits.py @@ -106,12 +106,12 @@ class UserLimitsService: limits = UserLimitsService(supabase_client, redis_client) # Check if user can add another repo - result = await limits.check_repo_count(user_id) + result = limits.check_repo_count(user_id) if not result.allowed: raise HTTPException(403, result.message) # Check if repo size is within limits - result = await limits.check_repo_size(user_id, file_count, function_count) + result = limits.check_repo_size(user_id, file_count, function_count) if not result.allowed: raise HTTPException(400, result.message) """ @@ -123,7 +123,7 @@ def __init__(self, supabase_client, redis_client=None): # ===== TIER MANAGEMENT ===== - async def get_user_tier(self, user_id: str) -> UserTier: + def get_user_tier(self, user_id: str) -> UserTier: """ Get user's current tier. @@ -141,7 +141,7 @@ async def get_user_tier(self, user_id: str) -> UserTier: pass # Query Supabase - tier = await self._get_tier_from_db(user_id) + tier = self._get_tier_from_db(user_id) # Cache the result if self.redis: @@ -150,7 +150,7 @@ async def get_user_tier(self, user_id: str) -> UserTier: return tier - async def _get_tier_from_db(self, user_id: str) -> UserTier: + 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() @@ -167,14 +167,14 @@ def get_limits(self, tier: UserTier) -> TierLimits: """Get limits for a tier""" return TIER_LIMITS.get(tier, TIER_LIMITS[UserTier.FREE]) - async def get_user_limits(self, user_id: str) -> TierLimits: + def get_user_limits(self, user_id: str) -> TierLimits: """Get limits for a specific user""" - tier = await self.get_user_tier(user_id) + tier = self.get_user_tier(user_id) return self.get_limits(tier) # ===== REPO COUNT LIMITS (#95) ===== - async def get_user_repo_count(self, user_id: str) -> int: + def get_user_repo_count(self, user_id: str) -> int: """Get current repo count for user""" try: result = self.supabase.table("repositories").select("id", count="exact").eq("user_id", user_id).execute() @@ -183,16 +183,16 @@ async def get_user_repo_count(self, user_id: str) -> int: logger.error("Failed to get repo count", user_id=user_id, error=str(e)) return 0 - async def check_repo_count(self, user_id: str) -> LimitCheckResult: + def check_repo_count(self, user_id: str) -> LimitCheckResult: """ Check if user can add another repository. Returns: LimitCheckResult with allowed=True if under limit """ - tier = await self.get_user_tier(user_id) + tier = self.get_user_tier(user_id) limits = self.get_limits(tier) - current_count = await self.get_user_repo_count(user_id) + current_count = self.get_user_repo_count(user_id) # Unlimited repos if limits.max_repos is None: @@ -223,7 +223,7 @@ async def check_repo_count(self, user_id: str) -> LimitCheckResult: # ===== REPO SIZE LIMITS (#94) ===== - async def check_repo_size( + def check_repo_size( self, user_id: str, file_count: int, @@ -240,7 +240,7 @@ async def check_repo_size( Returns: LimitCheckResult with allowed=True if within limits """ - tier = await self.get_user_tier(user_id) + tier = self.get_user_tier(user_id) limits = self.get_limits(tier) # Check file count @@ -290,14 +290,14 @@ def get_playground_limit(self, tier: UserTier = UserTier.FREE) -> Optional[int]: # ===== USAGE SUMMARY ===== - async def get_usage_summary(self, user_id: str) -> Dict[str, Any]: + def get_usage_summary(self, user_id: str) -> Dict[str, Any]: """ Get complete usage summary for user. Useful for dashboard display. """ - tier = await self.get_user_tier(user_id) + tier = self.get_user_tier(user_id) limits = self.get_limits(tier) - repo_count = await self.get_user_repo_count(user_id) + repo_count = self.get_user_repo_count(user_id) return { "tier": tier.value, From 80a44a2561236497e512cc3ec2fbabb9abfc9fbc Mon Sep 17 00:00:00 2001 From: Devanshu Rajesh Chicholikar Date: Fri, 12 Dec 2025 19:59:38 -0500 Subject: [PATCH 5/6] fix: Production-level improvements to tier system MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Security Fixes: - Remove broken RLS UPDATE policy (was allowing any user to update any profile) - Tier updates now ONLY via service role key (payment webhooks) Code Quality: - Add input validation for user_id (empty/null check) - Add Sentry error capture for exceptions - Fail-CLOSED on DB errors during check_repo_count (prevent bypass) - Add LimitCheckError exception class - Add invalidate_tier_cache() method for tier upgrades - Add error_code field to LimitCheckResult - Add tier field to all responses for frontend upgrade prompts - Better Redis error handling (continue on failure) Documentation: - Fix date: 2024 → 2025 - Remove async references (code is sync) - Add fail-safe behavior table - Update error response format to match actual code - Add new error codes: INVALID_USER, SYSTEM_ERROR --- backend/services/user_limits.py | 166 ++++++++++++++++++---- docs/TIER_SYSTEM_DESIGN.md | 87 +++++++----- supabase/migrations/001_user_profiles.sql | 18 ++- 3 files changed, 197 insertions(+), 74 deletions(-) diff --git a/backend/services/user_limits.py b/backend/services/user_limits.py index 3570da3..59099b4 100644 --- a/backend/services/user_limits.py +++ b/backend/services/user_limits.py @@ -12,11 +12,12 @@ - #94: Repo size limits - #95: Repo count limits """ -from dataclasses import dataclass +from dataclasses import dataclass, field 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): @@ -43,7 +44,7 @@ class TierLimits: mcp_access: bool = True -# Tier definitions +# Tier definitions - Single source of truth TIER_LIMITS: Dict[UserTier, TierLimits] = { UserTier.FREE: TierLimits( max_repos=3, @@ -82,20 +83,31 @@ class LimitCheckResult: 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 "∞" + return str(self.limit) if self.limit is not None else "unlimited" def to_dict(self) -> Dict[str, Any]: - return { + 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: @@ -108,12 +120,12 @@ class UserLimitsService: # Check if user can add another repo result = limits.check_repo_count(user_id) if not result.allowed: - raise HTTPException(403, result.message) + 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.message) + raise HTTPException(400, result.to_dict()) """ def __init__(self, supabase_client, redis_client=None): @@ -121,6 +133,12 @@ def __init__(self, supabase_client, redis_client=None): 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: @@ -130,23 +148,32 @@ def get_user_tier(self, user_id: str) -> UserTier: 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: - cache_key = f"user:tier:{user_id}" - cached = self.redis.get(cache_key) - if cached: - try: - return UserTier(cached.decode() if isinstance(cached, bytes) else cached) - except ValueError: - pass + 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: - cache_key = f"user:tier:{user_id}" - self.redis.setex(cache_key, self._tier_cache_ttl, tier.value) + 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 @@ -160,7 +187,9 @@ def _get_tier_from_db(self, user_id: str) -> UserTier: 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: @@ -172,15 +201,38 @@ def get_user_limits(self, user_id: str) -> TierLimits: 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) -> int: - """Get current repo count for user""" + 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: @@ -189,10 +241,34 @@ def check_repo_count(self, user_id: str) -> LimitCheckResult: 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) - current_count = self.get_user_repo_count(user_id) + + 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: @@ -200,7 +276,8 @@ def check_repo_count(self, user_id: str) -> LimitCheckResult: allowed=True, current=current_count, limit=None, - message=f"OK ({current_count}/∞ repos)" + message=f"OK ({current_count} repos)", + tier=tier.value ) # Check limit @@ -211,14 +288,17 @@ def check_repo_count(self, user_id: str) -> LimitCheckResult: allowed=False, current=current_count, limit=limits.max_repos, - message=f"Repository limit reached ({current_count}/{limits.max_repos}). Upgrade for more 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)" + message=f"OK ({current_count}/{limits.max_repos} repos)", + tier=tier.value ) # ===== REPO SIZE LIMITS (#94) ===== @@ -240,6 +320,16 @@ def check_repo_size( 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) @@ -256,7 +346,9 @@ def check_repo_size( 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." + 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 @@ -272,14 +364,17 @@ def check_repo_size( 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." + 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)" + message=f"OK ({file_count:,} files, {function_count:,} functions)", + tier=tier.value ) # ===== PLAYGROUND RATE LIMITS (#93) ===== @@ -295,6 +390,27 @@ 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) @@ -304,7 +420,7 @@ def get_usage_summary(self, user_id: str) -> Dict[str, Any]: "repositories": { "current": repo_count, "limit": limits.max_repos, - "display": f"{repo_count}/{limits.max_repos if limits.max_repos else '∞'}" + "display": f"{repo_count}/{limits.max_repos if limits.max_repos else 'unlimited'}" }, "limits": { "max_files_per_repo": limits.max_files_per_repo, diff --git a/docs/TIER_SYSTEM_DESIGN.md b/docs/TIER_SYSTEM_DESIGN.md index 722f515..d5b093f 100644 --- a/docs/TIER_SYSTEM_DESIGN.md +++ b/docs/TIER_SYSTEM_DESIGN.md @@ -2,8 +2,8 @@ > **Issues**: #93, #94, #95, #96, #97 > **Author**: Devanshu -> **Status**: Draft -> **Last Updated**: 2024-12-13 +> **Status**: Implemented +> **Last Updated**: 2025-12-13 --- @@ -90,12 +90,13 @@ CodeIntel needs a tiered system to: **Service Methods**: ```python class UserLimitsService: - async def get_user_tier(user_id) -> UserTier - async def get_user_limits(user_id) -> TierLimits - async def get_user_repo_count(user_id) -> int - async def check_repo_count(user_id) -> LimitCheckResult - async def check_repo_size(user_id, file_count, func_count) -> LimitCheckResult - async def get_usage_summary(user_id) -> dict + 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 @@ -104,19 +105,13 @@ class UserLimitsService: **Changes to `routes/repos.py`**: ```python @router.post("") -async def add_repository(request, auth): +def add_repository(request, auth): # NEW: Check repo count limit - result = await user_limits.check_repo_count(auth.user_id) + result = user_limits.check_repo_count(auth.user_id) if not result.allowed: raise HTTPException( status_code=403, - detail={ - "error": "REPO_LIMIT_REACHED", - "message": result.message, - "current": result.current, - "limit": result.limit, - "upgrade_url": "/pricing" # Frontend can use this - } + detail=result.to_dict() ) # ... existing code ``` @@ -132,7 +127,7 @@ async def add_repository(request, auth): **Changes to `routes/repos.py`**: ```python @router.post("/{repo_id}/index") -async def index_repository(repo_id, auth): +def index_repository(repo_id, auth): repo = get_repo_or_404(repo_id, auth.user_id) # Count files and estimate functions BEFORE indexing @@ -140,19 +135,13 @@ async def index_repository(repo_id, auth): estimated_functions = file_count * 25 # Conservative estimate # NEW: Check size limits - result = await user_limits.check_repo_size( + result = user_limits.check_repo_size( auth.user_id, file_count, estimated_functions ) if not result.allowed: raise HTTPException( status_code=400, - detail={ - "error": "REPO_TOO_LARGE", - "message": result.message, - "file_count": file_count, - "limit": result.limit, - "tier": (await user_limits.get_user_tier(auth.user_id)).value - } + detail=result.to_dict() ) # ... existing indexing code ``` @@ -167,7 +156,7 @@ class PlaygroundRateLimiter: self.redis = redis_client self.daily_limit = 50 - async def check_and_increment(self, ip: str) -> tuple[bool, dict]: + def check_and_increment(self, ip: str) -> tuple[bool, dict]: """Returns (allowed, headers_dict)""" key = f"playground:rate:{ip}" @@ -197,7 +186,7 @@ class PlaygroundRateLimiter: from fastapi import Request, Response @router.post("/search") -async def playground_search(request: Request, response: Response, body: SearchRequest): +def playground_search(request: Request, response: Response, body: SearchRequest): # Get client IP ip = request.client.host forwarded = request.headers.get("X-Forwarded-For") @@ -205,7 +194,7 @@ async def playground_search(request: Request, response: Response, body: SearchRe ip = forwarded.split(",")[0].strip() # Check rate limit - allowed, headers = await playground_rate_limiter.check_and_increment(ip) + allowed, headers = playground_rate_limiter.check_and_increment(ip) # Always add headers for key, value in headers.items(): @@ -256,17 +245,18 @@ const usePlaygroundUsage = () => { ## 5. Error Response Format -All limit-related errors follow this format: +All limit-related errors use `LimitCheckResult.to_dict()`: ```json { "detail": { - "error": "ERROR_CODE", - "message": "Human readable message", + "allowed": false, "current": 3, "limit": 3, + "limit_display": "3", + "message": "Repository limit reached (3/3). Upgrade to add more repositories.", "tier": "free", - "upgrade_url": "/pricing" + "error_code": "REPO_LIMIT_REACHED" } } ``` @@ -277,6 +267,8 @@ All limit-related errors follow this format: | `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 | --- @@ -293,12 +285,29 @@ CREATE TABLE user_profiles ( ); ``` +**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. Redis Keys +## 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 | |-------------|-----|-------------| @@ -307,7 +316,7 @@ Already has `user_id` column for ownership. --- -## 8. Frontend Integration Points +## 9. Frontend Integration Points ### Dashboard - Show usage bar: "2/3 repositories" @@ -327,7 +336,7 @@ Already has `user_id` column for ownership. --- -## 9. Migration Path +## 10. Migration Path ### Existing Users All existing users default to `free` tier. Migration auto-creates profile on first API call. @@ -337,7 +346,7 @@ No changes needed. Limit checks only apply to NEW repos. --- -## 10. Implementation Order +## 11. Implementation Order | Phase | Issue | Priority | Depends On | |-------|-------|----------|------------| @@ -349,7 +358,7 @@ No changes needed. Limit checks only apply to NEW repos. --- -## 11. Open Questions +## 12. Open Questions 1. **Upgrade Flow**: Stripe integration? Manual for now? 2. **Existing Large Repos**: Grandfather them or enforce limits? @@ -358,7 +367,7 @@ No changes needed. Limit checks only apply to NEW repos. --- -## 12. Files to Create/Modify +## 13. Files to Create/Modify ### Create - [x] `backend/services/user_limits.py` diff --git a/supabase/migrations/001_user_profiles.sql b/supabase/migrations/001_user_profiles.sql index c5ac15e..7f74ba8 100644 --- a/supabase/migrations/001_user_profiles.sql +++ b/supabase/migrations/001_user_profiles.sql @@ -34,13 +34,10 @@ CREATE POLICY "Users can create own profile" FOR INSERT WITH CHECK (auth.uid() = user_id); --- Only service role can update tier (prevents users upgrading themselves) --- Users update through payment flow which uses service role key -CREATE POLICY "Service role can update profiles" - ON public.user_profiles - FOR UPDATE - USING (true) - WITH CHECK (true); +-- 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() @@ -51,6 +48,7 @@ BEGIN 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 @@ -74,10 +72,10 @@ CREATE TRIGGER on_auth_user_created FOR EACH ROW EXECUTE FUNCTION public.handle_new_user(); --- Grant permissions +-- 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; --- Comment +-- 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'; +COMMENT ON COLUMN public.user_profiles.tier IS 'Subscription tier: free, pro, or enterprise. Updated only via service role (payment webhooks).'; From 320b4476c2f305f9670044133a40614afdae0e76 Mon Sep 17 00:00:00 2001 From: Devanshu Rajesh Chicholikar Date: Fri, 12 Dec 2025 20:14:31 -0500 Subject: [PATCH 6/6] fix: Remove hardcoded values, use TIER_LIMITS as single source of truth - routes/users.py: Use TIER_LIMITS[UserTier.FREE] instead of hardcoded values - routes/users.py: Return tier and limit in check-repo-add for API key users - user_limits.py: Remove unused 'field' import from dataclasses --- backend/routes/users.py | 25 +++++++++++++++++-------- backend/services/user_limits.py | 2 +- 2 files changed, 18 insertions(+), 9 deletions(-) diff --git a/backend/routes/users.py b/backend/routes/users.py index 9967981..544a223 100644 --- a/backend/routes/users.py +++ b/backend/routes/users.py @@ -4,6 +4,7 @@ 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"]) @@ -24,21 +25,22 @@ def get_user_usage(auth: AuthContext = Depends(require_auth)): 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": 3, - "display": "0/3" + "limit": free_limits.max_repos, + "display": f"0/{free_limits.max_repos}" }, "limits": { - "max_files_per_repo": 500, - "max_functions_per_repo": 2000, - "playground_searches_per_day": 50, + "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": False, - "mcp_access": True, + "priority_indexing": free_limits.priority_indexing, + "mcp_access": free_limits.mcp_access, } } @@ -59,7 +61,14 @@ def check_can_add_repo(auth: AuthContext = Depends(require_auth)): user_id = auth.user_id if not user_id: - return {"allowed": True, "message": "OK"} + # 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 index 59099b4..61a2404 100644 --- a/backend/services/user_limits.py +++ b/backend/services/user_limits.py @@ -12,7 +12,7 @@ - #94: Repo size limits - #95: Repo count limits """ -from dataclasses import dataclass, field +from dataclasses import dataclass from typing import Optional, Dict, Any from enum import Enum