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:
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(_)));
+ }
+}