app

Local-first trade for farms and co-ops
git clone https://radroots.dev/git/app.git
Log | Files | Refs | README | LICENSE

commit 977fc15b6e3c4b184b08e805ded8f10f412fe024
parent caaf19a7c2c0c3b1b5a02c56cdfb6b309cff8388
Author: triesap <triesap@radroots.dev>
Date:   Mon, 19 Jan 2026 04:21:50 +0000

app-core: add radroots web client

- add base url sanitization and nostr attest header builder
- implement radroots web requests for accounts and media upload
- expose radroots web module and add base url tests
- add url workspace dependency for app core

Diffstat:
MCargo.lock | 1+
MCargo.toml | 1+
Mcrates/core/Cargo.toml | 1+
Mcrates/core/src/radroots/mod.rs | 2++
Acrates/core/src/radroots/web.rs | 386+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
5 files changed, 391 insertions(+), 0 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -1481,6 +1481,7 @@ dependencies = [ "serde", "serde-wasm-bindgen", "serde_json", + "url", "wasm-bindgen", "wasm-bindgen-futures", "web-sys", diff --git a/Cargo.toml b/Cargo.toml @@ -63,6 +63,7 @@ wasm-bindgen-futures = "0.4" base64 = "0.22" serde-wasm-bindgen = "0.6" rusqlite = { version = "0.31", default-features = false } +url = "2" radroots-nostr = { path = "refs/crates/nostr" } [profile.release] diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml @@ -17,6 +17,7 @@ getrandom = { workspace = true } base64 = { workspace = true } radroots-nostr = { workspace = true } rusqlite = { workspace = true, features = ["bundled", "serialize"] } +url = { workspace = true } [target.'cfg(target_arch = "wasm32")'.dependencies] js-sys = { workspace = true } diff --git a/crates/core/src/radroots/mod.rs b/crates/core/src/radroots/mod.rs @@ -1,5 +1,6 @@ pub mod error; pub mod types; +pub mod web; pub use error::{RadrootsClientRadrootsError, RadrootsClientRadrootsErrorMessage}; pub use types::{ @@ -11,3 +12,4 @@ pub use types::{ RadrootsClientRadrootsAccountsRequest, RadrootsClientRadrootsResult, }; +pub use web::RadrootsClientWebRadroots; diff --git a/crates/core/src/radroots/web.rs b/crates/core/src/radroots/web.rs @@ -0,0 +1,386 @@ +use async_trait::async_trait; +#[cfg(target_arch = "wasm32")] +use std::str::FromStr; +#[cfg(target_arch = "wasm32")] +use base64::engine::general_purpose::STANDARD; +#[cfg(target_arch = "wasm32")] +use base64::Engine as _; +#[cfg(target_arch = "wasm32")] +use radroots_nostr::prelude::{ + RadrootsNostrEventBuilder, + RadrootsNostrKeys, + RadrootsNostrSecretKey, +}; +#[cfg(target_arch = "wasm32")] +use serde::Deserialize; +use url::Url; + +#[cfg(target_arch = "wasm32")] +use wasm_bindgen::{JsCast, JsValue}; +#[cfg(target_arch = "wasm32")] +use wasm_bindgen_futures::JsFuture; + +#[cfg(target_arch = "wasm32")] +use serde_json::Value; +#[cfg(target_arch = "wasm32")] +use crate::crypto::random::fill_random; + +use super::{ + RadrootsClientMediaImageUpload, + RadrootsClientMediaResource, + RadrootsClientRadroots, + RadrootsClientRadrootsAccountsActivate, + RadrootsClientRadrootsAccountsCreate, + RadrootsClientRadrootsAccountsRequest, + RadrootsClientRadrootsError, + RadrootsClientRadrootsResult, +}; + +#[cfg(target_arch = "wasm32")] +#[derive(Deserialize)] +struct MediaResourceWire { + base_url: String, + hash: String, + ext: String, +} + +pub struct RadrootsClientWebRadroots { + base_url: Option<String>, +} + +impl RadrootsClientWebRadroots { + pub fn new(base_url: Option<&str>) -> Self { + let base_url = base_url.and_then(sanitize_base_url); + Self { base_url } + } + + pub fn get_base_url(&self) -> Option<&str> { + self.base_url.as_deref() + } + + fn require_base_url(&self) -> RadrootsClientRadrootsResult<&str> { + self.base_url + .as_deref() + .ok_or(RadrootsClientRadrootsError::MissingBaseUrl) + } + + #[cfg(target_arch = "wasm32")] + fn create_x_nostr_event( + &self, + secret_key: &str, + ) -> RadrootsClientRadrootsResult<String> { + let secret_key = RadrootsNostrSecretKey::from_str(secret_key) + .map_err(|_| RadrootsClientRadrootsError::RequestFailure)?; + let keys = RadrootsNostrKeys::new(secret_key); + let content = random_content()?; + let event = RadrootsNostrEventBuilder::text_note(content) + .sign_with_keys(&keys) + .map_err(|_| RadrootsClientRadrootsError::RequestFailure)?; + serde_json::to_string(&event) + .map_err(|_| RadrootsClientRadrootsError::RequestFailure) + } + + #[cfg(target_arch = "wasm32")] + async fn send_json( + &self, + url: &str, + method: &str, + headers: Vec<(String, String)>, + body: Option<Value>, + ) -> RadrootsClientRadrootsResult<Option<Value>> { + let window = web_sys::window().ok_or(RadrootsClientRadrootsError::RequestFailure)?; + let mut init = web_sys::RequestInit::new(); + init.method(method); + let header_map = web_sys::Headers::new() + .map_err(|_| RadrootsClientRadrootsError::RequestFailure)?; + for (key, value) in headers { + header_map + .set(&key, &value) + .map_err(|_| RadrootsClientRadrootsError::RequestFailure)?; + } + if let Some(body) = body { + let body = serde_json::to_string(&body) + .map_err(|_| RadrootsClientRadrootsError::RequestFailure)?; + init.body(Some(&JsValue::from_str(&body))); + } + init.headers(&header_map); + let request = web_sys::Request::new_with_str_and_init(url, &init) + .map_err(|_| RadrootsClientRadrootsError::RequestFailure)?; + let response = JsFuture::from(window.fetch_with_request(&request)) + .await + .map_err(|_| RadrootsClientRadrootsError::RequestFailure)?; + let response: web_sys::Response = response + .dyn_into() + .map_err(|_| RadrootsClientRadrootsError::RequestFailure)?; + if !response.ok() { + return Err(RadrootsClientRadrootsError::RequestFailure); + } + parse_response(response).await + } + + #[cfg(target_arch = "wasm32")] + async fn send_bytes( + &self, + url: &str, + method: &str, + headers: Vec<(String, String)>, + body: &[u8], + ) -> RadrootsClientRadrootsResult<Option<Value>> { + let window = web_sys::window().ok_or(RadrootsClientRadrootsError::RequestFailure)?; + let mut init = web_sys::RequestInit::new(); + init.method(method); + let header_map = web_sys::Headers::new() + .map_err(|_| RadrootsClientRadrootsError::RequestFailure)?; + for (key, value) in headers { + header_map + .set(&key, &value) + .map_err(|_| RadrootsClientRadrootsError::RequestFailure)?; + } + let bytes = js_sys::Uint8Array::from(body); + init.body(Some(&bytes.into())); + init.headers(&header_map); + let request = web_sys::Request::new_with_str_and_init(url, &init) + .map_err(|_| RadrootsClientRadrootsError::RequestFailure)?; + let response = JsFuture::from(window.fetch_with_request(&request)) + .await + .map_err(|_| RadrootsClientRadrootsError::RequestFailure)?; + let response: web_sys::Response = response + .dyn_into() + .map_err(|_| RadrootsClientRadrootsError::RequestFailure)?; + if !response.ok() { + return Err(RadrootsClientRadrootsError::RequestFailure); + } + parse_response(response).await + } +} + +#[async_trait(?Send)] +impl RadrootsClientRadroots for RadrootsClientWebRadroots { + async fn accounts_request( + &self, + opts: RadrootsClientRadrootsAccountsRequest, + ) -> RadrootsClientRadrootsResult<String> { + let _ = self.require_base_url()?; + #[cfg(not(target_arch = "wasm32"))] + { + let _ = opts; + return Err(RadrootsClientRadrootsError::RequestFailure); + } + #[cfg(target_arch = "wasm32")] + { + let base_url = self.require_base_url()?; + let url = format!("{base_url}/v1/accounts/request"); + let event = self.create_x_nostr_event(&opts.secret_key)?; + let headers = vec![ + ("X-Nostr-Event".to_string(), event), + ("Content-Type".to_string(), "application/json".to_string()), + ]; + let body = serde_json::json!({ "profile_name": opts.profile_name }); + let data = self.send_json(&url, "POST", headers, Some(body)).await?; + if let Some(data) = data { + if is_pass_response(&data) { + if let Some(tok) = string_field(&data, "tok") { + return Ok(tok); + } + } + } + Err(RadrootsClientRadrootsError::AccountRegistered) + } + } + + async fn accounts_create( + &self, + opts: RadrootsClientRadrootsAccountsCreate, + ) -> RadrootsClientRadrootsResult<String> { + let _ = self.require_base_url()?; + #[cfg(not(target_arch = "wasm32"))] + { + let _ = opts; + return Err(RadrootsClientRadrootsError::RequestFailure); + } + #[cfg(target_arch = "wasm32")] + { + let base_url = self.require_base_url()?; + let url = format!("{base_url}/v1/accounts/create"); + let event = self.create_x_nostr_event(&opts.secret_key)?; + let token = encode_bearer_token(&opts.tok); + let headers = vec![ + ("X-Nostr-Event".to_string(), event), + ("Authorization".to_string(), format!("Bearer {token}")), + ]; + let data = self.send_json(&url, "POST", headers, None).await?; + if let Some(data) = data { + if is_pass_response(&data) { + if let Some(id) = string_field(&data, "id") { + return Ok(id); + } + } + } + Err(RadrootsClientRadrootsError::RequestFailure) + } + } + + async fn accounts_activate( + &self, + opts: RadrootsClientRadrootsAccountsActivate, + ) -> RadrootsClientRadrootsResult<String> { + let _ = self.require_base_url()?; + #[cfg(not(target_arch = "wasm32"))] + { + let _ = opts; + return Err(RadrootsClientRadrootsError::RequestFailure); + } + #[cfg(target_arch = "wasm32")] + { + let base_url = self.require_base_url()?; + let url = format!("{base_url}/v1/accounts/activate"); + let event = self.create_x_nostr_event(&opts.secret_key)?; + let headers = vec![ + ("X-Nostr-Event".to_string(), event), + ("Content-Type".to_string(), "application/json".to_string()), + ]; + let body = serde_json::json!({ "id": opts.id }); + let data = self.send_json(&url, "POST", headers, Some(body)).await?; + if let Some(data) = data { + if is_pass_response(&data) { + if let Some(id) = string_field(&data, "id") { + return Ok(id); + } + } + } + Err(RadrootsClientRadrootsError::RequestFailure) + } + } + + async fn media_image_upload( + &self, + opts: RadrootsClientMediaImageUpload, + ) -> RadrootsClientRadrootsResult<RadrootsClientMediaResource> { + let _ = self.require_base_url()?; + #[cfg(not(target_arch = "wasm32"))] + { + let _ = opts; + return Err(RadrootsClientRadrootsError::RequestFailure); + } + #[cfg(target_arch = "wasm32")] + { + let base_url = self.require_base_url()?; + let url = format!("{base_url}/v1/media/image/upload"); + let event = self.create_x_nostr_event(&opts.secret_key)?; + let mime_type = opts + .mime_type + .unwrap_or_else(|| String::from("image/png")); + let headers = vec![ + ("X-Nostr-Event".to_string(), event), + ("Content-Type".to_string(), mime_type), + ]; + let data = self + .send_bytes(&url, "PUT", headers, &opts.file_data) + .await?; + if let Some(data) = data { + if is_pass_response(&data) { + if let Some(resource) = parse_media_resource(&data) { + return Ok(resource); + } + } + } + Err(RadrootsClientRadrootsError::RequestFailure) + } + } +} + +fn sanitize_base_url(value: &str) -> Option<String> { + let trimmed = value.trim(); + if trimmed.is_empty() { + return None; + } + let parsed = Url::parse(trimmed).ok()?; + let base = format!("{}{}", parsed.origin().ascii_serialization(), parsed.path()); + Some(base.trim_end_matches('/').to_string()) +} + +#[cfg(target_arch = "wasm32")] +fn random_content() -> RadrootsClientRadrootsResult<String> { + let mut bytes = [0u8; 16]; + fill_random(&mut bytes).map_err(|_| RadrootsClientRadrootsError::RequestFailure)?; + Ok(STANDARD.encode(bytes)) +} + +#[cfg(target_arch = "wasm32")] +fn is_pass_response(value: &Value) -> bool { + matches!(value.get("pass"), Some(Value::Bool(true))) +} + +#[cfg(target_arch = "wasm32")] +fn string_field(value: &Value, key: &str) -> Option<String> { + value.get(key).and_then(|value| value.as_str()).map(|v| v.to_string()) +} + +#[cfg(target_arch = "wasm32")] +fn parse_media_resource(value: &Value) -> Option<RadrootsClientMediaResource> { + let resource: MediaResourceWire = serde_json::from_value(value.clone()).ok()?; + Some(RadrootsClientMediaResource { + base_url: resource.base_url, + hash: resource.hash, + ext: resource.ext, + }) +} + +#[cfg(target_arch = "wasm32")] +fn encode_bearer_token(value: &str) -> String { + url::form_urlencoded::byte_serialize(value.as_bytes()).collect() +} + +#[cfg(target_arch = "wasm32")] +async fn parse_response( + response: web_sys::Response, +) -> RadrootsClientRadrootsResult<Option<Value>> { + let json_response = response.clone().json(); + if let Ok(json_response) = json_response { + if let Ok(value) = JsFuture::from(json_response).await { + if let Ok(value) = serde_wasm_bindgen::from_value::<Value>(value) { + return Ok(Some(value)); + } + } + } + let text_response = response.text().map_err(|_| RadrootsClientRadrootsError::RequestFailure)?; + let text_value = JsFuture::from(text_response) + .await + .map_err(|_| RadrootsClientRadrootsError::RequestFailure)?; + if let Some(text) = text_value.as_string() { + if let Ok(value) = serde_json::from_str(&text) { + return Ok(Some(value)); + } + return Ok(Some(Value::String(text))); + } + Ok(None) +} + +#[cfg(test)] +mod tests { + use super::RadrootsClientWebRadroots; + use crate::radroots::{ + RadrootsClientRadroots, + RadrootsClientRadrootsAccountsRequest, + RadrootsClientRadrootsError, + }; + + #[test] + fn base_url_sanitizes_trailing_slash() { + let client = RadrootsClientWebRadroots::new(Some("https://example.com/app/")); + assert_eq!(client.get_base_url(), Some("https://example.com/app")); + } + + #[test] + fn missing_base_url_errors() { + let client = RadrootsClientWebRadroots::new(None); + let err = futures::executor::block_on(client.accounts_request( + RadrootsClientRadrootsAccountsRequest { + profile_name: "rad".to_string(), + secret_key: "deadbeef".to_string(), + }, + )) + .expect_err("missing base url"); + assert_eq!(err, RadrootsClientRadrootsError::MissingBaseUrl); + } +}