From 8a4fec8ba7df89931cb67a86be19df191f3cee71 Mon Sep 17 00:00:00 2001 From: Dave Waring Date: Mon, 19 Jan 2026 17:04:43 -0500 Subject: [PATCH 1/6] Add Library API endpoint for plugin file access Adds new API endpoints for plugins to access BrainDrive-Library content: - GET /api/v1/library/projects - List projects in Library - POST /api/v1/library/read-file - Read a specific file from a project - GET /api/v1/library/project/{slug}/context - Get all context files at once Security: - Path validation prevents directory traversal attacks - Restricted to ~/BrainDrive-Library/ only - Requires authenticated user - Allowed file types: .md, .txt, .json, .yaml, .yml This enables plugins like Research Assistant to load project context for AI-powered analysis without needing direct filesystem access. Co-Authored-By: Claude Opus 4.5 --- backend/app/api/v1/api.py | 4 +- backend/app/api/v1/endpoints/library.py | 264 ++++++++++++++++++++++++ 2 files changed, 267 insertions(+), 1 deletion(-) create mode 100644 backend/app/api/v1/endpoints/library.py diff --git a/backend/app/api/v1/api.py b/backend/app/api/v1/api.py index 2909d40..60d69b9 100644 --- a/backend/app/api/v1/api.py +++ b/backend/app/api/v1/api.py @@ -1,5 +1,5 @@ from fastapi import APIRouter -from app.api.v1.endpoints import auth, settings, ollama, ai_providers, ai_provider_settings, navigation_routes, components, conversations, tags, personas, plugin_state, demo, searxng, documents, jobs, diagnostics +from app.api.v1.endpoints import auth, settings, ollama, ai_providers, ai_provider_settings, navigation_routes, components, conversations, tags, personas, plugin_state, demo, searxng, documents, jobs, diagnostics, library from app.api.v1.internal import internal_router from app.routers import plugins from app.routes.pages import router as pages_router @@ -22,6 +22,8 @@ api_router.include_router(jobs.router, tags=["jobs"]) # Diagnostics api_router.include_router(diagnostics.router, tags=["diagnostics"]) +# Library access for plugins +api_router.include_router(library.router, tags=["library"]) # Include the plugins router (which already includes the lifecycle router) api_router.include_router(plugins.router, tags=["plugins"]) api_router.include_router(pages_router) diff --git a/backend/app/api/v1/endpoints/library.py b/backend/app/api/v1/endpoints/library.py new file mode 100644 index 0000000..4b6f487 --- /dev/null +++ b/backend/app/api/v1/endpoints/library.py @@ -0,0 +1,264 @@ +""" +BrainDrive Library API Endpoints + +Provides read access to the BrainDrive-Library for plugins that need +to access project context, documentation, and other Library content. + +Security: +- All paths are validated to stay within ~/BrainDrive-Library/ +- Path traversal attacks (../) are blocked +- Requires authenticated user +""" + +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel +from typing import List, Optional +from pathlib import Path +import os + +from app.core.auth_deps import require_user +from app.core.auth_context import AuthContext + +router = APIRouter(tags=["library"]) + +# Base path for the BrainDrive Library +LIBRARY_BASE = Path.home() / "BrainDrive-Library" + + +class ProjectInfo(BaseModel): + """Information about a Library project.""" + slug: str + name: str + path: str + has_agent_md: bool + has_spec_md: bool + has_build_plan: bool + status: Optional[str] = None + + +class FileReadRequest(BaseModel): + """Request to read a file from the Library.""" + project_slug: str + filename: str + + +class FileReadResponse(BaseModel): + """Response containing file content.""" + filename: str + content: str + exists: bool + + +def _validate_path(requested_path: Path) -> bool: + """ + Validate that a path is within the Library directory. + Prevents path traversal attacks. + """ + try: + # Resolve to absolute path and check it's within LIBRARY_BASE + resolved = requested_path.resolve() + library_resolved = LIBRARY_BASE.resolve() + return str(resolved).startswith(str(library_resolved)) + except (ValueError, RuntimeError): + return False + + +def _extract_status_from_agent_md(agent_md_path: Path) -> Optional[str]: + """Extract project status from AGENT.md if present.""" + try: + if agent_md_path.exists(): + content = agent_md_path.read_text(encoding='utf-8') + for line in content.split('\n'): + if line.startswith('**Status:**'): + return line.replace('**Status:**', '').strip() + except Exception: + pass + return None + + +@router.get("/library/projects", response_model=List[ProjectInfo]) +async def list_projects( + category: str = "active", + auth: AuthContext = Depends(require_user), +) -> List[ProjectInfo]: + """ + List all projects in the BrainDrive Library. + + Args: + category: Project category (active, completed, archived). Defaults to "active". + + Returns: + List of projects with metadata. + """ + # Validate category to prevent path traversal + allowed_categories = ["active", "completed", "archived"] + if category not in allowed_categories: + raise HTTPException( + status_code=400, + detail=f"Invalid category. Must be one of: {allowed_categories}" + ) + + projects_dir = LIBRARY_BASE / "projects" / category + + if not projects_dir.exists(): + return [] + + if not _validate_path(projects_dir): + raise HTTPException(status_code=403, detail="Access denied") + + projects = [] + try: + for item in projects_dir.iterdir(): + if item.is_dir() and not item.name.startswith('.'): + agent_md = item / "AGENT.md" + projects.append(ProjectInfo( + slug=item.name, + name=item.name.replace('-', ' ').title(), + path=str(item), + has_agent_md=agent_md.exists(), + has_spec_md=(item / "spec.md").exists(), + has_build_plan=(item / "build-plan.md").exists(), + status=_extract_status_from_agent_md(agent_md) + )) + except PermissionError: + raise HTTPException(status_code=403, detail="Permission denied reading Library") + + # Sort by name + projects.sort(key=lambda p: p.name) + return projects + + +@router.post("/library/read-file", response_model=FileReadResponse) +async def read_project_file( + request: FileReadRequest, + auth: AuthContext = Depends(require_user), +) -> FileReadResponse: + """ + Read a file from a Library project. + + Only allows reading specific documentation files (markdown, text). + + Args: + request: Contains project_slug and filename to read. + + Returns: + File content if found. + """ + # Allowed file extensions for security + allowed_extensions = {'.md', '.txt', '.json', '.yaml', '.yml'} + + # Validate filename - no path components allowed + if '/' in request.filename or '\\' in request.filename: + raise HTTPException( + status_code=400, + detail="Filename cannot contain path separators" + ) + + # Check extension + ext = Path(request.filename).suffix.lower() + if ext not in allowed_extensions: + raise HTTPException( + status_code=400, + detail=f"File type not allowed. Allowed: {allowed_extensions}" + ) + + # Validate project slug - no path components + if '/' in request.project_slug or '\\' in request.project_slug or '..' in request.project_slug: + raise HTTPException( + status_code=400, + detail="Invalid project slug" + ) + + # Build and validate full path + # Check in active, then completed, then archived + file_path = None + for category in ["active", "completed", "archived"]: + candidate = LIBRARY_BASE / "projects" / category / request.project_slug / request.filename + if candidate.exists(): + file_path = candidate + break + + if file_path is None: + # Return exists=False instead of 404 so caller can handle gracefully + return FileReadResponse( + filename=request.filename, + content="", + exists=False + ) + + # Final path validation + if not _validate_path(file_path): + raise HTTPException(status_code=403, detail="Access denied") + + try: + content = file_path.read_text(encoding='utf-8') + return FileReadResponse( + filename=request.filename, + content=content, + exists=True + ) + except PermissionError: + raise HTTPException(status_code=403, detail="Permission denied") + except UnicodeDecodeError: + raise HTTPException(status_code=400, detail="File is not valid UTF-8 text") + + +@router.get("/library/project/{project_slug}/context") +async def get_project_context( + project_slug: str, + auth: AuthContext = Depends(require_user), +) -> dict: + """ + Get full context for a project (AGENT.md + spec.md + build-plan.md). + + Convenience endpoint that reads all standard project files at once. + + Args: + project_slug: The project folder name. + + Returns: + Dictionary with content of each file (empty string if not found). + """ + # Validate project slug + if '/' in project_slug or '\\' in project_slug or '..' in project_slug: + raise HTTPException(status_code=400, detail="Invalid project slug") + + # Find project directory + project_dir = None + for category in ["active", "completed", "archived"]: + candidate = LIBRARY_BASE / "projects" / category / project_slug + if candidate.exists() and candidate.is_dir(): + project_dir = candidate + break + + if project_dir is None: + raise HTTPException(status_code=404, detail="Project not found") + + if not _validate_path(project_dir): + raise HTTPException(status_code=403, detail="Access denied") + + # Read standard files + context = { + "project_slug": project_slug, + "agent_md": "", + "spec_md": "", + "build_plan_md": "", + "research_findings_md": "", + } + + file_mapping = { + "agent_md": "AGENT.md", + "spec_md": "spec.md", + "build_plan_md": "build-plan.md", + "research_findings_md": "research-findings.md", + } + + for key, filename in file_mapping.items(): + file_path = project_dir / filename + if file_path.exists(): + try: + context[key] = file_path.read_text(encoding='utf-8') + except (PermissionError, UnicodeDecodeError): + pass # Leave as empty string + + return context From 4bc566e91dfca5e97ccda64de43809e5a5af3cce Mon Sep 17 00:00:00 2001 From: Dave Waring Date: Wed, 21 Jan 2026 15:24:25 -0500 Subject: [PATCH 2/6] Add backend plugin architecture (Phase 1 + 2.1) Implements core infrastructure for backend plugins that can register REST API endpoints dynamically without modifying core code. Phase 1 - Core Infrastructure: - Add @plugin_endpoint decorator for marking endpoint functions - Add PluginRouteLoader for dynamic route loading/unloading - Add admin endpoint POST /admin/plugins/reload-routes - Integrate route loader with app startup Phase 2.1 - Plugin Database Model: - Add plugin_type field (frontend/backend/fullstack) - Add endpoints_file field for Python endpoint file - Add route_prefix field for URL prefix - Add backend_dependencies field (JSON list of slugs) - Add Alembic migration for new fields Also includes: - Library API append-file endpoint for writing to project docs - CORS config property helpers Co-Authored-By: Claude Opus 4.5 --- backend/app/api/v1/api.py | 4 +- backend/app/api/v1/endpoints/library.py | 88 +++ backend/app/core/audit/models.py | 1 + backend/app/core/config.py | 21 + backend/app/main.py | 38 + backend/app/models/plugin.py | 36 +- backend/app/plugins/decorators.py | 344 +++++++++ backend/app/plugins/route_loader.py | 650 ++++++++++++++++++ backend/app/routers/admin.py | 114 +++ .../versions/add_backend_plugin_fields.py | 101 +++ 10 files changed, 1392 insertions(+), 5 deletions(-) create mode 100644 backend/app/plugins/decorators.py create mode 100644 backend/app/plugins/route_loader.py create mode 100644 backend/app/routers/admin.py create mode 100644 backend/migrations/versions/add_backend_plugin_fields.py diff --git a/backend/app/api/v1/api.py b/backend/app/api/v1/api.py index 60d69b9..9ca2f9f 100644 --- a/backend/app/api/v1/api.py +++ b/backend/app/api/v1/api.py @@ -1,7 +1,7 @@ from fastapi import APIRouter from app.api.v1.endpoints import auth, settings, ollama, ai_providers, ai_provider_settings, navigation_routes, components, conversations, tags, personas, plugin_state, demo, searxng, documents, jobs, diagnostics, library from app.api.v1.internal import internal_router -from app.routers import plugins +from app.routers import plugins, admin from app.routes.pages import router as pages_router api_router = APIRouter(prefix="/api/v1") @@ -26,6 +26,8 @@ api_router.include_router(library.router, tags=["library"]) # Include the plugins router (which already includes the lifecycle router) api_router.include_router(plugins.router, tags=["plugins"]) +# Admin endpoints (require admin authentication) +api_router.include_router(admin.router, tags=["admin"]) api_router.include_router(pages_router) # Internal endpoints (service-to-service, not in OpenAPI schema) diff --git a/backend/app/api/v1/endpoints/library.py b/backend/app/api/v1/endpoints/library.py index 4b6f487..83e892f 100644 --- a/backend/app/api/v1/endpoints/library.py +++ b/backend/app/api/v1/endpoints/library.py @@ -49,6 +49,20 @@ class FileReadResponse(BaseModel): exists: bool +class FileAppendRequest(BaseModel): + """Request to append content to a Library file.""" + project_slug: str + filename: str + content: str + + +class FileAppendResponse(BaseModel): + """Response after appending to a file.""" + filename: str + success: bool + message: str + + def _validate_path(requested_path: Path) -> bool: """ Validate that a path is within the Library directory. @@ -262,3 +276,77 @@ async def get_project_context( pass # Leave as empty string return context + + +@router.post("/library/append-file", response_model=FileAppendResponse) +async def append_to_project_file( + request: FileAppendRequest, + auth: AuthContext = Depends(require_user), +) -> FileAppendResponse: + """ + Append content to a Library project file. + + Only allows writing to specific documentation files for security: + - research-findings.md + - ideas.md + + Args: + request: Contains project_slug, filename, and content to append. + + Returns: + Success status and message. + """ + # Strictly allowed files for writing (security) + allowed_write_files = {"research-findings.md", "ideas.md"} + + # Validate filename + if request.filename not in allowed_write_files: + raise HTTPException( + status_code=400, + detail=f"Writing to this file is not allowed. Allowed: {allowed_write_files}" + ) + + # Validate project slug - no path components + if '/' in request.project_slug or '\\' in request.project_slug or '..' in request.project_slug: + raise HTTPException( + status_code=400, + detail="Invalid project slug" + ) + + # Find project directory (only in active projects for writing) + project_dir = LIBRARY_BASE / "projects" / "active" / request.project_slug + + if not project_dir.exists() or not project_dir.is_dir(): + raise HTTPException(status_code=404, detail="Project not found in active projects") + + if not _validate_path(project_dir): + raise HTTPException(status_code=403, detail="Access denied") + + # Build file path + file_path = project_dir / request.filename + + # Final path validation + if not _validate_path(file_path): + raise HTTPException(status_code=403, detail="Access denied") + + try: + # Create file with header if it doesn't exist + if not file_path.exists(): + header = f"# {request.filename.replace('.md', '').replace('-', ' ').title()}\n\n" + header += "Findings and notes captured from research sessions.\n\n---\n" + file_path.write_text(header, encoding='utf-8') + + # Append the content + with open(file_path, 'a', encoding='utf-8') as f: + f.write(request.content) + + return FileAppendResponse( + filename=request.filename, + success=True, + message=f"Content appended to {request.filename}" + ) + + except PermissionError: + raise HTTPException(status_code=403, detail="Permission denied writing to file") + except Exception as e: + raise HTTPException(status_code=500, detail=f"Failed to write file: {str(e)}") diff --git a/backend/app/core/audit/models.py b/backend/app/core/audit/models.py index d7b0749..b0b4044 100644 --- a/backend/app/core/audit/models.py +++ b/backend/app/core/audit/models.py @@ -38,6 +38,7 @@ class AuditEventType(str, Enum): ADMIN_SETTINGS_UPDATED = "admin.settings_updated" ADMIN_SETTINGS_DELETED = "admin.settings_deleted" ADMIN_DIAGNOSTICS_ACCESSED = "admin.diagnostics_accessed" + ADMIN_PLUGIN_ROUTES_RELOADED = "admin.plugin_routes_reloaded" # Plugin lifecycle events PLUGIN_ENABLED = "plugin.enabled" diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 3942ef2..a618c21 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -128,5 +128,26 @@ def parse_hosts(cls, v): "extra": "ignore", } + # Properties for CORS lists (used by main.py) + @property + def cors_origins_list(self) -> List[str]: + """Return CORS origins as a list.""" + return self.CORS_ORIGINS if self.CORS_ORIGINS else ["*"] + + @property + def cors_methods_list(self) -> List[str]: + """Return CORS methods as a list.""" + return self.CORS_METHODS + + @property + def cors_headers_list(self) -> List[str]: + """Return CORS headers as a list.""" + return self.CORS_HEADERS + + @property + def cors_expose_headers_list(self) -> List[str]: + """Return CORS expose headers as a list.""" + return self.CORS_EXPOSE_HEADERS + settings = Settings() __all__ = ["settings"] diff --git a/backend/app/main.py b/backend/app/main.py index cd00c46..fd1736a 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -7,6 +7,7 @@ from app.routers.plugins import plugin_manager from app.plugins.service_installler.start_stop_plugin_services import start_plugin_services_on_startup, stop_all_plugin_services_on_shutdown from app.core.job_manager_provider import initialize_job_manager, shutdown_job_manager +from app.plugins.route_loader import get_plugin_loader, initialize_plugin_routes import logging import time import structlog @@ -30,10 +31,47 @@ async def startup_event(): """Initialize required settings on application startup.""" logger.info("Initializing application settings...") from app.init_settings import init_ollama_settings + from app.core.database import get_db + await init_ollama_settings() await initialize_job_manager() # Start plugin services await start_plugin_services_on_startup() + + # Initialize backend plugin routes + logger.info("Initializing backend plugin routes...") + try: + # Set the FastAPI app on the route loader + loader = get_plugin_loader() + loader.set_app(app) + + # Load routes for all enabled backend plugins + async for db in get_db(): + result = await loader.reload_routes(db) + + if result.errors: + logger.warning( + "Backend plugin route initialization completed with errors", + loaded_count=len(result.loaded), + error_count=len(result.errors), + total_routes=result.total_routes, + errors=[e.to_dict() for e in result.errors], + ) + else: + logger.info( + "Backend plugin routes initialized successfully", + loaded_plugins=result.loaded, + total_routes=result.total_routes, + ) + break # Only need one session + except Exception as e: + # Log error but don't fail startup - backend plugins are optional + logger.error( + "Failed to initialize backend plugin routes", + error=str(e), + exception_type=type(e).__name__, + ) + logger.info("Settings initialization completed") diff --git a/backend/app/models/plugin.py b/backend/app/models/plugin.py index 8bad0dd..43d591c 100644 --- a/backend/app/models/plugin.py +++ b/backend/app/models/plugin.py @@ -1,13 +1,21 @@ -from sqlalchemy import Column, String, Integer, Boolean, ForeignKey, Text, JSON, UniqueConstraint, TIMESTAMP, DateTime +from sqlalchemy import Column, String, Integer, Boolean, ForeignKey, Text, JSON, UniqueConstraint, TIMESTAMP, DateTime, Enum import sqlalchemy from sqlalchemy.orm import relationship from sqlalchemy.sql import func from datetime import datetime, UTC import json +import enum from app.models.base import Base +class PluginType(str, enum.Enum): + """Enum for plugin types (frontend, backend, fullstack).""" + FRONTEND = "frontend" + BACKEND = "backend" + FULLSTACK = "fullstack" + + class Plugin(Base): """SQLAlchemy model for plugins.""" @@ -43,6 +51,12 @@ class Plugin(Base): installation_type = Column(String(20), default='local') # Installation type: local or remote permissions = Column(Text, nullable=True) # JSON array of required permissions + # Backend plugin fields + plugin_type = Column(String(20), default="frontend") # frontend, backend, fullstack + endpoints_file = Column(String, nullable=True) # e.g., "endpoints.py" + route_prefix = Column(String, nullable=True) # e.g., "/library" + backend_dependencies = Column(Text, nullable=True) # JSON list of plugin slugs + # JSON fields config_fields = Column(Text) # Stored as JSON string messages = Column(Text) # Stored as JSON string @@ -100,8 +114,12 @@ def to_dict(self): "updateAvailable": self.update_available, "latestVersion": self.latest_version, "installationType": self.installation_type, + # Backend plugin fields + "pluginType": self.plugin_type, + "endpointsFile": self.endpoints_file, + "routePrefix": self.route_prefix, } - + # Deserialize JSON fields if self.config_fields: result["configFields"] = json.loads(self.config_fields) @@ -128,6 +146,11 @@ def to_dict(self): else: result["requiredServicesRuntime"] = [] + if self.backend_dependencies: + result["backendDependencies"] = json.loads(self.backend_dependencies) + else: + result["backendDependencies"] = [] + return result @classmethod @@ -152,6 +175,11 @@ def from_dict(cls, data): "updateAvailable": "update_available", "latestVersion": "latest_version", "installationType": "installation_type", + # Backend plugin fields + "pluginType": "plugin_type", + "endpointsFile": "endpoints_file", + "routePrefix": "route_prefix", + "backendDependencies": "backend_dependencies", } # Create a new dictionary with snake_case keys @@ -163,8 +191,8 @@ def from_dict(cls, data): # Convert camelCase to snake_case db_key = ''.join(['_' + c.lower() if c.isupper() else c for c in key]).lstrip('_') - # Handle special fields - if db_key in ["config_fields", "messages", "dependencies", "permissions"] and value is not None: + # Handle special fields (JSON serialization) + if db_key in ["config_fields", "messages", "dependencies", "permissions", "backend_dependencies"] and value is not None: db_data[db_key] = json.dumps(value) else: db_data[db_key] = value diff --git a/backend/app/plugins/decorators.py b/backend/app/plugins/decorators.py new file mode 100644 index 0000000..c71e328 --- /dev/null +++ b/backend/app/plugins/decorators.py @@ -0,0 +1,344 @@ +""" +Plugin endpoint decorators for BrainDrive backend plugins. + +This module provides the @plugin_endpoint decorator for marking functions +as plugin REST API endpoints, along with supporting classes and utilities. + +Usage: + from app.plugins.decorators import plugin_endpoint, PluginRequest + + @plugin_endpoint('/projects', methods=['GET']) + async def list_projects(request: PluginRequest): + user_id = request.user_id + return {"projects": [...]} + + @plugin_endpoint('/admin/config', methods=['POST'], admin_only=True) + async def update_config(request: PluginRequest): + # Only admin users can access + return {"status": "updated"} +""" + +import re +from dataclasses import dataclass +from functools import wraps +from typing import List, Optional, Set, Callable, Any + +from fastapi import Request + +import structlog + +logger = structlog.get_logger() + + +# Regex pattern for valid plugin slugs +SLUG_PATTERN = re.compile(r'^[a-z0-9]+(-[a-z0-9]+)*$') + +# Reserved path prefixes that plugins cannot use +RESERVED_PREFIXES = ('/api/', '/admin/', '/_') + + +class PathValidationError(ValueError): + """Raised when an endpoint path fails validation.""" + pass + + +def validate_endpoint_path(path: str) -> str: + """ + Validate and normalize a plugin endpoint path. + + Ensures the path: + - Starts with '/' + - Does not use reserved prefixes (/api/, /admin/) + - Does not contain path traversal sequences (..) + - Does not contain double slashes (//) + - Is properly normalized (trailing slash removed) + + Args: + path: The endpoint path to validate + + Returns: + The normalized path + + Raises: + PathValidationError: If the path is invalid + """ + if not isinstance(path, str): + raise PathValidationError(f"Path must be a string, got {type(path).__name__}") + + if not path: + raise PathValidationError("Path cannot be empty") + + # Must start with / + if not path.startswith('/'): + raise PathValidationError(f"Path must start with '/': {path}") + + # Cannot use reserved prefixes + for prefix in RESERVED_PREFIXES: + if path.startswith(prefix) or path.lower().startswith(prefix): + raise PathValidationError(f"Path cannot use reserved prefix '{prefix}': {path}") + + # Cannot contain path traversal + if '..' in path: + raise PathValidationError(f"Path cannot contain '..': {path}") + + # Cannot contain double slashes (except potentially at start, which we check above) + if '//' in path: + raise PathValidationError(f"Path cannot contain '//': {path}") + + # Normalize: remove trailing slash (but keep single /) + normalized = path.rstrip('/') if path != '/' else '/' + + return normalized + + +def validate_slug(slug: str) -> str: + """ + Validate a plugin slug. + + Slugs must be lowercase alphanumeric with hyphens only. + Pattern: ^[a-z0-9]+(-[a-z0-9]+)*$ + + Args: + slug: The plugin slug to validate + + Returns: + The validated slug + + Raises: + ValueError: If the slug is invalid + """ + if not isinstance(slug, str): + raise ValueError(f"Slug must be a string, got {type(slug).__name__}") + + if not slug: + raise ValueError("Slug cannot be empty") + + if not SLUG_PATTERN.match(slug): + raise ValueError( + f"Invalid slug '{slug}'. Slugs must be lowercase alphanumeric " + "with hyphens only (e.g., 'my-plugin', 'braindrive-library')" + ) + + return slug + + +@dataclass +class PluginRequest: + """ + Wrapper around FastAPI Request providing convenient access to plugin context. + + This class wraps a FastAPI Request and provides easy access to: + - User authentication context (user_id, username, is_admin, roles) + - The underlying FastAPI Request for advanced use cases + + Attributes: + request: The underlying FastAPI Request object + user_id: The authenticated user's ID + username: The authenticated user's username + is_admin: Whether the user has admin privileges + roles: Set of role names assigned to the user + tenant_id: Optional tenant ID for multi-tenant deployments + """ + request: Request + user_id: str + username: str + is_admin: bool + roles: Set[str] + tenant_id: Optional[str] = None + + @classmethod + def from_auth_context(cls, request: Request, auth_context: Any) -> 'PluginRequest': + """ + Create a PluginRequest from a FastAPI Request and AuthContext. + + Args: + request: The FastAPI Request object + auth_context: The AuthContext from authentication + + Returns: + A new PluginRequest instance + """ + return cls( + request=request, + user_id=auth_context.user_id, + username=auth_context.username, + is_admin=auth_context.is_admin, + roles=auth_context.roles, + tenant_id=auth_context.tenant_id, + ) + + @property + def headers(self): + """Access request headers.""" + return self.request.headers + + @property + def query_params(self): + """Access query parameters.""" + return self.request.query_params + + @property + def path_params(self): + """Access path parameters.""" + return self.request.path_params + + @property + def cookies(self): + """Access cookies.""" + return self.request.cookies + + @property + def client(self): + """Access client information.""" + return self.request.client + + async def json(self): + """Parse request body as JSON.""" + return await self.request.json() + + async def body(self): + """Get raw request body.""" + return await self.request.body() + + async def form(self): + """Parse request body as form data.""" + return await self.request.form() + + +@dataclass +class EndpointMetadata: + """ + Metadata stored on decorated plugin endpoint functions. + + This dataclass holds all the configuration for a plugin endpoint, + used by the route loader to properly mount the endpoint. + """ + path: str + methods: List[str] + admin_only: bool + summary: Optional[str] = None + description: Optional[str] = None + tags: Optional[List[str]] = None + response_model: Optional[Any] = None + status_code: int = 200 + + +def plugin_endpoint( + path: str, + methods: List[str] = None, + admin_only: bool = False, + summary: Optional[str] = None, + description: Optional[str] = None, + tags: Optional[List[str]] = None, + response_model: Optional[Any] = None, + status_code: int = 200, +) -> Callable: + """ + Decorator to mark a function as a plugin endpoint. + + This decorator validates the endpoint path and stores metadata on the + function for later use by the plugin route loader. + + Args: + path: The endpoint path (e.g., '/projects', '/projects/{id}') + Must start with '/' and cannot use reserved prefixes. + methods: HTTP methods to support (default: ['GET']) + admin_only: If True, only admin users can access (default: False) + summary: Short summary for OpenAPI docs + description: Detailed description for OpenAPI docs + tags: Tags for OpenAPI docs grouping + response_model: Pydantic model for response validation + status_code: Default status code (default: 200) + + Returns: + Decorated function with _plugin_endpoint_metadata attribute + + Raises: + PathValidationError: If the path is invalid + + Example: + @plugin_endpoint('/projects', methods=['GET']) + async def list_projects(request: PluginRequest): + return {"projects": [...]} + + @plugin_endpoint('/projects', methods=['POST'], admin_only=True) + async def create_project(request: PluginRequest): + data = await request.json() + return {"id": "new-project"} + """ + # Default to GET if no methods specified + if methods is None: + methods = ['GET'] + + # Normalize methods to uppercase + methods = [m.upper() for m in methods] + + # Validate methods + valid_methods = {'GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'HEAD', 'OPTIONS'} + invalid_methods = set(methods) - valid_methods + if invalid_methods: + raise ValueError(f"Invalid HTTP methods: {invalid_methods}") + + # Validate path at decoration time + validated_path = validate_endpoint_path(path) + + def decorator(func: Callable) -> Callable: + # Create metadata + metadata = EndpointMetadata( + path=validated_path, + methods=methods, + admin_only=admin_only, + summary=summary or func.__doc__, + description=description, + tags=tags, + response_model=response_model, + status_code=status_code, + ) + + # Store metadata on function + func._plugin_endpoint = True + func._plugin_endpoint_metadata = metadata + + # Also store individual attributes for backward compatibility + func._path = validated_path + func._methods = methods + func._admin_only = admin_only + + @wraps(func) + async def wrapper(*args, **kwargs): + return await func(*args, **kwargs) + + # Copy metadata to wrapper + wrapper._plugin_endpoint = True + wrapper._plugin_endpoint_metadata = metadata + wrapper._path = validated_path + wrapper._methods = methods + wrapper._admin_only = admin_only + + return wrapper + + return decorator + + +def get_plugin_endpoints(module) -> List[Callable]: + """ + Discover all plugin endpoints in a module. + + Scans a module for functions decorated with @plugin_endpoint + and returns them as a list. + + Args: + module: The Python module to scan + + Returns: + List of decorated endpoint functions + """ + endpoints = [] + + for name in dir(module): + obj = getattr(module, name) + if callable(obj) and getattr(obj, '_plugin_endpoint', False): + endpoints.append(obj) + logger.debug(f"Discovered plugin endpoint: {name}", path=obj._path) + + return endpoints diff --git a/backend/app/plugins/route_loader.py b/backend/app/plugins/route_loader.py new file mode 100644 index 0000000..1ea8d57 --- /dev/null +++ b/backend/app/plugins/route_loader.py @@ -0,0 +1,650 @@ +""" +Plugin Route Loader for BrainDrive backend plugins. + +This module provides the PluginRouteLoader class that handles dynamic loading, +unloading, and mounting of plugin routes without server restart. + +Key features: +- Atomic route swapping with staging area +- Async locking to prevent concurrent reloads +- Namespaced module imports to prevent collisions +- Automatic sys.modules cleanup on unload +- Skip-on-failure: valid plugins load even if one fails + +Usage: + from app.plugins.route_loader import get_plugin_loader + + loader = get_plugin_loader() + result = await loader.reload_routes(app, db) +""" + +import asyncio +import importlib.util +import sys +from dataclasses import dataclass, field +from pathlib import Path +from typing import Dict, List, Any, Optional, Callable, Set + +from fastapi import APIRouter, FastAPI, Depends, Request +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +import structlog + +from app.plugins.decorators import ( + get_plugin_endpoints, + PluginRequest, + EndpointMetadata, +) +from app.core.auth_deps import require_user, require_admin +from app.core.auth_context import AuthContext +from app.models.plugin import Plugin + +logger = structlog.get_logger() + + +# Plugin route prefix - all plugin routes are mounted here +PLUGIN_ROUTE_PREFIX = "/api/v1/plugins" + + +@dataclass +class PluginLoadError: + """ + Represents an error that occurred while loading a plugin. + + Attributes: + plugin_slug: The slug of the plugin that failed to load + error: Human-readable error message + exception_type: The type of exception that occurred + details: Additional error details (optional) + """ + plugin_slug: str + error: str + exception_type: str = "Unknown" + details: Optional[Dict[str, Any]] = None + + def to_dict(self) -> Dict[str, Any]: + """Convert to dictionary for API responses.""" + result = { + "plugin_slug": self.plugin_slug, + "error": self.error, + "exception_type": self.exception_type, + } + if self.details: + result["details"] = self.details + return result + + +@dataclass +class ReloadResult: + """ + Result of a route reload operation. + + Attributes: + loaded: List of plugin slugs that were successfully loaded + unloaded: List of plugin slugs that were unloaded + errors: List of errors that occurred during loading + total_routes: Total number of routes loaded + """ + loaded: List[str] = field(default_factory=list) + unloaded: List[str] = field(default_factory=list) + errors: List[PluginLoadError] = field(default_factory=list) + total_routes: int = 0 + + def to_dict(self) -> Dict[str, Any]: + """Convert to dictionary for API responses.""" + return { + "loaded": self.loaded, + "unloaded": self.unloaded, + "errors": [e.to_dict() for e in self.errors], + "total_routes": self.total_routes, + "success": len(self.errors) == 0, + } + + +@dataclass +class PluginInfo: + """ + Information about a backend plugin needed for route loading. + + Extracted from database Plugin model and filesystem. + """ + slug: str + name: str + version: str + plugin_type: str + endpoints_file: str + route_prefix: str + plugin_path: Path + enabled: bool = True + + @property + def module_name(self) -> str: + """Generate the namespaced module name for this plugin.""" + module_slug = self.slug.replace('-', '_') + major_version = self.version.split('.')[0] + return f"braindrive.plugins.{module_slug}.v{major_version}.endpoints" + + @property + def endpoints_path(self) -> Path: + """Get the full path to the endpoints file.""" + return self.plugin_path / self.endpoints_file + + +class PluginRouteLoader: + """ + Manages dynamic loading and mounting of plugin routes. + + This class provides thread-safe route reloading with atomic swapping, + ensuring that in-flight requests complete normally while new requests + use updated routes. + + Thread Safety: + All route modifications are protected by an async lock to prevent + concurrent reload operations. + + Atomic Swap: + Routes are loaded into a staging area and validated before being + swapped into the active route table. + + Error Handling: + If a plugin fails to load, it is skipped and other valid plugins + are still loaded. Errors are collected and returned. + """ + + def __init__(self): + """Initialize the route loader.""" + self._reload_lock = asyncio.Lock() + self._active_routes: Dict[str, APIRouter] = {} + self._mounted_prefixes: Set[str] = set() + self._app: Optional[FastAPI] = None + logger.info("PluginRouteLoader initialized") + + def set_app(self, app: FastAPI) -> None: + """ + Set the FastAPI application instance. + + Must be called during app startup before reload_routes. + + Args: + app: The FastAPI application instance + """ + self._app = app + logger.info("FastAPI app registered with PluginRouteLoader") + + async def reload_routes(self, db: AsyncSession) -> ReloadResult: + """ + Reload all backend plugin routes. + + This method: + 1. Acquires an exclusive lock + 2. Queries for enabled backend plugins + 3. Loads routes into a staging area + 4. Validates loaded routes + 5. Atomically swaps staging to active + 6. Cleans up old modules + + Args: + db: Database session for querying plugins + + Returns: + ReloadResult with loaded plugins, unloaded plugins, and any errors + """ + if self._app is None: + raise RuntimeError("FastAPI app not set. Call set_app() first.") + + async with self._reload_lock: + logger.info("Starting plugin route reload") + + result = ReloadResult() + staging: Dict[str, APIRouter] = {} + + # Get enabled backend plugins + plugins = await self._get_enabled_backend_plugins(db) + logger.info(f"Found {len(plugins)} enabled backend plugins") + + # Track which plugins were previously loaded + previously_loaded = set(self._active_routes.keys()) + + # Load each plugin's routes into staging + for plugin_info in plugins: + try: + router = await self._load_plugin_routes(plugin_info) + if router: + staging[plugin_info.slug] = router + result.total_routes += len(router.routes) + logger.info( + f"Loaded plugin routes", + plugin=plugin_info.slug, + routes=len(router.routes) + ) + except Exception as e: + error = PluginLoadError( + plugin_slug=plugin_info.slug, + error=str(e), + exception_type=type(e).__name__, + ) + result.errors.append(error) + logger.error( + f"Failed to load plugin routes", + plugin=plugin_info.slug, + error=str(e), + exc_info=True + ) + + # Perform atomic swap + await self._swap_routes(staging) + + # Record results + result.loaded = list(staging.keys()) + result.unloaded = list(previously_loaded - set(staging.keys())) + + # Clean up modules for unloaded plugins + for slug in result.unloaded: + self._unload_plugin_module(slug) + + logger.info( + "Plugin route reload complete", + loaded=len(result.loaded), + unloaded=len(result.unloaded), + errors=len(result.errors), + total_routes=result.total_routes + ) + + return result + + async def _get_enabled_backend_plugins(self, db: AsyncSession) -> List[PluginInfo]: + """ + Query the database for enabled backend plugins. + + Args: + db: Database session + + Returns: + List of PluginInfo objects for enabled backend/fullstack plugins + """ + # Query for plugins with type 'backend' or 'fullstack' that are enabled + query = select(Plugin).where( + Plugin.enabled == True, + Plugin.type.in_(['backend', 'fullstack']) + ) + + result = await db.execute(query) + plugins = result.scalars().all() + + plugin_infos = [] + for plugin in plugins: + # Determine plugin path + # Standard path: backend/plugins/shared/{slug}/v{major_version}/ + major_version = plugin.version.split('.')[0] + plugin_path = Path(__file__).parent.parent.parent / "plugins" / "shared" / plugin.plugin_slug / f"v{major_version}" + + # Check if plugin has endpoints file configured + # For now, default to 'endpoints.py' if not specified + endpoints_file = "endpoints.py" + + # Check if the plugin metadata specifies an endpoints file + # This would come from the lifecycle_manager.py plugin_data + # For MVP, we use convention over configuration + + # Get route prefix from plugin metadata or default to plugin slug + route_prefix = f"/{plugin.plugin_slug}" + + plugin_info = PluginInfo( + slug=plugin.plugin_slug, + name=plugin.name, + version=plugin.version, + plugin_type=plugin.type, + endpoints_file=endpoints_file, + route_prefix=route_prefix, + plugin_path=plugin_path, + enabled=plugin.enabled, + ) + plugin_infos.append(plugin_info) + + return plugin_infos + + async def _load_plugin_routes(self, plugin_info: PluginInfo) -> Optional[APIRouter]: + """ + Load routes from a single plugin. + + This method: + 1. Loads the plugin's endpoints module using namespaced imports + 2. Discovers decorated endpoint functions + 3. Creates a FastAPI router with the endpoints + + Args: + plugin_info: Information about the plugin to load + + Returns: + APIRouter with the plugin's endpoints, or None if no endpoints found + """ + endpoints_path = plugin_info.endpoints_path + + # Check if endpoints file exists + if not endpoints_path.exists(): + logger.warning( + f"No endpoints file found for plugin", + plugin=plugin_info.slug, + path=str(endpoints_path) + ) + return None + + # Load the module with namespaced name + module = self._load_plugin_module( + plugin_info.slug, + plugin_info.version, + endpoints_path + ) + + if module is None: + return None + + # Discover decorated endpoints + endpoints = get_plugin_endpoints(module) + + if not endpoints: + logger.info( + f"No endpoints decorated in plugin", + plugin=plugin_info.slug + ) + return None + + # Create router for this plugin + router = self._create_router_from_endpoints(plugin_info, endpoints) + + return router + + def _load_plugin_module( + self, + plugin_slug: str, + version: str, + file_path: Path + ) -> Optional[Any]: + """ + Load a plugin module with a namespaced name. + + Uses importlib to load the module with a unique name to prevent + collisions in sys.modules. + + Args: + plugin_slug: The plugin's slug + version: The plugin's version + file_path: Path to the Python file to load + + Returns: + The loaded module, or None if loading failed + """ + # Convert slug to valid Python identifier + module_slug = plugin_slug.replace('-', '_') + major_version = version.split('.')[0] + + # Create namespaced module name + module_name = f"braindrive.plugins.{module_slug}.v{major_version}.endpoints" + + try: + # Create module spec + spec = importlib.util.spec_from_file_location(module_name, file_path) + if spec is None or spec.loader is None: + logger.error(f"Could not create module spec", path=str(file_path)) + return None + + # Create module from spec + module = importlib.util.module_from_spec(spec) + + # Register in sys.modules before executing + sys.modules[module_name] = module + + # Execute the module + spec.loader.exec_module(module) + + logger.debug( + f"Loaded plugin module", + plugin=plugin_slug, + module_name=module_name + ) + + return module + + except Exception as e: + # Clean up on failure + if module_name in sys.modules: + del sys.modules[module_name] + logger.error( + f"Failed to load plugin module", + plugin=plugin_slug, + error=str(e), + exc_info=True + ) + raise + + def _unload_plugin_module(self, plugin_slug: str) -> None: + """ + Unload a plugin's modules from sys.modules. + + Removes all modules with the plugin's namespace prefix. + + Args: + plugin_slug: The plugin's slug + """ + module_slug = plugin_slug.replace('-', '_') + prefix = f"braindrive.plugins.{module_slug}" + + # Find and remove all modules with this prefix + to_remove = [k for k in sys.modules if k.startswith(prefix)] + + for key in to_remove: + del sys.modules[key] + logger.debug(f"Unloaded module", module=key) + + if to_remove: + logger.info( + f"Unloaded plugin modules", + plugin=plugin_slug, + count=len(to_remove) + ) + + def _create_router_from_endpoints( + self, + plugin_info: PluginInfo, + endpoints: List[Callable] + ) -> APIRouter: + """ + Create a FastAPI router from decorated endpoint functions. + + Args: + plugin_info: Information about the plugin + endpoints: List of decorated endpoint functions + + Returns: + Configured APIRouter with all endpoints mounted + """ + router = APIRouter( + prefix=plugin_info.route_prefix, + tags=[f"plugin-{plugin_info.slug}"], + ) + + for endpoint_func in endpoints: + metadata: EndpointMetadata = endpoint_func._plugin_endpoint_metadata + + # Create wrapper that injects PluginRequest + wrapped = self._create_endpoint_wrapper(endpoint_func, metadata.admin_only) + + # Determine dependencies + if metadata.admin_only: + dependencies = [Depends(require_admin)] + else: + dependencies = [Depends(require_user)] + + # Add route for each method + for method in metadata.methods: + router.add_api_route( + path=metadata.path, + endpoint=wrapped, + methods=[method], + summary=metadata.summary, + description=metadata.description, + tags=metadata.tags, + response_model=metadata.response_model, + status_code=metadata.status_code, + dependencies=dependencies, + ) + + return router + + def _create_endpoint_wrapper( + self, + endpoint_func: Callable, + admin_only: bool + ) -> Callable: + """ + Create a wrapper function that injects PluginRequest. + + The wrapper extracts authentication context and creates a PluginRequest + to pass to the original endpoint function. + + Args: + endpoint_func: The original endpoint function + admin_only: Whether admin authentication is required + + Returns: + Wrapped async function compatible with FastAPI + """ + async def wrapper( + request: Request, + auth: AuthContext = Depends(require_admin if admin_only else require_user), + **kwargs + ): + # Create PluginRequest from auth context + plugin_request = PluginRequest.from_auth_context(request, auth) + + # Call the original endpoint + return await endpoint_func(plugin_request, **kwargs) + + # Preserve function metadata + wrapper.__name__ = endpoint_func.__name__ + wrapper.__doc__ = endpoint_func.__doc__ + + return wrapper + + async def _swap_routes(self, staging: Dict[str, APIRouter]) -> None: + """ + Atomically swap staged routes into the active route table. + + This method: + 1. Unmounts all currently mounted plugin routes + 2. Mounts all routes from staging + 3. Updates the active routes tracking + + Note: There is a brief period (~100-500ms) during swap where + plugin routes may be unavailable. This is acceptable. + + Args: + staging: Dictionary of plugin_slug -> APIRouter to mount + """ + if self._app is None: + raise RuntimeError("FastAPI app not set") + + # Unmount old plugin routes + # FastAPI doesn't have a direct unmount, so we need to rebuild + # For now, we track mounted prefixes and skip if already mounted + + # In FastAPI, routes are stored in app.routes + # We need to remove old plugin routes and add new ones + + # Get the base prefix for plugin routes + base_prefix = PLUGIN_ROUTE_PREFIX + + # Remove old plugin routes from app.routes + # We identify them by their path starting with the plugin prefix + routes_to_remove = [] + for route in self._app.routes: + if hasattr(route, 'path') and route.path.startswith(base_prefix): + routes_to_remove.append(route) + + for route in routes_to_remove: + self._app.routes.remove(route) + + logger.debug(f"Removed {len(routes_to_remove)} old plugin routes") + + # Mount new plugin routers + for plugin_slug, router in staging.items(): + # Create full prefix: /api/v1/plugins/{plugin_slug}{route_prefix} + # The router already has the route_prefix, so we just add the base + full_prefix = f"{base_prefix}/{plugin_slug}" + + # Include the router + self._app.include_router(router, prefix=full_prefix) + + logger.debug( + f"Mounted plugin router", + plugin=plugin_slug, + prefix=full_prefix + ) + + # Update tracking + self._active_routes = staging + self._mounted_prefixes = {f"{base_prefix}/{slug}" for slug in staging.keys()} + + logger.info(f"Swapped routes: {len(staging)} plugins mounted") + + def get_active_plugins(self) -> List[str]: + """ + Get list of currently loaded plugin slugs. + + Returns: + List of plugin slugs with active routes + """ + return list(self._active_routes.keys()) + + def is_plugin_loaded(self, plugin_slug: str) -> bool: + """ + Check if a plugin's routes are currently loaded. + + Args: + plugin_slug: The plugin slug to check + + Returns: + True if the plugin's routes are loaded + """ + return plugin_slug in self._active_routes + + +# Singleton instance +_plugin_loader: Optional[PluginRouteLoader] = None +_loader_lock = asyncio.Lock() + + +def get_plugin_loader() -> PluginRouteLoader: + """ + Get the singleton PluginRouteLoader instance. + + Creates the instance on first call. + + Returns: + The global PluginRouteLoader instance + """ + global _plugin_loader + + if _plugin_loader is None: + _plugin_loader = PluginRouteLoader() + + return _plugin_loader + + +async def initialize_plugin_routes(app: FastAPI, db: AsyncSession) -> ReloadResult: + """ + Initialize plugin routes on application startup. + + Convenience function to set up the route loader and load initial routes. + + Args: + app: The FastAPI application instance + db: Database session + + Returns: + ReloadResult from the initial route load + """ + loader = get_plugin_loader() + loader.set_app(app) + return await loader.reload_routes(db) diff --git a/backend/app/routers/admin.py b/backend/app/routers/admin.py new file mode 100644 index 0000000..8830a59 --- /dev/null +++ b/backend/app/routers/admin.py @@ -0,0 +1,114 @@ +""" +Admin API Router. + +Provides administrative endpoints for system management. +All endpoints require admin authentication. +""" + +from fastapi import APIRouter, Depends, Request +from sqlalchemy.ext.asyncio import AsyncSession +import structlog + +from app.core.database import get_db +from app.core.auth_deps import require_admin +from app.core.auth_context import AuthContext +from app.core.audit import audit_logger, AuditEventType +from app.plugins.route_loader import get_plugin_loader + +logger = structlog.get_logger() + +router = APIRouter(prefix="/admin", tags=["admin"]) + + +@router.post("/plugins/reload-routes") +async def reload_plugin_routes( + request: Request, + db: AsyncSession = Depends(get_db), + auth: AuthContext = Depends(require_admin), +): + """ + Reload all backend plugin routes. + + This endpoint triggers a full reload of all enabled backend plugin routes. + It uses an atomic swap pattern to minimize disruption to in-flight requests. + + Requires admin authentication. + + Returns: + JSON object with: + - success: bool - True if reload completed without errors + - loaded: list[str] - Plugin slugs that were successfully loaded + - unloaded: list[str] - Plugin slugs that were unloaded + - errors: list[dict] - Error details for any failed plugins + - total_routes: int - Total number of routes loaded + """ + logger.info( + "Admin route reload requested", + user_id=auth.user_id, + username=auth.username, + ) + + # Get the plugin route loader + loader = get_plugin_loader() + + # Perform the reload + result = await loader.reload_routes(db) + + # Log the result + if result.errors: + logger.warning( + "Plugin route reload completed with errors", + user_id=auth.user_id, + loaded=result.loaded, + unloaded=result.unloaded, + error_count=len(result.errors), + total_routes=result.total_routes, + ) + else: + logger.info( + "Plugin route reload completed successfully", + user_id=auth.user_id, + loaded=result.loaded, + unloaded=result.unloaded, + total_routes=result.total_routes, + ) + + # Audit log the reload + await audit_logger.log_admin_action( + request=request, + user_id=auth.user_id, + event_type=AuditEventType.ADMIN_PLUGIN_ROUTES_RELOADED, + resource_type="plugin_routes", + metadata={ + "loaded_count": len(result.loaded), + "unloaded_count": len(result.unloaded), + "error_count": len(result.errors), + "total_routes": result.total_routes, + "loaded_plugins": result.loaded, + }, + ) + + return result.to_dict() + + +@router.get("/plugins/routes") +async def get_active_plugin_routes( + auth: AuthContext = Depends(require_admin), +): + """ + Get information about currently active plugin routes. + + Returns a list of plugin slugs that have routes currently mounted. + + Requires admin authentication. + + Returns: + JSON object with: + - plugins: list[str] - Slugs of plugins with active routes + """ + loader = get_plugin_loader() + active_plugins = loader.get_active_plugins() + + return { + "plugins": active_plugins, + } diff --git a/backend/migrations/versions/add_backend_plugin_fields.py b/backend/migrations/versions/add_backend_plugin_fields.py new file mode 100644 index 0000000..6d7d110 --- /dev/null +++ b/backend/migrations/versions/add_backend_plugin_fields.py @@ -0,0 +1,101 @@ +"""Add backend plugin fields + +Revision ID: add_backend_plugin_fields +Revises: a1b2c3d4e5f6 +Create Date: 2026-01-21 + +Adds fields to support backend plugin architecture: +- plugin_type: "frontend", "backend", or "fullstack" +- endpoints_file: Python file containing plugin endpoints (e.g., "endpoints.py") +- route_prefix: URL prefix for plugin routes (e.g., "/library") +- backend_dependencies: JSON list of required backend plugin slugs +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = "add_backend_plugin_fields" +down_revision: Union[str, None] = "a1b2c3d4e5f6" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Add backend plugin fields to the plugin table.""" + # Get database connection and inspector + conn = op.get_bind() + inspector = sa.inspect(conn) + + # Check if plugin table exists and get its columns + if "plugin" in inspector.get_table_names(): + columns = [col["name"] for col in inspector.get_columns("plugin")] + + # Add columns only if they don't exist + if "plugin_type" not in columns: + op.add_column( + "plugin", + sa.Column( + "plugin_type", + sa.String(20), + nullable=False, + server_default="frontend", + comment="Plugin type: frontend, backend, or fullstack", + ), + ) + + if "endpoints_file" not in columns: + op.add_column( + "plugin", + sa.Column( + "endpoints_file", + sa.String(), + nullable=True, + comment="Python file containing plugin endpoints (e.g., endpoints.py)", + ), + ) + + if "route_prefix" not in columns: + op.add_column( + "plugin", + sa.Column( + "route_prefix", + sa.String(), + nullable=True, + comment="URL prefix for plugin routes (e.g., /library)", + ), + ) + + if "backend_dependencies" not in columns: + op.add_column( + "plugin", + sa.Column( + "backend_dependencies", + sa.Text(), + nullable=True, + comment="JSON list of required backend plugin slugs", + ), + ) + + +def downgrade() -> None: + """Remove backend plugin fields from the plugin table.""" + # Get database connection and inspector + conn = op.get_bind() + inspector = sa.inspect(conn) + + # Check if plugin table exists and get its columns + if "plugin" in inspector.get_table_names(): + columns = [col["name"] for col in inspector.get_columns("plugin")] + + # Drop columns only if they exist + if "backend_dependencies" in columns: + op.drop_column("plugin", "backend_dependencies") + if "route_prefix" in columns: + op.drop_column("plugin", "route_prefix") + if "endpoints_file" in columns: + op.drop_column("plugin", "endpoints_file") + if "plugin_type" in columns: + op.drop_column("plugin", "plugin_type") From e0aa3709db889b2dacdb2ed8f08c86f3223c5b70 Mon Sep 17 00:00:00 2001 From: Dave Waring Date: Wed, 21 Jan 2026 15:37:18 -0500 Subject: [PATCH 3/6] Add backend plugin support to lifecycle manager and repository (Phase 2.2-2.3) Phase 2.2 - Extend BaseLifecycleManager: - Add module documentation for backend plugin support - Add VALID_PLUGIN_TYPES constant (frontend, backend, fullstack) - Update validate_plugin_metadata() with backend field validation - Require endpoints_file for backend/fullstack plugins - Validate backend_dependencies is a list, route_prefix starts with / - Add is_backend_plugin() and get_backend_metadata() helper methods Phase 2.3 - Update Plugin Repository: - Add get_backend_plugins(user_id) - returns backend/fullstack plugins - Add get_enabled_backend_plugins(user_id) - enabled backend plugins only - Add get_plugins_depending_on(backend_slug, user_id) - for cascade disable - Add get_all_enabled_backend_plugins() - for route loader startup Co-Authored-By: Claude Opus 4.5 --- backend/app/plugins/base_lifecycle_manager.py | 107 +++++++++- backend/app/plugins/repository.py | 185 +++++++++++++++++- 2 files changed, 283 insertions(+), 9 deletions(-) diff --git a/backend/app/plugins/base_lifecycle_manager.py b/backend/app/plugins/base_lifecycle_manager.py index 476ef31..c331eef 100644 --- a/backend/app/plugins/base_lifecycle_manager.py +++ b/backend/app/plugins/base_lifecycle_manager.py @@ -3,6 +3,32 @@ Enhanced base class for plugin lifecycle managers that supports shared storage, user isolation, and efficient resource management. + +Backend Plugin Support +--------------------- +Plugins can specify their type via the `plugin_type` field in plugin_data: +- "frontend" (default): Frontend-only plugin with UI components +- "backend": Backend-only plugin with API endpoints +- "fullstack": Plugin with both frontend UI and backend endpoints + +Backend and fullstack plugins must specify: +- endpoints_file: Python file containing decorated endpoints (e.g., "endpoints.py") + +Optional backend fields: +- route_prefix: URL prefix for plugin routes (e.g., "/library") +- backend_dependencies: List of backend plugin slugs this plugin depends on + +Example plugin_data for a backend plugin: + plugin_data = { + "name": "My Backend Plugin", + "plugin_slug": "my-backend-plugin", + "version": "1.0.0", + "description": "A backend plugin example", + "plugin_type": "backend", + "endpoints_file": "endpoints.py", + "route_prefix": "/my-api", + "backend_dependencies": [], + } """ from abc import ABC, abstractmethod @@ -14,6 +40,9 @@ logger = structlog.get_logger() +# Valid plugin types for backend plugin architecture +VALID_PLUGIN_TYPES = ("frontend", "backend", "fullstack") + class BaseLifecycleManager(ABC): """Enhanced base class for plugin lifecycle managers""" @@ -195,6 +224,33 @@ def get_usage_stats(self) -> Dict[str, Any]: 'shared_path': str(self.shared_path) } + async def is_backend_plugin(self) -> bool: + """Check if this plugin has backend endpoints.""" + metadata = await self.get_plugin_metadata() + plugin_type = metadata.get('plugin_type', 'frontend') + return plugin_type in ('backend', 'fullstack') + + async def get_backend_metadata(self) -> Optional[Dict[str, Any]]: + """ + Get backend-specific metadata for this plugin. + + Returns: + Dict with backend fields if this is a backend/fullstack plugin, + None if this is a frontend-only plugin. + """ + metadata = await self.get_plugin_metadata() + plugin_type = metadata.get('plugin_type', 'frontend') + + if plugin_type not in ('backend', 'fullstack'): + return None + + return { + 'plugin_type': plugin_type, + 'endpoints_file': metadata.get('endpoints_file'), + 'route_prefix': metadata.get('route_prefix'), + 'backend_dependencies': metadata.get('backend_dependencies', []), + } + @abstractmethod async def _perform_user_installation(self, user_id: str, db: AsyncSession, shared_plugin_path: Path) -> Dict[str, Any]: """Plugin-specific installation logic using shared plugin path""" @@ -265,7 +321,24 @@ def __repr__(self) -> str: # Helper function to validate plugin metadata def validate_plugin_metadata(metadata: Dict[str, Any]) -> bool: - """Validate plugin metadata structure""" + """ + Validate plugin metadata structure. + + Required fields for all plugins: + - name: Display name of the plugin + - version: Semantic version string + - description: Short description + - plugin_slug: URL-safe identifier + + Backend plugin fields: + - plugin_type: "frontend" (default), "backend", or "fullstack" + - endpoints_file: Required for backend/fullstack plugins + - route_prefix: Optional URL prefix for routes + - backend_dependencies: Optional list of required backend plugin slugs + + Returns: + True if metadata is valid, False otherwise + """ required_fields = ['name', 'version', 'description', 'plugin_slug'] for field in required_fields: @@ -273,6 +346,38 @@ def validate_plugin_metadata(metadata: Dict[str, Any]) -> bool: logger.error(f"Missing required field '{field}' in plugin metadata") return False + # Validate plugin_type if present + plugin_type = metadata.get('plugin_type', 'frontend') + if plugin_type not in VALID_PLUGIN_TYPES: + logger.error( + f"Invalid plugin_type '{plugin_type}'. Must be one of: {VALID_PLUGIN_TYPES}" + ) + return False + + # Backend and fullstack plugins require endpoints_file + if plugin_type in ('backend', 'fullstack'): + if not metadata.get('endpoints_file'): + logger.error( + f"Plugin type '{plugin_type}' requires 'endpoints_file' field" + ) + return False + + # Validate backend_dependencies is a list if present + backend_deps = metadata.get('backend_dependencies') + if backend_deps is not None and not isinstance(backend_deps, list): + logger.error("'backend_dependencies' must be a list of plugin slugs") + return False + + # Validate route_prefix format if present + route_prefix = metadata.get('route_prefix') + if route_prefix is not None: + if not isinstance(route_prefix, str): + logger.error("'route_prefix' must be a string") + return False + if route_prefix and not route_prefix.startswith('/'): + logger.error("'route_prefix' must start with '/'") + return False + return True diff --git a/backend/app/plugins/repository.py b/backend/app/plugins/repository.py index b54c79b..1f29e61 100644 --- a/backend/app/plugins/repository.py +++ b/backend/app/plugins/repository.py @@ -594,21 +594,190 @@ async def update_module_status(self, plugin_id: str, module_id: str, enabled: bo .returning(Module.id) ) updated_id = result.scalar_one_or_none() - + if not updated_id: - logger.warning("Module not found for status update", - plugin_id=plugin_id, + logger.warning("Module not found for status update", + plugin_id=plugin_id, module_id=module_id) return False - + # Commit changes await self.db.commit() - + return True except Exception as e: await self.db.rollback() - logger.error("Error updating module status", - plugin_id=plugin_id, - module_id=module_id, + logger.error("Error updating module status", + plugin_id=plugin_id, + module_id=module_id, error=str(e)) raise + + # ------------------------------------------------------------------------- + # Backend Plugin Methods + # ------------------------------------------------------------------------- + + async def get_backend_plugins(self, user_id: str) -> List[Dict[str, Any]]: + """ + Get all backend plugins for a user. + + Returns plugins where plugin_type is 'backend' or 'fullstack'. + + Args: + user_id: The user ID to filter by + + Returns: + List of plugin dictionaries + """ + try: + query = select(Plugin).where( + Plugin.user_id == user_id, + Plugin.plugin_type.in_(["backend", "fullstack"]) + ) + + result = await self.db.execute(query) + plugins = result.scalars().all() + + logger.info( + "Retrieved backend plugins", + user_id=user_id, + count=len(plugins) + ) + + return [plugin.to_dict() for plugin in plugins] + except Exception as e: + logger.error( + "Error getting backend plugins", + user_id=user_id, + error=str(e) + ) + raise + + async def get_enabled_backend_plugins(self, user_id: str) -> List[Dict[str, Any]]: + """ + Get all enabled backend plugins for a user. + + Returns enabled plugins where plugin_type is 'backend' or 'fullstack'. + + Args: + user_id: The user ID to filter by + + Returns: + List of plugin dictionaries + """ + try: + query = select(Plugin).where( + Plugin.user_id == user_id, + Plugin.enabled == True, + Plugin.plugin_type.in_(["backend", "fullstack"]) + ) + + result = await self.db.execute(query) + plugins = result.scalars().all() + + logger.info( + "Retrieved enabled backend plugins", + user_id=user_id, + count=len(plugins) + ) + + return [plugin.to_dict() for plugin in plugins] + except Exception as e: + logger.error( + "Error getting enabled backend plugins", + user_id=user_id, + error=str(e) + ) + raise + + async def get_plugins_depending_on( + self, backend_slug: str, user_id: str + ) -> List[Dict[str, Any]]: + """ + Get all plugins that depend on a specific backend plugin. + + Used for cascade disable - when disabling a backend plugin, + find all plugins that have it in their backend_dependencies. + + Args: + backend_slug: The slug of the backend plugin to check dependencies for + user_id: The user ID to filter by + + Returns: + List of plugin dictionaries that depend on the specified backend plugin + """ + try: + # Query plugins that have backend_dependencies containing the slug + # backend_dependencies is stored as JSON text, so we search for the slug + query = select(Plugin).where( + Plugin.user_id == user_id, + Plugin.backend_dependencies.isnot(None), + Plugin.backend_dependencies != "[]", + Plugin.backend_dependencies != "" + ) + + result = await self.db.execute(query) + plugins = result.scalars().all() + + # Filter in Python to check if backend_slug is in the dependencies list + dependent_plugins = [] + for plugin in plugins: + if plugin.backend_dependencies: + try: + deps = json.loads(plugin.backend_dependencies) + if isinstance(deps, list) and backend_slug in deps: + dependent_plugins.append(plugin.to_dict()) + except json.JSONDecodeError: + logger.warning( + "Invalid JSON in backend_dependencies", + plugin_id=plugin.id, + plugin_slug=plugin.plugin_slug + ) + + logger.info( + "Retrieved plugins depending on backend", + backend_slug=backend_slug, + user_id=user_id, + dependent_count=len(dependent_plugins) + ) + + return dependent_plugins + except Exception as e: + logger.error( + "Error getting plugins depending on backend", + backend_slug=backend_slug, + user_id=user_id, + error=str(e) + ) + raise + + async def get_all_enabled_backend_plugins(self) -> List[Dict[str, Any]]: + """ + Get all enabled backend plugins across all users. + + Used by the route loader on startup to load all backend plugin routes. + + Returns: + List of plugin dictionaries with user_id included + """ + try: + query = select(Plugin).where( + Plugin.enabled == True, + Plugin.plugin_type.in_(["backend", "fullstack"]) + ) + + result = await self.db.execute(query) + plugins = result.scalars().all() + + logger.info( + "Retrieved all enabled backend plugins", + count=len(plugins) + ) + + return [plugin.to_dict() for plugin in plugins] + except Exception as e: + logger.error( + "Error getting all enabled backend plugins", + error=str(e) + ) + raise From bf9e0c109f588c12d4742995e19f67d91f739d69 Mon Sep 17 00:00:00 2001 From: Dave Waring Date: Wed, 21 Jan 2026 15:46:20 -0500 Subject: [PATCH 4/6] Integrate backend plugins with lifecycle operations (Phase 3) Phase 3.1 - Route Reload Triggers: - Add _trigger_route_reload_if_backend() helper for route reloads - Trigger reload after install/uninstall/update of backend plugins - Support all install methods: local, GitHub, file upload, remote URL Phase 3.2 - Cascade Disable: - Update PATCH /plugins/{plugin_id} to cascade-disable dependent plugins - When disabling backend plugin, find and disable dependents first - Return cascade_disabled list in response for transparency - Trigger route reload after enable/disable of backend plugins Phase 3.3 - Auto-Install Backend Dependencies: - Add _auto_install_backend_dependencies() to check plugin metadata - Auto-install missing backend dependencies before main plugin - Return auto_installed_dependencies in response - Fail with clear error if required dependency cannot be installed Co-Authored-By: Claude Opus 4.5 --- backend/app/plugins/lifecycle_api.py | 266 +++++++++++++++++++++++++-- backend/app/routers/plugins.py | 87 ++++++++- 2 files changed, 332 insertions(+), 21 deletions(-) diff --git a/backend/app/plugins/lifecycle_api.py b/backend/app/plugins/lifecycle_api.py index 342b1f6..dc766f5 100644 --- a/backend/app/plugins/lifecycle_api.py +++ b/backend/app/plugins/lifecycle_api.py @@ -24,9 +24,179 @@ # Import the remote installer from .remote_installer import RemotePluginInstaller, install_plugin_from_url +# Import route loader for backend plugin support +from .route_loader import get_plugin_loader + +# Import repository for backend plugin queries +from .repository import PluginRepository + logger = structlog.get_logger() +async def _trigger_route_reload_if_backend( + plugin_slug: str, + plugin_type: str, + db: AsyncSession, + operation: str +) -> None: + """ + Trigger a route reload if the plugin is a backend or fullstack plugin. + + Args: + plugin_slug: The slug of the plugin + plugin_type: The plugin_type field (frontend, backend, fullstack) + db: Database session + operation: The operation that triggered the reload (for logging) + """ + if plugin_type in ("backend", "fullstack"): + try: + logger.info( + f"Triggering route reload after {operation}", + plugin_slug=plugin_slug, + plugin_type=plugin_type, + ) + loader = get_plugin_loader() + result = await loader.reload_routes(db) + logger.info( + f"Route reload completed after {operation}", + plugin_slug=plugin_slug, + loaded_count=len(result.loaded), + error_count=len(result.errors), + ) + except Exception as e: + # Log error but don't fail the operation - route reload is best-effort + logger.error( + f"Route reload failed after {operation}", + plugin_slug=plugin_slug, + error=str(e), + ) + + +async def _auto_install_backend_dependencies( + plugin_slug: str, + user_id: str, + db: AsyncSession +) -> Dict[str, Any]: + """ + Check for and auto-install backend dependencies for a plugin. + + This function loads the plugin's lifecycle manager to get its metadata, + then checks if it has backend_dependencies. If so, it installs any + missing backend plugins before the main plugin can be installed. + + Args: + plugin_slug: The slug of the plugin being installed + user_id: The user ID installing the plugin + db: Database session + + Returns: + Dict with 'success', 'auto_installed' (list of installed deps), + and 'errors' if any dependencies failed to install + """ + result = { + 'success': True, + 'auto_installed': [], + 'errors': [] + } + + try: + # Load the plugin manager to get metadata + manager = universal_manager._load_plugin_manager(plugin_slug) + if not manager: + # Can't get metadata, proceed without auto-install + return result + + # Get plugin metadata + if hasattr(manager, 'plugin_data'): + plugin_data = manager.plugin_data + elif hasattr(manager, 'PLUGIN_DATA'): + plugin_data = manager.PLUGIN_DATA + else: + # No plugin_data available + return result + + # Check for backend_dependencies + backend_deps = plugin_data.get('backend_dependencies', []) + if not backend_deps: + return result + + logger.info( + f"Plugin {plugin_slug} has backend dependencies", + dependencies=backend_deps, + ) + + # Check each dependency + repo = PluginRepository(db) + for dep_slug in backend_deps: + # Check if dependency is already installed for user + existing = await repo.get_plugin_by_slug(dep_slug, user_id) + if existing and existing.get('enabled', False): + logger.info( + f"Backend dependency already installed", + plugin_slug=plugin_slug, + dependency=dep_slug, + ) + continue + + # Try to install the backend dependency + logger.info( + f"Auto-installing backend dependency", + plugin_slug=plugin_slug, + dependency=dep_slug, + ) + + try: + dep_result = await universal_manager.install_plugin(dep_slug, user_id, db) + if dep_result.get('success'): + result['auto_installed'].append({ + 'slug': dep_slug, + 'plugin_id': dep_result.get('plugin_id'), + }) + logger.info( + f"Auto-installed backend dependency successfully", + plugin_slug=plugin_slug, + dependency=dep_slug, + ) + else: + error_msg = dep_result.get('error', 'Unknown error') + result['errors'].append({ + 'slug': dep_slug, + 'error': error_msg, + }) + logger.error( + f"Failed to auto-install backend dependency", + plugin_slug=plugin_slug, + dependency=dep_slug, + error=error_msg, + ) + except Exception as e: + result['errors'].append({ + 'slug': dep_slug, + 'error': str(e), + }) + logger.error( + f"Exception auto-installing backend dependency", + plugin_slug=plugin_slug, + dependency=dep_slug, + error=str(e), + ) + + # If any required dependency failed, mark as failure + if result['errors']: + result['success'] = False + + return result + + except Exception as e: + logger.warning( + f"Could not check backend dependencies", + plugin_slug=plugin_slug, + error=str(e), + ) + # Don't fail the install if we can't check dependencies + return result + + def _log_plugin_audit_background( request: Request, event_type: str, @@ -618,10 +788,27 @@ async def install_plugin( auth: AuthContext = Depends(require_user), db: AsyncSession = Depends(get_db) ): - """Install any plugin for the current user""" + """Install any plugin for the current user. + + This endpoint automatically installs any backend dependencies + required by the plugin before installing the plugin itself. + """ try: logger.info(f"Plugin installation requested: {plugin_slug} by user {auth.user_id}") + # Auto-install backend dependencies first + deps_result = await _auto_install_backend_dependencies( + plugin_slug, auth.user_id, db + ) + if not deps_result['success']: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail={ + "message": "Failed to install required backend dependencies", + "failed_dependencies": deps_result['errors'] + } + ) + result = await universal_manager.install_plugin(plugin_slug, auth.user_id, db) if result['success']: @@ -631,16 +818,28 @@ async def install_plugin( plugin_id=result.get('plugin_id', plugin_slug), plugin_name=plugin_slug ) - + + # Trigger route reload for backend/fullstack plugins + plugin_type = result.get('plugin_type', 'frontend') + await _trigger_route_reload_if_backend( + plugin_slug, plugin_type, db, "install" + ) + + response_data = { + "plugin_slug": plugin_slug, + "plugin_id": result.get('plugin_id'), + "modules_created": result.get('modules_created', []), + "plugin_directory": result.get('plugin_directory') + } + + # Include auto-installed dependencies in response + if deps_result['auto_installed']: + response_data['auto_installed_dependencies'] = deps_result['auto_installed'] + return { "status": "success", "message": f"Plugin '{plugin_slug}' installed successfully", - "data": { - "plugin_slug": plugin_slug, - "plugin_id": result.get('plugin_id'), - "modules_created": result.get('modules_created', []), - "plugin_directory": result.get('plugin_directory') - } + "data": response_data } else: raise HTTPException( @@ -668,6 +867,16 @@ async def uninstall_plugin( try: logger.info(f"Plugin uninstallation requested: {plugin_slug} by user {auth.user_id}") + # Get plugin type before deletion for route reload check + plugin_type = 'frontend' + try: + repo = PluginRepository(db) + plugin_data = await repo.get_plugin_by_slug(plugin_slug, auth.user_id) + if plugin_data: + plugin_type = plugin_data.get('pluginType', 'frontend') + except Exception as e: + logger.warning(f"Could not get plugin type before deletion: {e}") + result = await universal_manager.delete_plugin(plugin_slug, auth.user_id, db) if result['success']: @@ -677,7 +886,12 @@ async def uninstall_plugin( plugin_id=result.get('plugin_id', plugin_slug), plugin_name=plugin_slug ) - + + # Trigger route reload for backend/fullstack plugins + await _trigger_route_reload_if_backend( + plugin_slug, plugin_type, db, "uninstall" + ) + return { "status": "success", "message": f"Plugin '{plugin_slug}' uninstalled successfully", @@ -999,6 +1213,12 @@ async def install_plugin_unified( ) if result['success']: + # Trigger route reload for backend/fullstack plugins + plugin_type = result.get('plugin_type', 'frontend') + await _trigger_route_reload_if_backend( + result.get('plugin_slug', 'unknown'), plugin_type, db, "github_install" + ) + return { "status": "success", "message": f"Plugin installed successfully from {repo_url}", @@ -1082,6 +1302,12 @@ async def install_plugin_unified( ) if result['success']: + # Trigger route reload for backend/fullstack plugins + plugin_type = result.get('plugin_type', 'frontend') + await _trigger_route_reload_if_backend( + result.get('plugin_slug', 'unknown'), plugin_type, db, "local_file_install" + ) + return { "status": "success", "message": f"Plugin '{filename}' installed successfully from local file", @@ -1165,6 +1391,12 @@ async def install_plugin_from_repository( # Get plugin name for display, fallback to slug if name not available plugin_name = result.get('plugin_name') or result.get('plugin_slug') or 'Unknown Plugin' + # Trigger route reload for backend/fullstack plugins + plugin_type = result.get('plugin_type', 'frontend') + await _trigger_route_reload_if_backend( + result.get('plugin_slug', 'unknown'), plugin_type, db, "remote_install" + ) + return { "status": "success", "message": f"Plugin '{plugin_name}' installed successfully from {request.repo_url}", @@ -1286,7 +1518,13 @@ async def update_plugin( plugin_name=plugin_slug, metadata={"previous_version": plugin.version, "new_version": result.get('version', 'latest')} ) - + + # Trigger route reload for backend/fullstack plugins + plugin_type = plugin.plugin_type if hasattr(plugin, 'plugin_type') else 'frontend' + await _trigger_route_reload_if_backend( + plugin_slug, plugin_type or 'frontend', db, "update" + ) + return { "status": "success", "message": f"Plugin '{plugin_slug}' updated successfully", @@ -1307,7 +1545,13 @@ async def update_plugin( plugin_name=plugin_slug, metadata={"previous_version": plugin.version, "new_version": "1.0.5"} ) - + + # Trigger route reload for backend/fullstack plugins + plugin_type = plugin.plugin_type if hasattr(plugin, 'plugin_type') else 'frontend' + await _trigger_route_reload_if_backend( + plugin_slug, plugin_type or 'frontend', db, "update" + ) + return { "status": "success", "message": f"Plugin '{plugin_slug}' updated successfully", diff --git a/backend/app/routers/plugins.py b/backend/app/routers/plugins.py index 09c99c2..3a8a9cb 100644 --- a/backend/app/routers/plugins.py +++ b/backend/app/routers/plugins.py @@ -16,6 +16,9 @@ # Import the lifecycle API router from ..plugins.lifecycle_api import router as lifecycle_router +# Import route loader for backend plugin support +from ..plugins.route_loader import get_plugin_loader + logger = structlog.get_logger() # Initialize plugin manager with the correct plugins directory @@ -936,30 +939,94 @@ async def update_plugin_status( db: AsyncSession = Depends(get_db), auth: AuthContext = Depends(require_user) ): - """Enable or disable a plugin.""" + """Enable or disable a plugin. + + For backend plugins, this will also trigger a route reload. + When disabling a backend plugin, dependent frontend plugins will be + cascade-disabled to prevent broken dependencies. + """ try: if "enabled" not in data: raise HTTPException(status_code=400, detail="Missing 'enabled' field in request body") - + enabled = bool(data["enabled"]) - - # Check if plugin belongs to current user - plugin = await db.execute( + + # Get plugin details including type + result = await db.execute( select(Plugin).where( Plugin.id == plugin_id, Plugin.user_id == auth.user_id ) ) - if not plugin.scalars().first(): + plugin = result.scalars().first() + if not plugin: raise HTTPException(status_code=404, detail=f"Plugin {plugin_id} not found or you don't have permission") - + + plugin_type = plugin.plugin_type or 'frontend' + plugin_slug = plugin.plugin_slug + cascade_disabled = [] + repo = PluginRepository(db) + + # Cascade disable: when disabling a backend plugin, disable dependent plugins first + if not enabled and plugin_type in ('backend', 'fullstack'): + dependent_plugins = await repo.get_plugins_depending_on(plugin_slug, auth.user_id) + for dep_plugin in dependent_plugins: + if dep_plugin.get('enabled', False): + dep_id = dep_plugin.get('id') + dep_slug = dep_plugin.get('pluginSlug', dep_plugin.get('plugin_slug')) + logger.info( + "Cascade disabling dependent plugin", + backend_slug=plugin_slug, + dependent_slug=dep_slug, + ) + await repo.update_plugin_status(dep_id, False) + cascade_disabled.append({ + 'id': dep_id, + 'slug': dep_slug, + 'reason': f"Depends on backend plugin '{plugin_slug}' which was disabled" + }) + + # Update the plugin status success = await repo.update_plugin_status(plugin_id, enabled) - + if not success: raise HTTPException(status_code=404, detail=f"Plugin {plugin_id} not found") - - return {"status": "success", "message": f"Plugin {plugin_id} {'enabled' if enabled else 'disabled'} successfully"} + + # Trigger route reload for backend/fullstack plugins + if plugin_type in ('backend', 'fullstack'): + try: + logger.info( + f"Triggering route reload after {'enable' if enabled else 'disable'}", + plugin_slug=plugin_slug, + plugin_type=plugin_type, + ) + loader = get_plugin_loader() + reload_result = await loader.reload_routes(db) + logger.info( + f"Route reload completed after {'enable' if enabled else 'disable'}", + plugin_slug=plugin_slug, + loaded_count=len(reload_result.loaded), + error_count=len(reload_result.errors), + ) + except Exception as e: + logger.error( + f"Route reload failed after {'enable' if enabled else 'disable'}", + plugin_slug=plugin_slug, + error=str(e), + ) + + response = { + "status": "success", + "message": f"Plugin {plugin_id} {'enabled' if enabled else 'disabled'} successfully" + } + + # Include cascade-disabled plugins in response + if cascade_disabled: + response["cascade_disabled"] = cascade_disabled + response["message"] += f" ({len(cascade_disabled)} dependent plugin(s) also disabled)" + + return response except HTTPException: raise except Exception as e: From 026902f0bb939a5a7800f31e2017c05f2c4de830 Mon Sep 17 00:00:00 2001 From: Dave Waring Date: Wed, 21 Jan 2026 17:46:08 -0500 Subject: [PATCH 5/6] Add Phase 4: Plugin Manager UI for backend plugins Frontend: - Add PluginTypeTabs component (All/Frontend/Backend/Fullstack) - Add BackendPluginWarningDialog for security warnings - Add backend/fullstack badges to ModuleCard - Add dependency relationships display in ModuleDetailHeader - Add cascade disable confirmation dialog - Add pluginType filter support to useModules hook and moduleService - Add plugin_type to installer types and InstallationResult Backend: - Fix route_loader.py: Change prefix from /api/v1/plugins to /api/v1/plugin-api to avoid removing core plugin management routes - Add plugin_type filter parameter to /plugins/manager endpoint - Add pluginType field to module response from parent plugin Co-Authored-By: Claude --- backend/app/plugins/route_loader.py | 5 +- backend/app/routers/plugins.py | 31 ++- .../components/InstallationResult.tsx | 39 +++- .../src/features/plugin-installer/types.ts | 3 + .../components/BackendPluginWarningDialog.tsx | 134 ++++++++++++ .../plugin-manager/components/ModuleCard.tsx | 39 +++- .../components/ModuleDetailHeader.tsx | 207 +++++++++++++++++- .../components/PluginTypeTabs.tsx | 73 ++++++ .../plugin-manager/hooks/useModules.ts | 6 +- frontend/src/features/plugin-manager/index.ts | 2 + .../plugin-manager/services/moduleService.ts | 67 +++++- frontend/src/features/plugin-manager/types.ts | 22 ++ frontend/src/pages/PluginManagerPage.tsx | 34 ++- 13 files changed, 621 insertions(+), 41 deletions(-) create mode 100644 frontend/src/features/plugin-manager/components/BackendPluginWarningDialog.tsx create mode 100644 frontend/src/features/plugin-manager/components/PluginTypeTabs.tsx diff --git a/backend/app/plugins/route_loader.py b/backend/app/plugins/route_loader.py index 1ea8d57..8e5b113 100644 --- a/backend/app/plugins/route_loader.py +++ b/backend/app/plugins/route_loader.py @@ -43,8 +43,9 @@ logger = structlog.get_logger() -# Plugin route prefix - all plugin routes are mounted here -PLUGIN_ROUTE_PREFIX = "/api/v1/plugins" +# Plugin route prefix - backend plugin dynamic routes are mounted here +# Using a distinct prefix to avoid conflicts with core plugin management routes +PLUGIN_ROUTE_PREFIX = "/api/v1/plugin-api" @dataclass diff --git a/backend/app/routers/plugins.py b/backend/app/routers/plugins.py index 3a8a9cb..3e2779c 100644 --- a/backend/app/routers/plugins.py +++ b/backend/app/routers/plugins.py @@ -343,6 +343,7 @@ async def get_plugins_for_manager( search: Optional[str] = None, category: Optional[str] = None, tags: Optional[str] = None, + plugin_type: Optional[str] = None, page: int = Query(1, ge=1), pageSize: int = Query(16, ge=1, le=100), db: AsyncSession = Depends(get_db), @@ -350,20 +351,27 @@ async def get_plugins_for_manager( ): """ Get all modules with optional filtering for the plugin manager. - + Args: search: Optional search term to filter modules by name, display name, or description category: Optional category to filter modules by tags: Optional comma-separated list of tags to filter modules by + plugin_type: Optional plugin type filter (frontend, backend, fullstack) page: Page number for pagination (1-based) pageSize: Number of items per page """ try: # Parse tags if provided tag_list = tags.split(',') if tags else [] - - # Build query for modules + + # Build query for modules with join to plugin table for plugin_type filtering query = select(Module).where(Module.user_id == auth.user_id) + + # Filter by plugin_type if provided + if plugin_type: + query = query.join(Plugin, Module.plugin_id == Plugin.id).where( + Plugin.plugin_type == plugin_type + ) # Apply filters if search: @@ -399,20 +407,31 @@ async def get_plugins_for_manager( end_idx = start_idx + pageSize paginated_modules = all_modules[start_idx:end_idx] + # Get plugin_type for each module's plugin + plugin_ids = list(set(m.plugin_id for m in paginated_modules)) + plugin_types = {} + if plugin_ids: + plugin_query = select(Plugin.id, Plugin.plugin_type).where(Plugin.id.in_(plugin_ids)) + plugin_result = await db.execute(plugin_query) + plugin_types = {row[0]: row[1] for row in plugin_result} + # Convert to dictionaries module_dicts = [] for module in paginated_modules: module_dict = module.to_dict() - + # Parse tags from JSON string if module_dict.get('tags') and isinstance(module_dict['tags'], str): try: module_dict['tags'] = json.loads(module_dict['tags']) except json.JSONDecodeError: module_dict['tags'] = [] - + + # Add plugin_type from parent plugin + module_dict['pluginType'] = plugin_types.get(module.plugin_id, 'frontend') + module_dicts.append(module_dict) - + return { "modules": module_dicts, "totalItems": total_items diff --git a/frontend/src/features/plugin-installer/components/InstallationResult.tsx b/frontend/src/features/plugin-installer/components/InstallationResult.tsx index dddd95e..632ae06 100644 --- a/frontend/src/features/plugin-installer/components/InstallationResult.tsx +++ b/frontend/src/features/plugin-installer/components/InstallationResult.tsx @@ -19,7 +19,9 @@ import { Extension as ExtensionIcon, GitHub as GitHubIcon, Refresh as RefreshIcon, - PlayArrow as PlayArrowIcon + PlayArrow as PlayArrowIcon, + Storage as StorageIcon, + Warning as WarningIcon } from '@mui/icons-material'; import { PluginInstallResponse, PluginTestState } from '../types'; import PluginTestResults from './PluginTestResults'; @@ -179,6 +181,22 @@ const InstallationResult: React.FC = ({ size="small" color="primary" /> + {result.data?.plugin_type === 'backend' && ( + } + label="Backend Plugin" + size="small" + color="secondary" + /> + )} + {result.data?.plugin_type === 'fullstack' && ( + } + label="Fullstack Plugin" + size="small" + color="info" + /> + )} @@ -187,6 +205,25 @@ const InstallationResult: React.FC = ({ + {/* Backend Plugin Warning - shown when plugin_type is backend or fullstack */} + {(result.data?.plugin_type === 'backend' || result.data?.plugin_type === 'fullstack') && ( + } + sx={{ mb: 2 }} + > + + + Backend Plugin Installed + + + This is a {result.data.plugin_type} plugin that executes server-side code. + Backend plugins can access server resources, databases, and APIs. + Only enable backend plugins from trusted sources. + + + )} + {/* Plugin Loading Test Section */} diff --git a/frontend/src/features/plugin-installer/types.ts b/frontend/src/features/plugin-installer/types.ts index 31f0a73..fe448b3 100644 --- a/frontend/src/features/plugin-installer/types.ts +++ b/frontend/src/features/plugin-installer/types.ts @@ -25,6 +25,8 @@ export interface LegacyPluginInstallRequest { version?: string; } +export type PluginType = 'frontend' | 'backend' | 'fullstack'; + export interface PluginInstallResponse { status: 'success' | 'error'; message: string; @@ -38,6 +40,7 @@ export interface PluginInstallResponse { version?: string; // Optional for file uploads filename?: string; // For file uploads file_size?: number; // For file uploads + plugin_type?: PluginType; // Backend plugin architecture field }; error?: string; } diff --git a/frontend/src/features/plugin-manager/components/BackendPluginWarningDialog.tsx b/frontend/src/features/plugin-manager/components/BackendPluginWarningDialog.tsx new file mode 100644 index 0000000..02600da --- /dev/null +++ b/frontend/src/features/plugin-manager/components/BackendPluginWarningDialog.tsx @@ -0,0 +1,134 @@ +import React from 'react'; +import { + Dialog, + DialogTitle, + DialogContent, + DialogContentText, + DialogActions, + Button, + Alert, + Box, + Typography, + List, + ListItem, + ListItemIcon, + ListItemText +} from '@mui/material'; +import WarningIcon from '@mui/icons-material/Warning'; +import StorageIcon from '@mui/icons-material/Storage'; +import SecurityIcon from '@mui/icons-material/Security'; +import CodeIcon from '@mui/icons-material/Code'; +import VerifiedUserIcon from '@mui/icons-material/VerifiedUser'; + +interface BackendPluginWarningDialogProps { + open: boolean; + onClose: () => void; + onConfirm: () => void; + pluginName?: string; + action?: 'install' | 'enable'; +} + +/** + * Warning dialog shown when installing or enabling a backend plugin. + * Backend plugins execute server-side code and require explicit user confirmation. + */ +export const BackendPluginWarningDialog: React.FC = ({ + open, + onClose, + onConfirm, + pluginName = 'this plugin', + action = 'install' +}) => { + const actionVerb = action === 'install' ? 'Installing' : 'Enabling'; + const actionPast = action === 'install' ? 'install' : 'enable'; + + return ( + + + + Backend Plugin Warning + + + } sx={{ mb: 2 }}> + + {actionVerb} a backend plugin + + + + + {pluginName} is a backend plugin that will execute server-side code. + Backend plugins have the ability to: + + + + + + + + + + + + + + + + + + + + + + + + } sx={{ mt: 2 }}> + + Only {actionPast} backend plugins from trusted sources. + + + Verify the plugin author and review the source code if possible before proceeding. + + + + + + + + + ); +}; + +export default BackendPluginWarningDialog; diff --git a/frontend/src/features/plugin-manager/components/ModuleCard.tsx b/frontend/src/features/plugin-manager/components/ModuleCard.tsx index f3a0eb9..3d57ac1 100644 --- a/frontend/src/features/plugin-manager/components/ModuleCard.tsx +++ b/frontend/src/features/plugin-manager/components/ModuleCard.tsx @@ -1,5 +1,7 @@ import React from 'react'; import { Box, Card, CardContent, Typography, Chip, Switch, CardActionArea } from '@mui/material'; +import StorageIcon from '@mui/icons-material/Storage'; +import WebIcon from '@mui/icons-material/Web'; import { Module } from '../types'; import { IconResolver } from '../../../components/IconResolver'; @@ -63,15 +65,45 @@ export const ModuleCard: React.FC = ({ height: '100%' }}> - + {module.icon && ( - + )} - + {module.displayName || module.name} + {module.pluginType === 'backend' && ( + } + label="Backend" + size="small" + color="secondary" + sx={{ + height: compact ? 18 : 22, + fontSize: compact ? '0.6rem' : '0.7rem', + flexShrink: 0, + '& .MuiChip-icon': { ml: 0.5 }, + '& .MuiChip-label': { px: 0.5 } + }} + /> + )} + {module.pluginType === 'fullstack' && ( + } + label="Fullstack" + size="small" + color="info" + sx={{ + height: compact ? 18 : 22, + fontSize: compact ? '0.6rem' : '0.7rem', + flexShrink: 0, + '& .MuiChip-icon': { ml: 0.5 }, + '& .MuiChip-label': { px: 0.5 } + }} + /> + )} = ({ onChange={handleToggleStatus} onClick={(e) => e.stopPropagation()} color="primary" + sx={{ flexShrink: 0 }} /> diff --git a/frontend/src/features/plugin-manager/components/ModuleDetailHeader.tsx b/frontend/src/features/plugin-manager/components/ModuleDetailHeader.tsx index d0c4474..ef7d740 100644 --- a/frontend/src/features/plugin-manager/components/ModuleDetailHeader.tsx +++ b/frontend/src/features/plugin-manager/components/ModuleDetailHeader.tsx @@ -1,4 +1,4 @@ -import React, { useState } from 'react'; +import React, { useState, useEffect } from 'react'; import { Box, Typography, @@ -12,15 +12,25 @@ import { DialogContentText, DialogActions, CircularProgress, - Alert + Alert, + List, + ListItem, + ListItemIcon, + ListItemText } from '@mui/material'; +import PowerOffIcon from '@mui/icons-material/PowerOff'; import ArrowBackIcon from '@mui/icons-material/ArrowBack'; import UpdateIcon from '@mui/icons-material/Update'; import DeleteIcon from '@mui/icons-material/Delete'; import PlayArrowIcon from '@mui/icons-material/PlayArrow'; -import { Module, Plugin } from '../types'; +import StorageIcon from '@mui/icons-material/Storage'; +import WebIcon from '@mui/icons-material/Web'; +import WarningIcon from '@mui/icons-material/Warning'; +import LinkIcon from '@mui/icons-material/Link'; +import { Module, Plugin, DependentPlugin } from '../types'; import ModuleStatusToggle from './ModuleStatusToggle'; import { pluginInstallerService } from '../../plugin-installer/services'; +import moduleService from '../services/moduleService'; import { PluginTestState } from '../../plugin-installer/types'; import PluginTestResults from '../../plugin-installer/components/PluginTestResults'; @@ -46,8 +56,11 @@ export const ModuleDetailHeader: React.FC = ({ }) => { const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const [updateDialogOpen, setUpdateDialogOpen] = useState(false); + const [cascadeDisableDialogOpen, setCascadeDisableDialogOpen] = useState(false); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); + const [dependentPlugins, setDependentPlugins] = useState([]); + const [cascadeDisableTargets, setCascadeDisableTargets] = useState([]); // Plugin test state const [testState, setTestState] = useState({ @@ -56,6 +69,21 @@ export const ModuleDetailHeader: React.FC = ({ hasRun: false }); + // Fetch dependent plugins for backend/fullstack plugins + useEffect(() => { + const fetchDependents = async () => { + if (plugin.pluginType === 'backend' || plugin.pluginType === 'fullstack') { + try { + const dependents = await moduleService.getDependentPlugins(plugin.name); + setDependentPlugins(dependents); + } catch (err) { + console.error('Failed to fetch dependent plugins:', err); + } + } + }; + fetchDependents(); + }, [plugin.name, plugin.pluginType]); + const handleUpdate = async () => { if (!onUpdate) return; @@ -141,6 +169,44 @@ export const ModuleDetailHeader: React.FC = ({ const canUpdate = plugin.sourceUrl && plugin.updateAvailable; const canDelete = plugin.sourceUrl; + const isBackendPlugin = plugin.pluginType === 'backend' || plugin.pluginType === 'fullstack'; + const hasEnabledDependents = dependentPlugins.some(dep => dep.enabled); + + // Intercept toggle status to show cascade disable warning for backend plugins + const handleToggleStatusWithCascade = async (enabled: boolean) => { + // If enabling, no cascade check needed + if (enabled) { + await onToggleStatus(enabled); + return; + } + + // If disabling a backend plugin with enabled dependents, show warning + if (isBackendPlugin && hasEnabledDependents) { + setCascadeDisableTargets(dependentPlugins.filter(dep => dep.enabled)); + setCascadeDisableDialogOpen(true); + return; + } + + // Otherwise, proceed normally + await onToggleStatus(enabled); + }; + + // Handle confirmed cascade disable + const handleConfirmCascadeDisable = async () => { + setLoading(true); + setError(null); + try { + // Use the cascade disable endpoint + await moduleService.disablePluginWithCascade(plugin.id); + setCascadeDisableDialogOpen(false); + // Refresh the page to show updated states + window.location.reload(); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to disable plugin'); + } finally { + setLoading(false); + } + }; return ( @@ -197,34 +263,110 @@ export const ModuleDetailHeader: React.FC = ({ moduleId={module.id} pluginId={module.pluginId} enabled={module.enabled} - onChange={onToggleStatus} + onChange={handleToggleStatusWithCascade} /> - - Plugin: {plugin.name} (v{plugin.version}) - - + + + Plugin: {plugin.name} (v{plugin.version}) + + {plugin.pluginType === 'backend' && ( + } + label="Backend Plugin" + size="small" + color="secondary" + /> + )} + {plugin.pluginType === 'fullstack' && ( + } + label="Fullstack Plugin" + size="small" + color="info" + /> + )} + {dependentPlugins.length > 0 && ( + } + label={`${dependentPlugins.length} dependent${dependentPlugins.length > 1 ? 's' : ''}`} + size="small" + color="warning" + /> + )} + + {module.author && ( Author: {module.author} )} - + {module.category && ( Category: {module.category} )} - + {module.lastUpdated && ( Updated: {new Date(module.lastUpdated).toLocaleDateString()} )} + + {/* Backend Dependencies */} + {plugin.backendDependencies && plugin.backendDependencies.length > 0 && ( + + + Requires: + + {plugin.backendDependencies.map((dep) => ( + } + label={dep} + size="small" + variant="outlined" + /> + ))} + + )} + + {/* Dependent Plugins Section for Backend Plugins */} + {(plugin.pluginType === 'backend' || plugin.pluginType === 'fullstack') && dependentPlugins.length > 0 && ( + <> + + + + Required by {dependentPlugins.length} Plugin{dependentPlugins.length > 1 ? 's' : ''} + + + Disabling this backend plugin will also disable the following dependent plugins. + + + {dependentPlugins.map((dep) => ( + + + + + + + + ))} + + + )} {module.description && ( <> @@ -314,6 +456,51 @@ export const ModuleDetailHeader: React.FC = ({ + + {/* Cascade Disable Confirmation Dialog */} + setCascadeDisableDialogOpen(false)} maxWidth="sm" fullWidth> + + + Disable Backend Plugin + + + + Disabling "{plugin.name}" will also disable the following dependent plugins: + + + {cascadeDisableTargets.map((dep) => ( + + + + + + + ))} + + + These plugins require "{plugin.name}" to function. They will be automatically disabled. + + {error && ( + + {error} + + )} + + + + + + ); }; diff --git a/frontend/src/features/plugin-manager/components/PluginTypeTabs.tsx b/frontend/src/features/plugin-manager/components/PluginTypeTabs.tsx new file mode 100644 index 0000000..a226aef --- /dev/null +++ b/frontend/src/features/plugin-manager/components/PluginTypeTabs.tsx @@ -0,0 +1,73 @@ +import React from 'react'; +import { Tabs, Tab, Box, Chip } from '@mui/material'; +import { PluginType } from '../types'; + +interface PluginTypeTabsProps { + selectedType: PluginType | 'all'; + onTypeChange: (type: PluginType | 'all') => void; + counts?: { + all?: number; + frontend?: number; + backend?: number; + fullstack?: number; + }; +} + +/** + * Tab component for filtering plugins by type (Frontend, Backend, or All) + */ +export const PluginTypeTabs: React.FC = ({ + selectedType, + onTypeChange, + counts +}) => { + const handleChange = (_event: React.SyntheticEvent, newValue: PluginType | 'all') => { + onTypeChange(newValue); + }; + + const renderLabel = (label: string, count?: number) => ( + + {label} + {count !== undefined && ( + + )} + + ); + + return ( + + + + + + + + + ); +}; + +export default PluginTypeTabs; diff --git a/frontend/src/features/plugin-manager/hooks/useModules.ts b/frontend/src/features/plugin-manager/hooks/useModules.ts index 016ef1a..8a07e65 100644 --- a/frontend/src/features/plugin-manager/hooks/useModules.ts +++ b/frontend/src/features/plugin-manager/hooks/useModules.ts @@ -1,11 +1,12 @@ import { useState, useEffect, useCallback, useRef } from 'react'; -import { Module } from '../types'; +import { Module, PluginType } from '../types'; import moduleService from '../services/moduleService'; interface UseModulesOptions { search?: string; category?: string | null; tags?: string[]; + pluginType?: PluginType | null; page?: number; pageSize?: number; } @@ -37,10 +38,11 @@ export const useModules = (options: UseModulesOptions = {}): UseModulesResult => search: options.search || '', category: options.category || null, tags: options.tags || [], + pluginType: options.pluginType || null, page: options.page || 1, pageSize: options.pageSize || 16 }; - }, [options.search, options.category, options.tags?.join(','), options.page, options.pageSize]); + }, [options.search, options.category, options.tags?.join(','), options.pluginType, options.page, options.pageSize]); const fetchModules = useCallback(async () => { try { diff --git a/frontend/src/features/plugin-manager/index.ts b/frontend/src/features/plugin-manager/index.ts index 9ab370d..50a94bb 100644 --- a/frontend/src/features/plugin-manager/index.ts +++ b/frontend/src/features/plugin-manager/index.ts @@ -9,6 +9,8 @@ export { default as ModuleFilters } from './components/ModuleFilters'; export { default as ModuleStatusToggle } from './components/ModuleStatusToggle'; export { default as ModuleDetailHeader } from './components/ModuleDetailHeader'; export { default as PluginUpdatesPanel } from './components/PluginUpdatesPanel'; +export { default as PluginTypeTabs } from './components/PluginTypeTabs'; +export { default as BackendPluginWarningDialog } from './components/BackendPluginWarningDialog'; // Export hooks export { default as useModules } from './hooks/useModules'; diff --git a/frontend/src/features/plugin-manager/services/moduleService.ts b/frontend/src/features/plugin-manager/services/moduleService.ts index d8be7c4..d6ec82b 100644 --- a/frontend/src/features/plugin-manager/services/moduleService.ts +++ b/frontend/src/features/plugin-manager/services/moduleService.ts @@ -1,4 +1,4 @@ -import { Module, Plugin } from '../types'; +import { Module, Plugin, PluginType, DependentPlugin } from '../types'; import ApiService from '../../../services/ApiService'; import { AvailableUpdatesResponse, PluginUpdateInfo } from '../../plugin-installer/types'; @@ -32,28 +32,33 @@ export class ModuleService { search?: string; category?: string | null; tags?: string[]; + pluginType?: PluginType | null; page?: number; pageSize?: number; }): Promise<{ modules: Module[]; totalItems: number }> { - const { search, category, tags, page = 1, pageSize = 16 } = options; - + const { search, category, tags, pluginType, page = 1, pageSize = 16 } = options; + const params: Record = { page, pageSize }; - + if (search) { params.search = search; } - + if (category) { params.category = category; } - + if (tags && tags.length > 0) { params.tags = tags.join(','); } - + + if (pluginType) { + params.plugin_type = pluginType; + } + try { const response = await this.apiService.get(`${this.basePath}/manager`, { params }); return { @@ -228,6 +233,54 @@ export class ModuleService { } } + /** + * Get plugins that depend on a backend plugin + * Used to show dependency relationships and cascade disable warnings + */ + async getDependentPlugins(pluginSlug: string): Promise { + try { + const response = await this.apiService.get(`${this.basePath}/${pluginSlug}/dependents`); + return response.dependents || []; + } catch (error) { + console.error(`Failed to fetch dependent plugins for ${pluginSlug}:`, error); + return []; + } + } + + /** + * Disable a backend plugin with cascade disable of dependent frontend plugins + * Returns the list of plugins that were cascade-disabled + */ + async disablePluginWithCascade(pluginId: string): Promise<{ + cascadeDisabled: DependentPlugin[]; + }> { + try { + const pluginSlug = this.extractPluginSlugFromId(pluginId); + const response = await this.apiService.post(`${this.basePath}/${pluginSlug}/disable-cascade`); + return { + cascadeDisabled: response.cascade_disabled || [] + }; + } catch (error) { + console.error(`Failed to cascade disable plugin ${pluginId}:`, error); + throw error; + } + } + + /** + * Check if disabling a plugin would affect dependent plugins + * Returns the list of plugins that would be cascade-disabled + */ + async checkCascadeDisable(pluginId: string): Promise { + try { + const pluginSlug = this.extractPluginSlugFromId(pluginId); + const response = await this.apiService.get(`${this.basePath}/${pluginSlug}/cascade-preview`); + return response.would_disable || []; + } catch (error) { + console.error(`Failed to check cascade disable for ${pluginId}:`, error); + return []; + } + } + /** * Update a plugin to the latest version */ diff --git a/frontend/src/features/plugin-manager/types.ts b/frontend/src/features/plugin-manager/types.ts index 4e085d9..9d19843 100644 --- a/frontend/src/features/plugin-manager/types.ts +++ b/frontend/src/features/plugin-manager/types.ts @@ -1,3 +1,17 @@ +/** + * Plugin type classification for backend plugin architecture + */ +export type PluginType = 'frontend' | 'backend' | 'fullstack'; + +/** + * Information about plugins that depend on this backend plugin + */ +export interface DependentPlugin { + id: string; + name: string; + enabled: boolean; +} + /** * Represents a plugin in the system */ @@ -34,6 +48,12 @@ export interface Plugin { latestVersion?: string; installationType?: string; permissions?: string[]; + // Backend plugin architecture fields + pluginType?: PluginType; + endpointsFile?: string; + routePrefix?: string; + backendDependencies?: string[]; + dependentPlugins?: DependentPlugin[]; } /** @@ -58,4 +78,6 @@ export interface Module { tags?: string[]; author?: string; lastUpdated?: string; + // Backend plugin architecture fields + pluginType?: PluginType; } diff --git a/frontend/src/pages/PluginManagerPage.tsx b/frontend/src/pages/PluginManagerPage.tsx index 0d8f3fa..637d05f 100644 --- a/frontend/src/pages/PluginManagerPage.tsx +++ b/frontend/src/pages/PluginManagerPage.tsx @@ -5,9 +5,10 @@ import { Add as AddIcon } from '@mui/icons-material'; import ModuleSearch from '../features/plugin-manager/components/ModuleSearch'; import ModuleFilters from '../features/plugin-manager/components/ModuleFilters'; import ModuleGrid from '../features/plugin-manager/components/ModuleGrid'; +import PluginTypeTabs from '../features/plugin-manager/components/PluginTypeTabs'; import useModules from '../features/plugin-manager/hooks/useModules'; import useModuleFilters from '../features/plugin-manager/hooks/useModuleFilters'; -import { Module } from '../features/plugin-manager/types'; +import { Module, PluginType } from '../features/plugin-manager/types'; /** * The main page for browsing and searching modules @@ -24,8 +25,9 @@ const PluginManagerPage: React.FC = () => { const navigate = useNavigate(); const [searchQuery, setSearchQuery] = useState(''); const [page, setPage] = useState(1); + const [selectedPluginType, setSelectedPluginType] = useState('all'); const pageSize = 16; // 4x4 grid - + const { categories, tags, @@ -34,17 +36,18 @@ const PluginManagerPage: React.FC = () => { setSelectedCategory, setSelectedTags } = useModuleFilters(); - - const { - modules, - totalModules, - loading, - error, - toggleModuleStatus + + const { + modules, + totalModules, + loading, + error, + toggleModuleStatus } = useModules({ search: searchQuery, category: selectedCategory, tags: selectedTags, + pluginType: selectedPluginType === 'all' ? null : selectedPluginType, page, pageSize }); @@ -55,6 +58,12 @@ const PluginManagerPage: React.FC = () => { setPage(1); // Reset to first page on new search }, []); + const handlePluginTypeChange = useCallback((type: PluginType | 'all') => { + console.log(`Plugin type changed to: ${type}`); + setSelectedPluginType(type); + setPage(1); // Reset to first page on type change + }, []); + const handleModuleClick = useCallback((module: Module) => { console.log(`Module clicked: ${module.name}`); navigate(`/plugin-manager/${module.pluginId}/${module.id}`); @@ -91,8 +100,13 @@ const PluginManagerPage: React.FC = () => { + + - + Date: Wed, 21 Jan 2026 18:58:14 -0500 Subject: [PATCH 6/6] Make Library path configurable via LIBRARY_PATH in .env Previously the Library path was hardcoded to ~/BrainDrive-Library. Now users can set LIBRARY_PATH in their .env file to configure a custom location for their BrainDrive-Library folder. - Add LIBRARY_PATH setting to config.py (default: ~/BrainDrive-Library) - Update library.py to use settings.LIBRARY_PATH instead of hardcoded path - Support ~ expansion for home directory paths - Update docstring to document the new configuration option Co-Authored-By: Claude Opus 4.5 --- backend/app/api/v1/endpoints/library.py | 24 +++++++++++++++++++++--- backend/app/core/config.py | 5 +++++ 2 files changed, 26 insertions(+), 3 deletions(-) diff --git a/backend/app/api/v1/endpoints/library.py b/backend/app/api/v1/endpoints/library.py index 83e892f..7af685e 100644 --- a/backend/app/api/v1/endpoints/library.py +++ b/backend/app/api/v1/endpoints/library.py @@ -4,8 +4,12 @@ Provides read access to the BrainDrive-Library for plugins that need to access project context, documentation, and other Library content. +Configuration: +- Set LIBRARY_PATH in .env to configure the Library location +- Default: ~/BrainDrive-Library + Security: -- All paths are validated to stay within ~/BrainDrive-Library/ +- All paths are validated to stay within the configured LIBRARY_PATH - Path traversal attacks (../) are blocked - Requires authenticated user """ @@ -18,11 +22,25 @@ from app.core.auth_deps import require_user from app.core.auth_context import AuthContext +from app.core.config import settings router = APIRouter(tags=["library"]) -# Base path for the BrainDrive Library -LIBRARY_BASE = Path.home() / "BrainDrive-Library" + +def _get_library_base() -> Path: + """ + Get the Library base path from settings. + Supports ~ expansion for home directory. + """ + library_path = settings.LIBRARY_PATH + # Expand ~ to home directory if present + if library_path.startswith("~"): + return Path(library_path).expanduser() + return Path(library_path) + + +# Base path for the BrainDrive Library (configurable via LIBRARY_PATH in .env) +LIBRARY_BASE = _get_library_base() class ProjectInfo(BaseModel): diff --git a/backend/app/core/config.py b/backend/app/core/config.py index a618c21..258b1b9 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -64,6 +64,11 @@ class Settings(BaseSettings): JOB_WORKER_TOKEN: str = "" # For background job worker callbacks PLUGIN_LIFECYCLE_TOKEN: str = "" # For plugin lifecycle operations + # BrainDrive Library + # Path to the BrainDrive-Library folder (configurable via .env) + # Default: ~/BrainDrive-Library + LIBRARY_PATH: str = str(Path.home() / "BrainDrive-Library") + # Database DATABASE_URL: str = "sqlite:///braindrive.db" DATABASE_TYPE: str = "sqlite"