app

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

commit 39f3f9bb31b98ffc77bc1df31a81d23116561f21
parent da9067f22962ea9fed09c33249250ea77b65084e
Author: triesap <tyson@radroots.org>
Date:   Fri,  6 Feb 2026 15:41:05 +0000

app: add config data model and storage keys

- add configuration record types with checksum validation
- add datastore helpers for config read/write/reset
- include config object key in key map defaults and validation
- export config types and constants for reuse

Diffstat:
Mapp/src/config.rs | 24++++++++++++++++++++++++
Aapp/src/configuration.rs | 362+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mapp/src/lib.rs | 25+++++++++++++++++++++++++
3 files changed, 411 insertions(+), 0 deletions(-)

diff --git a/app/src/config.rs b/app/src/config.rs @@ -19,6 +19,7 @@ pub const APP_DATASTORE_KEY_EULA_DATE: &str = "app:eula:date"; pub const APP_DATASTORE_KEY_SETUP_LOCK: &str = "app:setup:lock"; pub const APP_DATASTORE_KEY_OBJ_STATE: &str = "app:data"; pub const APP_DATASTORE_KEY_OBJ_SETUP_DRAFT: &str = "setup:draft"; +pub const APP_DATASTORE_KEY_OBJ_CONFIG: &str = "app:config"; pub const APP_DATASTORE_KEY_LOG_ENTRY: &str = "log:entry"; pub const APP_KEYSTORE_KEY_NOSTR_DEFAULT: &str = "nostr:default"; @@ -66,6 +67,7 @@ pub fn app_key_maps_default() -> RadrootsAppKeyMapConfig { let mut obj_map = BTreeMap::new(); obj_map.insert("state", APP_DATASTORE_KEY_OBJ_STATE); obj_map.insert("setup_draft", APP_DATASTORE_KEY_OBJ_SETUP_DRAFT); + obj_map.insert("config", APP_DATASTORE_KEY_OBJ_CONFIG); RadrootsAppKeyMapConfig { key_map, param_map, @@ -127,6 +129,9 @@ pub fn app_key_maps_validate(config: &RadrootsAppKeyMapConfig) -> RadrootsAppCon if !config.obj_map.contains_key("setup_draft") { return Err(RadrootsAppConfigError::MissingObjMap("setup_draft")); } + if !config.obj_map.contains_key("config") { + return Err(RadrootsAppConfigError::MissingObjMap("config")); + } Ok(()) } @@ -189,6 +194,12 @@ pub fn app_datastore_obj_key_setup_draft( app_datastore_obj_key(config, "setup_draft") } +pub fn app_datastore_obj_key_config( + config: &RadrootsAppKeyMapConfig, +) -> RadrootsAppConfigResult<&'static str> { + app_datastore_obj_key(config, "config") +} + pub fn app_keystore_key( config: &RadrootsAppKeystoreKeyMap, key: &'static str, @@ -328,6 +339,7 @@ mod tests { app_datastore_key_setup_lock, app_datastore_obj_key_state, app_datastore_obj_key_setup_draft, + app_datastore_obj_key_config, app_key_maps_validate, app_keystore_key_maps_default, app_keystore_key_maps_validate, @@ -348,6 +360,7 @@ mod tests { APP_DATASTORE_KEY_NOSTR_KEY, APP_DATASTORE_KEY_OBJ_STATE, APP_DATASTORE_KEY_OBJ_SETUP_DRAFT, + APP_DATASTORE_KEY_OBJ_CONFIG, APP_DATASTORE_KEY_LOG_ENTRY, APP_DATASTORE_KEY_SETUP_LOCK, APP_KEYSTORE_KEY_NOSTR_DEFAULT, @@ -448,6 +461,10 @@ mod tests { config.obj_map.get("setup_draft"), Some(&APP_DATASTORE_KEY_OBJ_SETUP_DRAFT) ); + assert_eq!( + config.obj_map.get("config"), + Some(&APP_DATASTORE_KEY_OBJ_CONFIG) + ); assert_eq!(app_datastore_param_nostr_profile("abc"), "nostr:abc:profile"); assert_eq!( app_datastore_param_log_entry("entry"), @@ -473,6 +490,9 @@ mod tests { missing.param_map.insert("log_entry", app_datastore_param_log_entry as RadrootsAppDatastoreKeyParam); let err = app_key_maps_validate(&missing).expect_err("missing draft"); assert_eq!(err, RadrootsAppConfigError::MissingObjMap("setup_draft")); + missing.obj_map.insert("setup_draft", APP_DATASTORE_KEY_OBJ_SETUP_DRAFT); + let err = app_key_maps_validate(&missing).expect_err("missing config"); + assert_eq!(err, RadrootsAppConfigError::MissingObjMap("config")); } #[test] @@ -508,6 +528,10 @@ mod tests { app_datastore_obj_key_setup_draft(&config).expect("draft key"), APP_DATASTORE_KEY_OBJ_SETUP_DRAFT ); + assert_eq!( + app_datastore_obj_key_config(&config).expect("config key"), + APP_DATASTORE_KEY_OBJ_CONFIG + ); let nostr_param = app_datastore_param_key(&config, "nostr_profile").expect("param"); assert_eq!(nostr_param("abc"), "nostr:abc:profile"); let log_param = app_datastore_param_key(&config, "log_entry").expect("param"); diff --git a/app/src/configuration.rs b/app/src/configuration.rs @@ -0,0 +1,362 @@ +#![forbid(unsafe_code)] + +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; + +use radroots_app_core::datastore::{RadrootsClientDatastore, RadrootsClientDatastoreError}; + +use crate::{ + app_datastore_obj_key_config, + app_state_timestamp_ms, + RadrootsAppConfigError, + RadrootsAppKeyMapConfig, + RadrootsAppRole, +}; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct RadrootsAppConfigProfile { + pub name: String, + pub location: String, +} + +impl Default for RadrootsAppConfigProfile { + fn default() -> Self { + Self { + name: String::new(), + location: String::new(), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct RadrootsAppConfigPreferences { + pub notifications_orders: bool, + pub notifications_messages: bool, + pub payment_method: Option<String>, +} + +impl Default for RadrootsAppConfigPreferences { + fn default() -> Self { + Self { + notifications_orders: true, + notifications_messages: true, + payment_method: None, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct RadrootsAppConfigFarmer { + pub farm_name: String, + pub farm_location: String, + pub products_growing: Vec<String>, +} + +impl Default for RadrootsAppConfigFarmer { + fn default() -> Self { + Self { + farm_name: String::new(), + farm_location: String::new(), + products_growing: Vec::new(), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct RadrootsAppConfigIndividual { + pub name: String, + pub location: String, + pub products_interested: Vec<String>, +} + +impl Default for RadrootsAppConfigIndividual { + fn default() -> Self { + Self { + name: String::new(), + location: String::new(), + products_interested: Vec::new(), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct RadrootsAppConfigBusiness { + pub name: String, + pub location: String, + pub operations: String, +} + +impl Default for RadrootsAppConfigBusiness { + fn default() -> Self { + Self { + name: String::new(), + location: String::new(), + operations: String::new(), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct RadrootsAppConfigData { + pub profile: RadrootsAppConfigProfile, + pub role: RadrootsAppRole, + pub farmer: Option<RadrootsAppConfigFarmer>, + pub business: Option<RadrootsAppConfigBusiness>, + pub individual: Option<RadrootsAppConfigIndividual>, + pub preferences: RadrootsAppConfigPreferences, +} + +impl Default for RadrootsAppConfigData { + fn default() -> Self { + Self { + profile: RadrootsAppConfigProfile::default(), + role: RadrootsAppRole::default(), + farmer: None, + business: None, + individual: None, + preferences: RadrootsAppConfigPreferences::default(), + } + } +} + +pub const APP_CONFIG_SCHEMA_VERSION: u32 = 1; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RadrootsAppConfigRecordError { + Missing, + Corrupt, + InvalidChecksum, + UnsupportedVersion(u32), + AlreadyExists, +} + +impl RadrootsAppConfigRecordError { + pub const fn message(&self) -> &'static str { + match self { + RadrootsAppConfigRecordError::Missing => "error.app.config.missing", + RadrootsAppConfigRecordError::Corrupt => "error.app.config.corrupt", + RadrootsAppConfigRecordError::InvalidChecksum => "error.app.config.checksum_invalid", + RadrootsAppConfigRecordError::UnsupportedVersion(_) => "error.app.config.schema_unsupported", + RadrootsAppConfigRecordError::AlreadyExists => "error.app.config.already_exists", + } + } +} + +impl std::fmt::Display for RadrootsAppConfigRecordError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.message()) + } +} + +impl std::error::Error for RadrootsAppConfigRecordError {} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct RadrootsAppConfigRecord { + pub schema_version: u32, + pub revision: u64, + pub updated_at_ms: i64, + pub checksum: String, + pub config: RadrootsAppConfigData, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +struct RadrootsAppConfigChecksumPayload { + schema_version: u32, + revision: u64, + updated_at_ms: i64, + config: RadrootsAppConfigData, +} + +fn app_config_record_checksum(payload: &RadrootsAppConfigChecksumPayload) -> String { + let serialized = serde_json::to_vec(payload).unwrap_or_else(|_| Vec::new()); + let hash = Sha256::digest(&serialized); + hex::encode(hash) +} + +pub fn app_config_record_new( + config: RadrootsAppConfigData, + revision: u64, + updated_at_ms: i64, +) -> RadrootsAppConfigRecord { + let payload = RadrootsAppConfigChecksumPayload { + schema_version: APP_CONFIG_SCHEMA_VERSION, + revision, + updated_at_ms, + config: config.clone(), + }; + let checksum = app_config_record_checksum(&payload); + RadrootsAppConfigRecord { + schema_version: APP_CONFIG_SCHEMA_VERSION, + revision, + updated_at_ms, + checksum, + config, + } +} + +pub fn app_config_record_validate( + record: &RadrootsAppConfigRecord, +) -> Result<(), RadrootsAppConfigRecordError> { + if record.schema_version != APP_CONFIG_SCHEMA_VERSION { + return Err(RadrootsAppConfigRecordError::UnsupportedVersion( + record.schema_version, + )); + } + let payload = RadrootsAppConfigChecksumPayload { + schema_version: record.schema_version, + revision: record.revision, + updated_at_ms: record.updated_at_ms, + config: record.config.clone(), + }; + let expected = app_config_record_checksum(&payload); + if record.checksum != expected { + return Err(RadrootsAppConfigRecordError::InvalidChecksum); + } + Ok(()) +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum RadrootsAppConfigStoreError { + Datastore(RadrootsClientDatastoreError), + Config(RadrootsAppConfigError), + Record(RadrootsAppConfigRecordError), +} + +impl std::fmt::Display for RadrootsAppConfigStoreError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + RadrootsAppConfigStoreError::Datastore(err) => write!(f, "{err}"), + RadrootsAppConfigStoreError::Config(err) => write!(f, "{err}"), + RadrootsAppConfigStoreError::Record(err) => write!(f, "{err}"), + } + } +} + +impl std::error::Error for RadrootsAppConfigStoreError {} + +pub type RadrootsAppConfigStoreResult<T> = Result<T, RadrootsAppConfigStoreError>; + +pub async fn app_datastore_write_config_record<T: RadrootsClientDatastore>( + datastore: &T, + key_maps: &RadrootsAppKeyMapConfig, + record: &RadrootsAppConfigRecord, +) -> RadrootsAppConfigStoreResult<RadrootsAppConfigRecord> { + let key = app_datastore_obj_key_config(key_maps).map_err(RadrootsAppConfigStoreError::Config)?; + let value = datastore + .set_obj(key, record) + .await + .map_err(RadrootsAppConfigStoreError::Datastore)?; + Ok(value) +} + +pub async fn app_datastore_read_config_record<T: RadrootsClientDatastore>( + datastore: &T, + key_maps: &RadrootsAppKeyMapConfig, +) -> RadrootsAppConfigStoreResult<RadrootsAppConfigRecord> { + let key = app_datastore_obj_key_config(key_maps).map_err(RadrootsAppConfigStoreError::Config)?; + match datastore.get_obj::<RadrootsAppConfigRecord>(key).await { + Ok(record) => { + app_config_record_validate(&record).map_err(RadrootsAppConfigStoreError::Record)?; + Ok(record) + } + Err(RadrootsClientDatastoreError::NoResult) => { + Err(RadrootsAppConfigStoreError::Record(RadrootsAppConfigRecordError::Missing)) + } + Err(err) => Err(RadrootsAppConfigStoreError::Datastore(err)), + } +} + +pub async fn app_datastore_create_config<T: RadrootsClientDatastore>( + datastore: &T, + key_maps: &RadrootsAppKeyMapConfig, + config: &RadrootsAppConfigData, +) -> RadrootsAppConfigStoreResult<RadrootsAppConfigData> { + let now_ms = app_state_timestamp_ms(); + match app_datastore_read_config_record(datastore, key_maps).await { + Ok(_) => Err(RadrootsAppConfigStoreError::Record( + RadrootsAppConfigRecordError::AlreadyExists, + )), + Err(RadrootsAppConfigStoreError::Record(RadrootsAppConfigRecordError::Missing)) => { + let record = app_config_record_new(config.clone(), 1, now_ms); + let value = app_datastore_write_config_record(datastore, key_maps, &record).await?; + Ok(value.config) + } + Err(err) => Err(err), + } +} + +pub async fn app_datastore_update_config<T: RadrootsClientDatastore>( + datastore: &T, + key_maps: &RadrootsAppKeyMapConfig, + config: &RadrootsAppConfigData, +) -> RadrootsAppConfigStoreResult<RadrootsAppConfigData> { + let now_ms = app_state_timestamp_ms(); + let record = match app_datastore_read_config_record(datastore, key_maps).await { + Ok(existing) => app_config_record_new(config.clone(), existing.revision + 1, now_ms), + Err(err) => return Err(err), + }; + let value = app_datastore_write_config_record(datastore, key_maps, &record).await?; + Ok(value.config) +} + +pub async fn app_datastore_read_config<T: RadrootsClientDatastore>( + datastore: &T, + key_maps: &RadrootsAppKeyMapConfig, +) -> RadrootsAppConfigStoreResult<RadrootsAppConfigData> { + let record = app_datastore_read_config_record(datastore, key_maps).await?; + Ok(record.config) +} + +pub async fn app_datastore_has_config<T: RadrootsClientDatastore>( + datastore: &T, + key_maps: &RadrootsAppKeyMapConfig, +) -> RadrootsAppConfigStoreResult<bool> { + match app_datastore_read_config_record(datastore, key_maps).await { + Ok(_) => Ok(true), + Err(RadrootsAppConfigStoreError::Record(RadrootsAppConfigRecordError::Missing)) => Ok(false), + Err(err) => Err(err), + } +} + +pub async fn app_datastore_clear_config<T: RadrootsClientDatastore>( + datastore: &T, + key_maps: &RadrootsAppKeyMapConfig, +) -> RadrootsAppConfigStoreResult<()> { + let key = app_datastore_obj_key_config(key_maps).map_err(RadrootsAppConfigStoreError::Config)?; + datastore + .del_obj(key) + .await + .map_err(RadrootsAppConfigStoreError::Datastore)?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::{ + app_config_record_new, + app_config_record_validate, + RadrootsAppConfigData, + RadrootsAppConfigRecordError, + APP_CONFIG_SCHEMA_VERSION, + }; + + #[test] + fn config_record_roundtrips() { + let config = RadrootsAppConfigData::default(); + let record = app_config_record_new(config.clone(), 1, 1234); + assert_eq!(record.schema_version, APP_CONFIG_SCHEMA_VERSION); + assert_eq!(record.revision, 1); + assert_eq!(record.updated_at_ms, 1234); + assert_eq!(record.config, config); + assert!(app_config_record_validate(&record).is_ok()); + } + + #[test] + fn config_record_detects_invalid_checksum() { + let config = RadrootsAppConfigData::default(); + let mut record = app_config_record_new(config, 1, 1234); + record.checksum = String::from("invalid"); + let err = app_config_record_validate(&record).expect_err("checksum"); + assert_eq!(err, RadrootsAppConfigRecordError::InvalidChecksum); + } +} diff --git a/app/src/lib.rs b/app/src/lib.rs @@ -4,6 +4,7 @@ mod app; mod bootstrap; mod context; mod config; +mod configuration; mod data; mod health; mod health_ui; @@ -56,6 +57,28 @@ pub use data::{ APP_EULA_VERSION, APP_STATE_SCHEMA_VERSION, }; +pub use configuration::{ + app_config_record_new, + app_config_record_validate, + app_datastore_clear_config, + app_datastore_create_config, + app_datastore_has_config, + app_datastore_read_config, + app_datastore_read_config_record, + app_datastore_update_config, + app_datastore_write_config_record, + RadrootsAppConfigBusiness, + RadrootsAppConfigData, + RadrootsAppConfigFarmer, + RadrootsAppConfigIndividual, + RadrootsAppConfigPreferences, + RadrootsAppConfigProfile, + RadrootsAppConfigRecord, + RadrootsAppConfigRecordError, + RadrootsAppConfigStoreError, + RadrootsAppConfigStoreResult, + APP_CONFIG_SCHEMA_VERSION, +}; pub use health::{ app_health_check_all, app_health_check_all_logged, @@ -194,6 +217,7 @@ pub use config::{ app_datastore_obj_key, app_datastore_obj_key_state, app_datastore_obj_key_setup_draft, + app_datastore_obj_key_config, app_assets_geocoder_db_url, app_assets_sql_wasm_url, app_keystore_key_maps_default, @@ -219,6 +243,7 @@ pub use config::{ APP_DATASTORE_KEY_NOSTR_KEY, APP_DATASTORE_KEY_OBJ_STATE, APP_DATASTORE_KEY_OBJ_SETUP_DRAFT, + APP_DATASTORE_KEY_OBJ_CONFIG, APP_DATASTORE_KEY_SETUP_LOCK, APP_KEYSTORE_KEY_NOSTR_DEFAULT, app_datastore_key_setup_lock,