app

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

commit a7b87697b3e0b7c2f93ba294c78058cb369de709
parent 330b2fffc1512532a61eb1d79dd1514f6b4c4a44
Author: triesap <tyson@radroots.org>
Date:   Mon,  2 Feb 2026 20:25:28 +0000

app: add setup lock primitives

- add setup lock record, ttl, and enable helpers
- add acquire and release helpers
- wire setup lock key into config defaults
- cover lock behavior with unit tests

Diffstat:
Mapp/src/config.rs | 22++++++++++++++++++++++
Mapp/src/lib.rs | 13+++++++++++++
Aapp/src/setup_lock.rs | 341+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
3 files changed, 376 insertions(+), 0 deletions(-)

diff --git a/app/src/config.rs b/app/src/config.rs @@ -16,6 +16,7 @@ pub type RadrootsAppKeystoreKeyMap = BTreeMap<&'static str, &'static str>; pub const APP_DATASTORE_KEY_NOSTR_KEY: &str = "nostr:key"; 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_LOG_ENTRY: &str = "log:entry"; @@ -54,6 +55,7 @@ pub fn app_key_maps_default() -> RadrootsAppKeyMapConfig { let mut key_map = BTreeMap::new(); key_map.insert("nostr_key", APP_DATASTORE_KEY_NOSTR_KEY); key_map.insert("eula_date", APP_DATASTORE_KEY_EULA_DATE); + key_map.insert("setup_lock", APP_DATASTORE_KEY_SETUP_LOCK); let mut param_map = BTreeMap::new(); param_map.insert("nostr_profile", app_datastore_param_nostr_profile as RadrootsAppDatastoreKeyParam); param_map.insert( @@ -107,6 +109,9 @@ pub fn app_key_maps_validate(config: &RadrootsAppKeyMapConfig) -> RadrootsAppCon if !config.key_map.contains_key("eula_date") { return Err(RadrootsAppConfigError::MissingKeyMap("eula_date")); } + if !config.key_map.contains_key("setup_lock") { + return Err(RadrootsAppConfigError::MissingKeyMap("setup_lock")); + } if !config.param_map.contains_key("nostr_profile") { return Err(RadrootsAppConfigError::MissingParamMap("nostr_profile")); } @@ -170,6 +175,10 @@ pub fn app_datastore_key_eula_date(config: &RadrootsAppKeyMapConfig) -> Radroots app_datastore_key(config, "eula_date") } +pub fn app_datastore_key_setup_lock(config: &RadrootsAppKeyMapConfig) -> RadrootsAppConfigResult<&'static str> { + app_datastore_key(config, "setup_lock") +} + pub fn app_datastore_obj_key_state(config: &RadrootsAppKeyMapConfig) -> RadrootsAppConfigResult<&'static str> { app_datastore_obj_key(config, "state") } @@ -316,6 +325,7 @@ mod tests { app_datastore_param_radroots_profile, app_datastore_key_eula_date, app_datastore_key_nostr_key, + app_datastore_key_setup_lock, app_datastore_obj_key_state, app_datastore_obj_key_setup_draft, app_key_maps_validate, @@ -339,6 +349,7 @@ mod tests { APP_DATASTORE_KEY_OBJ_STATE, APP_DATASTORE_KEY_OBJ_SETUP_DRAFT, APP_DATASTORE_KEY_LOG_ENTRY, + APP_DATASTORE_KEY_SETUP_LOCK, APP_KEYSTORE_KEY_NOSTR_DEFAULT, }; use radroots_app_core::idb::{IDB_CONFIG_DATASTORE, IDB_CONFIG_KEYSTORE_NOSTR}; @@ -426,6 +437,10 @@ mod tests { Some(&APP_DATASTORE_KEY_EULA_DATE) ); assert_eq!( + config.key_map.get("setup_lock"), + Some(&APP_DATASTORE_KEY_SETUP_LOCK) + ); + assert_eq!( config.obj_map.get("state"), Some(&APP_DATASTORE_KEY_OBJ_STATE) ); @@ -449,6 +464,9 @@ mod tests { let err = app_key_maps_validate(&missing).expect_err("missing keys"); assert_eq!(err, RadrootsAppConfigError::MissingKeyMap("eula_date")); missing.key_map.insert("eula_date", APP_DATASTORE_KEY_EULA_DATE); + let err = app_key_maps_validate(&missing).expect_err("missing lock"); + assert_eq!(err, RadrootsAppConfigError::MissingKeyMap("setup_lock")); + missing.key_map.insert("setup_lock", APP_DATASTORE_KEY_SETUP_LOCK); missing.obj_map.insert("state", APP_DATASTORE_KEY_OBJ_STATE); missing.param_map.insert("nostr_profile", app_datastore_param_nostr_profile as RadrootsAppDatastoreKeyParam); missing.param_map.insert("radroots_profile", app_datastore_param_radroots_profile as RadrootsAppDatastoreKeyParam); @@ -479,6 +497,10 @@ mod tests { APP_DATASTORE_KEY_EULA_DATE ); assert_eq!( + app_datastore_key_setup_lock(&config).expect("setup lock key"), + APP_DATASTORE_KEY_SETUP_LOCK + ); + assert_eq!( app_datastore_obj_key_state(&config).expect("state key"), APP_DATASTORE_KEY_OBJ_STATE ); diff --git a/app/src/lib.rs b/app/src/lib.rs @@ -14,6 +14,7 @@ mod logs; mod notifications; mod settings; mod setup; +mod setup_lock; mod setup_status; mod theme; mod tangle; @@ -143,6 +144,16 @@ pub use setup::{ app_setup_step_default, RadrootsAppSetupStep, }; +pub use setup_lock::{ + app_setup_lock_acquire, + app_setup_lock_enabled, + app_setup_lock_is_expired, + app_setup_lock_release, + app_setup_lock_ttl_ms, + RadrootsAppSetupLock, + RadrootsAppSetupLockStatus, + APP_SETUP_LOCK_TTL_MS, +}; pub use tangle::{RadrootsAppTangleClient, RadrootsAppTangleClientStub, RadrootsAppTangleError, RadrootsAppTangleResult}; pub use config::{ app_config_default, @@ -183,7 +194,9 @@ pub use config::{ APP_DATASTORE_KEY_NOSTR_KEY, APP_DATASTORE_KEY_OBJ_STATE, APP_DATASTORE_KEY_OBJ_SETUP_DRAFT, + APP_DATASTORE_KEY_SETUP_LOCK, APP_KEYSTORE_KEY_NOSTR_DEFAULT, + app_datastore_key_setup_lock, }; pub use init::{ app_init_assets, diff --git a/app/src/setup_lock.rs b/app/src/setup_lock.rs @@ -0,0 +1,341 @@ +#![forbid(unsafe_code)] + +use serde::{Deserialize, Serialize}; + +use radroots_app_core::datastore::{RadrootsClientDatastore, RadrootsClientDatastoreError}; + +use crate::{ + app_datastore_key_setup_lock, + RadrootsAppInitError, + RadrootsAppInitResult, + RadrootsAppKeyMapConfig, + RadrootsAppStateError, +}; + +pub const APP_SETUP_LOCK_TTL_MS: u64 = 10 * 60 * 1000; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct RadrootsAppSetupLock { + pub owner: String, + pub expires_at_ms: u64, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum RadrootsAppSetupLockStatus { + Acquired(RadrootsAppSetupLock), + Locked(RadrootsAppSetupLock), +} + +pub const fn app_setup_lock_enabled() -> bool { + cfg!(target_arch = "wasm32") +} + +pub const fn app_setup_lock_ttl_ms() -> u64 { + APP_SETUP_LOCK_TTL_MS +} + +pub fn app_setup_lock_is_expired(lock: &RadrootsAppSetupLock, now_ms: u64) -> bool { + lock.expires_at_ms <= now_ms +} + +fn app_setup_lock_new(owner: &str, now_ms: u64, ttl_ms: u64) -> RadrootsAppSetupLock { + RadrootsAppSetupLock { + owner: owner.to_string(), + expires_at_ms: now_ms.saturating_add(ttl_ms), + } +} + +pub async fn app_setup_lock_acquire<T: RadrootsClientDatastore>( + datastore: &T, + key_maps: &RadrootsAppKeyMapConfig, + owner: &str, + now_ms: u64, + ttl_ms: u64, +) -> RadrootsAppInitResult<RadrootsAppSetupLockStatus> { + let key = app_datastore_key_setup_lock(key_maps).map_err(RadrootsAppInitError::Config)?; + let existing = match datastore.get(key).await { + Ok(value) => serde_json::from_str::<RadrootsAppSetupLock>(&value).ok(), + Err(RadrootsClientDatastoreError::NoResult) => None, + Err(err) => return Err(RadrootsAppInitError::Datastore(err)), + }; + if let Some(lock) = existing.as_ref() { + if !app_setup_lock_is_expired(lock, now_ms) && lock.owner != owner { + return Ok(RadrootsAppSetupLockStatus::Locked(lock.clone())); + } + } + let lock = app_setup_lock_new(owner, now_ms, ttl_ms); + let encoded = serde_json::to_string(&lock) + .map_err(|_| RadrootsAppInitError::State(RadrootsAppStateError::Corrupt))?; + datastore + .set(key, &encoded) + .await + .map_err(RadrootsAppInitError::Datastore)?; + Ok(RadrootsAppSetupLockStatus::Acquired(lock)) +} + +pub async fn app_setup_lock_release<T: RadrootsClientDatastore>( + datastore: &T, + key_maps: &RadrootsAppKeyMapConfig, +) -> RadrootsAppInitResult<()> { + let key = app_datastore_key_setup_lock(key_maps).map_err(RadrootsAppInitError::Config)?; + match datastore.del(key).await { + Ok(_) => Ok(()), + Err(RadrootsClientDatastoreError::NoResult) => Ok(()), + Err(err) => Err(RadrootsAppInitError::Datastore(err)), + } +} + +#[cfg(test)] +mod tests { + use super::{ + app_setup_lock_acquire, + app_setup_lock_enabled, + app_setup_lock_is_expired, + app_setup_lock_release, + app_setup_lock_ttl_ms, + RadrootsAppSetupLock, + RadrootsAppSetupLockStatus, + APP_SETUP_LOCK_TTL_MS, + }; + use crate::{app_key_maps_default, RadrootsAppKeyMapConfig}; + use async_trait::async_trait; + use radroots_app_core::backup::RadrootsClientBackupDatastorePayload; + use radroots_app_core::datastore::{ + RadrootsClientDatastore, + RadrootsClientDatastoreEntries, + RadrootsClientDatastoreError, + RadrootsClientDatastoreResult, + }; + use radroots_app_core::idb::{RadrootsClientIdbConfig, IDB_CONFIG_DATASTORE}; + use serde::{de::DeserializeOwned, Serialize}; + use std::cell::RefCell; + use std::collections::BTreeMap; + + #[test] + fn lock_enabled_matches_target_arch() { + assert_eq!(app_setup_lock_enabled(), cfg!(target_arch = "wasm32")); + } + + #[test] + fn lock_ttl_defaults_to_constant() { + assert_eq!(app_setup_lock_ttl_ms(), APP_SETUP_LOCK_TTL_MS); + } + + #[test] + fn lock_expired_checks_timestamp() { + let lock = RadrootsAppSetupLock { + owner: "owner".to_string(), + expires_at_ms: 10, + }; + assert!(!app_setup_lock_is_expired(&lock, 5)); + assert!(app_setup_lock_is_expired(&lock, 10)); + } + + struct LockDatastore { + values: RefCell<BTreeMap<String, String>>, + } + + #[async_trait(?Send)] + impl RadrootsClientDatastore for LockDatastore { + fn get_config(&self) -> RadrootsClientIdbConfig { + IDB_CONFIG_DATASTORE + } + + fn get_store_id(&self) -> &str { + "test" + } + + async fn init(&self) -> RadrootsClientDatastoreResult<()> { + Ok(()) + } + + async fn set(&self, key: &str, value: &str) -> RadrootsClientDatastoreResult<String> { + self.values.borrow_mut().insert(key.to_string(), value.to_string()); + Ok(value.to_string()) + } + + async fn get(&self, key: &str) -> RadrootsClientDatastoreResult<String> { + self.values + .borrow() + .get(key) + .cloned() + .ok_or(RadrootsClientDatastoreError::NoResult) + } + + async fn set_obj<T>( + &self, + _key: &str, + _value: &T, + ) -> RadrootsClientDatastoreResult<T> + where + T: Serialize + DeserializeOwned + Clone, + { + Err(RadrootsClientDatastoreError::IdbUndefined) + } + + async fn update_obj<T>( + &self, + _key: &str, + _value: &T, + ) -> RadrootsClientDatastoreResult<T> + where + T: Serialize + DeserializeOwned + Clone, + { + Err(RadrootsClientDatastoreError::IdbUndefined) + } + + async fn get_obj<T>(&self, _key: &str) -> RadrootsClientDatastoreResult<T> + where + T: DeserializeOwned, + { + Err(RadrootsClientDatastoreError::IdbUndefined) + } + + async fn del_obj(&self, _key: &str) -> RadrootsClientDatastoreResult<String> { + Err(RadrootsClientDatastoreError::IdbUndefined) + } + + async fn del(&self, key: &str) -> RadrootsClientDatastoreResult<String> { + let removed = self.values.borrow_mut().remove(key); + match removed { + Some(value) => Ok(value), + None => Err(RadrootsClientDatastoreError::NoResult), + } + } + + async fn del_pref(&self, _key_prefix: &str) -> RadrootsClientDatastoreResult<Vec<String>> { + Err(RadrootsClientDatastoreError::IdbUndefined) + } + + async fn set_param( + &self, + _key: &str, + _key_param: &str, + _value: &str, + ) -> RadrootsClientDatastoreResult<String> { + Err(RadrootsClientDatastoreError::IdbUndefined) + } + + async fn get_param( + &self, + _key: &str, + _key_param: &str, + ) -> RadrootsClientDatastoreResult<String> { + Err(RadrootsClientDatastoreError::IdbUndefined) + } + + async fn keys(&self) -> RadrootsClientDatastoreResult<Vec<String>> { + Err(RadrootsClientDatastoreError::IdbUndefined) + } + + async fn entries(&self) -> RadrootsClientDatastoreResult<RadrootsClientDatastoreEntries> { + Err(RadrootsClientDatastoreError::IdbUndefined) + } + + async fn entries_pref( + &self, + _key_prefix: &str, + ) -> RadrootsClientDatastoreResult<RadrootsClientDatastoreEntries> { + Err(RadrootsClientDatastoreError::IdbUndefined) + } + + async fn reset(&self) -> RadrootsClientDatastoreResult<()> { + Err(RadrootsClientDatastoreError::IdbUndefined) + } + + async fn export_backup( + &self, + ) -> RadrootsClientDatastoreResult<RadrootsClientBackupDatastorePayload> { + Err(RadrootsClientDatastoreError::IdbUndefined) + } + + async fn import_backup( + &self, + _payload: RadrootsClientBackupDatastorePayload, + ) -> RadrootsClientDatastoreResult<()> { + Err(RadrootsClientDatastoreError::IdbUndefined) + } + } + + fn lock_datastore() -> LockDatastore { + LockDatastore { + values: RefCell::new(BTreeMap::new()), + } + } + + fn lock_key_maps() -> RadrootsAppKeyMapConfig { + app_key_maps_default() + } + + #[test] + fn acquire_returns_locked_for_other_owner() { + let datastore = lock_datastore(); + let key_maps = lock_key_maps(); + let acquired = futures::executor::block_on(app_setup_lock_acquire( + &datastore, + &key_maps, + "owner-a", + 100, + 50, + )) + .expect("acquire"); + assert!(matches!(acquired, RadrootsAppSetupLockStatus::Acquired(_))); + let locked = futures::executor::block_on(app_setup_lock_acquire( + &datastore, + &key_maps, + "owner-b", + 120, + 50, + )) + .expect("acquire"); + assert!(matches!(locked, RadrootsAppSetupLockStatus::Locked(_))); + } + + #[test] + fn acquire_refreshes_for_same_owner() { + let datastore = lock_datastore(); + let key_maps = lock_key_maps(); + let _ = futures::executor::block_on(app_setup_lock_acquire( + &datastore, + &key_maps, + "owner-a", + 100, + 50, + )) + .expect("acquire"); + let refreshed = futures::executor::block_on(app_setup_lock_acquire( + &datastore, + &key_maps, + "owner-a", + 140, + 50, + )) + .expect("refresh"); + assert!(matches!(refreshed, RadrootsAppSetupLockStatus::Acquired(_))); + } + + #[test] + fn release_clears_lock() { + let datastore = lock_datastore(); + let key_maps = lock_key_maps(); + let _ = futures::executor::block_on(app_setup_lock_acquire( + &datastore, + &key_maps, + "owner-a", + 100, + 50, + )) + .expect("acquire"); + futures::executor::block_on(app_setup_lock_release(&datastore, &key_maps)) + .expect("release"); + let acquired = futures::executor::block_on(app_setup_lock_acquire( + &datastore, + &key_maps, + "owner-b", + 200, + 50, + )) + .expect("acquire"); + assert!(matches!(acquired, RadrootsAppSetupLockStatus::Acquired(_))); + } +}