app

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

commit 1b8acc061ac044c56c03c60dd9ad69e230cba072
parent 9e68fb26ba41bb74b1bcb6f6f1aebc1ea6361ee3
Author: triesap <tyson@radroots.org>
Date:   Sun,  9 Aug 2026 17:49:52 +0000

runtime: own the Studio runtime crate

- copy the exact locked Studio runtime source and integration tests
- register runtime composition as a local workspace member
- compose only locally owned HarvestCircle product crates
- refresh the lockfile and UUID workspace dependency

Diffstat:
Mcore/Cargo.lock | 20++++++++++++++++++--
Mcore/Cargo.toml | 4+++-
Acore/crates/studio_runtime/Cargo.toml | 29+++++++++++++++++++++++++++++
Acore/crates/studio_runtime/src/blocking.rs | 114+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acore/crates/studio_runtime/src/installation.rs | 58++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acore/crates/studio_runtime/src/lib.rs | 12++++++++++++
Acore/crates/studio_runtime/src/persistence.rs | 746+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acore/crates/studio_runtime/src/runtime_actor.rs | 2276+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acore/crates/studio_runtime/tests/local_relay_e2e.rs | 100+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acore/crates/studio_runtime/tests/restart_isolation.rs | 95+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
10 files changed, 3451 insertions(+), 3 deletions(-)

diff --git a/core/Cargo.lock b/core/Cargo.lock @@ -2008,7 +2008,7 @@ dependencies = [ "radroots_studio_application 0.1.0-alpha (git+https://github.com/radrootslabs/lib?rev=09065a610d95e57acdc895a14c07580fa099e7c3)", "radroots_studio_domain 0.1.0-alpha (git+https://github.com/radrootslabs/lib?rev=09065a610d95e57acdc895a14c07580fa099e7c3)", "radroots_studio_nostr 0.1.0-alpha (git+https://github.com/radrootslabs/lib?rev=09065a610d95e57acdc895a14c07580fa099e7c3)", - "radroots_studio_runtime", + "radroots_studio_runtime 0.1.0-alpha (git+https://github.com/radrootslabs/lib?rev=09065a610d95e57acdc895a14c07580fa099e7c3)", "radroots_studio_storage 0.1.0-alpha (git+https://github.com/radrootslabs/lib?rev=09065a610d95e57acdc895a14c07580fa099e7c3)", "sha2", "syn 2.0.119", @@ -2056,6 +2056,22 @@ dependencies = [ [[package]] name = "radroots_studio_runtime" version = "0.1.0-alpha" +dependencies = [ + "nostr 0.44.1", + "nostr-relay-builder", + "nostr-sdk 0.44.0", + "radroots_studio_application 0.1.0-alpha", + "radroots_studio_domain 0.1.0-alpha", + "radroots_studio_nostr 0.1.0-alpha", + "radroots_studio_storage 0.1.0-alpha", + "tempfile", + "tokio", + "uuid", +] + +[[package]] +name = "radroots_studio_runtime" +version = "0.1.0-alpha" source = "git+https://github.com/radrootslabs/lib?rev=09065a610d95e57acdc895a14c07580fa099e7c3#09065a610d95e57acdc895a14c07580fa099e7c3" dependencies = [ "radroots_studio_application 0.1.0-alpha (git+https://github.com/radrootslabs/lib?rev=09065a610d95e57acdc895a14c07580fa099e7c3)", @@ -2075,7 +2091,7 @@ dependencies = [ "radroots_studio_ffi", "radroots_studio_nostr 0.1.0-alpha", "radroots_studio_preferences", - "radroots_studio_runtime", + "radroots_studio_runtime 0.1.0-alpha", "radroots_studio_storage 0.1.0-alpha", ] diff --git a/core/Cargo.toml b/core/Cargo.toml @@ -5,6 +5,7 @@ members = [ "crates/studio_domain", "crates/studio_nostr", "crates/studio_preferences", + "crates/studio_runtime", "crates/studio_storage", ] resolver = "3" @@ -32,7 +33,7 @@ radroots_studio_domain = { path = "crates/studio_domain", version = "=0.1.0-alph radroots_studio_ffi = { git = "https://github.com/radrootslabs/lib", rev = "09065a610d95e57acdc895a14c07580fa099e7c3", version = "=0.1.0-alpha" } radroots_studio_nostr = { path = "crates/studio_nostr", version = "=0.1.0-alpha" } radroots_studio_preferences = { path = "crates/studio_preferences", version = "=0.1.0-alpha" } -radroots_studio_runtime = { git = "https://github.com/radrootslabs/lib", rev = "09065a610d95e57acdc895a14c07580fa099e7c3", version = "=0.1.0-alpha" } +radroots_studio_runtime = { path = "crates/studio_runtime", version = "=0.1.0-alpha" } radroots_studio_storage = { path = "crates/studio_storage", version = "=0.1.0-alpha" } radroots_identity = { git = "https://github.com/radrootslabs/lib", rev = "09065a610d95e57acdc895a14c07580fa099e7c3", version = "=0.1.0-alpha", default-features = false } radroots_transport = { git = "https://github.com/radrootslabs/lib", rev = "09065a610d95e57acdc895a14c07580fa099e7c3", version = "=0.1.0-alpha", default-features = false } @@ -41,3 +42,4 @@ getrandom = { version = "0.2", default-features = false } hmac = { version = "0.12", default-features = false } rustix = { version = "1", features = ["fs", "process", "std"] } sha2 = { version = "0.10", default-features = false } +uuid = { version = "1.22.0", features = ["v4", "v7"] } diff --git a/core/crates/studio_runtime/Cargo.toml b/core/crates/studio_runtime/Cargo.toml @@ -0,0 +1,29 @@ +[package] +name = "radroots_studio_runtime" +description = "Private supervised composition runtime for Radroots Studio" +version = "0.1.0-alpha" +edition.workspace = true +authors.workspace = true +rust-version.workspace = true +license = "GPL-3.0-only" +repository.workspace = true +homepage.workspace = true +publish = false +include = ["src/**", "tests/**", "Cargo.toml"] + +[dependencies] +radroots_studio_application.workspace = true +radroots_studio_domain.workspace = true +radroots_studio_nostr.workspace = true +radroots_studio_storage.workspace = true +tokio = { version = "=1.47.1", features = ["macros", "rt-multi-thread", "sync", "time"] } +uuid.workspace = true + +[dev-dependencies] +nostr = { git = "https://github.com/rust-nostr/nostr.git", rev = "5bba5163eb77107f82c4a8262cf29d7f33a73219", package = "nostr" } +nostr-relay-builder = { git = "https://github.com/rust-nostr/nostr.git", rev = "5bba5163eb77107f82c4a8262cf29d7f33a73219", package = "nostr-relay-builder" } +nostr-sdk = { git = "https://github.com/rust-nostr/nostr.git", rev = "5bba5163eb77107f82c4a8262cf29d7f33a73219", package = "nostr-sdk" } +tempfile = "=3.23.0" + +[lints] +workspace = true diff --git a/core/crates/studio_runtime/src/blocking.rs b/core/crates/studio_runtime/src/blocking.rs @@ -0,0 +1,114 @@ +use std::sync::Arc; +use std::time::Instant; + +use tokio::runtime::Handle; +use tokio::sync::Semaphore; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum BlockingExecutionError { + DeadlineElapsed, + Saturated, + TaskFailed, +} + +#[derive(Clone)] +pub(crate) struct BoundedBlockingExecutor { + permits: Arc<Semaphore>, + runtime: Handle, +} + +impl BoundedBlockingExecutor { + pub(crate) fn new(capacity: usize, runtime: &Handle) -> Self { + Self { + permits: Arc::new(Semaphore::new(capacity)), + runtime: runtime.clone(), + } + } + + pub(crate) async fn execute<T, F>( + &self, + deadline: Instant, + operation: F, + ) -> Result<T, BlockingExecutionError> + where + T: Send + 'static, + F: FnOnce() -> T + Send + 'static, + { + if Instant::now() >= deadline { + return Err(BlockingExecutionError::DeadlineElapsed); + } + let permit = self + .permits + .clone() + .try_acquire_owned() + .map_err(|_| BlockingExecutionError::Saturated)?; + self.runtime + .spawn_blocking(move || { + let _permit = permit; + operation() + }) + .await + .map_err(|_| BlockingExecutionError::TaskFailed) + } +} + +#[cfg(test)] +mod tests { + use std::sync::{Arc, Condvar, Mutex}; + use std::time::{Duration, Instant}; + + use tokio::sync::oneshot; + + use super::{BlockingExecutionError, BoundedBlockingExecutor}; + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn executor_rejects_saturation_without_starting_excess_work() { + let executor = BoundedBlockingExecutor::new(1, &tokio::runtime::Handle::current()); + let release = Arc::new((Mutex::new(false), Condvar::new())); + let first_release = Arc::clone(&release); + let (started, started_rx) = oneshot::channel(); + let first_executor = executor.clone(); + let first = tokio::spawn(async move { + first_executor + .execute(Instant::now() + Duration::from_secs(5), move || { + let _ = started.send(()); + let (lock, ready) = &*first_release; + let mut released = lock.lock().expect("release lock"); + while !*released { + released = ready.wait(released).expect("release wait"); + } + 7 + }) + .await + }); + started_rx.await.expect("first work starts"); + + let second = executor + .execute(Instant::now() + Duration::from_secs(5), || 9) + .await; + assert_eq!(second, Err(BlockingExecutionError::Saturated)); + + let (lock, ready) = &*release; + *lock.lock().expect("release lock") = true; + ready.notify_all(); + assert_eq!(first.await.expect("first join"), Ok(7)); + } + + #[tokio::test] + async fn executor_rejects_expired_work_before_spawn() { + let executor = BoundedBlockingExecutor::new(1, &tokio::runtime::Handle::current()); + let result = executor.execute(Instant::now(), || 1).await; + assert_eq!(result, Err(BlockingExecutionError::DeadlineElapsed)); + } + + #[tokio::test] + async fn executor_classifies_panicked_work_without_panicking_the_actor() { + let executor = BoundedBlockingExecutor::new(1, &tokio::runtime::Handle::current()); + let result = executor + .execute::<(), _>(Instant::now() + Duration::from_secs(1), || { + panic!("test-only blocking task failure"); + }) + .await; + assert_eq!(result, Err(BlockingExecutionError::TaskFailed)); + } +} diff --git a/core/crates/studio_runtime/src/installation.rs b/core/crates/studio_runtime/src/installation.rs @@ -0,0 +1,58 @@ +use radroots_studio_domain::{SafeError, SafeErrorCode, SafeMessage}; + +#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] +pub struct InstallationIdentity(String); + +impl InstallationIdentity { + pub fn parse(value: impl Into<String>) -> Result<Self, SafeError> { + let value = value.into(); + if value.len() != 32 + || !value + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) + { + return Err(invalid_installation_identity()); + } + Ok(Self(value)) + } + + #[must_use] + pub fn as_str(&self) -> &str { + self.0.as_str() + } +} + +pub trait InstallationIdentitySource: Send + Sync { + fn generate(&self) -> Result<InstallationIdentity, SafeError>; +} + +#[derive(Clone, Copy, Debug, Default)] +pub struct UuidInstallationIdentitySource; + +impl InstallationIdentitySource for UuidInstallationIdentitySource { + fn generate(&self) -> Result<InstallationIdentity, SafeError> { + InstallationIdentity::parse(uuid::Uuid::new_v4().simple().to_string()) + } +} + +const fn invalid_installation_identity() -> SafeError { + SafeError::new( + SafeErrorCode::InvalidApplicationState, + SafeMessage::new("The installation identity is invalid."), + ) +} + +#[cfg(test)] +mod tests { + use super::{InstallationIdentity, InstallationIdentitySource, UuidInstallationIdentitySource}; + + #[test] + fn installation_identity_is_fixed_width_lowercase_hex() { + let identity = UuidInstallationIdentitySource.generate().expect("identity"); + assert_eq!(identity.as_str().len(), 32); + assert!(InstallationIdentity::parse(identity.as_str()).is_ok()); + for denied in ["", "AAaabbccddeeff001122334455667788", "not-an-identity"] { + assert!(InstallationIdentity::parse(denied).is_err()); + } + } +} diff --git a/core/crates/studio_runtime/src/lib.rs b/core/crates/studio_runtime/src/lib.rs @@ -0,0 +1,12 @@ +#![doc = "Radroots Studio supervised runtime composition."] + +mod blocking; +mod installation; +mod persistence; +mod runtime_actor; + +pub use installation::{ + InstallationIdentity, InstallationIdentitySource, UuidInstallationIdentitySource, +}; +pub use persistence::PersistentAppCore; +pub use runtime_actor::{RuntimeActorHandle, RuntimeChangeSubscription, RuntimeDependencies}; diff --git a/core/crates/studio_runtime/src/persistence.rs b/core/crates/studio_runtime/src/persistence.rs @@ -0,0 +1,746 @@ +use std::path::Path; +use std::sync::Arc; + +use radroots_studio_application::{ + AppCore, AppSnapshot, Clock, DurableRequestId, GenerateAccountReceipt, ImportAccountReceipt, + KeyMaterialProvider, RelayConfiguration, RemovalConfirmationToken, SecretStore, + StagedGeneratedKey, +}; +use radroots_studio_domain::{PublicKey, SafeError, SecretKeyInput}; +use radroots_studio_nostr::NostrKeyMaterialProvider; + +use radroots_studio_storage::Database; + +use crate::{InstallationIdentity, InstallationIdentitySource}; + +pub struct PersistentAppCore { + core: AppCore, + database: Database, + key_material: Arc<dyn KeyMaterialProvider>, +} + +impl PersistentAppCore { + pub(crate) fn initialize_installation_identity( + &self, + source: &dyn InstallationIdentitySource, + ) -> Result<InstallationIdentity, SafeError> { + if let Some(existing) = self.database.load_installation_id()? { + return InstallationIdentity::parse(existing); + } + let candidate = source.generate()?; + InstallationIdentity::parse( + self.database + .initialize_installation_id(candidate.as_str())?, + ) + } + + /// Commits an acknowledged generated-key stage through the durable coordinator. + /// + /// # Errors + /// + /// Returns a safe conflict, credential, storage, or recovery error. + pub fn commit_staged_generated_key( + &self, + request_id: &DurableRequestId, + staged: StagedGeneratedKey, + secrets: &(impl SecretStore + ?Sized), + clock: &(impl Clock + ?Sized), + ) -> Result<ImportAccountReceipt, SafeError> { + self.core.commit_staged_generated_key( + request_id, + staged, + &self.database, + &self.database, + secrets, + &self.database, + clock, + ) + } + + /// Opens the application database without accessing credentials or relays. + /// + /// # Errors + /// + /// Returns a safe storage error when the database cannot be opened or migrated. + pub fn open(path: &Path, relay_configuration: RelayConfiguration) -> Result<Self, SafeError> { + let key_material: Arc<dyn KeyMaterialProvider> = Arc::new(NostrKeyMaterialProvider); + Ok(Self { + core: AppCore::new(relay_configuration, Arc::clone(&key_material)), + database: Database::open(path)?, + key_material, + }) + } + + /// Creates an isolated persistent-core adapter for tests. + /// + /// # Errors + /// + /// Returns a safe storage error when the database cannot be initialized. + pub fn in_memory(relay_configuration: RelayConfiguration) -> Result<Self, SafeError> { + let key_material: Arc<dyn KeyMaterialProvider> = Arc::new(NostrKeyMaterialProvider); + Ok(Self { + core: AppCore::new(relay_configuration, Arc::clone(&key_material)), + database: Database::in_memory()?, + key_material, + }) + } + + pub(crate) fn key_material(&self) -> &dyn KeyMaterialProvider { + self.key_material.as_ref() + } + + /// Restores public accounts and selection while keeping the session signed out. + /// + /// # Errors + /// + /// Returns a safe storage or application-state error after publishing a fatal + /// snapshot when durable state cannot be restored. + pub fn bootstrap( + &self, + secrets: &(impl SecretStore + ?Sized), + clock: &(impl Clock + ?Sized), + ) -> Result<AppSnapshot, SafeError> { + self.core.recover_durable_operations( + &self.database, + &self.database, + secrets, + &self.database, + clock, + )?; + self.core.recover_pending_operations( + &self.database, + &self.database, + secrets, + &self.database, + clock, + )?; + self.core.bootstrap_from(&self.database, &self.database) + } + + /// Generates and durably persists one selected, signed-out local account. + /// + /// # Errors + /// + /// Returns a safe credential, storage, key, or application-state error. + pub fn generate_account( + &self, + secrets: &(impl SecretStore + ?Sized), + clock: &(impl Clock + ?Sized), + ) -> Result<GenerateAccountReceipt, SafeError> { + self.core.generate_account( + &self.database, + &self.database, + secrets, + &self.database, + clock, + ) + } + + /// Imports and durably persists one selected, signed-out local account. + /// + /// # Errors + /// + /// Returns a safe credential, storage, key, or application-state error. + pub fn import_secret_key( + &self, + input: SecretKeyInput, + secrets: &(impl SecretStore + ?Sized), + clock: &(impl Clock + ?Sized), + ) -> Result<ImportAccountReceipt, SafeError> { + self.core.import_secret_key( + input, + &self.database, + &self.database, + secrets, + &self.database, + clock, + ) + } + + /// Generates an account through the durable request coordinator. + /// + /// # Errors + /// + /// Returns a safe conflict, credential, storage, or application-state error. + pub fn generate_account_durable( + &self, + request_id: &DurableRequestId, + expected_revision: u64, + secrets: &(impl SecretStore + ?Sized), + clock: &(impl Clock + ?Sized), + ) -> Result<GenerateAccountReceipt, SafeError> { + self.core.generate_account_durable( + request_id, + expected_revision, + &self.database, + &self.database, + secrets, + &self.database, + clock, + ) + } + + /// Imports or repairs an account through the durable request coordinator. + /// + /// # Errors + /// + /// Returns a safe conflict, validation, credential, storage, or state error. + pub fn import_secret_key_durable( + &self, + request_id: &DurableRequestId, + expected_revision: u64, + input: SecretKeyInput, + secrets: &(impl SecretStore + ?Sized), + clock: &(impl Clock + ?Sized), + ) -> Result<ImportAccountReceipt, SafeError> { + self.core.import_secret_key_durable( + request_id, + expected_revision, + input, + &self.database, + &self.database, + secrets, + &self.database, + clock, + ) + } + + /// Persists and publishes one saved-account selection without activation. + /// + /// # Errors + /// + /// Returns a safe account, storage, or application-state error. + pub fn select_account(&self, public_key: PublicKey) -> Result<AppSnapshot, SafeError> { + self.core + .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, + ) + } + + /// Signs out while retaining durable account data and credentials. + /// + /// # Errors + /// + /// Returns a safe application-state error if sign out cannot complete. + pub fn sign_out(&self) -> Result<AppSnapshot, SafeError> { + 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, + clock: &(impl Clock + ?Sized), + ) -> Result<RemovalConfirmationToken, SafeError> { + self.core.request_account_removal(public_key, clock) + } + + /// 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, + ) + } + + /// Executes a confirmed removal through the durable request coordinator. + /// + /// # Errors + /// + /// Returns a safe expiry, conflict, credential, storage, recovery, or state error. + pub fn confirm_account_removal_durable( + &self, + request_id: &DurableRequestId, + token: RemovalConfirmationToken, + secrets: &(impl SecretStore + ?Sized), + clock: &(impl Clock + ?Sized), + ) -> Result<AppSnapshot, SafeError> { + self.core.confirm_account_removal_durable( + request_id, + token, + &self.database, + &self.database, + secrets, + &self.database, + clock, + ) + } + + #[must_use] + pub const fn core(&self) -> &AppCore { + &self.core + } + + #[must_use] + pub const fn database(&self) -> &Database { + &self.database + } +} + +#[cfg(test)] +mod tests { + use std::fs; + + use radroots_studio_application::{ + AccountOperationKind, AccountOperationPhase, AccountRepository, AppLifecycle, + AppStateRepository, Clock, DurableOperationKind, DurableOperationPhase, + DurableOperationRepository, DurableRequestId, DurableTerminalOutcome, FailureSecretStore, + InMemorySecretStore, OperationJournal, OperationPriorState, RelayConfiguration, + SecretStore, SecretStoreOperation, SessionState, + }; + use radroots_studio_domain::{ + AccountCreatedAt, AccountIdentity, AccountSummary, BindingAvailability, LocalSignerBinding, + PublicKey, SafeErrorCode, SecretKeyInput, UnixTimestamp, + }; + use tempfile::tempdir; + + use super::PersistentAppCore; + + fn account() -> AccountSummary { + let public_key = PublicKey::from_bytes([7; 32]).expect("valid public key"); + AccountSummary::new( + AccountIdentity::derive(public_key).expect("identity"), + LocalSignerBinding::new(public_key, BindingAvailability::Available), + None, + AccountCreatedAt::new(UnixTimestamp::from_seconds(1).expect("time")), + None, + ) + .expect("account") + } + + struct FixedClock; + + impl Clock for FixedClock { + fn now(&self) -> UnixTimestamp { + UnixTimestamp::from_seconds(25).expect("time") + } + } + + #[test] + fn persistent_bootstrap_handles_fresh_and_existing_signed_out_state() { + let directory = tempdir().expect("directory"); + let path = directory.path().join("studio.sqlite3"); + let public_key = account().public_key(); + let secrets = InMemorySecretStore::default(); + { + let adapter = PersistentAppCore::open(&path, RelayConfiguration::default()) + .expect("open adapter"); + let fresh = adapter + .bootstrap(&secrets, &FixedClock) + .expect("fresh bootstrap"); + assert!(fresh.accounts().is_empty()); + adapter + .database() + .insert_account(&account()) + .expect("account"); + adapter + .database() + .save_selected_account(Some(public_key)) + .expect("selection"); + } + + let adapter = + PersistentAppCore::open(&path, RelayConfiguration::default()).expect("reopen adapter"); + let restored = adapter.bootstrap(&secrets, &FixedClock).expect("restore"); + assert_eq!(restored.lifecycle(), AppLifecycle::Ready); + assert_eq!(restored.accounts().len(), 1); + assert_eq!(restored.selected_account(), Some(public_key)); + assert_eq!(restored.session(), SessionState::SignedOut); + assert!(restored.active_account().is_none()); + } + + #[test] + fn corrupt_database_fails_safely_without_recreation() { + let directory = tempdir().expect("directory"); + let path = directory.path().join("studio.sqlite3"); + fs::write(&path, b"not a sqlite database").expect("corrupt file"); + + let error = PersistentAppCore::open(&path, RelayConfiguration::default()) + .err() + .expect("safe failure"); + assert_eq!(error.code(), SafeErrorCode::StorageCorrupt); + assert_eq!( + fs::read(&path).expect("unchanged file"), + b"not a sqlite database" + ); + } + + #[test] + fn persisted_generate_and_import_survive_restart_without_secret_bytes() { + let directory = tempdir().expect("directory"); + let path = directory.path().join("studio.sqlite3"); + let secrets = InMemorySecretStore::default(); + let selected; + { + let adapter = + PersistentAppCore::open(&path, RelayConfiguration::default()).expect("adapter"); + adapter.bootstrap(&secrets, &FixedClock).expect("bootstrap"); + let generated = adapter + .generate_account(&secrets, &FixedClock) + .expect("generate"); + assert!( + secrets + .contains(generated.account().public_key()) + .expect("generated credential") + ); + let imported = adapter + .import_secret_key( + SecretKeyInput::parse( + "7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7" + .to_owned(), + ) + .expect("secret"), + &secrets, + &FixedClock, + ) + .expect("import"); + selected = imported.account().public_key(); + assert_eq!(adapter.core().snapshot().accounts().len(), 2); + } + + let bytes = fs::read(&path).expect("database bytes"); + assert!(!bytes.windows(5).any(|value| value == b"nsec1")); + assert!(!bytes.windows(64).any(|value| { + value == b"7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7" + })); + let reopened = + PersistentAppCore::open(&path, RelayConfiguration::default()).expect("reopen"); + let restored = reopened.bootstrap(&secrets, &FixedClock).expect("restore"); + assert_eq!(restored.accounts().len(), 2); + assert_eq!(restored.selected_account(), Some(selected)); + assert_eq!(restored.session(), SessionState::SignedOut); + } + + #[test] + fn durable_import_commits_each_phase_and_recovers_the_terminal_receipt() { + let adapter = PersistentAppCore::in_memory(RelayConfiguration::default()).expect("adapter"); + let secrets = InMemorySecretStore::default(); + let snapshot = adapter.bootstrap(&secrets, &FixedClock).expect("bootstrap"); + let request = DurableRequestId::parse("import:adapter:1").expect("request"); + let imported = adapter + .import_secret_key_durable( + &request, + snapshot.revision().value(), + SecretKeyInput::parse( + "7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7".to_owned(), + ) + .expect("secret"), + &secrets, + &FixedClock, + ) + .expect("durable import"); + let operation = adapter + .database() + .load_durable_operation(&request) + .expect("operation") + .expect("durable record"); + let receipt = operation.terminal().expect("terminal receipt"); + assert_eq!(receipt.account(), imported.account().public_key()); + assert_eq!( + receipt.resulting_revision(), + Some(adapter.core().snapshot().revision().value()) + ); + } + + #[test] + fn durable_recovery_preserves_repair_metadata_and_deletes_orphan_credentials() { + let adapter = PersistentAppCore::in_memory(RelayConfiguration::default()).expect("adapter"); + let secrets = InMemorySecretStore::default(); + let missing = account().with_binding_availability(BindingAvailability::CredentialMissing); + adapter + .database() + .insert_account(&missing) + .expect("account"); + adapter + .database() + .save_selected_account(Some(missing.public_key())) + .expect("selection"); + let request = DurableRequestId::parse("repair:recovery:1").expect("request"); + adapter + .database() + .begin_durable_operation( + &request, + DurableOperationKind::Repair, + missing.public_key(), + Some(0), + OperationPriorState::new( + Some(missing.public_key()), + Some(BindingAvailability::CredentialMissing), + ), + FixedClock.now(), + ) + .expect("intent"); + secrets + .put( + missing.public_key(), + SecretKeyInput::parse( + "7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7".to_owned(), + ) + .expect("secret"), + ) + .expect("credential"); + adapter + .database() + .advance_durable_operation( + &request, + DurableOperationPhase::IntentRecorded, + DurableOperationPhase::CredentialWritten, + FixedClock.now(), + None, + ) + .expect("credential phase"); + + adapter.bootstrap(&secrets, &FixedClock).expect("recovery"); + let repaired = adapter + .database() + .find_account(missing.public_key()) + .expect("lookup") + .expect("preserved account"); + assert_eq!( + repaired.signer().availability(), + BindingAvailability::CredentialMissing + ); + assert!(!secrets.contains(missing.public_key()).expect("credential")); + assert_eq!( + adapter + .database() + .load_durable_operation(&request) + .expect("operation") + .expect("record") + .terminal() + .expect("receipt") + .outcome(), + DurableTerminalOutcome::Failed + ); + } + + #[test] + fn durable_recovery_covers_response_loss_and_irreversible_removal_windows() { + let secrets = InMemorySecretStore::default(); + let adapter = PersistentAppCore::in_memory(RelayConfiguration::default()).expect("adapter"); + let saved = account(); + adapter.database().insert_account(&saved).expect("account"); + let import = DurableRequestId::parse("import:response-loss:1").expect("request"); + adapter + .database() + .begin_durable_operation( + &import, + DurableOperationKind::Import, + saved.public_key(), + Some(0), + OperationPriorState::new(None, None), + FixedClock.now(), + ) + .expect("intent"); + adapter + .database() + .advance_durable_operation( + &import, + DurableOperationPhase::IntentRecorded, + DurableOperationPhase::CredentialWritten, + FixedClock.now(), + None, + ) + .expect("credential"); + adapter + .database() + .advance_durable_operation( + &import, + DurableOperationPhase::CredentialWritten, + DurableOperationPhase::MetadataCommitted, + FixedClock.now(), + None, + ) + .expect("metadata"); + let restored = adapter + .bootstrap(&secrets, &FixedClock) + .expect("response recovery"); + assert_eq!(restored.selected_account(), Some(saved.public_key())); + assert_eq!( + adapter + .database() + .load_durable_operation(&import) + .expect("operation") + .expect("record") + .terminal() + .expect("receipt") + .outcome(), + DurableTerminalOutcome::Completed + ); + + let removal_adapter = + PersistentAppCore::in_memory(RelayConfiguration::default()).expect("remove adapter"); + removal_adapter + .database() + .insert_account(&saved) + .expect("remove account"); + removal_adapter + .database() + .save_selected_account(Some(saved.public_key())) + .expect("remove selection"); + let removal = DurableRequestId::parse("remove:response-loss:1").expect("request"); + removal_adapter + .database() + .begin_durable_operation( + &removal, + DurableOperationKind::Remove, + saved.public_key(), + Some(0), + OperationPriorState::new(None, Some(BindingAvailability::Available)), + FixedClock.now(), + ) + .expect("remove intent"); + removal_adapter + .database() + .advance_durable_operation( + &removal, + DurableOperationPhase::IntentRecorded, + DurableOperationPhase::CredentialDeleted, + FixedClock.now(), + None, + ) + .expect("credential deleted"); + let removed = removal_adapter + .bootstrap(&secrets, &FixedClock) + .expect("removal recovery"); + assert!(removed.accounts().is_empty()); + assert_eq!(removed.selected_account(), None); + } + + #[test] + fn bootstrap_recovery_completes_credential_deleted_removal_and_fallback() { + let directory = tempdir().expect("directory"); + let path = directory.path().join("studio.sqlite3"); + let secrets = InMemorySecretStore::default(); + let first; + let removed; + { + let adapter = + PersistentAppCore::open(&path, RelayConfiguration::default()).expect("adapter"); + adapter.bootstrap(&secrets, &FixedClock).expect("bootstrap"); + first = adapter + .generate_account(&secrets, &FixedClock) + .expect("first") + .account() + .public_key(); + removed = adapter + .generate_account(&secrets, &FixedClock) + .expect("removed") + .account() + .public_key(); + let operation = adapter + .database() + .begin_operation(AccountOperationKind::Remove, removed, FixedClock.now()) + .expect("intent"); + secrets.delete(removed).expect("credential deletion"); + adapter + .database() + .update_operation( + operation, + AccountOperationPhase::CredentialDeleted, + FixedClock.now(), + None, + ) + .expect("phase"); + } + + let reopened = + PersistentAppCore::open(&path, RelayConfiguration::default()).expect("reopen"); + let restored = reopened + .bootstrap(&secrets, &FixedClock) + .expect("recover and bootstrap"); + assert_eq!(restored.accounts().len(), 1); + assert_eq!(restored.selected_account(), Some(first)); + assert_eq!(restored.session(), SessionState::SignedOut); + assert!( + reopened + .database() + .list_pending_operations() + .expect("journal") + .is_empty() + ); + assert!( + reopened + .database() + .find_account(removed) + .expect("removed") + .is_none() + ); + } + + #[test] + fn bootstrap_skips_keyring_when_journal_empty_and_retains_failed_intent() { + let empty = PersistentAppCore::in_memory(RelayConfiguration::default()).expect("empty"); + let unavailable = FailureSecretStore::default(); + unavailable.fail_next(SecretStoreOperation::Delete); + empty + .bootstrap(&unavailable, &FixedClock) + .expect("empty journal does not access keyring"); + + let adapter = PersistentAppCore::in_memory(RelayConfiguration::default()).expect("adapter"); + adapter + .database() + .insert_account(&account()) + .expect("account"); + adapter + .database() + .save_selected_account(Some(account().public_key())) + .expect("selection"); + adapter + .database() + .begin_operation( + AccountOperationKind::Remove, + account().public_key(), + FixedClock.now(), + ) + .expect("intent"); + let failing = FailureSecretStore::default(); + failing.fail_next(SecretStoreOperation::Delete); + let error = adapter + .bootstrap(&failing, &FixedClock) + .expect_err("keyring unavailable"); + assert_eq!(error.code(), SafeErrorCode::KeyringUnavailable); + let pending = adapter + .database() + .list_pending_operations() + .expect("pending"); + assert_eq!(pending.len(), 1); + assert_eq!(pending[0].phase(), AccountOperationPhase::IntentRecorded); + } +} diff --git a/core/crates/studio_runtime/src/runtime_actor.rs b/core/crates/studio_runtime/src/runtime_actor.rs @@ -0,0 +1,2276 @@ +use std::collections::BTreeMap; +use std::future::Future; +use std::num::{NonZeroU64, NonZeroUsize}; +use std::path::Path; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant}; + +use radroots_studio_application::{ + ActorMailbox, AppSnapshot, ChangeSubscriptionId, Clock, CommandContext, CommandEnvelope, + CommandReceipt, CommandResult, CommandSubmission, DurableRequestId, ForegroundSessionBinding, + GenerateAccountReceipt, GeneratedKeyRecoveryHandle, GeneratedKeyStage, ImportAccountReceipt, + LifecycleGate, NostrClient, OrderedSnapshotChanges, ProfileFetchResult, ProfileRefreshPlan, + RecoveryStageId, RelayConfiguration, RemovalConfirmationToken, RequestId, RuntimeCommandClass, + RuntimeLifecycle, SecretStore, SessionGeneration, SnapshotChange, SnapshotChangeReceiver, + SnapshotRevision, StagedGeneratedKey, TaskCorrelation, +}; +use radroots_studio_domain::{ + AccountIdentity, BindingAvailability, LocalSignerBinding, PublicKey, SafeError, SafeErrorCode, + SafeMessage, SecretKeyInput, +}; +use tokio::runtime::Handle; +use tokio::sync::{mpsc, oneshot, watch}; + +use crate::blocking::{BlockingExecutionError, BoundedBlockingExecutor}; +use crate::{InstallationIdentity, InstallationIdentitySource, PersistentAppCore}; + +const DEFAULT_COMMAND_TIMEOUT: Duration = Duration::from_secs(30); +const DEFAULT_TASK_CAPACITY: usize = 64; +const DEFAULT_BLOCKING_CAPACITY: usize = 4; + +enum RuntimeCommand { + Snapshot, + GenerateAccount { + durable_request: DurableRequestId, + expected_revision: u64, + }, + BeginGeneratedKeyStage, + AcknowledgeGeneratedKeyStage { + id: RecoveryStageId, + durable_request: DurableRequestId, + }, + CancelGeneratedKeyStage, + ImportSecretKey { + input: SecretKeyInput, + durable_request: DurableRequestId, + expected_revision: u64, + }, + SelectAccount(PublicKey), + ActivateAccount(PublicKey), + SignOut, + RefreshActiveProfile, + RequestAccountRemoval(PublicKey), + ConfirmAccountRemoval { + token: RemovalConfirmationToken, + durable_request: DurableRequestId, + }, + SubscribeChanges(NonZeroUsize), + UnsubscribeChanges(ChangeSubscriptionId), + Close, +} + +enum RuntimeCommandValue { + Snapshot(Box<AppSnapshot>), + Generated(GenerateAccountReceipt), + GeneratedKeyStage(GeneratedKeyRecoveryHandle), + GeneratedKeyStageCancelled(bool), + Imported(ImportAccountReceipt), + RemovalRequest(RemovalConfirmationToken), + Subscription(RuntimeChangeSubscription), + Unsubscribed(bool), + Closed, +} + +impl RuntimeCommand { + const fn class(&self) -> RuntimeCommandClass { + match self { + Self::Snapshot | Self::SubscribeChanges(_) | Self::UnsubscribeChanges(_) => { + RuntimeCommandClass::Observe + } + Self::GenerateAccount { .. } + | Self::BeginGeneratedKeyStage + | Self::AcknowledgeGeneratedKeyStage { .. } + | Self::ImportSecretKey { .. } + | Self::ActivateAccount(_) + | Self::ConfirmAccountRemoval { .. } => RuntimeCommandClass::UseCredential, + Self::SelectAccount(_) + | Self::SignOut + | Self::RequestAccountRemoval(_) + | Self::CancelGeneratedKeyStage => RuntimeCommandClass::MutateLocalState, + Self::RefreshActiveProfile => RuntimeCommandClass::UseRelay, + Self::Close => RuntimeCommandClass::Shutdown, + } + } + + const fn resolves_revision_through_durable_replay(&self) -> bool { + matches!( + self, + Self::GenerateAccount { .. } | Self::ImportSecretKey { .. } + ) + } +} + +struct RuntimeActor { + adapter: Arc<PersistentAppCore>, + secrets: Arc<dyn SecretStore>, + clock: Arc<dyn Clock>, + nostr: Arc<dyn NostrClient>, + lifecycle: Arc<Mutex<LifecycleGate>>, + runtime: Handle, + blocking: BoundedBlockingExecutor, + session_generation: SessionGeneration, + published_session_generation: Arc<AtomicU64>, + profile_tasks: BTreeMap<RequestId, PendingProfileTask>, + changes: OrderedSnapshotChanges, + published_foreground_session: Arc<Mutex<Option<ForegroundSessionBinding>>>, + generated_key_stage: GeneratedKeyStage, +} + +struct PendingProfileTask { + correlation: TaskCorrelation, + plan: ProfileRefreshPlan, + deadline: Instant, + reply: oneshot::Sender<CommandReceipt<RuntimeCommandValue>>, + handle: tokio::task::JoinHandle<()>, +} + +struct ProfileCompletion { + request_id: RequestId, + result: Result<ProfileFetchResult, SafeError>, +} + +#[derive(Clone)] +pub struct RuntimeActorHandle { + mailbox: ActorMailbox<RuntimeCommand, RuntimeCommandValue>, + adapter: Arc<PersistentAppCore>, + lifecycle: Arc<Mutex<LifecycleGate>>, + next_request: Arc<AtomicU64>, + session_generation: Arc<AtomicU64>, + foreground_session: Arc<Mutex<Option<ForegroundSessionBinding>>>, + installation_identity: InstallationIdentity, + runtime: Handle, + actor_task: Arc<Mutex<Option<tokio::task::JoinHandle<()>>>>, + actor_exit: watch::Receiver<bool>, +} + +#[derive(Clone)] +pub struct RuntimeDependencies { + secrets: Arc<dyn SecretStore>, + clock: Arc<dyn Clock>, + nostr: Arc<dyn NostrClient>, + installation_source: Arc<dyn InstallationIdentitySource>, +} + +impl RuntimeDependencies { + #[must_use] + pub fn new( + secrets: Arc<dyn SecretStore>, + clock: Arc<dyn Clock>, + nostr: Arc<dyn NostrClient>, + installation_source: Arc<dyn InstallationIdentitySource>, + ) -> Self { + Self { + secrets, + clock, + nostr, + installation_source, + } + } +} + +pub struct RuntimeChangeSubscription { + id: ChangeSubscriptionId, + receiver: SnapshotChangeReceiver, +} + +impl RuntimeChangeSubscription { + #[must_use] + pub const fn id(&self) -> ChangeSubscriptionId { + self.id + } + + pub async fn receive(&mut self) -> Option<SnapshotChange> { + self.receiver.receive().await + } +} + +impl RuntimeActorHandle { + /// Opens, migrates, recovers, and starts one actor-owned file-backed runtime. + /// + /// # Errors + /// + /// Returns a safe storage, recovery, or lifecycle error before the actor is + /// published when opening cannot reach ready state. + pub async fn open( + path: &Path, + relay_configuration: RelayConfiguration, + dependencies: RuntimeDependencies, + capacity: NonZeroUsize, + runtime: &Handle, + ) -> Result<Self, SafeError> { + let blocking = BoundedBlockingExecutor::new(DEFAULT_BLOCKING_CAPACITY, runtime); + let path = path.to_path_buf(); + let adapter = blocking + .execute(Instant::now() + DEFAULT_COMMAND_TIMEOUT, move || { + PersistentAppCore::open(&path, relay_configuration) + }) + .await + .map_err(blocking_execution_failed)??; + Self::start(adapter, dependencies, capacity, runtime, blocking).await + } + + /// Starts one isolated actor-owned in-memory runtime for tests. + /// + /// # Errors + /// + /// Returns a safe storage, recovery, or lifecycle error before publication. + pub async fn in_memory( + relay_configuration: RelayConfiguration, + dependencies: RuntimeDependencies, + capacity: NonZeroUsize, + runtime: &Handle, + ) -> Result<Self, SafeError> { + let blocking = BoundedBlockingExecutor::new(DEFAULT_BLOCKING_CAPACITY, runtime); + let adapter = blocking + .execute(Instant::now() + DEFAULT_COMMAND_TIMEOUT, move || { + PersistentAppCore::in_memory(relay_configuration) + }) + .await + .map_err(blocking_execution_failed)??; + Self::start(adapter, dependencies, capacity, runtime, blocking).await + } + + async fn start( + adapter: PersistentAppCore, + dependencies: RuntimeDependencies, + capacity: NonZeroUsize, + runtime: &Handle, + blocking: BoundedBlockingExecutor, + ) -> Result<Self, SafeError> { + let mut gate = LifecycleGate::opening(); + gate.begin_compatibility_check()?; + gate.compatibility_accepted()?; + gate.ownership_acquired()?; + gate.migration_complete()?; + let RuntimeDependencies { + secrets, + clock, + nostr, + installation_source, + } = dependencies; + let adapter = Arc::new(adapter); + let bootstrap_adapter = Arc::clone(&adapter); + let bootstrap_secrets = Arc::clone(&secrets); + let bootstrap_clock = Arc::clone(&clock); + let installation_identity = blocking + .execute(Instant::now() + DEFAULT_COMMAND_TIMEOUT, move || { + bootstrap_adapter + .bootstrap(bootstrap_secrets.as_ref(), bootstrap_clock.as_ref())?; + bootstrap_adapter.initialize_installation_identity(installation_source.as_ref()) + }) + .await + .map_err(blocking_execution_failed)??; + gate.recovery_complete()?; + + let lifecycle = Arc::new(Mutex::new(gate)); + let (mailbox, receiver) = ActorMailbox::bounded(capacity); + let session_generation = Arc::new(AtomicU64::new(SessionGeneration::initial().value())); + let foreground_session = Arc::new(Mutex::new(None)); + let changes = OrderedSnapshotChanges::new(adapter.core().snapshot()); + let actor = RuntimeActor { + adapter: Arc::clone(&adapter), + secrets, + clock, + nostr, + lifecycle: Arc::clone(&lifecycle), + runtime: runtime.clone(), + blocking, + session_generation: SessionGeneration::initial(), + published_session_generation: Arc::clone(&session_generation), + profile_tasks: BTreeMap::new(), + changes, + published_foreground_session: Arc::clone(&foreground_session), + generated_key_stage: GeneratedKeyStage::default(), + }; + let (actor_exit_sender, actor_exit) = watch::channel(false); + let actor_task = runtime.spawn(async move { + actor.run(receiver).await; + let _ = actor_exit_sender.send(true); + }); + Ok(Self { + mailbox, + adapter, + lifecycle, + next_request: Arc::new(AtomicU64::new(1)), + session_generation, + foreground_session, + installation_identity, + runtime: runtime.clone(), + actor_task: Arc::new(Mutex::new(Some(actor_task))), + actor_exit, + }) + } + + #[must_use] + pub fn lifecycle(&self) -> RuntimeLifecycle { + self.lifecycle + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .lifecycle() + } + + #[must_use] + pub fn session_generation(&self) -> SessionGeneration { + SessionGeneration::from_value(self.session_generation.load(Ordering::Acquire)) + } + + #[must_use] + pub fn foreground_session(&self) -> Option<ForegroundSessionBinding> { + self.foreground_session + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .clone() + } + + #[must_use] + pub fn installation_identity(&self) -> &InstallationIdentity { + &self.installation_identity + } + + #[must_use] + pub fn snapshot(&self) -> AppSnapshot { + self.adapter.core().snapshot() + } + + /// Returns the ready snapshot through the actor command boundary. + /// + /// # Errors + /// + /// Returns a typed safe actor error. + pub async fn bootstrap(&self) -> Result<AppSnapshot, SafeError> { + Self::expect_snapshot(self.dispatch(RuntimeCommand::Snapshot, None).await?) + } + + /// Generates one account through the serialized actor boundary. + /// + /// # Errors + /// + /// Returns a safe account, storage, keyring, timeout, or actor error. + pub async fn generate_account( + &self, + request: DurableRequestId, + expected_revision: SnapshotRevision, + timeout: Duration, + ) -> Result<GenerateAccountReceipt, SafeError> { + match self + .dispatch_durable( + RuntimeCommand::GenerateAccount { + durable_request: request, + expected_revision: expected_revision.value(), + }, + expected_revision, + timeout, + ) + .await? + { + RuntimeCommandValue::Generated(receipt) => Ok(receipt), + _ => Err(invalid_actor_response()), + } + } + + /// Begins the only actor-owned generated-key recovery stage. + /// + /// # Errors + /// + /// Returns a safe conflict, timeout, key-generation, or actor error. + pub async fn begin_generated_key_stage(&self) -> Result<GeneratedKeyRecoveryHandle, SafeError> { + match self + .dispatch(RuntimeCommand::BeginGeneratedKeyStage, None) + .await? + { + RuntimeCommandValue::GeneratedKeyStage(view) => Ok(view), + _ => Err(invalid_actor_response()), + } + } + + /// Acknowledges recovery and commits the staged account and credential once. + /// + /// # Errors + /// + /// Returns a safe unavailable, conflict, keyring, storage, timeout, or actor error. + pub async fn acknowledge_generated_key_stage( + &self, + id: RecoveryStageId, + request: DurableRequestId, + expected_revision: SnapshotRevision, + timeout: Duration, + ) -> Result<AppSnapshot, SafeError> { + let value = self + .dispatch_durable( + RuntimeCommand::AcknowledgeGeneratedKeyStage { + id, + durable_request: request, + }, + expected_revision, + timeout, + ) + .await?; + Self::expect_snapshot(value) + } + + /// Cancels and zeroizes the active generated-key stage, if present. + /// + /// # Errors + /// + /// Returns a safe timeout or actor error. + pub async fn cancel_generated_key_stage(&self) -> Result<bool, SafeError> { + match self + .dispatch(RuntimeCommand::CancelGeneratedKeyStage, None) + .await? + { + RuntimeCommandValue::GeneratedKeyStageCancelled(cancelled) => Ok(cancelled), + _ => Err(invalid_actor_response()), + } + } + + /// Imports one account through the serialized actor boundary. + /// + /// # Errors + /// + /// Returns a safe account, storage, keyring, timeout, or actor error. + pub async fn import_secret_key( + &self, + request: DurableRequestId, + expected_revision: SnapshotRevision, + input: SecretKeyInput, + timeout: Duration, + ) -> Result<ImportAccountReceipt, SafeError> { + match self + .dispatch_durable( + RuntimeCommand::ImportSecretKey { + input, + durable_request: request, + expected_revision: expected_revision.value(), + }, + expected_revision, + timeout, + ) + .await? + { + RuntimeCommandValue::Imported(receipt) => Ok(receipt), + _ => Err(invalid_actor_response()), + } + } + + /// Selects one account through the serialized actor boundary. + /// + /// # Errors + /// + /// Returns a safe account, storage, timeout, or actor error. + pub async fn select_account(&self, public_key: PublicKey) -> Result<AppSnapshot, SafeError> { + let value = self + .dispatch(RuntimeCommand::SelectAccount(public_key), None) + .await?; + Self::expect_snapshot(value) + } + + /// Activates one account through the serialized actor boundary. + /// + /// # Errors + /// + /// Returns a safe account, credential, storage, timeout, or actor error. + pub async fn activate_account(&self, public_key: PublicKey) -> Result<AppSnapshot, SafeError> { + let value = self + .dispatch(RuntimeCommand::ActivateAccount(public_key), None) + .await?; + Self::expect_snapshot(value) + } + + /// Signs out through the serialized actor boundary. + /// + /// # Errors + /// + /// Returns a safe timeout or actor error. + pub async fn sign_out(&self) -> Result<AppSnapshot, SafeError> { + let value = self.dispatch(RuntimeCommand::SignOut, None).await?; + Self::expect_snapshot(value) + } + + /// Refreshes the active profile through the serialized actor boundary. + /// + /// # Errors + /// + /// Returns a safe relay, storage, timeout, or actor error. + pub async fn refresh_active_profile(&self) -> Result<AppSnapshot, SafeError> { + let value = self + .dispatch(RuntimeCommand::RefreshActiveProfile, None) + .await?; + Self::expect_snapshot(value) + } + + /// Creates one removal request through the serialized actor boundary. + /// + /// # Errors + /// + /// Returns a safe account, timeout, or actor error. + pub async fn request_account_removal( + &self, + public_key: PublicKey, + ) -> Result<RemovalConfirmationToken, SafeError> { + match self + .dispatch(RuntimeCommand::RequestAccountRemoval(public_key), None) + .await? + { + RuntimeCommandValue::RemovalRequest(token) => Ok(token), + _ => Err(invalid_actor_response()), + } + } + + /// Confirms one removal through the serialized actor boundary. + /// + /// # Errors + /// + /// Returns a safe account, credential, storage, timeout, or actor error. + pub async fn confirm_account_removal( + &self, + token: RemovalConfirmationToken, + request: DurableRequestId, + expected_revision: SnapshotRevision, + timeout: Duration, + ) -> Result<AppSnapshot, SafeError> { + let value = self + .dispatch_durable( + RuntimeCommand::ConfirmAccountRemoval { + token, + durable_request: request, + }, + expected_revision, + timeout, + ) + .await?; + Self::expect_snapshot(value) + } + + /// Closes command admission and cancels supervised work. + /// + /// # Errors + /// + /// Returns a safe timeout or actor error. Repeated calls return closed. + pub async fn close(&self) -> Result<(), SafeError> { + self.close_with_timeout(DEFAULT_COMMAND_TIMEOUT).await + } + + /// Closes the runtime within the supplied command deadline. + /// + /// # Errors + /// + /// Returns a safe timeout or actor error. An expired queued close cannot + /// later change runtime state. + pub async fn close_with_timeout(&self, timeout: Duration) -> Result<(), SafeError> { + let deadline = Instant::now() + timeout; + if matches!(self.lifecycle(), RuntimeLifecycle::Closed) { + return self.await_actor_exit(deadline).await; + } + let raw_request = self.next_request.fetch_add(1, Ordering::Relaxed); + let request_id = RequestId::new(raw_request).ok_or_else(request_space_exhausted)?; + let command_result = match self + .dispatch_with_deadline(RuntimeCommand::Close, None, request_id, deadline) + .await + { + Ok(RuntimeCommandValue::Closed) => Ok(()), + Ok(_) => Err(invalid_actor_response()), + Err(error) => Err(error), + }; + let exit_result = self.await_actor_exit(deadline).await; + if matches!(self.lifecycle(), RuntimeLifecycle::Closed) { + exit_result + } else { + command_result.and(exit_result) + } + } + + async fn await_actor_exit(&self, deadline: Instant) -> Result<(), SafeError> { + let mut actor_exit = self.actor_exit.clone(); + if !*actor_exit.borrow() { + let remaining = deadline.saturating_duration_since(Instant::now()); + self.timeout(remaining, actor_exit.wait_for(|exited| *exited)) + .await + .map_err(|_| command_timed_out())? + .map_err(|_| runtime_closed())?; + } + let actor_task = self + .actor_task + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .take(); + if let Some(actor_task) = actor_task { + let remaining = deadline.saturating_duration_since(Instant::now()); + self.timeout(remaining, actor_task) + .await + .map_err(|_| command_timed_out())? + .map_err(|_| runtime_closed())?; + } + Ok(()) + } + + /// Atomically registers a bounded ordered change consumer with its initial snapshot. + /// + /// # Errors + /// + /// Returns a safe actor or subscription error. + pub async fn subscribe_changes( + &self, + capacity: NonZeroUsize, + ) -> Result<RuntimeChangeSubscription, SafeError> { + match self + .dispatch(RuntimeCommand::SubscribeChanges(capacity), None) + .await? + { + RuntimeCommandValue::Subscription(subscription) => Ok(subscription), + _ => Err(invalid_actor_response()), + } + } + + /// Removes a change consumer through the serialized actor boundary. + /// + /// # Errors + /// + /// Returns a safe actor error. + pub async fn unsubscribe_changes(&self, id: ChangeSubscriptionId) -> Result<bool, SafeError> { + match self + .dispatch(RuntimeCommand::UnsubscribeChanges(id), None) + .await? + { + RuntimeCommandValue::Unsubscribed(removed) => Ok(removed), + _ => Err(invalid_actor_response()), + } + } + + async fn dispatch( + &self, + command: RuntimeCommand, + expected_revision: Option<SnapshotRevision>, + ) -> Result<RuntimeCommandValue, SafeError> { + let raw_request = self.next_request.fetch_add(1, Ordering::Relaxed); + let request_id = RequestId::new(raw_request).ok_or_else(request_space_exhausted)?; + self.dispatch_with_deadline( + command, + expected_revision, + request_id, + Instant::now() + DEFAULT_COMMAND_TIMEOUT, + ) + .await + } + + async fn dispatch_durable( + &self, + command: RuntimeCommand, + expected_revision: SnapshotRevision, + timeout: Duration, + ) -> Result<RuntimeCommandValue, SafeError> { + let raw_request = self.next_request.fetch_add(1, Ordering::Relaxed); + let request_id = RequestId::new(raw_request).ok_or_else(request_space_exhausted)?; + self.dispatch_with_deadline( + command, + Some(expected_revision), + request_id, + Instant::now() + timeout, + ) + .await + } + + async fn dispatch_with_deadline( + &self, + command: RuntimeCommand, + expected_revision: Option<SnapshotRevision>, + request_id: RequestId, + deadline: Instant, + ) -> Result<RuntimeCommandValue, SafeError> { + let context = CommandContext::new(request_id, expected_revision, deadline); + let receipt = match self.mailbox.submit(context, command) { + CommandSubmission::Accepted(ticket) => { + let remaining = deadline.saturating_duration_since(Instant::now()); + match self.timeout(remaining, ticket.receipt()).await { + Ok(receipt) => receipt, + Err(_) => CommandReceipt::new(request_id, CommandResult::TimedOut), + } + } + CommandSubmission::Rejected(receipt) => receipt, + }; + match receipt.into_result() { + CommandResult::Completed(value) => Ok(value), + CommandResult::Conflicted { .. } => Err(command_conflicted()), + CommandResult::Rejected(_) => Err(command_rejected()), + CommandResult::TimedOut => Err(command_timed_out()), + CommandResult::Closed => Err(runtime_closed()), + CommandResult::Failed(error) => Err(error), + } + } + + fn timeout<F>(&self, duration: Duration, future: F) -> tokio::time::Timeout<F> + where + F: Future, + { + let _guard = self.runtime.enter(); + tokio::time::timeout(duration, future) + } + + #[cfg(test)] + async fn import_secret_key_test( + &self, + input: SecretKeyInput, + ) -> Result<ImportAccountReceipt, SafeError> { + let request_number = self.next_request.fetch_add(1, Ordering::Relaxed); + self.import_secret_key( + DurableRequestId::parse(format!("test:import:{request_number}"))?, + self.snapshot().revision(), + input, + DEFAULT_COMMAND_TIMEOUT, + ) + .await + } + + #[cfg(test)] + async fn acknowledge_generated_key_stage_test( + &self, + id: RecoveryStageId, + ) -> Result<AppSnapshot, SafeError> { + let request_number = self.next_request.fetch_add(1, Ordering::Relaxed); + self.acknowledge_generated_key_stage( + id, + DurableRequestId::parse(format!("test:generate:{request_number}"))?, + self.snapshot().revision(), + DEFAULT_COMMAND_TIMEOUT, + ) + .await + } + + #[cfg(test)] + async fn confirm_account_removal_test( + &self, + token: RemovalConfirmationToken, + ) -> Result<AppSnapshot, SafeError> { + let request_number = self.next_request.fetch_add(1, Ordering::Relaxed); + self.confirm_account_removal( + token, + DurableRequestId::parse(format!("test:remove:{request_number}"))?, + self.snapshot().revision(), + DEFAULT_COMMAND_TIMEOUT, + ) + .await + } + + #[cfg(test)] + async fn import_secret_key_with_timeout( + &self, + input: SecretKeyInput, + timeout: Duration, + ) -> Result<ImportAccountReceipt, SafeError> { + let raw_request = self.next_request.fetch_add(1, Ordering::Relaxed); + let request_id = RequestId::new(raw_request).ok_or_else(request_space_exhausted)?; + let expected_revision = self.adapter.core().snapshot().revision(); + let durable_request = DurableRequestId::parse(format!("test:timeout:{raw_request}"))?; + match self + .dispatch_with_deadline( + RuntimeCommand::ImportSecretKey { + input, + durable_request, + expected_revision: expected_revision.value(), + }, + Some(expected_revision), + request_id, + Instant::now() + timeout, + ) + .await? + { + RuntimeCommandValue::Imported(receipt) => Ok(receipt), + _ => Err(invalid_actor_response()), + } + } + + fn expect_snapshot(value: RuntimeCommandValue) -> Result<AppSnapshot, SafeError> { + match value { + RuntimeCommandValue::Snapshot(snapshot) => Ok(*snapshot), + _ => Err(invalid_actor_response()), + } + } +} + +impl RuntimeActor { + async fn run( + mut self, + mut receiver: mpsc::Receiver<CommandEnvelope<RuntimeCommand, RuntimeCommandValue>>, + ) { + let (completion_sender, mut completions) = mpsc::channel(DEFAULT_TASK_CAPACITY); + loop { + tokio::select! { + envelope = receiver.recv() => { + let Some(envelope) = envelope else { + break; + }; + if !self.handle_command(envelope, &completion_sender).await { + break; + } + } + completion = completions.recv(), if !self.profile_tasks.is_empty() => { + if let Some(completion) = completion { + self.complete_profile_task(completion).await; + } + } + } + } + self.cancel_profile_tasks(None).await; + } + + async fn handle_command( + &mut self, + envelope: CommandEnvelope<RuntimeCommand, RuntimeCommandValue>, + completion_sender: &mpsc::Sender<ProfileCompletion>, + ) -> bool { + let (context, command, reply) = envelope.into_parts(); + if let Some(result) = self.preflight(context, &command) { + let _ = reply.send(CommandReceipt::new(context.request_id(), result)); + return true; + } + if matches!(command, RuntimeCommand::RefreshActiveProfile) { + self.start_profile_task(context, reply, completion_sender.clone()) + .await; + return true; + } + if matches!(command, RuntimeCommand::Close) { + let result = self.close_actor().await; + let closed = matches!(result, CommandResult::Completed(_)); + let _ = reply.send(CommandReceipt::new(context.request_id(), result)); + return !closed; + } + let changes_session = matches!( + command, + RuntimeCommand::ActivateAccount(_) + | RuntimeCommand::SignOut + | RuntimeCommand::ConfirmAccountRemoval { .. } + ); + let begins_generated_recovery = matches!(&command, RuntimeCommand::BeginGeneratedKeyStage); + let result = self.execute_command(context, command).await; + if begins_generated_recovery && matches!(&result, CommandResult::Completed(_)) { + let snapshot = self.adapter.core().snapshot(); + self.cancel_profile_tasks(Some(&snapshot)).await; + } + if changes_session && matches!(result, CommandResult::Completed(_)) { + self.advance_session_generation().await; + self.synchronize_foreground_session(); + } + if matches!(result, CommandResult::Completed(_)) { + self.changes.publish(self.adapter.core().snapshot()); + } + let _ = reply.send(CommandReceipt::new(context.request_id(), result)); + true + } + + fn preflight( + &self, + context: CommandContext, + command: &RuntimeCommand, + ) -> Option<CommandResult<RuntimeCommandValue>> { + if context.is_expired(Instant::now()) { + return Some(CommandResult::TimedOut); + } + let lifecycle = self + .lifecycle + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .to_owned(); + if matches!(lifecycle.lifecycle(), RuntimeLifecycle::Closed) { + return Some(CommandResult::Closed); + } + if !lifecycle.allows(command.class()) { + return Some(CommandResult::Failed(command_unavailable())); + } + if self.generated_key_stage.pending().is_some() + && !matches!( + command, + RuntimeCommand::Snapshot + | RuntimeCommand::AcknowledgeGeneratedKeyStage { .. } + | RuntimeCommand::CancelGeneratedKeyStage + | RuntimeCommand::SubscribeChanges(_) + | RuntimeCommand::UnsubscribeChanges(_) + | RuntimeCommand::Close + ) + { + return Some(CommandResult::Failed(generated_recovery_route_active())); + } + let current_revision = self.adapter.core().snapshot().revision(); + if !command.resolves_revision_through_durable_replay() + && context + .expected_revision() + .is_some_and(|expected| expected != current_revision) + { + return Some(CommandResult::Conflicted { current_revision }); + } + None + } + + async fn execute_command( + &mut self, + context: CommandContext, + command: RuntimeCommand, + ) -> CommandResult<RuntimeCommandValue> { + let result = match command { + RuntimeCommand::Snapshot => Ok(RuntimeCommandValue::Snapshot(Box::new( + self.adapter.core().snapshot(), + ))), + RuntimeCommand::GenerateAccount { + durable_request, + expected_revision, + } => { + self.run_blocking(context.deadline(), move |adapter, secrets, clock| { + adapter + .generate_account_durable( + &durable_request, + expected_revision, + secrets.as_ref(), + clock.as_ref(), + ) + .map(RuntimeCommandValue::Generated) + }) + .await + } + RuntimeCommand::BeginGeneratedKeyStage => { + match NonZeroU64::new(context.request_id().get()).map(RecoveryStageId::new) { + Some(stage_id) => self + .generated_key_stage + .begin( + self.adapter.key_material(), + stage_id, + self.adapter.core().snapshot().revision().value(), + self.clock.now(), + ) + .map(RuntimeCommandValue::GeneratedKeyStage), + None => Err(request_space_exhausted()), + } + } + RuntimeCommand::AcknowledgeGeneratedKeyStage { + id, + durable_request, + } => match self.generated_key_stage.take(id, self.clock.now()) { + Ok(staged) => { + self.run_blocking(context.deadline(), move |adapter, secrets, clock| { + commit_generated_key_stage( + adapter.as_ref(), + secrets.as_ref(), + clock.as_ref(), + &durable_request, + staged, + ) + }) + .await + } + Err(error) => Err(error), + }, + RuntimeCommand::CancelGeneratedKeyStage => Ok( + RuntimeCommandValue::GeneratedKeyStageCancelled(self.generated_key_stage.cancel()), + ), + RuntimeCommand::ImportSecretKey { + input, + durable_request, + expected_revision, + } => { + self.run_blocking(context.deadline(), move |adapter, secrets, clock| { + adapter + .import_secret_key_durable( + &durable_request, + expected_revision, + input, + secrets.as_ref(), + clock.as_ref(), + ) + .map(RuntimeCommandValue::Imported) + }) + .await + } + RuntimeCommand::SelectAccount(public_key) => { + self.run_blocking(context.deadline(), move |adapter, _, _| { + adapter + .select_account(public_key) + .map(Box::new) + .map(RuntimeCommandValue::Snapshot) + }) + .await + } + RuntimeCommand::ActivateAccount(public_key) => { + self.run_blocking(context.deadline(), move |adapter, secrets, clock| { + adapter + .activate_account(public_key, secrets.as_ref(), clock.as_ref()) + .map(Box::new) + .map(RuntimeCommandValue::Snapshot) + }) + .await + } + RuntimeCommand::SignOut => self + .adapter + .sign_out() + .map(Box::new) + .map(RuntimeCommandValue::Snapshot), + RuntimeCommand::RequestAccountRemoval(public_key) => self + .adapter + .request_account_removal(public_key, self.clock.as_ref()) + .map(RuntimeCommandValue::RemovalRequest), + RuntimeCommand::ConfirmAccountRemoval { + token, + durable_request, + } => { + self.run_blocking(context.deadline(), move |adapter, secrets, clock| { + adapter + .confirm_account_removal_durable( + &durable_request, + token, + secrets.as_ref(), + clock.as_ref(), + ) + .map(Box::new) + .map(RuntimeCommandValue::Snapshot) + }) + .await + } + RuntimeCommand::SubscribeChanges(capacity) => self + .changes + .subscribe(capacity) + .map(|(id, receiver)| { + RuntimeCommandValue::Subscription(RuntimeChangeSubscription { id, receiver }) + }) + .ok_or_else(observer_registration_failed), + RuntimeCommand::UnsubscribeChanges(id) => Ok(RuntimeCommandValue::Unsubscribed( + self.changes.unsubscribe(id), + )), + RuntimeCommand::Close | RuntimeCommand::RefreshActiveProfile => { + Err(invalid_actor_response()) + } + }; + result.map_or_else(CommandResult::Failed, CommandResult::Completed) + } + + async fn run_blocking<F>( + &self, + deadline: Instant, + operation: F, + ) -> Result<RuntimeCommandValue, SafeError> + where + F: FnOnce( + Arc<PersistentAppCore>, + Arc<dyn SecretStore>, + Arc<dyn Clock>, + ) -> Result<RuntimeCommandValue, SafeError> + + Send + + 'static, + { + let adapter = Arc::clone(&self.adapter); + let secrets = Arc::clone(&self.secrets); + let clock = Arc::clone(&self.clock); + self.blocking + .execute(deadline, move || operation(adapter, secrets, clock)) + .await + .map_err(blocking_execution_failed)? + } + + async fn start_profile_task( + &mut self, + context: CommandContext, + reply: oneshot::Sender<CommandReceipt<RuntimeCommandValue>>, + completion_sender: mpsc::Sender<ProfileCompletion>, + ) { + let foreground = self + .published_foreground_session + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .clone(); + let plan = match self.adapter.core().begin_profile_refresh() { + Ok(Some(plan)) => plan, + Ok(None) => { + let _ = reply.send(CommandReceipt::new( + context.request_id(), + CommandResult::Completed(RuntimeCommandValue::Snapshot(Box::new( + self.adapter.core().snapshot(), + ))), + )); + return; + } + Err(error) => { + let _ = reply.send(CommandReceipt::new( + context.request_id(), + CommandResult::Failed(error), + )); + return; + } + }; + let Some(foreground) = foreground.filter(|binding| { + binding.identity().public_key() == plan.public_key() + && binding.generation() == self.session_generation + }) else { + let _ = reply.send(CommandReceipt::new( + context.request_id(), + CommandResult::Failed(stale_profile_binding()), + )); + return; + }; + let correlation = TaskCorrelation::new( + context.request_id(), + plan.public_key(), + foreground.signer(), + plan.expected_revision(), + self.session_generation, + ); + let client = Arc::clone(&self.nostr); + let relays = plan.relays().to_vec(); + let request_id = context.request_id(); + let handle = self.runtime.spawn(async move { + let result = client + .fetch_profile(correlation.account(), &relays, context.deadline()) + .await; + let _ = completion_sender + .send(ProfileCompletion { request_id, result }) + .await; + }); + let previous = self.profile_tasks.insert( + request_id, + PendingProfileTask { + correlation, + plan, + deadline: context.deadline(), + reply, + handle, + }, + ); + if let Some(previous) = previous { + self.lifecycle + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .fail(request_space_exhausted()); + previous.handle.abort(); + let _ = previous.handle.await; + let _ = previous.reply.send(CommandReceipt::new( + previous.correlation.request_id(), + CommandResult::Failed(request_space_exhausted()), + )); + self.cancel_profile_tasks(None).await; + } + } + + async fn close_actor(&mut self) -> CommandResult<RuntimeCommandValue> { + let transition = (|| { + let mut lifecycle = self + .lifecycle + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + lifecycle.begin_shutdown()?; + lifecycle.finish_shutdown() + })(); + match transition { + Ok(()) => { + self.generated_key_stage.cancel(); + self.cancel_profile_tasks(None).await; + self.changes.close(); + *self + .published_foreground_session + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) = None; + CommandResult::Completed(RuntimeCommandValue::Closed) + } + Err(error) => CommandResult::Failed(error), + } + } + + async fn complete_profile_task(&mut self, completion: ProfileCompletion) { + let Some(task) = self.profile_tasks.remove(&completion.request_id) else { + return; + }; + let _ = task.handle.await; + let current = self.adapter.core().snapshot(); + let foreground = self + .published_foreground_session + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .clone(); + let correlated = task.correlation.session_generation() == self.session_generation + && foreground.is_some_and(|binding| { + binding.generation() == task.correlation.session_generation() + && binding.identity().public_key() == task.correlation.account() + && binding.signer() == task.correlation.binding() + }) + && current + .active_account() + .is_some_and(|active| active.account().public_key() == task.correlation.account()); + let result = if correlated { + let plan = task.plan.clone(); + let completed = self + .run_blocking(task.deadline, move |adapter, _, clock| { + adapter + .core() + .complete_profile_refresh( + &plan, + completion.result, + adapter.database(), + clock.as_ref(), + ) + .map(Box::new) + .map(RuntimeCommandValue::Snapshot) + }) + .await; + completed.map_or_else(CommandResult::Failed, CommandResult::Completed) + } else { + CommandResult::Completed(RuntimeCommandValue::Snapshot(Box::new(current))) + }; + if matches!(result, CommandResult::Completed(_)) { + self.changes.publish(self.adapter.core().snapshot()); + } + let _ = task + .reply + .send(CommandReceipt::new(task.correlation.request_id(), result)); + } + + async fn advance_session_generation(&mut self) { + let Some(next) = self.session_generation.next() else { + self.lifecycle + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .fail(request_space_exhausted()); + self.cancel_profile_tasks(None).await; + return; + }; + self.session_generation = next; + self.published_session_generation + .store(next.value(), Ordering::Release); + let snapshot = self.adapter.core().snapshot(); + self.cancel_profile_tasks(Some(&snapshot)).await; + } + + fn synchronize_foreground_session(&mut self) { + let session = self + .adapter + .core() + .snapshot() + .active_account() + .map(|active| { + let public_key = active.account().public_key(); + ForegroundSessionBinding::new( + AccountIdentity::derive(public_key)?, + LocalSignerBinding::new(public_key, BindingAvailability::Available), + self.session_generation, + ) + }); + let session = match session.transpose() { + Ok(session) => session, + Err(error) => { + self.lifecycle + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .fail(error); + None + } + }; + *self + .published_foreground_session + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) = session; + } + + async fn cancel_profile_tasks(&mut self, snapshot: Option<&AppSnapshot>) { + let tasks = std::mem::take(&mut self.profile_tasks); + for (_, task) in tasks { + task.handle.abort(); + let _ = task.handle.await; + let receipt_result = snapshot.map_or(CommandResult::Closed, |snapshot| { + CommandResult::Completed(RuntimeCommandValue::Snapshot(Box::new(snapshot.clone()))) + }); + let _ = task.reply.send(CommandReceipt::new( + task.correlation.request_id(), + receipt_result, + )); + } + } +} + +fn commit_generated_key_stage( + adapter: &PersistentAppCore, + secrets: &dyn SecretStore, + clock: &dyn Clock, + request: &DurableRequestId, + staged: StagedGeneratedKey, +) -> Result<RuntimeCommandValue, SafeError> { + adapter.commit_staged_generated_key(request, staged, secrets, clock)?; + Ok(RuntimeCommandValue::Snapshot(Box::new( + adapter.core().snapshot(), + ))) +} + +const fn blocking_execution_failed(error: BlockingExecutionError) -> SafeError { + match error { + BlockingExecutionError::DeadlineElapsed => command_timed_out(), + BlockingExecutionError::Saturated => command_rejected(), + BlockingExecutionError::TaskFailed => SafeError::new( + SafeErrorCode::InvalidApplicationState, + SafeMessage::new("The runtime blocking worker failed."), + ), + } +} + +const fn request_space_exhausted() -> SafeError { + SafeError::new( + SafeErrorCode::InvalidApplicationState, + SafeMessage::new("The runtime request identifier space is exhausted."), + ) +} + +const fn stale_profile_binding() -> SafeError { + SafeError::new( + SafeErrorCode::InvalidApplicationState, + SafeMessage::new("The active account binding changed before profile refresh."), + ) +} + +const fn command_conflicted() -> SafeError { + SafeError::new( + SafeErrorCode::InvalidApplicationState, + SafeMessage::new("The command conflicts with newer application state."), + ) +} + +const fn command_rejected() -> SafeError { + SafeError::new( + SafeErrorCode::InvalidApplicationState, + SafeMessage::new("The runtime is busy. Try again."), + ) +} + +const fn command_timed_out() -> SafeError { + SafeError::new( + SafeErrorCode::InvalidApplicationState, + SafeMessage::new("The runtime command timed out."), + ) +} + +const fn runtime_closed() -> SafeError { + SafeError::new( + SafeErrorCode::InvalidApplicationState, + SafeMessage::new("The application runtime is closed."), + ) +} + +const fn command_unavailable() -> SafeError { + SafeError::new( + SafeErrorCode::InvalidApplicationState, + SafeMessage::new("The command is unavailable in the current runtime state."), + ) +} + +const fn generated_recovery_route_active() -> SafeError { + SafeError::new( + SafeErrorCode::InvalidApplicationState, + SafeMessage::new("Complete or cancel generated-key recovery before another action."), + ) +} + +const fn invalid_actor_response() -> SafeError { + SafeError::new( + SafeErrorCode::InvalidApplicationState, + SafeMessage::new("The runtime returned an invalid command response."), + ) +} + +const fn observer_registration_failed() -> SafeError { + SafeError::new( + SafeErrorCode::ObserverRegistrationFailed, + SafeMessage::new("The application change subscription could not be registered."), + ) +} + +#[cfg(test)] +mod tests { + use std::future::Future; + use std::num::NonZeroUsize; + use std::sync::atomic::{AtomicBool, Ordering}; + use std::sync::{Arc, Condvar, Mutex}; + use std::task::{Context, Poll, Wake, Waker}; + use std::thread::{self, Thread}; + use std::time::{Duration, Instant}; + + use radroots_studio_application::{ + BoxFuture, Clock, DurableRequestId, FailureSecretStore, ForegroundSessionBinding, + InMemorySecretStore, NostrClient, ProfileFetchResult, RelayConfiguration, RuntimeLifecycle, + SecretStore, SecretStoreOperation, SessionGeneration, SessionState, SnapshotRevision, + }; + use radroots_studio_domain::{ + AccountIdentity, BindingAvailability, LocalSignerBinding, PublicKey, + RelayDestinationPolicy, RelayUrl, SafeError, SafeErrorCode, SecretKeyInput, UnixTimestamp, + }; + + use super::{ + DEFAULT_COMMAND_TIMEOUT, RuntimeActorHandle, RuntimeDependencies, command_unavailable, + }; + use crate::{InstallationIdentity, InstallationIdentitySource, UuidInstallationIdentitySource}; + + struct FixedInstallationIdentity(&'static str); + + impl InstallationIdentitySource for FixedInstallationIdentity { + fn generate(&self) -> Result<InstallationIdentity, SafeError> { + InstallationIdentity::parse(self.0) + } + } + + fn dependencies( + secrets: Arc<dyn SecretStore>, + nostr: Arc<dyn NostrClient>, + ) -> RuntimeDependencies { + RuntimeDependencies::new( + secrets, + Arc::new(FixedClock), + nostr, + Arc::new(UuidInstallationIdentitySource), + ) + } + + struct FixedClock; + + impl Clock for FixedClock { + fn now(&self) -> UnixTimestamp { + UnixTimestamp::from_seconds(50).expect("time") + } + } + + struct OfflineNostr; + + impl NostrClient for OfflineNostr { + fn fetch_profile<'a>( + &'a self, + _public_key: PublicKey, + _relays: &'a [RelayUrl], + _deadline: Instant, + ) -> BoxFuture<'a, Result<ProfileFetchResult, SafeError>> { + Box::pin(async { Ok(ProfileFetchResult::complete(None)) }) + } + } + + struct BlockingNostr { + started: tokio::sync::Semaphore, + release: tokio::sync::Semaphore, + } + + impl BlockingNostr { + fn new() -> Self { + Self { + started: tokio::sync::Semaphore::new(0), + release: tokio::sync::Semaphore::new(0), + } + } + } + + impl NostrClient for BlockingNostr { + fn fetch_profile<'a>( + &'a self, + _public_key: PublicKey, + _relays: &'a [RelayUrl], + _deadline: Instant, + ) -> BoxFuture<'a, Result<ProfileFetchResult, SafeError>> { + Box::pin(async move { + self.started.add_permits(1); + let permit = self.release.acquire().await.expect("release"); + permit.forget(); + Ok(ProfileFetchResult::complete(None)) + }) + } + } + + struct BlockingSecretStore { + inner: InMemorySecretStore, + block_next_put: AtomicBool, + put_started: AtomicBool, + released: Mutex<bool>, + release_signal: Condvar, + } + + impl BlockingSecretStore { + fn new() -> Self { + Self { + inner: InMemorySecretStore::default(), + block_next_put: AtomicBool::new(true), + put_started: AtomicBool::new(false), + released: Mutex::new(false), + release_signal: Condvar::new(), + } + } + + async fn wait_until_put_started(&self) { + while !self.put_started.load(Ordering::Acquire) { + tokio::task::yield_now().await; + } + } + + fn release(&self) { + *self + .released + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) = true; + self.release_signal.notify_all(); + } + } + + impl SecretStore for BlockingSecretStore { + fn put(&self, public_key: PublicKey, secret: SecretKeyInput) -> Result<(), SafeError> { + if self.block_next_put.swap(false, Ordering::AcqRel) { + self.put_started.store(true, Ordering::Release); + let released = self + .released + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + drop( + self.release_signal + .wait_while(released, |released| !*released) + .unwrap_or_else(std::sync::PoisonError::into_inner), + ); + } + self.inner.put(public_key, secret) + } + + fn load(&self, public_key: PublicKey) -> Result<SecretKeyInput, SafeError> { + self.inner.load(public_key) + } + + fn contains(&self, public_key: PublicKey) -> Result<bool, SafeError> { + self.inner.contains(public_key) + } + + fn delete(&self, public_key: PublicKey) -> Result<(), SafeError> { + self.inner.delete(public_key) + } + } + + async fn actor() -> (RuntimeActorHandle, Arc<InMemorySecretStore>) { + let secrets = Arc::new(InMemorySecretStore::default()); + let secret_port: Arc<dyn SecretStore> = secrets.clone(); + let actor = RuntimeActorHandle::in_memory( + RelayConfiguration::default(), + dependencies(secret_port, Arc::new(OfflineNostr)), + NonZeroUsize::new(8).expect("capacity"), + &tokio::runtime::Handle::current(), + ) + .await + .expect("actor"); + (actor, secrets) + } + + struct ThreadWake(Thread); + + impl Wake for ThreadWake { + fn wake(self: Arc<Self>) { + self.0.unpark(); + } + + fn wake_by_ref(self: &Arc<Self>) { + self.0.unpark(); + } + } + + fn block_on_without_runtime<F: Future>(future: F) -> F::Output { + let waker = Waker::from(Arc::new(ThreadWake(thread::current()))); + let mut context = Context::from_waker(&waker); + let mut future = std::pin::pin!(future); + loop { + match future.as_mut().poll(&mut context) { + Poll::Ready(output) => return output, + Poll::Pending => thread::park(), + } + } + } + + #[test] + fn actor_operations_support_foreign_executor_polling() { + let runtime = tokio::runtime::Runtime::new().expect("runtime"); + let (actor, _) = runtime.block_on(actor()); + + let snapshot = block_on_without_runtime(actor.bootstrap()).expect("bootstrap"); + assert_eq!(snapshot, actor.snapshot()); + block_on_without_runtime(actor.close()).expect("close"); + } + + #[tokio::test(flavor = "multi_thread")] + async fn installation_identity_survives_file_backed_runtime_restart() { + let directory = tempfile::tempdir().expect("temporary directory"); + let path = directory.path().join("studio.sqlite3"); + let first = RuntimeActorHandle::open( + &path, + RelayConfiguration::default(), + RuntimeDependencies::new( + Arc::new(InMemorySecretStore::default()), + Arc::new(FixedClock), + Arc::new(OfflineNostr), + Arc::new(FixedInstallationIdentity( + "11aabbccddeeff001122334455667788", + )), + ), + NonZeroUsize::new(8).expect("capacity"), + &tokio::runtime::Handle::current(), + ) + .await + .expect("first runtime"); + assert_eq!( + first.installation_identity().as_str(), + "11aabbccddeeff001122334455667788" + ); + first.close().await.expect("first close"); + drop(first); + + let second = RuntimeActorHandle::open( + &path, + RelayConfiguration::default(), + RuntimeDependencies::new( + Arc::new(InMemorySecretStore::default()), + Arc::new(FixedClock), + Arc::new(OfflineNostr), + Arc::new(FixedInstallationIdentity( + "22aabbccddeeff001122334455667788", + )), + ), + NonZeroUsize::new(8).expect("capacity"), + &tokio::runtime::Handle::current(), + ) + .await + .expect("second runtime"); + assert_eq!( + second.installation_identity().as_str(), + "11aabbccddeeff001122334455667788" + ); + second.close().await.expect("second close"); + } + + #[tokio::test(flavor = "multi_thread")] + async fn account_mutations_run_serially_through_one_ready_actor() { + let (actor, secrets) = actor().await; + assert_eq!(actor.lifecycle(), RuntimeLifecycle::Ready); + + let imported = actor + .import_secret_key_test( + SecretKeyInput::parse( + "7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7".to_owned(), + ) + .expect("input"), + ) + .await + .expect("import"); + let public_key = imported.account().public_key(); + let activated = actor.activate_account(public_key).await.expect("activate"); + assert_eq!(activated.session(), SessionState::Active); + let foreground = actor.foreground_session().expect("foreground session"); + assert_eq!(foreground.identity().public_key(), public_key); + assert_eq!(foreground.signer().account(), public_key); + assert_eq!(foreground.generation(), actor.session_generation()); + assert!(secrets.contains(public_key).expect("credential")); + + let signed_out = actor.sign_out().await.expect("sign out"); + assert_eq!(signed_out.session(), SessionState::SignedOut); + assert!(actor.foreground_session().is_none()); + let removal = actor + .request_account_removal(public_key) + .await + .expect("removal request"); + let removed = actor + .confirm_account_removal_test(removal) + .await + .expect("remove"); + assert!(removed.accounts().is_empty()); + assert!(!secrets.contains(public_key).expect("credential removed")); + } + + #[tokio::test(flavor = "multi_thread")] + async fn public_actor_commands_cover_generation_selection_and_empty_profile_refresh() { + let (actor, _) = actor().await; + let unchanged = actor + .refresh_active_profile() + .await + .expect("refresh without an active account"); + assert!(unchanged.active_account().is_none()); + + let generated = actor + .generate_account( + DurableRequestId::parse("test:generate:public-surface").expect("request"), + actor.snapshot().revision(), + DEFAULT_COMMAND_TIMEOUT, + ) + .await + .expect("generate account"); + let selected = actor + .select_account(generated.account().public_key()) + .await + .expect("select generated account"); + assert_eq!( + selected.selected_account(), + Some(generated.account().public_key()) + ); + + let missing = + PublicKey::from_hex("79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798") + .expect("public key"); + let error = match actor.request_account_removal(missing).await { + Ok(_) => panic!("unknown account removal must fail"), + Err(error) => error, + }; + assert_eq!(error.code(), SafeErrorCode::AccountNotFound); + } + + #[tokio::test(flavor = "multi_thread")] + async fn staged_recovery_rejects_a_stale_expected_revision_before_commit() { + let (actor, _) = actor().await; + let handle = actor + .begin_generated_key_stage() + .await + .expect("generated key stage"); + let stale = SnapshotRevision::from_value(actor.snapshot().revision().value() + 1); + let error = actor + .acknowledge_generated_key_stage( + handle.id(), + DurableRequestId::parse("test:generate:stale-revision").expect("request"), + stale, + DEFAULT_COMMAND_TIMEOUT, + ) + .await + .expect_err("stale revision must conflict"); + assert_eq!( + error.message().as_str(), + "The command conflicts with newer application state." + ); + assert!(actor.cancel_generated_key_stage().await.expect("cancel")); + } + + #[tokio::test(flavor = "multi_thread")] + async fn fatal_lifecycle_rejects_commands_before_execution() { + let (actor, _) = actor().await; + actor + .lifecycle + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .fail(command_unavailable()); + + let error = actor + .bootstrap() + .await + .expect_err("fatal lifecycle must reject command admission"); + assert_eq!( + error.message().as_str(), + "The command is unavailable in the current runtime state." + ); + assert!(matches!(actor.lifecycle(), RuntimeLifecycle::Fatal(_))); + } + + #[tokio::test(flavor = "multi_thread")] + async fn generated_key_stage_is_exclusive_cancelable_and_snapshot_free() { + let (actor, secrets) = actor().await; + let initial = actor.snapshot(); + let stage = actor + .begin_generated_key_stage() + .await + .expect("generated key stage"); + + assert!(actor.begin_generated_key_stage().await.is_err()); + assert_eq!(actor.snapshot(), initial); + assert!( + !secrets + .contains(stage.view().account().public_key()) + .expect("keyring") + ); + assert!(actor.sign_out().await.is_err()); + assert_eq!(actor.snapshot(), initial); + assert!(actor.cancel_generated_key_stage().await.expect("cancel")); + assert!( + !actor + .cancel_generated_key_stage() + .await + .expect("cancel empty") + ); + assert_eq!(actor.snapshot(), initial); + + actor + .begin_generated_key_stage() + .await + .expect("replacement stage"); + actor.close().await.expect("close clears stage"); + assert_eq!(actor.lifecycle(), RuntimeLifecycle::Closed); + } + + #[tokio::test(flavor = "multi_thread")] + async fn recovery_handle_is_one_use_and_acknowledgement_commits_once() { + let (actor, secrets) = actor().await; + let initial = actor.snapshot(); + let handle = actor + .begin_generated_key_stage() + .await + .expect("generated key stage"); + let public_key = handle.view().account().public_key(); + let recovery = handle.take_recovery_nsec().expect("recovery material"); + assert_eq!(recovery.with_exposed_secret(str::len), 63); + assert!(handle.take_recovery_nsec().is_err()); + assert_eq!(actor.snapshot(), initial); + assert!(!secrets.contains(public_key).expect("not committed")); + + let committed = actor + .acknowledge_generated_key_stage_test(handle.id()) + .await + .expect("acknowledge"); + assert_eq!(committed.accounts().len(), 1); + assert_eq!(committed.selected_account(), Some(public_key)); + assert!(secrets.contains(public_key).expect("credential committed")); + assert!( + actor + .acknowledge_generated_key_stage_test(handle.id()) + .await + .is_err() + ); + } + + #[tokio::test(flavor = "multi_thread")] + async fn failed_generated_commit_consumes_the_stage_without_poisoning_the_actor() { + let secrets = Arc::new(FailureSecretStore::default()); + secrets.fail_next(SecretStoreOperation::Put); + let secret_port: Arc<dyn SecretStore> = secrets.clone(); + let actor = RuntimeActorHandle::in_memory( + RelayConfiguration::default(), + dependencies(secret_port, Arc::new(OfflineNostr)), + NonZeroUsize::new(8).expect("capacity"), + &tokio::runtime::Handle::current(), + ) + .await + .expect("actor"); + let handle = actor + .begin_generated_key_stage() + .await + .expect("generated key stage"); + + let error = actor + .acknowledge_generated_key_stage_test(handle.id()) + .await + .expect_err("injected keyring failure"); + + assert_eq!(error.code(), SafeErrorCode::KeyringUnavailable); + assert!(actor.snapshot().accounts().is_empty()); + actor + .begin_generated_key_stage() + .await + .expect("fresh recovery after terminal failure"); + assert!(actor.cancel_generated_key_stage().await.expect("cancel")); + } + + #[tokio::test(flavor = "multi_thread")] + async fn session_generation_cancels_correlated_profile_work_on_sign_out() { + let client = Arc::new(BlockingNostr::new()); + let actor = RuntimeActorHandle::in_memory( + RelayConfiguration::new(vec![ + RelayUrl::parse("ws://localhost:8080", RelayDestinationPolicy::Local) + .expect("relay"), + ]) + .expect("relay configuration"), + dependencies(Arc::new(InMemorySecretStore::default()), client.clone()), + NonZeroUsize::new(8).expect("capacity"), + &tokio::runtime::Handle::current(), + ) + .await + .expect("actor"); + let imported = actor + .import_secret_key_test( + SecretKeyInput::parse( + "7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7".to_owned(), + ) + .expect("input"), + ) + .await + .expect("import"); + actor + .activate_account(imported.account().public_key()) + .await + .expect("activate"); + assert_eq!(actor.session_generation().value(), 1); + + let refresh_actor = actor.clone(); + let refresh = tokio::spawn(async move { refresh_actor.refresh_active_profile().await }); + let started = client.started.acquire().await.expect("refresh started"); + started.forget(); + let signed_out = actor.sign_out().await.expect("sign out"); + let cancelled = refresh + .await + .expect("refresh task") + .expect("safe cancellation"); + + assert_eq!(actor.session_generation().value(), 2); + assert_eq!(signed_out.session(), SessionState::SignedOut); + assert_eq!(cancelled.session(), SessionState::SignedOut); + assert!(cancelled.active_account().is_none()); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 4)] + async fn profile_refresh_rejects_stale_bindings_and_discards_stale_completions() { + let client = Arc::new(BlockingNostr::new()); + let actor = RuntimeActorHandle::in_memory( + RelayConfiguration::new(vec![ + RelayUrl::parse("ws://localhost:8080", RelayDestinationPolicy::Local) + .expect("relay"), + ]) + .expect("relay configuration"), + dependencies(Arc::new(InMemorySecretStore::default()), client.clone()), + NonZeroUsize::new(8).expect("capacity"), + &tokio::runtime::Handle::current(), + ) + .await + .expect("actor"); + let imported = actor + .import_secret_key_test(secret( + "7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7", + )) + .await + .expect("import"); + let public_key = imported.account().public_key(); + actor.activate_account(public_key).await.expect("activate"); + let binding = actor.foreground_session().expect("foreground binding"); + let stale_binding = ForegroundSessionBinding::new( + AccountIdentity::derive(public_key).expect("identity"), + LocalSignerBinding::new(public_key, BindingAvailability::Available), + SessionGeneration::from_value(binding.generation().value() + 1), + ) + .expect("stale binding fixture"); + let other_public_key = + PublicKey::from_hex("c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee5") + .expect("other public key"); + let other_binding = ForegroundSessionBinding::new( + AccountIdentity::derive(other_public_key).expect("other identity"), + LocalSignerBinding::new(other_public_key, BindingAvailability::Available), + binding.generation(), + ) + .expect("other binding fixture"); + + *actor + .foreground_session + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(stale_binding.clone()); + let error = actor + .refresh_active_profile() + .await + .expect_err("stale generation must reject before relay work"); + assert_eq!( + error.message().as_str(), + "The active account binding changed before profile refresh." + ); + + *actor + .foreground_session + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(other_binding.clone()); + let error = actor + .refresh_active_profile() + .await + .expect_err("different account binding must reject before relay work"); + assert_eq!( + error.message().as_str(), + "The active account binding changed before profile refresh." + ); + + *actor + .foreground_session + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(binding.clone()); + let refresh_actor = actor.clone(); + let refresh = tokio::spawn(async move { refresh_actor.refresh_active_profile().await }); + let started = client.started.acquire().await.expect("refresh started"); + started.forget(); + *actor + .foreground_session + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(stale_binding); + client.release.add_permits(1); + let unchanged = refresh + .await + .expect("refresh task") + .expect("stale completion returns current snapshot"); + assert_eq!(unchanged.revision(), actor.snapshot().revision()); + + *actor + .foreground_session + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(binding.clone()); + let refresh_actor = actor.clone(); + let refresh = tokio::spawn(async move { refresh_actor.refresh_active_profile().await }); + let started = client + .started + .acquire() + .await + .expect("second refresh started"); + started.forget(); + *actor + .foreground_session + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) = None; + client.release.add_permits(1); + let unchanged = refresh + .await + .expect("refresh task") + .expect("missing binding returns current snapshot"); + assert_eq!(unchanged.revision(), actor.snapshot().revision()); + + *actor + .foreground_session + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(binding.clone()); + let refresh_actor = actor.clone(); + let refresh = tokio::spawn(async move { refresh_actor.refresh_active_profile().await }); + let started = client + .started + .acquire() + .await + .expect("third refresh started"); + started.forget(); + *actor + .foreground_session + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(other_binding); + client.release.add_permits(1); + let unchanged = refresh + .await + .expect("refresh task") + .expect("different account binding returns current snapshot"); + assert_eq!(unchanged.revision(), actor.snapshot().revision()); + + *actor + .foreground_session + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(binding); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 4)] + async fn bounded_runtime_rejects_saturation_without_dropping_accepted_commands() { + let secrets = Arc::new(BlockingSecretStore::new()); + let actor = RuntimeActorHandle::in_memory( + RelayConfiguration::default(), + dependencies(secrets.clone(), Arc::new(OfflineNostr)), + NonZeroUsize::new(1).expect("capacity"), + &tokio::runtime::Handle::current(), + ) + .await + .expect("actor"); + + let first_actor = actor.clone(); + let first = tokio::spawn(async move { + first_actor + .import_secret_key_test(secret( + "7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7", + )) + .await + }); + secrets.wait_until_put_started().await; + + let second_actor = actor.clone(); + let second = tokio::spawn(async move { + second_actor + .import_secret_key_test(secret( + "0000000000000000000000000000000000000000000000000000000000000001", + )) + .await + }); + while actor.mailbox.available_capacity() != 0 { + assert!( + !second.is_finished(), + "second command must enter the mailbox" + ); + tokio::task::yield_now().await; + } + let rejected = actor + .import_secret_key_test(secret( + "0000000000000000000000000000000000000000000000000000000000000002", + )) + .await + .expect_err("full mailbox must reject"); + assert_eq!( + rejected.message().as_str(), + "The runtime is busy. Try again." + ); + + secrets.release(); + first.await.expect("first task").expect("first command"); + let second = second + .await + .expect("second task") + .expect_err("accepted stale revision conflicts explicitly"); + assert_eq!( + second.message().as_str(), + "The account operation conflicts with the current application state." + ); + assert_eq!( + actor.bootstrap().await.expect("snapshot").accounts().len(), + 1 + ); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 4)] + async fn queued_command_expiry_returns_timeout_and_prevents_late_mutation() { + let secrets = Arc::new(BlockingSecretStore::new()); + let actor = RuntimeActorHandle::in_memory( + RelayConfiguration::default(), + dependencies(secrets.clone(), Arc::new(OfflineNostr)), + NonZeroUsize::new(1).expect("capacity"), + &tokio::runtime::Handle::current(), + ) + .await + .expect("actor"); + + let first_actor = actor.clone(); + let first = tokio::spawn(async move { + first_actor + .import_secret_key_test(secret( + "7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7", + )) + .await + }); + secrets.wait_until_put_started().await; + + let expired = actor + .import_secret_key_with_timeout( + secret("0000000000000000000000000000000000000000000000000000000000000001"), + Duration::from_millis(10), + ) + .await + .expect_err("queued command must time out"); + assert_eq!(expired.message().as_str(), "The runtime command timed out."); + + secrets.release(); + first.await.expect("first task").expect("first command"); + assert_eq!( + actor.bootstrap().await.expect("snapshot").accounts().len(), + 1 + ); + } + + #[tokio::test(flavor = "multi_thread")] + async fn close_is_terminal_and_every_later_command_is_rejected_as_closed() { + let (actor, _) = actor().await; + actor.close().await.expect("close"); + assert_eq!(actor.lifecycle(), RuntimeLifecycle::Closed); + + let error = actor.bootstrap().await.expect_err("bootstrap after close"); + assert_eq!( + error.message().as_str(), + "The application runtime is closed." + ); + actor.close().await.expect("repeated close is idempotent"); + } + + #[tokio::test(flavor = "multi_thread")] + async fn actor_subscription_atomically_delivers_initial_then_ordered_changes() { + let (actor, _) = actor().await; + let mut subscription = actor + .subscribe_changes(NonZeroUsize::new(4).expect("capacity")) + .await + .expect("subscribe"); + let initial = subscription.receive().await.expect("initial snapshot"); + assert_eq!(initial.revision(), actor.snapshot().revision()); + assert!(initial.previous_revision().is_none()); + + actor + .import_secret_key_test(secret( + "7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7", + )) + .await + .expect("import"); + let changed = subscription.receive().await.expect("change"); + assert!(changed.revision() > initial.revision()); + assert_eq!(changed.previous_revision(), Some(initial.revision())); + assert!( + actor + .unsubscribe_changes(subscription.id()) + .await + .expect("unsubscribe") + ); + assert!( + !actor + .unsubscribe_changes(subscription.id()) + .await + .expect("second unsubscribe") + ); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 4)] + async fn expired_queued_shutdown_does_not_close_runtime_later() { + let secrets = Arc::new(BlockingSecretStore::new()); + let actor = RuntimeActorHandle::in_memory( + RelayConfiguration::default(), + dependencies(secrets.clone(), Arc::new(OfflineNostr)), + NonZeroUsize::new(1).expect("capacity"), + &tokio::runtime::Handle::current(), + ) + .await + .expect("actor"); + let import_actor = actor.clone(); + let import = tokio::spawn(async move { + import_actor + .import_secret_key_test(secret( + "7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7", + )) + .await + }); + secrets.wait_until_put_started().await; + + let timeout = actor + .close_with_timeout(Duration::from_millis(10)) + .await + .expect_err("queued shutdown must expire"); + assert_eq!(timeout.message().as_str(), "The runtime command timed out."); + secrets.release(); + import.await.expect("import task").expect("import"); + assert_eq!(actor.lifecycle(), RuntimeLifecycle::Ready); + assert_eq!( + actor + .bootstrap() + .await + .expect("still open") + .accounts() + .len(), + 1 + ); + actor.close().await.expect("later close"); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 4)] + async fn shutdown_cancels_in_flight_work_and_terminates_publication() { + let client = Arc::new(BlockingNostr::new()); + let actor = RuntimeActorHandle::in_memory( + RelayConfiguration::new(vec![ + RelayUrl::parse("ws://localhost:8080", RelayDestinationPolicy::Local) + .expect("relay"), + ]) + .expect("relay configuration"), + dependencies(Arc::new(InMemorySecretStore::default()), client.clone()), + NonZeroUsize::new(8).expect("capacity"), + &tokio::runtime::Handle::current(), + ) + .await + .expect("actor"); + let imported = actor + .import_secret_key_test(secret( + "7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7", + )) + .await + .expect("import"); + actor + .activate_account(imported.account().public_key()) + .await + .expect("activate"); + let mut changes = actor + .subscribe_changes(NonZeroUsize::new(4).expect("capacity")) + .await + .expect("subscribe"); + changes.receive().await.expect("initial"); + + let refresh_actor = actor.clone(); + let refresh = tokio::spawn(async move { refresh_actor.refresh_active_profile().await }); + let started = client.started.acquire().await.expect("refresh started"); + started.forget(); + actor.close().await.expect("close"); + + let cancelled = refresh + .await + .expect("refresh task") + .expect_err("refresh closes"); + assert_eq!( + cancelled.message().as_str(), + "The application runtime is closed." + ); + assert!(changes.receive().await.is_none()); + assert_eq!(actor.lifecycle(), RuntimeLifecycle::Closed); + } + + fn secret(value: &str) -> SecretKeyInput { + SecretKeyInput::parse(value.to_owned()).expect("valid test secret") + } +} diff --git a/core/crates/studio_runtime/tests/local_relay_e2e.rs b/core/crates/studio_runtime/tests/local_relay_e2e.rs @@ -0,0 +1,100 @@ +use std::time::Duration; + +use nostr::{EventBuilder, Keys, Metadata}; +use nostr_relay_builder::MockRelay; +use nostr_sdk::Client; +use radroots_studio_application::{ + Clock, InMemorySecretStore, ProfileLoadState, ProfileRepository, RelayConfiguration, + RelayConnectionState, SecretStore, SessionState, +}; +use radroots_studio_domain::{RelayDestinationPolicy, RelayUrl, SecretKeyInput, UnixTimestamp}; +use radroots_studio_nostr::SdkNostrClient; +use radroots_studio_runtime::PersistentAppCore; + +const SECRET_HEX: &str = "7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7"; + +struct FixedClock; + +impl Clock for FixedClock { + fn now(&self) -> UnixTimestamp { + UnixTimestamp::from_seconds(100).expect("fixed timestamp") + } +} + +#[tokio::test] +async fn local_relay_e2e_imports_activates_refreshes_and_caches_profile() { + let local_relay = MockRelay::run().await.expect("local relay"); + let relay_url = local_relay.url().await; + let keys = Keys::parse(SECRET_HEX).expect("known secret key"); + let publisher = Client::new(keys); + publisher + .add_relay(relay_url.clone()) + .await + .expect("publisher relay"); + publisher.connect().await; + publisher.wait_for_connection(Duration::from_secs(2)).await; + publisher + .send_event_builder(EventBuilder::metadata( + &Metadata::new() + .name("farmer") + .display_name("Farm Account") + .about("Local food profile"), + )) + .await + .expect("publish profile"); + + let relay = + RelayUrl::parse(relay_url.as_str(), RelayDestinationPolicy::Local).expect("relay URL"); + let adapter = PersistentAppCore::in_memory( + RelayConfiguration::new(vec![relay]).expect("relay configuration"), + ) + .expect("persistent adapter"); + let secrets = InMemorySecretStore::default(); + adapter.bootstrap(&secrets, &FixedClock).expect("bootstrap"); + let imported = adapter + .import_secret_key( + SecretKeyInput::parse(SECRET_HEX.to_owned()).expect("secret input"), + &secrets, + &FixedClock, + ) + .expect("import account"); + let public_key = imported.account().public_key(); + assert!(secrets.contains(public_key).expect("credential exists")); + adapter + .activate_account(public_key, &secrets, &FixedClock) + .expect("activate account"); + + let refreshed = adapter + .core() + .refresh_active_profile( + adapter.database(), + &SdkNostrClient::new(Duration::from_secs(2)), + &FixedClock, + std::time::Instant::now() + Duration::from_secs(2), + ) + .await + .expect("refresh profile"); + + assert_eq!(refreshed.session(), SessionState::Active); + let active = refreshed.active_account().expect("active account"); + assert_eq!(active.relay_state(), RelayConnectionState::Connected); + assert_eq!(active.profile_state(), ProfileLoadState::Fresh); + assert_eq!( + active.profile().and_then(|profile| profile.display_name()), + Some("Farm Account") + ); + let cached = adapter + .database() + .load_profile(public_key) + .expect("load cache") + .expect("cached profile"); + assert_eq!( + cached.candidate().metadata().preferred_name(), + Some("Farm Account") + ); + let public_debug = format!("{refreshed:?}"); + assert!(!public_debug.contains(SECRET_HEX)); + assert!(!public_debug.contains("nsec1")); + publisher.shutdown().await; + local_relay.shutdown(); +} diff --git a/core/crates/studio_runtime/tests/restart_isolation.rs b/core/crates/studio_runtime/tests/restart_isolation.rs @@ -0,0 +1,95 @@ +use std::fs; + +use radroots_studio_application::{ + AccountNamespaceRepository, AccountPreferenceKey, Clock, InMemorySecretStore, + RelayConfiguration, SessionState, +}; +use radroots_studio_domain::{SecretKeyInput, UnixTimestamp}; +use radroots_studio_runtime::PersistentAppCore; +use tempfile::tempdir; + +const SECRET_A: &str = "7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7"; +const SECRET_B: &str = "0101010101010101010101010101010101010101010101010101010101010101"; + +struct FixedClock; + +impl Clock for FixedClock { + fn now(&self) -> UnixTimestamp { + UnixTimestamp::from_seconds(200).expect("fixed timestamp") + } +} + +#[test] +fn restart_restores_selection_and_keeps_account_namespaces_isolated() { + let directory = tempdir().expect("temporary directory"); + let path = directory.path().join("studio.sqlite3"); + let secrets = InMemorySecretStore::default(); + let (owner_a, owner_b); + + { + let adapter = PersistentAppCore::open(&path, RelayConfiguration::default()) + .expect("persistent adapter"); + adapter.bootstrap(&secrets, &FixedClock).expect("bootstrap"); + owner_a = adapter + .import_secret_key( + SecretKeyInput::parse(SECRET_A.to_owned()).expect("secret A"), + &secrets, + &FixedClock, + ) + .expect("account A") + .account() + .public_key(); + owner_b = adapter + .import_secret_key( + SecretKeyInput::parse(SECRET_B.to_owned()).expect("secret B"), + &secrets, + &FixedClock, + ) + .expect("account B") + .account() + .public_key(); + adapter + .database() + .set_value(owner_a, AccountPreferenceKey::NamespaceProbe, "account-a") + .expect("namespace A"); + adapter + .database() + .set_value(owner_b, AccountPreferenceKey::NamespaceProbe, "account-b") + .expect("namespace B"); + adapter.select_account(owner_b).expect("select B"); + } + + let reopened = + PersistentAppCore::open(&path, RelayConfiguration::default()).expect("reopen adapter"); + let restored = reopened.bootstrap(&secrets, &FixedClock).expect("restore"); + assert_eq!(restored.accounts().len(), 2); + assert_eq!(restored.selected_account(), Some(owner_b)); + assert_eq!(restored.session(), SessionState::SignedOut); + assert_eq!( + reopened + .database() + .get_value(owner_a, AccountPreferenceKey::NamespaceProbe) + .expect("read A"), + Some("account-a".to_owned()) + ); + assert_eq!( + reopened + .database() + .get_value(owner_b, AccountPreferenceKey::NamespaceProbe) + .expect("read B"), + Some("account-b".to_owned()) + ); + + let database = fs::read(path).expect("database bytes"); + assert!( + !database + .windows(SECRET_A.len()) + .any(|bytes| bytes == SECRET_A.as_bytes()) + ); + assert!( + !database + .windows(SECRET_B.len()) + .any(|bytes| bytes == SECRET_B.as_bytes()) + ); + assert!(!database.windows(5).any(|bytes| bytes == b"nsec1")); +}