commit 1bce0fbf74b7bbd8e1c745a909ce025bc91e9163
parent 4bc4154e0ef6a5e02a62e3f6cddf9cb71af96299
Author: triesap <tyson@radroots.org>
Date: Sun, 2 Aug 2026 19:07:28 +0000
core(session): activate accounts with safe replacement
- validate candidate credentials before replacing active state
- load cached public profiles during session preparation
- persist selection and last-used metadata before activation
- preserve the previous working session when replacement fails
Diffstat:
4 files changed, 233 insertions(+), 0 deletions(-)
diff --git a/crates/studio_application/src/lib.rs b/crates/studio_application/src/lib.rs
@@ -4,6 +4,7 @@ pub mod accounts;
pub mod app_core;
pub mod ports;
pub mod secrets;
+pub mod session;
pub mod snapshot;
pub mod state_machine;
diff --git a/crates/studio_application/src/session.rs b/crates/studio_application/src/session.rs
@@ -0,0 +1,198 @@
+use radroots_studio_domain::{PublicKey, SafeError, SafeErrorCode, SafeMessage};
+use radroots_studio_nostr::import_secret;
+
+use crate::{
+ AccountRepository, ActiveAccountSnapshot, AppCore, AppSnapshot, AppStateRepository, Clock,
+ ProfileLoadState, ProfileRepository, RelayConnectionState, SecretStore, StateTransition,
+};
+
+impl AppCore {
+ /// Validates and prepares a saved local account before replacing the active session.
+ ///
+ /// # Errors
+ ///
+ /// Returns a safe account, credential, profile-cache, persistence, or state
+ /// error while preserving any previously active session.
+ pub fn activate_account(
+ &self,
+ public_key: PublicKey,
+ accounts: &(impl AccountRepository + ?Sized),
+ app_state: &(impl AppStateRepository + ?Sized),
+ profiles: &(impl ProfileRepository + ?Sized),
+ secrets: &(impl SecretStore + ?Sized),
+ clock: &(impl Clock + ?Sized),
+ ) -> Result<AppSnapshot, SafeError> {
+ let account = accounts
+ .find_account(public_key)?
+ .ok_or_else(account_not_found)?;
+ self.apply_transition(StateTransition::BeginActivation(public_key))?;
+ let prepared = (|| {
+ let credential = secrets.load(public_key)?;
+ let imported = import_secret(credential)?;
+ let (derived_public_key, _npub, canonical_secret) = imported.into_parts();
+ drop(canonical_secret);
+ if derived_public_key != public_key {
+ return Err(invalid_credential());
+ }
+ let cached = profiles.load_profile(public_key)?;
+ let active = ActiveAccountSnapshot::new(
+ account.with_last_used_at(clock.now()),
+ RelayConnectionState::Disconnected,
+ if cached.is_some() {
+ ProfileLoadState::Cached
+ } else {
+ ProfileLoadState::Empty
+ },
+ cached.map(|profile| profile.candidate().metadata().clone()),
+ );
+ accounts.update_account(active.account())?;
+ app_state.save_selected_account(Some(public_key))?;
+ Ok(active)
+ })();
+ match prepared {
+ Ok(active) => {
+ self.apply_transition(StateTransition::ActivationSucceeded(Box::new(active)))
+ }
+ Err(error) => {
+ self.apply_transition(StateTransition::ActivationFailed(error))?;
+ Err(error)
+ }
+ }
+ }
+}
+
+const fn account_not_found() -> SafeError {
+ SafeError::new(
+ SafeErrorCode::AccountNotFound,
+ SafeMessage::new("The account was not found."),
+ )
+}
+
+const fn invalid_credential() -> SafeError {
+ SafeError::new(
+ SafeErrorCode::InvalidSecretKey,
+ SafeMessage::new("The Nostr account credential is invalid."),
+ )
+}
+
+#[cfg(test)]
+mod tests {
+ use radroots_studio_domain::{PublicKey, SafeError, SecretKeyInput, UnixTimestamp};
+
+ use crate::{
+ AppCore, CachedProfile, Clock, InMemoryAccountRepository, InMemoryOperationJournal,
+ InMemorySecretStore, ProfileRefreshStatus, ProfileRepository, RelayConfiguration,
+ SecretStore, SessionState,
+ };
+
+ #[derive(Default)]
+ struct EmptyProfiles;
+
+ impl ProfileRepository for EmptyProfiles {
+ fn load_profile(&self, _public_key: PublicKey) -> Result<Option<CachedProfile>, SafeError> {
+ Ok(None)
+ }
+
+ fn save_profile(&self, _profile: &CachedProfile) -> Result<(), SafeError> {
+ Ok(())
+ }
+
+ fn record_refresh_status(
+ &self,
+ _public_key: PublicKey,
+ _refreshed_at: UnixTimestamp,
+ _status: ProfileRefreshStatus,
+ ) -> Result<(), SafeError> {
+ Ok(())
+ }
+
+ fn remove_profile(&self, _public_key: PublicKey) -> Result<(), SafeError> {
+ Ok(())
+ }
+ }
+
+ struct FixedClock;
+
+ impl Clock for FixedClock {
+ fn now(&self) -> UnixTimestamp {
+ UnixTimestamp::from_seconds(30).expect("time")
+ }
+ }
+
+ fn input(value: &str) -> SecretKeyInput {
+ SecretKeyInput::parse(value.to_owned()).expect("input")
+ }
+
+ #[test]
+ fn activate_account_switches_only_after_candidate_is_ready() {
+ let core = AppCore::in_memory(RelayConfiguration::default());
+ let accounts = InMemoryAccountRepository::default();
+ let secrets = InMemorySecretStore::default();
+ let journal = InMemoryOperationJournal::default();
+ let profiles = EmptyProfiles;
+ core.bootstrap().expect("bootstrap");
+ let first = core
+ .import_secret_key(
+ input("7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7"),
+ &accounts,
+ &accounts,
+ &secrets,
+ &journal,
+ &FixedClock,
+ )
+ .expect("first")
+ .account()
+ .public_key();
+ let second = core
+ .import_secret_key(
+ input("1111111111111111111111111111111111111111111111111111111111111111"),
+ &accounts,
+ &accounts,
+ &secrets,
+ &journal,
+ &FixedClock,
+ )
+ .expect("second")
+ .account()
+ .public_key();
+ core.activate_account(
+ first,
+ &accounts,
+ &accounts,
+ &profiles,
+ &secrets,
+ &FixedClock,
+ )
+ .expect("activate first");
+ assert_eq!(core.snapshot().session(), SessionState::Active);
+ assert_eq!(
+ core.snapshot()
+ .active_account()
+ .map(|active| active.account().public_key()),
+ Some(first)
+ );
+
+ secrets.delete(second).expect("remove second credential");
+ let error = core
+ .activate_account(
+ second,
+ &accounts,
+ &accounts,
+ &profiles,
+ &secrets,
+ &FixedClock,
+ )
+ .expect_err("missing credential");
+ assert_eq!(
+ error.code(),
+ radroots_studio_domain::SafeErrorCode::CredentialMissing
+ );
+ assert_eq!(core.snapshot().session(), SessionState::Active);
+ assert_eq!(
+ core.snapshot()
+ .active_account()
+ .map(|active| active.account().public_key()),
+ Some(first)
+ );
+ }
+}
diff --git a/crates/studio_domain/src/account.rs b/crates/studio_domain/src/account.rs
@@ -144,6 +144,19 @@ impl AccountSummary {
}
#[must_use]
+ pub fn with_last_used_at(&self, last_used_at: UnixTimestamp) -> Self {
+ Self {
+ public_key: self.public_key,
+ npub: self.npub.clone(),
+ signer_kind: self.signer_kind,
+ key_availability: self.key_availability,
+ label: self.label.clone(),
+ created_at: self.created_at,
+ last_used_at: Some(last_used_at),
+ }
+ }
+
+ #[must_use]
pub fn display_label(&self) -> String {
self.label
.as_ref()
diff --git a/crates/studio_storage/src/application_adapter.rs b/crates/studio_storage/src/application_adapter.rs
@@ -98,6 +98,27 @@ impl PersistentAppCore {
.select_account(public_key, &self.database, &self.database)
}
+ /// Activates a saved account after validating its credential and cached profile.
+ ///
+ /// # Errors
+ ///
+ /// Returns a safe account, credential, storage, or application-state error.
+ pub fn activate_account(
+ &self,
+ public_key: PublicKey,
+ secrets: &(impl SecretStore + ?Sized),
+ clock: &(impl Clock + ?Sized),
+ ) -> Result<AppSnapshot, SafeError> {
+ self.core.activate_account(
+ public_key,
+ &self.database,
+ &self.database,
+ &self.database,
+ secrets,
+ clock,
+ )
+ }
+
#[must_use]
pub const fn core(&self) -> &AppCore {
&self.core