"""Credential CRUD routes.""" import asyncio import logging import os from aiohttp import web from pydantic import SecretStr from framework.credentials.models import CredentialKey, CredentialObject from framework.credentials.store import CredentialStore from framework.server.app import validate_agent_path logger = logging.getLogger(__name__) def _get_store(request: web.Request) -> CredentialStore: return request.app["credential_store"] def _credential_to_dict(cred: CredentialObject) -> dict: """Serialize a CredentialObject to JSON — never include secret values.""" return { "credential_id": cred.id, "credential_type": str(cred.credential_type), "key_names": list(cred.keys.keys()), "created_at": cred.created_at.isoformat() if cred.created_at else None, "updated_at": cred.updated_at.isoformat() if cred.updated_at else None, } async def handle_list_credentials(request: web.Request) -> web.Response: """GET /api/credentials — list all credential metadata (no secrets).""" store = _get_store(request) cred_ids = store.list_credentials() credentials = [] for cid in cred_ids: cred = store.get_credential(cid, refresh_if_needed=False) if cred: credentials.append(_credential_to_dict(cred)) return web.json_response({"credentials": credentials}) async def handle_get_credential(request: web.Request) -> web.Response: """GET /api/credentials/{credential_id} — get single credential metadata.""" credential_id = request.match_info["credential_id"] store = _get_store(request) cred = store.get_credential(credential_id, refresh_if_needed=False) if cred is None: return web.json_response({"error": f"Credential '{credential_id}' not found"}, status=404) return web.json_response(_credential_to_dict(cred)) async def handle_save_credential(request: web.Request) -> web.Response: """POST /api/credentials — store a credential. Body: {"credential_id": "...", "keys": {"key_name": "value", ...}} """ body = await request.json() credential_id = body.get("credential_id") keys = body.get("keys") if not credential_id or not keys or not isinstance(keys, dict): return web.json_response({"error": "credential_id and keys are required"}, status=400) # ADEN_API_KEY is stored in the encrypted store via key_storage module if credential_id == "aden_api_key": key = keys.get("api_key", "").strip() if not key: return web.json_response({"error": "api_key is required"}, status=400) from framework.credentials.key_storage import save_aden_api_key save_aden_api_key(key) # Immediately sync OAuth tokens from Aden (runs in executor because # _presync_aden_tokens makes blocking HTTP calls to the Aden server). try: from aden_tools.credentials import CREDENTIAL_SPECS from framework.credentials.validation import _presync_aden_tokens loop = asyncio.get_running_loop() await loop.run_in_executor(None, _presync_aden_tokens, CREDENTIAL_SPECS) except Exception as exc: logger.warning("Aden token sync after key save failed: %s", exc) return web.json_response({"saved": "aden_api_key"}, status=201) store = _get_store(request) cred = CredentialObject( id=credential_id, keys={k: CredentialKey(name=k, value=SecretStr(v)) for k, v in keys.items()}, ) store.save_credential(cred) return web.json_response({"saved": credential_id}, status=201) async def handle_delete_credential(request: web.Request) -> web.Response: """DELETE /api/credentials/{credential_id} — delete a credential.""" credential_id = request.match_info["credential_id"] if credential_id == "aden_api_key": from framework.credentials.key_storage import delete_aden_api_key deleted = delete_aden_api_key() if not deleted: return web.json_response({"error": "Credential 'aden_api_key' not found"}, status=404) return web.json_response({"deleted": True}) store = _get_store(request) deleted = store.delete_credential(credential_id) if not deleted: return web.json_response({"error": f"Credential '{credential_id}' not found"}, status=404) return web.json_response({"deleted": True}) async def handle_check_agent(request: web.Request) -> web.Response: """POST /api/credentials/check-agent — check and validate agent credentials. Uses the same ``validate_agent_credentials`` as agent startup: 1. Presence — is the credential available (env, encrypted store, Aden)? 2. Health check — does the credential actually work (lightweight HTTP call)? Body: {"agent_path": "...", "verify": true} """ body = await request.json() agent_path = body.get("agent_path") verify = body.get("verify", True) if not agent_path: return web.json_response({"error": "agent_path is required"}, status=400) try: agent_path = str(validate_agent_path(agent_path)) except ValueError as e: return web.json_response({"error": str(e)}, status=400) try: from framework.credentials.setup import load_agent_nodes from framework.credentials.validation import ( ensure_credential_key_env, validate_agent_credentials, ) # Load env vars from shell config (same as runtime startup) ensure_credential_key_env() nodes = load_agent_nodes(agent_path) result = validate_agent_credentials( nodes, verify=verify, raise_on_error=False, force_refresh=True ) # If any credential needs Aden, include ADEN_API_KEY as a first-class row if any(c.aden_supported for c in result.credentials): aden_key_status = { "credential_name": "Aden Platform", "credential_id": "aden_api_key", "env_var": "ADEN_API_KEY", "description": "API key from the Developers tab in Settings", "help_url": "https://hive.adenhq.com/", "tools": [], "node_types": [], "available": result.has_aden_key, "valid": None, "validation_message": None, "direct_api_key_supported": True, "aden_supported": True, # renders with "Authorize" button to open Aden "credential_key": "api_key", } required = [aden_key_status] + [_status_to_dict(c) for c in result.credentials] else: required = [_status_to_dict(c) for c in result.credentials] return web.json_response( { "required": required, "has_aden_key": result.has_aden_key, } ) except Exception as e: logger.exception(f"Error checking agent credentials: {e}") return web.json_response( {"error": "Internal server error while checking credentials"}, status=500, ) def _status_to_dict(c) -> dict: """Convert a CredentialStatus to the JSON dict expected by the frontend.""" return { "credential_name": c.credential_name, "credential_id": c.credential_id, "env_var": c.env_var, "description": c.description, "help_url": c.help_url, "tools": c.tools, "node_types": c.node_types, "available": c.available, "direct_api_key_supported": c.direct_api_key_supported, "aden_supported": c.aden_supported, "credential_key": c.credential_key, "valid": c.valid, "validation_message": c.validation_message, "alternative_group": c.alternative_group, } async def handle_list_specs(request: web.Request) -> web.Response: """GET /api/credentials/specs — list ALL credential specs with availability.""" try: from aden_tools.credentials import CREDENTIAL_SPECS from framework.credentials.storage import ( CompositeStorage, EncryptedFileStorage, EnvVarStorage, ) from framework.credentials.store import CredentialStore from framework.credentials.validation import _presync_aden_tokens, ensure_credential_key_env ensure_credential_key_env() has_aden_key = bool(os.environ.get("ADEN_API_KEY")) if has_aden_key: _presync_aden_tokens(CREDENTIAL_SPECS) # Build composite store (env → encrypted file) env_mapping = { (spec.credential_id or name): spec.env_var for name, spec in CREDENTIAL_SPECS.items() } env_storage = EnvVarStorage(env_mapping=env_mapping) if os.environ.get("HIVE_CREDENTIAL_KEY"): storage = CompositeStorage(primary=env_storage, fallbacks=[EncryptedFileStorage()]) else: storage = env_storage store = CredentialStore(storage=storage) specs = [] any_aden = False for name, spec in CREDENTIAL_SPECS.items(): cred_id = spec.credential_id or name if spec.aden_supported: any_aden = True specs.append( { "credential_name": name, "credential_id": cred_id, "env_var": spec.env_var, "description": spec.description, "help_url": spec.help_url, "api_key_instructions": spec.api_key_instructions, "tools": spec.tools, "aden_supported": spec.aden_supported, "direct_api_key_supported": spec.direct_api_key_supported, "credential_key": spec.credential_key, "credential_group": spec.credential_group, "available": store.is_available(cred_id), } ) # Include aden_api_key synthetic row if any spec uses Aden if any_aden: specs.insert( 0, { "credential_name": "Aden Platform", "credential_id": "aden_api_key", "env_var": "ADEN_API_KEY", "description": "API key from the Developers tab in Settings", "help_url": "https://hive.adenhq.com/", "api_key_instructions": "1. Go to hive.adenhq.com\n2. Open Settings > Developers\n3. Copy your API key", "tools": [], "aden_supported": True, "direct_api_key_supported": True, "credential_key": "api_key", "credential_group": "", "available": has_aden_key, }, ) return web.json_response({"specs": specs, "has_aden_key": has_aden_key}) except Exception as e: logger.exception(f"Error listing credential specs: {e}") return web.json_response( {"error": "Internal server error while listing credential specs"}, status=500, ) def register_routes(app: web.Application) -> None: """Register credential routes on the application.""" # specs and check-agent must be registered BEFORE the {credential_id} wildcard app.router.add_get("/api/credentials/specs", handle_list_specs) app.router.add_post("/api/credentials/check-agent", handle_check_agent) app.router.add_get("/api/credentials", handle_list_credentials) app.router.add_post("/api/credentials", handle_save_credential) app.router.add_get("/api/credentials/{credential_id}", handle_get_credential) app.router.add_delete("/api/credentials/{credential_id}", handle_delete_credential)