diff --git a/backend/Dockerfile b/backend/Dockerfile
index eda348f0..456e8be2 100644
--- a/backend/Dockerfile
+++ b/backend/Dockerfile
@@ -15,7 +15,7 @@ COPY . .
# Calculate optimal worker count based on 16 vCPUs
# Using (2*CPU)+1 formula for CPU-bound applications
-ENV WORKERS=33
+ENV WORKERS=7
ENV THREADS=2
ENV WORKER_CONNECTIONS=2000
diff --git a/backend/agentpress/response_processor.py b/backend/agentpress/response_processor.py
index 470aaf44..84059a77 100644
--- a/backend/agentpress/response_processor.py
+++ b/backend/agentpress/response_processor.py
@@ -146,6 +146,9 @@ class ResponseProcessor:
prompt_messages: List[Dict[str, Any]],
llm_model: str,
config: ProcessorConfig = ProcessorConfig(),
+ can_auto_continue: bool = False,
+ auto_continue_count: int = 0,
+ continuous_state: Optional[Dict[str, Any]] = None,
) -> AsyncGenerator[Dict[str, Any], None]:
"""Process a streaming LLM response, handling tool calls and execution.
@@ -155,19 +158,25 @@ class ResponseProcessor:
prompt_messages: List of messages sent to the LLM (the prompt)
llm_model: The name of the LLM model used
config: Configuration for parsing and execution
+ can_auto_continue: Whether auto-continue is enabled
+ auto_continue_count: Number of auto-continue cycles
+ continuous_state: Previous state of the conversation
Yields:
Complete message objects matching the DB schema, except for content chunks.
"""
- accumulated_content = ""
+ # Initialize from continuous state if provided (for auto-continue)
+ continuous_state = continuous_state or {}
+ accumulated_content = continuous_state.get('accumulated_content', "")
tool_calls_buffer = {}
- current_xml_content = ""
+ current_xml_content = accumulated_content # equal to accumulated_content if auto-continuing, else blank
xml_chunks_buffer = []
pending_tool_executions = []
yielded_tool_indices = set() # Stores indices of tools whose *status* has been yielded
tool_index = 0
xml_tool_call_count = 0
finish_reason = None
+ should_auto_continue = False
last_assistant_message_object = None # Store the final saved assistant message object
tool_result_message_objects = {} # tool_index -> full saved message object
has_printed_thinking_prefix = False # Flag for printing thinking prefix only once
@@ -191,26 +200,29 @@ class ResponseProcessor:
logger.info(f"Streaming Config: XML={config.xml_tool_calling}, Native={config.native_tool_calling}, "
f"Execute on stream={config.execute_on_stream}, Strategy={config.tool_execution_strategy}")
- thread_run_id = str(uuid.uuid4())
+ # Reuse thread_run_id for auto-continue or create new one
+ thread_run_id = continuous_state.get('thread_run_id') or str(uuid.uuid4())
+ continuous_state['thread_run_id'] = thread_run_id
try:
- # --- Save and Yield Start Events ---
- start_content = {"status_type": "thread_run_start", "thread_run_id": thread_run_id}
- start_msg_obj = await self.add_message(
- thread_id=thread_id, type="status", content=start_content,
- is_llm_message=False, metadata={"thread_run_id": thread_run_id}
- )
- if start_msg_obj: yield format_for_yield(start_msg_obj)
+ # --- Save and Yield Start Events (only if not auto-continuing) ---
+ if auto_continue_count == 0:
+ start_content = {"status_type": "thread_run_start", "thread_run_id": thread_run_id}
+ start_msg_obj = await self.add_message(
+ thread_id=thread_id, type="status", content=start_content,
+ is_llm_message=False, metadata={"thread_run_id": thread_run_id}
+ )
+ if start_msg_obj: yield format_for_yield(start_msg_obj)
- assist_start_content = {"status_type": "assistant_response_start"}
- assist_start_msg_obj = await self.add_message(
- thread_id=thread_id, type="status", content=assist_start_content,
- is_llm_message=False, metadata={"thread_run_id": thread_run_id}
- )
- if assist_start_msg_obj: yield format_for_yield(assist_start_msg_obj)
+ assist_start_content = {"status_type": "assistant_response_start"}
+ assist_start_msg_obj = await self.add_message(
+ thread_id=thread_id, type="status", content=assist_start_content,
+ is_llm_message=False, metadata={"thread_run_id": thread_run_id}
+ )
+ if assist_start_msg_obj: yield format_for_yield(assist_start_msg_obj)
# --- End Start Events ---
- __sequence = 0
+ __sequence = continuous_state.get('sequence', 0) # get the sequence from the previous auto-continue cycle
async for chunk in llm_response:
# Extract streaming metadata from chunks
@@ -492,8 +504,12 @@ class ResponseProcessor:
logger.info(f"Stream finished with reason: xml_tool_limit_reached after {xml_tool_call_count} XML tool calls")
self.trace.event(name="stream_finished_with_reason_xml_tool_limit_reached_after_xml_tool_calls", level="DEFAULT", status_message=(f"Stream finished with reason: xml_tool_limit_reached after {xml_tool_call_count} XML tool calls"))
+ # Calculate if auto-continue is needed if the finish reason is length
+ should_auto_continue = (can_auto_continue and finish_reason == 'length')
+
# --- SAVE and YIELD Final Assistant Message ---
- if accumulated_content:
+ # Only save assistant message if NOT auto-continuing due to length to avoid duplicate messages
+ if accumulated_content and not should_auto_continue:
# ... (Truncate accumulated_content logic) ...
if config.max_xml_tool_calls > 0 and xml_tool_call_count >= config.max_xml_tool_calls and xml_chunks_buffer:
last_xml_chunk = xml_chunks_buffer[-1]
@@ -746,53 +762,55 @@ class ResponseProcessor:
return
# --- Save and Yield assistant_response_end ---
- if last_assistant_message_object: # Only save if assistant message was saved
- try:
- # Calculate response time if we have timing data
- if streaming_metadata["first_chunk_time"] and streaming_metadata["last_chunk_time"]:
- streaming_metadata["response_ms"] = (streaming_metadata["last_chunk_time"] - streaming_metadata["first_chunk_time"]) * 1000
+ # Only save assistant_response_end if not auto-continuing (response is actually complete)
+ if not should_auto_continue:
+ if last_assistant_message_object: # Only save if assistant message was saved
+ try:
+ # Calculate response time if we have timing data
+ if streaming_metadata["first_chunk_time"] and streaming_metadata["last_chunk_time"]:
+ streaming_metadata["response_ms"] = (streaming_metadata["last_chunk_time"] - streaming_metadata["first_chunk_time"]) * 1000
- # Create a LiteLLM-like response object for streaming
- # Check if we have any actual usage data
- has_usage_data = (
- streaming_metadata["usage"]["prompt_tokens"] > 0 or
- streaming_metadata["usage"]["completion_tokens"] > 0 or
- streaming_metadata["usage"]["total_tokens"] > 0
- )
-
- assistant_end_content = {
- "choices": [
- {
- "finish_reason": finish_reason or "stop",
- "index": 0,
- "message": {
- "role": "assistant",
- "content": accumulated_content,
- "tool_calls": complete_native_tool_calls or None
+ # Create a LiteLLM-like response object for streaming
+ # Check if we have any actual usage data
+ has_usage_data = (
+ streaming_metadata["usage"]["prompt_tokens"] > 0 or
+ streaming_metadata["usage"]["completion_tokens"] > 0 or
+ streaming_metadata["usage"]["total_tokens"] > 0
+ )
+
+ assistant_end_content = {
+ "choices": [
+ {
+ "finish_reason": finish_reason or "stop",
+ "index": 0,
+ "message": {
+ "role": "assistant",
+ "content": accumulated_content,
+ "tool_calls": complete_native_tool_calls or None
+ }
}
- }
- ],
- "created": streaming_metadata.get("created"),
- "model": streaming_metadata.get("model", llm_model),
- "usage": streaming_metadata["usage"], # Always include usage like LiteLLM does
- "streaming": True, # Add flag to indicate this was reconstructed from streaming
- }
-
- # Only include response_ms if we have timing data
- if streaming_metadata.get("response_ms"):
- assistant_end_content["response_ms"] = streaming_metadata["response_ms"]
-
- await self.add_message(
- thread_id=thread_id,
- type="assistant_response_end",
- content=assistant_end_content,
- is_llm_message=False,
- metadata={"thread_run_id": thread_run_id}
- )
- logger.info("Assistant response end saved for stream")
- except Exception as e:
- logger.error(f"Error saving assistant response end for stream: {str(e)}")
- self.trace.event(name="error_saving_assistant_response_end_for_stream", level="ERROR", status_message=(f"Error saving assistant response end for stream: {str(e)}"))
+ ],
+ "created": streaming_metadata.get("created"),
+ "model": streaming_metadata.get("model", llm_model),
+ "usage": streaming_metadata["usage"], # Always include usage like LiteLLM does
+ "streaming": True, # Add flag to indicate this was reconstructed from streaming
+ }
+
+ # Only include response_ms if we have timing data
+ if streaming_metadata.get("response_ms"):
+ assistant_end_content["response_ms"] = streaming_metadata["response_ms"]
+
+ await self.add_message(
+ thread_id=thread_id,
+ type="assistant_response_end",
+ content=assistant_end_content,
+ is_llm_message=False,
+ metadata={"thread_run_id": thread_run_id}
+ )
+ logger.info("Assistant response end saved for stream")
+ except Exception as e:
+ logger.error(f"Error saving assistant response end for stream: {str(e)}")
+ self.trace.event(name="error_saving_assistant_response_end_for_stream", level="ERROR", status_message=(f"Error saving assistant response end for stream: {str(e)}"))
except Exception as e:
logger.error(f"Error processing stream: {str(e)}", exc_info=True)
@@ -815,17 +833,24 @@ class ResponseProcessor:
raise # Use bare 'raise' to preserve the original exception with its traceback
finally:
- # Save and Yield the final thread_run_end status
- try:
- end_content = {"status_type": "thread_run_end"}
- end_msg_obj = await self.add_message(
- thread_id=thread_id, type="status", content=end_content,
- is_llm_message=False, metadata={"thread_run_id": thread_run_id if 'thread_run_id' in locals() else None}
- )
- if end_msg_obj: yield format_for_yield(end_msg_obj)
- except Exception as final_e:
- logger.error(f"Error in finally block: {str(final_e)}", exc_info=True)
- self.trace.event(name="error_in_finally_block", level="ERROR", status_message=(f"Error in finally block: {str(final_e)}"))
+ # Update continuous state for potential auto-continue
+ if should_auto_continue:
+ continuous_state['accumulated_content'] = accumulated_content
+ continuous_state['sequence'] = __sequence
+
+ logger.info(f"Updated continuous state for auto-continue with {len(accumulated_content)} chars")
+ else:
+ # Save and Yield the final thread_run_end status (only if not auto-continuing and finish_reason is not 'length')
+ try:
+ end_content = {"status_type": "thread_run_end"}
+ end_msg_obj = await self.add_message(
+ thread_id=thread_id, type="status", content=end_content,
+ is_llm_message=False, metadata={"thread_run_id": thread_run_id if 'thread_run_id' in locals() else None}
+ )
+ if end_msg_obj: yield format_for_yield(end_msg_obj)
+ except Exception as final_e:
+ logger.error(f"Error in finally block: {str(final_e)}", exc_info=True)
+ self.trace.event(name="error_in_finally_block", level="ERROR", status_message=(f"Error in finally block: {str(final_e)}"))
async def process_non_streaming_response(
self,
diff --git a/backend/agentpress/thread_manager.py b/backend/agentpress/thread_manager.py
index a4d1d974..d6a00530 100644
--- a/backend/agentpress/thread_manager.py
+++ b/backend/agentpress/thread_manager.py
@@ -297,6 +297,12 @@ Here are the XML tools available with examples:
# Control whether we need to auto-continue due to tool_calls finish reason
auto_continue = True
auto_continue_count = 0
+
+ # Shared state for continuous streaming across auto-continues
+ continuous_state = {
+ 'accumulated_content': '',
+ 'thread_run_id': None
+ }
# Define inner function to handle a single run
async def _run_once(temp_msg=None):
@@ -342,6 +348,18 @@ Here are the XML tools available with examples:
prepared_messages.append(temp_msg)
logger.debug("Added temporary message to the end of prepared messages")
+ # Add partial assistant content for auto-continue context (without saving to DB)
+ if auto_continue_count > 0 and continuous_state.get('accumulated_content'):
+ partial_content = continuous_state.get('accumulated_content', '')
+
+ # Create temporary assistant message with just the text content
+ temporary_assistant_message = {
+ "role": "assistant",
+ "content": partial_content
+ }
+ prepared_messages.append(temporary_assistant_message)
+ logger.info(f"Added temporary assistant message with {len(partial_content)} chars for auto-continue context")
+
# 4. Prepare tools for LLM call
openapi_tool_schemas = None
if config.native_tool_calling:
@@ -395,6 +413,9 @@ Here are the XML tools available with examples:
config=config,
prompt_messages=prepared_messages,
llm_model=llm_model,
+ can_auto_continue=(native_max_auto_continues > 0),
+ auto_continue_count=auto_continue_count,
+ continuous_state=continuous_state
)
else:
# Fallback to non-streaming if response is not iterable
@@ -467,6 +488,14 @@ Here are the XML tools available with examples:
auto_continue = False
# Still yield the chunk to inform the client
+ elif chunk.get('type') == 'status':
+ # if the finish reason is length, auto-continue
+ content = json.loads(chunk.get('content'))
+ if content.get('finish_reason') == 'length':
+ logger.info(f"Detected finish_reason='length', auto-continuing ({auto_continue_count + 1}/{native_max_auto_continues})")
+ auto_continue = True
+ auto_continue_count += 1
+ continue
# Otherwise just yield the chunk normally
yield chunk
else:
@@ -480,7 +509,9 @@ Here are the XML tools available with examples:
if ("AnthropicException - Overloaded" in str(e)):
logger.error(f"AnthropicException - Overloaded detected - Falling back to OpenRouter: {str(e)}", exc_info=True)
nonlocal llm_model
- llm_model = f"openrouter/{llm_model}"
+ # Remove "-20250514" from the model name if present
+ model_name_cleaned = llm_model.replace("-20250514", "")
+ llm_model = f"openrouter/{model_name_cleaned}"
auto_continue = True
continue # Continue the loop
else:
diff --git a/backend/api.py b/backend/api.py
index ffb3f602..0d274c34 100644
--- a/backend/api.py
+++ b/backend/api.py
@@ -187,8 +187,10 @@ api_router.include_router(workflows_router, prefix="/workflows")
from pipedream import api as pipedream_api
api_router.include_router(pipedream_api.router)
-from auth import phone_verification_supabase_mfa
-api_router.include_router(phone_verification_supabase_mfa.router)
+# MFA functionality moved to frontend
+
+from local_env_manager import api as local_env_manager_api
+api_router.include_router(local_env_manager_api.router)
@api_router.get("/health")
async def health_check():
diff --git a/backend/auth/phone_verification_supabase_mfa.py b/backend/auth/phone_verification_supabase_mfa.py
deleted file mode 100644
index a478292f..00000000
--- a/backend/auth/phone_verification_supabase_mfa.py
+++ /dev/null
@@ -1,591 +0,0 @@
-"""
-Auth MFA endpoints for Supabase Phone-based Multi-Factor Authentication (MFA).
-
-Currently, only SMS is supported as a second factor. Users can enroll their phone number for SMS-based 2FA.
-No recovery codes are supported, but users can update their phone number for backup.
-
-This API provides endpoints to:
-- Enroll a phone number for SMS 2FA
-- Create a challenge for a phone factor (sends SMS)
-- Verify a challenge with SMS code
-- Create and verify a challenge in a single step
-- List enrolled factors
-- Unenroll a factor
-- Get Authenticator Assurance Level (AAL)
-"""
-
-import json
-import os
-from fastapi import APIRouter, Depends, HTTPException, Request
-from pydantic import BaseModel, Field
-from typing import List, Optional
-import jwt
-from datetime import datetime, timezone
-from supabase import create_client, Client
-from utils.auth_utils import get_current_user_id_from_jwt
-from utils.config import config
-from utils.logger import logger, structlog
-
-router = APIRouter(prefix="/mfa", tags=["MFA"])
-
-# Initialize Supabase client with anon key for user operations
-supabase_url = config.SUPABASE_URL
-supabase_anon_key = config.SUPABASE_ANON_KEY
-
-# Cutoff date for new user phone verification requirement
-# Users created after this date will be required to have phone verification
-# Users created before this date are grandfathered in and not required to verify
-PHONE_VERIFICATION_CUTOFF_DATE = datetime(2025, 7, 21, 0, 0, 0, tzinfo=timezone.utc)
-
-def is_phone_verification_mandatory() -> bool:
- """Check if phone verification is mandatory based on environment variable."""
- env_val = os.getenv("PHONE_NUMBER_MANDATORY")
- if env_val is None:
- return False
- return env_val.lower() in ('true', 't', 'yes', 'y', '1')
-
-
-def get_authenticated_client(request: Request) -> Client:
- """
- Create a Supabase client authenticated with the user's JWT token.
- This approach uses the JWT token directly for server-side authentication.
- """
- # Extract the JWT token from the Authorization header
- auth_header = request.headers.get("Authorization")
- if not auth_header or not auth_header.startswith("Bearer "):
- raise HTTPException(
- status_code=401, detail="Missing or invalid Authorization header"
- )
-
- token = auth_header.split(" ")[1]
-
- # Extract the refresh token from the custom header
- refresh_token = request.headers.get("X-Refresh-Token")
-
- # Create a new Supabase client with the anon key
- client = create_client(supabase_url, supabase_anon_key)
-
- # Set the session with the JWT token
- # For server-side operations, we can use the token directly
- try:
- # Verify the token is valid by getting the user
- user_response = client.auth.get_user(token)
- if not user_response.user:
- raise HTTPException(status_code=401, detail="Invalid token")
-
- # Set the session with both access and refresh tokens
- client.auth.set_session(token, refresh_token)
- return client
- except Exception as e:
- raise HTTPException(status_code=401, detail=f"Authentication failed: {str(e)}")
-
-
-# Request/Response Models
-class EnrollFactorRequest(BaseModel):
- friendly_name: str = Field(..., description="User-friendly name for the factor")
- phone_number: str = Field(
- ..., description="Phone number in E.164 format (e.g., +1234567890)"
- )
-
-
-class EnrollFactorResponse(BaseModel):
- id: str
- friendly_name: str
- phone_number: str
- # Note: Supabase response may not include status, created_at, updated_at
- qr_code: Optional[str] = None
- secret: Optional[str] = None
-
-
-class ChallengeRequest(BaseModel):
- factor_id: str = Field(..., description="ID of the factor to challenge")
-
-
-class ChallengeResponse(BaseModel):
- id: str
- expires_at: Optional[str] = None
- # Note: Supabase response may not include factor_type, created_at
-
-
-class VerifyRequest(BaseModel):
- factor_id: str = Field(..., description="ID of the factor to verify")
- challenge_id: str = Field(..., description="ID of the challenge to verify")
- code: str = Field(..., description="SMS code received on phone")
-
-
-class ChallengeAndVerifyRequest(BaseModel):
- factor_id: str = Field(..., description="ID of the factor to challenge and verify")
- code: str = Field(..., description="SMS code received on phone")
-
-
-class FactorInfo(BaseModel):
- id: str
- friendly_name: Optional[str] = None
- factor_type: Optional[str] = None
- status: Optional[str] = None
- phone: Optional[str] = None
- created_at: Optional[str] = None
- updated_at: Optional[str] = None
-
-
-class ListFactorsResponse(BaseModel):
- factors: List[FactorInfo]
-
-
-class UnenrollRequest(BaseModel):
- factor_id: str = Field(..., description="ID of the factor to unenroll")
-
-
-class AALResponse(BaseModel):
- current_level: Optional[str] = None
- next_level: Optional[str] = None
- current_authentication_methods: Optional[List[str]] = None
- # Add action guidance based on AAL status
- action_required: Optional[str] = None
- message: Optional[str] = None
- # Phone verification requirement fields
- phone_verification_required: Optional[bool] = None
- user_created_at: Optional[str] = None
- cutoff_date: Optional[str] = None
- # Computed verification status fields
- verification_required: Optional[bool] = None
- is_verified: Optional[bool] = None
- factors: Optional[List[dict]] = None
-
-
-@router.post("/enroll", response_model=EnrollFactorResponse)
-async def enroll_factor(
- request_data: EnrollFactorRequest,
- client: Client = Depends(get_authenticated_client),
- user_id: str = Depends(get_current_user_id_from_jwt),
-):
- """
- Enroll a new phone number for SMS-based 2FA.
-
- Currently only supports 'phone' factor type.
- Phone number must be in E.164 format (e.g., +1234567890).
- """
- structlog.contextvars.bind_contextvars(
- user_id=user_id,
- action="mfa_enroll",
- phone_number=request_data.phone_number,
- friendly_name=request_data.friendly_name
- )
-
- try:
- response = client.auth.mfa.enroll(
- {
- "factor_type": "phone",
- "friendly_name": request_data.friendly_name,
- "phone": request_data.phone_number,
- }
- )
-
- # Build response with defensive field access
- enroll_response = EnrollFactorResponse(
- id=response.id,
- friendly_name=request_data.friendly_name, # Use request data as fallback
- phone_number=request_data.phone_number,
- )
-
- # Add optional fields if they exist
- if hasattr(response, "qr_code"):
- enroll_response.qr_code = response.qr_code
- if hasattr(response, "secret"):
- enroll_response.secret = response.secret
-
- return enroll_response
- except Exception as e:
- raise HTTPException(
- status_code=400, detail=f"Failed to enroll phone factor: {str(e)}"
- )
-
-
-@router.post("/challenge", response_model=ChallengeResponse)
-async def create_challenge(
- request_data: ChallengeRequest,
- client: Client = Depends(get_authenticated_client),
- user_id: str = Depends(get_current_user_id_from_jwt),
-):
- """
- Create a challenge for an enrolled phone factor.
-
- This will send an SMS code to the registered phone number.
- The challenge must be verified within the time limit.
- """
- structlog.contextvars.bind_contextvars(
- user_id=user_id,
- action="mfa_challenge",
- factor_id=request_data.factor_id
- )
-
- try:
- response = client.auth.mfa.challenge(
- {
- "factor_id": request_data.factor_id,
- }
- )
-
- # Build response with defensive field access
- challenge_response = ChallengeResponse(id=response.id)
-
- # Add optional fields if they exist
- if hasattr(response, "expires_at"):
- challenge_response.expires_at = response.expires_at
-
- return challenge_response
- except Exception as e:
- raise HTTPException(
- status_code=400, detail=f"Failed to create SMS challenge: {str(e)}"
- )
-
-
-@router.post("/verify")
-async def verify_challenge(
- request_data: VerifyRequest,
- client: Client = Depends(get_authenticated_client),
- user_id: str = Depends(get_current_user_id_from_jwt),
-):
- """
- Verify a challenge with an SMS code.
-
- The challenge must be active and the SMS code must be valid.
- """
- structlog.contextvars.bind_contextvars(
- user_id=user_id,
- action="mfa_verify",
- factor_id=request_data.factor_id,
- challenge_id=request_data.challenge_id
- )
-
- try:
- logger.info(f"🔵 Starting MFA verification for user {user_id}: "
- f"factor_id={request_data.factor_id}, "
- f"challenge_id={request_data.challenge_id}")
-
- # Check AAL BEFORE verification
- try:
- aal_before = client.auth.mfa.get_authenticator_assurance_level()
- logger.info(f"📊 AAL BEFORE verification: "
- f"current={aal_before.current_level}, "
- f"next={aal_before.next_level}")
- except Exception as e:
- logger.warning(f"Failed to get AAL before verification: {e}")
-
- # Verify the challenge
- response = client.auth.mfa.verify(
- {
- "factor_id": request_data.factor_id,
- "challenge_id": request_data.challenge_id,
- "code": request_data.code,
- }
- )
-
- logger.info(f"✅ MFA verification successful for user {user_id}")
- logger.info(f"Verification response type: {type(response)}")
- logger.info(f"Verification response attributes: {dir(response)}")
-
- # Check if response has session info
- if hasattr(response, 'session') and response.session:
- logger.info(f"New session info: access_token present: {bool(getattr(response.session, 'access_token', None))}")
- logger.info(f"New session user: {getattr(response.session, 'user', None)}")
-
- # Check AAL AFTER verification
- try:
- aal_after = client.auth.mfa.get_authenticator_assurance_level()
- logger.info(f"📊 AAL AFTER verification: "
- f"current={aal_after.current_level}, "
- f"next={aal_after.next_level}")
- except Exception as e:
- logger.warning(f"Failed to get AAL after verification: {e}")
-
- # Check factor status AFTER verification
- try:
- user_response = client.auth.get_user()
- if user_response.user and hasattr(user_response.user, "factors"):
- for factor in user_response.user.factors:
- if factor.id == request_data.factor_id:
- logger.info(f"Factor {request_data.factor_id} status after verification: {getattr(factor, 'status', 'unknown')}")
- break
- except Exception as e:
- logger.warning(f"Failed to check factor status after verification: {e}")
-
- return {
- "success": True,
- "message": "SMS code verified successfully",
- "session": response,
- }
- except Exception as e:
- logger.error(f"❌ MFA verification failed for user {user_id}: {str(e)}")
- raise HTTPException(
- status_code=400, detail=f"Failed to verify SMS code: {str(e)}"
- )
-
-
-@router.post("/challenge-and-verify")
-async def challenge_and_verify(
- request_data: ChallengeAndVerifyRequest,
- client: Client = Depends(get_authenticated_client),
- user_id: str = Depends(get_current_user_id_from_jwt),
-):
- """
- Create a challenge and verify it in a single step.
-
- This will send an SMS code and verify it immediately when provided.
- This is a convenience method that combines challenge creation and verification.
- """
- structlog.contextvars.bind_contextvars(
- user_id=user_id,
- action="mfa_challenge_and_verify",
- factor_id=request_data.factor_id
- )
-
- try:
- response = client.auth.mfa.challenge_and_verify(
- {"factor_id": request_data.factor_id, "code": request_data.code}
- )
-
- return {
- "success": True,
- "message": "SMS challenge created and verified successfully",
- "session": response,
- }
- except Exception as e:
- raise HTTPException(
- status_code=400, detail=f"Failed to challenge and verify SMS: {str(e)}"
- )
-
-
-@router.get("/factors", response_model=ListFactorsResponse)
-async def list_factors(
- client: Client = Depends(get_authenticated_client),
- user_id: str = Depends(get_current_user_id_from_jwt),
-):
- """
- List all enrolled factors for the authenticated user.
- """
- structlog.contextvars.bind_contextvars(
- user_id=user_id,
- action="mfa_list_factors"
- )
-
- try:
- # Get user info which includes factors
- user_response = client.auth.get_user()
- if not user_response.user:
- raise HTTPException(status_code=401, detail="User not found")
-
- # Extract factors from user data with defensive access
- factors = []
- if hasattr(user_response.user, "factors") and user_response.user.factors:
- for factor in user_response.user.factors:
- # Convert datetime objects to strings for Pydantic validation
- created_at = getattr(factor, "created_at", None)
- if created_at and hasattr(created_at, "isoformat"):
- created_at = created_at.isoformat()
-
- updated_at = getattr(factor, "updated_at", None)
- if updated_at and hasattr(updated_at, "isoformat"):
- updated_at = updated_at.isoformat()
-
- factor_info = FactorInfo(
- id=factor.id if hasattr(factor, "id") else str(factor),
- friendly_name=getattr(factor, "friendly_name", None),
- factor_type=getattr(factor, "factor_type", None),
- status=getattr(factor, "status", None),
- phone=getattr(factor, "phone", None),
- created_at=created_at,
- updated_at=updated_at,
- )
- factors.append(factor_info)
-
- return ListFactorsResponse(factors=factors)
- except Exception as e:
- raise HTTPException(status_code=400, detail=f"Failed to list factors: {str(e)}")
-
-
-@router.post("/unenroll")
-async def unenroll_factor(
- request_data: UnenrollRequest,
- client: Client = Depends(get_authenticated_client),
- user_id: str = Depends(get_current_user_id_from_jwt),
-):
- """
- Unenroll a phone factor for the authenticated user.
-
- This will remove the phone number and invalidate any active sessions if the factor was verified.
- """
- structlog.contextvars.bind_contextvars(
- user_id=user_id,
- action="mfa_unenroll",
- factor_id=request_data.factor_id
- )
-
- try:
- response = client.auth.mfa.unenroll({"factor_id": request_data.factor_id})
-
- return {"success": True, "message": "Phone factor unenrolled successfully"}
- except Exception as e:
- raise HTTPException(
- status_code=400, detail=f"Failed to unenroll phone factor: {str(e)}"
- )
-
-
-@router.get("/aal", response_model=AALResponse)
-async def get_authenticator_assurance_level(
- client: Client = Depends(get_authenticated_client),
- user_id: str = Depends(get_current_user_id_from_jwt),
-):
- """
- Get the Authenticator Assurance Level (AAL) for the current session.
-
- This endpoint combines AAL status with phone verification requirements:
- - aal1 -> aal1: User does not have MFA enrolled
- - aal1 -> aal2: User has MFA enrolled but not verified (requires verification)
- - aal2 -> aal2: User has verified their MFA factor
- - aal2 -> aal1: User has disabled MFA (stale JWT, requires reauthentication)
-
- Also includes phone verification requirement based on account creation date.
- """
- structlog.contextvars.bind_contextvars(
- user_id=user_id,
- action="mfa_get_aal"
- )
-
- try:
- # Get the current AAL from Supabase
- response = client.auth.mfa.get_authenticator_assurance_level()
-
- # Extract AAL levels from response first
- current = response.current_level
- next_level = response.next_level
-
- # Get user creation date and factors for phone verification requirement
- user_response = client.auth.get_user()
- if not user_response.user:
- raise HTTPException(status_code=401, detail="User not found")
-
- user_created_at = None
- if hasattr(user_response.user, 'created_at') and user_response.user.created_at:
- try:
- # Handle different possible formats for created_at
- created_at_value = user_response.user.created_at
- if isinstance(created_at_value, str):
- # Parse ISO format string
- user_created_at = datetime.fromisoformat(created_at_value.replace('Z', '+00:00'))
- elif hasattr(created_at_value, 'isoformat'):
- # Already a datetime object
- user_created_at = created_at_value
- if user_created_at.tzinfo is None:
- user_created_at = user_created_at.replace(tzinfo=timezone.utc)
- else:
- logger.warning(f"Unexpected created_at type: {type(created_at_value)}")
- except Exception as e:
- logger.error(f"Failed to parse user created_at: {e}")
- # Fall back to treating as new user for safety
- user_created_at = datetime.now(timezone.utc)
-
- # Determine if this is a new user who needs phone verification
- is_new_user = (
- user_created_at is not None and
- user_created_at >= PHONE_VERIFICATION_CUTOFF_DATE
- )
-
- # Get factors and compute phone verification status
- factors = []
- phone_factors = []
- has_verified_phone = False
-
- if hasattr(user_response.user, "factors") and user_response.user.factors:
- for factor in user_response.user.factors:
- # Convert datetime objects to strings for JSON serialization
- created_at = getattr(factor, "created_at", None)
- if created_at and hasattr(created_at, "isoformat"):
- created_at = created_at.isoformat()
-
- updated_at = getattr(factor, "updated_at", None)
- if updated_at and hasattr(updated_at, "isoformat"):
- updated_at = updated_at.isoformat()
-
- factor_dict = {
- "id": factor.id if hasattr(factor, "id") else str(factor),
- "friendly_name": getattr(factor, "friendly_name", None),
- "factor_type": getattr(factor, "factor_type", None),
- "status": getattr(factor, "status", None),
- "phone": getattr(factor, "phone", None),
- "created_at": created_at,
- "updated_at": updated_at,
- }
- factors.append(factor_dict)
-
- # Track phone factors
- if factor_dict.get("factor_type") == "phone":
- phone_factors.append(factor_dict)
- if factor_dict.get("status") == "verified":
- has_verified_phone = True
-
- # Determine action required based on AAL combination
- action_required = None
- message = None
-
- if current == "aal1" and next_level == "aal1":
- # User does not have MFA enrolled
- action_required = "none"
- message = "MFA is not enrolled for this account"
- elif current == "aal1" and next_level == "aal2":
- # User has MFA enrolled but needs to verify it
- action_required = "verify_mfa"
- message = "MFA verification required to access full features"
- elif current == "aal2" and next_level == "aal2":
- # User has verified their MFA factor
- action_required = "none"
- message = "MFA is verified and active"
- elif current == "aal2" and next_level == "aal1":
- # User has disabled MFA or has stale JWT
- action_required = "reauthenticate"
- message = "Session needs refresh due to MFA changes"
- else:
- # Unknown combination
- action_required = "unknown"
- message = f"Unknown AAL combination: {current} -> {next_level}"
-
- # Determine verification_required based on AAL status AND grandfathering logic
- verification_required = False
- if is_new_user:
- # New users (created after cutoff date) must have phone verification
- if current == 'aal1' and next_level == 'aal1':
- # No MFA enrolled - new users must enroll
- verification_required = True
- elif action_required == 'verify_mfa':
- # MFA enrolled but needs verification
- verification_required = True
- else:
- # Existing users (grandfathered) - only require verification if AAL demands it
- verification_required = action_required == 'verify_mfa'
-
- phone_verification_required = False and is_new_user and is_phone_verification_mandatory()
- verification_required = False and is_new_user and verification_required and is_phone_verification_mandatory()
-
- logger.info(f"AAL check for user {user_id}: "
- f"current_level={current}, "
- f"next_level={next_level}, "
- f"action_required={action_required}, "
- f"phone_verification_required={phone_verification_required}, "
- f"verification_required={verification_required}, "
- f"is_verified={has_verified_phone}")
-
- return AALResponse(
- current_level=current,
- next_level=next_level,
- current_authentication_methods=[x.method for x in response.current_authentication_methods],
- action_required=action_required,
- message=message,
- phone_verification_required=phone_verification_required,
- user_created_at=user_created_at.isoformat() if user_created_at else None,
- cutoff_date=PHONE_VERIFICATION_CUTOFF_DATE.isoformat(),
- verification_required=verification_required,
- is_verified=has_verified_phone,
- factors=factors,
- )
- except Exception as e:
- raise HTTPException(status_code=400, detail=f"Failed to get AAL: {str(e)}")
diff --git a/backend/local_env_manager/api.py b/backend/local_env_manager/api.py
new file mode 100644
index 00000000..8c575699
--- /dev/null
+++ b/backend/local_env_manager/api.py
@@ -0,0 +1,44 @@
+from fastapi import APIRouter
+from utils.config import config, EnvMode
+from fastapi import HTTPException
+from typing import Dict
+from dotenv import load_dotenv, set_key, find_dotenv, dotenv_values
+from utils.logger import logger
+
+router = APIRouter(tags=["local-env-manager"])
+
+@router.get("/env-vars")
+def get_env_vars() -> Dict[str, str]:
+ if config.ENV_MODE != EnvMode.LOCAL:
+ raise HTTPException(status_code=403, detail="Env vars management only available in local mode")
+
+ try:
+ env_path = find_dotenv()
+ if not env_path:
+ logger.error("Could not find .env file")
+ return {}
+
+ return dotenv_values(env_path)
+ except Exception as e:
+ logger.error(f"Failed to get env vars: {e}")
+ raise HTTPException(status_code=500, detail=f"Failed to get env variables: {e}")
+
+@router.post("/env-vars")
+def save_env_vars(request: Dict[str, str]) -> Dict[str, str]:
+ if config.ENV_MODE != EnvMode.LOCAL:
+ raise HTTPException(status_code=403, detail="Env vars management only available in local mode")
+
+ try:
+ env_path = find_dotenv()
+ if not env_path:
+ raise HTTPException(status_code=500, detail="Could not find .env file")
+
+ for key, value in request.items():
+ set_key(env_path, key, value)
+
+ load_dotenv(override=True)
+ logger.info(f"Env variables saved successfully: {request}")
+ return {"message": "Env variables saved successfully"}
+ except Exception as e:
+ logger.error(f"Failed to save env variables: {e}")
+ raise HTTPException(status_code=500, detail=f"Failed to save env variables: {e}")
\ No newline at end of file
diff --git a/backend/services/billing.py b/backend/services/billing.py
index 1b37a553..117c79a2 100644
--- a/backend/services/billing.py
+++ b/backend/services/billing.py
@@ -497,6 +497,7 @@ async def check_billing_status(client, user_id: str) -> Tuple[bool, str, Optiona
# Calculate current month's usage
current_usage = await calculate_monthly_usage(client, user_id)
+ # TODO: also do user's AAL check
# Check if within limits
if current_usage >= tier_info['cost']:
return False, f"Monthly limit of {tier_info['cost']} dollars reached. Please upgrade your plan or wait until next month.", subscription
diff --git a/docker-compose.yaml b/docker-compose.yaml
index 094db576..5c539be5 100644
--- a/docker-compose.yaml
+++ b/docker-compose.yaml
@@ -37,7 +37,7 @@ services:
ports:
- "8000:8000"
volumes:
- - ./backend/.env:/app/.env:ro
+ - ./backend/.env:/app/.env
env_file:
- ./backend/.env
environment:
diff --git a/frontend/src/app/(dashboard)/(personalAccount)/settings/env-manager/page.tsx b/frontend/src/app/(dashboard)/(personalAccount)/settings/env-manager/page.tsx
new file mode 100644
index 00000000..a07ad48e
--- /dev/null
+++ b/frontend/src/app/(dashboard)/(personalAccount)/settings/env-manager/page.tsx
@@ -0,0 +1,9 @@
+import { isLocalMode } from "@/lib/config";
+import { Alert, AlertDescription } from "@/components/ui/alert";
+import { Shield } from "lucide-react";
+import { LocalEnvManager } from "@/components/env-manager/local-env-manager";
+
+export default function LocalEnvManagerPage() {
+
+ return