roboticus-api 0.11.3

HTTP routes, WebSocket, auth, rate limiting, and dashboard for the Roboticus agent runtime
Documentation
pub async fn a2a_hello(
    State(state): State<AppState>,
    axum::Json(body): axum::Json<A2aHelloRequest>,
) -> Result<impl IntoResponse, JsonError> {
    let peer_did =
        roboticus_channels::a2a::A2aProtocol::verify_hello(&body.hello).map_err(bad_request)?;

    let mut a2a = state.a2a.write().await;
    a2a.check_rate_limit(&peer_did)
        .map_err(|e| JsonError(StatusCode::TOO_MANY_REQUESTS, e.to_string()))?;
    drop(a2a);

    let config = state.config.read().await;
    let our_did = format!("did:roboticus:{}", config.agent.id);
    drop(config);

    let nonce = uuid::Uuid::new_v4();
    let our_hello = roboticus_channels::a2a::A2aProtocol::generate_hello(&our_did, nonce.as_bytes());

    Ok(axum::Json(json!({
        "protocol": "a2a",
        "version": "0.1",
        "status": "ok",
        "peer_did": peer_did,
        "hello": our_hello,
    })))
}

// ── Keystore / provider key management ───────────────────────

/// Map a keystore error to an HTTP status + message pair.
/// Locked → 503 Service Unavailable; anything else → 500.
fn keystore_error_to_status(e: roboticus_core::RoboticusError) -> (StatusCode, String) {
    let status = if e.is_keystore_locked() {
        StatusCode::SERVICE_UNAVAILABLE
    } else {
        StatusCode::INTERNAL_SERVER_ERROR
    };
    (status, e.to_string())
}

#[derive(Deserialize)]
pub struct SetProviderKeyRequest {
    pub api_key: String,
}

pub async fn set_provider_key(
    State(state): State<AppState>,
    Path(name): Path<String>,
    axum::Json(body): axum::Json<SetProviderKeyRequest>,
) -> std::result::Result<impl IntoResponse, JsonError> {
    let key = body.api_key.trim();
    if key.is_empty() {
        return Err(bad_request("api_key cannot be empty"));
    }

    let config = state.config.read().await;
    if !config.providers.contains_key(&name) {
        return Err(not_found(format!("provider '{name}' not found in config")));
    }
    drop(config);

    let ks_name = format!("{name}_api_key");
    state.keystore.set(&ks_name, key).map_err(|e| {
        let is_locked = e.is_keystore_locked();
        tracing::error!(provider = %name, error = %e, locked = is_locked, "failed to store API key in keystore");
        keystore_error_to_status(e)
    })?;

    tracing::info!(provider = %name, keystore_entry = %ks_name, "API key stored in keystore via dashboard");

    Ok(axum::Json(json!({
        "stored": true,
        "provider": name,
        "keystore_entry": ks_name,
    })))
}

pub async fn delete_provider_key(
    State(state): State<AppState>,
    Path(name): Path<String>,
) -> std::result::Result<impl IntoResponse, JsonError> {
    let config = state.config.read().await;
    if !config.providers.contains_key(&name) {
        return Err(not_found(format!("provider '{name}' not found in config")));
    }
    drop(config);

    let ks_name = format!("{name}_api_key");
    let removed = state.keystore.remove(&ks_name).map_err(|e| {
        let is_locked = e.is_keystore_locked();
        tracing::error!(provider = %name, error = %e, locked = is_locked, "failed to remove API key from keystore");
        keystore_error_to_status(e)
    })?;

    if removed {
        tracing::info!(provider = %name, keystore_entry = %ks_name, "API key removed from keystore via dashboard");
    }

    Ok(axum::Json(json!({
        "removed": removed,
        "provider": name,
        "keystore_entry": ks_name,
    })))
}

// ── Keystore status & unlock ────────────────────────────────

pub async fn keystore_status(
    State(state): State<AppState>,
) -> impl IntoResponse {
    axum::Json(json!({
        "unlocked": state.keystore.is_unlocked(),
        "path": roboticus_core::keystore::Keystore::default_path().display().to_string(),
    }))
}

pub async fn keystore_unlock(
    State(state): State<AppState>,
) -> std::result::Result<impl IntoResponse, JsonError> {
    if state.keystore.is_unlocked() {
        return Ok(axum::Json(json!({
            "unlocked": true,
            "action": "already_unlocked",
        })));
    }

    state.keystore.unlock_machine().map_err(|e| {
        tracing::warn!(error = %e, "keystore unlock via dashboard failed");
        JsonError(
            StatusCode::CONFLICT,
            format!("keystore unlock failed: {e}"),
        )
    })?;

    tracing::info!("keystore unlocked via dashboard");
    Ok(axum::Json(json!({
        "unlocked": true,
        "action": "unlocked",
    })))
}