From c5690b8b069c1c03983c796516b9d30c80df5bb3 Mon Sep 17 00:00:00 2001 From: Justin Wu Date: Sun, 1 Feb 2026 12:05:09 -0500 Subject: [PATCH 1/3] add device code authorization endpoint and CORS middleware --- backend/api/server_fastapi_router.py | 45 ++++++++++++++++++++++++++++ backend/services/http_server.py | 11 +++++++ 2 files changed, 56 insertions(+) diff --git a/backend/api/server_fastapi_router.py b/backend/api/server_fastapi_router.py index 42aef2d..402ae7e 100644 --- a/backend/api/server_fastapi_router.py +++ b/backend/api/server_fastapi_router.py @@ -5,10 +5,19 @@ import modal from fastapi import APIRouter, File, Form, HTTPException, UploadFile +from pydantic import BaseModel logger = logging.getLogger(__name__) +class AuthorizeDeviceRequest(BaseModel): + """Request body for device code authorization.""" + user_code: str + user_id: str + id_token: str + refresh_token: str + + class ServerFastAPIRouter: """ FastAPI router for the Server service. @@ -93,6 +102,7 @@ def _register_routes(self): self.router.add_api_route("/cache/clear", self.clear_cache, methods=["POST"]) self.router.add_api_route("/auth/device/code", self.request_device_code, methods=["POST"]) self.router.add_api_route("/auth/device/poll", self.poll_device_code, methods=["POST"]) + self.router.add_api_route("/auth/device/authorize", self.authorize_device_code, methods=["POST"]) async def health(self): """ @@ -355,3 +365,38 @@ async def poll_device_code(self, device_code: str): except Exception as e: logger.error(f"[Device Poll] Error polling device code: {e}") raise HTTPException(status_code=500, detail=str(e)) + + async def authorize_device_code(self, request: AuthorizeDeviceRequest): + try: + # Look up device_code by user_code + device_code = self.server_instance.auth_connector.get_device_code_by_user_code(request.user_code) + + if device_code is None: + raise HTTPException( + status_code=404, + detail="User code not found or expired" + ) + + # Mark device code as authorized with user tokens + success = self.server_instance.auth_connector.set_device_code_authorized( + device_code=device_code, + user_id=request.user_id, + id_token=request.id_token, + refresh_token=request.refresh_token + ) + + if not success: + raise HTTPException( + status_code=500, + detail="Failed to authorize device code" + ) + + logger.info(f"[Device Authorize] User code {request.user_code} authorized for user {request.user_id}") + + return {"status": "success"} + + except HTTPException: + raise + except Exception as e: + logger.error(f"[Device Authorize] Error authorizing device code: {e}") + raise HTTPException(status_code=500, detail=str(e)) diff --git a/backend/services/http_server.py b/backend/services/http_server.py index a940221..dd82a98 100644 --- a/backend/services/http_server.py +++ b/backend/services/http_server.py @@ -73,8 +73,19 @@ def create_fastapi_app(self, processing_service_cls=None): """ from api import ServerFastAPIRouter from fastapi import FastAPI + from fastapi.middleware.cors import CORSMiddleware self.fastapi_app = FastAPI(title="Clipabit Server") + + # Add CORS middleware for testing + self.fastapi_app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], + ) + api_router = ServerFastAPIRouter( server_instance=self, is_file_change_enabled=self.is_file_change_enabled, From 02629d6a2df96cc0965a4bf203fcc4a1c5dabccf Mon Sep 17 00:00:00 2001 From: Justin Wu Date: Sat, 7 Feb 2026 15:51:10 -0500 Subject: [PATCH 2/3] add staging support to CLI for concurrent app serving --- backend/apps/dev_combined.py | 2 +- backend/apps/processing_app.py | 2 +- backend/apps/search_app.py | 2 +- backend/apps/server.py | 2 +- backend/cli.py | 87 +++++++++++++++++++++++++++++++-- backend/pyproject.toml | 1 + backend/services/http_server.py | 4 +- backend/shared/config.py | 9 ++-- 8 files changed, 95 insertions(+), 14 deletions(-) diff --git a/backend/apps/dev_combined.py b/backend/apps/dev_combined.py index d66a57c..0837f2c 100644 --- a/backend/apps/dev_combined.py +++ b/backend/apps/dev_combined.py @@ -42,7 +42,7 @@ app = modal.App( name=f"{env}-server", image=get_dev_image(), - secrets=[get_secrets()] + secrets=get_secrets() ) # SearchService exposes its own ASGI app for direct HTTP access (no server hop) diff --git a/backend/apps/processing_app.py b/backend/apps/processing_app.py index 7fee60a..2ecdb5b 100644 --- a/backend/apps/processing_app.py +++ b/backend/apps/processing_app.py @@ -24,7 +24,7 @@ app = modal.App( name=f"{env}-processing", image=get_processing_image(), - secrets=[get_secrets()] + secrets=get_secrets() ) # Register ProcessingService with this app diff --git a/backend/apps/search_app.py b/backend/apps/search_app.py index 8343caf..2874031 100644 --- a/backend/apps/search_app.py +++ b/backend/apps/search_app.py @@ -24,7 +24,7 @@ app = modal.App( name=f"{env}-search", image=get_search_image(), - secrets=[get_secrets()] + secrets=get_secrets() ) # Register SearchService with this app diff --git a/backend/apps/server.py b/backend/apps/server.py index 4c863cf..5afecb4 100644 --- a/backend/apps/server.py +++ b/backend/apps/server.py @@ -20,7 +20,7 @@ app = modal.App( name=f"{env}-server", image=get_server_image(), - secrets=[get_secrets()] + secrets=get_secrets() ) diff --git a/backend/cli.py b/backend/cli.py index 7227512..8f215b7 100644 --- a/backend/cli.py +++ b/backend/cli.py @@ -1,17 +1,20 @@ """CLI for serving Modal apps locally.""" +import os import signal import subprocess import sys +import threading +from pathlib import Path # Dev combined app - all services in one for local iteration DEV_COMBINED_APP = "apps/dev_combined.py" # Individual apps for staging/prod deployment APPS = { - "server": ("services/http_server.py", "\033[36m"), # Cyan - "search": ("services/search_service.py", "\033[33m"), # Yellow - "processing": ("services/processing_service.py", "\033[35m"), # Magenta + "server": ("apps/server.py", "\033[36m"), # Cyan + "search": ("apps/search_app.py", "\033[33m"), # Yellow + "processing": ("apps/processing_app.py", "\033[35m"), # Magenta } RESET = "\033[0m" @@ -41,14 +44,22 @@ def serve_all(): print("Note: For staging/prod, deploy individual apps separately.\n") print("-" * 60 + "\n") + # Ensure venv bin is in PATH for Modal subprocess calls + venv_bin = Path(__file__).parent / ".venv" / "bin" + env = os.environ.copy() + if venv_bin.exists(): + current_path = env.get("PATH", "") + env["PATH"] = f"{venv_bin}:{current_path}" if current_path else str(venv_bin) + # Run with color-coded output prefixing color = "\033[32m" # Green for combined dev app process = subprocess.Popen( - ["modal", "serve", DEV_COMBINED_APP], + ["uv", "run", "modal", "serve", DEV_COMBINED_APP], stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, bufsize=1, + env=env, ) # Handle graceful shutdown @@ -68,12 +79,20 @@ def _serve_single_app(name: str): """Serve a single app with color-coded output.""" path, color = APPS[name] + # Ensure venv bin is in PATH for Modal subprocess calls + venv_bin = Path(__file__).parent / ".venv" / "bin" + env = os.environ.copy() + if venv_bin.exists(): + current_path = env.get("PATH", "") + env["PATH"] = f"{venv_bin}:{current_path}" if current_path else str(venv_bin) + process = subprocess.Popen( - ["modal", "serve", path], + ["uv", "run", "modal", "serve", path], stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, bufsize=1, + env=env, ) # Handle graceful shutdown @@ -102,3 +121,61 @@ def serve_search(): def serve_processing(): """Serve the processing app.""" _serve_single_app("processing") + + +def serve_staging(): + """ + Serve all staging apps concurrently (server + search + processing). + + Runs all three apps in separate processes with ENVIRONMENT=staging. + This matches the production architecture but runs locally. + """ + print("Starting staging apps (all services separately)...\n") + print(f" \033[36m●{RESET} server\n") + print(f" \033[33m●{RESET} search\n") + print(f" \033[35m●{RESET} processing\n") + print("Note: Cross-app communication works between these deployed apps.\n") + print("-" * 60 + "\n") + + # Ensure venv bin is in PATH for Modal subprocess calls + venv_bin = Path(__file__).parent / ".venv" / "bin" + env = os.environ.copy() + env["ENVIRONMENT"] = "staging" + if venv_bin.exists(): + current_path = env.get("PATH", "") + env["PATH"] = f"{venv_bin}:{current_path}" if current_path else str(venv_bin) + + # Start all three processes + processes = [] + for name in ["server", "search", "processing"]: + path, color = APPS[name] + process = subprocess.Popen( + ["uv", "run", "modal", "serve", path], + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + bufsize=1, + env=env, + ) + processes.append((process, name, color)) + + # Handle graceful shutdown + def signal_handler(sig, frame): + for process, _, _ in processes: + process.terminate() + sys.exit(0) + + signal.signal(signal.SIGINT, signal_handler) + signal.signal(signal.SIGTERM, signal_handler) + + # Stream output from all processes with color prefixing + threads = [] + for process, name, color in processes: + thread = threading.Thread(target=_prefix_output, args=(process, name, color)) + thread.daemon = True + thread.start() + threads.append(thread) + + # Wait for all processes + for process, _, _ in processes: + process.wait() diff --git a/backend/pyproject.toml b/backend/pyproject.toml index e02da15..8f2a1f4 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -22,6 +22,7 @@ dependencies = [ [project.scripts] dev = "cli:serve_all" +staging = "cli:serve_staging" server = "cli:serve_server" search = "cli:serve_search" processing = "cli:serve_processing" diff --git a/backend/services/http_server.py b/backend/services/http_server.py index 7c9d72a..b53e482 100644 --- a/backend/services/http_server.py +++ b/backend/services/http_server.py @@ -31,7 +31,7 @@ def _initialize_connectors(self): try: import firebase_admin import json - firebase_credentials = json.loads(get_env_var("FIREBASE_ADMIN_KEY")) + firebase_credentials = json.loads(get_env_var("FIREBASE_SERVICE_ACCOUNT_JSON")) from firebase_admin import credentials cred = credentials.Certificate(firebase_credentials) firebase_admin.initialize_app(cred) @@ -40,7 +40,7 @@ def _initialize_connectors(self): # Already initialized, which is fine pass except Exception as e: - logger.warning(f"[{self.__class__.__name__}] Firebase initialization failed: {e}") + logger.error(f"[{self.__class__.__name__}] Firebase initialization failed: {e}") # Get environment variables PINECONE_API_KEY = get_env_var("PINECONE_API_KEY") diff --git a/backend/shared/config.py b/backend/shared/config.py index 3e83537..6a14188 100644 --- a/backend/shared/config.py +++ b/backend/shared/config.py @@ -72,15 +72,18 @@ def get_modal_environment() -> str: """Get the modal environment name.""" return 'main' -def get_secrets() -> modal.Secret: +def get_secrets() -> list[modal.Secret]: """ Get Modal secrets for the current environment. Returns: - modal.Secret: Secret object containing environment variables + list[modal.Secret]: List of secret objects containing environment variables """ env = get_environment() - return modal.Secret.from_name(env) + return [ + modal.Secret.from_name(env), + modal.Secret.from_name("FIREBASE_SERVICE_ACCOUNT_JSON"), + ] def get_pinecone_index() -> str: From 4ea7a8670963d540c0924aa24b2daa93dcd96651 Mon Sep 17 00:00:00 2001 From: Justin Wu Date: Mon, 9 Feb 2026 15:10:16 -0500 Subject: [PATCH 3/3] add Firebase token verification error handling and update secrets retrieval in app configurations --- backend/api/server_fastapi_router.py | 7 ++++--- backend/apps/dev_combined.py | 2 +- backend/apps/processing_app.py | 2 +- backend/apps/search_app.py | 2 +- backend/apps/server.py | 2 +- backend/auth/auth_connector.py | 13 +++++++++---- backend/services/http_server.py | 19 ++++++++++--------- backend/shared/config.py | 9 +++------ 8 files changed, 30 insertions(+), 26 deletions(-) diff --git a/backend/api/server_fastapi_router.py b/backend/api/server_fastapi_router.py index 256c1eb..fbaf612 100644 --- a/backend/api/server_fastapi_router.py +++ b/backend/api/server_fastapi_router.py @@ -391,11 +391,12 @@ async def authorize_device( ) # Verify Firebase token - user_info = self.server_instance.auth_connector.verify_firebase_token(firebase_id_token) - if not user_info: + try: + user_info = self.server_instance.auth_connector.verify_firebase_token(firebase_id_token) + except ValueError as e: raise HTTPException( status_code=401, - detail="Invalid Firebase token" + detail=str(e) ) user_id = user_info["user_id"] diff --git a/backend/apps/dev_combined.py b/backend/apps/dev_combined.py index 0837f2c..d66a57c 100644 --- a/backend/apps/dev_combined.py +++ b/backend/apps/dev_combined.py @@ -42,7 +42,7 @@ app = modal.App( name=f"{env}-server", image=get_dev_image(), - secrets=get_secrets() + secrets=[get_secrets()] ) # SearchService exposes its own ASGI app for direct HTTP access (no server hop) diff --git a/backend/apps/processing_app.py b/backend/apps/processing_app.py index 2ecdb5b..7fee60a 100644 --- a/backend/apps/processing_app.py +++ b/backend/apps/processing_app.py @@ -24,7 +24,7 @@ app = modal.App( name=f"{env}-processing", image=get_processing_image(), - secrets=get_secrets() + secrets=[get_secrets()] ) # Register ProcessingService with this app diff --git a/backend/apps/search_app.py b/backend/apps/search_app.py index 2874031..8343caf 100644 --- a/backend/apps/search_app.py +++ b/backend/apps/search_app.py @@ -24,7 +24,7 @@ app = modal.App( name=f"{env}-search", image=get_search_image(), - secrets=get_secrets() + secrets=[get_secrets()] ) # Register SearchService with this app diff --git a/backend/apps/server.py b/backend/apps/server.py index 5afecb4..4c863cf 100644 --- a/backend/apps/server.py +++ b/backend/apps/server.py @@ -20,7 +20,7 @@ app = modal.App( name=f"{env}-server", image=get_server_image(), - secrets=get_secrets() + secrets=[get_secrets()] ) diff --git a/backend/auth/auth_connector.py b/backend/auth/auth_connector.py index 6fe9886..9870126 100644 --- a/backend/auth/auth_connector.py +++ b/backend/auth/auth_connector.py @@ -249,7 +249,9 @@ def delete_device_code(self, device_code: str) -> bool: def verify_firebase_token(self, id_token: str) -> Optional[Dict[str, Any]]: """Verify Firebase ID token from website/plugin.""" try: + logger.info(f"Verifying Firebase token (length={len(id_token)}, prefix={id_token[:20]}...)") decoded_token = auth.verify_id_token(id_token) + logger.info(f"Firebase token verified for uid: {decoded_token['uid']}") return { "user_id": decoded_token['uid'], "email": decoded_token.get('email'), @@ -257,13 +259,16 @@ def verify_firebase_token(self, id_token: str) -> Optional[Dict[str, Any]]: } except auth.InvalidIdTokenError as e: logger.error(f"Invalid Firebase token: {e}") - return None + raise ValueError(f"Invalid token: {e}") except auth.ExpiredIdTokenError as e: logger.error(f"Expired Firebase token: {e}") - return None + raise ValueError(f"Expired token: {e}") except auth.RevokedIdTokenError as e: logger.error(f"Revoked Firebase token: {e}") - return None + raise ValueError(f"Revoked token: {e}") except auth.CertificateFetchError as e: logger.error(f"Firebase certificate fetch error: {e}") - return None + raise ValueError(f"Certificate fetch error: {e}") + except Exception as e: + logger.error(f"Unexpected Firebase token error: {e}") + raise ValueError(f"Token verification failed: {e}") diff --git a/backend/services/http_server.py b/backend/services/http_server.py index b53e482..f9e3f91 100644 --- a/backend/services/http_server.py +++ b/backend/services/http_server.py @@ -30,17 +30,18 @@ def _initialize_connectors(self): # Initialize Firebase Admin SDK (required for token verification) try: import firebase_admin - import json - firebase_credentials = json.loads(get_env_var("FIREBASE_SERVICE_ACCOUNT_JSON")) - from firebase_admin import credentials - cred = credentials.Certificate(firebase_credentials) - firebase_admin.initialize_app(cred) - logger.info(f"[{self.__class__.__name__}] Firebase Admin SDK initialized") - except ValueError: - # Already initialized, which is fine - pass + if not firebase_admin._apps: + import json + firebase_credentials = json.loads(get_env_var("FIREBASE_SERVICE_ACCOUNT_JSON")) + from firebase_admin import credentials + cred = credentials.Certificate(firebase_credentials) + firebase_admin.initialize_app(cred) + logger.info(f"[{self.__class__.__name__}] Firebase Admin SDK initialized") + else: + logger.info(f"[{self.__class__.__name__}] Firebase Admin SDK already initialized") except Exception as e: logger.error(f"[{self.__class__.__name__}] Firebase initialization failed: {e}") + raise # Get environment variables PINECONE_API_KEY = get_env_var("PINECONE_API_KEY") diff --git a/backend/shared/config.py b/backend/shared/config.py index 6a14188..3e83537 100644 --- a/backend/shared/config.py +++ b/backend/shared/config.py @@ -72,18 +72,15 @@ def get_modal_environment() -> str: """Get the modal environment name.""" return 'main' -def get_secrets() -> list[modal.Secret]: +def get_secrets() -> modal.Secret: """ Get Modal secrets for the current environment. Returns: - list[modal.Secret]: List of secret objects containing environment variables + modal.Secret: Secret object containing environment variables """ env = get_environment() - return [ - modal.Secret.from_name(env), - modal.Secret.from_name("FIREBASE_SERVICE_ACCOUNT_JSON"), - ] + return modal.Secret.from_name(env) def get_pinecone_index() -> str: