commit d54cb234c3c14ce45f09cecf0e03ce5ee0a82ab2
parent 4f922d5cea7240d6f1750297aca1b40891ba63f2
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:
5 files changed, 250 insertions(+), 5 deletions(-)
diff --git a/crates/studio_application/src/accounts.rs b/crates/studio_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 = ®istry[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/crates/studio_application/src/app_core.rs b/crates/studio_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/crates/studio_application/src/lib.rs b/crates/studio_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/crates/studio_application/src/state_machine.rs b/crates/studio_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/crates/studio_storage/src/application_adapter.rs b/crates/studio_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