app

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

commit f622b54591ac7be50ffa8d1ad1edcbe97168676e
parent 9fccab186c11e9dbae435bc90a8f7c665645a81e
Author: triesap <tyson@radroots.org>
Date:   Sun,  2 Aug 2026 19:10:45 +0000

core(accounts): require confirmed account removal

- issue single-use confirmations bound to pubkey and revision
- reject stale confirmations without destructive side effects
- delete credentials and cascading account metadata explicitly
- select the next then preceding fallback without activation

Diffstat:
Mcore/crates/application/src/accounts.rs | 120++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mcore/crates/application/src/app_core.rs | 73+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Mcore/crates/application/src/lib.rs | 2+-
Mcore/crates/application/src/state_machine.rs | 25+++++++++++++++++++++++++
Mcore/crates/storage/src/application_adapter.rs | 35++++++++++++++++++++++++++++++++++-
Mdocs/implementation/nostr-runtime-rcld.md | 2+-
6 files changed, 251 insertions(+), 6 deletions(-)

diff --git a/core/crates/application/src/accounts.rs b/core/crates/application/src/accounts.rs @@ -9,7 +9,7 @@ use radroots_studio_nostr::{generate_local_keypair, import_secret}; use crate::{ AccountOperationKind, AccountOperationPhase, AccountRepository, AppCore, AppStateRepository, Clock, OperationDiagnostic, OperationId, OperationJournal, PendingAccountOperation, - SecretStore, StateTransition, + RemovalConfirmationToken, SecretStore, StateTransition, }; pub struct GenerateAccountReceipt { @@ -42,6 +42,84 @@ impl GenerateAccountReceipt { } impl AppCore { + /// Issues a single-use confirmation bound to the target and current revision. + /// + /// # Errors + /// + /// Returns a safe account or application-state error. + pub fn request_account_removal( + &self, + public_key: PublicKey, + ) -> Result<RemovalConfirmationToken, SafeError> { + self.issue_removal_token(public_key) + } + + /// Permanently removes a confirmed account and selects a deterministic fallback. + /// + /// # Errors + /// + /// Returns a safe confirmation, credential, persistence, recovery, or state error. + pub fn confirm_account_removal( + &self, + token: RemovalConfirmationToken, + accounts: &(impl AccountRepository + ?Sized), + app_state: &(impl AppStateRepository + ?Sized), + secrets: &(impl SecretStore + ?Sized), + journal: &(impl OperationJournal + ?Sized), + clock: &(impl Clock + ?Sized), + ) -> Result<crate::AppSnapshot, SafeError> { + let public_key = self.consume_removal_token(token)?; + let registry = accounts.list_accounts()?; + let index = registry + .iter() + .position(|account| account.public_key() == public_key) + .ok_or_else(account_not_found)?; + let selected = if self.snapshot().selected_account() == Some(public_key) { + registry + .get(index + 1) + .or_else(|| index.checked_sub(1).and_then(|before| registry.get(before))) + .map(AccountSummary::public_key) + } else { + self.snapshot().selected_account() + }; + let operation = + journal.begin_operation(AccountOperationKind::Remove, public_key, clock.now())?; + let was_active = self + .snapshot() + .active_account() + .is_some_and(|active| active.account().public_key() == public_key); + if was_active { + self.sign_out()?; + } + let account = &registry[index]; + match secrets.delete(public_key) { + Ok(()) => {} + Err(error) + if error.code() == SafeErrorCode::CredentialMissing + && account.key_availability() == KeyAvailability::CredentialMissing => {} + Err(error) => return Err(error), + } + journal.update_operation( + operation, + AccountOperationPhase::CredentialDeleted, + clock.now(), + None, + )?; + accounts.remove_account(public_key)?; + app_state.save_selected_account(selected)?; + journal.update_operation( + operation, + AccountOperationPhase::MetadataDeleted, + clock.now(), + None, + )?; + journal.finalize_operation(operation)?; + self.apply_transition(StateTransition::ReplaceRegistryPreservingSession { + accounts: accounts.list_accounts()?, + selected, + }) + } + /// Persists and publishes a saved account selection without activating it. /// /// # Errors @@ -776,4 +854,44 @@ mod tests { assert_eq!(missing.code(), SafeErrorCode::AccountNotFound); assert_eq!(core.snapshot(), selected); } + + #[test] + fn remove_account_requires_fresh_single_use_confirmation_and_selects_next_fallback() { + let core = AppCore::in_memory(RelayConfiguration::default()); + let accounts = InMemoryAccountRepository::default(); + let secrets = InMemorySecretStore::default(); + let journal = InMemoryOperationJournal::default(); + core.bootstrap().expect("bootstrap"); + let first = core + .generate_account(&accounts, &accounts, &secrets, &journal, &FixedClock) + .expect("first") + .account() + .public_key(); + let second = core + .generate_account(&accounts, &accounts, &secrets, &journal, &FixedClock) + .expect("second") + .account() + .public_key(); + core.select_account(first, &accounts, &accounts) + .expect("select first"); + let stale = core.request_account_removal(first).expect("stale token"); + core.select_account(second, &accounts, &accounts) + .expect("change revision"); + let stale_error = core + .confirm_account_removal(stale, &accounts, &accounts, &secrets, &journal, &FixedClock) + .expect_err("stale token"); + assert_eq!(stale_error.code(), SafeErrorCode::InvalidApplicationState); + assert_eq!(core.snapshot().accounts().len(), 2); + + core.select_account(first, &accounts, &accounts) + .expect("reselect first"); + let token = core.request_account_removal(first).expect("token"); + let removed = core + .confirm_account_removal(token, &accounts, &accounts, &secrets, &journal, &FixedClock) + .expect("remove"); + assert_eq!(removed.accounts().len(), 1); + assert_eq!(removed.selected_account(), Some(second)); + assert!(!secrets.contains(first).expect("credential removed")); + assert_eq!(removed.session(), SessionState::SignedOut); + } } diff --git a/core/crates/application/src/app_core.rs b/core/crates/application/src/app_core.rs @@ -4,8 +4,8 @@ use std::sync::{Arc, Mutex, MutexGuard}; use radroots_studio_domain::{SafeError, SafeErrorCode, SafeMessage}; use crate::{ - AccountRepository, AppSnapshot, AppStateRepository, RelayConfiguration, StateMachine, - StateTransition, + AccountRepository, AppSnapshot, AppStateRepository, RelayConfiguration, SnapshotRevision, + StateMachine, StateTransition, }; pub trait AppObserver: Send + Sync { @@ -15,6 +15,12 @@ pub trait AppObserver: Send + Sync { #[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] pub struct ObserverHandle(u64); +pub struct RemovalConfirmationToken { + id: u64, + public_key: radroots_studio_domain::PublicKey, + revision: SnapshotRevision, +} + impl ObserverHandle { #[must_use] pub const fn value(self) -> u64 { @@ -26,6 +32,8 @@ struct CoreState { state_machine: StateMachine, observers: BTreeMap<ObserverHandle, Arc<dyn AppObserver>>, next_observer: u64, + removal_tokens: BTreeMap<u64, (radroots_studio_domain::PublicKey, SnapshotRevision)>, + next_removal_token: u64, } pub struct AppCore { @@ -42,6 +50,8 @@ impl AppCore { state_machine: StateMachine::booting(), observers: BTreeMap::new(), next_observer: 1, + removal_tokens: BTreeMap::new(), + next_removal_token: 1, }), } } @@ -137,6 +147,51 @@ impl AppCore { Ok(snapshot) } + pub(crate) fn issue_removal_token( + &self, + public_key: radroots_studio_domain::PublicKey, + ) -> Result<RemovalConfirmationToken, SafeError> { + let mut state = self.lock_state(); + if !state + .state_machine + .snapshot() + .accounts() + .iter() + .any(|account| account.public_key() == public_key) + { + return Err(account_not_found()); + } + let id = state.next_removal_token; + state.next_removal_token = id.checked_add(1).ok_or_else(invalid_application_state)?; + let revision = state.state_machine.snapshot().revision(); + state.removal_tokens.insert(id, (public_key, revision)); + Ok(RemovalConfirmationToken { + id, + public_key, + revision, + }) + } + + #[allow(clippy::needless_pass_by_value)] + pub(crate) fn consume_removal_token( + &self, + token: RemovalConfirmationToken, + ) -> Result<radroots_studio_domain::PublicKey, SafeError> { + let RemovalConfirmationToken { + id, + public_key, + revision, + } = token; + let mut state = self.lock_state(); + let stored = state.removal_tokens.remove(&id); + if stored != Some((public_key, revision)) + || state.state_machine.snapshot().revision() != revision + { + return Err(invalid_application_state()); + } + Ok(public_key) + } + fn lock_state(&self) -> MutexGuard<'_, CoreState> { self.state .lock() @@ -151,6 +206,20 @@ const fn observer_registration_failed() -> SafeError { ) } +const fn invalid_application_state() -> SafeError { + SafeError::new( + SafeErrorCode::InvalidApplicationState, + SafeMessage::new("The account removal confirmation is no longer valid."), + ) +} + +const fn account_not_found() -> SafeError { + SafeError::new( + SafeErrorCode::AccountNotFound, + SafeMessage::new("The account was not found."), + ) +} + #[cfg(test)] mod tests { use std::sync::{Arc, Mutex, Weak}; diff --git a/core/crates/application/src/lib.rs b/core/crates/application/src/lib.rs @@ -12,7 +12,7 @@ pub use accounts::{ GenerateAccountReceipt, ImportAccountReceipt, InMemoryAccountRepository, InMemoryOperationJournal, }; -pub use app_core::{AppCore, AppObserver, ObserverHandle}; +pub use app_core::{AppCore, AppObserver, ObserverHandle, RemovalConfirmationToken}; pub use ports::{ AccountNamespaceRepository, AccountOperationKind, AccountOperationPhase, AccountPreferenceKey, AccountRepository, AppStateRepository, BoxFuture, CachedProfile, Clock, NostrClient, diff --git a/core/crates/application/src/state_machine.rs b/core/crates/application/src/state_machine.rs @@ -14,6 +14,10 @@ pub enum StateTransition { accounts: Vec<AccountSummary>, selected: Option<PublicKey>, }, + ReplaceRegistryPreservingSession { + accounts: Vec<AccountSummary>, + selected: Option<PublicKey>, + }, Select(PublicKey), BeginActivation(PublicKey), ActivationSucceeded(Box<ActiveAccountSnapshot>), @@ -75,6 +79,9 @@ impl StateMachine { StateTransition::ReplaceRegistry { accounts, selected } => { self.replace_registry(next_revision, accounts, selected)? } + StateTransition::ReplaceRegistryPreservingSession { accounts, selected } => { + self.replace_registry_preserving_session(next_revision, accounts, selected)? + } StateTransition::Select(public_key) => self.select(next_revision, public_key)?, StateTransition::BeginActivation(public_key) => { self.begin_activation(next_revision, public_key)? @@ -156,6 +163,24 @@ impl StateMachine { ) } + fn replace_registry_preserving_session( + &mut self, + revision: crate::SnapshotRevision, + accounts: Vec<AccountSummary>, + selected: Option<PublicKey>, + ) -> Result<AppSnapshot, SafeError> { + self.pending_activation = None; + AppSnapshot::ready( + revision, + self.snapshot.relay_configuration().clone(), + accounts, + selected, + self.snapshot.session(), + self.snapshot.active_account().cloned(), + None, + ) + } + fn select( &self, revision: crate::SnapshotRevision, diff --git a/core/crates/storage/src/application_adapter.rs b/core/crates/storage/src/application_adapter.rs @@ -2,7 +2,7 @@ use std::path::Path; use radroots_studio_application::{ AppCore, AppSnapshot, Clock, GenerateAccountReceipt, ImportAccountReceipt, RelayConfiguration, - SecretStore, + RemovalConfirmationToken, SecretStore, }; use radroots_studio_domain::{PublicKey, SafeError, SecretKeyInput}; @@ -128,6 +128,39 @@ impl PersistentAppCore { self.core.sign_out() } + /// Issues a revision-bound, single-use account-removal confirmation. + /// + /// # Errors + /// + /// Returns a safe error when the target account is not saved. + pub fn request_account_removal( + &self, + public_key: PublicKey, + ) -> Result<RemovalConfirmationToken, SafeError> { + self.core.request_account_removal(public_key) + } + + /// Permanently removes one confirmed account and its credential. + /// + /// # Errors + /// + /// Returns a safe confirmation, credential, storage, recovery, or state error. + pub fn confirm_account_removal( + &self, + token: RemovalConfirmationToken, + secrets: &(impl SecretStore + ?Sized), + clock: &(impl Clock + ?Sized), + ) -> Result<AppSnapshot, SafeError> { + self.core.confirm_account_removal( + token, + &self.database, + &self.database, + secrets, + &self.database, + clock, + ) + } + #[must_use] pub const fn core(&self) -> &AppCore { &self.core diff --git a/docs/implementation/nostr-runtime-rcld.md b/docs/implementation/nostr-runtime-rcld.md @@ -508,7 +508,7 @@ handoff commit sequence. - [x] 31. Implement select account command. - [x] 32. Implement activate account with safe replacement ordering. - [x] 33. Implement sign out command. -- [ ] 34. Implement revision-bound removal request/confirmation flow. +- [x] 34. Implement revision-bound removal request/confirmation flow. - [ ] 35. Implement removal journal recovery. ### RCLD-08