//! WASM-compatible OpenRPC client for Hero Supervisor //! //! This module provides a WASM-compatible client library for interacting with the Hero Supervisor //! OpenRPC server using browser-native fetch APIs. use wasm_bindgen::prelude::*; use wasm_bindgen_futures::JsFuture; use web_sys::{Headers, Request, RequestInit, RequestMode, Response}; use serde_json::json; use secp256k1::{Message, PublicKey, Secp256k1, SecretKey, ecdsa::Signature}; use sha2::{Sha256, Digest}; use serde::{Deserialize, Serialize}; use thiserror::Error; use uuid::Uuid; /// WASM-compatible client for communicating with Hero Supervisor OpenRPC server #[wasm_bindgen] #[derive(Clone)] pub struct WasmSupervisorClient { server_url: String, secret: Option, } /// Error types for WASM client operations #[derive(Error, Debug)] pub enum WasmClientError { #[error("Network error: {0}")] Network(String), #[error("Serialization error: {0}")] Serialization(#[from] serde_json::Error), #[error("JavaScript error: {0}")] JavaScript(String), #[error("Server error: {message}")] Server { message: String }, #[error("Invalid response format")] InvalidResponse, } /// Result type for WASM client operations pub type WasmClientResult = Result; /// Auth verification response #[derive(Debug, Clone, Serialize, Deserialize)] pub struct AuthVerifyResponse { pub valid: bool, pub name: String, pub scope: String, } /// JSON-RPC request structure #[derive(Serialize)] struct JsonRpcRequest { jsonrpc: String, method: String, params: serde_json::Value, id: u32, } /// JSON-RPC response structure #[derive(Deserialize)] struct JsonRpcResponse { jsonrpc: String, #[serde(skip_serializing_if = "Option::is_none")] result: Option, #[serde(skip_serializing_if = "Option::is_none")] error: Option, id: u32, } /// JSON-RPC error structure #[derive(Deserialize)] struct JsonRpcError { code: i32, message: String, #[serde(skip_serializing_if = "Option::is_none")] data: Option, } /// Types of runners supported by the supervisor #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[wasm_bindgen] pub enum WasmRunnerType { SALRunner, OSISRunner, VRunner, } /// Job type enumeration that maps to runner types #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[wasm_bindgen] pub enum WasmJobType { SAL, OSIS, V, } /// Job status enumeration #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub enum JobStatus { Pending, Running, Finished, Error, } /// Job error type #[derive(Debug, Clone, thiserror::Error)] pub enum JobError { #[error("Validation error: {0}")] Validation(String), #[error("Execution error: {0}")] Execution(String), #[error("Timeout error")] Timeout, } /// Job builder for WASM pub struct JobBuilder { id: Option, caller_id: Option, context_id: Option, payload: Option, runner: Option, executor: Option, timeout_secs: Option, env_vars: Option, } impl JobBuilder { pub fn new() -> Self { Self { id: None, caller_id: None, context_id: None, payload: None, runner: None, executor: None, timeout_secs: None, env_vars: None, } } pub fn caller_id(mut self, caller_id: &str) -> Self { self.caller_id = Some(caller_id.to_string()); self } pub fn context_id(mut self, context_id: &str) -> Self { self.context_id = Some(context_id.to_string()); self } pub fn payload(mut self, payload: &str) -> Self { self.payload = Some(payload.to_string()); self } pub fn runner(mut self, runner: &str) -> Self { self.runner = Some(runner.to_string()); self } pub fn executor(mut self, executor: &str) -> Self { self.executor = Some(executor.to_string()); self } pub fn timeout(mut self, timeout_secs: u64) -> Self { self.timeout_secs = Some(timeout_secs); self } pub fn build(self) -> Result { let now = chrono::Utc::now().to_rfc3339(); Ok(Job { id: self.id.unwrap_or_else(|| uuid::Uuid::new_v4().to_string()), caller_id: self.caller_id.ok_or_else(|| JobError::Validation("caller_id is required".to_string()))?, context_id: self.context_id.ok_or_else(|| JobError::Validation("context_id is required".to_string()))?, payload: self.payload.ok_or_else(|| JobError::Validation("payload is required".to_string()))?, runner: self.runner.ok_or_else(|| JobError::Validation("runner is required".to_string()))?, executor: self.executor.ok_or_else(|| JobError::Validation("executor is required".to_string()))?, timeout_secs: self.timeout_secs.unwrap_or(30), env_vars: self.env_vars.unwrap_or_else(|| "{}".to_string()), created_at: now.clone(), updated_at: now, }) } } /// Job structure for creating and managing jobs (alias for WasmJob) pub type Job = WasmJob; /// Job structure for creating and managing jobs #[derive(Debug, Clone, Serialize, Deserialize)] #[wasm_bindgen] pub struct WasmJob { id: String, caller_id: String, context_id: String, payload: String, runner: String, executor: String, timeout_secs: u64, env_vars: String, // JSON string of HashMap created_at: String, updated_at: String, } #[wasm_bindgen] impl WasmSupervisorClient { /// Create a new WASM supervisor client without authentication #[wasm_bindgen(constructor)] pub fn new(server_url: String) -> Self { console_log::init_with_level(log::Level::Info).ok(); Self { server_url, secret: None, } } /// Create a new WASM supervisor client with authentication secret #[wasm_bindgen] pub fn with_secret(server_url: String, secret: String) -> Self { console_log::init_with_level(log::Level::Info).ok(); Self { server_url, secret: Some(secret), } } /// Get the server URL #[wasm_bindgen(getter)] pub fn server_url(&self) -> String { self.server_url.clone() } /// Test connection using OpenRPC discovery method pub async fn discover(&self) -> Result { let result = self.call_method("rpc.discover", serde_json::Value::Null).await; match result { Ok(value) => Ok(wasm_bindgen::JsValue::from_str(&value.to_string())), Err(e) => Err(JsValue::from_str(&e.to_string())), } } /// Verify an API key and return its metadata as JSON /// The key is sent via Authorization header (Bearer token) pub async fn auth_verify(&self, key: String) -> Result { // Create a temporary client with the key to verify let temp_client = WasmSupervisorClient::with_secret(self.server_url.clone(), key); // Send empty object as params - the key is in the Authorization header let params = serde_json::json!({}); match temp_client.call_method("auth.verify", params).await { Ok(result) => { // Parse to AuthVerifyResponse to validate, then convert to JsValue let auth_response: AuthVerifyResponse = serde_json::from_value(result) .map_err(|e| JsValue::from_str(&format!("Failed to parse auth response: {}", e)))?; // Convert to JsValue serde_wasm_bindgen::to_value(&auth_response) .map_err(|e| JsValue::from_str(&format!("Failed to convert to JsValue: {}", e))) } Err(e) => Err(JsValue::from_str(&format!("Failed to verify auth: {}", e))), } } /// Verify the client's stored API key /// Uses the secret that was set when creating the client with with_secret() pub async fn auth_verify_self(&self) -> Result { let key = self.secret.as_ref() .ok_or_else(|| JsValue::from_str("Client not authenticated - use with_secret() to create authenticated client"))?; self.auth_verify(key.clone()).await } /// Create a new API key (admin only) /// Returns the created API key with its key string pub async fn auth_create_key(&self, name: String, scope: String) -> Result { let params = serde_json::json!({ "name": name, "scope": scope }); match self.call_method("auth.create_key", params).await { Ok(result) => Ok(serde_wasm_bindgen::to_value(&result) .map_err(|e| JsValue::from_str(&format!("Failed to convert result: {}", e)))?), Err(e) => Err(JsValue::from_str(&format!("Failed to create key: {}", e))), } } /// List all API keys (admin only) pub async fn auth_list_keys(&self) -> Result { match self.call_method("auth.list_keys", serde_json::Value::Null).await { Ok(result) => Ok(serde_wasm_bindgen::to_value(&result) .map_err(|e| JsValue::from_str(&format!("Failed to convert result: {}", e)))?), Err(e) => Err(JsValue::from_str(&format!("Failed to list keys: {}", e))), } } /// Remove an API key (admin only) pub async fn auth_remove_key(&self, key: String) -> Result { let params = serde_json::json!({ "key": key }); match self.call_method("auth.remove_key", params).await { Ok(result) => { if let Some(success) = result.as_bool() { Ok(success) } else { Err(JsValue::from_str("Invalid response format: expected boolean")) } }, Err(e) => Err(JsValue::from_str(&format!("Failed to remove key: {}", e))), } } /// Register a new runner to the supervisor /// The queue name is automatically set to match the runner name /// Authentication uses the secret from Authorization header (set during client creation) pub async fn register_runner(&self, name: String) -> Result { // Secret is sent via Authorization header, not in params let params = serde_json::json!({ "name": name }); match self.call_method("register_runner", params).await { Ok(result) => { // Extract the runner name from the result if let Some(runner) = result.as_str() { Ok(runner.to_string()) } else { Err(JsValue::from_str("Invalid response format: expected runner name")) } }, Err(e) => Err(JsValue::from_str(&format!("Failed to register runner: {}", e))), } } /// Create a job (fire-and-forget, non-blocking) - DEPRECATED: Use create_job with API key auth #[wasm_bindgen] pub async fn create_job_with_secret(&self, secret: String, job: WasmJob) -> Result { // Backend expects RunJobParams struct with secret and job fields - wrap in array like register_runner let params = serde_json::json!([{ "secret": secret, "job": { "id": job.id, "caller_id": job.caller_id, "context_id": job.context_id, "payload": job.payload, "runner": job.runner, "executor": job.executor, "timeout": { "secs": job.timeout_secs, "nanos": 0 }, "env_vars": serde_json::from_str::(&job.env_vars).unwrap_or(serde_json::json!({})), "created_at": job.created_at, "updated_at": job.updated_at } }]); match self.call_method("create_job", params).await { Ok(result) => { if let Some(job_id) = result.as_str() { Ok(job_id.to_string()) } else { Ok(result.to_string()) } } Err(e) => Err(JsValue::from_str(&format!("Failed to create job: {:?}", e))) } } /// Run a job on a specific runner (blocking, returns result) #[wasm_bindgen] pub async fn run_job(&self, secret: String, job: WasmJob) -> Result { // Backend expects RunJobParams struct with secret and job fields - wrap in array like register_runner let params = serde_json::json!([{ "secret": secret, "job": { "id": job.id, "caller_id": job.caller_id, "context_id": job.context_id, "payload": job.payload, "runner": job.runner, "executor": job.executor, "timeout": { "secs": job.timeout_secs, "nanos": 0 }, "env_vars": serde_json::from_str::(&job.env_vars).unwrap_or(serde_json::json!({})), "created_at": job.created_at, "updated_at": job.updated_at } }]); match self.call_method("jobs.run", params).await { Ok(result) => { if let Some(result_str) = result.as_str() { Ok(result_str.to_string()) } else { Ok(result.to_string()) } }, Err(e) => Err(JsValue::from_str(&e.to_string())), } } /// List all runner IDs pub async fn list_runners(&self) -> Result, JsValue> { match self.call_method("list_runners", serde_json::Value::Null).await { Ok(result) => { if let Ok(runners) = serde_json::from_value::>(result) { Ok(runners) } else { Err(JsValue::from_str("Invalid response format for list_runners")) } }, Err(e) => Err(JsValue::from_str(&e.to_string())), } } /// Create a job from a JsValue (full Job object) pub async fn create_job(&self, job: JsValue) -> Result { // Convert JsValue to serde_json::Value let job_value: serde_json::Value = serde_wasm_bindgen::from_value(job) .map_err(|e| JsValue::from_str(&format!("Failed to parse job: {}", e)))?; // Wrap in RunJobParams structure and pass as positional parameter let params = serde_json::json!([{ "job": job_value }]); match self.call_method("jobs.create", params).await { Ok(result) => { if let Some(job_id) = result.as_str() { Ok(job_id.to_string()) } else { Err(JsValue::from_str("Invalid response format: expected job ID")) } }, Err(e) => Err(JsValue::from_str(&format!("Failed to create job: {}", e))), } } /// Create a job with basic parameters (simplified version) pub async fn create_simple_job( &self, runner: String, caller_id: String, context_id: String, payload: String, executor: String, ) -> Result { // Generate a unique job ID let job_id = format!("job-{}", uuid::Uuid::new_v4()); let job = serde_json::json!({ "id": job_id, "runner": runner, "caller_id": caller_id, "context_id": context_id, "payload": payload, "executor": executor, "timeout": 30, "env": {} }); let params = serde_json::json!({ "job": job }); match self.call_method("jobs.create", params).await { Ok(result) => { if let Some(job_id) = result.as_str() { Ok(job_id.to_string()) } else { Err(JsValue::from_str("Invalid response format: expected job ID")) } }, Err(e) => Err(JsValue::from_str(&format!("Failed to create job: {}", e))), } } /// Get a job by job ID pub async fn get_job(&self, job_id: &str) -> Result { let params = serde_json::json!([job_id]); match self.call_method("get_job", params).await { Ok(result) => { // Convert the Job result to WasmJob if let Ok(job_value) = serde_json::from_value::(result) { // Extract fields from the job let id = job_value.get("id").and_then(|v| v.as_str()).unwrap_or("").to_string(); let caller_id = job_value.get("caller_id").and_then(|v| v.as_str()).unwrap_or("").to_string(); let context_id = job_value.get("context_id").and_then(|v| v.as_str()).unwrap_or("").to_string(); let payload = job_value.get("payload").and_then(|v| v.as_str()).unwrap_or("").to_string(); let runner = job_value.get("runner").and_then(|v| v.as_str()).unwrap_or("").to_string(); let executor = job_value.get("executor").and_then(|v| v.as_str()).unwrap_or("").to_string(); let timeout_secs = job_value.get("timeout").and_then(|v| v.get("secs")).and_then(|v| v.as_u64()).unwrap_or(30); let env_vars = job_value.get("env_vars").map(|v| v.to_string()).unwrap_or_else(|| "{}".to_string()); let created_at = job_value.get("created_at").and_then(|v| v.as_str()).unwrap_or("").to_string(); let updated_at = job_value.get("updated_at").and_then(|v| v.as_str()).unwrap_or("").to_string(); Ok(WasmJob { id, caller_id, context_id, payload, runner, executor, timeout_secs, env_vars, created_at, updated_at, }) } else { Err(JsValue::from_str("Invalid response format for get_job")) } }, Err(e) => Err(JsValue::from_str(&e.to_string())), } } /// Ping a runner by dispatching a ping job to its queue #[wasm_bindgen] pub async fn ping_runner(&self, runner_id: &str) -> Result { let params = serde_json::json!([runner_id]); match self.call_method("ping_runner", params).await { Ok(result) => { if let Some(job_id) = result.as_str() { Ok(job_id.to_string()) } else { Ok(result.to_string()) } } Err(e) => Err(JsValue::from_str(&format!("Failed to ping runner: {:?}", e))) } } /// Stop a job by ID #[wasm_bindgen] pub async fn stop_job(&self, job_id: &str) -> Result<(), JsValue> { let params = serde_json::json!([job_id]); match self.call_method("stop_job", params).await { Ok(_) => Ok(()), Err(e) => Err(JsValue::from_str(&format!("Failed to stop job: {:?}", e))) } } /// Delete a job by ID #[wasm_bindgen] pub async fn delete_job(&self, job_id: &str) -> Result<(), JsValue> { let params = serde_json::json!([{ "job_id": job_id }]); match self.call_method("job.delete", params).await { Ok(_) => Ok(()), Err(e) => Err(JsValue::from_str(&format!("Failed to delete job: {:?}", e))) } } /// Remove a runner from the supervisor pub async fn remove_runner(&self, actor_id: &str) -> Result<(), JsValue> { let params = serde_json::json!([actor_id]); match self.call_method("remove_runner", params).await { Ok(_) => Ok(()), Err(e) => Err(JsValue::from_str(&e.to_string())), } } /// Start a specific runner pub async fn start_runner(&self, actor_id: &str) -> Result<(), JsValue> { let params = serde_json::json!([actor_id]); match self.call_method("start_runner", params).await { Ok(_) => Ok(()), Err(e) => Err(JsValue::from_str(&e.to_string())), } } /// Stop a specific runner pub async fn stop_runner(&self, actor_id: &str, force: bool) -> Result<(), JsValue> { let params = serde_json::json!([actor_id, force]); self.call_method("stop_runner", params) .await .map_err(|e| JsValue::from_str(&e.to_string()))?; Ok(()) } /// Get a specific runner by ID pub async fn get_runner(&self, actor_id: &str) -> Result { let params = serde_json::json!([actor_id]); let result = self.call_method("get_runner", params) .await .map_err(|e| JsValue::from_str(&e.to_string()))?; // Convert the serde_json::Value to a JsValue via string serialization let json_string = serde_json::to_string(&result) .map_err(|e| JsValue::from_str(&e.to_string()))?; Ok(js_sys::JSON::parse(&json_string) .map_err(|e| JsValue::from_str("Failed to parse JSON"))?) } /// Add a secret to the supervisor pub async fn add_secret(&self, admin_secret: &str, secret_type: &str, secret_value: &str) -> Result<(), JsValue> { let params = serde_json::json!([{ "admin_secret": admin_secret, "secret_type": secret_type, "secret_value": secret_value }]); match self.call_method("add_secret", params).await { Ok(_) => Ok(()), Err(e) => Err(JsValue::from_str(&e.to_string())), } } /// Remove a secret from the supervisor pub async fn remove_secret(&self, admin_secret: &str, secret_type: &str, secret_value: &str) -> Result<(), JsValue> { let params = serde_json::json!([{ "admin_secret": admin_secret, "secret_type": secret_type, "secret_value": secret_value }]); match self.call_method("remove_secret", params).await { Ok(_) => Ok(()), Err(e) => Err(JsValue::from_str(&e.to_string())), } } /// List secrets (returns supervisor info including secret counts) pub async fn list_secrets(&self, admin_secret: &str) -> Result { let params = serde_json::json!([{ "admin_secret": admin_secret }]); match self.call_method("list_secrets", params).await { Ok(result) => { // Convert serde_json::Value to JsValue let result_str = serde_json::to_string(&result) .map_err(|e| JsValue::from_str(&e.to_string()))?; Ok(js_sys::JSON::parse(&result_str) .map_err(|e| JsValue::from_str(&format!("JSON parse error: {:?}", e)))?) }, Err(e) => Err(JsValue::from_str(&e.to_string())), } } /// Get supervisor information including secret counts pub async fn get_supervisor_info(&self, admin_secret: &str) -> Result { let params = serde_json::json!({ "admin_secret": admin_secret }); match self.call_method("get_supervisor_info", params).await { Ok(result) => { let result_str = serde_json::to_string(&result) .map_err(|e| JsValue::from_str(&format!("Serialization error: {:?}", e)))?; Ok(js_sys::JSON::parse(&result_str) .map_err(|e| JsValue::from_str(&format!("JSON parse error: {:?}", e)))?) }, Err(e) => Err(JsValue::from_str(&format!("Failed to get supervisor info: {:?}", e))), } } /// List admin secrets (returns actual secret values) pub async fn list_admin_secrets(&self, admin_secret: &str) -> Result, JsValue> { let params = serde_json::json!({ "admin_secret": admin_secret }); match self.call_method("list_admin_secrets", params).await { Ok(result) => { let secrets: Vec = serde_json::from_value(result) .map_err(|e| JsValue::from_str(&format!("Failed to parse admin secrets: {:?}", e)))?; Ok(secrets) }, Err(e) => Err(JsValue::from_str(&format!("Failed to list admin secrets: {:?}", e))), } } /// List user secrets (returns actual secret values) pub async fn list_user_secrets(&self, admin_secret: &str) -> Result, JsValue> { let params = serde_json::json!({ "admin_secret": admin_secret }); match self.call_method("list_user_secrets", params).await { Ok(result) => { let secrets: Vec = serde_json::from_value(result) .map_err(|e| JsValue::from_str(&format!("Failed to parse user secrets: {:?}", e)))?; Ok(secrets) }, Err(e) => Err(JsValue::from_str(&format!("Failed to list user secrets: {:?}", e))), } } /// List register secrets (returns actual secret values) pub async fn list_register_secrets(&self, admin_secret: &str) -> Result, JsValue> { let params = serde_json::json!({ "admin_secret": admin_secret }); match self.call_method("list_register_secrets", params).await { Ok(result) => { let secrets: Vec = serde_json::from_value(result) .map_err(|e| JsValue::from_str(&format!("Failed to parse register secrets: {:?}", e)))?; Ok(secrets) }, Err(e) => Err(JsValue::from_str(&format!("Failed to list register secrets: {:?}", e))), } } } #[wasm_bindgen] impl WasmJob { /// Create a new job with default values #[wasm_bindgen(constructor)] pub fn new(id: String, payload: String, executor: String, runner: String) -> Self { let now = js_sys::Date::new_0().to_iso_string().as_string().unwrap(); Self { id, caller_id: "wasm_client".to_string(), context_id: "wasm_context".to_string(), payload, runner, executor, timeout_secs: 30, env_vars: "{}".to_string(), created_at: now.clone(), updated_at: now, } } /// Set the caller ID #[wasm_bindgen(setter)] pub fn set_caller_id(&mut self, caller_id: String) { self.caller_id = caller_id; } /// Set the context ID #[wasm_bindgen(setter)] pub fn set_context_id(&mut self, context_id: String) { self.context_id = context_id; } /// Set the timeout in seconds #[wasm_bindgen(setter)] pub fn set_timeout_secs(&mut self, timeout_secs: u64) { self.timeout_secs = timeout_secs; } /// Set environment variables as JSON string #[wasm_bindgen(setter)] pub fn set_env_vars(&mut self, env_vars: String) { self.env_vars = env_vars; } /// Generate a new UUID for the job #[wasm_bindgen] pub fn generate_id(&mut self) { self.id = Uuid::new_v4().to_string(); } /// Get the job ID #[wasm_bindgen(getter)] pub fn id(&self) -> String { self.id.clone() } /// Get the caller ID #[wasm_bindgen(getter)] pub fn caller_id(&self) -> String { self.caller_id.clone() } /// Get the context ID #[wasm_bindgen(getter)] pub fn context_id(&self) -> String { self.context_id.clone() } /// Get the payload #[wasm_bindgen(getter)] pub fn payload(&self) -> String { self.payload.clone() } /// Get the job type #[wasm_bindgen(getter)] pub fn executor(&self) -> String { self.executor.clone() } /// Get the runner name #[wasm_bindgen(getter)] pub fn runner(&self) -> String { self.runner.clone() } /// Get the timeout in seconds #[wasm_bindgen(getter)] pub fn timeout_secs(&self) -> u64 { self.timeout_secs } /// Get the environment variables as JSON string #[wasm_bindgen(getter)] pub fn env_vars(&self) -> String { self.env_vars.clone() } /// Get the created timestamp #[wasm_bindgen(getter)] pub fn created_at(&self) -> String { self.created_at.clone() } /// Get the updated timestamp #[wasm_bindgen(getter)] pub fn updated_at(&self) -> String { self.updated_at.clone() } } impl WasmSupervisorClient { /// List all jobs (returns full job objects as Vec) /// This is not exposed to WASM directly due to type limitations pub async fn list_jobs(&self) -> Result, JsValue> { let params = serde_json::json!([]); match self.call_method("jobs.list", params).await { Ok(result) => { if let Ok(jobs) = serde_json::from_value::>(result) { Ok(jobs) } else { Err(JsValue::from_str("Invalid response format for jobs.list")) } }, Err(e) => Err(JsValue::from_str(&e.to_string())), } } /// Start a previously created job by queuing it to its assigned runner pub async fn start_job(&self, job_id: &str) -> Result<(), JsValue> { let params = serde_json::json!([{ "job_id": job_id }]); match self.call_method("job.start", params).await { Ok(_) => Ok(()), Err(e) => Err(JsValue::from_str(&e.to_string())), } } /// Get the status of a job pub async fn get_job_status(&self, job_id: &str) -> Result { let params = serde_json::json!([job_id]); match self.call_method("job.status", params).await { Ok(result) => Ok(result), Err(e) => Err(JsValue::from_str(&e.to_string())), } } /// Get the result of a completed job pub async fn get_job_result(&self, job_id: &str) -> Result { let params = serde_json::json!([job_id]); match self.call_method("job.result", params).await { Ok(result) => Ok(result), Err(e) => Err(JsValue::from_str(&e.to_string())), } } /// Internal method to make JSON-RPC calls async fn call_method(&self, method: &str, params: serde_json::Value) -> WasmClientResult { let request = JsonRpcRequest { jsonrpc: "2.0".to_string(), method: method.to_string(), params, id: 1, }; let body = serde_json::to_string(&request)?; // Create headers let headers = Headers::new().map_err(|e| WasmClientError::JavaScript(format!("{:?}", e)))?; headers.set("Content-Type", "application/json") .map_err(|e| WasmClientError::JavaScript(format!("{:?}", e)))?; // Add Authorization header if secret is present if let Some(secret) = &self.secret { headers.set("Authorization", &format!("Bearer {}", secret)) .map_err(|e| WasmClientError::JavaScript(format!("{:?}", e)))?; } // Create request init let opts = RequestInit::new(); opts.set_method("POST"); opts.set_headers(&headers); opts.set_body(&JsValue::from_str(&body)); opts.set_mode(RequestMode::Cors); // Create request let request = Request::new_with_str_and_init(&self.server_url, &opts) .map_err(|e| WasmClientError::JavaScript(format!("{:?}", e)))?; // Get window and fetch let window = web_sys::window().ok_or_else(|| WasmClientError::JavaScript("No window object".to_string()))?; let resp_value = JsFuture::from(window.fetch_with_request(&request)).await .map_err(|e| WasmClientError::Network(format!("{:?}", e)))?; // Convert to Response let resp: Response = resp_value.dyn_into() .map_err(|e| WasmClientError::JavaScript(format!("{:?}", e)))?; // Check if response is ok if !resp.ok() { return Err(WasmClientError::Network(format!("HTTP {}: {}", resp.status(), resp.status_text()))); } // Get response text let text_promise = resp.text() .map_err(|e| WasmClientError::JavaScript(format!("{:?}", e)))?; let text_value = JsFuture::from(text_promise).await .map_err(|e| WasmClientError::Network(format!("{:?}", e)))?; let text = text_value.as_string() .ok_or_else(|| WasmClientError::InvalidResponse)?; // Parse JSON-RPC response let response: JsonRpcResponse = serde_json::from_str(&text)?; if let Some(error) = response.error { return Err(WasmClientError::Server { message: format!("{}: {}", error.code, error.message), }); } // For void methods, null result is valid Ok(response.result.unwrap_or(serde_json::Value::Null)) } } /// Initialize the WASM client library (call manually if needed) pub fn init() { console_log::init_with_level(log::Level::Info).ok(); log::info!("Hero Supervisor WASM OpenRPC Client initialized"); } /// Utility function to create a job from JavaScript /// Create a new job (convenience function for JavaScript) #[wasm_bindgen] pub fn create_job(id: String, payload: String, executor: String, runner: String) -> WasmJob { WasmJob::new(id, payload, executor, runner) } /// Utility function to create a client from JavaScript #[wasm_bindgen] pub fn create_client(server_url: String) -> WasmSupervisorClient { WasmSupervisorClient::new(server_url) } /// Sign a job's canonical representation with a private key /// Returns a tuple of (public_key_hex, signature_hex) #[wasm_bindgen] pub fn sign_job_canonical( canonical_repr: String, private_key_hex: String, ) -> Result { // Decode private key from hex let secret_bytes = hex::decode(&private_key_hex) .map_err(|e| JsValue::from_str(&format!("Invalid private key hex: {}", e)))?; let secret_key = SecretKey::from_slice(&secret_bytes) .map_err(|e| JsValue::from_str(&format!("Invalid private key: {}", e)))?; // Get the public key let secp = Secp256k1::new(); let public_key = PublicKey::from_secret_key(&secp, &secret_key); let public_key_hex = hex::encode(public_key.serialize()); // Hash the canonical representation let mut hasher = Sha256::new(); hasher.update(canonical_repr.as_bytes()); let hash = hasher.finalize(); // Create message from hash let message = Message::from_digest_slice(&hash) .map_err(|e| JsValue::from_str(&format!("Invalid message: {}", e)))?; // Sign the message let signature = secp.sign_ecdsa(&message, &secret_key); let signature_hex = hex::encode(signature.serialize_compact()); // Return as JS object let result = serde_json::json!({ "public_key": public_key_hex, "signature": signature_hex }); serde_wasm_bindgen::to_value(&result) .map_err(|e| JsValue::from_str(&format!("Failed to serialize result: {}", e))) } /// Create canonical representation of a job for signing /// This matches the format used in runner_rust Job::canonical_representation #[wasm_bindgen] pub fn create_job_canonical_repr( id: String, caller_id: String, context_id: String, payload: String, runner: String, executor: String, timeout: u64, env_vars_json: String, ) -> Result { // Parse env_vars from JSON let env_vars: std::collections::HashMap = serde_json::from_str(&env_vars_json) .map_err(|e| JsValue::from_str(&format!("Invalid env_vars JSON: {}", e)))?; // Sort env_vars keys for deterministic ordering let mut env_vars_sorted: Vec<_> = env_vars.iter().collect(); env_vars_sorted.sort_by_key(|&(k, _)| k); // Create canonical representation (matches Job::canonical_representation in runner_rust) let canonical = format!( "{}:{}:{}:{}:{}:{}:{}:{:?}", id, caller_id, context_id, payload, runner, executor, timeout, env_vars_sorted ); Ok(canonical) }