"""Site management logic for the Live Platform (Track E.3). Provides site CRUD operations, connection validation, and credential field definitions for all 9 plugin types. Coordinates between the database, encryption, and plugin health check layers. Usage: from core.site_api import create_user_site, get_user_sites, validate_site_connection ok, msg = await validate_site_connection("wordpress", "https://example.com", creds) site = await create_user_site(user_id, "wordpress", "myblog", url, creds) """ import logging import os from typing import Any import aiohttp logger = logging.getLogger(__name__) # Fallback used when the settings DB is unavailable at import time _MAX_SITES_FALLBACK = int(os.getenv("MAX_SITES_PER_USER", "10")) # Plugin credential field definitions — drives the dynamic "Add Site" form # and server-side validation. Each field has: # name: form input name (matches credential JSON key) # label: display label # type: "text" or "password" # required: whether the field is mandatory PLUGIN_CREDENTIAL_FIELDS: dict[str, list[dict[str, Any]]] = { "wordpress": [ { "name": "username", "label": "Username", "type": "text", "required": True, "hint": "Your WordPress admin username (used as the HTTP Basic username for every API call).", }, { "name": "app_password", "label": "Application Password", "type": "password", "required": True, "hint": ( "WordPress Admin → Users → Profile → Application Passwords. " "This IS the API credential — no separate API key is needed. " "Paste the value WP shows once after creation (spaces can stay or be removed)." ), }, ], "woocommerce": [ { "name": "consumer_key", "label": "Consumer Key", "type": "text", "required": True, "hint": ( "WooCommerce → Settings → Advanced → REST API → Add Key. " "Read/Write permission. This pair IS the API auth — no extra API key field exists." ), }, { "name": "consumer_secret", "label": "Consumer Secret", "type": "password", "required": True, "hint": ( "Shown once when the REST API key is created. " "Starts with ``cs_``. Save it immediately — WooCommerce will not display it again." ), }, # F.X.fix-pass4 — optional WP Application Password, only used by # media tools (upload_and_attach_to_product / attach_media_to_ # product / set_featured_image). WooCommerce's Consumer Key + # Secret authenticate ``/wc/v3/*`` but NOT ``/wp/v2/media``; # WordPress core REST media uploads require an Application # Password from the WP admin user. Leave blank if the store # never needs MCP-driven image uploads. { "name": "wp_username", "label": "WordPress Username (for media tools)", "type": "text", "required": False, "advanced": True, "hint": ( "Only required if you want the AI / media tools " "(upload_and_attach_to_product, attach_media_to_product, " "set_featured_image, generate_and_upload_image with attach_to_post). " "Other WC tools work with Consumer Key / Secret alone." ), }, { "name": "wp_app_password", "label": "WordPress Application Password (for media tools)", "type": "password", "required": False, "advanced": True, "hint": ( "WP Admin → Users → Profile → Application Passwords. " "WC media uploads hit /wp/v2/media which only accepts " "Application Passwords (not Consumer Key / Secret). " "Leave empty if you don't use MCP for image uploads." ), }, ], # F.19.1 WordPress Specialist — companion-backed, no Docker socket. # Same auth shape as the basic wordpress plugin (Application Password) # but the WP user behind the password must have ``manage_options``, # AND Airano MCP Bridge v2.11.0+ must be active on the WP site for # the admin namespace to exist. "wordpress_specialist": [ { "name": "username", "label": "Username", "type": "text", "required": True, "hint": "WordPress admin username (the user that owns the Application Password).", }, { "name": "app_password", "label": "Application Password", "type": "password", "required": True, "hint": ( "WordPress Admin → Users → Profile → Application Passwords. " "The user MUST have manage_options. Requires Airano MCP " "Bridge v2.11.0+ on the WP side — install from " "wordpress-plugin/airano-mcp-bridge.zip in the repo." ), }, ], "gitea": [ { "name": "token", "label": "Access Token", "type": "password", "required": True, "hint": "Gitea → Settings → Applications → Generate Token", }, ], "n8n": [ { "name": "api_key", "label": "API Key", "type": "password", "required": True, "hint": "n8n → Settings → API → Create API Key", }, ], "supabase": [ { "name": "service_role_key", "label": "Service Role Key", "type": "password", "required": True, "hint": ( "Supabase Dashboard → Settings → API → service_role key. " "Note: On supabase.com cloud, postgres-meta tools " "(list_tables, execute_sql, get_table_schema, etc.) are not available — " "they only work on self-hosted Supabase." ), }, { "name": "anon_key", "label": "Anon Key (Optional)", "type": "password", "required": False, "advanced": True, "hint": ( "Supabase Dashboard → Settings → API → anon key. " "Optional — if omitted, service_role_key is used for all calls. " "Only useful for testing RLS policies as a regular user." ), }, { "name": "meta_url", "label": "postgres-meta URL (Optional)", "type": "text", "required": False, "advanced": True, "hint": ( "Only needed if your Supabase setup does not expose /pg/ through Kong. " "Most self-hosted installs (including Coolify) work without this. " "Example: http://supabase-meta:8080 or https://your-meta.example.com" ), }, { "name": "meta_auth", "label": "postgres-meta Auth (Optional)", "type": "password", "required": False, "advanced": True, "hint": ( "Basic Auth for postgres-meta (format: username:password). " "Only needed when postgres-meta is exposed via a public URL." ), }, ], "openpanel": [ { "name": "client_id", "label": "Client ID", "type": "text", "required": True, "hint": "Open your project on dashboard.openpanel.dev → Settings → Clients (URL: dashboard.openpanel.dev/{org}/{project-id}/settings/clients). Create a client in 'root' mode for full access.", }, { "name": "client_secret", "label": "Client Secret", "type": "password", "required": True, "hint": "Generated with your Client ID", }, { "name": "project_id", "label": "Project ID", "type": "text", "required": False, "hint": "From dashboard URL: dashboard.openpanel.dev/{org}/{project-id}/ — sets default for Export & Insights tools", }, { "name": "organization_id", "label": "Organization ID", "type": "text", "required": False, "hint": "From dashboard URL: dashboard.openpanel.dev/{org}/{project-id}/", "advanced": True, }, ], "appwrite": [ { "name": "project_id", "label": "Project ID", "type": "text", "required": True, "hint": "Appwrite Console → Project Settings → Project ID", }, { "name": "api_key", "label": "API Key", "type": "password", "required": True, "hint": "Appwrite Console → Project Settings → API Keys → Create", }, ], "directus": [ { "name": "token", "label": "Static Token", "type": "password", "required": True, "hint": "Directus → Settings → User → Static Token", }, ], "coolify": [ { "name": "token", "label": "API Token", "type": "password", "required": True, "hint": "Coolify → Keys & Tokens → API tokens → Create", }, ], } # Plugin display names for UI PLUGIN_DISPLAY_NAMES: dict[str, str] = { "wordpress": "WordPress", "woocommerce": "WooCommerce", "wordpress_specialist": "WordPress Specialist", "gitea": "Gitea", "n8n": "n8n", "supabase": "Supabase", "openpanel": "OpenPanel", "appwrite": "Appwrite", "directus": "Directus", "coolify": "Coolify", } # Health check endpoints per plugin type _HEALTH_ENDPOINTS: dict[str, dict[str, Any]] = { "wordpress": {"path": "/wp-json/wp/v2/users/me", "method": "GET"}, "woocommerce": {"path": "/wp-json/wc/v3/system_status", "method": "GET"}, # F.19.1: hits the companion's lightest admin route. Returns 404 if # Airano MCP Bridge v2.11.0+ is missing, 403 if the user lacks # ``manage_options``, 200 with a small JSON body otherwise. This # makes "Test Connection" surface the actual end-to-end requirement # instead of just "auth works" (which /users/me would). "wordpress_specialist": {"path": "/wp-json/airano-mcp/v1/admin/maintenance", "method": "GET"}, "gitea": {"path": "/api/v1/user", "method": "GET"}, "n8n": {"path": "/healthz", "method": "GET"}, "supabase": {"path": "/rest/v1/", "method": "GET"}, "openpanel": {"path": "/healthcheck", "method": "GET"}, "appwrite": {"path": "/v1/health", "method": "GET"}, "directus": {"path": "/server/health", "method": "GET"}, "coolify": {"path": "/api/v1/version", "method": "GET"}, } def get_credential_fields(plugin_type: str) -> list[dict[str, Any]]: """Get credential field definitions for a plugin type. Args: plugin_type: Plugin type name. Returns: List of field definition dicts. Raises: ValueError: If plugin_type is unknown. """ fields = PLUGIN_CREDENTIAL_FIELDS.get(plugin_type) if fields is None: raise ValueError( f"Unknown plugin type '{plugin_type}'. " f"Valid: {list(PLUGIN_CREDENTIAL_FIELDS.keys())}" ) return fields def get_user_credential_fields(is_admin: bool = False) -> dict[str, list[dict[str, Any]]]: """Get credential fields for the dashboard's Add/Edit Site forms. Admin users see every registered plugin so they can use admin-only plugins (wordpress_specialist) without having to add them to ``ENABLED_PLUGINS`` for everyone. Public users get only the plugins enabled via ``ENABLED_PLUGINS`` env var (Track F.1). F.19.1 (2026-05-01) — surfaced when an admin OAuth user added a wordpress_specialist site and the manage page rendered an empty credential form on revisit because the unfiltered map was being filtered for public visibility. The site row exists but the template can't render its fields. Args: is_admin: When True, return every plugin's credential fields. Defaults to False for backward compatibility. Returns: Dict of plugin_type -> field definitions. """ if is_admin: return dict(PLUGIN_CREDENTIAL_FIELDS) from core.plugin_visibility import get_public_plugin_types public = get_public_plugin_types() return {k: v for k, v in PLUGIN_CREDENTIAL_FIELDS.items() if k in public} def get_user_plugin_names(is_admin: bool = False) -> dict[str, str]: """Get plugin display names for the dashboard's plugin pickers. Admin users see every registered plugin (so the Add Site dropdown surfaces ``wordpress_specialist`` etc.); public users get only the plugins enabled via ``ENABLED_PLUGINS`` env var. Args: is_admin: When True, return every plugin's display name. Defaults to False for backward compatibility. Returns: Dict of plugin_type -> display name. """ if is_admin: return dict(PLUGIN_DISPLAY_NAMES) from core.plugin_visibility import get_public_plugin_types public = get_public_plugin_types() return {k: v for k, v in PLUGIN_DISPLAY_NAMES.items() if k in public} def validate_credentials(plugin_type: str, credentials: dict[str, str]) -> tuple[bool, list[str]]: """Validate that all required credential fields are present and non-empty. Args: plugin_type: Plugin type name. credentials: Dict of credential key→value. Returns: Tuple of (is_valid, list_of_error_messages). """ fields = get_credential_fields(plugin_type) errors: list[str] = [] for field in fields: if field["required"]: value = credentials.get(field["name"], "").strip() if not value: errors.append(f"'{field['label']}' is required") return (len(errors) == 0, errors) async def validate_site_connection( plugin_type: str, url: str, credentials: dict[str, str] ) -> tuple[bool, str]: """Test connectivity to a site using HTTP health check. Args: plugin_type: Plugin type name. url: Site URL. credentials: Plaintext credential dict. Returns: Tuple of (success, message). Message is "OK" on success or a human-readable error description. """ endpoint_info = _HEALTH_ENDPOINTS.get(plugin_type) if endpoint_info is None: return False, f"Unknown plugin type '{plugin_type}'" check_url = url.rstrip("/") + endpoint_info["path"] method = endpoint_info["method"] # Build auth headers per plugin type headers: dict[str, str] = {} if plugin_type in ("wordpress", "wordpress_specialist"): import base64 username = credentials.get("username", "") app_password = credentials.get("app_password", "") token = base64.b64encode(f"{username}:{app_password}".encode()).decode() headers["Authorization"] = f"Basic {token}" elif plugin_type == "woocommerce": import base64 ck = credentials.get("consumer_key", "") cs = credentials.get("consumer_secret", "") token = base64.b64encode(f"{ck}:{cs}".encode()).decode() headers["Authorization"] = f"Basic {token}" elif plugin_type == "gitea": headers["Authorization"] = f"token {credentials.get('token', '')}" elif plugin_type == "n8n": headers["X-N8N-API-KEY"] = credentials.get("api_key", "") elif plugin_type == "supabase": headers["apikey"] = credentials.get("service_role_key", "") headers["Authorization"] = f"Bearer {credentials.get('service_role_key', '')}" elif plugin_type == "appwrite": headers["X-Appwrite-Project"] = credentials.get("project_id", "") headers["X-Appwrite-Key"] = credentials.get("api_key", "") elif plugin_type in ("directus", "coolify"): headers["Authorization"] = f"Bearer {credentials.get('token', '')}" elif plugin_type == "openpanel": headers["openpanel-client-id"] = credentials.get("client_id", "") headers["openpanel-client-secret"] = credentials.get("client_secret", "") try: timeout = aiohttp.ClientTimeout(total=15) async with aiohttp.ClientSession(timeout=timeout) as session: if method == "POST": async with session.post(check_url, headers=headers) as resp: status_code = resp.status resp_text = await resp.text() else: async with session.get(check_url, headers=headers) as resp: status_code = resp.status resp_text = await resp.text() if status_code < 400: return True, "OK" elif status_code == 401: return False, "Authentication failed — check credentials" elif status_code == 403: return False, "Access forbidden — check permissions or API may be disabled" elif status_code == 404: return False, f"Endpoint not found at {check_url} — check URL" else: return False, f"HTTP {status_code}: {resp_text[:200]}" except aiohttp.ClientConnectorError: return False, "Connection failed — check URL and ensure the site is reachable" except TimeoutError: return False, "Connection timed out (15s) — site may be slow or unreachable" except aiohttp.InvalidURL: return False, "Invalid URL protocol — use https:// or http://" except Exception as e: return False, f"Connection error: {type(e).__name__}: {e}" async def create_user_site( user_id: str, plugin_type: str, alias: str, url: str, credentials: dict[str, str], skip_validation: bool = False, ) -> dict[str, Any]: """Create a new site for a user. Validates credentials, tests the connection, encrypts credentials, and stores in the database. Args: user_id: Owner's UUID. plugin_type: Plugin type name. alias: User-chosen friendly name. url: Site URL. credentials: Plaintext credential dict. skip_validation: If True, skip connection test (for testing). Returns: The created site dict (without decrypted credentials). Raises: ValueError: On validation errors (bad plugin type, missing fields, alias taken, site limit reached, connection failed). """ from core.database import get_database from core.encryption import get_credential_encryption # Validate plugin type if plugin_type not in PLUGIN_CREDENTIAL_FIELDS: raise ValueError( f"Unknown plugin type '{plugin_type}'. " f"Valid: {list(PLUGIN_CREDENTIAL_FIELDS.keys())}" ) # Validate alias format alias = alias.strip().lower() if not alias or len(alias) < 2 or len(alias) > 50: raise ValueError("Alias must be 2-50 characters") if not alias.replace("-", "").replace("_", "").isalnum(): raise ValueError("Alias may only contain letters, numbers, hyphens, and underscores") # Validate required credential fields valid, errors = validate_credentials(plugin_type, credentials) if not valid: raise ValueError(f"Missing credentials: {', '.join(errors)}") db = get_database() # Check site limit (reads DB > ENV > default so dashboard/settings changes take effect) from core.settings import get_cached_max_sites max_sites = get_cached_max_sites() count = await db.count_sites_by_user(user_id) if count >= max_sites: raise ValueError(f"Site limit reached ({max_sites} sites per user)") # Check alias uniqueness (DB constraint will also catch this) existing = await db.get_site_by_alias(user_id, alias) if existing is not None: raise ValueError(f"Alias '{alias}' is already in use") # Test connection status = "active" status_msg = "Connection verified" if not skip_validation: ok, msg = await validate_site_connection(plugin_type, url, credentials) if not ok: raise ValueError(f"Connection test failed: {msg}") # Encrypt credentials encryptor = get_credential_encryption() # We need the site_id for encryption, but we don't have it yet. # Use a pre-generated UUID as the site_id. import uuid site_id = str(uuid.uuid4()) encrypted = encryptor.encrypt_credentials(credentials, site_id) # Store in database — we bypass db.create_site() to use our pre-generated ID from core.database import _utc_now now = _utc_now() await db.execute( "INSERT INTO sites (id, user_id, plugin_type, alias, url, credentials, " "status, status_msg, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", (site_id, user_id, plugin_type, alias, url, encrypted, status, status_msg, now), ) # Return the created site (without credentials blob) site = await db.get_site(site_id, user_id) if site is None: raise RuntimeError(f"Failed to read back created site {site_id}") result = dict(site) result.pop("credentials", None) logger.info("Created site %s (%s) for user %s", alias, plugin_type, user_id) return result async def get_user_sites(user_id: str) -> list[dict[str, Any]]: """Get all sites for a user (without credentials). Args: user_id: Owner's UUID. Returns: List of site dicts. """ from core.database import get_database db = get_database() sites = await db.get_sites_by_user(user_id) # Strip credentials blob from response return [{k: v for k, v in site.items() if k != "credentials"} for site in sites] async def get_user_site(site_id: str, user_id: str) -> dict[str, Any] | None: """Get a single site (without credentials). Args: site_id: Site UUID. user_id: Owner's UUID. Returns: Site dict or None. """ from core.database import get_database db = get_database() site = await db.get_site(site_id, user_id) if site is None: return None result = dict(site) result.pop("credentials", None) return result async def delete_user_site(site_id: str, user_id: str) -> bool: """Delete a user's site. Args: site_id: Site UUID. user_id: Owner's UUID. Returns: True if deleted, False if not found. """ from core.database import get_database db = get_database() deleted = await db.delete_site(site_id, user_id) if deleted: logger.info("Deleted site %s for user %s", site_id, user_id) return deleted async def update_user_site( site_id: str, user_id: str, url: str, credentials: dict[str, str], skip_validation: bool = False, ) -> dict[str, Any]: """Update URL and credentials for an existing site. Password fields left blank are preserved from the existing encrypted credentials. Re-validates the connection after update. Args: site_id: Site UUID. user_id: Owner's UUID. url: New base URL (required). credentials: Credential dict — blank password fields keep their current value. skip_validation: If True, skip connection test (for testing). Returns: The updated site dict (without decrypted credentials). Raises: ValueError: If site not found, validation fails, or connection test fails. """ from core.database import get_database from core.encryption import get_credential_encryption db = get_database() # Verify site ownership existing_site = await db.get_site(site_id, user_id) if existing_site is None: raise ValueError("Site not found") plugin_type = existing_site["plugin_type"] # Validate URL url = url.strip().rstrip("/") if not url or not (url.startswith("http://") or url.startswith("https://")): raise ValueError("URL must start with http:// or https://") # Merge new credentials with existing ones — blank fields keep existing values encryptor = get_credential_encryption() existing_credentials = encryptor.decrypt_credentials(existing_site["credentials"], site_id) merged: dict[str, str] = dict(existing_credentials) for key, value in credentials.items(): # Only override if the new value is non-empty if value and value.strip(): merged[key] = value.strip() # Blank value for a non-required field (e.g. meta_url) → explicitly clear it else: field_defs = {f["name"]: f for f in PLUGIN_CREDENTIAL_FIELDS.get(plugin_type, [])} if key in field_defs and not field_defs[key].get("required", True): merged[key] = "" # Strip empty optional values before storing (keep storage clean) merged = {k: v for k, v in merged.items() if v} # Validate required fields are still present valid, errors = validate_credentials(plugin_type, merged) if not valid: raise ValueError(f"Missing required credentials: {', '.join(errors)}") # Test connection if not skip_validation: ok, msg = await validate_site_connection(plugin_type, url, merged) if not ok: raise ValueError(f"Connection test failed: {msg}") # Encrypt merged credentials encrypted = encryptor.encrypt_credentials(merged, site_id) # Persist updated = await db.update_site_credentials(site_id, user_id, url, encrypted) if not updated: raise RuntimeError(f"Failed to update site {site_id}") # Mark active after successful connection test status_msg = "Connection verified" if not skip_validation else "Updated (not tested)" await db.update_site_status(site_id, "active", status_msg, user_id=user_id) result = await db.get_site(site_id, user_id) if result is None: raise RuntimeError(f"Failed to read back updated site {site_id}") site_dict = dict(result) site_dict.pop("credentials", None) logger.info("Updated site %s (%s) for user %s", site_id, plugin_type, user_id) return site_dict # --------------------------------------------------------------------------- # F.5a.9.x: per-site AI provider keys (OpenAI / Stability / Replicate) # --------------------------------------------------------------------------- # Only WordPress / WooCommerce sites can host AI image provider keys — the # only consumer is ``wordpress_generate_and_upload_image``, which uploads into # a WordPress media library. Other plugin types never surface the section. PROVIDER_KEYS_ALLOWED_PLUGIN_TYPES: frozenset[str] = frozenset({"wordpress", "woocommerce"}) # Providers supported for per-site keys. Must stay in sync with # plugins/ai_image/registry.py ``_PROVIDERS`` (order is preserved to # drive the UI). SITE_PROVIDERS: tuple[str, ...] = ("openai", "stability", "replicate", "openrouter") def site_provider_scope(site_id: str, provider: str) -> str: """Return the HKDF scope used for per-site provider-key encryption. Format: ``site_provider:{site_id}:{provider}`` — distinct from the per-site credentials scope (bare ``site_id``) so the same master key never produces the same derived key for both credential types. """ return f"site_provider:{site_id}:{provider}" def site_supports_provider_keys(plugin_type: str) -> bool: """Return True if the plugin type can hold AI provider keys.""" return plugin_type in PROVIDER_KEYS_ALLOWED_PLUGIN_TYPES async def set_site_provider_key( site_id: str, user_id: str, provider: str, api_key: str, ) -> dict[str, Any]: """Encrypt and store an AI provider API key for a user-owned site. Args: site_id: Site UUID. user_id: Owner's UUID (enforces tenant isolation). provider: One of :data:`SITE_PROVIDERS`. api_key: The plaintext API key to store. Returns: The stored row (without plaintext). Raises: ValueError: If the site is not found, the plugin type does not support provider keys, the provider is unknown, or the key is empty. """ from core.database import get_database from core.encryption import get_credential_encryption if provider not in SITE_PROVIDERS: raise ValueError( f"Unsupported provider '{provider}'. " f"Supported: {', '.join(SITE_PROVIDERS)}" ) if not api_key or not api_key.strip(): raise ValueError("API key must not be empty") db = get_database() site = await db.get_site(site_id, user_id) if site is None: raise ValueError("Site not found") if not site_supports_provider_keys(site["plugin_type"]): raise ValueError( f"Plugin type '{site['plugin_type']}' does not support AI " f"provider keys (only WordPress/WooCommerce)." ) encryptor = get_credential_encryption() ciphertext = encryptor.encrypt_for_scope( api_key.strip(), site_provider_scope(site_id, provider) ) row = await db.upsert_site_provider_key(site_id, provider, ciphertext) logger.info("Stored %s provider key for site %s (user %s)", provider, site_id, user_id) return row async def get_site_provider_key( site_id: str, provider: str, ) -> str | None: """Return the decrypted AI provider key for a site, or None. Caller is responsible for verifying site ownership — this helper is also called from the AI tool handler which already trusts the resolved site_id. Args: site_id: Site UUID. provider: Provider identifier. Returns: Decrypted API key string, or None if no key is stored. """ from core.database import get_database from core.encryption import get_credential_encryption if provider not in SITE_PROVIDERS: return None db = get_database() row = await db.get_site_provider_key(site_id, provider) if row is None: return None encryptor = get_credential_encryption() try: return encryptor.decrypt_for_scope( row["key_ciphertext"], site_provider_scope(site_id, provider) ) except Exception: logger.exception( "Failed to decrypt site provider key (site=%s provider=%s)", site_id, provider, ) return None async def list_site_providers_set(site_id: str) -> set[str]: """Return the set of providers that have a key stored for the site.""" from core.database import get_database db = get_database() rows = await db.list_site_provider_keys(site_id) return {row["provider"] for row in rows} async def delete_site_provider_key( site_id: str, user_id: str, provider: str, ) -> bool: """Remove an AI provider key for a site. Returns True if a row was deleted. Args: site_id: Site UUID. user_id: Owner's UUID — enforces tenant isolation before deletion. provider: Provider identifier. """ from core.database import get_database db = get_database() site = await db.get_site(site_id, user_id) if site is None: return False deleted = await db.delete_site_provider_key(site_id, provider) if deleted: logger.info( "Deleted %s provider key for site %s (user %s)", provider, site_id, user_id, ) return deleted async def test_site_connection(site_id: str, user_id: str) -> tuple[bool, str]: """Test connectivity to an existing site. Decrypts credentials from the database, runs the health check, and updates the site status. Args: site_id: Site UUID. user_id: Owner's UUID. Returns: Tuple of (success, message). Raises: ValueError: If site not found. """ from core.database import get_database from core.encryption import get_credential_encryption db = get_database() site = await db.get_site(site_id, user_id) if site is None: raise ValueError("Site not found") encryptor = get_credential_encryption() credentials = encryptor.decrypt_credentials(site["credentials"], site_id) ok, msg = await validate_site_connection(site["plugin_type"], site["url"], credentials) # Update status new_status = "active" if ok else "error" await db.update_site_status(site_id, new_status, msg, user_id=user_id) return ok, msg