app

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

commit c0fdd2de0a5902a12903059003692d5c3e659435
parent 0e123a2f9f47664ec175115e10aba9785d4bf674
Author: triesap <triesap@radroots.dev>
Date:   Mon, 19 Jan 2026 19:45:26 +0000

app: validate app data for active key

- add app data active key health check
- compare app data key to stored nostr key
- include active key check in health report
- add tests and dev dependency for app data checks

Diffstat:
MCargo.lock | 1+
Mapp/Cargo.toml | 1+
Mapp/src/health.rs | 93++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mapp/src/lib.rs | 1+
4 files changed, 95 insertions(+), 1 deletion(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -1545,6 +1545,7 @@ dependencies = [ "leptos", "radroots-app-core", "serde", + "serde_json", "wasm-bindgen", ] diff --git a/app/Cargo.toml b/app/Cargo.toml @@ -18,3 +18,4 @@ serde.workspace = true [dev-dependencies] futures.workspace = true async-trait.workspace = true +serde_json.workspace = true diff --git a/app/src/health.rs b/app/src/health.rs @@ -51,6 +51,7 @@ pub struct AppHealthReport { pub key_maps: AppHealthCheckResult, pub bootstrap_config: AppHealthCheckResult, pub bootstrap_app_data: AppHealthCheckResult, + pub app_data_active_key: AppHealthCheckResult, pub datastore_roundtrip: AppHealthCheckResult, pub keystore: AppHealthCheckResult, } @@ -61,6 +62,7 @@ impl Default for AppHealthReport { key_maps: AppHealthCheckResult::skipped(), bootstrap_config: AppHealthCheckResult::skipped(), bootstrap_app_data: AppHealthCheckResult::skipped(), + app_data_active_key: AppHealthCheckResult::skipped(), datastore_roundtrip: AppHealthCheckResult::skipped(), keystore: AppHealthCheckResult::skipped(), } @@ -77,6 +79,7 @@ use crate::{ app_datastore_has_app_data, app_datastore_has_config, app_datastore_key_nostr_key, + app_datastore_read_app_data, app_key_maps_validate, AppKeyMapConfig, }; @@ -112,6 +115,32 @@ pub async fn app_health_check_bootstrap_app_data<T: RadrootsClientDatastore>( } } +pub async fn app_health_check_app_data_active_key<T: RadrootsClientDatastore>( + datastore: &T, + key_maps: &AppKeyMapConfig, +) -> AppHealthCheckResult { + let app_data = match app_datastore_read_app_data(datastore, key_maps).await { + Ok(value) => value, + Err(err) => return AppHealthCheckResult::error(err.to_string()), + }; + if app_data.active_key.is_empty() { + return AppHealthCheckResult::error("missing"); + } + let key_name = match app_datastore_key_nostr_key(key_maps) { + Ok(value) => value, + Err(err) => return AppHealthCheckResult::error(err.to_string()), + }; + let stored = match datastore.get(key_name).await { + Ok(value) => value, + Err(RadrootsClientDatastoreError::NoResult) => return AppHealthCheckResult::error("missing"), + Err(err) => return AppHealthCheckResult::error(err.to_string()), + }; + if stored != app_data.active_key { + return AppHealthCheckResult::error("mismatch"); + } + AppHealthCheckResult::ok() +} + const APP_HEALTH_TEMP_KEY: &str = "radroots.health.temp"; pub async fn app_health_check_datastore_roundtrip<T: RadrootsClientDatastore>( @@ -167,6 +196,7 @@ pub async fn app_health_check_all<T: RadrootsClientDatastore, K: RadrootsClientK key_maps: app_health_check_key_maps(key_maps), bootstrap_config: app_health_check_bootstrap_config(datastore, key_maps).await, bootstrap_app_data: app_health_check_bootstrap_app_data(datastore, key_maps).await, + app_data_active_key: app_health_check_app_data_active_key(datastore, key_maps).await, datastore_roundtrip: app_health_check_datastore_roundtrip(datastore).await, keystore: app_health_check_keystore_access(datastore, keystore, key_maps).await, } @@ -175,6 +205,7 @@ pub async fn app_health_check_all<T: RadrootsClientDatastore, K: RadrootsClientK #[cfg(test)] mod tests { use super::{ + app_health_check_app_data_active_key, app_health_check_all, app_health_check_key_maps, app_health_check_bootstrap_app_data, @@ -228,6 +259,7 @@ mod tests { assert_eq!(report.key_maps.status, AppHealthCheckStatus::Skipped); assert_eq!(report.bootstrap_config.status, AppHealthCheckStatus::Skipped); assert_eq!(report.bootstrap_app_data.status, AppHealthCheckStatus::Skipped); + assert_eq!(report.app_data_active_key.status, AppHealthCheckStatus::Skipped); assert_eq!(report.datastore_roundtrip.status, AppHealthCheckStatus::Skipped); assert_eq!(report.keystore.status, AppHealthCheckStatus::Skipped); } @@ -269,6 +301,7 @@ mod tests { struct TestDatastore { get_result: RadrootsClientDatastoreResult<String>, + app_data: Option<crate::AppAppData>, } fn datastore_err<T>() -> RadrootsClientDatastoreResult<T> { @@ -315,7 +348,13 @@ mod tests { where T: serde::de::DeserializeOwned, { - datastore_err() + let Some(data) = self.app_data.as_ref() else { + return Err(RadrootsClientDatastoreError::NoResult); + }; + let serialized = + serde_json::to_string(data).map_err(|_| RadrootsClientDatastoreError::NoResult)?; + serde_json::from_str(&serialized) + .map_err(|_| RadrootsClientDatastoreError::NoResult) } async fn del_obj(&self, _key: &str) -> RadrootsClientDatastoreResult<String> { @@ -421,6 +460,7 @@ mod tests { fn health_check_keystore_reports_missing_datastore_key() { let datastore = TestDatastore { get_result: Err(RadrootsClientDatastoreError::NoResult), + app_data: None, }; let keystore = TestKeystore { read_result: Err(RadrootsClientKeystoreError::MissingKey), @@ -439,6 +479,7 @@ mod tests { fn health_check_keystore_reports_missing_keystore_key() { let datastore = TestDatastore { get_result: Ok("pub".to_string()), + app_data: None, }; let keystore = TestKeystore { read_result: Err(RadrootsClientKeystoreError::MissingKey), @@ -457,6 +498,7 @@ mod tests { fn health_check_keystore_accepts_matching_key() { let datastore = TestDatastore { get_result: Ok("pub".to_string()), + app_data: None, }; let keystore = TestKeystore { read_result: Ok("secret".to_string()), @@ -471,6 +513,54 @@ mod tests { } #[test] + fn health_check_app_data_requires_active_key() { + let datastore = TestDatastore { + get_result: Ok("pub".to_string()), + app_data: Some(crate::AppAppData::default()), + }; + let key_maps = crate::app_key_maps_default(); + let result = futures::executor::block_on(app_health_check_app_data_active_key( + &datastore, + &key_maps, + )); + assert_eq!(result.status, AppHealthCheckStatus::Error); + assert_eq!(result.message.as_deref(), Some("missing")); + } + + #[test] + fn health_check_app_data_detects_mismatch() { + let mut app_data = crate::AppAppData::default(); + app_data.active_key = "other".to_string(); + let datastore = TestDatastore { + get_result: Ok("pub".to_string()), + app_data: Some(app_data), + }; + let key_maps = crate::app_key_maps_default(); + let result = futures::executor::block_on(app_health_check_app_data_active_key( + &datastore, + &key_maps, + )); + assert_eq!(result.status, AppHealthCheckStatus::Error); + assert_eq!(result.message.as_deref(), Some("mismatch")); + } + + #[test] + fn health_check_app_data_accepts_match() { + let mut app_data = crate::AppAppData::default(); + app_data.active_key = "pub".to_string(); + let datastore = TestDatastore { + get_result: Ok("pub".to_string()), + app_data: Some(app_data), + }; + let key_maps = crate::app_key_maps_default(); + let result = futures::executor::block_on(app_health_check_app_data_active_key( + &datastore, + &key_maps, + )); + assert_eq!(result.status, AppHealthCheckStatus::Ok); + } + + #[test] fn health_check_all_reports_idb_errors() { let datastore = RadrootsClientWebDatastore::new(None); let keystore = RadrootsClientWebKeystoreNostr::new(None); @@ -480,6 +570,7 @@ mod tests { assert_eq!(report.key_maps.status, AppHealthCheckStatus::Ok); assert_eq!(report.bootstrap_config.status, AppHealthCheckStatus::Error); assert_eq!(report.bootstrap_app_data.status, AppHealthCheckStatus::Error); + assert_eq!(report.app_data_active_key.status, AppHealthCheckStatus::Error); assert_eq!(report.datastore_roundtrip.status, AppHealthCheckStatus::Error); assert_eq!(report.keystore.status, AppHealthCheckStatus::Error); } diff --git a/app/src/lib.rs b/app/src/lib.rs @@ -23,6 +23,7 @@ pub use context::{app_context, AppContext}; pub use data::{AppAppData, AppConfigData, AppConfigRole}; pub use health::{ app_health_check_all, + app_health_check_app_data_active_key, app_health_check_bootstrap_app_data, app_health_check_bootstrap_config, app_health_check_datastore_roundtrip,