use axum::extract::{Query, State}; use axum::http::StatusCode; use axum::Json; use cm_db::repo::audit::Actor; use cm_domain::AgentId; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; use uuid::Uuid; use crate::routes::claws::workspace_agent; use crate::{ApiError, AppState, Authed}; #[derive(Serialize)] pub struct DirectoryApp { pub id: &'static str, pub name: &'static str, pub description: &'static str, pub category: &'static str, } fn catalog() -> Vec { vec![ DirectoryApp { id: "gmail", name: "Gmail", description: "Read and draft email on your behalf.", category: "Email", }, DirectoryApp { id: "google-calendar", name: "Google Calendar", description: "Check availability and schedule events.", category: "Calendar", }, DirectoryApp { id: "google-drive", name: "Google Drive", description: "Search and read team documents.", category: "Storage", }, DirectoryApp { id: "notion", name: "Notion", description: "Read and update pages and databases.", category: "Docs", }, DirectoryApp { id: "linear", name: "Linear", description: "Track and file engineering issues.", category: "Project management", }, DirectoryApp { id: "github", name: "GitHub", description: "Review repos, issues, and pull requests.", category: "Engineering", }, DirectoryApp { id: "figma", name: "Figma", description: "Inspect design files and comments.", category: "Design", }, DirectoryApp { id: "zoom", name: "Zoom", description: "Schedule and summarize meetings.", category: "Meetings", }, DirectoryApp { id: "stripe", name: "Stripe", description: "Look up customers, invoices, and payments.", category: "Finance", }, DirectoryApp { id: "hubspot", name: "HubSpot", description: "Manage contacts and deals.", category: "CRM", }, DirectoryApp { id: "google-sheets", name: "Google Sheets", description: "Read and update spreadsheets.", category: "Docs", }, DirectoryApp { id: "slack", name: "Slack", description: "Respond on @mention in your channels.", category: "Chat", }, DirectoryApp { id: "telegram", name: "Telegram", description: "Send and receive messages.", category: "Chat", }, DirectoryApp { id: "webhook", name: "HTTP / Webhook", description: "Call any HTTP endpoint.", category: "Developer", }, DirectoryApp { id: "postgres", name: "Postgres", description: "Query your databases.", category: "Data", }, DirectoryApp { id: "aws", name: "AWS", description: "Inspect cloud resources.", category: "Infrastructure", }, DirectoryApp { id: "sendgrid", name: "SendGrid", description: "Deliver transactional email.", category: "Email", }, ] } #[derive(Deserialize)] pub struct DirectoryQuery { #[serde(rename = "clawId")] claw_id: Option, } /// GET /api/apps[?clawId=] — the connect directory (§8.2), with live /// connection status when scoped to a claw. pub async fn directory( State(state): State, Authed(user): Authed, Query(query): Query, ) -> Result>, ApiError> { let connections = match query.claw_id { Some(claw_id) => { let agent = workspace_agent(&state, &user, claw_id).await?; cm_db::repo::connections::list_for_agent(&state.pool, user.workspace_id, agent.id) .await? } None => Vec::new(), }; let merged = catalog() .into_iter() .map(|app| { let connection = connections.iter().find(|c| c.provider == app.id); json!({ "id": app.id, "name": app.name, "description": app.description, "category": app.category, "connected": connection.is_some(), "connection_id": connection.map(|c| c.id), }) }) .collect(); Ok(Json(merged)) } #[derive(Deserialize)] pub struct ConnectRequest { #[serde(rename = "clawId")] claw_id: AgentId, provider: String, #[serde(rename = "authType")] auth_type: String, /// The API key / token (keys) or "user:password" (basic). Sent once, /// stored encrypted by the broker, never readable again. secret: String, } /// POST /api/apps/connect — custom app credentials (§10 Add Custom App, /// keys/basic). OAuth and MCP-OAuth flows follow. pub async fn connect( State(state): State, Authed(user): Authed, Json(body): Json, ) -> Result<(StatusCode, Json), ApiError> { if !matches!(body.auth_type.as_str(), "keys" | "basic") { return Err(ApiError::Conflict); } let agent = workspace_agent(&state, &user, body.claw_id).await?; let socket = state.broker_socket.as_ref().ok_or(ApiError::Internal)?; let mut broker = cm_secrets::BrokerClient::connect(socket) .await .map_err(|_| ApiError::Internal)?; let secret_ref = broker .store_secret( user.workspace_id, &format!("{}_{}", body.provider, body.auth_type), &body.secret, ) .await .map_err(|_| ApiError::Internal)?; let connection = cm_db::repo::connections::insert( &state.pool, user.workspace_id, Some(agent.id), &body.provider, &body.auth_type, secret_ref, ) .await?; cm_db::repo::audit::append( &state.pool, user.workspace_id, Actor::User(user.user_id), "app.connected", "app_connection", &connection.id.to_string(), json!({"provider": body.provider, "auth_type": body.auth_type}), ) .await?; Ok(( StatusCode::CREATED, Json(json!({"id": connection.id, "provider": connection.provider, "status": "connected"})), )) } #[derive(Deserialize)] pub struct DisconnectRequest { #[serde(rename = "connectionId")] connection_id: Uuid, } /// POST /api/apps/disconnect pub async fn disconnect( State(state): State, Authed(user): Authed, Json(body): Json, ) -> Result { cm_db::repo::connections::disconnect(&state.pool, body.connection_id).await?; cm_db::repo::audit::append( &state.pool, user.workspace_id, Actor::User(user.user_id), "app.disconnected", "app_connection", &body.connection_id.to_string(), json!({}), ) .await?; Ok(StatusCode::NO_CONTENT) }