lib

Core libraries for Radroots
git clone https://radroots.dev/git/lib.git
Log | Files | Refs | README

commit d4cf13e2b3aa0cbcb39ad0dbf652d171d94ff992
parent 773b957ff90cff99804db55c4f365a0a9edf4852
Author: triesap <tyson@radroots.org>
Date:   Thu,  6 Aug 2026 08:22:28 +0000

consolidation: import studio rust history

- merge the verified Studio package history without squashing
- preserve original authorship timestamps and commit messages
- retain GPL package boundaries and migration source verbatim
- stage the donor workspace manifest for canonical refactoring

Diffstat:
Acrates/studio_application/Cargo.toml | 22++++++++++++++++++++++
Acrates/studio_application/src/accounts.rs | 1412+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/studio_application/src/actor.rs | 785+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/studio_application/src/app_core.rs | 300+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/studio_application/src/change_stream.rs | 239+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/studio_application/src/config.rs | 105+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/studio_application/src/custody.rs | 279+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/studio_application/src/lib.rs | 55+++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/studio_application/src/nostr_client.rs | 138+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/studio_application/src/ports.rs | 780+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/studio_application/src/profile_refresh.rs | 559+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/studio_application/src/recovery.rs | 358+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/studio_application/src/secrets.rs | 308+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/studio_application/src/session.rs | 250+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/studio_application/src/snapshot.rs | 385+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/studio_application/src/state_machine.rs | 506+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/studio_application/tests/redaction.rs | 47+++++++++++++++++++++++++++++++++++++++++++++++
Acrates/studio_domain/Cargo.toml | 16++++++++++++++++
Acrates/studio_domain/src/account.rs | 423+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/studio_domain/src/error.rs | 110+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/studio_domain/src/key.rs | 391+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/studio_domain/src/lib.rs | 20++++++++++++++++++++
Acrates/studio_domain/src/profile.rs | 295+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/studio_domain/src/relay.rs | 157+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/studio_domain/src/time.rs | 34++++++++++++++++++++++++++++++++++
Acrates/studio_ffi/Cargo.toml | 27+++++++++++++++++++++++++++
Acrates/studio_ffi/src/commands.rs | 853+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/studio_ffi/src/dto.rs | 419+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/studio_ffi/src/lib.rs | 34++++++++++++++++++++++++++++++++++
Acrates/studio_ffi/src/observer.rs | 363+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/studio_ffi/uniffi.toml | 3+++
Acrates/studio_nostr/Cargo.toml | 14++++++++++++++
Acrates/studio_nostr/src/keys.rs | 152+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/studio_nostr/src/lib.rs | 7+++++++
Acrates/studio_nostr/src/profile.rs | 165+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/studio_storage/Cargo.toml | 27+++++++++++++++++++++++++++
Acrates/studio_storage/migrations/V1__initialize.sql | 6++++++
Acrates/studio_storage/migrations/V2__accounts.sql | 28++++++++++++++++++++++++++++
Acrates/studio_storage/migrations/V3__profile_cache.sql | 12++++++++++++
Acrates/studio_storage/migrations/V4__account_namespace.sql | 6++++++
Acrates/studio_storage/migrations/V5__operation_journal.sql | 8++++++++
Acrates/studio_storage/migrations/V6__normalized_runtime_schema.sql | 100+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/studio_storage/migrations/V7__migrate_v5_runtime_data.sql | 68++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/studio_storage/migrations/V8__normalized_account_preferences.sql | 10++++++++++
Acrates/studio_storage/migrations/V9__durable_operation_receipts.sql | 11+++++++++++
Acrates/studio_storage/src/account_namespace.rs | 162+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/studio_storage/src/accounts.rs | 394+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/studio_storage/src/application_adapter.rs | 718+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/studio_storage/src/db.rs | 590+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/studio_storage/src/journal.rs | 661+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/studio_storage/src/lib.rs | 15+++++++++++++++
Acrates/studio_storage/src/os_keyring.rs | 131+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/studio_storage/src/profiles.rs | 250+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/studio_storage/src/runtime_actor.rs | 1693+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/studio_storage/tests/local_relay_e2e.rs | 95+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/studio_storage/tests/redaction.rs | 52++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/studio_storage/tests/restart_isolation.rs | 95+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/studio_uniffi_bindgen/Cargo.toml | 14++++++++++++++
Acrates/studio_uniffi_bindgen/src/main.rs | 13+++++++++++++
Aimports/studio_workspace/Cargo.toml | 43+++++++++++++++++++++++++++++++++++++++++++
60 files changed, 15213 insertions(+), 0 deletions(-)

diff --git a/crates/studio_application/Cargo.toml b/crates/studio_application/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "radroots-studio-application" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true + +[dependencies] +nostr.workspace = true +nostr-sdk.workspace = true +radroots-studio-domain = { path = "../domain" } +radroots-studio-nostr = { path = "../nostr" } +secrecy.workspace = true +tokio.workspace = true + +[dev-dependencies] +nostr-relay-builder.workspace = true +tokio = { workspace = true, features = ["macros", "rt-multi-thread", "sync", "time"] } + +[lints] +workspace = true diff --git a/crates/studio_application/src/accounts.rs b/crates/studio_application/src/accounts.rs @@ -0,0 +1,1412 @@ +use std::sync::{Mutex, MutexGuard}; + +use radroots_studio_domain::{ + AccountCreatedAt, AccountIdentity, AccountSummary, BindingAvailability, LocalSignerBinding, + Nsec, PublicKey, SafeError, SafeErrorCode, SafeMessage, SecretKeyInput, +}; +use radroots_studio_nostr::{generate_local_keypair, import_secret}; + +use crate::{ + AccountOperationKind, AccountOperationPhase, AccountRepository, AppCore, AppStateRepository, + Clock, DurableOperationKind, DurableOperationPhase, DurableOperationRepository, + DurableOperationStart, DurableRequestId, DurableTerminalOutcome, OperationDiagnostic, + OperationId, OperationJournal, OperationPriorState, PendingAccountOperation, + RemovalConfirmationToken, SecretStore, StagedGeneratedKey, StateTransition, +}; + +pub struct GenerateAccountReceipt { + account: AccountSummary, + generated_nsec: Nsec, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ImportAccountReceipt { + account: AccountSummary, +} + +impl ImportAccountReceipt { + #[must_use] + pub const fn account(&self) -> &AccountSummary { + &self.account + } +} + +impl GenerateAccountReceipt { + #[must_use] + pub const fn account(&self) -> &AccountSummary { + &self.account + } + + #[must_use] + pub const fn generated_nsec(&self) -> &Nsec { + &self.generated_nsec + } +} + +impl AppCore { + /// Commits a staged generated key only after its recovery acknowledgement. + /// + /// # Errors + /// + /// Returns a safe conflict, keyring, persistence, or recovery error. + #[allow(clippy::too_many_arguments)] + pub fn commit_staged_generated_key( + &self, + request_id: &DurableRequestId, + staged: StagedGeneratedKey, + accounts: &(impl AccountRepository + ?Sized), + app_state: &(impl AppStateRepository + ?Sized), + secrets: &(impl SecretStore + ?Sized), + operations: &(impl DurableOperationRepository + ?Sized), + clock: &(impl Clock + ?Sized), + ) -> Result<ImportAccountReceipt, SafeError> { + let expected_revision = staged.expected_revision(); + self.require_revision(expected_revision)?; + let (account, secret) = staged.into_commit_parts(); + self.persist_account_durable( + request_id, + DurableOperationKind::Create, + expected_revision, + &account, + secret, + None, + accounts, + app_state, + secrets, + operations, + clock, + )?; + Ok(ImportAccountReceipt { account }) + } + + /// Generates and commits one account under a durable caller request. + /// + /// # Errors + /// + /// Returns a safe conflict, keyring, persistence, or state error. Staged recovery transport + /// replaces this transitional generated-secret receipt in the custody phase. + #[allow(clippy::too_many_arguments)] + pub fn generate_account_durable( + &self, + request_id: &DurableRequestId, + expected_revision: u64, + accounts: &(impl AccountRepository + ?Sized), + app_state: &(impl AppStateRepository + ?Sized), + secrets: &(impl SecretStore + ?Sized), + operations: &(impl DurableOperationRepository + ?Sized), + clock: &(impl Clock + ?Sized), + ) -> Result<GenerateAccountReceipt, SafeError> { + self.require_revision(expected_revision)?; + let generated = generate_local_keypair()?; + let (public_key, npub, secret, nsec) = generated.into_parts(); + let account = AccountSummary::new( + AccountIdentity::verify(public_key, npub.as_str().to_owned())?, + LocalSignerBinding::new(public_key, BindingAvailability::Available), + None, + AccountCreatedAt::new(clock.now()), + None, + )?; + self.persist_account_durable( + request_id, + DurableOperationKind::Create, + expected_revision, + &account, + secret, + None, + accounts, + app_state, + secrets, + operations, + clock, + )?; + Ok(GenerateAccountReceipt { + account, + generated_nsec: nsec, + }) + } + + /// Imports or explicitly repairs one local account under a durable caller request. + /// + /// # Errors + /// + /// Returns a safe conflict, validation, keyring, persistence, or state error. + #[allow(clippy::too_many_arguments)] + pub fn import_secret_key_durable( + &self, + request_id: &DurableRequestId, + expected_revision: u64, + input: SecretKeyInput, + accounts: &(impl AccountRepository + ?Sized), + app_state: &(impl AppStateRepository + ?Sized), + secrets: &(impl SecretStore + ?Sized), + operations: &(impl DurableOperationRepository + ?Sized), + clock: &(impl Clock + ?Sized), + ) -> Result<ImportAccountReceipt, SafeError> { + if let Some(existing) = operations.load_durable_operation(request_id)? { + return if existing + .terminal() + .is_some_and(|receipt| receipt.outcome() == DurableTerminalOutcome::Completed) + { + accounts + .find_account(existing.account())? + .map(|account| ImportAccountReceipt { account }) + .ok_or_else(recovery_required) + } else { + Err(recovery_required()) + }; + } + self.require_revision(expected_revision)?; + let imported = import_secret(input)?; + let (public_key, npub, secret) = imported.into_parts(); + let previous = accounts.find_account(public_key)?; + if let Some(existing) = &previous + && (existing.signer().availability() != BindingAvailability::CredentialMissing + || secrets.contains(public_key)?) + { + return Err(account_exists()); + } + if previous.is_none() && secrets.contains(public_key)? { + return Err(account_exists()); + } + let account = if let Some(existing) = &previous { + existing.with_binding_availability(BindingAvailability::Available) + } else { + AccountSummary::new( + AccountIdentity::verify(public_key, npub.as_str().to_owned())?, + LocalSignerBinding::new(public_key, BindingAvailability::Available), + None, + AccountCreatedAt::new(clock.now()), + None, + )? + }; + let kind = if previous.is_some() { + DurableOperationKind::Repair + } else { + DurableOperationKind::Import + }; + self.persist_account_durable( + request_id, + kind, + expected_revision, + &account, + secret, + previous.as_ref(), + accounts, + app_state, + secrets, + operations, + clock, + )?; + Ok(ImportAccountReceipt { account }) + } + + fn require_revision(&self, expected_revision: u64) -> Result<(), SafeError> { + if self.snapshot().revision().value() != expected_revision { + return Err(operation_conflict()); + } + Ok(()) + } + + #[allow(clippy::too_many_arguments)] + fn persist_account_durable( + &self, + request_id: &DurableRequestId, + kind: DurableOperationKind, + expected_revision: u64, + account: &AccountSummary, + secret: SecretKeyInput, + previous: Option<&AccountSummary>, + accounts: &(impl AccountRepository + ?Sized), + app_state: &(impl AppStateRepository + ?Sized), + secrets: &(impl SecretStore + ?Sized), + operations: &(impl DurableOperationRepository + ?Sized), + clock: &(impl Clock + ?Sized), + ) -> Result<(), SafeError> { + let prior = OperationPriorState::new( + app_state.load_selected_account()?, + previous.map(|account| account.signer().availability()), + ); + match operations.begin_durable_operation( + request_id, + kind, + account.public_key(), + Some(expected_revision), + prior, + clock.now(), + )? { + DurableOperationStart::Started(_) => {} + DurableOperationStart::Existing(operation) => { + return if operation + .terminal() + .is_some_and(|receipt| receipt.outcome() == DurableTerminalOutcome::Completed) + { + Ok(()) + } else { + Err(recovery_required()) + }; + } + } + secrets.put(account.public_key(), secret)?; + operations.advance_durable_operation( + request_id, + DurableOperationPhase::IntentRecorded, + DurableOperationPhase::CredentialWritten, + clock.now(), + None, + )?; + previous.map_or_else( + || accounts.insert_account(account), + |_| accounts.update_account(account), + )?; + operations.advance_durable_operation( + request_id, + DurableOperationPhase::CredentialWritten, + DurableOperationPhase::MetadataCommitted, + clock.now(), + None, + )?; + app_state.save_selected_account(Some(account.public_key()))?; + operations.advance_durable_operation( + request_id, + DurableOperationPhase::MetadataCommitted, + DurableOperationPhase::SelectionCommitted, + clock.now(), + None, + )?; + let snapshot = self.apply_transition(StateTransition::ReplaceRegistry { + accounts: accounts.list_accounts()?, + selected: Some(account.public_key()), + })?; + operations.finalize_durable_operation( + request_id, + DurableOperationPhase::SelectionCommitted, + DurableTerminalOutcome::Completed, + Some(snapshot.revision().value()), + clock.now(), + )?; + Ok(()) + } + + /// Issues a single-use confirmation bound to the target and current revision. + /// + /// # Errors + /// + /// Returns a safe account or application-state error. + pub fn request_account_removal( + &self, + public_key: PublicKey, + clock: &(impl Clock + ?Sized), + ) -> Result<RemovalConfirmationToken, SafeError> { + self.issue_removal_token(public_key, clock.now()) + } + + pub fn cancel_account_removal(&self, token: RemovalConfirmationToken) -> bool { + self.cancel_removal_token(token) + } + + /// Permanently removes a confirmed account and selects a deterministic fallback. + /// + /// # Errors + /// + /// Returns a safe confirmation, credential, persistence, recovery, or state error. + pub fn confirm_account_removal( + &self, + token: RemovalConfirmationToken, + accounts: &(impl AccountRepository + ?Sized), + app_state: &(impl AppStateRepository + ?Sized), + secrets: &(impl SecretStore + ?Sized), + journal: &(impl OperationJournal + ?Sized), + clock: &(impl Clock + ?Sized), + ) -> Result<crate::AppSnapshot, SafeError> { + let public_key = self.consume_removal_token(token, clock.now())?; + let registry = accounts.list_accounts()?; + let index = registry + .iter() + .position(|account| account.public_key() == public_key) + .ok_or_else(account_not_found)?; + let selected = if self.snapshot().selected_account() == Some(public_key) { + registry + .get(index + 1) + .or_else(|| index.checked_sub(1).and_then(|before| registry.get(before))) + .map(AccountSummary::public_key) + } else { + self.snapshot().selected_account() + }; + let operation = + journal.begin_operation(AccountOperationKind::Remove, public_key, clock.now())?; + let was_active = self + .snapshot() + .active_account() + .is_some_and(|active| active.account().public_key() == public_key); + if was_active { + self.sign_out()?; + } + let account = &registry[index]; + match secrets.delete(public_key) { + Ok(()) => {} + Err(error) + if error.code() == SafeErrorCode::CredentialMissing + && account.signer().availability() + == BindingAvailability::CredentialMissing => {} + Err(error) => return Err(error), + } + journal.update_operation( + operation, + AccountOperationPhase::CredentialDeleted, + clock.now(), + None, + )?; + accounts.remove_account(public_key)?; + app_state.save_selected_account(selected)?; + journal.update_operation( + operation, + AccountOperationPhase::MetadataDeleted, + clock.now(), + None, + )?; + journal.finalize_operation(operation)?; + self.apply_transition(StateTransition::ReplaceRegistryPreservingSession { + accounts: accounts.list_accounts()?, + selected, + }) + } + + /// Confirms and executes an expiring removal plan as a durable request. + /// + /// # Errors + /// + /// Returns a safe expiry, conflict, credential, persistence, or recovery error. + #[allow(clippy::too_many_arguments)] + pub fn confirm_account_removal_durable( + &self, + request_id: &DurableRequestId, + token: RemovalConfirmationToken, + accounts: &(impl AccountRepository + ?Sized), + app_state: &(impl AppStateRepository + ?Sized), + secrets: &(impl SecretStore + ?Sized), + operations: &(impl DurableOperationRepository + ?Sized), + clock: &(impl Clock + ?Sized), + ) -> Result<crate::AppSnapshot, SafeError> { + let expected_revision = token.revision().value(); + let public_key = self.consume_removal_token(token, clock.now())?; + self.require_revision(expected_revision)?; + let registry = accounts.list_accounts()?; + let index = registry + .iter() + .position(|account| account.public_key() == public_key) + .ok_or_else(account_not_found)?; + let selected = if self.snapshot().selected_account() == Some(public_key) { + registry + .get(index + 1) + .or_else(|| index.checked_sub(1).and_then(|before| registry.get(before))) + .map(AccountSummary::public_key) + } else { + self.snapshot().selected_account() + }; + let account = &registry[index]; + match operations.begin_durable_operation( + request_id, + DurableOperationKind::Remove, + public_key, + Some(expected_revision), + OperationPriorState::new(selected, Some(account.signer().availability())), + clock.now(), + )? { + DurableOperationStart::Started(_) => {} + DurableOperationStart::Existing(operation) => { + return if operation + .terminal() + .is_some_and(|receipt| receipt.outcome() == DurableTerminalOutcome::Completed) + { + Ok(self.snapshot()) + } else { + Err(recovery_required()) + }; + } + } + if self + .snapshot() + .active_account() + .is_some_and(|active| active.account().public_key() == public_key) + { + self.sign_out()?; + } + match secrets.delete(public_key) { + Ok(()) => {} + Err(error) + if error.code() == SafeErrorCode::CredentialMissing + && account.signer().availability() + == BindingAvailability::CredentialMissing => {} + Err(error) => return Err(error), + } + operations.advance_durable_operation( + request_id, + DurableOperationPhase::IntentRecorded, + DurableOperationPhase::CredentialDeleted, + clock.now(), + None, + )?; + accounts.remove_account(public_key)?; + operations.advance_durable_operation( + request_id, + DurableOperationPhase::CredentialDeleted, + DurableOperationPhase::MetadataDeleted, + clock.now(), + None, + )?; + app_state.save_selected_account(selected)?; + operations.advance_durable_operation( + request_id, + DurableOperationPhase::MetadataDeleted, + DurableOperationPhase::SelectionCommitted, + clock.now(), + None, + )?; + let snapshot = + self.apply_transition(StateTransition::ReplaceRegistryPreservingSession { + accounts: accounts.list_accounts()?, + selected, + })?; + operations.finalize_durable_operation( + request_id, + DurableOperationPhase::SelectionCommitted, + DurableTerminalOutcome::Completed, + Some(snapshot.revision().value()), + clock.now(), + )?; + Ok(snapshot) + } + + /// Persists and publishes a saved account selection without activating it. + /// + /// # Errors + /// + /// Returns a safe account, persistence, or application-state error. + pub fn select_account( + &self, + public_key: PublicKey, + accounts: &(impl AccountRepository + ?Sized), + app_state: &(impl AppStateRepository + ?Sized), + ) -> Result<crate::AppSnapshot, SafeError> { + if accounts.find_account(public_key)?.is_none() { + return Err(account_not_found()); + } + app_state.save_selected_account(Some(public_key))?; + self.apply_transition(StateTransition::Select(public_key)) + } + + /// Generates, stores, and selects one local Nostr account without activating it. + /// + /// # Errors + /// + /// Returns a safe key, credential, persistence, or application-state error. + pub fn generate_account( + &self, + accounts: &(impl AccountRepository + ?Sized), + app_state: &(impl AppStateRepository + ?Sized), + secrets: &(impl SecretStore + ?Sized), + journal: &(impl OperationJournal + ?Sized), + clock: &(impl Clock + ?Sized), + ) -> Result<GenerateAccountReceipt, SafeError> { + let generated = generate_local_keypair()?; + let (public_key, npub, secret, nsec) = generated.into_parts(); + let account = AccountSummary::new( + AccountIdentity::verify(public_key, npub.as_str().to_owned())?, + LocalSignerBinding::new(public_key, BindingAvailability::Available), + None, + AccountCreatedAt::new(clock.now()), + None, + )?; + Self::persist_account_transaction( + AccountOperationKind::Add, + &account, + secret, + None, + accounts, + app_state, + secrets, + journal, + clock, + )?; + let registry = accounts.list_accounts()?; + self.apply_transition(StateTransition::ReplaceRegistry { + accounts: registry, + selected: Some(public_key), + })?; + Ok(GenerateAccountReceipt { + account, + generated_nsec: nsec, + }) + } + + /// Imports, stores, and selects one local Nostr account without activating it. + /// + /// # Errors + /// + /// Returns a safe key, credential, persistence, or application-state error. + pub fn import_secret_key( + &self, + input: SecretKeyInput, + accounts: &(impl AccountRepository + ?Sized), + app_state: &(impl AppStateRepository + ?Sized), + secrets: &(impl SecretStore + ?Sized), + journal: &(impl OperationJournal + ?Sized), + clock: &(impl Clock + ?Sized), + ) -> Result<ImportAccountReceipt, SafeError> { + let imported = import_secret(input)?; + let (public_key, npub, secret) = imported.into_parts(); + if let Some(existing) = accounts.find_account(public_key)? { + if existing.signer().availability() != BindingAvailability::CredentialMissing + || secrets.contains(public_key)? + { + return Err(account_exists()); + } + let repaired = existing.with_binding_availability(BindingAvailability::Available); + Self::persist_account_transaction( + AccountOperationKind::Import, + &repaired, + secret, + Some(&existing), + accounts, + app_state, + secrets, + journal, + clock, + )?; + self.apply_transition(StateTransition::ReplaceRegistry { + accounts: accounts.list_accounts()?, + selected: Some(public_key), + })?; + return Ok(ImportAccountReceipt { account: repaired }); + } + if secrets.contains(public_key)? { + return Err(account_exists()); + } + let account = AccountSummary::new( + AccountIdentity::verify(public_key, npub.as_str().to_owned())?, + LocalSignerBinding::new(public_key, BindingAvailability::Available), + None, + AccountCreatedAt::new(clock.now()), + None, + )?; + Self::persist_account_transaction( + AccountOperationKind::Import, + &account, + secret, + None, + accounts, + app_state, + secrets, + journal, + clock, + )?; + self.apply_transition(StateTransition::ReplaceRegistry { + accounts: accounts.list_accounts()?, + selected: Some(public_key), + })?; + Ok(ImportAccountReceipt { account }) + } + + #[allow(clippy::too_many_arguments)] + fn persist_account_transaction( + kind: AccountOperationKind, + account: &AccountSummary, + secret: SecretKeyInput, + previous: Option<&AccountSummary>, + accounts: &(impl AccountRepository + ?Sized), + app_state: &(impl AppStateRepository + ?Sized), + secrets: &(impl SecretStore + ?Sized), + journal: &(impl OperationJournal + ?Sized), + clock: &(impl Clock + ?Sized), + ) -> Result<(), SafeError> { + let public_key = account.public_key(); + let previous_selection = app_state.load_selected_account()?; + let operation = journal.begin_operation(kind, public_key, clock.now())?; + if let Err(error) = secrets.put(public_key, secret) { + let _ = journal.finalize_operation(operation); + return Err(error); + } + if let Err(error) = journal.update_operation( + operation, + AccountOperationPhase::CredentialWritten, + clock.now(), + None, + ) { + return compensate_account_write( + operation, + public_key, + error, + None, + previous_selection, + accounts, + app_state, + secrets, + journal, + clock, + ); + } + let metadata_result = previous.map_or_else( + || accounts.insert_account(account), + |_| accounts.update_account(account), + ); + if let Err(error) = metadata_result { + return compensate_account_write( + operation, + public_key, + error, + previous, + previous_selection, + accounts, + app_state, + secrets, + journal, + clock, + ); + } + if let Err(error) = app_state.save_selected_account(Some(public_key)) { + return compensate_account_write( + operation, + public_key, + error, + previous, + previous_selection, + accounts, + app_state, + secrets, + journal, + clock, + ); + } + journal.update_operation( + operation, + AccountOperationPhase::MetadataCommitted, + clock.now(), + None, + )?; + journal.finalize_operation(operation) + } +} + +#[allow(clippy::too_many_arguments)] +fn compensate_account_write( + operation: OperationId, + public_key: PublicKey, + original_error: SafeError, + previous: Option<&AccountSummary>, + previous_selection: Option<PublicKey>, + accounts: &(impl AccountRepository + ?Sized), + app_state: &(impl AppStateRepository + ?Sized), + secrets: &(impl SecretStore + ?Sized), + journal: &(impl OperationJournal + ?Sized), + clock: &(impl Clock + ?Sized), +) -> Result<(), SafeError> { + let metadata_rollback = if let Some(previous) = previous { + accounts.update_account(previous) + } else { + accounts.remove_account(public_key) + }; + let selection_rollback = app_state.save_selected_account(previous_selection); + let credential_rollback = secrets.delete(public_key); + if metadata_rollback.is_err() || selection_rollback.is_err() || credential_rollback.is_err() { + let _ = journal.update_operation( + operation, + AccountOperationPhase::CompensationPending, + clock.now(), + Some(OperationDiagnostic::CompensationFailed), + ); + return Err(recovery_required()); + } + let _ = journal.finalize_operation(operation); + Err(original_error) +} + +#[derive(Default)] +pub struct InMemoryOperationJournal { + state: Mutex<InMemoryJournalState>, +} + +#[derive(Default)] +struct InMemoryJournalState { + next_id: u64, + pending: Vec<PendingAccountOperation>, +} + +impl OperationJournal for InMemoryOperationJournal { + fn begin_operation( + &self, + kind: AccountOperationKind, + subject: PublicKey, + updated_at: radroots_studio_domain::UnixTimestamp, + ) -> Result<OperationId, SafeError> { + let mut state = self + .state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + state.next_id = state.next_id.checked_add(1).ok_or_else(recovery_required)?; + let id = OperationId::from_raw(state.next_id); + state.pending.push(PendingAccountOperation::new( + id, + kind, + subject, + AccountOperationPhase::IntentRecorded, + updated_at, + None, + )); + Ok(id) + } + + fn update_operation( + &self, + id: OperationId, + phase: AccountOperationPhase, + updated_at: radroots_studio_domain::UnixTimestamp, + diagnostic: Option<OperationDiagnostic>, + ) -> Result<(), SafeError> { + let mut state = self + .state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let operation = state + .pending + .iter_mut() + .find(|operation| operation.id() == id) + .ok_or_else(recovery_required)?; + *operation = PendingAccountOperation::new( + id, + operation.kind(), + operation.subject(), + phase, + updated_at, + diagnostic, + ); + Ok(()) + } + + fn list_pending_operations(&self) -> Result<Vec<PendingAccountOperation>, SafeError> { + Ok(self + .state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .pending + .clone()) + } + + fn finalize_operation(&self, id: OperationId) -> Result<(), SafeError> { + self.state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .pending + .retain(|operation| operation.id() != id); + Ok(()) + } +} + +#[derive(Default)] +pub struct InMemoryAccountRepository { + state: Mutex<InMemoryAccountState>, +} + +#[derive(Default)] +struct InMemoryAccountState { + accounts: Vec<AccountSummary>, + selected: Option<PublicKey>, +} + +impl InMemoryAccountRepository { + fn state(&self) -> MutexGuard<'_, InMemoryAccountState> { + self.state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + } +} + +impl AccountRepository for InMemoryAccountRepository { + fn list_accounts(&self) -> Result<Vec<AccountSummary>, SafeError> { + Ok(self.state().accounts.clone()) + } + + fn find_account(&self, public_key: PublicKey) -> Result<Option<AccountSummary>, SafeError> { + Ok(self + .state() + .accounts + .iter() + .find(|account| account.public_key() == public_key) + .cloned()) + } + + fn insert_account(&self, account: &AccountSummary) -> Result<(), SafeError> { + let mut state = self.state(); + if state + .accounts + .iter() + .any(|saved| saved.public_key() == account.public_key()) + { + return Err(account_exists()); + } + state.accounts.push(account.clone()); + state + .accounts + .sort_by_key(|saved| (saved.created_at().timestamp(), saved.public_key())); + Ok(()) + } + + fn update_account(&self, account: &AccountSummary) -> Result<(), SafeError> { + let mut state = self.state(); + let saved = state + .accounts + .iter_mut() + .find(|saved| saved.public_key() == account.public_key()) + .ok_or_else(account_not_found)?; + *saved = account.clone(); + Ok(()) + } + + fn remove_account(&self, public_key: PublicKey) -> Result<(), SafeError> { + let mut state = self.state(); + state + .accounts + .retain(|account| account.public_key() != public_key); + if state.selected == Some(public_key) { + state.selected = None; + } + Ok(()) + } +} + +impl AppStateRepository for InMemoryAccountRepository { + fn load_selected_account(&self) -> Result<Option<PublicKey>, SafeError> { + Ok(self.state().selected) + } + + fn save_selected_account(&self, public_key: Option<PublicKey>) -> Result<(), SafeError> { + let mut state = self.state(); + if public_key.is_some_and(|key| { + !state + .accounts + .iter() + .any(|account| account.public_key() == key) + }) { + return Err(account_not_found()); + } + state.selected = public_key; + Ok(()) + } +} + +const fn account_exists() -> SafeError { + SafeError::new( + SafeErrorCode::AccountAlreadyExists, + SafeMessage::new("The Nostr account is already saved."), + ) +} + +const fn account_not_found() -> SafeError { + SafeError::new( + SafeErrorCode::AccountNotFound, + SafeMessage::new("The account was not found."), + ) +} + +const fn recovery_required() -> SafeError { + SafeError::new( + SafeErrorCode::PendingOperationRecoveryRequired, + SafeMessage::new("Account recovery is required before this operation can continue."), + ) +} + +const fn operation_conflict() -> SafeError { + SafeError::new( + SafeErrorCode::InvalidApplicationState, + SafeMessage::new("The account operation conflicts with the current application state."), + ) +} + +#[cfg(test)] +mod tests { + use std::sync::atomic::{AtomicBool, Ordering}; + + use radroots_studio_domain::{ + AccountCreatedAt, AccountIdentity, AccountSummary, BindingAvailability, LocalSignerBinding, + PublicKey, SafeError, SafeErrorCode, SafeMessage, SecretKeyInput, UnixTimestamp, + }; + + use super::InMemoryAccountRepository; + use crate::{ + AccountOperationPhase, AccountRepository, AppCore, AppStateRepository, Clock, + FailureSecretStore, InMemoryOperationJournal, InMemorySecretStore, OperationJournal, + RelayConfiguration, SecretStore, SecretStoreOperation, SessionState, StateTransition, + }; + + struct FixedClock; + + impl Clock for FixedClock { + fn now(&self) -> UnixTimestamp { + UnixTimestamp::from_seconds(10).expect("time") + } + } + + struct LateClock; + + impl Clock for LateClock { + fn now(&self) -> UnixTimestamp { + UnixTimestamp::from_seconds(311).expect("time") + } + } + + #[derive(Default)] + struct FailingInsertRepository { + inner: InMemoryAccountRepository, + } + + #[derive(Default)] + struct FailingSelectionRepository { + inner: InMemoryAccountRepository, + fail_next_selection: AtomicBool, + } + + impl AccountRepository for FailingSelectionRepository { + fn list_accounts(&self) -> Result<Vec<AccountSummary>, SafeError> { + self.inner.list_accounts() + } + + fn find_account(&self, public_key: PublicKey) -> Result<Option<AccountSummary>, SafeError> { + self.inner.find_account(public_key) + } + + fn insert_account(&self, account: &AccountSummary) -> Result<(), SafeError> { + self.inner.insert_account(account) + } + + fn update_account(&self, account: &AccountSummary) -> Result<(), SafeError> { + self.inner.update_account(account) + } + + fn remove_account(&self, public_key: PublicKey) -> Result<(), SafeError> { + self.inner.remove_account(public_key) + } + } + + impl AppStateRepository for FailingSelectionRepository { + fn load_selected_account(&self) -> Result<Option<PublicKey>, SafeError> { + self.inner.load_selected_account() + } + + fn save_selected_account(&self, public_key: Option<PublicKey>) -> Result<(), SafeError> { + if self.fail_next_selection.swap(false, Ordering::SeqCst) { + return Err(SafeError::new( + SafeErrorCode::StorageUnavailable, + SafeMessage::new("The test selection repository is unavailable."), + )); + } + self.inner.save_selected_account(public_key) + } + } + + impl AccountRepository for FailingInsertRepository { + fn list_accounts(&self) -> Result<Vec<AccountSummary>, SafeError> { + self.inner.list_accounts() + } + + fn find_account(&self, public_key: PublicKey) -> Result<Option<AccountSummary>, SafeError> { + self.inner.find_account(public_key) + } + + fn insert_account(&self, _account: &AccountSummary) -> Result<(), SafeError> { + Err(SafeError::new( + SafeErrorCode::StorageUnavailable, + SafeMessage::new("The test account repository is unavailable."), + )) + } + + fn update_account(&self, account: &AccountSummary) -> Result<(), SafeError> { + self.inner.update_account(account) + } + + fn remove_account(&self, public_key: PublicKey) -> Result<(), SafeError> { + self.inner.remove_account(public_key) + } + } + + impl AppStateRepository for FailingInsertRepository { + fn load_selected_account(&self) -> Result<Option<PublicKey>, SafeError> { + self.inner.load_selected_account() + } + + fn save_selected_account(&self, public_key: Option<PublicKey>) -> Result<(), SafeError> { + self.inner.save_selected_account(public_key) + } + } + + #[test] + fn generate_account_stores_selects_and_returns_one_time_nsec_without_activation() { + let core = AppCore::in_memory(RelayConfiguration::default()); + let accounts = InMemoryAccountRepository::default(); + let secrets = InMemorySecretStore::default(); + let journal = InMemoryOperationJournal::default(); + core.bootstrap().expect("bootstrap"); + + let receipt = core + .generate_account(&accounts, &accounts, &secrets, &journal, &FixedClock) + .expect("generate"); + let public_key = receipt.account().public_key(); + assert_eq!(public_key.to_hex().len(), 64); + assert!(secrets.contains(public_key).expect("credential")); + assert_eq!( + accounts.load_selected_account().expect("selection"), + Some(public_key) + ); + assert_eq!(core.snapshot().selected_account(), Some(public_key)); + assert_eq!(core.snapshot().session(), SessionState::SignedOut); + assert!(core.snapshot().active_account().is_none()); + assert_eq!(receipt.generated_nsec().with_exposed_secret(str::len), 63); + assert!(!format!("{:?}", core.snapshot()).contains("nsec1")); + } + + #[test] + fn import_secret_key_accepts_nsec_and_hex_without_exposing_or_activating() { + for input in [ + "nsec1vl029mgpspedva04g90vltkh6fvh240zqtv9k0t9af8935ke9laqsnlfe5", + "7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7", + ] { + let core = AppCore::in_memory(RelayConfiguration::default()); + let accounts = InMemoryAccountRepository::default(); + let secrets = InMemorySecretStore::default(); + let journal = InMemoryOperationJournal::default(); + core.bootstrap().expect("bootstrap"); + let receipt = core + .import_secret_key( + SecretKeyInput::parse(input.to_owned()).expect("input"), + &accounts, + &accounts, + &secrets, + &journal, + &FixedClock, + ) + .expect("import"); + let public_key = receipt.account().public_key(); + assert!(secrets.contains(public_key).expect("credential")); + assert_eq!(core.snapshot().selected_account(), Some(public_key)); + assert_eq!(core.snapshot().session(), SessionState::SignedOut); + assert!(!format!("{:?}", core.snapshot()).contains(input)); + } + } + + #[test] + fn import_secret_key_rejects_invalid_nsec_checksum_before_persistence() { + let core = AppCore::in_memory(RelayConfiguration::default()); + let accounts = InMemoryAccountRepository::default(); + let secrets = InMemorySecretStore::default(); + let journal = InMemoryOperationJournal::default(); + core.bootstrap().expect("bootstrap"); + let input = SecretKeyInput::parse( + "nsec1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq".to_owned(), + ) + .expect("domain shape"); + let error = core + .import_secret_key(input, &accounts, &accounts, &secrets, &journal, &FixedClock) + .expect_err("invalid import"); + assert_eq!(error.code(), SafeErrorCode::InvalidSecretKey); + assert!(core.snapshot().accounts().is_empty()); + } + + #[test] + fn duplicate_import_preserves_existing_credential_and_snapshot() { + let core = AppCore::in_memory(RelayConfiguration::default()); + let accounts = InMemoryAccountRepository::default(); + let secrets = InMemorySecretStore::default(); + let journal = InMemoryOperationJournal::default(); + core.bootstrap().expect("bootstrap"); + let import = || { + SecretKeyInput::parse( + "7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7".to_owned(), + ) + .expect("input") + }; + core.import_secret_key( + import(), + &accounts, + &accounts, + &secrets, + &journal, + &FixedClock, + ) + .expect("first import"); + let before = core.snapshot(); + let error = core + .import_secret_key( + import(), + &accounts, + &accounts, + &secrets, + &journal, + &FixedClock, + ) + .expect_err("duplicate"); + assert_eq!(error.code(), SafeErrorCode::AccountAlreadyExists); + assert_eq!(core.snapshot(), before); + assert_eq!(core.snapshot().accounts().len(), 1); + } + + #[test] + fn duplicate_import_repairs_only_explicit_missing_credential_account() { + let core = AppCore::in_memory(RelayConfiguration::default()); + let accounts = InMemoryAccountRepository::default(); + let secrets = InMemorySecretStore::default(); + let journal = InMemoryOperationJournal::default(); + core.bootstrap().expect("bootstrap"); + let input = || { + SecretKeyInput::parse( + "7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7".to_owned(), + ) + .expect("input") + }; + let imported = radroots_studio_nostr::import_secret(input()).expect("derive"); + let (public_key, npub, _) = imported.into_parts(); + let missing = AccountSummary::new( + AccountIdentity::verify(public_key, npub.as_str().to_owned()).expect("identity"), + LocalSignerBinding::new(public_key, BindingAvailability::CredentialMissing), + None, + AccountCreatedAt::new(FixedClock.now()), + None, + ) + .expect("missing account"); + accounts.insert_account(&missing).expect("missing metadata"); + accounts + .save_selected_account(Some(public_key)) + .expect("selection"); + core.apply_transition(StateTransition::ReplaceRegistry { + accounts: vec![missing], + selected: Some(public_key), + }) + .expect("registry"); + + let receipt = core + .import_secret_key( + input(), + &accounts, + &accounts, + &secrets, + &journal, + &FixedClock, + ) + .expect("repair"); + assert_eq!( + receipt.account().signer().availability(), + BindingAvailability::Available + ); + assert!(secrets.contains(public_key).expect("credential")); + assert_eq!(core.snapshot().accounts().len(), 1); + } + + #[test] + fn account_transaction_publishes_nothing_when_credential_write_fails() { + let core = AppCore::in_memory(RelayConfiguration::default()); + let accounts = InMemoryAccountRepository::default(); + let secrets = FailureSecretStore::default(); + let journal = InMemoryOperationJournal::default(); + core.bootstrap().expect("bootstrap"); + secrets.fail_next(SecretStoreOperation::Put); + + let error = core + .generate_account(&accounts, &accounts, &secrets, &journal, &FixedClock) + .err() + .expect("credential failure"); + assert_eq!(error.code(), SafeErrorCode::KeyringUnavailable); + assert!(core.snapshot().accounts().is_empty()); + assert!( + journal + .list_pending_operations() + .expect("journal") + .is_empty() + ); + } + + #[test] + fn account_transaction_removes_written_credential_when_metadata_fails() { + let core = AppCore::in_memory(RelayConfiguration::default()); + let accounts = FailingInsertRepository::default(); + let secrets = FailureSecretStore::default(); + let journal = InMemoryOperationJournal::default(); + core.bootstrap().expect("bootstrap"); + + let error = core + .generate_account(&accounts, &accounts, &secrets, &journal, &FixedClock) + .err() + .expect("metadata failure"); + assert_eq!(error.code(), SafeErrorCode::StorageUnavailable); + let calls = secrets.calls(); + assert_eq!(calls[0].operation(), SecretStoreOperation::Put); + assert_eq!(calls[1].operation(), SecretStoreOperation::Delete); + assert_eq!(calls[0].public_key(), calls[1].public_key()); + assert!(core.snapshot().accounts().is_empty()); + assert!( + journal + .list_pending_operations() + .expect("journal") + .is_empty() + ); + } + + #[test] + fn account_transaction_rolls_back_metadata_and_credential_when_selection_fails() { + let core = AppCore::in_memory(RelayConfiguration::default()); + let accounts = FailingSelectionRepository::default(); + let secrets = FailureSecretStore::default(); + let journal = InMemoryOperationJournal::default(); + core.bootstrap().expect("bootstrap"); + accounts.fail_next_selection.store(true, Ordering::SeqCst); + + let error = core + .generate_account(&accounts, &accounts, &secrets, &journal, &FixedClock) + .err() + .expect("selection failure"); + + assert_eq!(error.code(), SafeErrorCode::StorageUnavailable); + assert!(accounts.list_accounts().expect("accounts").is_empty()); + assert_eq!(accounts.load_selected_account().expect("selection"), None); + let calls = secrets.calls(); + assert_eq!(calls[0].operation(), SecretStoreOperation::Put); + assert_eq!(calls[1].operation(), SecretStoreOperation::Delete); + assert_eq!(calls[0].public_key(), calls[1].public_key()); + assert!(core.snapshot().accounts().is_empty()); + assert!( + journal + .list_pending_operations() + .expect("journal") + .is_empty() + ); + } + + #[test] + fn account_transaction_retains_non_secret_journal_when_compensation_fails() { + let core = AppCore::in_memory(RelayConfiguration::default()); + let accounts = FailingInsertRepository::default(); + let secrets = FailureSecretStore::default(); + let journal = InMemoryOperationJournal::default(); + core.bootstrap().expect("bootstrap"); + secrets.fail_next(SecretStoreOperation::Delete); + + let error = core + .generate_account(&accounts, &accounts, &secrets, &journal, &FixedClock) + .err() + .expect("recovery required"); + assert_eq!( + error.code(), + SafeErrorCode::PendingOperationRecoveryRequired + ); + let pending = journal.list_pending_operations().expect("journal"); + assert_eq!(pending.len(), 1); + assert_eq!( + pending[0].phase(), + AccountOperationPhase::CompensationPending + ); + assert!(!format!("{pending:?}").contains("nsec1")); + assert!(core.snapshot().accounts().is_empty()); + } + + #[test] + fn select_account_persists_existing_choice_without_activating() { + let core = AppCore::in_memory(RelayConfiguration::default()); + let accounts = InMemoryAccountRepository::default(); + let secrets = InMemorySecretStore::default(); + let journal = InMemoryOperationJournal::default(); + core.bootstrap().expect("bootstrap"); + let first = core + .generate_account(&accounts, &accounts, &secrets, &journal, &FixedClock) + .expect("first") + .account() + .public_key(); + core.generate_account(&accounts, &accounts, &secrets, &journal, &FixedClock) + .expect("second"); + + let selected = core + .select_account(first, &accounts, &accounts) + .expect("select first"); + assert_eq!(selected.selected_account(), Some(first)); + assert_eq!(selected.session(), SessionState::SignedOut); + assert!(selected.active_account().is_none()); + assert_eq!( + accounts.load_selected_account().expect("saved"), + Some(first) + ); + let missing = core + .select_account(PublicKey::from_bytes([0xff; 32]), &accounts, &accounts) + .expect_err("missing account"); + assert_eq!(missing.code(), SafeErrorCode::AccountNotFound); + assert_eq!(core.snapshot(), selected); + } + + #[test] + fn remove_account_requires_fresh_single_use_confirmation_and_selects_next_fallback() { + let core = AppCore::in_memory(RelayConfiguration::default()); + let accounts = InMemoryAccountRepository::default(); + let secrets = InMemorySecretStore::default(); + let journal = InMemoryOperationJournal::default(); + core.bootstrap().expect("bootstrap"); + let first = core + .generate_account(&accounts, &accounts, &secrets, &journal, &FixedClock) + .expect("first") + .account() + .public_key(); + let second = core + .generate_account(&accounts, &accounts, &secrets, &journal, &FixedClock) + .expect("second") + .account() + .public_key(); + core.select_account(first, &accounts, &accounts) + .expect("select first"); + let stale = core + .request_account_removal(first, &FixedClock) + .expect("stale token"); + core.select_account(second, &accounts, &accounts) + .expect("change revision"); + let stale_error = core + .confirm_account_removal(stale, &accounts, &accounts, &secrets, &journal, &FixedClock) + .expect_err("stale token"); + assert_eq!(stale_error.code(), SafeErrorCode::InvalidApplicationState); + assert_eq!(core.snapshot().accounts().len(), 2); + + core.select_account(first, &accounts, &accounts) + .expect("reselect first"); + let token = core + .request_account_removal(first, &FixedClock) + .expect("token"); + let removed = core + .confirm_account_removal(token, &accounts, &accounts, &secrets, &journal, &FixedClock) + .expect("remove"); + assert_eq!(removed.accounts().len(), 1); + assert_eq!(removed.selected_account(), Some(second)); + assert!(!secrets.contains(first).expect("credential removed")); + assert_eq!(removed.session(), SessionState::SignedOut); + } + + #[test] + fn removal_preflight_reports_impact_expires_and_can_be_cancelled() { + let core = AppCore::in_memory(RelayConfiguration::default()); + let accounts = InMemoryAccountRepository::default(); + let secrets = InMemorySecretStore::default(); + let journal = InMemoryOperationJournal::default(); + core.bootstrap().expect("bootstrap"); + let account = core + .generate_account(&accounts, &accounts, &secrets, &journal, &FixedClock) + .expect("account") + .account() + .public_key(); + let expired = core + .request_account_removal(account, &FixedClock) + .expect("plan"); + assert!(expired.impact().deletes_local_credential()); + assert!(!expired.impact().signs_out()); + assert!( + core.confirm_account_removal( + expired, &accounts, &accounts, &secrets, &journal, &LateClock, + ) + .is_err() + ); + let cancelled = core + .request_account_removal(account, &FixedClock) + .expect("replacement plan"); + assert!(core.cancel_account_removal(cancelled)); + assert_eq!(core.snapshot().accounts().len(), 1); + } +} diff --git a/crates/studio_application/src/actor.rs b/crates/studio_application/src/actor.rs @@ -0,0 +1,785 @@ +use std::num::{NonZeroU64, NonZeroUsize}; +use std::time::Instant; + +use radroots_studio_domain::{ + AccountIdentity, BindingAvailability, LocalSignerBinding, PublicKey, SafeError, SafeErrorCode, + SafeMessage, +}; +use tokio::sync::{mpsc, oneshot}; + +use crate::SnapshotRevision; + +#[derive(Clone, Copy, Debug, Default, Eq, Hash, Ord, PartialEq, PartialOrd)] +pub struct SessionGeneration(u64); + +impl SessionGeneration { + #[must_use] + pub const fn initial() -> Self { + Self(0) + } + + #[must_use] + pub const fn from_value(value: u64) -> Self { + Self(value) + } + + #[must_use] + pub const fn value(self) -> u64 { + self.0 + } + + #[must_use] + pub const fn next(self) -> Option<Self> { + match self.0.checked_add(1) { + Some(value) => Some(Self(value)), + None => None, + } + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ForegroundSessionBinding { + identity: AccountIdentity, + signer: LocalSignerBinding, + generation: SessionGeneration, +} + +impl ForegroundSessionBinding { + /// Binds one foreground session to a ready local signer and generation. + /// + /// # Errors + /// + /// Returns a safe state error when account and binding differ or when the + /// signer is unavailable. + pub fn new( + identity: AccountIdentity, + signer: LocalSignerBinding, + generation: SessionGeneration, + ) -> Result<Self, SafeError> { + if identity.public_key() != signer.account() + || signer.availability() != BindingAvailability::Available + { + return Err(invalid_foreground_session()); + } + Ok(Self { + identity, + signer, + generation, + }) + } + + #[must_use] + pub const fn identity(&self) -> &AccountIdentity { + &self.identity + } + + #[must_use] + pub const fn signer(&self) -> LocalSignerBinding { + self.signer + } + + #[must_use] + pub const fn generation(&self) -> SessionGeneration { + self.generation + } +} + +const fn invalid_foreground_session() -> SafeError { + SafeError::new( + SafeErrorCode::InvalidApplicationState, + SafeMessage::new("The foreground session binding is invalid."), + ) +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct TaskCorrelation { + request_id: RequestId, + account: PublicKey, + binding: LocalSignerBinding, + expected_revision: SnapshotRevision, + session_generation: SessionGeneration, +} + +impl TaskCorrelation { + #[must_use] + pub const fn new( + request_id: RequestId, + account: PublicKey, + binding: LocalSignerBinding, + expected_revision: SnapshotRevision, + session_generation: SessionGeneration, + ) -> Self { + Self { + request_id, + account, + binding, + expected_revision, + session_generation, + } + } + + #[must_use] + pub const fn request_id(self) -> RequestId { + self.request_id + } + + #[must_use] + pub const fn account(self) -> PublicKey { + self.account + } + + #[must_use] + pub const fn binding(self) -> LocalSignerBinding { + self.binding + } + + #[must_use] + pub const fn expected_revision(self) -> SnapshotRevision { + self.expected_revision + } + + #[must_use] + pub const fn session_generation(self) -> SessionGeneration { + self.session_generation + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum RuntimeLifecycle { + Opening, + CompatibilityChecking, + AcquiringOwnership, + Migrating, + Recovering, + Ready, + Degraded(SafeError), + Blocked(SafeError), + ShuttingDown, + Closed, + Fatal(SafeError), +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum RuntimeCommandClass { + Observe, + MutateLocalState, + UseCredential, + UseRelay, + RetryOpening, + Shutdown, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct LifecycleGate { + lifecycle: RuntimeLifecycle, +} + +impl Default for LifecycleGate { + fn default() -> Self { + Self::opening() + } +} + +impl LifecycleGate { + #[must_use] + pub const fn opening() -> Self { + Self { + lifecycle: RuntimeLifecycle::Opening, + } + } + + #[must_use] + pub const fn lifecycle(self) -> RuntimeLifecycle { + self.lifecycle + } + + #[must_use] + pub const fn allows(self, command: RuntimeCommandClass) -> bool { + match self.lifecycle { + RuntimeLifecycle::Opening + | RuntimeLifecycle::CompatibilityChecking + | RuntimeLifecycle::AcquiringOwnership + | RuntimeLifecycle::Migrating + | RuntimeLifecycle::Recovering => { + matches!( + command, + RuntimeCommandClass::Observe | RuntimeCommandClass::Shutdown + ) + } + RuntimeLifecycle::Ready => !matches!(command, RuntimeCommandClass::RetryOpening), + RuntimeLifecycle::Degraded(_) => !matches!( + command, + RuntimeCommandClass::UseRelay | RuntimeCommandClass::RetryOpening + ), + RuntimeLifecycle::Blocked(_) => matches!( + command, + RuntimeCommandClass::Observe + | RuntimeCommandClass::RetryOpening + | RuntimeCommandClass::Shutdown + ), + RuntimeLifecycle::ShuttingDown => matches!(command, RuntimeCommandClass::Observe), + RuntimeLifecycle::Closed | RuntimeLifecycle::Fatal(_) => false, + } + } + + /// Advances the required open sequence to compatibility checking. + /// + /// # Errors + /// + /// Returns a safe lifecycle error when the stage is out of order. + pub fn begin_compatibility_check(&mut self) -> Result<(), SafeError> { + self.advance( + RuntimeLifecycle::Opening, + RuntimeLifecycle::CompatibilityChecking, + ) + } + + /// Records compatibility acceptance and begins ownership acquisition. + /// + /// # Errors + /// + /// Returns a safe lifecycle error when the stage is out of order. + pub fn compatibility_accepted(&mut self) -> Result<(), SafeError> { + self.advance( + RuntimeLifecycle::CompatibilityChecking, + RuntimeLifecycle::AcquiringOwnership, + ) + } + + /// Records exclusive ownership and begins migration. + /// + /// # Errors + /// + /// Returns a safe lifecycle error when the stage is out of order. + pub fn ownership_acquired(&mut self) -> Result<(), SafeError> { + self.advance( + RuntimeLifecycle::AcquiringOwnership, + RuntimeLifecycle::Migrating, + ) + } + + /// Records migration completion and begins recovery. + /// + /// # Errors + /// + /// Returns a safe lifecycle error when the stage is out of order. + pub fn migration_complete(&mut self) -> Result<(), SafeError> { + self.advance(RuntimeLifecycle::Migrating, RuntimeLifecycle::Recovering) + } + + /// Records recovery completion and admits normal commands. + /// + /// # Errors + /// + /// Returns a safe lifecycle error when the stage is out of order. + pub fn recovery_complete(&mut self) -> Result<(), SafeError> { + self.advance(RuntimeLifecycle::Recovering, RuntimeLifecycle::Ready) + } + + pub fn block(&mut self, error: SafeError) { + self.lifecycle = RuntimeLifecycle::Blocked(error); + } + + pub fn fail(&mut self, error: SafeError) { + self.lifecycle = RuntimeLifecycle::Fatal(error); + } + + /// Moves a ready runtime into a nonfatal degraded state. + /// + /// # Errors + /// + /// Returns a safe lifecycle error when the runtime is not ready. + pub fn degrade(&mut self, error: SafeError) -> Result<(), SafeError> { + self.advance(RuntimeLifecycle::Ready, RuntimeLifecycle::Degraded(error)) + } + + /// Restores local and relay command availability after degradation. + /// + /// # Errors + /// + /// Returns a safe lifecycle error when the runtime is not degraded. + pub fn restore_ready(&mut self) -> Result<(), SafeError> { + if !matches!(self.lifecycle, RuntimeLifecycle::Degraded(_)) { + return Err(invalid_lifecycle_transition()); + } + self.lifecycle = RuntimeLifecycle::Ready; + Ok(()) + } + + /// Begins actor-owned shutdown. + /// + /// # Errors + /// + /// Returns a safe lifecycle error after shutdown or close has begun. + pub fn begin_shutdown(&mut self) -> Result<(), SafeError> { + if matches!( + self.lifecycle, + RuntimeLifecycle::ShuttingDown | RuntimeLifecycle::Closed + ) { + return Err(invalid_lifecycle_transition()); + } + self.lifecycle = RuntimeLifecycle::ShuttingDown; + Ok(()) + } + + /// Completes actor-owned shutdown. + /// + /// # Errors + /// + /// Returns a safe lifecycle error unless shutdown already began. + pub fn finish_shutdown(&mut self) -> Result<(), SafeError> { + self.advance(RuntimeLifecycle::ShuttingDown, RuntimeLifecycle::Closed) + } + + fn advance( + &mut self, + expected: RuntimeLifecycle, + next: RuntimeLifecycle, + ) -> Result<(), SafeError> { + if self.lifecycle != expected { + return Err(invalid_lifecycle_transition()); + } + self.lifecycle = next; + Ok(()) + } +} + +const fn invalid_lifecycle_transition() -> SafeError { + SafeError::new( + radroots_studio_domain::SafeErrorCode::InvalidApplicationState, + radroots_studio_domain::SafeMessage::new("The runtime lifecycle transition is invalid."), + ) +} + +#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] +pub struct RequestId(NonZeroU64); + +impl RequestId { + #[must_use] + pub const fn new(value: u64) -> Option<Self> { + match NonZeroU64::new(value) { + Some(value) => Some(Self(value)), + None => None, + } + } + + #[must_use] + pub const fn get(self) -> u64 { + self.0.get() + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct CommandContext { + request_id: RequestId, + expected_revision: Option<SnapshotRevision>, + deadline: Instant, +} + +impl CommandContext { + #[must_use] + pub const fn new( + request_id: RequestId, + expected_revision: Option<SnapshotRevision>, + deadline: Instant, + ) -> Self { + Self { + request_id, + expected_revision, + deadline, + } + } + + #[must_use] + pub const fn request_id(self) -> RequestId { + self.request_id + } + + #[must_use] + pub const fn expected_revision(self) -> Option<SnapshotRevision> { + self.expected_revision + } + + #[must_use] + pub const fn deadline(self) -> Instant { + self.deadline + } + + #[must_use] + pub fn is_expired(self, now: Instant) -> bool { + now >= self.deadline + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum CommandRejection { + MailboxSaturated, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum CommandResult<T> { + Completed(T), + Rejected(CommandRejection), + Conflicted { current_revision: SnapshotRevision }, + TimedOut, + Closed, + Failed(SafeError), +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct CommandReceipt<T> { + request_id: RequestId, + result: CommandResult<T>, +} + +impl<T> CommandReceipt<T> { + #[must_use] + pub const fn new(request_id: RequestId, result: CommandResult<T>) -> Self { + Self { request_id, result } + } + + #[must_use] + pub const fn request_id(&self) -> RequestId { + self.request_id + } + + #[must_use] + pub const fn result(&self) -> &CommandResult<T> { + &self.result + } + + #[must_use] + pub fn into_result(self) -> CommandResult<T> { + self.result + } +} + +pub struct CommandTicket<T> { + request_id: RequestId, + receiver: oneshot::Receiver<CommandReceipt<T>>, +} + +impl<T> CommandTicket<T> { + #[must_use] + pub const fn request_id(&self) -> RequestId { + self.request_id + } + + pub async fn receipt(self) -> CommandReceipt<T> { + self.receiver + .await + .unwrap_or_else(|_| CommandReceipt::new(self.request_id, CommandResult::Closed)) + } +} + +pub enum CommandSubmission<T> { + Accepted(CommandTicket<T>), + Rejected(CommandReceipt<T>), +} + +impl<T> CommandSubmission<T> { + #[must_use] + pub const fn request_id(&self) -> RequestId { + match self { + Self::Accepted(ticket) => ticket.request_id(), + Self::Rejected(receipt) => receipt.request_id(), + } + } +} + +pub struct CommandEnvelope<C, R> { + context: CommandContext, + command: C, + reply: oneshot::Sender<CommandReceipt<R>>, +} + +impl<C, R> CommandEnvelope<C, R> { + #[must_use] + pub const fn context(&self) -> CommandContext { + self.context + } + + #[must_use] + pub const fn command(&self) -> &C { + &self.command + } + + #[must_use] + pub fn into_parts(self) -> (CommandContext, C, oneshot::Sender<CommandReceipt<R>>) { + (self.context, self.command, self.reply) + } +} + +pub struct ActorMailbox<C, R> { + sender: mpsc::Sender<CommandEnvelope<C, R>>, +} + +impl<C, R> Clone for ActorMailbox<C, R> { + fn clone(&self) -> Self { + Self { + sender: self.sender.clone(), + } + } +} + +impl<C, R> ActorMailbox<C, R> { + #[must_use] + pub fn bounded(capacity: NonZeroUsize) -> (Self, mpsc::Receiver<CommandEnvelope<C, R>>) { + let (sender, receiver) = mpsc::channel(capacity.get()); + (Self { sender }, receiver) + } + + #[must_use] + pub fn available_capacity(&self) -> usize { + self.sender.capacity() + } + + #[must_use] + pub fn submit(&self, context: CommandContext, command: C) -> CommandSubmission<R> { + let request_id = context.request_id(); + if context.is_expired(Instant::now()) { + return CommandSubmission::Rejected(CommandReceipt::new( + request_id, + CommandResult::TimedOut, + )); + } + let (reply, receiver) = oneshot::channel(); + let envelope = CommandEnvelope { + context, + command, + reply, + }; + match self.sender.try_send(envelope) { + Ok(()) => CommandSubmission::Accepted(CommandTicket { + request_id, + receiver, + }), + Err(mpsc::error::TrySendError::Full(_)) => { + CommandSubmission::Rejected(CommandReceipt::new( + request_id, + CommandResult::Rejected(CommandRejection::MailboxSaturated), + )) + } + Err(mpsc::error::TrySendError::Closed(_)) => { + CommandSubmission::Rejected(CommandReceipt::new(request_id, CommandResult::Closed)) + } + } + } +} + +#[cfg(test)] +mod tests { + use std::num::NonZeroUsize; + use std::time::{Duration, Instant}; + + use radroots_studio_domain::{ + AccountIdentity, BindingAvailability, LocalSignerBinding, PublicKey, + }; + + use crate::{ + ActorMailbox, CommandContext, CommandReceipt, CommandRejection, CommandResult, + CommandSubmission, ForegroundSessionBinding, LifecycleGate, RequestId, RuntimeCommandClass, + RuntimeLifecycle, SessionGeneration, + }; + + fn context(id: u64) -> CommandContext { + CommandContext::new( + RequestId::new(id).expect("nonzero request"), + None, + Instant::now() + Duration::from_secs(1), + ) + } + + #[test] + fn foreground_session_requires_matching_available_binding_and_generation() { + let public_key = PublicKey::from_bytes([3_u8; 32]); + let identity = AccountIdentity::derive(public_key).expect("identity"); + let generation = SessionGeneration::from_value(4); + let session = ForegroundSessionBinding::new( + identity.clone(), + LocalSignerBinding::new(public_key, BindingAvailability::Available), + generation, + ) + .expect("session"); + assert_eq!(session.identity(), &identity); + assert_eq!(session.signer().account(), public_key); + assert_eq!(session.generation(), generation); + + let correlation = super::TaskCorrelation::new( + RequestId::new(8).expect("request"), + public_key, + session.signer(), + crate::SnapshotRevision::from_value(9), + generation, + ); + assert_eq!(correlation.request_id().get(), 8); + assert_eq!(correlation.account(), public_key); + assert_eq!(correlation.binding(), session.signer()); + assert_eq!(correlation.expected_revision().value(), 9); + assert_eq!(correlation.session_generation(), generation); + + assert!( + ForegroundSessionBinding::new( + identity.clone(), + LocalSignerBinding::new( + PublicKey::from_bytes([4_u8; 32]), + BindingAvailability::Available, + ), + generation, + ) + .is_err() + ); + assert!( + ForegroundSessionBinding::new( + identity, + LocalSignerBinding::new(public_key, BindingAvailability::CredentialMissing), + generation, + ) + .is_err() + ); + } + + #[tokio::test] + async fn bounded_mailbox_accepts_one_and_rejects_saturation() { + let (mailbox, mut receiver) = + ActorMailbox::<u8, u8>::bounded(NonZeroUsize::new(1).expect("capacity")); + let CommandSubmission::Accepted(ticket) = mailbox.submit(context(1), 7) else { + panic!("first command must be accepted"); + }; + let CommandSubmission::Rejected(rejected) = mailbox.submit(context(2), 8) else { + panic!("second command must be rejected"); + }; + assert_eq!( + rejected.into_result(), + CommandResult::Rejected(CommandRejection::MailboxSaturated) + ); + + let envelope = receiver.recv().await.expect("command"); + assert_eq!(envelope.context().request_id().get(), 1); + assert_eq!(*envelope.command(), 7); + let (context, command, reply) = envelope.into_parts(); + reply + .send(CommandReceipt::new( + context.request_id(), + CommandResult::Completed(command + 1), + )) + .expect("ticket remains open"); + assert_eq!( + ticket.receipt().await.into_result(), + CommandResult::Completed(8) + ); + } + + #[test] + fn expired_and_closed_mailboxes_reject_without_enqueuing() { + let (mailbox, receiver) = + ActorMailbox::<u8, u8>::bounded(NonZeroUsize::new(1).expect("capacity")); + let expired = + CommandContext::new(RequestId::new(1).expect("request"), None, Instant::now()); + let CommandSubmission::Rejected(receipt) = mailbox.submit(expired, 1) else { + panic!("expired command must be rejected"); + }; + assert_eq!(receipt.into_result(), CommandResult::TimedOut); + + drop(receiver); + let CommandSubmission::Rejected(receipt) = mailbox.submit(context(2), 2) else { + panic!("closed mailbox must be rejected"); + }; + assert_eq!(receipt.into_result(), CommandResult::Closed); + } + + #[tokio::test] + async fn dropped_actor_reply_becomes_closed_receipt() { + let (mailbox, mut receiver) = + ActorMailbox::<u8, u8>::bounded(NonZeroUsize::new(1).expect("capacity")); + let CommandSubmission::Accepted(ticket) = mailbox.submit(context(1), 1) else { + panic!("command must be accepted"); + }; + drop(receiver.recv().await.expect("command")); + + assert_eq!(ticket.receipt().await.into_result(), CommandResult::Closed); + } + + #[test] + fn opening_sequence_gates_mutation_until_recovery_completes() { + let mut lifecycle = LifecycleGate::opening(); + for expected in [ + RuntimeLifecycle::Opening, + RuntimeLifecycle::CompatibilityChecking, + RuntimeLifecycle::AcquiringOwnership, + RuntimeLifecycle::Migrating, + RuntimeLifecycle::Recovering, + ] { + assert_eq!(lifecycle.lifecycle(), expected); + assert!(lifecycle.allows(RuntimeCommandClass::Observe)); + assert!(lifecycle.allows(RuntimeCommandClass::Shutdown)); + assert!(!lifecycle.allows(RuntimeCommandClass::MutateLocalState)); + match expected { + RuntimeLifecycle::Opening => { + lifecycle + .begin_compatibility_check() + .expect("compatibility"); + } + RuntimeLifecycle::CompatibilityChecking => { + lifecycle + .compatibility_accepted() + .expect("compatibility accepted"); + } + RuntimeLifecycle::AcquiringOwnership => { + lifecycle.ownership_acquired().expect("ownership"); + } + RuntimeLifecycle::Migrating => { + lifecycle.migration_complete().expect("migration"); + } + RuntimeLifecycle::Recovering => { + lifecycle.recovery_complete().expect("recovery"); + } + _ => unreachable!("opening states only"), + } + } + assert_eq!(lifecycle.lifecycle(), RuntimeLifecycle::Ready); + assert!(lifecycle.allows(RuntimeCommandClass::MutateLocalState)); + assert!(lifecycle.allows(RuntimeCommandClass::UseCredential)); + assert!(lifecycle.allows(RuntimeCommandClass::UseRelay)); + } + + #[test] + fn blocked_degraded_fatal_and_closed_states_fail_safe() { + let problem = radroots_studio_domain::SafeError::new( + radroots_studio_domain::SafeErrorCode::StorageUnavailable, + radroots_studio_domain::SafeMessage::new("The runtime is unavailable."), + ); + let mut blocked = LifecycleGate::opening(); + blocked.block(problem); + assert!(blocked.allows(RuntimeCommandClass::RetryOpening)); + assert!(!blocked.allows(RuntimeCommandClass::MutateLocalState)); + + let mut degraded = LifecycleGate::opening(); + degraded.begin_compatibility_check().expect("compatibility"); + degraded.compatibility_accepted().expect("accepted"); + degraded.ownership_acquired().expect("ownership"); + degraded.migration_complete().expect("migration"); + degraded.recovery_complete().expect("recovery"); + degraded.degrade(problem).expect("degraded"); + assert!(degraded.allows(RuntimeCommandClass::MutateLocalState)); + assert!(!degraded.allows(RuntimeCommandClass::UseRelay)); + degraded.restore_ready().expect("restored"); + + let mut fatal = LifecycleGate::opening(); + fatal.fail(problem); + assert!(!fatal.allows(RuntimeCommandClass::Observe)); + fatal.begin_shutdown().expect("fatal can close"); + fatal.finish_shutdown().expect("closed"); + assert_eq!(fatal.lifecycle(), RuntimeLifecycle::Closed); + assert!(!fatal.allows(RuntimeCommandClass::Shutdown)); + } + + #[test] + fn opening_stages_reject_out_of_order_and_repeated_transitions() { + let mut lifecycle = LifecycleGate::opening(); + assert!(lifecycle.migration_complete().is_err()); + lifecycle.begin_compatibility_check().expect("first stage"); + assert!(lifecycle.begin_compatibility_check().is_err()); + assert!(lifecycle.recovery_complete().is_err()); + } +} diff --git a/crates/studio_application/src/app_core.rs b/crates/studio_application/src/app_core.rs @@ -0,0 +1,300 @@ +use std::collections::BTreeMap; +use std::sync::{Mutex, MutexGuard}; + +use radroots_studio_domain::{PublicKey, SafeError, SafeErrorCode, SafeMessage, UnixTimestamp}; + +use crate::{ + AccountRepository, AppSnapshot, AppStateRepository, RelayConfiguration, SnapshotRevision, + StateMachine, StateTransition, +}; + +pub struct RemovalConfirmationToken { + id: u64, + public_key: PublicKey, + revision: SnapshotRevision, + expires_at: UnixTimestamp, + impact: RemovalImpact, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct RemovalImpact { + deletes_local_credential: bool, + signs_out: bool, +} + +impl RemovalImpact { + #[must_use] + pub const fn deletes_local_credential(self) -> bool { + self.deletes_local_credential + } + #[must_use] + pub const fn signs_out(self) -> bool { + self.signs_out + } +} + +impl RemovalConfirmationToken { + #[must_use] + pub const fn public_key(&self) -> PublicKey { + self.public_key + } + #[must_use] + pub const fn revision(&self) -> SnapshotRevision { + self.revision + } + #[must_use] + pub const fn expires_at(&self) -> UnixTimestamp { + self.expires_at + } + #[must_use] + pub const fn impact(&self) -> RemovalImpact { + self.impact + } +} + +#[derive(Clone, Copy)] +struct RemovalTokenState { + public_key: PublicKey, + revision: SnapshotRevision, + expires_at: UnixTimestamp, + impact: RemovalImpact, +} + +struct CoreState { + state_machine: StateMachine, + removal_tokens: BTreeMap<u64, RemovalTokenState>, + next_removal_token: u64, +} + +pub struct AppCore { + relay_configuration: RelayConfiguration, + state: Mutex<CoreState>, +} + +impl AppCore { + #[must_use] + pub fn in_memory(relay_configuration: RelayConfiguration) -> Self { + Self { + relay_configuration, + state: Mutex::new(CoreState { + state_machine: StateMachine::booting(), + removal_tokens: BTreeMap::new(), + next_removal_token: 1, + }), + } + } + + /// Moves the in-memory core from booting to an empty ready snapshot. + /// + /// # Errors + /// + /// Returns a safe application-state error if the ready snapshot invariant + /// cannot be constructed. + pub fn bootstrap(&self) -> Result<AppSnapshot, SafeError> { + self.apply_transition(StateTransition::Bootstrap) + } + + /// Loads the durable public registry and selection into a signed-out snapshot. + /// + /// # Errors + /// + /// Returns the safe persistence error after publishing a fatal snapshot when + /// durable state cannot be read or violates application invariants. + pub fn bootstrap_from( + &self, + accounts: &(impl AccountRepository + ?Sized), + app_state: &(impl AppStateRepository + ?Sized), + ) -> Result<AppSnapshot, SafeError> { + let loaded = accounts.list_accounts().and_then(|accounts| { + app_state + .load_selected_account() + .map(|selected| (accounts, selected)) + }); + match loaded { + Ok((accounts, selected)) => { + self.apply_transition(StateTransition::BootstrapRegistry { accounts, selected }) + } + Err(error) => { + self.apply_transition(StateTransition::Fatal(error))?; + Err(error) + } + } + } + + #[must_use] + pub fn snapshot(&self) -> AppSnapshot { + self.lock_state().state_machine.snapshot().clone() + } + + pub(crate) fn apply_transition( + &self, + transition: StateTransition, + ) -> Result<AppSnapshot, SafeError> { + self.lock_state() + .state_machine + .apply(transition, &self.relay_configuration) + } + + pub(crate) fn issue_removal_token( + &self, + public_key: PublicKey, + now: UnixTimestamp, + ) -> Result<RemovalConfirmationToken, SafeError> { + let mut state = self.lock_state(); + let Some(account) = state + .state_machine + .snapshot() + .accounts() + .iter() + .find(|account| account.public_key() == public_key) + else { + return Err(account_not_found()); + }; + let deletes_local_credential = account.signer().availability() + != radroots_studio_domain::BindingAvailability::CredentialMissing; + let id = state.next_removal_token; + state.next_removal_token = id.checked_add(1).ok_or_else(invalid_application_state)?; + let revision = state.state_machine.snapshot().revision(); + let expires_at = UnixTimestamp::from_seconds( + now.as_seconds() + .checked_add(300) + .ok_or_else(invalid_application_state)?, + ) + .ok_or_else(invalid_application_state)?; + let impact = RemovalImpact { + deletes_local_credential, + signs_out: state + .state_machine + .snapshot() + .active_account() + .is_some_and(|active| active.account().public_key() == public_key), + }; + state.removal_tokens.insert( + id, + RemovalTokenState { + public_key, + revision, + expires_at, + impact, + }, + ); + Ok(RemovalConfirmationToken { + id, + public_key, + revision, + expires_at, + impact, + }) + } + + #[allow(clippy::needless_pass_by_value)] + pub(crate) fn consume_removal_token( + &self, + token: RemovalConfirmationToken, + now: UnixTimestamp, + ) -> Result<PublicKey, SafeError> { + let RemovalConfirmationToken { + id, + public_key, + revision, + expires_at, + impact, + } = token; + let mut state = self.lock_state(); + let stored = state.removal_tokens.remove(&id); + if stored.is_none_or(|stored| { + stored.public_key != public_key + || stored.revision != revision + || stored.expires_at != expires_at + || stored.impact != impact + }) || state.state_machine.snapshot().revision() != revision + || now.as_seconds() > expires_at.as_seconds() + { + return Err(invalid_application_state()); + } + Ok(public_key) + } + + #[allow(clippy::needless_pass_by_value)] + pub(crate) fn cancel_removal_token(&self, token: RemovalConfirmationToken) -> bool { + self.lock_state().removal_tokens.remove(&token.id).is_some() + } + + fn lock_state(&self) -> MutexGuard<'_, CoreState> { + self.state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + } +} + +const fn invalid_application_state() -> SafeError { + SafeError::new( + SafeErrorCode::InvalidApplicationState, + SafeMessage::new("The account removal confirmation is no longer valid."), + ) +} + +const fn account_not_found() -> SafeError { + SafeError::new( + SafeErrorCode::AccountNotFound, + SafeMessage::new("The account was not found."), + ) +} + +#[cfg(test)] +mod tests { + use radroots_studio_domain::{ + AccountCreatedAt, AccountIdentity, AccountSummary, BindingAvailability, LocalSignerBinding, + PublicKey, UnixTimestamp, + }; + + use crate::{AppCore, AppLifecycle, RelayConfiguration, StateTransition}; + + #[test] + fn bootstrap_is_idempotent_and_advances_only_once() { + let core = AppCore::in_memory(RelayConfiguration::default()); + let ready = core.bootstrap().expect("bootstrap"); + let repeated = core.bootstrap().expect("idempotent bootstrap"); + + assert_eq!(ready.lifecycle(), AppLifecycle::Ready); + assert_eq!(ready.revision().value(), 1); + assert_eq!(repeated, ready); + } + + #[test] + fn core_instances_never_share_state() { + let first = AppCore::in_memory(RelayConfiguration::default()); + let second = AppCore::in_memory(RelayConfiguration::default()); + + first.bootstrap().expect("first bootstrap"); + + assert_eq!(first.snapshot().revision().value(), 1); + assert_eq!(second.snapshot().revision().value(), 0); + } + + #[test] + fn removal_impact_matches_missing_local_binding() { + let core = AppCore::in_memory(RelayConfiguration::default()); + let public_key = PublicKey::from_bytes([9; 32]); + let account = AccountSummary::new( + AccountIdentity::derive(public_key).expect("identity"), + LocalSignerBinding::new(public_key, BindingAvailability::CredentialMissing), + None, + AccountCreatedAt::new(UnixTimestamp::from_seconds(1).expect("time")), + None, + ) + .expect("account"); + core.apply_transition(StateTransition::BootstrapRegistry { + accounts: vec![account], + selected: Some(public_key), + }) + .expect("registry"); + + let removal = core + .issue_removal_token(public_key, UnixTimestamp::from_seconds(2).expect("time")) + .expect("removal"); + + assert!(!removal.impact().deletes_local_credential()); + assert!(!removal.impact().signs_out()); + } +} diff --git a/crates/studio_application/src/change_stream.rs b/crates/studio_application/src/change_stream.rs @@ -0,0 +1,239 @@ +use std::collections::BTreeMap; +use std::num::{NonZeroU64, NonZeroUsize}; + +use tokio::sync::mpsc; + +use crate::{AppSnapshot, SnapshotRevision}; + +#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)] +pub struct ChangeSubscriptionId(NonZeroU64); + +impl ChangeSubscriptionId { + #[must_use] + pub const fn value(self) -> u64 { + self.0.get() + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct SnapshotChange { + snapshot: AppSnapshot, + previous_revision: Option<SnapshotRevision>, +} + +impl SnapshotChange { + #[must_use] + pub const fn revision(&self) -> SnapshotRevision { + self.snapshot.revision() + } + + #[must_use] + pub const fn snapshot(&self) -> &AppSnapshot { + &self.snapshot + } + + #[must_use] + pub fn into_snapshot(self) -> AppSnapshot { + self.snapshot + } + + #[must_use] + pub const fn previous_revision(&self) -> Option<SnapshotRevision> { + self.previous_revision + } + + #[must_use] + pub fn recovers_gap_after(&self, observed: SnapshotRevision) -> bool { + self.previous_revision + .is_some_and(|previous| previous != observed) + } +} + +pub struct SnapshotChangeReceiver { + receiver: mpsc::Receiver<SnapshotChange>, +} + +impl SnapshotChangeReceiver { + pub async fn receive(&mut self) -> Option<SnapshotChange> { + self.receiver.recv().await + } +} + +pub struct OrderedSnapshotChanges { + latest: AppSnapshot, + next_subscription: u64, + subscribers: BTreeMap<ChangeSubscriptionId, mpsc::Sender<SnapshotChange>>, + closed: bool, +} + +impl OrderedSnapshotChanges { + #[must_use] + pub fn new(initial_snapshot: AppSnapshot) -> Self { + Self { + latest: initial_snapshot, + next_subscription: 1, + subscribers: BTreeMap::new(), + closed: false, + } + } + + #[must_use] + pub const fn last_revision(&self) -> SnapshotRevision { + self.latest.revision() + } + + /// Registers a bounded consumer for future changes. + /// + /// # Errors + /// + /// Returns `None` if the subscription identifier space is exhausted. + pub fn subscribe( + &mut self, + capacity: NonZeroUsize, + ) -> Option<(ChangeSubscriptionId, SnapshotChangeReceiver)> { + if self.closed { + return None; + } + let id = ChangeSubscriptionId(NonZeroU64::new(self.next_subscription)?); + self.next_subscription = self.next_subscription.checked_add(1)?; + let (sender, receiver) = mpsc::channel(capacity.get()); + sender + .try_send(SnapshotChange { + snapshot: self.latest.clone(), + previous_revision: None, + }) + .ok()?; + self.subscribers.insert(id, sender); + Some((id, SnapshotChangeReceiver { receiver })) + } + + #[must_use] + pub fn unsubscribe(&mut self, id: ChangeSubscriptionId) -> bool { + self.subscribers.remove(&id).is_some() + } + + pub fn publish(&mut self, snapshot: AppSnapshot) { + if self.closed || snapshot.revision() <= self.latest.revision() { + return; + } + let change = SnapshotChange { + previous_revision: Some(self.latest.revision()), + snapshot, + }; + self.latest = change.snapshot.clone(); + self.subscribers + .retain(|_, sender| match sender.try_send(change.clone()) { + Ok(()) | Err(mpsc::error::TrySendError::Full(_)) => true, + Err(mpsc::error::TrySendError::Closed(_)) => false, + }); + } + + pub fn close(&mut self) { + self.closed = true; + self.subscribers.clear(); + } +} + +#[cfg(test)] +mod tests { + use std::num::NonZeroUsize; + + use crate::{ + AppSnapshot, OrderedSnapshotChanges, RelayConfiguration, SessionState, SnapshotRevision, + }; + + #[tokio::test] + async fn change_stream_publishes_monotonic_revisions_to_multiple_consumers() { + let mut changes = OrderedSnapshotChanges::new(snapshot(0)); + let (_, mut first) = changes + .subscribe(NonZeroUsize::new(4).expect("capacity")) + .expect("first subscription"); + let (_, mut second) = changes + .subscribe(NonZeroUsize::new(4).expect("capacity")) + .expect("second subscription"); + + assert_eq!( + first.receive().await.expect("initial").revision(), + revision(0) + ); + assert_eq!( + second.receive().await.expect("initial").revision(), + revision(0) + ); + changes.publish(snapshot(1)); + changes.publish(snapshot(1)); + changes.publish(snapshot(2)); + + for receiver in [&mut first, &mut second] { + assert_eq!( + receiver.receive().await.expect("revision 1").revision(), + revision(1) + ); + assert_eq!( + receiver.receive().await.expect("revision 2").revision(), + revision(2) + ); + } + assert_eq!(changes.last_revision(), revision(2)); + } + + #[tokio::test] + async fn slow_consumers_expose_a_revision_gap_without_blocking_publication() { + let mut changes = OrderedSnapshotChanges::new(snapshot(0)); + let (_, mut receiver) = changes + .subscribe(NonZeroUsize::new(1).expect("capacity")) + .expect("subscription"); + + assert_eq!( + receiver.receive().await.expect("initial").revision(), + revision(0) + ); + changes.publish(snapshot(1)); + changes.publish(snapshot(2)); + let first = receiver.receive().await.expect("first"); + assert_eq!(first.revision(), revision(1)); + changes.publish(snapshot(3)); + let recovered = receiver.receive().await.expect("gap recovery"); + assert_eq!(recovered.revision(), revision(3)); + assert!(recovered.recovers_gap_after(first.revision())); + } + + #[tokio::test] + async fn close_terminates_consumers_and_rejects_later_subscriptions() { + let mut changes = OrderedSnapshotChanges::new(snapshot(0)); + let (_, mut receiver) = changes + .subscribe(NonZeroUsize::new(1).expect("capacity")) + .expect("subscription"); + receiver.receive().await.expect("initial"); + + changes.close(); + changes.publish(snapshot(1)); + assert!(receiver.receive().await.is_none()); + assert!( + changes + .subscribe(NonZeroUsize::new(1).expect("capacity")) + .is_none() + ); + } + + fn revision(value: u64) -> SnapshotRevision { + SnapshotRevision::from_value(value) + } + + fn snapshot(value: u64) -> AppSnapshot { + if value == 0 { + AppSnapshot::booting() + } else { + AppSnapshot::ready( + revision(value), + RelayConfiguration::default(), + Vec::new(), + None, + SessionState::SignedOut, + None, + None, + ) + .expect("snapshot") + } + } +} diff --git a/crates/studio_application/src/config.rs b/crates/studio_application/src/config.rs @@ -0,0 +1,105 @@ +use radroots_studio_domain::{SafeError, SafeErrorCode, SafeMessage, normalize_relay_urls}; + +use crate::RelayConfiguration; + +pub const RELAY_ENVIRONMENT_VARIABLE: &str = "RADROOTS_NOSTR_RELAYS"; +const DEVELOPMENT_RELAY: &str = "ws://localhost:8080"; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum RelayRuntimeMode { + Development, + Packaged, +} + +/// Reads the process relay configuration once through the Rust-owned boundary. +/// +/// # Errors +/// +/// Returns a safe configuration error for missing Unicode or invalid relay data. +pub fn relay_configuration_from_environment( + mode: RelayRuntimeMode, +) -> Result<RelayConfiguration, SafeError> { + let value = match std::env::var(RELAY_ENVIRONMENT_VARIABLE) { + Ok(value) => Some(value), + Err(std::env::VarError::NotPresent) => None, + Err(std::env::VarError::NotUnicode(_)) => return Err(invalid_configuration()), + }; + relay_configuration_from_value(value.as_deref(), mode) +} + +/// Parses an injected comma-separated relay list without mutating process state. +/// +/// # Errors +/// +/// Returns a safe configuration error when an entry is invalid or packaged mode +/// has no configured relay. +pub fn relay_configuration_from_value( + value: Option<&str>, + mode: RelayRuntimeMode, +) -> Result<RelayConfiguration, SafeError> { + let configured = value.unwrap_or_default().trim(); + let source = if configured.is_empty() { + match mode { + RelayRuntimeMode::Development => DEVELOPMENT_RELAY, + RelayRuntimeMode::Packaged => return Err(invalid_configuration()), + } + } else { + configured + }; + let normalized = normalize_relay_urls(source.split(',').map(str::trim))?; + if normalized.is_empty() { + return Err(invalid_configuration()); + } + Ok(RelayConfiguration::new(normalized)) +} + +const fn invalid_configuration() -> SafeError { + SafeError::new( + SafeErrorCode::InvalidRelayConfiguration, + SafeMessage::new("The Nostr relay configuration is invalid."), + ) +} + +#[cfg(test)] +mod tests { + use radroots_studio_domain::SafeErrorCode; + + use super::{RelayRuntimeMode, relay_configuration_from_value}; + + #[test] + fn relay_config_uses_localhost_fallback_only_for_development() { + for value in [None, Some(""), Some(" ")] { + let development = relay_configuration_from_value(value, RelayRuntimeMode::Development) + .expect("development fallback"); + assert_eq!(development.relays()[0].as_str(), "ws://localhost:8080/"); + let packaged = relay_configuration_from_value(value, RelayRuntimeMode::Packaged) + .expect_err("packaged configuration required"); + assert_eq!(packaged.code(), SafeErrorCode::InvalidRelayConfiguration); + } + } + + #[test] + fn relay_config_trims_deduplicates_and_preserves_order() { + let configuration = relay_configuration_from_value( + Some(" wss://relay.one ,wss://relay.two,wss://relay.one/ "), + RelayRuntimeMode::Packaged, + ) + .expect("configuration"); + let relays = configuration + .relays() + .iter() + .map(radroots_studio_domain::RelayUrl::as_str) + .collect::<Vec<_>>(); + assert_eq!(relays, ["wss://relay.one/", "wss://relay.two/"]); + } + + #[test] + fn relay_config_rejects_any_invalid_comma_separated_entry() { + let error = relay_configuration_from_value( + Some("wss://relay.one,https://not-a-relay.test"), + RelayRuntimeMode::Packaged, + ) + .expect_err("invalid entry"); + assert_eq!(error.code(), SafeErrorCode::InvalidRelayConfiguration); + } +} diff --git a/crates/studio_application/src/custody.rs b/crates/studio_application/src/custody.rs @@ -0,0 +1,279 @@ +use std::num::NonZeroU64; +use std::sync::Mutex; +use std::time::Duration; + +use radroots_studio_domain::{ + AccountCreatedAt, AccountIdentity, AccountSummary, BindingAvailability, LocalSignerBinding, + Nsec, SafeError, SafeErrorCode, SafeMessage, SecretKeyInput, UnixTimestamp, +}; +use radroots_studio_nostr::generate_local_keypair; + +pub const GENERATED_KEY_STAGE_TTL: Duration = Duration::from_mins(5); + +#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] +pub struct RecoveryStageId(NonZeroU64); + +impl RecoveryStageId { + #[must_use] + pub const fn new(value: NonZeroU64) -> Self { + Self(value) + } + + #[must_use] + pub const fn value(self) -> u64 { + self.0.get() + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct GeneratedKeyStageView { + account: AccountSummary, + expires_at: UnixTimestamp, +} + +impl GeneratedKeyStageView { + #[must_use] + pub const fn account(&self) -> &AccountSummary { + &self.account + } + + #[must_use] + pub const fn expires_at(&self) -> UnixTimestamp { + self.expires_at + } +} + +pub struct StagedGeneratedKey { + id: RecoveryStageId, + account: AccountSummary, + secret: SecretKeyInput, + expected_revision: u64, + expires_at: UnixTimestamp, +} + +impl StagedGeneratedKey { + #[must_use] + pub fn view(&self) -> GeneratedKeyStageView { + GeneratedKeyStageView { + account: self.account.clone(), + expires_at: self.expires_at, + } + } + + #[must_use] + pub const fn expected_revision(&self) -> u64 { + self.expected_revision + } + + #[must_use] + pub const fn id(&self) -> RecoveryStageId { + self.id + } + + #[must_use] + pub const fn account(&self) -> &AccountSummary { + &self.account + } + + #[must_use] + pub fn into_commit_parts(self) -> (AccountSummary, SecretKeyInput) { + (self.account, self.secret) + } +} + +pub struct GeneratedKeyRecoveryHandle { + id: RecoveryStageId, + view: GeneratedKeyStageView, + recovery_nsec: Mutex<Option<Nsec>>, +} + +impl GeneratedKeyRecoveryHandle { + fn new(id: RecoveryStageId, view: GeneratedKeyStageView, recovery_nsec: Nsec) -> Self { + Self { + id, + view, + recovery_nsec: Mutex::new(Some(recovery_nsec)), + } + } + + #[must_use] + pub const fn id(&self) -> RecoveryStageId { + self.id + } + + #[must_use] + pub const fn view(&self) -> &GeneratedKeyStageView { + &self.view + } + + /// Returns the generated recovery value exactly once. + /// + /// # Errors + /// + /// Returns a safe unavailable error after the value was already consumed. + pub fn take_recovery_nsec(&self) -> Result<Nsec, SafeError> { + self.recovery_nsec + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .take() + .ok_or_else(recovery_not_available) + } +} + +#[derive(Default)] +pub struct GeneratedKeyStage { + pending: Option<StagedGeneratedKey>, +} + +impl GeneratedKeyStage { + /// Replaces an expired stage or creates the only active generated-key stage. + /// + /// # Errors + /// + /// Returns a safe conflict while an unexpired recovery stage is active. + pub fn begin( + &mut self, + id: RecoveryStageId, + expected_revision: u64, + now: UnixTimestamp, + ) -> Result<GeneratedKeyRecoveryHandle, SafeError> { + self.expire(now); + if self.pending.is_some() { + return Err(recovery_in_progress()); + } + let generated = generate_local_keypair()?; + let (public_key, npub, secret, recovery_nsec) = generated.into_parts(); + let account = AccountSummary::new( + AccountIdentity::verify(public_key, npub.as_str().to_owned())?, + LocalSignerBinding::new(public_key, BindingAvailability::Available), + None, + AccountCreatedAt::new(now), + None, + )?; + let ttl = + i64::try_from(GENERATED_KEY_STAGE_TTL.as_secs()).map_err(|_| invalid_stage_expiry())?; + let expires_at = now + .as_seconds() + .checked_add(ttl) + .and_then(UnixTimestamp::from_seconds) + .ok_or_else(invalid_stage_expiry)?; + let pending = StagedGeneratedKey { + id, + account, + secret, + expected_revision, + expires_at, + }; + let view = pending.view(); + self.pending = Some(pending); + Ok(GeneratedKeyRecoveryHandle::new(id, view, recovery_nsec)) + } + + pub fn cancel(&mut self) -> bool { + self.pending.take().is_some() + } + + pub fn expire(&mut self, now: UnixTimestamp) -> bool { + if self + .pending + .as_ref() + .is_some_and(|pending| now >= pending.expires_at) + { + self.pending = None; + true + } else { + false + } + } + + #[must_use] + pub const fn pending(&self) -> Option<&StagedGeneratedKey> { + self.pending.as_ref() + } + + /// Consumes the active, unexpired stage for its commit boundary. + /// + /// # Errors + /// + /// Returns a safe unavailable error when no live stage remains. + pub fn take( + &mut self, + id: RecoveryStageId, + now: UnixTimestamp, + ) -> Result<StagedGeneratedKey, SafeError> { + self.expire(now); + if self.pending.as_ref().map(StagedGeneratedKey::id) != Some(id) { + return Err(recovery_not_available()); + } + self.pending.take().ok_or_else(recovery_not_available) + } +} + +const fn recovery_in_progress() -> SafeError { + SafeError::new( + SafeErrorCode::InvalidApplicationState, + SafeMessage::new("A generated-key recovery step is already in progress."), + ) +} + +const fn recovery_not_available() -> SafeError { + SafeError::new( + SafeErrorCode::InvalidApplicationState, + SafeMessage::new("The generated-key recovery step is no longer available."), + ) +} + +const fn invalid_stage_expiry() -> SafeError { + SafeError::new( + SafeErrorCode::InvalidApplicationState, + SafeMessage::new("The generated-key recovery expiry is invalid."), + ) +} + +#[cfg(test)] +mod tests { + use std::num::NonZeroU64; + + use radroots_studio_domain::UnixTimestamp; + + use super::{GENERATED_KEY_STAGE_TTL, GeneratedKeyStage, RecoveryStageId}; + + fn time(seconds: i64) -> UnixTimestamp { + UnixTimestamp::from_seconds(seconds).expect("time") + } + + fn id(value: u64) -> RecoveryStageId { + RecoveryStageId::new(NonZeroU64::new(value).expect("id")) + } + + #[test] + fn stage_is_exclusive_cancelable_and_never_publishes_secret_debug() { + let mut stage = GeneratedKeyStage::default(); + let handle = stage.begin(id(1), 4, time(10)).expect("begin"); + let view = handle.view(); + assert_eq!(view.expires_at().as_seconds(), 310); + assert_eq!(stage.pending().expect("pending").expected_revision(), 4); + assert!(stage.begin(id(2), 4, time(11)).is_err()); + let nsec = handle.take_recovery_nsec().expect("one-use recovery"); + assert_eq!(nsec.with_exposed_secret(str::len), 63); + assert!(handle.take_recovery_nsec().is_err()); + assert!(stage.cancel()); + assert!(!stage.cancel()); + assert!(format!("{view:?}").contains(view.account().npub().as_str())); + assert!(!format!("{view:?}").contains("nsec1")); + } + + #[test] + fn stage_expires_and_is_destroyed_on_owner_drop() { + let mut stage = GeneratedKeyStage::default(); + stage.begin(id(1), 0, time(20)).expect("begin"); + let expiry = 20 + i64::try_from(GENERATED_KEY_STAGE_TTL.as_secs()).expect("ttl"); + assert!(stage.expire(time(expiry))); + assert!(stage.pending().is_none()); + assert!(stage.take(id(1), time(expiry)).is_err()); + + let mut shutdown_stage = GeneratedKeyStage::default(); + shutdown_stage.begin(id(2), 0, time(30)).expect("begin"); + drop(shutdown_stage); + } +} diff --git a/crates/studio_application/src/lib.rs b/crates/studio_application/src/lib.rs @@ -0,0 +1,55 @@ +#![doc = "Radroots Studio application runtime."] + +pub mod accounts; +pub mod actor; +pub mod app_core; +mod change_stream; +pub mod config; +pub mod custody; +pub mod nostr_client; +pub mod ports; +mod profile_refresh; +pub mod recovery; +pub mod secrets; +pub mod session; +pub mod snapshot; +pub mod state_machine; + +pub use accounts::{ + GenerateAccountReceipt, ImportAccountReceipt, InMemoryAccountRepository, + InMemoryOperationJournal, +}; +pub use actor::{ + ActorMailbox, CommandContext, CommandEnvelope, CommandReceipt, CommandRejection, CommandResult, + CommandSubmission, CommandTicket, ForegroundSessionBinding, LifecycleGate, RequestId, + RuntimeCommandClass, RuntimeLifecycle, SessionGeneration, TaskCorrelation, +}; +pub use app_core::{AppCore, RemovalConfirmationToken, RemovalImpact}; +pub use change_stream::{ + ChangeSubscriptionId, OrderedSnapshotChanges, SnapshotChange, SnapshotChangeReceiver, +}; +pub use config::{ + RelayRuntimeMode, relay_configuration_from_environment, relay_configuration_from_value, +}; +pub use custody::{ + GENERATED_KEY_STAGE_TTL, GeneratedKeyRecoveryHandle, GeneratedKeyStage, GeneratedKeyStageView, + RecoveryStageId, StagedGeneratedKey, +}; +pub use nostr_client::SdkNostrClient; +pub use ports::{ + AccountNamespaceRepository, AccountOperationKind, AccountOperationPhase, AccountPreferenceKey, + AccountRepository, AppStateRepository, BoxFuture, CachedProfile, Clock, + DurableAccountOperation, DurableOperationKind, DurableOperationPhase, DurableOperationReceipt, + DurableOperationRepository, DurableOperationStart, DurableRequestId, DurableTerminalOutcome, + NostrClient, OperationDiagnostic, OperationId, OperationJournal, OperationPriorState, + PendingAccountOperation, ProfileRefreshStatus, ProfileRepository, +}; +pub use profile_refresh::ProfileRefreshPlan; +pub use secrets::{ + FailureSecretStore, InMemorySecretStore, SecretStore, SecretStoreCall, SecretStoreOperation, +}; +pub use snapshot::{ + ActiveAccountSnapshot, AppLifecycle, AppSnapshot, ProfileLoadState, RelayConfiguration, + RelayConnectionState, SessionState, SnapshotRevision, +}; +pub use state_machine::{StateMachine, StateTransition}; diff --git a/crates/studio_application/src/nostr_client.rs b/crates/studio_application/src/nostr_client.rs @@ -0,0 +1,138 @@ +use std::time::Duration; + +use nostr::{Filter, JsonUtil, Kind, PublicKey as NostrPublicKey}; +use nostr_sdk::ClientBuilder; +use radroots_studio_domain::{ + Kind0ProfileCandidate, PublicKey, RelayUrl, SafeError, SafeErrorCode, SafeMessage, + select_latest_kind0, +}; + +use crate::{BoxFuture, NostrClient}; + +pub struct SdkNostrClient { + timeout: Duration, +} + +impl SdkNostrClient { + #[must_use] + pub const fn new(timeout: Duration) -> Self { + Self { timeout } + } +} + +impl NostrClient for SdkNostrClient { + fn fetch_profile<'a>( + &'a self, + public_key: PublicKey, + relays: &'a [RelayUrl], + ) -> BoxFuture<'a, Result<Option<Kind0ProfileCandidate>, SafeError>> { + Box::pin(async move { + if relays.is_empty() { + return Err(invalid_relay_configuration()); + } + + let client = ClientBuilder::new().build(); + for relay in relays { + client + .add_relay(relay.as_str()) + .await + .map_err(|_| relay_connection_failed())?; + } + client.connect().await; + client.wait_for_connection(self.timeout).await; + + let author = NostrPublicKey::from_slice(public_key.as_bytes()) + .map_err(|_| profile_refresh_failed())?; + let filter = Filter::new().author(author).kind(Kind::Metadata).limit(64); + let fetched = client.fetch_events(filter, self.timeout).await; + client.shutdown().await; + let events = fetched.map_err(|_| relay_connection_failed())?; + + let mut candidates = Vec::with_capacity(events.len()); + for event in events.iter() { + candidates.push(radroots_studio_nostr::parse_verified_kind0( + &event.as_json(), + public_key, + )?); + } + Ok(select_latest_kind0(candidates)) + }) + } +} + +const fn invalid_relay_configuration() -> SafeError { + SafeError::new( + SafeErrorCode::InvalidRelayConfiguration, + SafeMessage::new("No Nostr relay is configured."), + ) +} + +const fn relay_connection_failed() -> SafeError { + SafeError::new( + SafeErrorCode::RelayConnectionFailed, + SafeMessage::new("The Nostr relays could not be reached."), + ) +} + +const fn profile_refresh_failed() -> SafeError { + SafeError::new( + SafeErrorCode::ProfileRefreshFailed, + SafeMessage::new("The Nostr profile could not be refreshed."), + ) +} + +#[cfg(test)] +mod tests { + use std::time::Duration; + + use nostr::{EventBuilder, Keys, Metadata}; + use nostr_relay_builder::MockRelay; + use nostr_sdk::Client; + use radroots_studio_domain::{PublicKey, RelayUrl, SafeErrorCode}; + + use crate::{NostrClient, SdkNostrClient}; + + #[tokio::test] + async fn sdk_client_fetches_verified_profile_from_ephemeral_local_relay() { + let relay = MockRelay::run().await.expect("local relay"); + let relay_url = relay.url().await; + let keys = Keys::generate(); + let publisher = Client::new(keys.clone()); + publisher + .add_relay(relay_url.clone()) + .await + .expect("add 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"), + )) + .await + .expect("publish metadata"); + + let adapter = SdkNostrClient::new(Duration::from_secs(2)); + let domain_relay = RelayUrl::parse(relay_url.as_str()).expect("domain relay URL"); + let public_key = PublicKey::from_bytes(keys.public_key().to_bytes()); + let profile = adapter + .fetch_profile(public_key, &[domain_relay]) + .await + .expect("fetch profile") + .expect("published profile"); + + assert_eq!(profile.author(), public_key); + assert_eq!(profile.metadata().preferred_name(), Some("Farm Account")); + publisher.shutdown().await; + relay.shutdown(); + } + + #[tokio::test] + async fn sdk_client_rejects_empty_configuration_without_network_access() { + let error = SdkNostrClient::new(Duration::from_millis(10)) + .fetch_profile(PublicKey::from_bytes([1; 32]), &[]) + .await + .expect_err("empty relay list"); + + assert_eq!(error.code(), SafeErrorCode::InvalidRelayConfiguration); + } +} diff --git a/crates/studio_application/src/ports.rs b/crates/studio_application/src/ports.rs @@ -0,0 +1,780 @@ +use std::future::Future; +use std::pin::Pin; + +use radroots_studio_domain::{ + AccountSummary, BindingAvailability, Kind0ProfileCandidate, PublicKey, RelayUrl, SafeError, + SafeErrorCode, SafeMessage, UnixTimestamp, +}; + +const MAX_DURABLE_REQUEST_ID_BYTES: usize = 128; + +#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] +pub struct DurableRequestId(String); + +impl DurableRequestId { + /// Validates an opaque caller-generated idempotency key. + /// + /// # Errors + /// + /// Returns a safe validation error when the value is empty, oversized, or contains anything + /// other than visible ASCII characters. + pub fn parse(value: impl Into<String>) -> Result<Self, SafeError> { + let value = value.into(); + if value.is_empty() + || value.len() > MAX_DURABLE_REQUEST_ID_BYTES + || !value.bytes().all(|byte| byte.is_ascii_graphic()) + { + return Err(invalid_request_id()); + } + Ok(Self(value)) + } + + #[must_use] + pub fn as_str(&self) -> &str { + &self.0 + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum DurableOperationKind { + Create, + Import, + Repair, + Remove, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum DurableOperationPhase { + IntentRecorded, + CredentialWritten, + MetadataCommitted, + SelectionCommitted, + CompensationPending, + CredentialDeleted, + MetadataDeleted, + Finalized, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum DurableTerminalOutcome { + Completed, + Cancelled, + Failed, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct OperationPriorState { + selected_account: Option<PublicKey>, + binding_availability: Option<BindingAvailability>, +} + +impl OperationPriorState { + #[must_use] + pub const fn new( + selected_account: Option<PublicKey>, + binding_availability: Option<BindingAvailability>, + ) -> Self { + Self { + selected_account, + binding_availability, + } + } + + #[must_use] + pub const fn selected_account(self) -> Option<PublicKey> { + self.selected_account + } + + #[must_use] + pub const fn binding_availability(self) -> Option<BindingAvailability> { + self.binding_availability + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct DurableOperationReceipt { + request_id: DurableRequestId, + account: PublicKey, + outcome: DurableTerminalOutcome, + resulting_revision: Option<u64>, +} + +impl DurableOperationReceipt { + #[must_use] + pub const fn new( + request_id: DurableRequestId, + account: PublicKey, + outcome: DurableTerminalOutcome, + resulting_revision: Option<u64>, + ) -> Self { + Self { + request_id, + account, + outcome, + resulting_revision, + } + } + + #[must_use] + pub const fn request_id(&self) -> &DurableRequestId { + &self.request_id + } + + #[must_use] + pub const fn account(&self) -> PublicKey { + self.account + } + + #[must_use] + pub const fn outcome(&self) -> DurableTerminalOutcome { + self.outcome + } + + #[must_use] + pub const fn resulting_revision(&self) -> Option<u64> { + self.resulting_revision + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct DurableAccountOperation { + request_id: DurableRequestId, + kind: DurableOperationKind, + account: PublicKey, + expected_revision: Option<u64>, + phase: DurableOperationPhase, + prior: OperationPriorState, + updated_at: UnixTimestamp, + diagnostic: Option<OperationDiagnostic>, + terminal: Option<DurableOperationReceipt>, +} + +impl DurableAccountOperation { + #[allow(clippy::too_many_arguments)] + #[must_use] + pub const fn new( + request_id: DurableRequestId, + kind: DurableOperationKind, + account: PublicKey, + expected_revision: Option<u64>, + phase: DurableOperationPhase, + prior: OperationPriorState, + updated_at: UnixTimestamp, + diagnostic: Option<OperationDiagnostic>, + terminal: Option<DurableOperationReceipt>, + ) -> Self { + Self { + request_id, + kind, + account, + expected_revision, + phase, + prior, + updated_at, + diagnostic, + terminal, + } + } + + #[must_use] + pub const fn request_id(&self) -> &DurableRequestId { + &self.request_id + } + #[must_use] + pub const fn kind(&self) -> DurableOperationKind { + self.kind + } + #[must_use] + pub const fn account(&self) -> PublicKey { + self.account + } + #[must_use] + pub const fn expected_revision(&self) -> Option<u64> { + self.expected_revision + } + #[must_use] + pub const fn phase(&self) -> DurableOperationPhase { + self.phase + } + #[must_use] + pub const fn prior(&self) -> OperationPriorState { + self.prior + } + #[must_use] + pub const fn updated_at(&self) -> UnixTimestamp { + self.updated_at + } + #[must_use] + pub const fn diagnostic(&self) -> Option<OperationDiagnostic> { + self.diagnostic + } + #[must_use] + pub const fn terminal(&self) -> Option<&DurableOperationReceipt> { + self.terminal.as_ref() + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum DurableOperationStart { + Started(DurableAccountOperation), + Existing(DurableAccountOperation), +} + +const fn invalid_request_id() -> SafeError { + SafeError::new( + SafeErrorCode::InvalidApplicationState, + SafeMessage::new("The request identifier is invalid."), + ) +} + +pub type BoxFuture<'a, T> = Pin<Box<dyn Future<Output = T> + Send + 'a>>; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ProfileRefreshStatus { + Success, + Offline, + InvalidData, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct CachedProfile { + candidate: Kind0ProfileCandidate, + refreshed_at: UnixTimestamp, + refresh_status: ProfileRefreshStatus, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum AccountPreferenceKey { + NamespaceProbe, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum AccountOperationKind { + Add, + Import, + Remove, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum AccountOperationPhase { + IntentRecorded, + CredentialWritten, + MetadataCommitted, + CompensationPending, + CredentialDeleted, + MetadataDeleted, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum OperationDiagnostic { + StorageUnavailable, + KeyringUnavailable, + CredentialMissing, + CompensationFailed, + Conflict, + Expired, +} + +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] +pub struct OperationId(u64); + +impl OperationId { + #[must_use] + pub const fn from_raw(value: u64) -> Self { + Self(value) + } + + #[must_use] + pub const fn as_raw(self) -> u64 { + self.0 + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct PendingAccountOperation { + id: OperationId, + kind: AccountOperationKind, + subject: PublicKey, + phase: AccountOperationPhase, + updated_at: UnixTimestamp, + diagnostic: Option<OperationDiagnostic>, +} + +impl PendingAccountOperation { + #[must_use] + pub const fn new( + id: OperationId, + kind: AccountOperationKind, + subject: PublicKey, + phase: AccountOperationPhase, + updated_at: UnixTimestamp, + diagnostic: Option<OperationDiagnostic>, + ) -> Self { + Self { + id, + kind, + subject, + phase, + updated_at, + diagnostic, + } + } + + #[must_use] + pub const fn id(&self) -> OperationId { + self.id + } + #[must_use] + pub const fn kind(&self) -> AccountOperationKind { + self.kind + } + #[must_use] + pub const fn subject(&self) -> PublicKey { + self.subject + } + #[must_use] + pub const fn phase(&self) -> AccountOperationPhase { + self.phase + } + #[must_use] + pub const fn updated_at(&self) -> UnixTimestamp { + self.updated_at + } + #[must_use] + pub const fn diagnostic(&self) -> Option<OperationDiagnostic> { + self.diagnostic + } +} + +impl CachedProfile { + #[must_use] + pub const fn new( + candidate: Kind0ProfileCandidate, + refreshed_at: UnixTimestamp, + refresh_status: ProfileRefreshStatus, + ) -> Self { + Self { + candidate, + refreshed_at, + refresh_status, + } + } + + #[must_use] + pub const fn candidate(&self) -> &Kind0ProfileCandidate { + &self.candidate + } + + #[must_use] + pub const fn refreshed_at(&self) -> UnixTimestamp { + self.refreshed_at + } + + #[must_use] + pub const fn refresh_status(&self) -> ProfileRefreshStatus { + self.refresh_status + } +} + +pub trait AccountRepository: Send + Sync { + /// Lists saved public account records in deterministic order. + /// + /// # Errors + /// + /// Returns a safe storage error when records cannot be read. + fn list_accounts(&self) -> Result<Vec<AccountSummary>, SafeError>; + /// Finds one saved public account record. + /// + /// # Errors + /// + /// Returns a safe storage error when the lookup cannot complete. + fn find_account(&self, public_key: PublicKey) -> Result<Option<AccountSummary>, SafeError>; + /// Inserts one public account record. + /// + /// # Errors + /// + /// Returns a safe storage error when the durable write fails. + fn insert_account(&self, account: &AccountSummary) -> Result<(), SafeError>; + /// Updates one existing public account record. + /// + /// # Errors + /// + /// Returns a safe storage or account-not-found error when the durable + /// update cannot complete. + fn update_account(&self, account: &AccountSummary) -> Result<(), SafeError>; + /// Removes one public account record. + /// + /// # Errors + /// + /// Returns a safe storage error when the durable delete fails. + fn remove_account(&self, public_key: PublicKey) -> Result<(), SafeError>; +} + +pub trait ProfileRepository: Send + Sync { + /// Loads cached public profile metadata. + /// + /// # Errors + /// + /// Returns a safe storage error when the cache cannot be read. + fn load_profile(&self, public_key: PublicKey) -> Result<Option<CachedProfile>, SafeError>; + /// Saves a verified kind-0 profile candidate. + /// + /// # Errors + /// + /// Returns a safe storage error when the cache cannot be committed. + fn save_profile(&self, profile: &CachedProfile) -> Result<(), SafeError>; + /// Records the result of a profile refresh without replacing cached metadata. + /// + /// # Errors + /// + /// Returns a safe storage error when the cache cannot be committed. + fn record_refresh_status( + &self, + public_key: PublicKey, + refreshed_at: UnixTimestamp, + status: ProfileRefreshStatus, + ) -> Result<(), SafeError>; + /// Removes cached profile metadata for an account. + /// + /// # Errors + /// + /// Returns a safe storage error when the cache cannot be deleted. + fn remove_profile(&self, public_key: PublicKey) -> Result<(), SafeError>; +} + +pub trait AccountNamespaceRepository: Send + Sync { + /// Reads one internal non-secret account-scoped value. + /// + /// # Errors + /// + /// Returns a safe storage error when the value cannot be read. + fn get_value( + &self, + owner: PublicKey, + key: AccountPreferenceKey, + ) -> Result<Option<String>, SafeError>; + /// Writes one internal non-secret account-scoped value. + /// + /// # Errors + /// + /// Returns a safe storage error when the value cannot be committed. + fn set_value( + &self, + owner: PublicKey, + key: AccountPreferenceKey, + value: &str, + ) -> Result<(), SafeError>; + /// Removes all internal values owned by an account. + /// + /// # Errors + /// + /// Returns a safe storage error when cleanup cannot be committed. + fn clear_owner(&self, owner: PublicKey) -> Result<(), SafeError>; +} + +pub trait AppStateRepository: Send + Sync { + /// Loads the persisted selected account. + /// + /// # Errors + /// + /// Returns a safe storage error when application state cannot be read. + fn load_selected_account(&self) -> Result<Option<PublicKey>, SafeError>; + /// Persists the selected account or the empty selection. + /// + /// # Errors + /// + /// Returns a safe storage error when application state cannot be committed. + fn save_selected_account(&self, public_key: Option<PublicKey>) -> Result<(), SafeError>; +} + +pub trait OperationJournal: Send + Sync { + /// Records one cross-resource account operation intent. + /// + /// # Errors + /// + /// Returns a safe storage error when the entry cannot be committed. + fn begin_operation( + &self, + kind: AccountOperationKind, + subject: PublicKey, + updated_at: UnixTimestamp, + ) -> Result<OperationId, SafeError>; + /// Advances an operation to a durable recovery phase. + /// + /// # Errors + /// + /// Returns a safe storage error when the entry cannot be updated. + fn update_operation( + &self, + id: OperationId, + phase: AccountOperationPhase, + updated_at: UnixTimestamp, + diagnostic: Option<OperationDiagnostic>, + ) -> Result<(), SafeError>; + /// Loads all unfinished operations in deterministic order. + /// + /// # Errors + /// + /// Returns a safe storage error when entries cannot be read. + fn list_pending_operations(&self) -> Result<Vec<PendingAccountOperation>, SafeError>; + /// Deletes one fully reconciled operation entry. + /// + /// # Errors + /// + /// Returns a safe storage error when finalization cannot be committed. + fn finalize_operation(&self, id: OperationId) -> Result<(), SafeError>; +} + +pub trait DurableOperationRepository: Send + Sync { + /// Records one idempotent durable operation or returns the existing matching request. + /// + /// # Errors + /// + /// Returns a safe conflict or storage error when the request cannot be recorded. + #[allow(clippy::too_many_arguments)] + fn begin_durable_operation( + &self, + request_id: &DurableRequestId, + kind: DurableOperationKind, + account: PublicKey, + expected_revision: Option<u64>, + prior: OperationPriorState, + updated_at: UnixTimestamp, + ) -> Result<DurableOperationStart, SafeError>; + /// Loads one durable operation by its idempotency key. + /// + /// # Errors + /// + /// Returns a safe storage error when the lookup cannot complete. + fn load_durable_operation( + &self, + request_id: &DurableRequestId, + ) -> Result<Option<DurableAccountOperation>, SafeError>; + /// Advances one operation only from the caller's expected phase. + /// + /// # Errors + /// + /// Returns a safe conflict or storage error when the transition cannot commit. + fn advance_durable_operation( + &self, + request_id: &DurableRequestId, + expected_phase: DurableOperationPhase, + next_phase: DurableOperationPhase, + updated_at: UnixTimestamp, + diagnostic: Option<OperationDiagnostic>, + ) -> Result<DurableAccountOperation, SafeError>; + /// Finalizes one operation and durably retains its recoverable receipt. + /// + /// # Errors + /// + /// Returns a safe conflict or storage error when finalization cannot commit. + fn finalize_durable_operation( + &self, + request_id: &DurableRequestId, + expected_phase: DurableOperationPhase, + outcome: DurableTerminalOutcome, + resulting_revision: Option<u64>, + updated_at: UnixTimestamp, + ) -> Result<DurableOperationReceipt, SafeError>; + /// Lists unfinished operations in deterministic request order. + /// + /// # Errors + /// + /// Returns a safe storage error when operations cannot be read. + fn list_unfinished_durable_operations(&self) + -> Result<Vec<DurableAccountOperation>, SafeError>; +} + +pub trait NostrClient: Send + Sync { + fn fetch_profile<'a>( + &'a self, + public_key: PublicKey, + relays: &'a [RelayUrl], + ) -> BoxFuture<'a, Result<Option<Kind0ProfileCandidate>, SafeError>>; +} + +pub trait Clock: Send + Sync { + fn now(&self) -> UnixTimestamp; +} + +#[cfg(test)] +mod tests { + use std::sync::Mutex; + + use radroots_studio_domain::{ + AccountSummary, Kind0ProfileCandidate, PublicKey, RelayUrl, SafeError, UnixTimestamp, + }; + + use super::{ + AccountNamespaceRepository, AccountOperationKind, AccountOperationPhase, + AccountPreferenceKey, AccountRepository, AppStateRepository, BoxFuture, CachedProfile, + Clock, DurableOperationReceipt, DurableRequestId, DurableTerminalOutcome, NostrClient, + OperationDiagnostic, OperationId, OperationJournal, PendingAccountOperation, + ProfileRefreshStatus, ProfileRepository, + }; + + #[test] + fn durable_request_ids_and_terminal_receipts_are_bounded_and_public() { + let request = DurableRequestId::parse("create:desktop:0001").expect("request id"); + let receipt = DurableOperationReceipt::new( + request.clone(), + PublicKey::from_bytes([3; 32]), + DurableTerminalOutcome::Completed, + Some(42), + ); + assert_eq!(receipt.request_id(), &request); + assert_eq!(receipt.resulting_revision(), Some(42)); + for invalid in ["", "contains space", &"x".repeat(129)] { + assert!(DurableRequestId::parse(invalid).is_err()); + } + } + + #[derive(Default)] + struct FakePorts { + selected: Mutex<Option<PublicKey>>, + } + + impl AccountRepository for FakePorts { + fn list_accounts(&self) -> Result<Vec<AccountSummary>, SafeError> { + Ok(Vec::new()) + } + + fn find_account( + &self, + _public_key: PublicKey, + ) -> Result<Option<AccountSummary>, SafeError> { + Ok(None) + } + + fn insert_account(&self, _account: &AccountSummary) -> Result<(), SafeError> { + Ok(()) + } + + fn update_account(&self, _account: &AccountSummary) -> Result<(), SafeError> { + Ok(()) + } + + fn remove_account(&self, _public_key: PublicKey) -> Result<(), SafeError> { + Ok(()) + } + } + + impl ProfileRepository for FakePorts { + fn load_profile(&self, _public_key: PublicKey) -> Result<Option<CachedProfile>, SafeError> { + Ok(None) + } + + fn save_profile(&self, _profile: &CachedProfile) -> Result<(), SafeError> { + Ok(()) + } + + fn record_refresh_status( + &self, + _public_key: PublicKey, + _refreshed_at: UnixTimestamp, + _status: ProfileRefreshStatus, + ) -> Result<(), SafeError> { + Ok(()) + } + + fn remove_profile(&self, _public_key: PublicKey) -> Result<(), SafeError> { + Ok(()) + } + } + + impl AccountNamespaceRepository for FakePorts { + fn get_value( + &self, + _owner: PublicKey, + _key: AccountPreferenceKey, + ) -> Result<Option<String>, SafeError> { + Ok(None) + } + + fn set_value( + &self, + _owner: PublicKey, + _key: AccountPreferenceKey, + _value: &str, + ) -> Result<(), SafeError> { + Ok(()) + } + + fn clear_owner(&self, _owner: PublicKey) -> Result<(), SafeError> { + Ok(()) + } + } + + impl AppStateRepository for FakePorts { + fn load_selected_account(&self) -> Result<Option<PublicKey>, SafeError> { + Ok(*self.selected.lock().expect("selected lock")) + } + + fn save_selected_account(&self, public_key: Option<PublicKey>) -> Result<(), SafeError> { + *self.selected.lock().expect("selected lock") = public_key; + Ok(()) + } + } + + impl OperationJournal for FakePorts { + fn begin_operation( + &self, + _kind: AccountOperationKind, + _subject: PublicKey, + _updated_at: UnixTimestamp, + ) -> Result<OperationId, SafeError> { + Ok(OperationId::from_raw(1)) + } + + fn update_operation( + &self, + _id: OperationId, + _phase: AccountOperationPhase, + _updated_at: UnixTimestamp, + _diagnostic: Option<OperationDiagnostic>, + ) -> Result<(), SafeError> { + Ok(()) + } + + fn list_pending_operations(&self) -> Result<Vec<PendingAccountOperation>, SafeError> { + Ok(Vec::new()) + } + + fn finalize_operation(&self, _id: OperationId) -> Result<(), SafeError> { + Ok(()) + } + } + + impl NostrClient for FakePorts { + fn fetch_profile<'a>( + &'a self, + _public_key: PublicKey, + _relays: &'a [RelayUrl], + ) -> BoxFuture<'a, Result<Option<Kind0ProfileCandidate>, SafeError>> { + Box::pin(async { Ok(None) }) + } + } + + impl Clock for FakePorts { + fn now(&self) -> UnixTimestamp { + UnixTimestamp::from_seconds(1).expect("valid fake time") + } + } + + fn assert_send_sync<T: Send + Sync>() {} + + #[test] + fn ports_accept_send_sync_test_fakes() { + assert_send_sync::<FakePorts>(); + + let ports = FakePorts::default(); + ports + .save_selected_account(Some(PublicKey::from_bytes([1_u8; 32]))) + .expect("save selection"); + assert_eq!( + ports.load_selected_account().expect("load selection"), + Some(PublicKey::from_bytes([1_u8; 32])) + ); + assert_eq!(ports.now().as_seconds(), 1); + } +} diff --git a/crates/studio_application/src/profile_refresh.rs b/crates/studio_application/src/profile_refresh.rs @@ -0,0 +1,559 @@ +use radroots_studio_domain::{PublicKey, RelayUrl, SafeError, SafeErrorCode}; + +use crate::{ + ActiveAccountSnapshot, AppCore, AppSnapshot, CachedProfile, Clock, NostrClient, + ProfileLoadState, ProfileRefreshStatus, ProfileRepository, RelayConnectionState, + SnapshotRevision, StateTransition, +}; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ProfileRefreshPlan { + public_key: PublicKey, + active_account: ActiveAccountSnapshot, + relays: Vec<RelayUrl>, + expected_revision: SnapshotRevision, +} + +impl ProfileRefreshPlan { + #[must_use] + pub const fn public_key(&self) -> PublicKey { + self.public_key + } + + #[must_use] + pub const fn active_account(&self) -> &ActiveAccountSnapshot { + &self.active_account + } + + #[must_use] + pub fn relays(&self) -> &[RelayUrl] { + &self.relays + } + + #[must_use] + pub const fn expected_revision(&self) -> SnapshotRevision { + self.expected_revision + } +} + +impl AppCore { + /// Manually refreshes the active account's Nostr kind-0 profile. + /// + /// Cached public metadata remains visible while the asynchronous request is + /// running. Calling this command while signed out is an idempotent no-op. + /// + /// # Errors + /// + /// Returns a safe storage or application-state error. Relay and invalid-data + /// failures are represented as nonfatal snapshot state. + pub async fn refresh_active_profile( + &self, + profiles: &(impl ProfileRepository + ?Sized), + client: &(impl NostrClient + ?Sized), + clock: &(impl Clock + ?Sized), + ) -> Result<AppSnapshot, SafeError> { + self.refresh_profile_for_active_account(profiles, client, clock) + .await + } + + /// Refreshes the current active account while retaining any cached profile. + /// + /// Stale results are discarded when the account is replaced or signed out. + /// + /// # Errors + /// + /// Returns a safe storage or application-state error. Relay and invalid-data + /// failures are represented as nonfatal snapshot state. + async fn refresh_profile_for_active_account( + &self, + profiles: &(impl ProfileRepository + ?Sized), + client: &(impl NostrClient + ?Sized), + clock: &(impl Clock + ?Sized), + ) -> Result<AppSnapshot, SafeError> { + let Some(plan) = self.begin_profile_refresh()? else { + return Ok(self.snapshot()); + }; + let result = client.fetch_profile(plan.public_key(), plan.relays()).await; + self.complete_profile_refresh(&plan, result, profiles, clock) + } + + /// Begins a refresh on the actor and returns the immutable network plan. + /// + /// # Errors + /// + /// Returns a safe state error when the loading transition is invalid. + pub fn begin_profile_refresh(&self) -> Result<Option<ProfileRefreshPlan>, SafeError> { + let Some(active) = self.snapshot().active_account().cloned() else { + return Ok(None); + }; + let public_key = active.account().public_key(); + let loading = self.apply_transition(StateTransition::UpdateActiveAccount { + expected: public_key, + active_account: Box::new(ActiveAccountSnapshot::new( + active.account().clone(), + RelayConnectionState::Connecting, + ProfileLoadState::Loading, + active.profile().cloned(), + )), + problem: None, + })?; + Ok(Some(ProfileRefreshPlan { + public_key, + active_account: active, + relays: loading.relay_configuration().relays().to_vec(), + expected_revision: loading.revision(), + })) + } + + /// Applies a correlated refresh result on the actor. + /// + /// # Errors + /// + /// Returns a safe storage or application-state error. Stale results are + /// discarded without persistence or publication. + pub fn complete_profile_refresh( + &self, + plan: &ProfileRefreshPlan, + result: Result<Option<radroots_studio_domain::Kind0ProfileCandidate>, SafeError>, + profiles: &(impl ProfileRepository + ?Sized), + clock: &(impl Clock + ?Sized), + ) -> Result<AppSnapshot, SafeError> { + if !is_current_active(self, plan.public_key()) { + return Ok(self.snapshot()); + } + + let current_active = self + .snapshot() + .active_account() + .cloned() + .ok_or_else(invalid_profile_completion)?; + + match result { + Ok(Some(candidate)) => { + let cached = CachedProfile::new( + candidate.clone(), + clock.now(), + ProfileRefreshStatus::Success, + ); + profiles.save_profile(&cached)?; + let winning_profile = profiles.load_profile(plan.public_key())?.map_or_else( + || candidate.metadata().clone(), + |profile| profile.candidate().metadata().clone(), + ); + self.apply_transition(StateTransition::UpdateActiveAccount { + expected: plan.public_key(), + active_account: Box::new(ActiveAccountSnapshot::new( + current_active.account().clone(), + RelayConnectionState::Connected, + ProfileLoadState::Fresh, + Some(winning_profile), + )), + problem: None, + }) + } + Ok(None) => self.apply_transition(StateTransition::UpdateActiveAccount { + expected: plan.public_key(), + active_account: Box::new(ActiveAccountSnapshot::new( + current_active.account().clone(), + RelayConnectionState::Connected, + if current_active.profile().is_some() { + ProfileLoadState::Cached + } else { + ProfileLoadState::Empty + }, + current_active.profile().cloned(), + )), + problem: None, + }), + Err(error) => { + let status = refresh_status(error); + profiles.record_refresh_status(plan.public_key(), clock.now(), status)?; + self.apply_transition(StateTransition::UpdateActiveAccount { + expected: plan.public_key(), + active_account: Box::new(ActiveAccountSnapshot::new( + current_active.account().clone(), + RelayConnectionState::Degraded, + ProfileLoadState::Error(error), + current_active.profile().cloned(), + )), + problem: Some(error), + }) + } + } + } +} + +const fn invalid_profile_completion() -> SafeError { + SafeError::new( + SafeErrorCode::InvalidApplicationState, + radroots_studio_domain::SafeMessage::new("The active profile refresh is no longer valid."), + ) +} + +fn is_current_active(core: &AppCore, public_key: PublicKey) -> bool { + core.snapshot() + .active_account() + .is_some_and(|active| active.account().public_key() == public_key) +} + +const fn refresh_status(error: SafeError) -> ProfileRefreshStatus { + match error.code() { + SafeErrorCode::InvalidProfileMetadata | SafeErrorCode::ProfileRefreshFailed => { + ProfileRefreshStatus::InvalidData + } + _ => ProfileRefreshStatus::Offline, + } +} + +#[cfg(test)] +mod tests { + use std::sync::Mutex; + + use radroots_studio_domain::{ + EventId, Kind0ProfileCandidate, ProfileMetadata, PublicKey, RelayUrl, SafeError, + SafeErrorCode, SafeMessage, SecretKeyInput, UnixTimestamp, select_latest_kind0, + }; + + use crate::{ + ActiveAccountSnapshot, AppCore, BoxFuture, CachedProfile, Clock, InMemoryAccountRepository, + InMemoryOperationJournal, InMemorySecretStore, NostrClient, ProfileLoadState, + ProfileRefreshStatus, ProfileRepository, RelayConfiguration, RelayConnectionState, + }; + + #[derive(Default)] + struct MemoryProfiles(Mutex<Option<CachedProfile>>); + + impl ProfileRepository for MemoryProfiles { + fn load_profile(&self, _public_key: PublicKey) -> Result<Option<CachedProfile>, SafeError> { + Ok(self.0.lock().expect("profiles").clone()) + } + fn save_profile(&self, profile: &CachedProfile) -> Result<(), SafeError> { + let mut cached = self.0.lock().expect("profiles"); + let selected = cached.as_ref().map_or_else( + || profile.clone(), + |current| { + let winner = select_latest_kind0([ + current.candidate().clone(), + profile.candidate().clone(), + ]) + .expect("two candidates"); + if &winner == current.candidate() { + current.clone() + } else { + profile.clone() + } + }, + ); + *cached = Some(selected); + Ok(()) + } + fn record_refresh_status( + &self, + _public_key: PublicKey, + refreshed_at: UnixTimestamp, + status: ProfileRefreshStatus, + ) -> Result<(), SafeError> { + if let Some(profile) = self.0.lock().expect("profiles").as_mut() { + *profile = CachedProfile::new(profile.candidate().clone(), refreshed_at, status); + } + Ok(()) + } + fn remove_profile(&self, _public_key: PublicKey) -> Result<(), SafeError> { + *self.0.lock().expect("profiles") = None; + Ok(()) + } + } + + struct FixedClock; + impl Clock for FixedClock { + fn now(&self) -> UnixTimestamp { + UnixTimestamp::from_seconds(50).expect("time") + } + } + + struct FixedClient(Result<Option<Kind0ProfileCandidate>, SafeError>); + impl NostrClient for FixedClient { + fn fetch_profile<'a>( + &'a self, + _public_key: PublicKey, + _relays: &'a [RelayUrl], + ) -> BoxFuture<'a, Result<Option<Kind0ProfileCandidate>, SafeError>> { + let result = self.0.clone(); + Box::pin(async move { result }) + } + } + + struct BlockingClient { + started: tokio::sync::Semaphore, + release: tokio::sync::Semaphore, + result: Result<Option<Kind0ProfileCandidate>, SafeError>, + } + + impl BlockingClient { + fn new(result: Result<Option<Kind0ProfileCandidate>, SafeError>) -> Self { + Self { + started: tokio::sync::Semaphore::new(0), + release: tokio::sync::Semaphore::new(0), + result, + } + } + } + + impl NostrClient for BlockingClient { + fn fetch_profile<'a>( + &'a self, + _public_key: PublicKey, + _relays: &'a [RelayUrl], + ) -> BoxFuture<'a, Result<Option<Kind0ProfileCandidate>, SafeError>> { + Box::pin(async move { + self.started.add_permits(1); + let permit = self.release.acquire().await.expect("release open"); + permit.forget(); + self.result.clone() + }) + } + } + + fn profile(public_key: PublicKey, name: &str, timestamp: i64) -> Kind0ProfileCandidate { + Kind0ProfileCandidate::new( + EventId::from_bytes([u8::try_from(timestamp).expect("small timestamp"); 32]), + public_key, + UnixTimestamp::from_seconds(timestamp).expect("time"), + ProfileMetadata::new(Some(name.to_owned()), None, None, None, None).expect("profile"), + ) + } + + fn active_core(profiles: &MemoryProfiles, cached_name: Option<&str>) -> (AppCore, PublicKey) { + let relays = + RelayConfiguration::new(vec![RelayUrl::parse("ws://localhost:8080").expect("relay")]); + let core = AppCore::in_memory(relays); + let accounts = InMemoryAccountRepository::default(); + let secrets = InMemorySecretStore::default(); + let journal = InMemoryOperationJournal::default(); + core.bootstrap().expect("bootstrap"); + let public_key = core + .import_secret_key( + SecretKeyInput::parse( + "7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7".to_owned(), + ) + .expect("secret"), + &accounts, + &accounts, + &secrets, + &journal, + &FixedClock, + ) + .expect("import") + .account() + .public_key(); + if let Some(name) = cached_name { + profiles + .save_profile(&CachedProfile::new( + profile(public_key, name, 10), + UnixTimestamp::from_seconds(11).expect("time"), + ProfileRefreshStatus::Success, + )) + .expect("cache"); + } + core.activate_account( + public_key, + &accounts, + &accounts, + profiles, + &secrets, + &FixedClock, + ) + .expect("activate"); + (core, public_key) + } + + #[tokio::test] + async fn refresh_transitions_from_cache_through_loading_to_fresh_profile() { + let profiles = MemoryProfiles::default(); + let (core, public_key) = active_core(&profiles, Some("Cached")); + assert_eq!( + core.snapshot() + .active_account() + .map(crate::ActiveAccountSnapshot::profile_state), + Some(ProfileLoadState::Cached) + ); + let plan = core + .begin_profile_refresh() + .expect("begin refresh") + .expect("active refresh"); + let loading = core.snapshot(); + assert_eq!( + loading + .active_account() + .map(crate::ActiveAccountSnapshot::profile_state), + Some(ProfileLoadState::Loading) + ); + assert_eq!( + loading + .active_account() + .map(crate::ActiveAccountSnapshot::relay_state), + Some(RelayConnectionState::Connecting) + ); + let client = FixedClient(Ok(Some(profile(public_key, "Fresh", 20)))); + let result = client.fetch_profile(plan.public_key(), plan.relays()).await; + core.complete_profile_refresh(&plan, result, &profiles, &FixedClock) + .expect("complete refresh"); + assert_eq!( + core.snapshot() + .active_account() + .and_then(|active| active.profile()) + .and_then(ProfileMetadata::name), + Some("Fresh") + ); + } + + #[tokio::test] + async fn refresh_failure_preserves_cached_profile_as_nonfatal_state() { + let profiles = MemoryProfiles::default(); + let (core, public_key) = active_core(&profiles, Some("Cached")); + let cached = profile(public_key, "Cached", 10); + let error = SafeError::new( + SafeErrorCode::RelayConnectionFailed, + SafeMessage::new("The relay is offline."), + ); + + let snapshot = core + .refresh_profile_for_active_account(&profiles, &FixedClient(Err(error)), &FixedClock) + .await + .expect("nonfatal refresh"); + + assert_eq!(snapshot.recoverable_problem(), Some(error)); + assert_eq!( + snapshot + .active_account() + .map(crate::ActiveAccountSnapshot::relay_state), + Some(RelayConnectionState::Degraded) + ); + assert_eq!( + profiles + .load_profile(public_key) + .expect("load") + .expect("cache") + .candidate(), + &cached + ); + } + + #[tokio::test] + async fn refresh_discards_stale_completion_after_sign_out() { + let profiles = MemoryProfiles::default(); + let (core, public_key) = active_core(&profiles, Some("Cached")); + let client = BlockingClient::new(Ok(Some(profile(public_key, "Stale", 20)))); + + let refresh = core.refresh_profile_for_active_account(&profiles, &client, &FixedClock); + let sign_out = async { + let permit = client.started.acquire().await.expect("refresh starts"); + permit.forget(); + core.sign_out().expect("sign out"); + client.release.add_permits(1); + }; + let (result, ()) = tokio::join!(refresh, sign_out); + + assert!( + result + .expect("stale result is harmless") + .active_account() + .is_none() + ); + assert_eq!( + profiles + .load_profile(public_key) + .expect("load") + .expect("cached") + .candidate() + .metadata() + .name(), + Some("Cached") + ); + } + + #[tokio::test] + async fn manual_refresh_is_repeatable_and_signed_out_safe() { + let profiles = MemoryProfiles::default(); + let (core, public_key) = active_core(&profiles, None); + let first = core + .refresh_active_profile( + &profiles, + &FixedClient(Ok(Some(profile(public_key, "First", 10)))), + &FixedClock, + ) + .await + .expect("first refresh"); + let second = core + .refresh_active_profile( + &profiles, + &FixedClient(Ok(Some(profile(public_key, "Second", 20)))), + &FixedClock, + ) + .await + .expect("second refresh"); + + assert!(second.revision() > first.revision()); + assert_eq!( + second + .active_account() + .and_then(|active| active.profile()) + .and_then(ProfileMetadata::name), + Some("Second") + ); + let signed_out = core.sign_out().expect("sign out"); + let no_op = core + .refresh_active_profile(&profiles, &FixedClient(Ok(None)), &FixedClock) + .await + .expect("signed-out no-op"); + assert_eq!(no_op, signed_out); + } + + #[test] + fn overlapping_refreshes_keep_the_newest_event_regardless_of_completion_order() { + let profiles = MemoryProfiles::default(); + let (core, public_key) = active_core(&profiles, Some("Cached")); + let first = core + .begin_profile_refresh() + .expect("first") + .expect("active"); + let second = core + .begin_profile_refresh() + .expect("second") + .expect("active"); + + core.complete_profile_refresh( + &second, + Ok(Some(profile(public_key, "Newest", 30))), + &profiles, + &FixedClock, + ) + .expect("newest completes first"); + let final_snapshot = core + .complete_profile_refresh( + &first, + Ok(Some(profile(public_key, "Older", 20))), + &profiles, + &FixedClock, + ) + .expect("older completes last"); + + assert_eq!( + final_snapshot + .active_account() + .and_then(ActiveAccountSnapshot::profile) + .and_then(ProfileMetadata::name), + Some("Newest") + ); + assert_eq!( + profiles + .load_profile(public_key) + .expect("cache") + .expect("profile") + .candidate() + .metadata() + .name(), + Some("Newest") + ); + } +} diff --git a/crates/studio_application/src/recovery.rs b/crates/studio_application/src/recovery.rs @@ -0,0 +1,358 @@ +use radroots_studio_domain::{PublicKey, SafeError}; + +use crate::{ + AccountOperationKind, AccountOperationPhase, AccountRepository, AppCore, AppStateRepository, + Clock, DurableAccountOperation, DurableOperationKind, DurableOperationPhase, + DurableOperationRepository, DurableTerminalOutcome, OperationJournal, SecretStore, +}; + +impl AppCore { + /// Reconciles durable request operations before public state is restored. + /// + /// # Errors + /// + /// Returns a safe credential, persistence, or recovery error while retaining the operation + /// at its last durable phase for a later retry. + pub fn recover_durable_operations( + &self, + accounts: &(impl AccountRepository + ?Sized), + app_state: &(impl AppStateRepository + ?Sized), + secrets: &(impl SecretStore + ?Sized), + operations: &(impl DurableOperationRepository + ?Sized), + clock: &(impl Clock + ?Sized), + ) -> Result<(), SafeError> { + for operation in operations.list_unfinished_durable_operations()? { + match operation.kind() { + DurableOperationKind::Create + | DurableOperationKind::Import + | DurableOperationKind::Repair => recover_durable_addition( + &operation, accounts, app_state, secrets, operations, clock, + )?, + DurableOperationKind::Remove => recover_durable_removal( + &operation, accounts, app_state, secrets, operations, clock, + )?, + } + } + Ok(()) + } + + /// Reconciles non-secret cross-resource journal entries before bootstrap. + /// + /// An empty journal does not access the credential store. + /// + /// # Errors + /// + /// Returns a safe credential, persistence, or recovery error while retaining + /// the unfinished journal entry for a later retry. + pub fn recover_pending_operations( + &self, + accounts: &(impl AccountRepository + ?Sized), + app_state: &(impl AppStateRepository + ?Sized), + secrets: &(impl SecretStore + ?Sized), + journal: &(impl OperationJournal + ?Sized), + clock: &(impl Clock + ?Sized), + ) -> Result<(), SafeError> { + for operation in journal.list_pending_operations()? { + match operation.kind() { + AccountOperationKind::Remove => { + recover_removal(&operation, accounts, app_state, secrets, journal, clock)?; + } + AccountOperationKind::Add | AccountOperationKind::Import => { + recover_addition(&operation, accounts, secrets, journal, clock)?; + } + } + } + Ok(()) + } +} + +fn recover_durable_removal( + operation: &DurableAccountOperation, + accounts: &(impl AccountRepository + ?Sized), + app_state: &(impl AppStateRepository + ?Sized), + secrets: &(impl SecretStore + ?Sized), + operations: &(impl DurableOperationRepository + ?Sized), + clock: &(impl Clock + ?Sized), +) -> Result<(), SafeError> { + let request = operation.request_id(); + let account = operation.account(); + let mut phase = operation.phase(); + if phase == DurableOperationPhase::IntentRecorded { + if secrets.contains(account)? { + secrets.delete(account)?; + } + operations.advance_durable_operation( + request, + phase, + DurableOperationPhase::CredentialDeleted, + clock.now(), + None, + )?; + phase = DurableOperationPhase::CredentialDeleted; + } + if phase == DurableOperationPhase::CredentialDeleted { + if accounts.find_account(account)?.is_some() { + accounts.remove_account(account)?; + } + operations.advance_durable_operation( + request, + phase, + DurableOperationPhase::MetadataDeleted, + clock.now(), + None, + )?; + phase = DurableOperationPhase::MetadataDeleted; + } + if phase == DurableOperationPhase::MetadataDeleted { + app_state.save_selected_account(operation.prior().selected_account())?; + operations.advance_durable_operation( + request, + phase, + DurableOperationPhase::SelectionCommitted, + clock.now(), + None, + )?; + phase = DurableOperationPhase::SelectionCommitted; + } + if phase == DurableOperationPhase::SelectionCommitted { + operations.finalize_durable_operation( + request, + phase, + DurableTerminalOutcome::Completed, + None, + clock.now(), + )?; + } + Ok(()) +} + +fn recover_durable_addition( + operation: &DurableAccountOperation, + accounts: &(impl AccountRepository + ?Sized), + app_state: &(impl AppStateRepository + ?Sized), + secrets: &(impl SecretStore + ?Sized), + operations: &(impl DurableOperationRepository + ?Sized), + clock: &(impl Clock + ?Sized), +) -> Result<(), SafeError> { + let request = operation.request_id(); + let account = operation.account(); + match operation.phase() { + DurableOperationPhase::IntentRecorded => { + if secrets.contains(account)? { + secrets.delete(account)?; + } + operations.finalize_durable_operation( + request, + DurableOperationPhase::IntentRecorded, + DurableTerminalOutcome::Failed, + None, + clock.now(), + )?; + } + DurableOperationPhase::CredentialWritten => { + let metadata = accounts.find_account(account)?; + let committed = metadata.as_ref().is_some_and(|saved| { + saved.signer().availability() + == radroots_studio_domain::BindingAvailability::Available + }); + if committed { + operations.advance_durable_operation( + request, + DurableOperationPhase::CredentialWritten, + DurableOperationPhase::MetadataCommitted, + clock.now(), + None, + )?; + finish_durable_selection(operation, app_state, operations, clock)?; + } else { + operations.advance_durable_operation( + request, + DurableOperationPhase::CredentialWritten, + DurableOperationPhase::CompensationPending, + clock.now(), + None, + )?; + compensate_durable_addition( + operation, accounts, app_state, secrets, operations, clock, + )?; + } + } + DurableOperationPhase::MetadataCommitted => { + finish_durable_selection(operation, app_state, operations, clock)?; + } + DurableOperationPhase::SelectionCommitted => { + operations.finalize_durable_operation( + request, + DurableOperationPhase::SelectionCommitted, + DurableTerminalOutcome::Completed, + None, + clock.now(), + )?; + } + DurableOperationPhase::CompensationPending => { + compensate_durable_addition( + operation, accounts, app_state, secrets, operations, clock, + )?; + } + DurableOperationPhase::CredentialDeleted | DurableOperationPhase::MetadataDeleted => { + operations.finalize_durable_operation( + request, + operation.phase(), + DurableTerminalOutcome::Failed, + None, + clock.now(), + )?; + } + DurableOperationPhase::Finalized => {} + } + Ok(()) +} + +fn finish_durable_selection( + operation: &DurableAccountOperation, + app_state: &(impl AppStateRepository + ?Sized), + operations: &(impl DurableOperationRepository + ?Sized), + clock: &(impl Clock + ?Sized), +) -> Result<(), SafeError> { + app_state.save_selected_account(Some(operation.account()))?; + operations.advance_durable_operation( + operation.request_id(), + DurableOperationPhase::MetadataCommitted, + DurableOperationPhase::SelectionCommitted, + clock.now(), + None, + )?; + operations.finalize_durable_operation( + operation.request_id(), + DurableOperationPhase::SelectionCommitted, + DurableTerminalOutcome::Completed, + None, + clock.now(), + )?; + Ok(()) +} + +fn compensate_durable_addition( + operation: &DurableAccountOperation, + accounts: &(impl AccountRepository + ?Sized), + app_state: &(impl AppStateRepository + ?Sized), + secrets: &(impl SecretStore + ?Sized), + operations: &(impl DurableOperationRepository + ?Sized), + clock: &(impl Clock + ?Sized), +) -> Result<(), SafeError> { + if secrets.contains(operation.account())? { + secrets.delete(operation.account())?; + } + if let Some(availability) = operation.prior().binding_availability() { + if let Some(previous) = accounts.find_account(operation.account())? { + accounts.update_account(&previous.with_binding_availability(availability))?; + } + } else if accounts.find_account(operation.account())?.is_some() { + accounts.remove_account(operation.account())?; + } + app_state.save_selected_account(operation.prior().selected_account())?; + operations.advance_durable_operation( + operation.request_id(), + DurableOperationPhase::CompensationPending, + DurableOperationPhase::CredentialDeleted, + clock.now(), + None, + )?; + operations.finalize_durable_operation( + operation.request_id(), + DurableOperationPhase::CredentialDeleted, + DurableTerminalOutcome::Failed, + None, + clock.now(), + )?; + Ok(()) +} + +fn recover_removal( + operation: &crate::PendingAccountOperation, + accounts: &(impl AccountRepository + ?Sized), + app_state: &(impl AppStateRepository + ?Sized), + secrets: &(impl SecretStore + ?Sized), + journal: &(impl OperationJournal + ?Sized), + clock: &(impl Clock + ?Sized), +) -> Result<(), SafeError> { + let public_key = operation.subject(); + if operation.phase() == AccountOperationPhase::IntentRecorded { + match secrets.delete(public_key) { + Ok(()) => {} + Err(error) + if error.code() == radroots_studio_domain::SafeErrorCode::CredentialMissing => {} + Err(error) => return Err(error), + } + journal.update_operation( + operation.id(), + AccountOperationPhase::CredentialDeleted, + clock.now(), + None, + )?; + } + if matches!( + operation.phase(), + AccountOperationPhase::IntentRecorded | AccountOperationPhase::CredentialDeleted + ) { + let registry = accounts.list_accounts()?; + let selected = removal_fallback(&registry, app_state.load_selected_account()?, public_key); + accounts.remove_account(public_key)?; + app_state.save_selected_account(selected)?; + journal.update_operation( + operation.id(), + AccountOperationPhase::MetadataDeleted, + clock.now(), + None, + )?; + } + journal.finalize_operation(operation.id()) +} + +fn recover_addition( + operation: &crate::PendingAccountOperation, + accounts: &(impl AccountRepository + ?Sized), + secrets: &(impl SecretStore + ?Sized), + journal: &(impl OperationJournal + ?Sized), + clock: &(impl Clock + ?Sized), +) -> Result<(), SafeError> { + let has_metadata = accounts.find_account(operation.subject())?.is_some(); + match operation.phase() { + AccountOperationPhase::CredentialWritten | AccountOperationPhase::CompensationPending + if !has_metadata => + { + match secrets.delete(operation.subject()) { + Ok(()) => {} + Err(error) + if error.code() == radroots_studio_domain::SafeErrorCode::CredentialMissing => { + } + Err(error) => return Err(error), + } + journal.update_operation( + operation.id(), + AccountOperationPhase::MetadataDeleted, + clock.now(), + None, + )?; + } + _ => {} + } + journal.finalize_operation(operation.id()) +} + +fn removal_fallback( + registry: &[radroots_studio_domain::AccountSummary], + selected: Option<PublicKey>, + removed: PublicKey, +) -> Option<PublicKey> { + if selected != Some(removed) { + return selected; + } + let index = registry + .iter() + .position(|account| account.public_key() == removed)?; + registry + .get(index + 1) + .or_else(|| index.checked_sub(1).and_then(|before| registry.get(before))) + .map(radroots_studio_domain::AccountSummary::public_key) +} diff --git a/crates/studio_application/src/secrets.rs b/crates/studio_application/src/secrets.rs @@ -0,0 +1,308 @@ +use std::collections::BTreeMap; +use std::sync::{Mutex, MutexGuard}; + +use radroots_studio_domain::{PublicKey, SafeError, SafeErrorCode, SafeMessage, SecretKeyInput}; +use secrecy::{ExposeSecret, SecretString}; + +pub trait SecretStore: Send + Sync { + /// Stores a credential under its canonical public key without overwriting. + /// + /// # Errors + /// + /// Returns a safe duplicate or keyring error without exposing the credential. + fn put(&self, public_key: PublicKey, secret: SecretKeyInput) -> Result<(), SafeError>; + /// Loads a credential into a non-cloneable redacted boundary value. + /// + /// # Errors + /// + /// Returns a safe missing-credential or keyring error. + fn load(&self, public_key: PublicKey) -> Result<SecretKeyInput, SafeError>; + /// Reports whether a credential exists without exposing it. + /// + /// # Errors + /// + /// Returns a safe keyring error when availability cannot be determined. + fn contains(&self, public_key: PublicKey) -> Result<bool, SafeError>; + /// Deletes a credential without affecting public account metadata. + /// + /// # Errors + /// + /// Returns a safe missing-credential or keyring error. + fn delete(&self, public_key: PublicKey) -> Result<(), SafeError>; +} + +#[derive(Default)] +pub struct InMemorySecretStore { + credentials: Mutex<BTreeMap<PublicKey, SecretString>>, +} + +#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)] +pub enum SecretStoreOperation { + Put, + Load, + Contains, + Delete, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct SecretStoreCall { + operation: SecretStoreOperation, + public_key: PublicKey, +} + +impl SecretStoreCall { + #[must_use] + pub const fn operation(self) -> SecretStoreOperation { + self.operation + } + + #[must_use] + pub const fn public_key(self) -> PublicKey { + self.public_key + } +} + +#[derive(Default)] +pub struct FailureSecretStore { + inner: InMemorySecretStore, + remaining_failures: Mutex<BTreeMap<SecretStoreOperation, usize>>, + calls: Mutex<Vec<SecretStoreCall>>, +} + +impl FailureSecretStore { + pub fn fail_next(&self, operation: SecretStoreOperation) { + *self + .remaining_failures + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .entry(operation) + .or_default() += 1; + } + + #[must_use] + pub fn calls(&self) -> Vec<SecretStoreCall> { + self.calls + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .clone() + } + + fn record_and_should_fail( + &self, + operation: SecretStoreOperation, + public_key: PublicKey, + ) -> bool { + self.calls + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .push(SecretStoreCall { + operation, + public_key, + }); + let mut failures = self + .remaining_failures + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let remaining = failures.entry(operation).or_default(); + let should_fail = *remaining > 0; + *remaining = remaining.saturating_sub(1); + should_fail + } +} + +impl SecretStore for FailureSecretStore { + fn put(&self, public_key: PublicKey, secret: SecretKeyInput) -> Result<(), SafeError> { + if self.record_and_should_fail(SecretStoreOperation::Put, public_key) { + return Err(keyring_unavailable()); + } + self.inner.put(public_key, secret) + } + + fn load(&self, public_key: PublicKey) -> Result<SecretKeyInput, SafeError> { + if self.record_and_should_fail(SecretStoreOperation::Load, public_key) { + return Err(keyring_unavailable()); + } + self.inner.load(public_key) + } + + fn contains(&self, public_key: PublicKey) -> Result<bool, SafeError> { + if self.record_and_should_fail(SecretStoreOperation::Contains, public_key) { + return Err(keyring_unavailable()); + } + self.inner.contains(public_key) + } + + fn delete(&self, public_key: PublicKey) -> Result<(), SafeError> { + if self.record_and_should_fail(SecretStoreOperation::Delete, public_key) { + return Err(keyring_unavailable()); + } + self.inner.delete(public_key) + } +} + +impl InMemorySecretStore { + fn credentials(&self) -> MutexGuard<'_, BTreeMap<PublicKey, SecretString>> { + self.credentials + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + } +} + +impl SecretStore for InMemorySecretStore { + fn put(&self, public_key: PublicKey, secret: SecretKeyInput) -> Result<(), SafeError> { + let mut credentials = self.credentials(); + if credentials.contains_key(&public_key) { + return Err(credential_exists()); + } + let value = secret.with_exposed_secret(ToOwned::to_owned); + credentials.insert(public_key, SecretString::from(value)); + Ok(()) + } + + fn load(&self, public_key: PublicKey) -> Result<SecretKeyInput, SafeError> { + let credentials = self.credentials(); + let secret = credentials + .get(&public_key) + .ok_or_else(credential_missing)?; + SecretKeyInput::parse(secret.expose_secret().to_owned()).map_err(|_| credential_missing()) + } + + fn contains(&self, public_key: PublicKey) -> Result<bool, SafeError> { + Ok(self.credentials().contains_key(&public_key)) + } + + fn delete(&self, public_key: PublicKey) -> Result<(), SafeError> { + self.credentials() + .remove(&public_key) + .map(|_| ()) + .ok_or_else(credential_missing) + } +} + +const fn credential_exists() -> SafeError { + SafeError::new( + SafeErrorCode::AccountAlreadyExists, + SafeMessage::new("The Nostr account credential already exists."), + ) +} + +const fn credential_missing() -> SafeError { + SafeError::new( + SafeErrorCode::CredentialMissing, + SafeMessage::new("The Nostr account credential is missing."), + ) +} + +const fn keyring_unavailable() -> SafeError { + SafeError::new( + SafeErrorCode::KeyringUnavailable, + SafeMessage::new("The operating system credential store is unavailable."), + ) +} + +#[cfg(test)] +mod tests { + use radroots_studio_domain::{PublicKey, SafeErrorCode, SecretKeyInput}; + + use super::{FailureSecretStore, InMemorySecretStore, SecretStore, SecretStoreOperation}; + + const SECRET: &str = "7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7"; + + #[test] + fn secret_store_puts_loads_checks_and_deletes_redacted_credentials() { + let store = InMemorySecretStore::default(); + let public_key = PublicKey::from_bytes([1; 32]); + assert!(!store.contains(public_key).expect("contains")); + store + .put( + public_key, + SecretKeyInput::parse(SECRET.to_owned()).expect("secret"), + ) + .expect("put"); + assert!(store.contains(public_key).expect("contains")); + let loaded = store.load(public_key).expect("load"); + assert_eq!(loaded.with_exposed_secret(str::len), 64); + store.delete(public_key).expect("delete"); + assert!(!store.contains(public_key).expect("contains")); + } + + #[test] + fn secret_store_rejects_duplicates_and_reports_missing_credentials() { + let store = InMemorySecretStore::default(); + let public_key = PublicKey::from_bytes([2; 32]); + let Err(missing) = store.load(public_key) else { + panic!("missing credential was returned"); + }; + assert_eq!(missing.code(), SafeErrorCode::CredentialMissing); + store + .put( + public_key, + SecretKeyInput::parse(SECRET.to_owned()).expect("secret"), + ) + .expect("put"); + let duplicate = store + .put( + public_key, + SecretKeyInput::parse(SECRET.to_owned()).expect("secret"), + ) + .expect_err("duplicate"); + assert_eq!(duplicate.code(), SafeErrorCode::AccountAlreadyExists); + store.delete(public_key).expect("delete"); + let missing = store.delete(public_key).expect_err("missing delete"); + assert_eq!(missing.code(), SafeErrorCode::CredentialMissing); + } + + #[test] + fn failure_secret_store_injects_each_boundary_without_mutating_state() { + let store = FailureSecretStore::default(); + let public_key = PublicKey::from_bytes([3; 32]); + store.fail_next(SecretStoreOperation::Put); + let error = store + .put( + public_key, + SecretKeyInput::parse(SECRET.to_owned()).expect("secret"), + ) + .expect_err("put failure"); + assert_eq!(error.code(), SafeErrorCode::KeyringUnavailable); + assert!(!store.contains(public_key).expect("not written")); + + store + .put( + public_key, + SecretKeyInput::parse(SECRET.to_owned()).expect("secret"), + ) + .expect("put"); + for operation in [ + SecretStoreOperation::Load, + SecretStoreOperation::Contains, + SecretStoreOperation::Delete, + ] { + store.fail_next(operation); + let error = match operation { + SecretStoreOperation::Load => store.load(public_key).map(|_| ()), + SecretStoreOperation::Contains => store.contains(public_key).map(|_| ()), + SecretStoreOperation::Delete => store.delete(public_key), + SecretStoreOperation::Put => unreachable!("put tested separately"), + } + .expect_err("injected failure"); + assert_eq!(error.code(), SafeErrorCode::KeyringUnavailable); + } + assert!(store.contains(public_key).expect("credential retained")); + } + + #[test] + fn failure_secret_store_call_log_contains_only_public_identity() { + let store = FailureSecretStore::default(); + let public_key = PublicKey::from_bytes([4; 32]); + store + .put( + public_key, + SecretKeyInput::parse(SECRET.to_owned()).expect("secret"), + ) + .expect("put"); + let calls = store.calls(); + assert_eq!(calls[0].operation(), SecretStoreOperation::Put); + assert_eq!(calls[0].public_key(), public_key); + assert!(!format!("{calls:?}").contains(SECRET)); + } +} diff --git a/crates/studio_application/src/session.rs b/crates/studio_application/src/session.rs @@ -0,0 +1,250 @@ +use radroots_studio_domain::{PublicKey, SafeError, SafeErrorCode, SafeMessage}; +use radroots_studio_nostr::import_secret; + +use crate::{ + AccountRepository, ActiveAccountSnapshot, AppCore, AppSnapshot, AppStateRepository, Clock, + ProfileLoadState, ProfileRepository, RelayConnectionState, SecretStore, StateTransition, +}; + +impl AppCore { + /// Drops the active session while retaining accounts, selection, and credentials. + /// + /// # Errors + /// + /// Returns a safe application-state error if the transition cannot be applied. + pub fn sign_out(&self) -> Result<AppSnapshot, SafeError> { + if matches!(self.snapshot().session(), crate::SessionState::SignedOut) { + return Ok(self.snapshot()); + } + self.apply_transition(StateTransition::SignOut) + } + + /// Validates and prepares a saved local account before replacing the active session. + /// + /// # Errors + /// + /// Returns a safe account, credential, profile-cache, persistence, or state + /// error while preserving any previously active session. + pub fn activate_account( + &self, + public_key: PublicKey, + accounts: &(impl AccountRepository + ?Sized), + app_state: &(impl AppStateRepository + ?Sized), + profiles: &(impl ProfileRepository + ?Sized), + secrets: &(impl SecretStore + ?Sized), + clock: &(impl Clock + ?Sized), + ) -> Result<AppSnapshot, SafeError> { + let account = accounts + .find_account(public_key)? + .ok_or_else(account_not_found)?; + self.apply_transition(StateTransition::BeginActivation(public_key))?; + let prepared = (|| { + let credential = secrets.load(public_key)?; + let imported = import_secret(credential)?; + let (derived_public_key, _npub, canonical_secret) = imported.into_parts(); + drop(canonical_secret); + if derived_public_key != public_key { + return Err(invalid_credential()); + } + let cached = profiles.load_profile(public_key)?; + let active = ActiveAccountSnapshot::new( + account.with_last_used_at(clock.now()), + RelayConnectionState::Disconnected, + if cached.is_some() { + ProfileLoadState::Cached + } else { + ProfileLoadState::Empty + }, + cached.map(|profile| profile.candidate().metadata().clone()), + ); + accounts.update_account(active.account())?; + app_state.save_selected_account(Some(public_key))?; + Ok(active) + })(); + match prepared { + Ok(active) => { + self.apply_transition(StateTransition::ActivationSucceeded(Box::new(active))) + } + Err(error) => { + self.apply_transition(StateTransition::ActivationFailed(error))?; + Err(error) + } + } + } +} + +const fn account_not_found() -> SafeError { + SafeError::new( + SafeErrorCode::AccountNotFound, + SafeMessage::new("The account was not found."), + ) +} + +const fn invalid_credential() -> SafeError { + SafeError::new( + SafeErrorCode::InvalidSecretKey, + SafeMessage::new("The Nostr account credential is invalid."), + ) +} + +#[cfg(test)] +mod tests { + use radroots_studio_domain::{PublicKey, SafeError, SecretKeyInput, UnixTimestamp}; + + use crate::{ + AppCore, CachedProfile, Clock, InMemoryAccountRepository, InMemoryOperationJournal, + InMemorySecretStore, ProfileRefreshStatus, ProfileRepository, RelayConfiguration, + SecretStore, SessionState, + }; + + #[derive(Default)] + struct EmptyProfiles; + + impl ProfileRepository for EmptyProfiles { + fn load_profile(&self, _public_key: PublicKey) -> Result<Option<CachedProfile>, SafeError> { + Ok(None) + } + + fn save_profile(&self, _profile: &CachedProfile) -> Result<(), SafeError> { + Ok(()) + } + + fn record_refresh_status( + &self, + _public_key: PublicKey, + _refreshed_at: UnixTimestamp, + _status: ProfileRefreshStatus, + ) -> Result<(), SafeError> { + Ok(()) + } + + fn remove_profile(&self, _public_key: PublicKey) -> Result<(), SafeError> { + Ok(()) + } + } + + struct FixedClock; + + impl Clock for FixedClock { + fn now(&self) -> UnixTimestamp { + UnixTimestamp::from_seconds(30).expect("time") + } + } + + fn input(value: &str) -> SecretKeyInput { + SecretKeyInput::parse(value.to_owned()).expect("input") + } + + #[test] + fn activate_account_switches_only_after_candidate_is_ready() { + let core = AppCore::in_memory(RelayConfiguration::default()); + let accounts = InMemoryAccountRepository::default(); + let secrets = InMemorySecretStore::default(); + let journal = InMemoryOperationJournal::default(); + let profiles = EmptyProfiles; + core.bootstrap().expect("bootstrap"); + let first = core + .import_secret_key( + input("7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7"), + &accounts, + &accounts, + &secrets, + &journal, + &FixedClock, + ) + .expect("first") + .account() + .public_key(); + let second = core + .import_secret_key( + input("1111111111111111111111111111111111111111111111111111111111111111"), + &accounts, + &accounts, + &secrets, + &journal, + &FixedClock, + ) + .expect("second") + .account() + .public_key(); + core.activate_account( + first, + &accounts, + &accounts, + &profiles, + &secrets, + &FixedClock, + ) + .expect("activate first"); + assert_eq!(core.snapshot().session(), SessionState::Active); + assert_eq!( + core.snapshot() + .active_account() + .map(|active| active.account().public_key()), + Some(first) + ); + + secrets.delete(second).expect("remove second credential"); + let error = core + .activate_account( + second, + &accounts, + &accounts, + &profiles, + &secrets, + &FixedClock, + ) + .expect_err("missing credential"); + assert_eq!( + error.code(), + radroots_studio_domain::SafeErrorCode::CredentialMissing + ); + assert_eq!(core.snapshot().session(), SessionState::Active); + assert_eq!( + core.snapshot() + .active_account() + .map(|active| active.account().public_key()), + Some(first) + ); + } + + #[test] + fn sign_out_retains_saved_account_selection_and_credential() { + let core = AppCore::in_memory(RelayConfiguration::default()); + let accounts = InMemoryAccountRepository::default(); + let secrets = InMemorySecretStore::default(); + let journal = InMemoryOperationJournal::default(); + let profiles = EmptyProfiles; + core.bootstrap().expect("bootstrap"); + let public_key = core + .import_secret_key( + input("7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7"), + &accounts, + &accounts, + &secrets, + &journal, + &FixedClock, + ) + .expect("import") + .account() + .public_key(); + core.activate_account( + public_key, + &accounts, + &accounts, + &profiles, + &secrets, + &FixedClock, + ) + .expect("activate"); + + let signed_out = core.sign_out().expect("sign out"); + let repeated = core.sign_out().expect("idempotent sign out"); + assert_eq!(signed_out, repeated); + assert_eq!(signed_out.session(), SessionState::SignedOut); + assert!(signed_out.active_account().is_none()); + assert_eq!(signed_out.accounts().len(), 1); + assert_eq!(signed_out.selected_account(), Some(public_key)); + assert!(secrets.contains(public_key).expect("credential retained")); + } +} diff --git a/crates/studio_application/src/snapshot.rs b/crates/studio_application/src/snapshot.rs @@ -0,0 +1,385 @@ +use std::collections::HashSet; + +use radroots_studio_domain::{ + AccountSummary, ProfileMetadata, PublicKey, RelayUrl, SafeError, SafeErrorCode, SafeMessage, +}; + +#[derive(Clone, Copy, Debug, Default, Eq, Ord, PartialEq, PartialOrd)] +pub struct SnapshotRevision(u64); + +impl SnapshotRevision { + #[must_use] + pub const fn initial() -> Self { + Self(0) + } + + #[must_use] + pub const fn from_value(value: u64) -> Self { + Self(value) + } + + #[must_use] + pub const fn value(self) -> u64 { + self.0 + } + + #[must_use] + pub const fn next(self) -> Option<Self> { + match self.0.checked_add(1) { + Some(value) => Some(Self(value)), + None => None, + } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum AppLifecycle { + Booting, + Ready, + Fatal(SafeError), +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum SessionState { + SignedOut, + Activating(PublicKey), + Active, + SigningOut, + Failed(SafeError), +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum RelayConnectionState { + Disconnected, + Connecting, + Connected, + Degraded, + Error(SafeError), +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ProfileLoadState { + Empty, + Loading, + Cached, + Fresh, + Error(SafeError), +} + +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct RelayConfiguration(Vec<RelayUrl>); + +impl RelayConfiguration { + #[must_use] + pub fn new(relays: Vec<RelayUrl>) -> Self { + Self(relays) + } + + #[must_use] + pub fn relays(&self) -> &[RelayUrl] { + &self.0 + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ActiveAccountSnapshot { + account: AccountSummary, + relay_state: RelayConnectionState, + profile_state: ProfileLoadState, + profile: Option<ProfileMetadata>, +} + +impl ActiveAccountSnapshot { + #[must_use] + pub const fn new( + account: AccountSummary, + relay_state: RelayConnectionState, + profile_state: ProfileLoadState, + profile: Option<ProfileMetadata>, + ) -> Self { + Self { + account, + relay_state, + profile_state, + profile, + } + } + + #[must_use] + pub const fn account(&self) -> &AccountSummary { + &self.account + } + + #[must_use] + pub const fn relay_state(&self) -> RelayConnectionState { + self.relay_state + } + + #[must_use] + pub const fn profile_state(&self) -> ProfileLoadState { + self.profile_state + } + + #[must_use] + pub const fn profile(&self) -> Option<&ProfileMetadata> { + self.profile.as_ref() + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct AppSnapshot { + revision: SnapshotRevision, + lifecycle: AppLifecycle, + relay_configuration: RelayConfiguration, + accounts: Vec<AccountSummary>, + selected_account: Option<PublicKey>, + session: SessionState, + active_account: Option<ActiveAccountSnapshot>, + recoverable_problem: Option<SafeError>, +} + +impl AppSnapshot { + #[must_use] + pub fn booting() -> Self { + Self { + revision: SnapshotRevision::initial(), + lifecycle: AppLifecycle::Booting, + relay_configuration: RelayConfiguration::default(), + accounts: Vec::new(), + selected_account: None, + session: SessionState::SignedOut, + active_account: None, + recoverable_problem: None, + } + } + + #[must_use] + pub fn fatal( + revision: SnapshotRevision, + relay_configuration: RelayConfiguration, + error: SafeError, + ) -> Self { + Self { + revision, + lifecycle: AppLifecycle::Fatal(error), + relay_configuration, + accounts: Vec::new(), + selected_account: None, + session: SessionState::SignedOut, + active_account: None, + recoverable_problem: None, + } + } + + /// Constructs a ready immutable snapshot after validating state invariants. + /// + /// # Errors + /// + /// Returns a safe invalid-state error for duplicate accounts, invalid + /// selection, or inconsistent active-session state. + pub fn ready( + revision: SnapshotRevision, + relay_configuration: RelayConfiguration, + accounts: Vec<AccountSummary>, + selected_account: Option<PublicKey>, + session: SessionState, + active_account: Option<ActiveAccountSnapshot>, + recoverable_problem: Option<SafeError>, + ) -> Result<Self, SafeError> { + validate_snapshot( + &accounts, + selected_account, + session, + active_account.as_ref(), + )?; + Ok(Self { + revision, + lifecycle: AppLifecycle::Ready, + relay_configuration, + accounts, + selected_account, + session, + active_account, + recoverable_problem, + }) + } + + #[must_use] + pub const fn revision(&self) -> SnapshotRevision { + self.revision + } + + #[must_use] + pub const fn lifecycle(&self) -> AppLifecycle { + self.lifecycle + } + + #[must_use] + pub const fn relay_configuration(&self) -> &RelayConfiguration { + &self.relay_configuration + } + + #[must_use] + pub fn accounts(&self) -> &[AccountSummary] { + &self.accounts + } + + #[must_use] + pub const fn selected_account(&self) -> Option<PublicKey> { + self.selected_account + } + + #[must_use] + pub const fn session(&self) -> SessionState { + self.session + } + + #[must_use] + pub const fn active_account(&self) -> Option<&ActiveAccountSnapshot> { + self.active_account.as_ref() + } + + #[must_use] + pub const fn recoverable_problem(&self) -> Option<SafeError> { + self.recoverable_problem + } +} + +fn validate_snapshot( + accounts: &[AccountSummary], + selected_account: Option<PublicKey>, + session: SessionState, + active_account: Option<&ActiveAccountSnapshot>, +) -> Result<(), SafeError> { + let unique_accounts = accounts + .iter() + .map(AccountSummary::public_key) + .collect::<HashSet<_>>(); + if unique_accounts.len() != accounts.len() + || (accounts.is_empty() != selected_account.is_none()) + || selected_account.is_some_and(|key| !unique_accounts.contains(&key)) + || active_account + .is_some_and(|active| !unique_accounts.contains(&active.account().public_key())) + || (matches!(session, SessionState::Active) && active_account.is_none()) + || (matches!(session, SessionState::SignedOut) && active_account.is_some()) + { + return Err(invalid_snapshot()); + } + Ok(()) +} + +const fn invalid_snapshot() -> SafeError { + SafeError::new( + SafeErrorCode::InvalidApplicationState, + SafeMessage::new("The application state is invalid."), + ) +} + +#[cfg(test)] +mod tests { + use radroots_studio_domain::{ + AccountCreatedAt, AccountIdentity, AccountSummary, BindingAvailability, LocalSignerBinding, + PublicKey, UnixTimestamp, + }; + + use super::{ + ActiveAccountSnapshot, AppLifecycle, AppSnapshot, ProfileLoadState, RelayConfiguration, + RelayConnectionState, SessionState, SnapshotRevision, + }; + + fn account(key_byte: u8) -> AccountSummary { + let public_key = PublicKey::from_bytes([key_byte; 32]); + AccountSummary::new( + AccountIdentity::derive(public_key).expect("identity"), + LocalSignerBinding::new(public_key, BindingAvailability::Available), + None, + AccountCreatedAt::new(UnixTimestamp::from_seconds(1).expect("valid time")), + None, + ) + .expect("account") + } + + #[test] + fn snapshot_boots_empty_and_secret_free() { + let snapshot = AppSnapshot::booting(); + let debug = format!("{snapshot:?}"); + + assert_eq!(snapshot.revision(), SnapshotRevision::initial()); + assert_eq!(snapshot.lifecycle(), AppLifecycle::Booting); + assert_eq!(snapshot.session(), SessionState::SignedOut); + assert!(snapshot.accounts().is_empty()); + assert!(snapshot.selected_account().is_none()); + assert!(snapshot.active_account().is_none()); + assert!(snapshot.relay_configuration().relays().is_empty()); + assert!(snapshot.recoverable_problem().is_none()); + assert!(!debug.contains("nsec1")); + assert!(!debug.contains(&"11".repeat(32))); + } + + #[test] + fn revision_helper_is_monotonic_and_checked() { + assert_eq!( + SnapshotRevision::initial() + .next() + .map(SnapshotRevision::value), + Some(1) + ); + assert_eq!(SnapshotRevision::from_value(u64::MAX).next(), None); + } + + #[test] + fn ready_snapshot_requires_valid_selection_and_active_session() { + let first = account(1); + let second = account(2); + let active = ActiveAccountSnapshot::new( + second.clone(), + RelayConnectionState::Disconnected, + ProfileLoadState::Empty, + None, + ); + let valid = AppSnapshot::ready( + SnapshotRevision::from_value(1), + RelayConfiguration::default(), + vec![first.clone(), second.clone()], + Some(first.public_key()), + SessionState::Active, + Some(active), + None, + ) + .expect("valid ready snapshot"); + + assert_eq!(valid.lifecycle(), AppLifecycle::Ready); + assert_eq!(valid.selected_account(), Some(first.public_key())); + assert_eq!( + valid + .active_account() + .map(|value| value.account().public_key()), + Some(second.public_key()) + ); + + assert!( + AppSnapshot::ready( + SnapshotRevision::initial(), + RelayConfiguration::default(), + vec![first.clone(), first], + Some(second.public_key()), + SessionState::SignedOut, + None, + None, + ) + .is_err() + ); + assert!( + AppSnapshot::ready( + SnapshotRevision::initial(), + RelayConfiguration::default(), + vec![second.clone()], + Some(second.public_key()), + SessionState::Active, + None, + None, + ) + .is_err() + ); + } +} diff --git a/crates/studio_application/src/state_machine.rs b/crates/studio_application/src/state_machine.rs @@ -0,0 +1,506 @@ +use radroots_studio_domain::{AccountSummary, PublicKey, SafeError, SafeErrorCode, SafeMessage}; + +use crate::{ActiveAccountSnapshot, AppLifecycle, AppSnapshot, RelayConfiguration, SessionState}; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum StateTransition { + Bootstrap, + BootstrapRegistry { + accounts: Vec<AccountSummary>, + selected: Option<PublicKey>, + }, + Fatal(SafeError), + ReplaceRegistry { + accounts: Vec<AccountSummary>, + selected: Option<PublicKey>, + }, + ReplaceRegistryPreservingSession { + accounts: Vec<AccountSummary>, + selected: Option<PublicKey>, + }, + Select(PublicKey), + BeginActivation(PublicKey), + ActivationSucceeded(Box<ActiveAccountSnapshot>), + ActivationFailed(SafeError), + UpdateActiveAccount { + expected: PublicKey, + active_account: Box<ActiveAccountSnapshot>, + problem: Option<SafeError>, + }, + SignOut, + SetProblem(Option<SafeError>), +} + +#[derive(Clone)] +struct PreviousSession { + session: SessionState, + active_account: Option<ActiveAccountSnapshot>, +} + +pub struct StateMachine { + snapshot: AppSnapshot, + pending_activation: Option<(PublicKey, PreviousSession)>, +} + +impl StateMachine { + #[must_use] + pub fn booting() -> Self { + Self { + snapshot: AppSnapshot::booting(), + pending_activation: None, + } + } + + #[must_use] + pub const fn snapshot(&self) -> &AppSnapshot { + &self.snapshot + } + + /// Applies one deterministic state transition and returns the new snapshot. + /// + /// # Errors + /// + /// Returns a safe application error when the transition violates account, + /// revision, activation, or snapshot invariants. + pub fn apply( + &mut self, + transition: StateTransition, + relay_configuration: &RelayConfiguration, + ) -> Result<AppSnapshot, SafeError> { + let next_revision = self + .snapshot + .revision() + .next() + .ok_or_else(invalid_application_state)?; + + let next = match transition { + StateTransition::Bootstrap => self.bootstrap(next_revision, relay_configuration)?, + StateTransition::BootstrapRegistry { accounts, selected } => { + self.bootstrap_registry(next_revision, relay_configuration, accounts, selected)? + } + StateTransition::Fatal(error) => { + AppSnapshot::fatal(next_revision, relay_configuration.clone(), error) + } + StateTransition::ReplaceRegistry { accounts, selected } => { + self.replace_registry(next_revision, accounts, selected)? + } + StateTransition::ReplaceRegistryPreservingSession { accounts, selected } => { + self.replace_registry_preserving_session(next_revision, accounts, selected)? + } + StateTransition::Select(public_key) => self.select(next_revision, public_key)?, + StateTransition::BeginActivation(public_key) => { + self.begin_activation(next_revision, public_key)? + } + StateTransition::ActivationSucceeded(active_account) => { + self.activation_succeeded(next_revision, *active_account)? + } + StateTransition::ActivationFailed(problem) => { + self.activation_failed(next_revision, problem)? + } + StateTransition::UpdateActiveAccount { + expected, + active_account, + problem, + } => self.update_active_account(next_revision, expected, *active_account, problem)?, + StateTransition::SignOut => self.sign_out(next_revision)?, + StateTransition::SetProblem(problem) => self.copy_ready( + next_revision, + self.snapshot.selected_account(), + self.snapshot.session(), + self.snapshot.active_account().cloned(), + problem, + )?, + }; + self.snapshot = next.clone(); + Ok(next) + } + + fn bootstrap( + &self, + revision: crate::SnapshotRevision, + relay_configuration: &RelayConfiguration, + ) -> Result<AppSnapshot, SafeError> { + if !matches!(self.snapshot.lifecycle(), AppLifecycle::Booting) { + return Ok(self.snapshot.clone()); + } + AppSnapshot::ready( + revision, + relay_configuration.clone(), + Vec::new(), + None, + SessionState::SignedOut, + None, + None, + ) + } + + fn bootstrap_registry( + &self, + revision: crate::SnapshotRevision, + relay_configuration: &RelayConfiguration, + accounts: Vec<AccountSummary>, + selected: Option<PublicKey>, + ) -> Result<AppSnapshot, SafeError> { + if !matches!(self.snapshot.lifecycle(), AppLifecycle::Booting) { + return Ok(self.snapshot.clone()); + } + AppSnapshot::ready( + revision, + relay_configuration.clone(), + accounts, + selected, + SessionState::SignedOut, + None, + None, + ) + } + + fn replace_registry( + &mut self, + revision: crate::SnapshotRevision, + accounts: Vec<AccountSummary>, + selected: Option<PublicKey>, + ) -> Result<AppSnapshot, SafeError> { + self.pending_activation = None; + AppSnapshot::ready( + revision, + self.snapshot.relay_configuration().clone(), + accounts, + selected, + SessionState::SignedOut, + None, + None, + ) + } + + fn replace_registry_preserving_session( + &mut self, + revision: crate::SnapshotRevision, + accounts: Vec<AccountSummary>, + selected: Option<PublicKey>, + ) -> Result<AppSnapshot, SafeError> { + self.pending_activation = None; + AppSnapshot::ready( + revision, + self.snapshot.relay_configuration().clone(), + accounts, + selected, + self.snapshot.session(), + self.snapshot.active_account().cloned(), + None, + ) + } + + fn select( + &self, + revision: crate::SnapshotRevision, + public_key: PublicKey, + ) -> Result<AppSnapshot, SafeError> { + self.require_account(public_key)?; + self.copy_ready( + revision, + Some(public_key), + self.snapshot.session(), + self.snapshot.active_account().cloned(), + None, + ) + } + + fn begin_activation( + &mut self, + revision: crate::SnapshotRevision, + public_key: PublicKey, + ) -> Result<AppSnapshot, SafeError> { + self.require_account(public_key)?; + if self.pending_activation.is_some() { + return Err(invalid_application_state()); + } + self.pending_activation = Some(( + public_key, + PreviousSession { + session: self.snapshot.session(), + active_account: self.snapshot.active_account().cloned(), + }, + )); + self.copy_ready( + revision, + self.snapshot.selected_account(), + SessionState::Activating(public_key), + self.snapshot.active_account().cloned(), + None, + ) + } + + fn activation_succeeded( + &mut self, + revision: crate::SnapshotRevision, + active_account: ActiveAccountSnapshot, + ) -> Result<AppSnapshot, SafeError> { + let Some((target, _previous)) = self.pending_activation.as_ref() else { + return Err(invalid_application_state()); + }; + if active_account.account().public_key() != *target { + return Err(invalid_application_state()); + } + let target = *target; + self.pending_activation = None; + self.copy_ready( + revision, + Some(target), + SessionState::Active, + Some(active_account), + None, + ) + } + + fn activation_failed( + &mut self, + revision: crate::SnapshotRevision, + problem: SafeError, + ) -> Result<AppSnapshot, SafeError> { + let Some((_target, previous)) = self.pending_activation.take() else { + return Err(invalid_application_state()); + }; + self.copy_ready( + revision, + self.snapshot.selected_account(), + previous.session, + previous.active_account, + Some(problem), + ) + } + + fn sign_out(&mut self, revision: crate::SnapshotRevision) -> Result<AppSnapshot, SafeError> { + self.pending_activation = None; + self.copy_ready( + revision, + self.snapshot.selected_account(), + SessionState::SignedOut, + None, + None, + ) + } + + fn update_active_account( + &self, + revision: crate::SnapshotRevision, + expected: PublicKey, + active_account: ActiveAccountSnapshot, + problem: Option<SafeError>, + ) -> Result<AppSnapshot, SafeError> { + if !matches!(self.snapshot.session(), SessionState::Active) + || self + .snapshot + .active_account() + .map(|active| active.account().public_key()) + != Some(expected) + || active_account.account().public_key() != expected + { + return Err(invalid_application_state()); + } + self.copy_ready( + revision, + self.snapshot.selected_account(), + SessionState::Active, + Some(active_account), + problem, + ) + } + + fn require_account(&self, public_key: PublicKey) -> Result<(), SafeError> { + if self + .snapshot + .accounts() + .iter() + .any(|account| account.public_key() == public_key) + { + Ok(()) + } else { + Err(account_not_found()) + } + } + + fn copy_ready( + &self, + revision: crate::SnapshotRevision, + selected_account: Option<PublicKey>, + session: SessionState, + active_account: Option<ActiveAccountSnapshot>, + recoverable_problem: Option<SafeError>, + ) -> Result<AppSnapshot, SafeError> { + AppSnapshot::ready( + revision, + self.snapshot.relay_configuration().clone(), + self.snapshot.accounts().to_vec(), + selected_account, + session, + active_account, + recoverable_problem, + ) + } +} + +const fn invalid_application_state() -> SafeError { + SafeError::new( + SafeErrorCode::InvalidApplicationState, + SafeMessage::new("The application state is invalid."), + ) +} + +const fn account_not_found() -> SafeError { + SafeError::new( + SafeErrorCode::AccountNotFound, + SafeMessage::new("The account was not found."), + ) +} + +#[cfg(test)] +mod tests { + use radroots_studio_domain::{ + AccountCreatedAt, AccountIdentity, AccountSummary, BindingAvailability, LocalSignerBinding, + PublicKey, SafeError, SafeErrorCode, SafeMessage, UnixTimestamp, + }; + + use crate::{ + ActiveAccountSnapshot, ProfileLoadState, RelayConfiguration, RelayConnectionState, + SessionState, StateMachine, StateTransition, + }; + + fn account(key_byte: u8) -> AccountSummary { + let public_key = PublicKey::from_bytes([key_byte; 32]); + AccountSummary::new( + AccountIdentity::derive(public_key).expect("identity"), + LocalSignerBinding::new(public_key, BindingAvailability::Available), + None, + AccountCreatedAt::new(UnixTimestamp::from_seconds(1).expect("valid time")), + None, + ) + .expect("account") + } + + fn active(account: AccountSummary) -> ActiveAccountSnapshot { + ActiveAccountSnapshot::new( + account, + RelayConnectionState::Disconnected, + ProfileLoadState::Empty, + None, + ) + } + + #[test] + fn state_machine_command_trace_preserves_working_session_on_failed_replacement() { + let first = account(1); + let second = account(2); + let mut machine = StateMachine::booting(); + let relays = RelayConfiguration::default(); + let problem = SafeError::new( + SafeErrorCode::CredentialMissing, + SafeMessage::new("The account credential is missing."), + ); + + machine + .apply(StateTransition::Bootstrap, &relays) + .expect("bootstrap"); + machine + .apply( + StateTransition::ReplaceRegistry { + accounts: vec![first.clone(), second.clone()], + selected: Some(first.public_key()), + }, + &relays, + ) + .expect("load registry"); + machine + .apply( + StateTransition::BeginActivation(first.public_key()), + &relays, + ) + .expect("begin first activation"); + machine + .apply( + StateTransition::ActivationSucceeded(Box::new(active(first.clone()))), + &relays, + ) + .expect("activate first"); + machine + .apply(StateTransition::Select(second.public_key()), &relays) + .expect("select second"); + let pending = machine + .apply( + StateTransition::BeginActivation(second.public_key()), + &relays, + ) + .expect("begin replacement"); + let restored = machine + .apply(StateTransition::ActivationFailed(problem), &relays) + .expect("fail replacement"); + + assert_eq!( + pending.session(), + SessionState::Activating(second.public_key()) + ); + assert_eq!( + pending + .active_account() + .map(|value| value.account().public_key()), + Some(first.public_key()) + ); + assert_eq!(restored.session(), SessionState::Active); + assert_eq!(restored.selected_account(), Some(second.public_key())); + assert_eq!( + restored + .active_account() + .map(|value| value.account().public_key()), + Some(first.public_key()) + ); + assert_eq!(restored.recoverable_problem(), Some(problem)); + assert_eq!(restored.revision().value(), 7); + } + + #[test] + fn state_machine_rejects_missing_targets_and_signs_out_without_deleting() { + let account = account(1); + let mut machine = StateMachine::booting(); + let relays = RelayConfiguration::default(); + machine + .apply(StateTransition::Bootstrap, &relays) + .expect("bootstrap"); + machine + .apply( + StateTransition::ReplaceRegistry { + accounts: vec![account.clone()], + selected: Some(account.public_key()), + }, + &relays, + ) + .expect("load registry"); + + let error = machine + .apply( + StateTransition::Select(PublicKey::from_bytes([9_u8; 32])), + &relays, + ) + .expect_err("missing account"); + assert_eq!(error.code(), SafeErrorCode::AccountNotFound); + + machine + .apply( + StateTransition::BeginActivation(account.public_key()), + &relays, + ) + .expect("begin activation"); + machine + .apply( + StateTransition::ActivationSucceeded(Box::new(active(account.clone()))), + &relays, + ) + .expect("activate"); + let signed_out = machine + .apply(StateTransition::SignOut, &relays) + .expect("sign out"); + + assert_eq!(signed_out.accounts(), &[account]); + assert_eq!(signed_out.session(), SessionState::SignedOut); + assert!(signed_out.active_account().is_none()); + } +} diff --git a/crates/studio_application/tests/redaction.rs b/crates/studio_application/tests/redaction.rs @@ -0,0 +1,47 @@ +use radroots_studio_application::{ + AppSnapshot, RelayConfiguration, SessionState, SnapshotRevision, +}; +use radroots_studio_domain::{ + AccountCreatedAt, AccountIdentity, AccountSummary, BindingAvailability, LocalSignerBinding, + PublicKey, SafeError, SafeErrorCode, SafeMessage, UnixTimestamp, +}; + +const SECRET_HEX: &str = "1111111111111111111111111111111111111111111111111111111111111111"; +const SECRET_NSEC: &str = "nsec1vl029mgpspedva04g90vltkh6fvh240zqtv9k0t9af8935ke9laqsnlfe5"; +fn assert_redacted(text: &str) { + assert!(!text.contains(SECRET_HEX)); + assert!(!text.contains(SECRET_NSEC)); + assert!(!text.contains("nsec1")); +} + +#[test] +fn redaction_guards_public_snapshot_and_safe_error_debug() { + let account = AccountSummary::new( + AccountIdentity::derive(PublicKey::from_bytes([2; 32])).expect("identity"), + LocalSignerBinding::new( + PublicKey::from_bytes([2; 32]), + BindingAvailability::Available, + ), + None, + AccountCreatedAt::new(UnixTimestamp::from_seconds(1).expect("time")), + None, + ) + .expect("account"); + let snapshot = AppSnapshot::ready( + SnapshotRevision::from_value(1), + RelayConfiguration::default(), + vec![account.clone()], + Some(account.public_key()), + SessionState::SignedOut, + None, + None, + ) + .expect("snapshot"); + let error = SafeError::new( + SafeErrorCode::KeyringUnavailable, + SafeMessage::new("The operating system credential store is unavailable."), + ); + + assert_redacted(&format!("{snapshot:?}")); + assert_redacted(&format!("{error:?} {error}")); +} diff --git a/crates/studio_domain/Cargo.toml b/crates/studio_domain/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "radroots-studio-domain" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true + +[dependencies] +bech32.workspace = true +secrecy.workspace = true +zeroize.workspace = true +url.workspace = true + +[lints] +workspace = true diff --git a/crates/studio_domain/src/account.rs b/crates/studio_domain/src/account.rs @@ -0,0 +1,423 @@ +//! Public account metadata and lifecycle values. + +use crate::time::UnixTimestamp; +use crate::{Npub, PublicKey, SafeError, SafeErrorCode, SafeMessage}; + +const MAX_ACCOUNT_LABEL_CHARS: usize = 80; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct AccountIdentity { + public_key: PublicKey, + npub: Npub, +} + +impl AccountIdentity { + /// Constructs one canonical Nostr account identity and derives its npub. + /// + /// # Errors + /// + /// Returns a safe public-key error if canonical NIP-19 encoding fails. + pub fn derive(public_key: PublicKey) -> Result<Self, SafeError> { + Ok(Self { + public_key, + npub: Npub::derive(public_key)?, + }) + } + + /// Reconstitutes persisted identity only when its public forms agree. + /// + /// # Errors + /// + /// Returns a safe public-key error for a mismatched or malformed npub. + pub fn verify(public_key: PublicKey, npub: String) -> Result<Self, SafeError> { + Ok(Self { + public_key, + npub: Npub::verify(public_key, npub)?, + }) + } + + #[must_use] + pub const fn public_key(&self) -> PublicKey { + self.public_key + } + + #[must_use] + pub const fn npub(&self) -> &Npub { + &self.npub + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct LocalSignerBinding { + account: PublicKey, + availability: BindingAvailability, +} + +impl LocalSignerBinding { + #[must_use] + pub const fn new(account: PublicKey, availability: BindingAvailability) -> Self { + Self { + account, + availability, + } + } + + #[must_use] + pub const fn account(self) -> PublicKey { + self.account + } + + #[must_use] + pub const fn availability(self) -> BindingAvailability { + self.availability + } + + #[must_use] + pub const fn repair_action(self) -> Option<BindingRepairAction> { + match self.availability { + BindingAvailability::Available => None, + BindingAvailability::CredentialMissing => Some(BindingRepairAction::ImportCredential), + BindingAvailability::StoreUnavailable => { + Some(BindingRepairAction::RetryCredentialStore) + } + } + } + + /// Records a missing credential after a successful store lookup. + /// + /// # Errors + /// + /// Returns a safe state error unless the binding was previously available. + pub fn mark_credential_missing(&mut self) -> Result<(), SafeError> { + self.transition( + BindingAvailability::Available, + BindingAvailability::CredentialMissing, + ) + } + + pub fn mark_store_unavailable(&mut self) { + self.availability = BindingAvailability::StoreUnavailable; + } + + /// Completes an explicit credential repair. + /// + /// # Errors + /// + /// Returns a safe state error unless a credential was missing. + pub fn repair_credential(&mut self) -> Result<(), SafeError> { + self.transition( + BindingAvailability::CredentialMissing, + BindingAvailability::Available, + ) + } + + /// Resolves a recovered store lookup to its observed credential state. + /// + /// # Errors + /// + /// Returns a safe state error unless the credential store was unavailable. + pub fn resolve_store_recovery(&mut self, credential_present: bool) -> Result<(), SafeError> { + if self.availability != BindingAvailability::StoreUnavailable { + return Err(invalid_account_metadata()); + } + self.availability = if credential_present { + BindingAvailability::Available + } else { + BindingAvailability::CredentialMissing + }; + Ok(()) + } + + fn transition( + &mut self, + expected: BindingAvailability, + next: BindingAvailability, + ) -> Result<(), SafeError> { + if self.availability != expected { + return Err(invalid_account_metadata()); + } + self.availability = next; + Ok(()) + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum BindingAvailability { + Available, + CredentialMissing, + StoreUnavailable, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum BindingRepairAction { + ImportCredential, + RetryCredentialStore, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct AccountLabel(String); + +impl AccountLabel { + /// Trims and validates an optional human-assigned account label value. + /// + /// # Errors + /// + /// Returns a safe metadata error when the resulting label is empty, too + /// long, or contains a control character. + pub fn parse(value: &str) -> Result<Self, SafeError> { + let normalized = value.trim(); + if normalized.is_empty() + || normalized.chars().count() > MAX_ACCOUNT_LABEL_CHARS + || normalized.chars().any(char::is_control) + { + return Err(invalid_account_metadata()); + } + Ok(Self(normalized.to_owned())) + } + + #[must_use] + pub fn as_str(&self) -> &str { + &self.0 + } +} + +#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)] +pub struct AccountCreatedAt(UnixTimestamp); + +impl AccountCreatedAt { + #[must_use] + pub const fn new(timestamp: UnixTimestamp) -> Self { + Self(timestamp) + } + + #[must_use] + pub const fn timestamp(self) -> UnixTimestamp { + self.0 + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct AccountSummary { + identity: AccountIdentity, + signer: LocalSignerBinding, + label: Option<AccountLabel>, + created_at: AccountCreatedAt, + last_used_at: Option<UnixTimestamp>, +} + +impl AccountSummary { + /// Creates an account summary whose identity and signer binding refer to the same account. + /// + /// # Errors + /// + /// Returns an invalid-account-metadata error when the signer binding belongs to a different + /// public key. + pub fn new( + identity: AccountIdentity, + signer: LocalSignerBinding, + label: Option<AccountLabel>, + created_at: AccountCreatedAt, + last_used_at: Option<UnixTimestamp>, + ) -> Result<Self, SafeError> { + if identity.public_key() != signer.account() { + return Err(invalid_account_metadata()); + } + Ok(Self { + identity, + signer, + label, + created_at, + last_used_at, + }) + } + + #[must_use] + pub const fn public_key(&self) -> PublicKey { + self.identity.public_key() + } + + #[must_use] + pub fn npub(&self) -> &Npub { + self.identity.npub() + } + + #[must_use] + pub const fn signer(&self) -> LocalSignerBinding { + self.signer + } + + #[must_use] + pub fn label(&self) -> Option<&AccountLabel> { + self.label.as_ref() + } + + #[must_use] + pub const fn created_at(&self) -> AccountCreatedAt { + self.created_at + } + + #[must_use] + pub const fn last_used_at(&self) -> Option<UnixTimestamp> { + self.last_used_at + } + + #[must_use] + pub fn with_binding_availability(&self, availability: BindingAvailability) -> Self { + Self { + identity: self.identity.clone(), + signer: LocalSignerBinding::new(self.public_key(), availability), + label: self.label.clone(), + created_at: self.created_at, + last_used_at: self.last_used_at, + } + } + + #[must_use] + pub fn with_last_used_at(&self, last_used_at: UnixTimestamp) -> Self { + Self { + identity: self.identity.clone(), + signer: self.signer, + label: self.label.clone(), + created_at: self.created_at, + last_used_at: Some(last_used_at), + } + } + + #[must_use] + pub fn display_label(&self) -> String { + self.label + .as_ref() + .map_or_else(|| self.npub().short(), |label| label.as_str().to_owned()) + } +} + +const fn invalid_account_metadata() -> SafeError { + SafeError::new( + SafeErrorCode::InvalidAccountMetadata, + SafeMessage::new("The account metadata is invalid."), + ) +} + +#[cfg(test)] +mod tests { + use crate::PublicKey; + use crate::time::UnixTimestamp; + + use super::{ + AccountCreatedAt, AccountIdentity, AccountLabel, AccountSummary, BindingAvailability, + BindingRepairAction, LocalSignerBinding, + }; + + const DERIVED_NPUB: &str = "npub1qurswpc8qurswpc8qurswpc8qurswpc8qurswpc8qurswpc8qursnvjvl7"; + const MISMATCHED_NPUB: &str = "npub10elfcs4fr0l0r8af98jlmgdh9c8tcxjvz9qkw038js35mp4dma8qzvjptg"; + + fn account(label: Option<AccountLabel>) -> AccountSummary { + let public_key = PublicKey::from_bytes([7_u8; 32]); + AccountSummary::new( + AccountIdentity::derive(public_key).expect("identity"), + LocalSignerBinding::new(public_key, BindingAvailability::Available), + label, + AccountCreatedAt::new(UnixTimestamp::from_seconds(10).expect("valid time")), + None, + ) + .expect("account") + } + + #[test] + fn account_label_is_trimmed_bounded_and_control_free() { + let label = AccountLabel::parse(" Farm account ").expect("valid label"); + assert_eq!(label.as_str(), "Farm account"); + + for invalid in ["", " ", "line\nbreak", &"x".repeat(81)] { + assert!(AccountLabel::parse(invalid).is_err()); + } + } + + #[test] + fn account_display_prefers_label_then_shortened_npub() { + let labelled = account(Some(AccountLabel::parse("Farm").expect("valid label"))); + let unlabelled = account(None); + + assert_eq!(labelled.display_label(), "Farm"); + assert_eq!(unlabelled.display_label(), "npub1qurswpc…rsnvjvl7"); + } + + #[test] + fn local_account_summary_contains_public_metadata_only() { + let account = account(None); + let debug = format!("{account:?}"); + + assert_eq!( + account.signer().availability(), + BindingAvailability::Available + ); + assert!(account.label().is_none()); + assert!(account.last_used_at().is_none()); + assert_eq!(account.created_at().timestamp().as_seconds(), 10); + assert_eq!(account.public_key(), PublicKey::from_bytes([7_u8; 32])); + assert_eq!(account.npub().as_str(), DERIVED_NPUB); + assert!(!debug.contains("nsec1")); + assert!(!debug.contains(&"11".repeat(32))); + } + + #[test] + fn account_identity_derives_npub_and_rejects_mismatched_persisted_forms() { + let public_key = PublicKey::from_bytes([7_u8; 32]); + let identity = AccountIdentity::derive(public_key).expect("identity"); + assert_eq!(identity.public_key(), public_key); + assert_eq!(identity.npub().as_str(), DERIVED_NPUB); + assert_eq!( + AccountIdentity::verify(public_key, DERIVED_NPUB.to_owned()).expect("verified"), + identity + ); + assert!(AccountIdentity::verify(public_key, MISMATCHED_NPUB.to_owned()).is_err()); + assert!( + AccountIdentity::verify( + PublicKey::from_bytes([8_u8; 32]), + MISMATCHED_NPUB.to_owned() + ) + .is_err() + ); + } + + #[test] + fn local_signer_binding_carries_only_canonical_account_identity() { + let public_key = PublicKey::from_bytes([9_u8; 32]); + let identity = AccountIdentity::derive(public_key).expect("identity"); + let binding = LocalSignerBinding::new(public_key, BindingAvailability::Available); + + assert_eq!(binding.account(), identity.public_key()); + assert!(!format!("{binding:?}").contains("nsec1")); + } + + #[test] + fn local_binding_repair_transitions_are_typed_and_fail_closed() { + let public_key = PublicKey::from_bytes([9_u8; 32]); + let mut binding = LocalSignerBinding::new(public_key, BindingAvailability::Available); + assert_eq!(binding.repair_action(), None); + assert!(binding.repair_credential().is_err()); + + binding + .mark_credential_missing() + .expect("missing credential"); + assert_eq!( + binding.repair_action(), + Some(BindingRepairAction::ImportCredential) + ); + binding.repair_credential().expect("repair"); + + binding.mark_store_unavailable(); + assert_eq!( + binding.repair_action(), + Some(BindingRepairAction::RetryCredentialStore) + ); + binding + .resolve_store_recovery(false) + .expect("store recovery"); + assert_eq!( + binding.availability(), + BindingAvailability::CredentialMissing + ); + assert!(binding.resolve_store_recovery(true).is_err()); + } +} diff --git a/crates/studio_domain/src/error.rs b/crates/studio_domain/src/error.rs @@ -0,0 +1,110 @@ +use std::error::Error; +use std::fmt::{self, Debug, Display, Formatter}; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[non_exhaustive] +pub enum SafeErrorCode { + InvalidPublicKey, + InvalidSecretKey, + InvalidAccountMetadata, + InvalidProfileMetadata, + InvalidApplicationState, + AccountAlreadyExists, + AccountNotFound, + KeyringUnavailable, + CredentialMissing, + StorageUnavailable, + StorageCorrupt, + PendingOperationRecoveryRequired, + InvalidRelayConfiguration, + RelayConnectionFailed, + ProfileRefreshFailed, + ObserverRegistrationFailed, + NativeLibraryLoadFailed, +} + +#[derive(Clone, Copy, Eq, PartialEq)] +pub struct SafeMessage(&'static str); + +impl SafeMessage { + #[must_use] + pub const fn new(message: &'static str) -> Self { + Self(message) + } + + #[must_use] + pub const fn as_str(self) -> &'static str { + self.0 + } +} + +impl Debug for SafeMessage { + fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result { + formatter.debug_tuple("SafeMessage").field(&self.0).finish() + } +} + +impl Display for SafeMessage { + fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result { + formatter.write_str(self.0) + } +} + +#[derive(Clone, Copy, Eq, PartialEq)] +pub struct SafeError { + code: SafeErrorCode, + message: SafeMessage, +} + +impl SafeError { + #[must_use] + pub const fn new(code: SafeErrorCode, message: SafeMessage) -> Self { + Self { code, message } + } + + #[must_use] + pub const fn code(self) -> SafeErrorCode { + self.code + } + + #[must_use] + pub const fn message(self) -> SafeMessage { + self.message + } +} + +impl Debug for SafeError { + fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("SafeError") + .field("code", &self.code) + .field("message", &self.message) + .finish() + } +} + +impl Display for SafeError { + fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result { + Display::fmt(&self.message, formatter) + } +} + +impl Error for SafeError {} + +#[cfg(test)] +mod tests { + use super::{SafeError, SafeErrorCode, SafeMessage}; + + #[test] + fn safe_error_formats_only_a_static_public_message() { + let error = SafeError::new( + SafeErrorCode::InvalidSecretKey, + SafeMessage::new("The secret key is invalid."), + ); + + assert_eq!(error.to_string(), "The secret key is invalid."); + assert_eq!(error.code(), SafeErrorCode::InvalidSecretKey); + assert_eq!(error.message().as_str(), "The secret key is invalid."); + assert!(!format!("{error:?}").contains("nsec1unsafe-test-value")); + } +} diff --git a/crates/studio_domain/src/key.rs b/crates/studio_domain/src/key.rs @@ -0,0 +1,391 @@ +//! Validated Nostr public and secret-key boundary values. + +use std::fmt::{self, Display, Formatter}; +use std::str::FromStr; + +use secrecy::{ExposeSecret, SecretString}; +use zeroize::Zeroizing; + +use crate::{SafeError, SafeErrorCode, SafeMessage}; + +pub const PUBLIC_KEY_BYTE_LENGTH: usize = 32; +pub const PUBLIC_KEY_HEX_LENGTH: usize = PUBLIC_KEY_BYTE_LENGTH * 2; +pub const MAX_SECRET_KEY_INPUT_BYTES: usize = 128; +const NIP19_KEY_LENGTH: usize = 63; +const BECH32_DATA_CHARSET: &[u8] = b"qpzry9x8gf2tvdw0s3jn54khce6mua7l"; + +#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] +pub struct Npub(String); + +impl Npub { + /// Constructs a human-facing npub after structural validation. + /// + /// Cryptographic conversion and checksum validation are performed by the + /// selected Nostr adapter before this domain value is created in runtime + /// flows. + /// + /// # Errors + /// + /// Returns a safe invalid-public-key error for a malformed npub shape. + pub fn from_encoded(value: String) -> Result<Self, SafeError> { + if !is_nip19_key_shape(&value, "npub1") { + return Err(invalid_public_key()); + } + Ok(Self(value)) + } + + /// Derives the canonical NIP-19 display identity from a public key. + /// + /// # Errors + /// + /// Returns a safe public-key error if canonical encoding fails. + pub fn derive(public_key: PublicKey) -> Result<Self, SafeError> { + let hrp = bech32::Hrp::parse("npub").map_err(|_| invalid_public_key())?; + bech32::encode::<bech32::Bech32>(hrp, public_key.as_bytes()) + .map_err(|_| invalid_public_key()) + .and_then(Self::from_encoded) + } + + /// Validates that encoded display identity belongs to the canonical key. + /// + /// # Errors + /// + /// Returns a safe public-key error when the values do not match. + pub fn verify(public_key: PublicKey, encoded: String) -> Result<Self, SafeError> { + let candidate = Self::from_encoded(encoded)?; + if candidate != Self::derive(public_key)? { + return Err(invalid_public_key()); + } + Ok(candidate) + } + + #[must_use] + pub fn as_str(&self) -> &str { + &self.0 + } + + #[must_use] + pub fn short(&self) -> String { + format!("{}…{}", &self.0[..12], &self.0[self.0.len() - 8..]) + } +} + +impl Display for Npub { + fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result { + formatter.write_str(&self.0) + } +} + +pub struct Nsec(SecretString); + +impl Nsec { + /// Constructs a secret nsec display value after structural validation. + /// + /// # Errors + /// + /// Returns a safe invalid-secret-key error for a malformed nsec shape. + pub fn from_encoded(value: String) -> Result<Self, SafeError> { + if !is_nip19_key_shape(&value, "nsec1") { + return Err(invalid_secret_key()); + } + Ok(Self(SecretString::from(value))) + } + + pub fn with_exposed_secret<T>(&self, operation: impl FnOnce(&str) -> T) -> T { + operation(self.0.expose_secret()) + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum SecretKeyInputKind { + Nsec, + Hex, +} + +pub struct SecretKeyInput { + value: SecretString, + kind: SecretKeyInputKind, +} + +impl SecretKeyInput { + /// Moves bounded transport bytes into the zeroizing secret boundary. + /// + /// The source byte allocation is cleared on every return path. + /// + /// # Errors + /// + /// Returns a safe invalid-secret-key error for oversized, non-UTF-8, or + /// structurally invalid input. + pub fn parse_bytes(value: Vec<u8>) -> Result<Self, SafeError> { + let value = Zeroizing::new(value); + if value.len() > MAX_SECRET_KEY_INPUT_BYTES { + return Err(invalid_secret_key()); + } + let encoded = std::str::from_utf8(&value).map_err(|_| invalid_secret_key())?; + Self::parse(encoded.to_owned()) + } + + /// Moves one secret input string into a zeroizing boundary. + /// + /// Nsec inputs receive complete NIP-19 validation in the Nostr adapter. + /// Hex input is structurally validated here to prevent ambiguous fallback. + /// + /// # Errors + /// + /// Returns a safe invalid-secret-key error when the input is neither an + /// nsec-looking value nor exactly 64 lowercase hexadecimal characters. + pub fn parse(value: String) -> Result<Self, SafeError> { + let kind = if value.len() == PUBLIC_KEY_HEX_LENGTH + && value + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) + { + SecretKeyInputKind::Hex + } else if is_nip19_key_shape(&value, "nsec1") { + SecretKeyInputKind::Nsec + } else { + return Err(invalid_secret_key()); + }; + + Ok(Self { + value: SecretString::from(value), + kind, + }) + } + + #[must_use] + pub const fn kind(&self) -> SecretKeyInputKind { + self.kind + } + + pub fn with_exposed_secret<T>(&self, operation: impl FnOnce(&str) -> T) -> T { + operation(self.value.expose_secret()) + } +} + +#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] +pub struct PublicKey([u8; PUBLIC_KEY_BYTE_LENGTH]); + +impl PublicKey { + #[must_use] + pub const fn from_bytes(bytes: [u8; PUBLIC_KEY_BYTE_LENGTH]) -> Self { + Self(bytes) + } + + /// Parses a canonical lowercase hexadecimal Nostr public key. + /// + /// # Errors + /// + /// Returns a safe invalid-public-key error when the value is not exactly + /// 64 lowercase hexadecimal characters. + pub fn from_hex(value: &str) -> Result<Self, SafeError> { + if value.len() != PUBLIC_KEY_HEX_LENGTH + || !value + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) + { + return Err(invalid_public_key()); + } + + let mut bytes = [0_u8; PUBLIC_KEY_BYTE_LENGTH]; + for (index, pair) in value.as_bytes().chunks_exact(2).enumerate() { + let high = decode_hex_digit(pair[0]).ok_or_else(invalid_public_key)?; + let low = decode_hex_digit(pair[1]).ok_or_else(invalid_public_key)?; + bytes[index] = (high << 4) | low; + } + Ok(Self(bytes)) + } + + #[must_use] + pub const fn as_bytes(&self) -> &[u8; PUBLIC_KEY_BYTE_LENGTH] { + &self.0 + } + + #[must_use] + pub fn to_hex(self) -> String { + const HEX: &[u8; 16] = b"0123456789abcdef"; + let mut output = String::with_capacity(PUBLIC_KEY_HEX_LENGTH); + for byte in self.0 { + output.push(char::from(HEX[usize::from(byte >> 4)])); + output.push(char::from(HEX[usize::from(byte & 0x0f)])); + } + output + } + + #[must_use] + pub fn short_hex(self) -> String { + let hex = self.to_hex(); + format!("{}…{}", &hex[..8], &hex[PUBLIC_KEY_HEX_LENGTH - 8..]) + } +} + +impl Display for PublicKey { + fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result { + formatter.write_str(&self.to_hex()) + } +} + +impl From<[u8; PUBLIC_KEY_BYTE_LENGTH]> for PublicKey { + fn from(bytes: [u8; PUBLIC_KEY_BYTE_LENGTH]) -> Self { + Self::from_bytes(bytes) + } +} + +impl FromStr for PublicKey { + type Err = SafeError; + + fn from_str(value: &str) -> Result<Self, Self::Err> { + Self::from_hex(value) + } +} + +const fn invalid_public_key() -> SafeError { + SafeError::new( + SafeErrorCode::InvalidPublicKey, + SafeMessage::new("The Nostr public key is invalid."), + ) +} + +const fn invalid_secret_key() -> SafeError { + SafeError::new( + SafeErrorCode::InvalidSecretKey, + SafeMessage::new("The Nostr secret key is invalid."), + ) +} + +const fn decode_hex_digit(byte: u8) -> Option<u8> { + match byte { + b'0'..=b'9' => Some(byte - b'0'), + b'a'..=b'f' => Some(byte - b'a' + 10), + _ => None, + } +} + +fn is_nip19_key_shape(value: &str, prefix: &str) -> bool { + value.len() == NIP19_KEY_LENGTH + && value.starts_with(prefix) + && value[prefix.len()..] + .bytes() + .all(|byte| BECH32_DATA_CHARSET.contains(&byte)) +} + +#[cfg(test)] +mod tests { + use std::str::FromStr; + + use super::{ + MAX_SECRET_KEY_INPUT_BYTES, Npub, Nsec, PUBLIC_KEY_BYTE_LENGTH, PublicKey, SecretKeyInput, + SecretKeyInputKind, + }; + use crate::SafeErrorCode; + + const HEX: &str = "7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7"; + const NPUB: &str = "npub10elfcs4fr0l0r8af98jlmgdh9c8tcxjvz9qkw038js35mp4dma8qzvjptg"; + const NSEC: &str = "nsec1vl029mgpspedva04g90vltkh6fvh240zqtv9k0t9af8935ke9laqsnlfe5"; + + #[test] + fn public_key_round_trips_canonical_hex_and_bytes() { + let key = PublicKey::from_str(HEX).expect("valid public key"); + + assert_eq!(key.to_hex(), HEX); + assert_eq!(key.to_string(), HEX); + assert_eq!(key.short_hex(), "7e7e9c42…2107f6d7"); + assert_eq!(PublicKey::from_bytes(*key.as_bytes()), key); + assert_eq!(key.as_bytes().len(), PUBLIC_KEY_BYTE_LENGTH); + } + + #[test] + fn public_key_rejects_noncanonical_or_malformed_hex() { + for value in [ + "", + "00", + "7E7E9C42A91BFEF19FA7EA99D52D8AFDB67D893A8FEFBA1F5CB9793F2107F6D7", + "ze7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7", + " 7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7", + ] { + let error = PublicKey::from_hex(value).expect_err("invalid public key"); + assert_eq!(error.code(), SafeErrorCode::InvalidPublicKey); + } + } + + #[test] + fn public_keys_are_ordered_by_canonical_bytes() { + let low = PublicKey::from_bytes([0_u8; PUBLIC_KEY_BYTE_LENGTH]); + let high = PublicKey::from_bytes([1_u8; PUBLIC_KEY_BYTE_LENGTH]); + + assert!(low < high); + } + + #[test] + fn secret_input_is_redacted_and_exposed_only_to_a_scoped_operation() { + let secret = "11".repeat(PUBLIC_KEY_BYTE_LENGTH); + let input = SecretKeyInput::parse(secret.clone()).expect("valid secret hex"); + + assert_eq!(input.kind(), SecretKeyInputKind::Hex); + assert_eq!(input.with_exposed_secret(str::len), secret.len()); + assert_eq!(input.with_exposed_secret(str::len), 64); + } + + #[test] + fn secret_input_accepts_nsec_shape_without_exposing_it() { + let secret = NSEC.to_owned(); + let input = SecretKeyInput::parse(secret.clone()).expect("nsec-shaped input"); + + assert_eq!(input.kind(), SecretKeyInputKind::Nsec); + assert_eq!(input.with_exposed_secret(str::len), secret.len()); + } + + #[test] + fn secret_input_rejects_invalid_hex_and_arbitrary_text() { + for value in [ + "", + "very-sensitive-input", + &"GG".repeat(PUBLIC_KEY_BYTE_LENGTH), + ] { + let Err(error) = SecretKeyInput::parse(value.to_owned()) else { + panic!("invalid secret accepted"); + }; + assert_eq!(error.code(), SafeErrorCode::InvalidSecretKey); + if !value.is_empty() { + assert!(!format!("{error:?}").contains(value)); + } + } + } + + #[test] + fn secret_byte_transport_is_bounded_and_validated() { + let parsed = SecretKeyInput::parse_bytes(HEX.as_bytes().to_vec()).expect("bytes"); + assert_eq!(parsed.with_exposed_secret(str::len), 64); + assert!(SecretKeyInput::parse_bytes(vec![0xff]).is_err()); + assert!(SecretKeyInput::parse_bytes(vec![b'a'; MAX_SECRET_KEY_INPUT_BYTES + 1]).is_err()); + } + + #[test] + fn npub_is_public_display_data_but_not_canonical_identity() { + let npub = Npub::from_encoded(NPUB.to_owned()).expect("valid npub shape"); + + assert_eq!(npub.as_str(), NPUB); + assert_eq!(npub.to_string(), NPUB); + } + + #[test] + fn nsec_is_redacted_and_exposed_only_to_a_scoped_operation() { + let nsec = Nsec::from_encoded(NSEC.to_owned()).expect("valid nsec shape"); + + assert_eq!(nsec.with_exposed_secret(str::len), NSEC.len()); + assert_eq!(nsec.with_exposed_secret(str::len), NSEC.len()); + } + + #[test] + fn nip19_display_types_reject_wrong_prefix_length_and_charset() { + for invalid in [ + "", + "npub1short", + "nsec1short", + "npub10elfcs4fr0l0r8af98jlmgdh9c8tcxjvz9qkw038js35mp4dma8qzvjp!g", + ] { + assert!(Npub::from_encoded(invalid.to_owned()).is_err()); + assert!(Nsec::from_encoded(invalid.to_owned()).is_err()); + } + } +} diff --git a/crates/studio_domain/src/lib.rs b/crates/studio_domain/src/lib.rs @@ -0,0 +1,20 @@ +#![doc = "Radroots Studio Nostr account domain types."] + +pub mod account; +pub mod error; +pub mod key; +pub mod profile; +pub mod relay; +pub mod time; + +pub use account::{ + AccountCreatedAt, AccountIdentity, AccountLabel, AccountSummary, BindingAvailability, + BindingRepairAction, LocalSignerBinding, +}; +pub use error::{SafeError, SafeErrorCode, SafeMessage}; +pub use key::{ + MAX_SECRET_KEY_INPUT_BYTES, Npub, Nsec, PublicKey, SecretKeyInput, SecretKeyInputKind, +}; +pub use profile::{EventId, Kind0ProfileCandidate, ProfileMetadata, select_latest_kind0}; +pub use relay::{RelayUrl, normalize_relay_urls}; +pub use time::UnixTimestamp; diff --git a/crates/studio_domain/src/profile.rs b/crates/studio_domain/src/profile.rs @@ -0,0 +1,295 @@ +//! Public Nostr profile metadata values. + +use crate::{PublicKey, SafeError, SafeErrorCode, SafeMessage, UnixTimestamp}; + +const EVENT_ID_BYTES: usize = 32; +const EVENT_ID_HEX: usize = EVENT_ID_BYTES * 2; +const MAX_NAME_CHARS: usize = 128; +const MAX_NIP05_CHARS: usize = 320; +const MAX_ABOUT_CHARS: usize = 4_096; +const MAX_PICTURE_CHARS: usize = 2_048; + +#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] +pub struct EventId([u8; EVENT_ID_BYTES]); + +impl EventId { + /// Parses a canonical lowercase hexadecimal Nostr event ID. + /// + /// # Errors + /// + /// Returns a safe profile error for malformed input. + pub fn from_hex(value: &str) -> Result<Self, SafeError> { + if value.len() != EVENT_ID_HEX + || !value + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) + { + return Err(invalid_profile_metadata()); + } + + let mut bytes = [0_u8; EVENT_ID_BYTES]; + for (index, pair) in value.as_bytes().chunks_exact(2).enumerate() { + let high = decode_hex(pair[0]).ok_or_else(invalid_profile_metadata)?; + let low = decode_hex(pair[1]).ok_or_else(invalid_profile_metadata)?; + bytes[index] = (high << 4) | low; + } + Ok(Self(bytes)) + } + + #[must_use] + pub const fn from_bytes(bytes: [u8; EVENT_ID_BYTES]) -> Self { + Self(bytes) + } + + #[must_use] + pub const fn as_bytes(self) -> [u8; EVENT_ID_BYTES] { + self.0 + } + + #[must_use] + pub fn to_hex(self) -> String { + const HEX: &[u8; 16] = b"0123456789abcdef"; + let mut output = String::with_capacity(EVENT_ID_HEX); + for byte in self.0 { + output.push(char::from(HEX[usize::from(byte >> 4)])); + output.push(char::from(HEX[usize::from(byte & 0x0f)])); + } + output + } +} + +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct ProfileMetadata { + name: Option<String>, + display_name: Option<String>, + nip05: Option<String>, + about: Option<String>, + picture: Option<String>, +} + +impl ProfileMetadata { + /// Normalizes and bounds public kind-0 profile fields. + /// + /// # Errors + /// + /// Returns a safe profile error when a field exceeds its limit or contains + /// a forbidden control character. + pub fn new( + name: Option<String>, + display_name: Option<String>, + nip05: Option<String>, + about: Option<String>, + picture: Option<String>, + ) -> Result<Self, SafeError> { + Ok(Self { + name: normalize_field(name, MAX_NAME_CHARS, false)?, + display_name: normalize_field(display_name, MAX_NAME_CHARS, false)?, + nip05: normalize_field(nip05, MAX_NIP05_CHARS, false)?, + about: normalize_field(about, MAX_ABOUT_CHARS, true)?, + picture: normalize_field(picture, MAX_PICTURE_CHARS, false)?, + }) + } + + #[must_use] + pub fn name(&self) -> Option<&str> { + self.name.as_deref() + } + + #[must_use] + pub fn display_name(&self) -> Option<&str> { + self.display_name.as_deref() + } + + #[must_use] + pub fn nip05(&self) -> Option<&str> { + self.nip05.as_deref() + } + + #[must_use] + pub fn about(&self) -> Option<&str> { + self.about.as_deref() + } + + #[must_use] + pub fn picture(&self) -> Option<&str> { + self.picture.as_deref() + } + + #[must_use] + pub fn preferred_name(&self) -> Option<&str> { + self.display_name().or_else(|| self.name()) + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Kind0ProfileCandidate { + event_id: EventId, + author: PublicKey, + created_at: UnixTimestamp, + metadata: ProfileMetadata, +} + +impl Kind0ProfileCandidate { + #[must_use] + pub const fn new( + event_id: EventId, + author: PublicKey, + created_at: UnixTimestamp, + metadata: ProfileMetadata, + ) -> Self { + Self { + event_id, + author, + created_at, + metadata, + } + } + + #[must_use] + pub const fn event_id(&self) -> EventId { + self.event_id + } + + #[must_use] + pub const fn author(&self) -> PublicKey { + self.author + } + + #[must_use] + pub const fn created_at(&self) -> UnixTimestamp { + self.created_at + } + + #[must_use] + pub const fn metadata(&self) -> &ProfileMetadata { + &self.metadata + } +} + +#[must_use] +pub fn select_latest_kind0( + candidates: impl IntoIterator<Item = Kind0ProfileCandidate>, +) -> Option<Kind0ProfileCandidate> { + candidates.into_iter().reduce(|selected, candidate| { + if candidate.created_at > selected.created_at + || (candidate.created_at == selected.created_at + && candidate.event_id < selected.event_id) + { + candidate + } else { + selected + } + }) +} + +fn normalize_field( + value: Option<String>, + max_chars: usize, + allow_layout_controls: bool, +) -> Result<Option<String>, SafeError> { + let Some(value) = value else { + return Ok(None); + }; + let normalized = value.trim(); + if normalized.is_empty() { + return Ok(None); + } + if normalized.chars().count() > max_chars + || normalized.chars().any(|character| { + character.is_control() + && !(allow_layout_controls && matches!(character, '\n' | '\r' | '\t')) + }) + { + return Err(invalid_profile_metadata()); + } + Ok(Some(normalized.to_owned())) +} + +const fn invalid_profile_metadata() -> SafeError { + SafeError::new( + SafeErrorCode::InvalidProfileMetadata, + SafeMessage::new("The Nostr profile metadata is invalid."), + ) +} + +const fn decode_hex(byte: u8) -> Option<u8> { + match byte { + b'0'..=b'9' => Some(byte - b'0'), + b'a'..=b'f' => Some(byte - b'a' + 10), + _ => None, + } +} + +#[cfg(test)] +mod tests { + use crate::{PublicKey, UnixTimestamp}; + + use super::{EventId, Kind0ProfileCandidate, ProfileMetadata, select_latest_kind0}; + + fn profile(name: &str) -> ProfileMetadata { + ProfileMetadata::new(Some(name.to_owned()), None, None, None, None).expect("valid profile") + } + + fn candidate(id_byte: u8, created_at: i64, name: &str) -> Kind0ProfileCandidate { + Kind0ProfileCandidate::new( + EventId::from_bytes([id_byte; 32]), + PublicKey::from_bytes([9_u8; 32]), + UnixTimestamp::from_seconds(created_at).expect("valid timestamp"), + profile(name), + ) + } + + #[test] + fn profile_fields_are_trimmed_bounded_and_public() { + let metadata = ProfileMetadata::new( + Some(" farmer ".to_owned()), + Some(" Farm Account ".to_owned()), + Some("farmer@example.test".to_owned()), + Some("First line\nSecond line".to_owned()), + Some("https://images.example.test/profile.png".to_owned()), + ) + .expect("valid profile"); + + assert_eq!(metadata.name(), Some("farmer")); + assert_eq!(metadata.display_name(), Some("Farm Account")); + assert_eq!(metadata.preferred_name(), Some("Farm Account")); + assert_eq!(metadata.nip05(), Some("farmer@example.test")); + assert_eq!(metadata.about(), Some("First line\nSecond line")); + assert_eq!( + metadata.picture(), + Some("https://images.example.test/profile.png") + ); + } + + #[test] + fn profile_fields_reject_forbidden_controls_and_oversize_values() { + assert!( + ProfileMetadata::new(Some("bad\0name".to_owned()), None, None, None, None).is_err() + ); + assert!(ProfileMetadata::new(Some("x".repeat(129)), None, None, None, None).is_err()); + } + + #[test] + fn latest_kind0_uses_timestamp_then_lowest_event_id() { + let older = candidate(0, 10, "older"); + let equal_high_id = candidate(9, 20, "high-id"); + let equal_low_id = candidate(1, 20, "low-id"); + + let selected = + select_latest_kind0([older, equal_high_id, equal_low_id]).expect("selected profile"); + + assert_eq!(selected.metadata().name(), Some("low-id")); + assert_eq!(selected.event_id().as_bytes(), [1_u8; 32]); + assert_eq!(selected.author(), PublicKey::from_bytes([9_u8; 32])); + assert_eq!(selected.created_at().as_seconds(), 20); + } + + #[test] + fn event_id_rejects_noncanonical_hex_and_round_trips() { + let hex = "12".repeat(32); + let event_id = EventId::from_hex(&hex).expect("valid event id"); + + assert_eq!(event_id.to_hex(), hex); + assert!(EventId::from_hex(&"GG".repeat(32)).is_err()); + } +} diff --git a/crates/studio_domain/src/relay.rs b/crates/studio_domain/src/relay.rs @@ -0,0 +1,157 @@ +//! Validated Nostr relay values. + +use std::collections::HashSet; +use std::fmt::{self, Display, Formatter}; +use std::str::FromStr; + +use url::{Host, Url}; + +use crate::{SafeError, SafeErrorCode, SafeMessage}; + +#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] +pub struct RelayUrl(String); + +impl RelayUrl { + /// Parses and normalizes an allowed WebSocket relay URL. + /// + /// # Errors + /// + /// Returns a safe configuration error for empty or malformed input, + /// forbidden schemes, credentials, fragments, or non-loopback `ws://`. + pub fn parse(value: &str) -> Result<Self, SafeError> { + let trimmed = value.trim(); + if trimmed.is_empty() || trimmed.chars().any(char::is_control) { + return Err(invalid_relay()); + } + + let parsed = Url::parse(trimmed).map_err(|_| invalid_relay())?; + if !parsed.username().is_empty() + || parsed.password().is_some() + || parsed.fragment().is_some() + { + return Err(invalid_relay()); + } + + match parsed.scheme() { + "wss" => {} + "ws" if is_loopback(&parsed) => {} + _ => return Err(invalid_relay()), + } + + if parsed.host().is_none() { + return Err(invalid_relay()); + } + + Ok(Self(parsed.to_string())) + } + + #[must_use] + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl Display for RelayUrl { + fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result { + formatter.write_str(&self.0) + } +} + +impl FromStr for RelayUrl { + type Err = SafeError; + + fn from_str(value: &str) -> Result<Self, Self::Err> { + Self::parse(value) + } +} + +/// Parses relay values and removes duplicates without changing first-seen order. +/// +/// # Errors +/// +/// Returns the first safe relay validation error. +pub fn normalize_relay_urls<I, S>(values: I) -> Result<Vec<RelayUrl>, SafeError> +where + I: IntoIterator<Item = S>, + S: AsRef<str>, +{ + let mut seen = HashSet::new(); + let mut relays = Vec::new(); + for value in values { + let relay = RelayUrl::parse(value.as_ref())?; + if seen.insert(relay.clone()) { + relays.push(relay); + } + } + Ok(relays) +} + +fn is_loopback(url: &Url) -> bool { + match url.host() { + Some(Host::Domain(domain)) => domain == "localhost", + Some(Host::Ipv4(address)) => address.octets()[0] == 127, + Some(Host::Ipv6(address)) => address.is_loopback(), + None => false, + } +} + +const fn invalid_relay() -> SafeError { + SafeError::new( + SafeErrorCode::InvalidRelayConfiguration, + SafeMessage::new("The Nostr relay URL is invalid."), + ) +} + +#[cfg(test)] +mod tests { + use super::{RelayUrl, normalize_relay_urls}; + use crate::SafeErrorCode; + + #[test] + fn relay_accepts_secure_remote_and_loopback_development_urls() { + for (input, expected) in [ + (" wss://Relay.Example/path ", "wss://relay.example/path"), + ("ws://localhost:8080", "ws://localhost:8080/"), + ("ws://127.42.1.9:8080", "ws://127.42.1.9:8080/"), + ("ws://[::1]:8080", "ws://[::1]:8080/"), + ] { + let relay = RelayUrl::parse(input).expect("allowed relay"); + assert_eq!(relay.as_str(), expected); + assert_eq!(relay.to_string(), expected); + } + } + + #[test] + fn relay_rejects_non_websocket_credentials_fragments_and_remote_plaintext() { + for input in [ + "", + "https://relay.example", + "http://localhost:8080", + "wss://user:password@relay.example", + "wss://relay.example/#fragment", + "ws://relay.example", + "ws://192.168.1.2:8080", + "ws://localhost.evil.example:8080", + "wss://relay.example/\nunsafe", + ] { + let error = RelayUrl::parse(input).expect_err("forbidden relay"); + assert_eq!(error.code(), SafeErrorCode::InvalidRelayConfiguration); + } + } + + #[test] + fn relay_deduplication_preserves_normalized_first_seen_order() { + let relays = normalize_relay_urls([ + "wss://relay.example", + " wss://second.example/path ", + "wss://RELAY.example/", + "wss://second.example/path", + ]) + .expect("valid relays"); + + assert_eq!( + relays.iter().map(RelayUrl::as_str).collect::<Vec<_>>(), + vec!["wss://relay.example/", "wss://second.example/path"] + ); + } +} diff --git a/crates/studio_domain/src/time.rs b/crates/studio_domain/src/time.rs @@ -0,0 +1,34 @@ +//! Time values shared by account and profile records. + +#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] +pub struct UnixTimestamp(i64); + +impl UnixTimestamp { + #[must_use] + pub const fn from_seconds(seconds: i64) -> Option<Self> { + if seconds < 0 { + None + } else { + Some(Self(seconds)) + } + } + + #[must_use] + pub const fn as_seconds(self) -> i64 { + self.0 + } +} + +#[cfg(test)] +mod tests { + use super::UnixTimestamp; + + #[test] + fn timestamp_rejects_negative_seconds() { + assert_eq!(UnixTimestamp::from_seconds(-1), None); + assert_eq!( + UnixTimestamp::from_seconds(0).map(UnixTimestamp::as_seconds), + Some(0) + ); + } +} diff --git a/crates/studio_ffi/Cargo.toml b/crates/studio_ffi/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "radroots-studio-ffi" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true + +[lib] +crate-type = ["cdylib", "rlib"] + +[dependencies] +directories.workspace = true +radroots-studio-application = { path = "../application" } +radroots-studio-domain = { path = "../domain" } +radroots-studio-storage = { path = "../storage" } +tokio.workspace = true +uniffi.workspace = true + +[dev-dependencies] +nostr.workspace = true +nostr-relay-builder.workspace = true +nostr-sdk.workspace = true +tempfile = "=3.23.0" + +[lints] +workspace = true diff --git a/crates/studio_ffi/src/commands.rs b/crates/studio_ffi/src/commands.rs @@ -0,0 +1,853 @@ +use std::collections::BTreeMap; +use std::fmt::{self, Display, Formatter}; +use std::num::NonZeroUsize; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex, OnceLock}; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use directories::ProjectDirs; +use radroots_studio_application::{ + Clock, DurableRequestId, GeneratedKeyRecoveryHandle, RelayConfiguration, RelayRuntimeMode, + RemovalConfirmationToken, SdkNostrClient, relay_configuration_from_environment, +}; +use radroots_studio_domain::{PublicKey, SafeError, SecretKeyInput, UnixTimestamp}; +use radroots_studio_storage::{OsKeyringSecretStore, RuntimeActorHandle}; + +use crate::{ + AccountDto, AppSnapshotDto, WireErrorCategory, WireErrorCode, WireRecoveryAction, + dto::error_policy, +}; + +const DATABASE_QUALIFIER: &str = "org"; +const DATABASE_ORGANIZATION: &str = "radroots"; +const DATABASE_APPLICATION: &str = "studio"; +const DATABASE_FILENAME: &str = "studio.sqlite3"; +const DEVELOPMENT_DATA_DIR_ENVIRONMENT: &str = "RADROOTS_STUDIO_DEVELOPMENT_DATA_DIR"; +pub(crate) const ACTOR_MAILBOX_CAPACITY: usize = 64; +pub const FFI_CONTRACT_MAJOR: u16 = 2; +pub const FFI_CONTRACT_MINOR: u16 = 0; +pub const FFI_CONTRACT_HASH: &str = "radroots-studio-native-v2-2026-08-03"; +const MAX_COMMAND_DEADLINE_MILLIS: u64 = 30_000; + +#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)] +pub struct RequestContextDto { + pub request_id: String, + pub expected_revision: u64, + pub deadline_millis: u64, +} + +#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)] +pub struct AccountCommandReceiptDto { + pub request_id: String, + pub committed_revision: u64, + pub snapshot: AppSnapshotDto, +} + +#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)] +pub struct CompatibilityDescriptor { + pub contract_major: u16, + pub contract_minor: u16, + pub contract_hash: String, + pub minimum_schema_version: u32, + pub current_schema_version: u32, +} + +#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)] +pub struct CompatibilityExpectation { + pub contract_major: u16, + pub minimum_contract_minor: u16, + pub contract_hash: String, + pub minimum_schema_version: u32, + pub maximum_schema_version: u32, +} + +#[uniffi::export] +pub fn compatibility_descriptor() -> CompatibilityDescriptor { + CompatibilityDescriptor { + contract_major: FFI_CONTRACT_MAJOR, + contract_minor: FFI_CONTRACT_MINOR, + contract_hash: FFI_CONTRACT_HASH.to_owned(), + minimum_schema_version: 5, + current_schema_version: radroots_studio_storage::CURRENT_SCHEMA_VERSION, + } +} + +#[derive(Debug, uniffi::Error)] +pub enum StudioError { + Failure { + code: WireErrorCode, + category: WireErrorCategory, + retryable: bool, + recovery_action: WireRecoveryAction, + correlation_id: Option<String>, + safe_message: String, + }, +} + +impl Display for StudioError { + fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result { + match self { + Self::Failure { safe_message, .. } => formatter.write_str(safe_message), + } + } +} + +impl std::error::Error for StudioError {} + +impl From<SafeError> for StudioError { + fn from(error: SafeError) -> Self { + let (category, retryable, recovery_action) = error_policy(error.code()); + Self::Failure { + code: error.code().into(), + category, + retryable, + recovery_action, + correlation_id: None, + safe_message: error.message().as_str().to_owned(), + } + } +} + +impl StudioError { + fn correlated(error: SafeError, correlation_id: &str) -> Self { + let (category, retryable, recovery_action) = error_policy(error.code()); + Self::Failure { + code: error.code().into(), + category, + retryable, + recovery_action, + correlation_id: Some(correlation_id.to_owned()), + safe_message: error.message().as_str().to_owned(), + } + } +} + +#[derive(uniffi::Object)] +pub struct GeneratedRecoveryRequest { + handle: GeneratedKeyRecoveryHandle, + resolved: AtomicBool, +} + +#[uniffi::export] +impl GeneratedRecoveryRequest { + pub fn account(&self) -> AccountDto { + self.handle.view().account().into() + } + + pub fn expires_at_seconds(&self) -> i64 { + self.handle.view().expires_at().as_seconds() + } + + /// Returns the recovery secret exactly once. + /// + /// # Errors + /// + /// Returns a safe unavailable error after the first read. + pub fn take_recovery_nsec(&self) -> Result<String, StudioError> { + self.handle + .take_recovery_nsec() + .map(|nsec| nsec.with_exposed_secret(str::to_owned)) + .map_err(StudioError::from) + } +} + +#[derive(uniffi::Object)] +pub struct RemovalRequest { + public_key_hex: String, + deletes_local_credential: bool, + signs_out: bool, + expires_at_seconds: i64, + token: Mutex<Option<RemovalConfirmationToken>>, +} + +#[uniffi::export] +impl RemovalRequest { + pub fn public_key_hex(&self) -> String { + self.public_key_hex.clone() + } + + pub fn deletes_local_credential(&self) -> bool { + self.deletes_local_credential + } + + pub fn signs_out(&self) -> bool { + self.signs_out + } + + pub fn expires_at_seconds(&self) -> i64 { + self.expires_at_seconds + } +} + +pub(crate) struct RuntimeCore { + pub(crate) actor: RuntimeActorHandle, + pub(crate) observers: Mutex< + BTreeMap<radroots_studio_application::ChangeSubscriptionId, tokio::task::JoinHandle<()>>, + >, + pub(crate) closed: AtomicBool, + pub(crate) startup_relay_problem: Option<SafeError>, +} + +impl RuntimeCore { + pub(crate) fn snapshot_dto(&self) -> AppSnapshotDto { + AppSnapshotDto::from_runtime(&self.actor.snapshot(), self.effective_lifecycle()) + } + + pub(crate) fn dto_for( + &self, + snapshot: &radroots_studio_application::AppSnapshot, + ) -> AppSnapshotDto { + AppSnapshotDto::from_runtime(snapshot, self.effective_lifecycle()) + } + + pub(crate) fn effective_lifecycle(&self) -> radroots_studio_application::RuntimeLifecycle { + let lifecycle = self.actor.lifecycle(); + match (lifecycle, self.startup_relay_problem) { + (radroots_studio_application::RuntimeLifecycle::Ready, Some(problem)) => { + radroots_studio_application::RuntimeLifecycle::Degraded(problem) + } + _ => lifecycle, + } + } +} + +#[derive(uniffi::Object)] +pub struct StudioAppCore { + pub(crate) inner: Arc<RuntimeCore>, +} + +#[uniffi::export] +impl StudioAppCore { + /// Verifies the static contract before touching the application data path. + /// + /// # Errors + /// + /// Returns a safe compatibility error without opening or migrating storage. + #[uniffi::constructor] + #[allow(clippy::needless_pass_by_value)] + pub fn open_compatible( + expectation: CompatibilityExpectation, + development_mode: bool, + ) -> Result<Arc<Self>, StudioError> { + let path = application_database_path(development_mode)?; + Self::open_path_compatible(&path, &expectation, development_mode) + } + + /// Restores durable public application state. + /// + /// # Errors + /// + /// Returns a safe storage, recovery, or application-state error. + pub async fn bootstrap(&self) -> Result<AppSnapshotDto, StudioError> { + self.inner + .actor + .bootstrap() + .await + .map(|snapshot| self.inner.dto_for(&snapshot)) + .map_err(StudioError::from) + } + + #[must_use] + pub fn snapshot(&self) -> AppSnapshotDto { + self.inner.snapshot_dto() + } + + /// Begins the exclusive generated-account recovery flow without persistence. + /// + /// # Errors + /// + /// Returns a safe key-generation, conflict, timeout, or lifecycle error. + pub async fn begin_generated_account_v2( + &self, + ) -> Result<Arc<GeneratedRecoveryRequest>, StudioError> { + self.inner + .actor + .begin_generated_key_stage() + .await + .map(|handle| { + Arc::new(GeneratedRecoveryRequest { + handle, + resolved: AtomicBool::new(false), + }) + }) + .map_err(StudioError::from) + } + + /// Acknowledges recovery and commits the generated account once. + /// + /// # Errors + /// + /// Returns a terminal safe recovery, credential, persistence, timeout, or lifecycle error. + /// A failed commit must be recovered by importing the already-saved recovery key. + pub async fn acknowledge_generated_account_v2( + &self, + request: Arc<GeneratedRecoveryRequest>, + ) -> Result<AppSnapshotDto, StudioError> { + if request.resolved.swap(true, Ordering::AcqRel) { + return Err(generated_recovery_expired()); + } + self.inner + .actor + .acknowledge_generated_key_stage(request.handle.id()) + .await + .map(|snapshot| self.inner.dto_for(&snapshot)) + .map_err(generated_commit_failed) + } + + /// Cancels the exclusive generated-account recovery flow. + /// + /// # Errors + /// + /// Returns a safe timeout or lifecycle error. + pub async fn cancel_generated_account_v2( + &self, + request: Arc<GeneratedRecoveryRequest>, + ) -> Result<bool, StudioError> { + if request.resolved.swap(true, Ordering::AcqRel) { + return Ok(false); + } + self.inner + .actor + .cancel_generated_key_stage() + .await + .map_err(StudioError::from) + } + + /// Imports or repairs an account using a caller-owned idempotency key. + /// + /// # Errors + /// + /// Returns a correlated validation, conflict, timeout, credential, or storage error. + pub async fn import_account_v2( + &self, + context: RequestContextDto, + secret_key: Vec<u8>, + ) -> Result<AccountCommandReceiptDto, StudioError> { + let request_id = DurableRequestId::parse(context.request_id.clone()) + .map_err(|error| StudioError::correlated(error, &context.request_id))?; + let timeout = command_timeout(context.deadline_millis, &context.request_id)?; + let input = SecretKeyInput::parse_bytes(secret_key) + .map_err(|error| StudioError::correlated(error, &context.request_id))?; + self.inner + .actor + .import_secret_key_request( + request_id, + radroots_studio_application::SnapshotRevision::from_value( + context.expected_revision, + ), + input, + timeout, + ) + .await + .map(|_| { + let snapshot = self.inner.snapshot_dto(); + AccountCommandReceiptDto { + request_id: context.request_id.clone(), + committed_revision: snapshot.revision, + snapshot, + } + }) + .map_err(|error| StudioError::correlated(error, &context.request_id)) + } + + /// Selects one saved account without activating it. + /// + /// # Errors + /// + /// Returns a safe public-key, account, or storage error. + pub async fn select_account( + &self, + public_key_hex: String, + ) -> Result<AppSnapshotDto, StudioError> { + let public_key = parse_public_key(&public_key_hex)?; + self.inner + .actor + .select_account(public_key) + .await + .map(|snapshot| self.inner.dto_for(&snapshot)) + .map_err(StudioError::from) + } + + /// Activates one saved account after validating its credential. + /// + /// # Errors + /// + /// Returns a safe public-key, credential, account, or storage error. + pub async fn activate_account( + &self, + public_key_hex: String, + ) -> Result<AppSnapshotDto, StudioError> { + let public_key = parse_public_key(&public_key_hex)?; + self.inner + .actor + .activate_account(public_key) + .await + .map(|snapshot| self.inner.dto_for(&snapshot)) + .map_err(StudioError::from) + } + + /// Signs out while retaining accounts and credentials. + /// + /// # Errors + /// + /// Returns a safe application-state error. + pub async fn sign_out(&self) -> Result<AppSnapshotDto, StudioError> { + self.inner + .actor + .sign_out() + .await + .map(|snapshot| self.inner.dto_for(&snapshot)) + .map_err(StudioError::from) + } + + /// Refreshes the active Nostr profile from configured relays. + /// + /// # Errors + /// + /// Returns a safe storage or application-state error. + pub async fn refresh_active_profile(&self) -> Result<AppSnapshotDto, StudioError> { + self.inner + .actor + .refresh_active_profile() + .await + .map(|snapshot| self.inner.dto_for(&snapshot)) + .map_err(StudioError::from) + } + + /// Issues a revision-bound removal confirmation object. + /// + /// # Errors + /// + /// Returns a safe public-key or account error. + pub async fn request_account_removal( + &self, + public_key_hex: String, + ) -> Result<Arc<RemovalRequest>, StudioError> { + let public_key = parse_public_key(&public_key_hex)?; + self.inner + .actor + .request_account_removal(public_key) + .await + .map(|token| { + let impact = token.impact(); + Arc::new(RemovalRequest { + public_key_hex, + deletes_local_credential: impact.deletes_local_credential(), + signs_out: impact.signs_out(), + expires_at_seconds: token.expires_at().as_seconds(), + token: Mutex::new(Some(token)), + }) + }) + .map_err(StudioError::from) + } + + /// Permanently removes the account represented by a one-time request. + /// + /// # Errors + /// + /// Returns a safe confirmation, credential, recovery, or storage error. + pub async fn confirm_account_removal( + &self, + request: Arc<RemovalRequest>, + ) -> Result<AppSnapshotDto, StudioError> { + let token = request + .token + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .take() + .ok_or_else(confirmation_expired)?; + self.inner + .actor + .confirm_account_removal(token) + .await + .map(|snapshot| self.inner.dto_for(&snapshot)) + .map_err(StudioError::from) + } +} + +fn verify_compatibility(expectation: &CompatibilityExpectation) -> Result<(), StudioError> { + let actual = compatibility_descriptor(); + if expectation.contract_major != actual.contract_major + || expectation.minimum_contract_minor > actual.contract_minor + || expectation.contract_hash != actual.contract_hash + || expectation.minimum_schema_version > actual.current_schema_version + || expectation.maximum_schema_version < actual.minimum_schema_version + { + return Err(compatibility_mismatch()); + } + Ok(()) +} + +impl StudioAppCore { + fn open_path_compatible( + path: &Path, + expectation: &CompatibilityExpectation, + development_mode: bool, + ) -> Result<Arc<Self>, StudioError> { + verify_compatibility(expectation)?; + std::fs::create_dir_all(path.parent().ok_or_else(path_unavailable)?) + .map_err(|_| path_unavailable())?; + Self::open_path(path, development_mode) + } + + fn open_path(path: &Path, development_mode: bool) -> Result<Arc<Self>, StudioError> { + let mode = if development_mode { + RelayRuntimeMode::Development + } else { + RelayRuntimeMode::Packaged + }; + let (relays, startup_relay_problem) = + local_first_relay_configuration(relay_configuration_from_environment(mode)); + let actor = RuntimeActorHandle::open( + path, + relays, + Arc::new(OsKeyringSecretStore::default()), + Arc::new(SystemClock), + Arc::new(SdkNostrClient::new(Duration::from_secs(5))), + NonZeroUsize::new(ACTOR_MAILBOX_CAPACITY).expect("nonzero actor mailbox capacity"), + runtime().handle(), + )?; + Ok(Arc::new(Self { + inner: Arc::new(RuntimeCore { + actor, + observers: Mutex::new(BTreeMap::new()), + closed: AtomicBool::new(false), + startup_relay_problem, + }), + })) + } +} + +fn local_first_relay_configuration( + configured: Result<RelayConfiguration, SafeError>, +) -> (RelayConfiguration, Option<SafeError>) { + match configured { + Ok(relays) => (relays, None), + Err(problem) => (RelayConfiguration::default(), Some(problem)), + } +} + +#[derive(Clone, Copy)] +pub(crate) struct SystemClock; + +impl Clock for SystemClock { + fn now(&self) -> UnixTimestamp { + let seconds = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_or(0, |duration| { + i64::try_from(duration.as_secs()).unwrap_or(i64::MAX) + }); + UnixTimestamp::from_seconds(seconds).expect("system time is nonnegative") + } +} + +fn application_database_path(development_mode: bool) -> Result<PathBuf, StudioError> { + if development_mode && let Some(directory) = std::env::var_os(DEVELOPMENT_DATA_DIR_ENVIRONMENT) + { + return Ok(PathBuf::from(directory).join(DATABASE_FILENAME)); + } + ProjectDirs::from( + DATABASE_QUALIFIER, + DATABASE_ORGANIZATION, + DATABASE_APPLICATION, + ) + .map(|project| project.data_dir().join(DATABASE_FILENAME)) + .ok_or_else(path_unavailable) +} + +fn parse_public_key(value: &str) -> Result<PublicKey, StudioError> { + PublicKey::from_hex(value).map_err(StudioError::from) +} + +fn command_timeout(millis: u64, correlation_id: &str) -> Result<Duration, StudioError> { + if millis == 0 || millis > MAX_COMMAND_DEADLINE_MILLIS { + return Err(StudioError::Failure { + code: WireErrorCode::InvalidApplicationState, + category: WireErrorCategory::Input, + retryable: false, + recovery_action: WireRecoveryAction::None, + correlation_id: Some(correlation_id.to_owned()), + safe_message: "The command deadline is invalid.".to_owned(), + }); + } + Ok(Duration::from_millis(millis)) +} + +pub(crate) fn runtime() -> &'static tokio::runtime::Runtime { + static RUNTIME: OnceLock<tokio::runtime::Runtime> = OnceLock::new(); + RUNTIME.get_or_init(|| { + tokio::runtime::Builder::new_multi_thread() + .enable_all() + .thread_name("radroots-studio-core") + .build() + .expect("Tokio runtime construction") + }) +} + +fn path_unavailable() -> StudioError { + StudioError::Failure { + code: WireErrorCode::StorageUnavailable, + category: WireErrorCategory::Storage, + retryable: true, + recovery_action: WireRecoveryAction::RestartApplication, + correlation_id: None, + safe_message: "The application data directory is unavailable.".to_owned(), + } +} + +fn confirmation_expired() -> StudioError { + StudioError::Failure { + code: WireErrorCode::InvalidApplicationState, + category: WireErrorCategory::Lifecycle, + retryable: false, + recovery_action: WireRecoveryAction::None, + correlation_id: None, + safe_message: "The account removal confirmation is no longer valid.".to_owned(), + } +} + +fn generated_recovery_expired() -> StudioError { + StudioError::Failure { + code: WireErrorCode::InvalidApplicationState, + category: WireErrorCategory::Lifecycle, + retryable: false, + recovery_action: WireRecoveryAction::None, + correlation_id: None, + safe_message: "The generated-key recovery step is no longer valid.".to_owned(), + } +} + +fn generated_commit_failed(error: SafeError) -> StudioError { + let (category, _, _) = error_policy(error.code()); + StudioError::Failure { + code: error.code().into(), + category, + retryable: false, + recovery_action: WireRecoveryAction::None, + correlation_id: None, + safe_message: + "The generated account could not be saved. Import the recovery key you saved to try again." + .to_owned(), + } +} + +fn compatibility_mismatch() -> StudioError { + StudioError::Failure { + code: WireErrorCode::CompatibilityMismatch, + category: WireErrorCategory::Compatibility, + retryable: false, + recovery_action: WireRecoveryAction::UpdateApplication, + correlation_id: None, + safe_message: "The application and native runtime are incompatible.".to_owned(), + } +} + +#[cfg(test)] +mod tests { + use std::num::NonZeroUsize; + use std::sync::Arc; + + use radroots_studio_application::{InMemorySecretStore, RelayConfiguration, SdkNostrClient}; + use radroots_studio_domain::SafeError; + use radroots_studio_storage::RuntimeActorHandle; + + use radroots_studio_storage::{CREDENTIAL_SERVICE, CURRENT_SCHEMA_VERSION}; + + use super::{ + ACTOR_MAILBOX_CAPACITY, CompatibilityExpectation, DATABASE_APPLICATION, DATABASE_FILENAME, + DATABASE_ORGANIZATION, DATABASE_QUALIFIER, FFI_CONTRACT_HASH, FFI_CONTRACT_MAJOR, + FFI_CONTRACT_MINOR, RequestContextDto, RuntimeCore, StudioAppCore, StudioError, + SystemClock, compatibility_descriptor, local_first_relay_configuration, runtime, + verify_compatibility, + }; + + fn in_memory_core() -> Arc<StudioAppCore> { + let actor = RuntimeActorHandle::in_memory( + RelayConfiguration::default(), + Arc::new(InMemorySecretStore::default()), + Arc::new(SystemClock), + Arc::new(SdkNostrClient::new(std::time::Duration::from_millis(10))), + NonZeroUsize::new(ACTOR_MAILBOX_CAPACITY).expect("capacity"), + runtime().handle(), + ) + .expect("in-memory actor"); + Arc::new(StudioAppCore { + inner: Arc::new(RuntimeCore { + actor, + observers: std::sync::Mutex::new(std::collections::BTreeMap::new()), + closed: std::sync::atomic::AtomicBool::new(false), + startup_relay_problem: None, + }), + }) + } + + #[tokio::test] + async fn exported_bootstrap_and_snapshot_are_revisioned() { + let core = in_memory_core(); + let bootstrapped = core.bootstrap().await.expect("bootstrap"); + let current = core.snapshot(); + + assert_eq!(bootstrapped, current); + assert_eq!(current.revision, 1); + } + + #[tokio::test] + async fn request_context_import_replays_one_committed_receipt() { + let core = in_memory_core(); + let initial = core.snapshot(); + let context = RequestContextDto { + request_id: "ffi-test-import-1".to_owned(), + expected_revision: initial.revision, + deadline_millis: 5_000, + }; + let secret = b"7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7"; + let first = core + .import_account_v2(context.clone(), secret.to_vec()) + .await + .expect("first import"); + let replay = core + .import_account_v2(context, secret.to_vec()) + .await + .expect("replayed import"); + + assert_eq!(first, replay); + assert_eq!(first.snapshot.accounts.len(), 1); + assert_eq!(first.request_id, "ffi-test-import-1"); + } + + #[tokio::test] + async fn generated_recovery_handle_is_one_use_and_acknowledgement_gated() { + let core = in_memory_core(); + let initial = core.snapshot(); + let recovery = core + .begin_generated_account_v2() + .await + .expect("begin recovery"); + + assert_eq!(core.snapshot(), initial); + let nsec = recovery.take_recovery_nsec().expect("one-use nsec"); + assert!(nsec.starts_with("nsec1")); + assert!(recovery.take_recovery_nsec().is_err()); + let committed = core + .acknowledge_generated_account_v2(Arc::clone(&recovery)) + .await + .expect("acknowledge"); + assert_eq!(committed.accounts.len(), 1); + let repeated = core + .acknowledge_generated_account_v2(recovery) + .await + .expect_err("repeated acknowledgement"); + assert!(matches!( + repeated, + StudioError::Failure { safe_message, .. } + if safe_message == "The generated-key recovery step is no longer valid." + )); + } + + #[test] + fn compatibility_matrix_rejects_before_storage_mutation() { + let actual = compatibility_descriptor(); + let compatible = CompatibilityExpectation { + contract_major: FFI_CONTRACT_MAJOR, + minimum_contract_minor: FFI_CONTRACT_MINOR, + contract_hash: FFI_CONTRACT_HASH.to_owned(), + minimum_schema_version: 5, + maximum_schema_version: CURRENT_SCHEMA_VERSION, + }; + verify_compatibility(&compatible).expect("compatible"); + + for incompatible in [ + CompatibilityExpectation { + contract_major: FFI_CONTRACT_MAJOR + 1, + ..compatible.clone() + }, + CompatibilityExpectation { + minimum_contract_minor: FFI_CONTRACT_MINOR + 1, + ..compatible.clone() + }, + CompatibilityExpectation { + contract_hash: "wrong-contract".to_owned(), + ..compatible.clone() + }, + CompatibilityExpectation { + minimum_schema_version: actual.current_schema_version + 1, + ..compatible.clone() + }, + CompatibilityExpectation { + maximum_schema_version: actual.minimum_schema_version - 1, + ..compatible.clone() + }, + ] { + assert!(verify_compatibility(&incompatible).is_err()); + } + + let directory = tempfile::tempdir().expect("directory"); + let rejected = directory.path().join("rejected").join("studio.sqlite3"); + let incompatible = CompatibilityExpectation { + contract_major: FFI_CONTRACT_MAJOR + 1, + ..compatible + }; + assert!(StudioAppCore::open_path_compatible(&rejected, &incompatible, true).is_err()); + assert!(!rejected.parent().expect("parent").exists()); + } + + #[test] + fn v5_compatibility_fixture_preserves_external_coordinates() { + let fixture = include_str!("../../../compatibility/v5-baseline.properties"); + let property = |key: &str| { + fixture.lines().find_map(|line| { + line.split_once('=') + .filter(|(candidate, _)| *candidate == key) + .map(|(_, value)| value) + }) + }; + + assert_eq!(property("baseline.id"), Some("studio-runtime-v5")); + assert_eq!(property("schema.version"), Some("5")); + assert_eq!(CURRENT_SCHEMA_VERSION, 9); + assert_eq!(property("ffi.contract"), Some("legacy-unversioned-v1")); + assert_eq!(property("ffi.snapshot.schema"), Some("1")); + assert_eq!(property("ffi.runtime.version"), Some("0.1.0-alpha")); + assert_eq!(property("database.qualifier"), Some(DATABASE_QUALIFIER)); + assert_eq!( + property("database.organization"), + Some(DATABASE_ORGANIZATION) + ); + assert_eq!(property("database.application"), Some(DATABASE_APPLICATION)); + assert_eq!(property("database.filename"), Some(DATABASE_FILENAME)); + assert_eq!(property("keyring.service"), Some(CREDENTIAL_SERVICE)); + assert_eq!( + property("keyring.account"), + Some("canonical-lowercase-public-key-hex") + ); + } + + #[test] + fn superseded_v1_ffi_commands_are_absent() { + let commands = include_str!("commands.rs"); + let observer = include_str!("observer.rs"); + for forbidden in [ + format!("pub async fn {}_account(", "generate"), + format!("pub async fn {}_secret_key(", "import"), + format!("pub fn {}(development_mode", "open"), + format!("pub async fn {}(", "subscribe"), + format!("pub fn {}(&self)", "shutdown"), + ] { + assert!(!commands.contains(&forbidden)); + assert!(!observer.contains(&forbidden)); + } + } + + #[test] + fn invalid_relay_configuration_preserves_local_startup_as_degraded() { + let problem = SafeError::new( + radroots_studio_domain::SafeErrorCode::InvalidRelayConfiguration, + radroots_studio_domain::SafeMessage::new("The Nostr relay configuration is invalid."), + ); + let (relays, degraded) = local_first_relay_configuration(Err(problem)); + + assert!(relays.relays().is_empty()); + assert_eq!(degraded, Some(problem)); + } +} diff --git a/crates/studio_ffi/src/dto.rs b/crates/studio_ffi/src/dto.rs @@ -0,0 +1,419 @@ +use radroots_studio_application::{ + ActiveAccountSnapshot, AppLifecycle, AppSnapshot, ProfileLoadState, RelayConnectionState, + RuntimeLifecycle, SessionState, +}; +use radroots_studio_domain::{ + AccountSummary, BindingAvailability, ProfileMetadata, SafeError, SafeErrorCode, +}; + +#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)] +pub enum WireErrorCode { + InvalidPublicKey, + InvalidSecretKey, + InvalidAccountMetadata, + InvalidProfileMetadata, + InvalidApplicationState, + AccountAlreadyExists, + AccountNotFound, + KeyringUnavailable, + CredentialMissing, + StorageUnavailable, + StorageCorrupt, + PendingOperationRecoveryRequired, + InvalidRelayConfiguration, + RelayConnectionFailed, + ProfileRefreshFailed, + ObserverRegistrationFailed, + NativeLibraryLoadFailed, + CompatibilityMismatch, + Internal, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)] +pub enum WireErrorCategory { + Input, + Conflict, + Credential, + Storage, + Network, + Lifecycle, + Compatibility, + Internal, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)] +pub enum WireRecoveryAction { + None, + Retry, + RepairCredential, + CheckConfiguration, + RestartApplication, + UpdateApplication, +} + +#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)] +pub struct SafeErrorDto { + pub code: WireErrorCode, + pub category: WireErrorCategory, + pub retryable: bool, + pub recovery_action: WireRecoveryAction, + pub message: String, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)] +pub enum AppLifecycleDto { + Opening, + CompatibilityChecking, + AcquiringOwnership, + Migrating, + Recovering, + Ready, + Degraded, + Blocked, + ShuttingDown, + Closed, + Fatal, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)] +pub enum SessionStateDto { + SignedOut, + Activating, + Active, + SigningOut, + Failed, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)] +pub enum RelayConnectionStateDto { + Disconnected, + Connecting, + Connected, + Degraded, + Error, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)] +pub enum ProfileLoadStateDto { + Empty, + Loading, + Cached, + Fresh, + Error, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)] +pub enum SignerKindDto { + LocalSecret, + WatchOnly, + RemoteNip46, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)] +pub enum KeyAvailabilityDto { + Available, + CredentialMissing, + StoreUnavailable, + NotRequired, +} + +#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)] +pub struct ProfileDto { + pub name: Option<String>, + pub display_name: Option<String>, + pub nip05: Option<String>, + pub about: Option<String>, + pub picture: Option<String>, +} + +#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)] +pub struct AccountDto { + pub public_key_hex: String, + pub npub: String, + pub display_label: String, + pub signer_kind: SignerKindDto, + pub key_availability: KeyAvailabilityDto, + pub created_at_seconds: i64, + pub last_used_at_seconds: Option<i64>, +} + +#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)] +pub struct ActiveAccountDto { + pub account: AccountDto, + pub relay_state: RelayConnectionStateDto, + pub profile_state: ProfileLoadStateDto, + pub profile: Option<ProfileDto>, +} + +#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)] +pub struct AppSnapshotDto { + pub revision: u64, + pub lifecycle: AppLifecycleDto, + pub lifecycle_error: Option<SafeErrorDto>, + pub configured_relays: Vec<String>, + pub accounts: Vec<AccountDto>, + pub selected_public_key_hex: Option<String>, + pub session: SessionStateDto, + pub session_subject_public_key_hex: Option<String>, + pub session_error: Option<SafeErrorDto>, + pub active_account: Option<ActiveAccountDto>, + pub recoverable_problem: Option<SafeErrorDto>, +} + +impl From<&AppSnapshot> for AppSnapshotDto { + fn from(snapshot: &AppSnapshot) -> Self { + let (lifecycle, lifecycle_error) = match snapshot.lifecycle() { + AppLifecycle::Booting => (AppLifecycleDto::Opening, None), + AppLifecycle::Ready => (AppLifecycleDto::Ready, None), + AppLifecycle::Fatal(error) => (AppLifecycleDto::Fatal, Some(error.into())), + }; + let (session, session_subject_public_key_hex, session_error) = match snapshot.session() { + SessionState::SignedOut => (SessionStateDto::SignedOut, None, None), + SessionState::Activating(public_key) => { + (SessionStateDto::Activating, Some(public_key.to_hex()), None) + } + SessionState::Active => (SessionStateDto::Active, None, None), + SessionState::SigningOut => (SessionStateDto::SigningOut, None, None), + SessionState::Failed(error) => (SessionStateDto::Failed, None, Some(error.into())), + }; + Self { + revision: snapshot.revision().value(), + lifecycle, + lifecycle_error, + configured_relays: snapshot + .relay_configuration() + .relays() + .iter() + .map(|relay| relay.as_str().to_owned()) + .collect(), + accounts: snapshot.accounts().iter().map(AccountDto::from).collect(), + selected_public_key_hex: snapshot + .selected_account() + .map(radroots_studio_domain::PublicKey::to_hex), + session, + session_subject_public_key_hex, + session_error, + active_account: snapshot.active_account().map(ActiveAccountDto::from), + recoverable_problem: snapshot.recoverable_problem().map(SafeErrorDto::from), + } + } +} + +impl AppSnapshotDto { + pub(crate) fn from_runtime(snapshot: &AppSnapshot, runtime: RuntimeLifecycle) -> Self { + let mut dto = Self::from(snapshot); + let (lifecycle, problem) = match runtime { + RuntimeLifecycle::Opening => (AppLifecycleDto::Opening, None), + RuntimeLifecycle::CompatibilityChecking => { + (AppLifecycleDto::CompatibilityChecking, None) + } + RuntimeLifecycle::AcquiringOwnership => (AppLifecycleDto::AcquiringOwnership, None), + RuntimeLifecycle::Migrating => (AppLifecycleDto::Migrating, None), + RuntimeLifecycle::Recovering => (AppLifecycleDto::Recovering, None), + RuntimeLifecycle::Ready => (AppLifecycleDto::Ready, None), + RuntimeLifecycle::Degraded(error) => { + (AppLifecycleDto::Degraded, Some(SafeErrorDto::from(error))) + } + RuntimeLifecycle::Blocked(error) => { + (AppLifecycleDto::Blocked, Some(SafeErrorDto::from(error))) + } + RuntimeLifecycle::ShuttingDown => (AppLifecycleDto::ShuttingDown, None), + RuntimeLifecycle::Closed => (AppLifecycleDto::Closed, None), + RuntimeLifecycle::Fatal(error) => { + (AppLifecycleDto::Fatal, Some(SafeErrorDto::from(error))) + } + }; + dto.lifecycle = lifecycle; + dto.lifecycle_error = problem; + dto + } +} + +impl From<&AccountSummary> for AccountDto { + fn from(account: &AccountSummary) -> Self { + Self { + public_key_hex: account.public_key().to_hex(), + npub: account.npub().as_str().to_owned(), + display_label: account.display_label(), + signer_kind: SignerKindDto::LocalSecret, + key_availability: account.signer().availability().into(), + created_at_seconds: account.created_at().timestamp().as_seconds(), + last_used_at_seconds: account + .last_used_at() + .map(radroots_studio_domain::UnixTimestamp::as_seconds), + } + } +} + +impl From<&ActiveAccountSnapshot> for ActiveAccountDto { + fn from(active: &ActiveAccountSnapshot) -> Self { + Self { + account: active.account().into(), + relay_state: active.relay_state().into(), + profile_state: active.profile_state().into(), + profile: active.profile().map(ProfileDto::from), + } + } +} + +impl From<&ProfileMetadata> for ProfileDto { + fn from(profile: &ProfileMetadata) -> Self { + Self { + name: profile.name().map(str::to_owned), + display_name: profile.display_name().map(str::to_owned), + nip05: profile.nip05().map(str::to_owned), + about: profile.about().map(str::to_owned), + picture: profile.picture().map(str::to_owned), + } + } +} + +impl From<SafeError> for SafeErrorDto { + fn from(error: SafeError) -> Self { + let (category, retryable, recovery_action) = error_policy(error.code()); + Self { + code: error.code().into(), + category, + retryable, + recovery_action, + message: error.message().as_str().to_owned(), + } + } +} + +impl From<SafeErrorCode> for WireErrorCode { + fn from(code: SafeErrorCode) -> Self { + match code { + SafeErrorCode::InvalidPublicKey => Self::InvalidPublicKey, + SafeErrorCode::InvalidSecretKey => Self::InvalidSecretKey, + SafeErrorCode::InvalidAccountMetadata => Self::InvalidAccountMetadata, + SafeErrorCode::InvalidProfileMetadata => Self::InvalidProfileMetadata, + SafeErrorCode::InvalidApplicationState => Self::InvalidApplicationState, + SafeErrorCode::AccountAlreadyExists => Self::AccountAlreadyExists, + SafeErrorCode::AccountNotFound => Self::AccountNotFound, + SafeErrorCode::KeyringUnavailable => Self::KeyringUnavailable, + SafeErrorCode::CredentialMissing => Self::CredentialMissing, + SafeErrorCode::StorageUnavailable => Self::StorageUnavailable, + SafeErrorCode::StorageCorrupt => Self::StorageCorrupt, + SafeErrorCode::PendingOperationRecoveryRequired => { + Self::PendingOperationRecoveryRequired + } + SafeErrorCode::InvalidRelayConfiguration => Self::InvalidRelayConfiguration, + SafeErrorCode::RelayConnectionFailed => Self::RelayConnectionFailed, + SafeErrorCode::ProfileRefreshFailed => Self::ProfileRefreshFailed, + SafeErrorCode::ObserverRegistrationFailed => Self::ObserverRegistrationFailed, + SafeErrorCode::NativeLibraryLoadFailed => Self::NativeLibraryLoadFailed, + _ => Self::Internal, + } + } +} + +pub(crate) const fn error_policy( + code: SafeErrorCode, +) -> (WireErrorCategory, bool, WireRecoveryAction) { + match code { + SafeErrorCode::InvalidPublicKey + | SafeErrorCode::InvalidSecretKey + | SafeErrorCode::InvalidAccountMetadata + | SafeErrorCode::InvalidProfileMetadata => { + (WireErrorCategory::Input, false, WireRecoveryAction::None) + } + SafeErrorCode::AccountAlreadyExists | SafeErrorCode::AccountNotFound => { + (WireErrorCategory::Conflict, false, WireRecoveryAction::None) + } + SafeErrorCode::KeyringUnavailable => ( + WireErrorCategory::Credential, + true, + WireRecoveryAction::Retry, + ), + SafeErrorCode::CredentialMissing => ( + WireErrorCategory::Credential, + false, + WireRecoveryAction::RepairCredential, + ), + SafeErrorCode::StorageUnavailable => ( + WireErrorCategory::Storage, + true, + WireRecoveryAction::RestartApplication, + ), + SafeErrorCode::StorageCorrupt | SafeErrorCode::PendingOperationRecoveryRequired => ( + WireErrorCategory::Storage, + false, + WireRecoveryAction::RestartApplication, + ), + SafeErrorCode::InvalidRelayConfiguration => ( + WireErrorCategory::Network, + false, + WireRecoveryAction::CheckConfiguration, + ), + SafeErrorCode::RelayConnectionFailed | SafeErrorCode::ProfileRefreshFailed => { + (WireErrorCategory::Network, true, WireRecoveryAction::Retry) + } + SafeErrorCode::InvalidApplicationState | SafeErrorCode::ObserverRegistrationFailed => ( + WireErrorCategory::Lifecycle, + true, + WireRecoveryAction::Retry, + ), + SafeErrorCode::NativeLibraryLoadFailed => ( + WireErrorCategory::Internal, + false, + WireRecoveryAction::RestartApplication, + ), + _ => (WireErrorCategory::Internal, false, WireRecoveryAction::None), + } +} + +impl From<BindingAvailability> for KeyAvailabilityDto { + fn from(value: BindingAvailability) -> Self { + match value { + BindingAvailability::Available => Self::Available, + BindingAvailability::CredentialMissing => Self::CredentialMissing, + BindingAvailability::StoreUnavailable => Self::StoreUnavailable, + } + } +} + +impl From<RelayConnectionState> for RelayConnectionStateDto { + fn from(value: RelayConnectionState) -> Self { + match value { + RelayConnectionState::Disconnected => Self::Disconnected, + RelayConnectionState::Connecting => Self::Connecting, + RelayConnectionState::Connected => Self::Connected, + RelayConnectionState::Degraded => Self::Degraded, + RelayConnectionState::Error(_) => Self::Error, + } + } +} + +impl From<ProfileLoadState> for ProfileLoadStateDto { + fn from(value: ProfileLoadState) -> Self { + match value { + ProfileLoadState::Empty => Self::Empty, + ProfileLoadState::Loading => Self::Loading, + ProfileLoadState::Cached => Self::Cached, + ProfileLoadState::Fresh => Self::Fresh, + ProfileLoadState::Error(_) => Self::Error, + } + } +} + +#[cfg(test)] +mod tests { + use radroots_studio_application::{AppCore, RelayConfiguration}; + + use super::AppSnapshotDto; + + #[test] + fn snapshot_dto_is_revisioned_public_and_secret_free() { + let core = AppCore::in_memory(RelayConfiguration::default()); + let snapshot = core.bootstrap().expect("bootstrap"); + let dto = AppSnapshotDto::from(&snapshot); + let debug = format!("{dto:?}"); + + assert_eq!(dto.revision, 1); + assert!(dto.accounts.is_empty()); + assert!(!debug.contains("nsec")); + assert!(!debug.contains("secret_key")); + assert!(!debug.contains("server_url")); + } +} diff --git a/crates/studio_ffi/src/lib.rs b/crates/studio_ffi/src/lib.rs @@ -0,0 +1,34 @@ +#![doc = "Radroots Studio `UniFFI` boundary."] + +mod commands; +mod dto; +mod observer; + +pub use commands::{ + AccountCommandReceiptDto, GeneratedRecoveryRequest, RemovalRequest, RequestContextDto, + StudioAppCore, StudioError, +}; +pub use dto::{ + AccountDto, ActiveAccountDto, AppLifecycleDto, AppSnapshotDto, KeyAvailabilityDto, ProfileDto, + ProfileLoadStateDto, RelayConnectionStateDto, SafeErrorDto, SessionStateDto, SignerKindDto, + WireErrorCategory, WireErrorCode, WireRecoveryAction, +}; +pub use observer::{ + ObserverSubscription, ShutdownReceiptDto, SnapshotChangeDto, StudioChangeObserver, +}; + +uniffi::setup_scaffolding!(); + +#[uniffi::export] +#[must_use] +pub fn native_runtime_version() -> String { + env!("CARGO_PKG_VERSION").to_owned() +} + +#[cfg(test)] +mod tests { + #[test] + fn native_runtime_reports_the_crate_version() { + assert_eq!(super::native_runtime_version(), "0.1.0-alpha"); + } +} diff --git a/crates/studio_ffi/src/observer.rs b/crates/studio_ffi/src/observer.rs @@ -0,0 +1,363 @@ +use std::num::NonZeroUsize; +use std::sync::atomic::Ordering; +use std::sync::{Arc, Mutex, Weak}; + +use radroots_studio_application::ChangeSubscriptionId; + +use crate::commands::RuntimeCore; +use crate::{AppSnapshotDto, StudioAppCore, StudioError}; + +const OBSERVER_CHANGE_CAPACITY: NonZeroUsize = match NonZeroUsize::new(64) { + Some(capacity) => capacity, + None => unreachable!(), +}; + +#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)] +pub struct SnapshotChangeDto { + pub snapshot: AppSnapshotDto, + pub previous_revision: Option<u64>, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Record)] +pub struct ShutdownReceiptDto { + pub final_revision: u64, + pub closed: bool, +} + +#[uniffi::export(callback_interface)] +pub trait StudioChangeObserver: Send + Sync { + fn on_change(&self, change: SnapshotChangeDto); +} + +#[derive(uniffi::Object)] +pub struct ObserverSubscription { + core: Weak<RuntimeCore>, + id: Mutex<Option<ChangeSubscriptionId>>, +} + +#[uniffi::export] +impl ObserverSubscription { + pub fn unsubscribe(&self) { + let id = self + .id + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .take(); + let (Some(core), Some(id)) = (self.core.upgrade(), id) else { + return; + }; + if let Some(task) = core + .observers + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .remove(&id) + { + task.abort(); + } + let actor = core.actor.clone(); + crate::commands::runtime().spawn(async move { + let _ = actor.unsubscribe_changes(id).await; + }); + } +} + +impl Drop for ObserverSubscription { + fn drop(&mut self) { + self.unsubscribe(); + } +} + +#[uniffi::export] +impl StudioAppCore { + /// Subscribes to ordered revision changes including predecessor metadata. + /// + /// # Errors + /// + /// Returns a safe observer or lifecycle error. + pub async fn subscribe_changes_v2( + &self, + observer: Box<dyn StudioChangeObserver>, + ) -> Result<Arc<ObserverSubscription>, StudioError> { + if self.inner.closed.load(Ordering::Acquire) { + return Err(closed_error()); + } + let mut subscription = self + .inner + .actor + .subscribe_changes(OBSERVER_CHANGE_CAPACITY) + .await + .map_err(StudioError::from)?; + let id = subscription.id(); + let observer: Arc<dyn StudioChangeObserver> = Arc::from(observer); + let runtime_core = Arc::clone(&self.inner); + let task = crate::commands::runtime().spawn(async move { + while let Some(change) = subscription.receive().await { + observer.on_change(SnapshotChangeDto { + snapshot: AppSnapshotDto::from_runtime( + change.snapshot(), + runtime_core.effective_lifecycle(), + ), + previous_revision: change + .previous_revision() + .map(radroots_studio_application::SnapshotRevision::value), + }); + } + }); + self.inner + .observers + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .insert(id, task); + Ok(Arc::new(ObserverSubscription { + core: Arc::downgrade(&self.inner), + id: Mutex::new(Some(id)), + })) + } + + /// Stops observer delivery and waits for actor-owned shutdown. + /// + /// # Errors + /// + /// Returns a safe closed or timeout error when shutdown cannot complete. + pub async fn shutdown_v2(&self) -> Result<ShutdownReceiptDto, StudioError> { + if self.inner.closed.swap(true, Ordering::AcqRel) { + return Err(closed_error()); + } + let handles = std::mem::take( + &mut *self + .inner + .observers + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner), + ); + for (_, task) in handles { + task.abort(); + } + self.inner.actor.close().await.map_err(StudioError::from)?; + Ok(ShutdownReceiptDto { + final_revision: self.inner.actor.snapshot().revision().value(), + closed: true, + }) + } +} + +fn closed_error() -> StudioError { + StudioError::Failure { + code: crate::WireErrorCode::InvalidApplicationState, + category: crate::WireErrorCategory::Lifecycle, + retryable: false, + recovery_action: crate::WireRecoveryAction::None, + correlation_id: None, + safe_message: "The application runtime is closed.".to_owned(), + } +} + +#[cfg(test)] +mod tests { + use std::num::NonZeroUsize; + use std::sync::{Arc, Mutex}; + use std::time::Duration; + + use nostr::{EventBuilder, Keys, Metadata}; + use nostr_relay_builder::MockRelay; + use nostr_sdk::Client; + use radroots_studio_application::{InMemorySecretStore, RelayConfiguration, SdkNostrClient}; + use radroots_studio_domain::RelayUrl; + use radroots_studio_storage::RuntimeActorHandle; + + use crate::commands::{ACTOR_MAILBOX_CAPACITY, RuntimeCore, SystemClock, runtime}; + use crate::{ + AppSnapshotDto, ProfileLoadStateDto, SnapshotChangeDto, StudioAppCore, StudioChangeObserver, + }; + + const SECRET_HEX: &str = "7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7"; + + #[derive(Default)] + struct RecordingObserver { + snapshots: Mutex<Vec<AppSnapshotDto>>, + core: Mutex<Option<Arc<StudioAppCore>>>, + } + + impl StudioChangeObserver for RecordingObserver { + fn on_change(&self, change: SnapshotChangeDto) { + let snapshot = change.snapshot; + if let Some(core) = self.core.lock().expect("core").as_ref() { + assert_eq!(core.snapshot().revision, snapshot.revision); + } + self.snapshots.lock().expect("snapshots").push(snapshot); + } + } + + fn core() -> Arc<StudioAppCore> { + core_with_relays(RelayConfiguration::default()) + } + + fn core_with_relays(relays: RelayConfiguration) -> Arc<StudioAppCore> { + let actor = RuntimeActorHandle::in_memory( + relays, + Arc::new(InMemorySecretStore::default()), + Arc::new(SystemClock), + Arc::new(SdkNostrClient::new(std::time::Duration::from_millis(10))), + NonZeroUsize::new(ACTOR_MAILBOX_CAPACITY).expect("capacity"), + runtime().handle(), + ) + .expect("actor"); + Arc::new(StudioAppCore { + inner: Arc::new(RuntimeCore { + actor, + observers: Mutex::new(std::collections::BTreeMap::new()), + closed: std::sync::atomic::AtomicBool::new(false), + startup_relay_problem: None, + }), + }) + } + + #[test] + fn callbacks_allow_reentry_and_stop_after_subscription_close() { + runtime().block_on(async { + let core = core(); + let observer = Arc::new(RecordingObserver::default()); + *observer.core.lock().expect("core") = Some(Arc::clone(&core)); + let subscription = core + .subscribe_changes_v2(Box::new(ArcObserver(observer.clone()))) + .await + .expect("subscribe"); + + wait_for_snapshot_count(&observer, 1).await; + core.inner + .actor + .bootstrap() + .await + .expect("idempotent bootstrap"); + assert_eq!(observer.snapshots.lock().expect("snapshots").len(), 1); + subscription.unsubscribe(); + core.inner.actor.sign_out().await.expect("sign out"); + assert_eq!(observer.snapshots.lock().expect("snapshots").len(), 1); + }); + } + + #[test] + fn core_close_deregisters_all_observers_and_rejects_new_subscriptions() { + let core = core(); + let observer = Arc::new(RecordingObserver::default()); + let _subscription = runtime() + .block_on(core.subscribe_changes_v2(Box::new(ArcObserver(observer.clone())))) + .expect("subscribe"); + + runtime().block_on(core.shutdown_v2()).expect("shutdown"); + + assert!( + runtime() + .block_on(core.subscribe_changes_v2(Box::new(ArcObserver(observer)))) + .is_err() + ); + assert!(core.inner.observers.lock().expect("observers").is_empty()); + } + + #[tokio::test] + async fn ffi_callback_receives_async_profile_refresh_and_stops_after_unsubscribe() { + let local_relay = MockRelay::run().await.expect("local relay"); + let relay_url = local_relay.url().await; + let publisher = Client::new(Keys::parse(SECRET_HEX).expect("known key")); + 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().display_name("FFI Profile"), + )) + .await + .expect("publish profile"); + + let core = core_with_relays(RelayConfiguration::new(vec![ + RelayUrl::parse(relay_url.as_str()).expect("relay URL"), + ])); + core.bootstrap().await.expect("bootstrap"); + let observer = Arc::new(RecordingObserver::default()); + *observer.core.lock().expect("core") = Some(Arc::clone(&core)); + let subscription = core + .subscribe_changes_v2(Box::new(ArcObserver(observer.clone()))) + .await + .expect("subscribe"); + let imported = core + .import_account_v2( + crate::RequestContextDto { + request_id: "observer-import".to_owned(), + expected_revision: core.snapshot().revision, + deadline_millis: 5_000, + }, + SECRET_HEX.as_bytes().to_vec(), + ) + .await + .expect("import") + .snapshot; + let public_key = imported.selected_public_key_hex.expect("selection"); + core.activate_account(public_key).await.expect("activate"); + core.refresh_active_profile().await.expect("refresh"); + + wait_for_fresh_profile(&observer).await; + let snapshots = observer.snapshots.lock().expect("snapshots").clone(); + assert!(snapshots.iter().any(|snapshot| { + snapshot.active_account.as_ref().is_some_and(|active| { + active.profile_state == ProfileLoadStateDto::Fresh + && active + .profile + .as_ref() + .and_then(|profile| profile.display_name.as_deref()) + == Some("FFI Profile") + }) + })); + subscription.unsubscribe(); + let count = observer.snapshots.lock().expect("snapshots").len(); + core.sign_out().await.expect("sign out"); + assert_eq!(observer.snapshots.lock().expect("snapshots").len(), count); + + core.shutdown_v2().await.expect("shutdown"); + publisher.shutdown().await; + local_relay.shutdown(); + } + + struct ArcObserver(Arc<RecordingObserver>); + + impl StudioChangeObserver for ArcObserver { + fn on_change(&self, change: SnapshotChangeDto) { + self.0.on_change(change); + } + } + + async fn wait_for_snapshot_count(observer: &RecordingObserver, minimum: usize) { + tokio::time::timeout(Duration::from_secs(1), async { + while observer.snapshots.lock().expect("snapshots").len() < minimum { + tokio::task::yield_now().await; + } + }) + .await + .expect("snapshot delivery"); + } + + async fn wait_for_fresh_profile(observer: &RecordingObserver) { + tokio::time::timeout(Duration::from_secs(1), async { + loop { + let fresh = observer + .snapshots + .lock() + .expect("snapshots") + .iter() + .any(|snapshot| { + snapshot.active_account.as_ref().is_some_and(|active| { + active.profile_state == ProfileLoadStateDto::Fresh + }) + }); + if fresh { + break; + } + tokio::task::yield_now().await; + } + }) + .await + .expect("fresh profile delivery"); + } +} diff --git a/crates/studio_ffi/uniffi.toml b/crates/studio_ffi/uniffi.toml @@ -0,0 +1,3 @@ +[crates.radroots_studio_ffi.bindings.kotlin] +package_name = "org.radroots.studio.ffi" +cdylib_name = "radroots_studio_ffi" diff --git a/crates/studio_nostr/Cargo.toml b/crates/studio_nostr/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "radroots-studio-nostr" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true + +[dependencies] +nostr.workspace = true +radroots-studio-domain = { path = "../domain" } + +[lints] +workspace = true diff --git a/crates/studio_nostr/src/keys.rs b/crates/studio_nostr/src/keys.rs @@ -0,0 +1,152 @@ +use nostr::{Keys, ToBech32}; +use radroots_studio_domain::{ + Npub, Nsec, PublicKey, SafeError, SafeErrorCode, SafeMessage, SecretKeyInput, +}; + +pub struct GeneratedKeyMaterial { + public_key: PublicKey, + npub: Npub, + secret: SecretKeyInput, + nsec: Nsec, +} + +impl GeneratedKeyMaterial { + #[must_use] + pub fn into_parts(self) -> (PublicKey, Npub, SecretKeyInput, Nsec) { + (self.public_key, self.npub, self.secret, self.nsec) + } +} + +pub struct ImportedKeyMaterial { + public_key: PublicKey, + npub: Npub, + secret: SecretKeyInput, +} + +impl ImportedKeyMaterial { + #[must_use] + pub fn into_parts(self) -> (PublicKey, Npub, SecretKeyInput) { + (self.public_key, self.npub, self.secret) + } +} + +/// Generates one cryptographically random local Nostr keypair. +/// +/// # Errors +/// +/// Returns a safe key error if an upstream encoding cannot be represented by +/// the stricter Radroots domain boundary. +pub fn generate_local_keypair() -> Result<GeneratedKeyMaterial, SafeError> { + let keys = Keys::generate(); + let (public_key, npub, secret, nsec) = encode_keys(&keys)?; + Ok(GeneratedKeyMaterial { + public_key, + npub, + secret, + nsec, + }) +} + +/// Parses nsec or canonical secret hex and derives public Nostr identity. +/// +/// # Errors +/// +/// Returns a safe invalid-secret-key error for checksum, scalar, or encoding +/// failures without exposing the rejected input. +pub fn import_secret(input: SecretKeyInput) -> Result<ImportedKeyMaterial, SafeError> { + let keys = input + .with_exposed_secret(Keys::parse) + .map_err(|_| invalid_secret_key())?; + drop(input); + let public_key = PublicKey::from_bytes(keys.public_key().to_bytes()); + let npub = keys + .public_key() + .to_bech32() + .map_err(|_| invalid_public_key()) + .and_then(Npub::from_encoded)?; + let secret = SecretKeyInput::parse(keys.secret_key().to_secret_hex())?; + Ok(ImportedKeyMaterial { + public_key, + npub, + secret, + }) +} + +fn encode_keys(keys: &Keys) -> Result<(PublicKey, Npub, SecretKeyInput, Nsec), SafeError> { + let public_key = PublicKey::from_bytes(keys.public_key().to_bytes()); + let npub = keys + .public_key() + .to_bech32() + .map_err(|_| invalid_public_key()) + .and_then(Npub::from_encoded)?; + let secret = SecretKeyInput::parse(keys.secret_key().to_secret_hex())?; + let nsec = keys + .secret_key() + .to_bech32() + .map_err(|_| invalid_secret_key()) + .and_then(Nsec::from_encoded)?; + Ok((public_key, npub, secret, nsec)) +} + +const fn invalid_secret_key() -> SafeError { + SafeError::new( + SafeErrorCode::InvalidSecretKey, + SafeMessage::new("The Nostr secret key is invalid."), + ) +} + +const fn invalid_public_key() -> SafeError { + SafeError::new( + SafeErrorCode::InvalidPublicKey, + SafeMessage::new("The Nostr public key is invalid."), + ) +} + +#[cfg(test)] +mod tests { + use radroots_studio_domain::{SafeErrorCode, SecretKeyInput}; + + use super::{generate_local_keypair, import_secret}; + + const SECRET_HEX: &str = "7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7"; + const NSEC: &str = "nsec1vl029mgpspedva04g90vltkh6fvh240zqtv9k0t9af8935ke9laqsnlfe5"; + const NSEC_PUBLIC_HEX: &str = + "7e7e9c42a91bfef19fa929e5fda1b72e0ebc1a4c1141673e2794234d86addf4e"; + const HEX_PUBLIC_HEX: &str = "0cfda0afa91cc2fbbd6050c285802fe95c7a1755e0f68323999e13760501dc40"; + + #[test] + fn keys_generate_valid_redacted_material() { + let generated = generate_local_keypair().expect("generated"); + let (public_key, npub, secret, nsec) = generated.into_parts(); + assert_eq!(public_key.to_hex().len(), 64); + assert!(npub.as_str().starts_with("npub1")); + assert_eq!(secret.with_exposed_secret(str::len), 64); + assert_eq!(nsec.with_exposed_secret(str::len), 63); + assert_eq!(secret.with_exposed_secret(str::len), 64); + assert_eq!(nsec.with_exposed_secret(str::len), 63); + } + + #[test] + fn keys_import_known_nsec_and_hex_vectors() { + let from_nsec = import_secret(SecretKeyInput::parse(NSEC.to_owned()).expect("nsec")) + .expect("import nsec"); + let from_hex = import_secret(SecretKeyInput::parse(SECRET_HEX.to_owned()).expect("hex")) + .expect("import hex"); + let (nsec_public, nsec_npub, _) = from_nsec.into_parts(); + let (hex_public, hex_npub, _) = from_hex.into_parts(); + assert_eq!(nsec_public.to_hex(), NSEC_PUBLIC_HEX); + assert_eq!(hex_public.to_hex(), HEX_PUBLIC_HEX); + assert!(nsec_npub.as_str().starts_with("npub1")); + assert!(hex_npub.as_str().starts_with("npub1")); + } + + #[test] + fn keys_reject_structurally_plausible_nsec_with_invalid_checksum() { + let input = SecretKeyInput::parse( + "nsec1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq".to_owned(), + ) + .expect("domain shape"); + let error = import_secret(input).err().expect("invalid checksum"); + assert_eq!(error.code(), SafeErrorCode::InvalidSecretKey); + } +} diff --git a/crates/studio_nostr/src/lib.rs b/crates/studio_nostr/src/lib.rs @@ -0,0 +1,7 @@ +#![doc = "Radroots Studio Nostr protocol adapters."] + +pub mod keys; +pub mod profile; + +pub use keys::{GeneratedKeyMaterial, ImportedKeyMaterial, generate_local_keypair, import_secret}; +pub use profile::parse_verified_kind0; diff --git a/crates/studio_nostr/src/profile.rs b/crates/studio_nostr/src/profile.rs @@ -0,0 +1,165 @@ +use nostr::{Event, JsonUtil, Kind, Metadata}; +use radroots_studio_domain::{ + EventId, Kind0ProfileCandidate, ProfileMetadata, PublicKey, SafeError, SafeErrorCode, + SafeMessage, UnixTimestamp, +}; + +const MAX_EVENT_JSON_BYTES: usize = 64 * 1_024; +const MAX_PROFILE_CONTENT_BYTES: usize = 16 * 1_024; + +/// Verifies and converts one serialized Nostr kind-0 event. +/// +/// # Errors +/// +/// Returns a safe profile-refresh error when the event is oversized, +/// malformed, invalidly signed, authored by another key, or not kind 0. +pub fn parse_verified_kind0( + event_json: &str, + expected_author: PublicKey, +) -> Result<Kind0ProfileCandidate, SafeError> { + if event_json.len() > MAX_EVENT_JSON_BYTES { + return Err(invalid_event()); + } + + let event = Event::from_json(event_json).map_err(|_| invalid_event())?; + event.verify().map_err(|_| invalid_event())?; + if event.kind != Kind::Metadata + || event.pubkey.to_bytes() != *expected_author.as_bytes() + || event.content.len() > MAX_PROFILE_CONTENT_BYTES + { + return Err(invalid_event()); + } + + let metadata = Metadata::from_json(&event.content).map_err(|_| invalid_metadata())?; + let profile = ProfileMetadata::new( + metadata.name, + metadata.display_name, + metadata.nip05, + metadata.about, + metadata.picture, + )?; + let created_at = i64::try_from(event.created_at.as_secs()) + .ok() + .and_then(UnixTimestamp::from_seconds) + .ok_or_else(invalid_event)?; + + Ok(Kind0ProfileCandidate::new( + EventId::from_bytes(event.id.to_bytes()), + expected_author, + created_at, + profile, + )) +} + +const fn invalid_event() -> SafeError { + SafeError::new( + SafeErrorCode::ProfileRefreshFailed, + SafeMessage::new("The Nostr profile event is invalid."), + ) +} + +const fn invalid_metadata() -> SafeError { + SafeError::new( + SafeErrorCode::InvalidProfileMetadata, + SafeMessage::new("The Nostr profile metadata is invalid."), + ) +} + +#[cfg(test)] +mod tests { + use nostr::{EventBuilder, JsonUtil, Keys, Metadata, Url}; + use radroots_studio_domain::{PublicKey, SafeErrorCode}; + + use super::parse_verified_kind0; + + fn signed_profile() -> (Keys, String) { + let keys = Keys::generate(); + let event = EventBuilder::metadata( + &Metadata::new() + .name(" farmer ") + .display_name(" Farm Account ") + .nip05("farmer@example.test") + .about("Local grower") + .picture( + Url::parse("https://images.example.test/farmer.png") + .expect("valid picture URL"), + ), + ) + .sign_with_keys(&keys) + .expect("signed metadata event"); + (keys, event.as_json()) + } + + #[test] + fn profile_event_verifies_signature_author_kind_and_metadata() { + let (keys, json) = signed_profile(); + let expected_author = PublicKey::from_bytes(keys.public_key().to_bytes()); + + let candidate = parse_verified_kind0(&json, expected_author).expect("verified profile"); + + assert_eq!(candidate.author(), expected_author); + assert_eq!(candidate.metadata().name(), Some("farmer")); + assert_eq!(candidate.metadata().display_name(), Some("Farm Account")); + assert_eq!(candidate.metadata().nip05(), Some("farmer@example.test")); + assert_eq!(candidate.metadata().about(), Some("Local grower")); + assert_eq!( + candidate.metadata().picture(), + Some("https://images.example.test/farmer.png") + ); + } + + #[test] + fn profile_event_rejects_tampering_wrong_author_kind_and_oversize_content() { + let (keys, json) = signed_profile(); + let expected_author = PublicKey::from_bytes(keys.public_key().to_bytes()); + let wrong_author = PublicKey::from_bytes(Keys::generate().public_key().to_bytes()); + let tampered = json.replace("Local grower", "Remote grower"); + let note = EventBuilder::text_note("not metadata") + .sign_with_keys(&keys) + .expect("signed note") + .as_json(); + let oversized = EventBuilder::metadata(&Metadata::new().about("x".repeat(16 * 1_024 + 1))) + .sign_with_keys(&keys) + .expect("signed oversized profile") + .as_json(); + + for rejected in [ + parse_verified_kind0(&tampered, expected_author), + parse_verified_kind0(&json, wrong_author), + parse_verified_kind0(&note, expected_author), + parse_verified_kind0(&oversized, expected_author), + ] { + assert_eq!( + rejected.expect_err("invalid event").code(), + SafeErrorCode::ProfileRefreshFailed + ); + } + } + + #[test] + fn profile_event_rejects_malformed_and_bounded_invalid_metadata() { + let keys = Keys::generate(); + let malformed = EventBuilder::new(nostr::Kind::Metadata, "not json") + .sign_with_keys(&keys) + .expect("signed malformed metadata") + .as_json(); + let invalid = EventBuilder::metadata(&Metadata::new().name("x".repeat(129))) + .sign_with_keys(&keys) + .expect("signed invalid metadata") + .as_json(); + let author = PublicKey::from_bytes(keys.public_key().to_bytes()); + + assert_eq!( + parse_verified_kind0(&malformed, author) + .expect_err("malformed metadata") + .code(), + SafeErrorCode::InvalidProfileMetadata + ); + assert_eq!( + parse_verified_kind0(&invalid, author) + .expect_err("bounded metadata") + .code(), + SafeErrorCode::InvalidProfileMetadata + ); + } +} diff --git a/crates/studio_storage/Cargo.toml b/crates/studio_storage/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "radroots-studio-storage" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true + +[dependencies] +radroots-studio-application = { path = "../application" } +radroots-studio-domain = { path = "../domain" } +fs2.workspace = true +keyring.workspace = true +zeroize.workspace = true +refinery.workspace = true +rusqlite.workspace = true +tokio.workspace = true + +[dev-dependencies] +nostr.workspace = true +nostr-relay-builder.workspace = true +nostr-sdk.workspace = true +tempfile = "=3.23.0" +tokio = { workspace = true, features = ["macros", "rt-multi-thread", "time"] } + +[lints] +workspace = true diff --git a/crates/studio_storage/migrations/V1__initialize.sql b/crates/studio_storage/migrations/V1__initialize.sql @@ -0,0 +1,6 @@ +CREATE TABLE application_schema ( + singleton INTEGER PRIMARY KEY CHECK (singleton = 1), + schema_version INTEGER NOT NULL CHECK (schema_version >= 1) +); + +INSERT INTO application_schema (singleton, schema_version) VALUES (1, 1); diff --git a/crates/studio_storage/migrations/V2__accounts.sql b/crates/studio_storage/migrations/V2__accounts.sql @@ -0,0 +1,28 @@ +CREATE TABLE accounts ( + pubkey TEXT PRIMARY KEY NOT NULL CHECK ( + length(pubkey) = 64 AND pubkey = lower(pubkey) + ), + npub TEXT NOT NULL CHECK (length(npub) = 63), + signer_kind TEXT NOT NULL CHECK ( + signer_kind IN ('local_secret', 'watch_only', 'remote_nip46') + ), + key_availability TEXT NOT NULL CHECK ( + key_availability IN ( + 'available', + 'credential_missing', + 'store_unavailable', + 'not_required' + ) + ), + label TEXT, + created_at INTEGER NOT NULL CHECK (created_at >= 0), + last_used_at INTEGER CHECK (last_used_at >= 0) +); + +CREATE TABLE app_state ( + singleton INTEGER PRIMARY KEY CHECK (singleton = 1), + selected_pubkey TEXT REFERENCES accounts(pubkey) ON DELETE SET NULL +); + +INSERT INTO app_state (singleton, selected_pubkey) VALUES (1, NULL); +UPDATE application_schema SET schema_version = 2 WHERE singleton = 1; diff --git a/crates/studio_storage/migrations/V3__profile_cache.sql b/crates/studio_storage/migrations/V3__profile_cache.sql @@ -0,0 +1,12 @@ +CREATE TABLE profile_cache ( + subject_pubkey TEXT PRIMARY KEY NOT NULL REFERENCES accounts(pubkey) ON DELETE CASCADE, + event_id TEXT NOT NULL, + event_created_at INTEGER NOT NULL, + name TEXT, + display_name TEXT, + nip05 TEXT, + about TEXT, + picture TEXT, + refreshed_at INTEGER NOT NULL, + refresh_status TEXT NOT NULL CHECK (refresh_status IN ('success', 'offline', 'invalid_data')) +) STRICT; diff --git a/crates/studio_storage/migrations/V4__account_namespace.sql b/crates/studio_storage/migrations/V4__account_namespace.sql @@ -0,0 +1,6 @@ +CREATE TABLE account_namespace ( + owner_pubkey TEXT NOT NULL REFERENCES accounts(pubkey) ON DELETE CASCADE, + preference_key TEXT NOT NULL CHECK (preference_key IN ('namespace_probe')), + preference_value TEXT NOT NULL CHECK (length(preference_value) <= 4096), + PRIMARY KEY (owner_pubkey, preference_key) +) STRICT; diff --git a/crates/studio_storage/migrations/V5__operation_journal.sql b/crates/studio_storage/migrations/V5__operation_journal.sql @@ -0,0 +1,8 @@ +CREATE TABLE operation_journal ( + operation_id INTEGER PRIMARY KEY AUTOINCREMENT, + operation_kind TEXT NOT NULL CHECK (operation_kind IN ('add', 'import', 'remove')), + subject_pubkey TEXT NOT NULL, + phase TEXT NOT NULL CHECK (phase IN ('intent_recorded', 'credential_written', 'metadata_committed', 'compensation_pending', 'credential_deleted', 'metadata_deleted')), + updated_at INTEGER NOT NULL, + diagnostic_code TEXT CHECK (diagnostic_code IN ('storage_unavailable', 'keyring_unavailable', 'credential_missing', 'compensation_failed')) +) STRICT; diff --git a/crates/studio_storage/migrations/V6__normalized_runtime_schema.sql b/crates/studio_storage/migrations/V6__normalized_runtime_schema.sql @@ -0,0 +1,100 @@ +CREATE TABLE account_identities ( + public_key TEXT PRIMARY KEY NOT NULL CHECK ( + length(public_key) = 64 AND public_key = lower(public_key) + ), + npub TEXT NOT NULL UNIQUE CHECK (length(npub) = 63), + label TEXT CHECK (label IS NULL OR length(label) BETWEEN 1 AND 80), + created_at INTEGER NOT NULL CHECK (created_at >= 0), + last_used_at INTEGER CHECK (last_used_at IS NULL OR last_used_at >= 0) +) STRICT; + +CREATE TABLE local_signer_bindings ( + account_public_key TEXT NOT NULL, + binding_public_key TEXT NOT NULL, + binding_kind TEXT NOT NULL CHECK (binding_kind = 'local_secret'), + availability TEXT NOT NULL CHECK ( + availability IN ('available', 'credential_missing', 'store_unavailable') + ), + PRIMARY KEY (account_public_key, binding_public_key), + UNIQUE (account_public_key, binding_kind), + FOREIGN KEY (account_public_key) REFERENCES account_identities(public_key) ON DELETE CASCADE, + CHECK (account_public_key = binding_public_key) +) STRICT; + +CREATE TABLE runtime_state ( + singleton INTEGER PRIMARY KEY CHECK (singleton = 1), + selected_public_key TEXT REFERENCES account_identities(public_key) ON DELETE SET NULL, + active_account_public_key TEXT, + active_binding_public_key TEXT, + session_generation INTEGER NOT NULL DEFAULT 0 CHECK (session_generation >= 0), + FOREIGN KEY (active_account_public_key, active_binding_public_key) + REFERENCES local_signer_bindings(account_public_key, binding_public_key) + ON DELETE SET NULL, + CHECK ( + (active_account_public_key IS NULL AND active_binding_public_key IS NULL) + OR + (active_account_public_key IS NOT NULL AND active_binding_public_key IS NOT NULL) + ) +) STRICT; + +INSERT INTO runtime_state (singleton) VALUES (1); + +CREATE TABLE profile_cache_v6 ( + subject_public_key TEXT PRIMARY KEY NOT NULL + REFERENCES account_identities(public_key) ON DELETE CASCADE, + event_id TEXT NOT NULL CHECK (length(event_id) = 64 AND event_id = lower(event_id)), + event_created_at INTEGER NOT NULL CHECK (event_created_at >= 0), + name TEXT, + display_name TEXT, + nip05 TEXT, + about TEXT, + picture TEXT, + refreshed_at INTEGER NOT NULL CHECK (refreshed_at >= 0), + refresh_status TEXT NOT NULL CHECK ( + refresh_status IN ('success', 'offline', 'invalid_data') + ) +) STRICT; + +CREATE TABLE durable_operations ( + request_id TEXT PRIMARY KEY NOT NULL CHECK (length(request_id) BETWEEN 1 AND 128), + operation_kind TEXT NOT NULL CHECK ( + operation_kind IN ('create', 'import', 'repair', 'remove') + ), + account_public_key TEXT NOT NULL CHECK ( + length(account_public_key) = 64 AND account_public_key = lower(account_public_key) + ), + binding_public_key TEXT NOT NULL CHECK (binding_public_key = account_public_key), + expected_revision INTEGER CHECK (expected_revision IS NULL OR expected_revision >= 0), + phase TEXT NOT NULL CHECK ( + phase IN ( + 'intent_recorded', + 'credential_written', + 'metadata_committed', + 'selection_committed', + 'compensation_pending', + 'credential_deleted', + 'metadata_deleted', + 'finalized' + ) + ), + terminal_outcome TEXT CHECK ( + terminal_outcome IS NULL OR terminal_outcome IN ('completed', 'cancelled', 'failed') + ), + prior_selected_public_key TEXT, + updated_at INTEGER NOT NULL CHECK (updated_at >= 0), + diagnostic_code TEXT CHECK ( + diagnostic_code IS NULL OR diagnostic_code IN ( + 'storage_unavailable', + 'keyring_unavailable', + 'credential_missing', + 'compensation_failed', + 'conflict', + 'expired' + ) + ), + CHECK ( + (phase = 'finalized' AND terminal_outcome IS NOT NULL) + OR + (phase <> 'finalized' AND terminal_outcome IS NULL) + ) +) STRICT; diff --git a/crates/studio_storage/migrations/V7__migrate_v5_runtime_data.sql b/crates/studio_storage/migrations/V7__migrate_v5_runtime_data.sql @@ -0,0 +1,68 @@ +INSERT INTO account_identities ( + public_key, + npub, + label, + created_at, + last_used_at +) +SELECT pubkey, npub, label, created_at, last_used_at +FROM accounts; + +INSERT INTO local_signer_bindings ( + account_public_key, + binding_public_key, + binding_kind, + availability +) +SELECT pubkey, pubkey, 'local_secret', key_availability +FROM accounts; + +UPDATE runtime_state +SET selected_public_key = ( + SELECT selected_pubkey FROM app_state WHERE singleton = 1 +) +WHERE singleton = 1; + +INSERT INTO profile_cache_v6 ( + subject_public_key, + event_id, + event_created_at, + name, + display_name, + nip05, + about, + picture, + refreshed_at, + refresh_status +) +SELECT + subject_pubkey, + event_id, + event_created_at, + name, + display_name, + nip05, + about, + picture, + refreshed_at, + refresh_status +FROM profile_cache; + +INSERT INTO durable_operations ( + request_id, + operation_kind, + account_public_key, + binding_public_key, + phase, + updated_at, + diagnostic_code +) +SELECT + 'legacy-v5-' || operation_id, + CASE operation_kind WHEN 'add' THEN 'create' ELSE operation_kind END, + subject_pubkey, + subject_pubkey, + phase, + updated_at, + diagnostic_code +FROM operation_journal; diff --git a/crates/studio_storage/migrations/V8__normalized_account_preferences.sql b/crates/studio_storage/migrations/V8__normalized_account_preferences.sql @@ -0,0 +1,10 @@ +CREATE TABLE account_preferences ( + owner_public_key TEXT NOT NULL REFERENCES account_identities(public_key) ON DELETE CASCADE, + preference_key TEXT NOT NULL CHECK (preference_key = 'namespace_probe'), + preference_value TEXT NOT NULL CHECK (length(preference_value) <= 4096), + PRIMARY KEY (owner_public_key, preference_key) +) STRICT; + +INSERT INTO account_preferences (owner_public_key, preference_key, preference_value) +SELECT owner_pubkey, preference_key, preference_value +FROM account_namespace; diff --git a/crates/studio_storage/migrations/V9__durable_operation_receipts.sql b/crates/studio_storage/migrations/V9__durable_operation_receipts.sql @@ -0,0 +1,11 @@ +ALTER TABLE durable_operations ADD COLUMN prior_binding_availability TEXT CHECK ( + prior_binding_availability IS NULL OR prior_binding_availability IN ( + 'available', + 'credential_missing', + 'store_unavailable' + ) +); + +ALTER TABLE durable_operations ADD COLUMN resulting_revision INTEGER CHECK ( + resulting_revision IS NULL OR resulting_revision >= 0 +); diff --git a/crates/studio_storage/src/account_namespace.rs b/crates/studio_storage/src/account_namespace.rs @@ -0,0 +1,162 @@ +use radroots_studio_application::{AccountNamespaceRepository, AccountPreferenceKey}; +use radroots_studio_domain::{PublicKey, SafeError, SafeErrorCode, SafeMessage}; +use rusqlite::{OptionalExtension, params}; + +use crate::Database; + +const MAX_VALUE_CHARS: usize = 4_096; + +impl AccountNamespaceRepository for Database { + fn get_value( + &self, + owner: PublicKey, + key: AccountPreferenceKey, + ) -> Result<Option<String>, SafeError> { + self.connection() + .query_row( + "SELECT preference_value FROM account_preferences \ + WHERE owner_public_key = ?1 AND preference_key = ?2", + params![owner.to_hex(), encode_key(key)], + |row| row.get(0), + ) + .optional() + .map_err(|_| storage_error()) + } + + fn set_value( + &self, + owner: PublicKey, + key: AccountPreferenceKey, + value: &str, + ) -> Result<(), SafeError> { + if value.chars().count() > MAX_VALUE_CHARS || value.chars().any(char::is_control) { + return Err(invalid_preference()); + } + self.connection() + .execute( + "INSERT INTO account_preferences (owner_public_key, preference_key, preference_value) \ + VALUES (?1, ?2, ?3) ON CONFLICT(owner_public_key, preference_key) DO UPDATE SET \ + preference_value = excluded.preference_value", + params![owner.to_hex(), encode_key(key), value], + ) + .map(|_| ()) + .map_err(|_| storage_error()) + } + + fn clear_owner(&self, owner: PublicKey) -> Result<(), SafeError> { + self.connection() + .execute( + "DELETE FROM account_preferences WHERE owner_public_key = ?1", + [owner.to_hex()], + ) + .map(|_| ()) + .map_err(|_| storage_error()) + } +} + +const fn encode_key(key: AccountPreferenceKey) -> &'static str { + match key { + AccountPreferenceKey::NamespaceProbe => "namespace_probe", + } +} + +const fn storage_error() -> SafeError { + SafeError::new( + SafeErrorCode::StorageUnavailable, + SafeMessage::new("The account preference is unavailable."), + ) +} + +const fn invalid_preference() -> SafeError { + SafeError::new( + SafeErrorCode::InvalidAccountMetadata, + SafeMessage::new("The account preference is invalid."), + ) +} + +#[cfg(test)] +mod tests { + use radroots_studio_application::{ + AccountNamespaceRepository, AccountPreferenceKey, AccountRepository, AppStateRepository, + }; + use radroots_studio_domain::{ + AccountCreatedAt, AccountIdentity, AccountSummary, BindingAvailability, LocalSignerBinding, + PublicKey, UnixTimestamp, + }; + + use crate::Database; + + fn account(byte: u8) -> AccountSummary { + let public_key = PublicKey::from_bytes([byte; 32]); + AccountSummary::new( + AccountIdentity::derive(public_key).expect("identity"), + LocalSignerBinding::new(public_key, BindingAvailability::Available), + None, + AccountCreatedAt::new(UnixTimestamp::from_seconds(i64::from(byte)).expect("time")), + None, + ) + .expect("account") + } + + #[test] + fn namespace_partitions_same_typed_key_by_owner_and_selection() { + let database = Database::in_memory().expect("database"); + let owner_a = PublicKey::from_bytes([1; 32]); + let owner_b = PublicKey::from_bytes([2; 32]); + database.insert_account(&account(1)).expect("account a"); + database.insert_account(&account(2)).expect("account b"); + database + .set_value(owner_a, AccountPreferenceKey::NamespaceProbe, "A") + .expect("set a"); + database + .set_value(owner_b, AccountPreferenceKey::NamespaceProbe, "B") + .expect("set b"); + + database + .save_selected_account(Some(owner_b)) + .expect("select b"); + let selected = database + .load_selected_account() + .expect("selection") + .expect("selected owner"); + assert_eq!( + database + .get_value(selected, AccountPreferenceKey::NamespaceProbe) + .expect("selected value"), + Some("B".to_owned()) + ); + assert_eq!( + database + .get_value(owner_a, AccountPreferenceKey::NamespaceProbe) + .expect("owner a value"), + Some("A".to_owned()) + ); + } + + #[test] + fn namespace_updates_and_cascades_with_owner_removal() { + let database = Database::in_memory().expect("database"); + let owner = PublicKey::from_bytes([3; 32]); + database.insert_account(&account(3)).expect("account"); + database + .set_value(owner, AccountPreferenceKey::NamespaceProbe, "before") + .expect("set"); + database + .set_value(owner, AccountPreferenceKey::NamespaceProbe, "after") + .expect("update"); + assert_eq!( + database + .get_value(owner, AccountPreferenceKey::NamespaceProbe) + .expect("value"), + Some("after".to_owned()) + ); + + database.remove_account(owner).expect("remove"); + assert_eq!( + database + .get_value(owner, AccountPreferenceKey::NamespaceProbe) + .expect("deleted value"), + None + ); + } +} diff --git a/crates/studio_storage/src/accounts.rs b/crates/studio_storage/src/accounts.rs @@ -0,0 +1,394 @@ +use radroots_studio_application::{AccountRepository, AppStateRepository}; +use radroots_studio_domain::{ + AccountCreatedAt, AccountIdentity, AccountLabel, AccountSummary, BindingAvailability, + LocalSignerBinding, PublicKey, SafeError, SafeErrorCode, SafeMessage, UnixTimestamp, +}; +use rusqlite::{OptionalExtension, Row, params}; + +use crate::Database; + +impl AccountRepository for Database { + fn list_accounts(&self) -> Result<Vec<AccountSummary>, SafeError> { + let connection = self.connection(); + let mut statement = connection + .prepare( + "SELECT identity.public_key, identity.npub, binding.binding_kind, \ + binding.availability, identity.label, identity.created_at, identity.last_used_at \ + FROM account_identities AS identity \ + JOIN local_signer_bindings AS binding \ + ON binding.account_public_key = identity.public_key \ + ORDER BY identity.created_at ASC, identity.public_key ASC", + ) + .map_err(|_| storage_error())?; + let rows = statement + .query_map([], decode_account) + .map_err(|_| storage_error())?; + rows.map(|row| row.map_err(|_| corrupt_storage_error())) + .collect() + } + + fn find_account(&self, public_key: PublicKey) -> Result<Option<AccountSummary>, SafeError> { + self.connection() + .query_row( + "SELECT identity.public_key, identity.npub, binding.binding_kind, \ + binding.availability, identity.label, identity.created_at, identity.last_used_at \ + FROM account_identities AS identity \ + JOIN local_signer_bindings AS binding \ + ON binding.account_public_key = identity.public_key \ + WHERE identity.public_key = ?1", + [public_key.to_hex()], + decode_account, + ) + .optional() + .map_err(|_| storage_error()) + } + + fn insert_account(&self, account: &AccountSummary) -> Result<(), SafeError> { + let encoded = EncodedAccount::from(account); + let mut connection = self.connection(); + let transaction = connection.transaction().map_err(|_| storage_error())?; + let result = transaction.execute( + "INSERT INTO account_identities (public_key, npub, label, created_at, last_used_at) \ + VALUES (?1, ?2, ?3, ?4, ?5)", + params![ + encoded.public_key, + encoded.npub, + encoded.label, + encoded.created_at, + encoded.last_used_at + ], + ); + match result { + Ok(1) => {} + Err(error) if is_constraint_violation(&error) => return Err(account_exists()), + Ok(_) | Err(_) => return Err(storage_error()), + } + if transaction + .execute( + "INSERT INTO local_signer_bindings (account_public_key, binding_public_key, \ + binding_kind, availability) VALUES (?1, ?1, ?2, ?3)", + params![ + encoded.public_key, + encoded.signer_kind, + encoded.key_availability + ], + ) + .map_err(|_| storage_error())? + != 1 + { + return Err(storage_error()); + } + transaction.commit().map_err(|_| storage_error()) + } + + fn update_account(&self, account: &AccountSummary) -> Result<(), SafeError> { + let encoded = EncodedAccount::from(account); + let mut connection = self.connection(); + let transaction = connection.transaction().map_err(|_| storage_error())?; + let identity_rows = transaction + .execute( + "UPDATE account_identities SET npub = ?2, label = ?5, created_at = ?6, \ + last_used_at = ?7 WHERE public_key = ?1", + params![ + encoded.public_key, + encoded.npub, + encoded.signer_kind, + encoded.key_availability, + encoded.label, + encoded.created_at, + encoded.last_used_at, + ], + ) + .map_err(|_| storage_error())?; + if identity_rows == 0 { + return Err(account_not_found()); + } + if identity_rows != 1 { + return Err(storage_error()); + } + let binding_rows = transaction + .execute( + "UPDATE local_signer_bindings SET binding_kind = ?2, availability = ?3 \ + WHERE account_public_key = ?1 AND binding_public_key = ?1", + params![ + encoded.public_key, + encoded.signer_kind, + encoded.key_availability + ], + ) + .map_err(|_| storage_error())?; + if binding_rows != 1 { + return Err(corrupt_storage_error()); + } + transaction.commit().map_err(|_| storage_error()) + } + + fn remove_account(&self, public_key: PublicKey) -> Result<(), SafeError> { + match self.connection().execute( + "DELETE FROM account_identities WHERE public_key = ?1", + [public_key.to_hex()], + ) { + Ok(1) => Ok(()), + Ok(0) => Err(account_not_found()), + Ok(_) | Err(_) => Err(storage_error()), + } + } +} + +impl AppStateRepository for Database { + fn load_selected_account(&self) -> Result<Option<PublicKey>, SafeError> { + let value = self + .connection() + .query_row( + "SELECT selected_public_key FROM runtime_state WHERE singleton = 1", + [], + |row| row.get::<_, Option<String>>(0), + ) + .map_err(|_| corrupt_storage_error())?; + value + .map(|hex| PublicKey::from_hex(&hex).map_err(|_| corrupt_storage_error())) + .transpose() + } + + fn save_selected_account(&self, public_key: Option<PublicKey>) -> Result<(), SafeError> { + let mut connection = self.connection(); + let transaction = connection.transaction().map_err(|_| storage_error())?; + if let Some(public_key) = public_key { + let exists = transaction + .query_row( + "SELECT EXISTS(SELECT 1 FROM account_identities WHERE public_key = ?1)", + [public_key.to_hex()], + |row| row.get::<_, bool>(0), + ) + .map_err(|_| storage_error())?; + if !exists { + return Err(account_not_found()); + } + } + let rows = transaction + .execute( + "UPDATE runtime_state SET selected_public_key = ?1 WHERE singleton = 1", + [public_key.map(PublicKey::to_hex)], + ) + .map_err(|_| storage_error())?; + if rows != 1 { + return Err(corrupt_storage_error()); + } + transaction.commit().map_err(|_| storage_error()) + } +} + +struct EncodedAccount { + public_key: String, + npub: String, + signer_kind: &'static str, + key_availability: &'static str, + label: Option<String>, + created_at: i64, + last_used_at: Option<i64>, +} + +impl From<&AccountSummary> for EncodedAccount { + fn from(account: &AccountSummary) -> Self { + Self { + public_key: account.public_key().to_hex(), + npub: account.npub().as_str().to_owned(), + signer_kind: "local_secret", + key_availability: encode_key_availability(account.signer().availability()), + label: account.label().map(|label| label.as_str().to_owned()), + created_at: account.created_at().timestamp().as_seconds(), + last_used_at: account.last_used_at().map(UnixTimestamp::as_seconds), + } + } +} + +fn decode_account(row: &Row<'_>) -> rusqlite::Result<AccountSummary> { + let public_key = + PublicKey::from_hex(row.get::<_, String>(0)?.as_str()).map_err(|_| invalid_column(0))?; + let npub: String = row.get(1)?; + if row.get::<_, String>(2)?.as_str() != "local_secret" { + return Err(invalid_column(2)); + } + let key_availability = decode_key_availability(row.get::<_, String>(3)?.as_str())?; + let label = row + .get::<_, Option<String>>(4)? + .map(|value| AccountLabel::parse(&value).map_err(|_| invalid_column(4))) + .transpose()?; + let created_at = UnixTimestamp::from_seconds(row.get(5)?).ok_or_else(|| invalid_column(5))?; + let last_used_at = row + .get::<_, Option<i64>>(6)? + .map(|value| UnixTimestamp::from_seconds(value).ok_or_else(|| invalid_column(6))) + .transpose()?; + + AccountSummary::new( + AccountIdentity::verify(public_key, npub).map_err(|_| invalid_column(1))?, + LocalSignerBinding::new(public_key, key_availability), + label, + AccountCreatedAt::new(created_at), + last_used_at, + ) + .map_err(|_| invalid_column(0)) +} + +const fn encode_key_availability(value: BindingAvailability) -> &'static str { + match value { + BindingAvailability::Available => "available", + BindingAvailability::CredentialMissing => "credential_missing", + BindingAvailability::StoreUnavailable => "store_unavailable", + } +} + +fn decode_key_availability(value: &str) -> rusqlite::Result<BindingAvailability> { + match value { + "available" => Ok(BindingAvailability::Available), + "credential_missing" => Ok(BindingAvailability::CredentialMissing), + "store_unavailable" => Ok(BindingAvailability::StoreUnavailable), + _ => Err(invalid_column(3)), + } +} + +fn invalid_column(index: usize) -> rusqlite::Error { + rusqlite::Error::InvalidColumnType( + index, + "public account metadata".to_owned(), + rusqlite::types::Type::Text, + ) +} + +fn is_constraint_violation(error: &rusqlite::Error) -> bool { + matches!( + error, + rusqlite::Error::SqliteFailure( + rusqlite::ffi::Error { + code: rusqlite::ErrorCode::ConstraintViolation, + .. + }, + _ + ) + ) +} + +const fn storage_error() -> SafeError { + SafeError::new( + SafeErrorCode::StorageUnavailable, + SafeMessage::new("The application database is unavailable."), + ) +} + +const fn corrupt_storage_error() -> SafeError { + SafeError::new( + SafeErrorCode::StorageCorrupt, + SafeMessage::new("The application database could not be read."), + ) +} + +const fn account_exists() -> SafeError { + SafeError::new( + SafeErrorCode::AccountAlreadyExists, + SafeMessage::new("The Nostr account is already saved."), + ) +} + +const fn account_not_found() -> SafeError { + SafeError::new( + SafeErrorCode::AccountNotFound, + SafeMessage::new("The account was not found."), + ) +} + +#[cfg(test)] +mod tests { + use std::fs; + + use radroots_studio_application::{AccountRepository, AppStateRepository}; + use radroots_studio_domain::{ + AccountCreatedAt, AccountIdentity, AccountLabel, AccountSummary, BindingAvailability, + LocalSignerBinding, PublicKey, SafeErrorCode, UnixTimestamp, + }; + use tempfile::tempdir; + + use crate::Database; + + fn account(key_byte: u8, created_at: i64) -> AccountSummary { + let public_key = PublicKey::from_bytes([key_byte; 32]); + AccountSummary::new( + AccountIdentity::derive(public_key).expect("identity"), + LocalSignerBinding::new(public_key, BindingAvailability::Available), + Some(AccountLabel::parse("Farm account").expect("valid label")), + AccountCreatedAt::new( + UnixTimestamp::from_seconds(created_at).expect("valid timestamp"), + ), + None, + ) + .expect("account") + } + + #[test] + fn accounts_insert_list_update_and_reject_duplicates() { + let database = Database::in_memory().expect("database"); + let first = account(1, 20); + let second = account(2, 10); + + database.insert_account(&first).expect("insert first"); + database.insert_account(&second).expect("insert second"); + let duplicate = database.insert_account(&first).expect_err("duplicate"); + + assert_eq!(duplicate.code(), SafeErrorCode::AccountAlreadyExists); + assert_eq!( + database.list_accounts().expect("list"), + vec![second, first.clone()] + ); + assert_eq!( + database.find_account(first.public_key()).expect("find"), + Some(first) + ); + } + + #[test] + fn accounts_and_selection_survive_restart_without_secret_text() { + let directory = tempdir().expect("temporary directory"); + let path = directory.path().join("studio.sqlite3"); + let account = account(3, 30); + + { + let database = Database::open(&path).expect("database"); + database.insert_account(&account).expect("insert"); + database + .save_selected_account(Some(account.public_key())) + .expect("select"); + } + let reopened = Database::open(&path).expect("reopen"); + + assert_eq!( + reopened.list_accounts().expect("list"), + vec![account.clone()] + ); + assert_eq!( + reopened.load_selected_account().expect("selection"), + Some(account.public_key()) + ); + let bytes = fs::read(path).expect("database bytes"); + assert!(!String::from_utf8_lossy(&bytes).contains("nsec1known-test-secret")); + } + + #[test] + fn selection_requires_an_existing_account_and_clears_on_delete() { + let database = Database::in_memory().expect("database"); + let account = account(4, 40); + + let missing = database + .save_selected_account(Some(account.public_key())) + .expect_err("missing account"); + assert_eq!(missing.code(), SafeErrorCode::AccountNotFound); + + database.insert_account(&account).expect("insert"); + database + .save_selected_account(Some(account.public_key())) + .expect("select"); + database + .remove_account(account.public_key()) + .expect("remove"); + + assert_eq!(database.load_selected_account().expect("selection"), None); + } +} diff --git a/crates/studio_storage/src/application_adapter.rs b/crates/studio_storage/src/application_adapter.rs @@ -0,0 +1,718 @@ +use std::path::Path; + +use radroots_studio_application::{ + AppCore, AppSnapshot, Clock, DurableRequestId, GenerateAccountReceipt, ImportAccountReceipt, + RelayConfiguration, RemovalConfirmationToken, SecretStore, StagedGeneratedKey, +}; +use radroots_studio_domain::{PublicKey, SafeError, SecretKeyInput}; + +use crate::Database; + +pub struct PersistentAppCore { + core: AppCore, + database: Database, +} + +impl PersistentAppCore { + /// 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> { + Ok(Self { + core: AppCore::in_memory(relay_configuration), + database: Database::open(path)?, + }) + } + + /// 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> { + Ok(Self { + core: AppCore::in_memory(relay_configuration), + database: Database::in_memory()?, + }) + } + + /// 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([4; 32]); + 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/crates/studio_storage/src/db.rs b/crates/studio_storage/src/db.rs @@ -0,0 +1,590 @@ +use std::fs::{self, File, OpenOptions}; +use std::ops::{Deref, DerefMut}; +use std::path::{Path, PathBuf}; +use std::sync::{Mutex, MutexGuard}; +use std::time::Duration; + +use fs2::FileExt; +use radroots_studio_domain::{AccountIdentity, PublicKey, SafeError, SafeErrorCode, SafeMessage}; +use refinery::embed_migrations; +use rusqlite::{Connection, OpenFlags}; + +pub const CURRENT_SCHEMA_VERSION: u32 = 9; + +mod migrations { + use super::embed_migrations; + + embed_migrations!("migrations"); +} + +pub struct Database { + connection: Mutex<Connection>, + path: Option<PathBuf>, + _ownership: Option<WritableOwnership>, +} + +pub(crate) struct DatabaseConnection<'a> { + connection: MutexGuard<'a, Connection>, + path: Option<&'a Path>, +} + +struct WritableOwnership { + _file: File, +} + +impl Database { + /// Opens, configures, and migrates a file-backed `SQLite` database. + /// + /// # Errors + /// + /// Returns a safe storage error when the file, connection configuration, + /// permission update, or migration cannot complete. + pub fn open(path: &Path) -> Result<Self, SafeError> { + let parent = path.parent().ok_or_else(storage_error)?; + create_secure_directory(parent)?; + let ownership = WritableOwnership::acquire(path)?; + let flags = OpenFlags::SQLITE_OPEN_READ_WRITE + | OpenFlags::SQLITE_OPEN_CREATE + | OpenFlags::SQLITE_OPEN_NO_MUTEX; + let mut connection = + Connection::open_with_flags(path, flags).map_err(|_| storage_error())?; + configure(&connection).map_err(|_| corrupt_storage_error())?; + validate_legacy_account_identities(&connection)?; + migrations::migrations::runner() + .run(&mut connection) + .map_err(|_| corrupt_storage_error())?; + restrict_file_permissions(path)?; + restrict_sqlite_sidecars(path)?; + Ok(Self { + connection: Mutex::new(connection), + path: Some(path.to_path_buf()), + _ownership: Some(ownership), + }) + } + + /// Opens and migrates an isolated in-memory `SQLite` database. + /// + /// # Errors + /// + /// Returns a safe storage error when configuration or migration fails. + pub fn in_memory() -> Result<Self, SafeError> { + let mut connection = Connection::open_in_memory().map_err(|_| storage_error())?; + configure(&connection)?; + migrations::migrations::runner() + .run(&mut connection) + .map_err(|_| corrupt_storage_error())?; + Ok(Self { + connection: Mutex::new(connection), + path: None, + _ownership: None, + }) + } + + /// Returns the highest successfully applied migration version. + /// + /// # Errors + /// + /// Returns a safe storage error when migration history cannot be read. + pub fn schema_version(&self) -> Result<u32, SafeError> { + self.connection() + .query_row( + "SELECT COALESCE(MAX(version), 0) FROM refinery_schema_history", + [], + |row| row.get(0), + ) + .map_err(|_| corrupt_storage_error()) + } + + pub(crate) fn connection(&self) -> DatabaseConnection<'_> { + DatabaseConnection { + connection: self + .connection + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner), + path: self.path.as_deref(), + } + } +} + +impl Deref for DatabaseConnection<'_> { + type Target = Connection; + + fn deref(&self) -> &Self::Target { + &self.connection + } +} + +impl DerefMut for DatabaseConnection<'_> { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.connection + } +} + +impl Drop for DatabaseConnection<'_> { + fn drop(&mut self) { + if let Some(path) = self.path { + let _ = restrict_sqlite_sidecars(path); + } + } +} + +impl WritableOwnership { + fn acquire(database_path: &Path) -> Result<Self, SafeError> { + let lock_path = database_path.with_extension("sqlite3.lock"); + let file = OpenOptions::new() + .read(true) + .write(true) + .create(true) + .truncate(false) + .open(&lock_path) + .map_err(|_| storage_error())?; + restrict_file_permissions(&lock_path)?; + file.try_lock_exclusive().map_err(|_| ownership_error())?; + Ok(Self { _file: file }) + } +} + +fn create_secure_directory(path: &Path) -> Result<(), SafeError> { + fs::create_dir_all(path).map_err(|_| storage_error())?; + restrict_directory_permissions(path) +} + +fn configure(connection: &Connection) -> Result<(), SafeError> { + connection + .pragma_update(None, "foreign_keys", "ON") + .and_then(|()| connection.pragma_update(None, "trusted_schema", "OFF")) + .and_then(|()| connection.pragma_update(None, "journal_mode", "WAL")) + .and_then(|()| connection.pragma_update(None, "synchronous", "FULL")) + .and_then(|()| connection.pragma_update(None, "secure_delete", "ON")) + .and_then(|()| connection.pragma_update(None, "wal_autocheckpoint", 1_000)) + .and_then(|()| connection.busy_timeout(Duration::from_secs(5))) + .map_err(|_| storage_error()) +} + +fn validate_legacy_account_identities(connection: &Connection) -> Result<(), SafeError> { + let has_accounts = connection + .query_row( + "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'accounts')", + [], + |row| row.get::<_, bool>(0), + ) + .map_err(|_| corrupt_storage_error())?; + if !has_accounts { + return Ok(()); + } + + let mut statement = connection + .prepare("SELECT pubkey, npub, signer_kind, key_availability FROM accounts") + .map_err(|_| corrupt_storage_error())?; + let rows = statement + .query_map([], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, String>(2)?, + row.get::<_, String>(3)?, + )) + }) + .map_err(|_| corrupt_storage_error())?; + for row in rows { + let (public_key, npub, signer_kind, availability) = + row.map_err(|_| corrupt_storage_error())?; + let public_key = PublicKey::from_hex(&public_key).map_err(|_| corrupt_storage_error())?; + AccountIdentity::verify(public_key, npub).map_err(|_| corrupt_storage_error())?; + if signer_kind != "local_secret" + || !matches!( + availability.as_str(), + "available" | "credential_missing" | "store_unavailable" + ) + { + return Err(corrupt_storage_error()); + } + } + Ok(()) +} + +fn restrict_sqlite_sidecars(path: &Path) -> Result<(), SafeError> { + for suffix in ["-wal", "-shm"] { + let sidecar = PathBuf::from(format!("{}{suffix}", path.display())); + if sidecar.exists() { + restrict_file_permissions(&sidecar)?; + } + } + Ok(()) +} + +#[cfg(unix)] +fn restrict_file_permissions(path: &Path) -> Result<(), SafeError> { + use std::os::unix::fs::PermissionsExt; + + fs::set_permissions(path, fs::Permissions::from_mode(0o600)).map_err(|_| storage_error()) +} + +#[cfg(unix)] +fn restrict_directory_permissions(path: &Path) -> Result<(), SafeError> { + use std::os::unix::fs::PermissionsExt; + + fs::set_permissions(path, fs::Permissions::from_mode(0o700)).map_err(|_| storage_error()) +} + +#[cfg(not(unix))] +fn restrict_file_permissions(_path: &Path) -> Result<(), SafeError> { + Ok(()) +} + +#[cfg(not(unix))] +fn restrict_directory_permissions(_path: &Path) -> Result<(), SafeError> { + Ok(()) +} + +const fn storage_error() -> SafeError { + SafeError::new( + SafeErrorCode::StorageUnavailable, + SafeMessage::new("The application database is unavailable."), + ) +} + +const fn corrupt_storage_error() -> SafeError { + SafeError::new( + SafeErrorCode::StorageCorrupt, + SafeMessage::new("The application database could not be read."), + ) +} + +const fn ownership_error() -> SafeError { + SafeError::new( + SafeErrorCode::StorageUnavailable, + SafeMessage::new("The application database is already in use."), + ) +} + +#[cfg(test)] +mod tests { + use std::fs; + use std::path::Path; + use std::process::Command; + + use tempfile::tempdir; + + use radroots_studio_application::{AccountRepository, AppStateRepository}; + use radroots_studio_domain::PublicKey; + use refinery::Target; + use rusqlite::Connection; + + use super::{CURRENT_SCHEMA_VERSION, Database, configure, migrations}; + + #[test] + fn migration_opens_fresh_memory_database_once() { + let database = Database::in_memory().expect("open memory database"); + + assert_eq!( + database.schema_version().expect("schema version"), + CURRENT_SCHEMA_VERSION + ); + assert_eq!( + database.schema_version().expect("repeat schema version"), + CURRENT_SCHEMA_VERSION + ); + } + + #[test] + fn sqlite_connection_enforces_trust_durability_and_busy_policy() { + let database = Database::in_memory().expect("open memory database"); + let connection = database.connection(); + + assert_eq!( + connection + .pragma_query_value(None, "foreign_keys", |row| row.get::<_, u8>(0)) + .expect("foreign keys"), + 1 + ); + assert_eq!( + connection + .pragma_query_value(None, "trusted_schema", |row| row.get::<_, u8>(0)) + .expect("trusted schema"), + 0 + ); + assert_eq!( + connection + .pragma_query_value(None, "synchronous", |row| row.get::<_, u8>(0)) + .expect("synchronous"), + 2 + ); + assert_eq!( + connection + .pragma_query_value(None, "busy_timeout", |row| row.get::<_, i64>(0)) + .expect("busy timeout"), + 5_000 + ); + } + + #[test] + fn normalized_schema_is_strict_and_enforces_same_account_bindings() { + let database = Database::in_memory().expect("open memory database"); + let connection = database.connection(); + let strict_tables: i64 = connection + .query_row( + "SELECT COUNT(*) FROM pragma_table_list WHERE name IN ('account_identities', 'local_signer_bindings', 'runtime_state', 'profile_cache_v6', 'durable_operations') AND strict = 1", + [], + |row| row.get(0), + ) + .expect("strict table inventory"); + assert_eq!(strict_tables, 5); + + connection + .execute( + "INSERT INTO account_identities (public_key, npub, created_at) VALUES (?1, ?2, 1)", + [ + "07".repeat(32), + "npub1qurswpc8qurswpc8qurswpc8qurswpc8qurswpc8qurswpc8qursnvjvl7".to_owned(), + ], + ) + .expect("identity"); + assert!( + connection + .execute( + "INSERT INTO local_signer_bindings (account_public_key, binding_public_key, binding_kind, availability) VALUES (?1, ?2, 'local_secret', 'available')", + ["07".repeat(32), "08".repeat(32)], + ) + .is_err() + ); + } + + #[test] + fn v5_data_migrates_append_only_with_identity_profile_and_selection() { + let directory = tempdir().expect("temporary directory"); + let path = directory.path().join("studio.sqlite3"); + let public_key = "07".repeat(32); + { + let mut connection = Connection::open(&path).expect("legacy database"); + configure(&connection).expect("configuration"); + migrations::migrations::runner() + .set_target(Target::Version(5)) + .run(&mut connection) + .expect("V5 schema"); + connection + .execute( + "INSERT INTO accounts (pubkey, npub, signer_kind, key_availability, created_at) VALUES (?1, ?2, 'local_secret', 'available', 10)", + [&public_key, "npub1qurswpc8qurswpc8qurswpc8qurswpc8qurswpc8qurswpc8qursnvjvl7"], + ) + .expect("legacy account"); + connection + .execute( + "UPDATE app_state SET selected_pubkey = ?1 WHERE singleton = 1", + [&public_key], + ) + .expect("legacy selection"); + connection + .execute( + "INSERT INTO profile_cache (subject_pubkey, event_id, event_created_at, name, refreshed_at, refresh_status) VALUES (?1, ?2, 11, 'Farm', 12, 'success')", + [&public_key, &"01".repeat(32)], + ) + .expect("legacy profile"); + } + + let database = Database::open(&path).expect("migrated database"); + assert_eq!(database.schema_version().expect("version"), 9); + assert_eq!(database.list_accounts().expect("accounts").len(), 1); + assert_eq!( + database.load_selected_account().expect("selection"), + Some(PublicKey::from_bytes([7; 32])) + ); + let connection = database.connection(); + let migrated: (i64, i64, i64) = connection + .query_row( + "SELECT (SELECT COUNT(*) FROM account_identities), (SELECT COUNT(*) FROM local_signer_bindings), (SELECT COUNT(*) FROM profile_cache_v6)", + [], + |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)), + ) + .expect("migrated inventory"); + assert_eq!(migrated, (1, 1, 1)); + } + + #[test] + fn corrupt_v5_identity_fails_before_migration_without_recreation() { + let directory = tempdir().expect("temporary directory"); + let path = directory.path().join("studio.sqlite3"); + { + let mut connection = Connection::open(&path).expect("legacy database"); + configure(&connection).expect("configuration"); + migrations::migrations::runner() + .set_target(Target::Version(5)) + .run(&mut connection) + .expect("V5 schema"); + connection + .execute( + "INSERT INTO accounts (pubkey, npub, signer_kind, key_availability, created_at) VALUES (?1, ?2, 'local_secret', 'available', 10)", + ["07".repeat(32), "npub10elfcs4fr0l0r8af98jlmgdh9c8tcxjvz9qkw038js35mp4dma8qzvjptg".to_owned()], + ) + .expect("mismatched legacy account"); + } + + assert!(Database::open(&path).is_err()); + let connection = Connection::open(&path).expect("inspect legacy database"); + let version: u32 = connection + .query_row( + "SELECT MAX(version) FROM refinery_schema_history", + [], + |row| row.get(0), + ) + .expect("legacy version"); + let accounts: i64 = connection + .query_row("SELECT COUNT(*) FROM accounts", [], |row| row.get(0)) + .expect("legacy accounts"); + assert_eq!((version, accounts), (5, 1)); + } + + #[test] + fn failed_v5_copy_rolls_back_the_active_migration() { + let directory = tempdir().expect("temporary directory"); + let path = directory.path().join("studio.sqlite3"); + let public_key = "07".repeat(32); + { + let mut connection = Connection::open(&path).expect("legacy database"); + configure(&connection).expect("configuration"); + migrations::migrations::runner() + .set_target(Target::Version(5)) + .run(&mut connection) + .expect("V5 schema"); + connection + .execute( + "INSERT INTO accounts (pubkey, npub, signer_kind, key_availability, created_at) VALUES (?1, ?2, 'local_secret', 'available', 10)", + [&public_key, "npub1qurswpc8qurswpc8qurswpc8qurswpc8qurswpc8qurswpc8qursnvjvl7"], + ) + .expect("legacy account"); + connection + .execute( + "INSERT INTO profile_cache (subject_pubkey, event_id, event_created_at, refreshed_at, refresh_status) VALUES (?1, 'invalid', 11, 12, 'success')", + [&public_key], + ) + .expect("legacy corrupt profile"); + } + + assert!(Database::open(&path).is_err()); + let connection = Connection::open(&path).expect("inspect interrupted migration"); + let version: u32 = connection + .query_row( + "SELECT MAX(version) FROM refinery_schema_history", + [], + |row| row.get(0), + ) + .expect("migration version"); + let copied: i64 = connection + .query_row("SELECT COUNT(*) FROM account_identities", [], |row| { + row.get(0) + }) + .expect("normalized accounts"); + assert_eq!((version, copied), (6, 0)); + } + + #[test] + fn foreign_keys_reject_orphan_normalized_records() { + let database = Database::in_memory().expect("database"); + let connection = database.connection(); + assert!( + connection + .execute( + "INSERT INTO local_signer_bindings (account_public_key, binding_public_key, binding_kind, availability) VALUES (?1, ?1, 'local_secret', 'available')", + ["09".repeat(32)], + ) + .is_err() + ); + } + + #[test] + fn second_process_cannot_acquire_writable_ownership() { + let directory = tempdir().expect("temporary directory"); + let path = directory.path().join("studio.sqlite3"); + let _owner = Database::open(&path).expect("parent owner"); + let status = Command::new(std::env::current_exe().expect("test executable")) + .arg("--exact") + .arg("db::tests::writable_ownership_child_probe") + .arg("--nocapture") + .env("RADROOTS_STUDIO_LOCK_PROBE_PATH", &path) + .status() + .expect("child process"); + assert!(status.success()); + } + + #[test] + fn writable_ownership_child_probe() { + let Ok(path) = std::env::var("RADROOTS_STUDIO_LOCK_PROBE_PATH") else { + return; + }; + assert!(Database::open(Path::new(&path)).is_err()); + } + + #[test] + fn migration_persists_schema_version_across_file_reopen() { + let directory = tempdir().expect("temporary directory"); + let path = directory.path().join("studio.sqlite3"); + + { + let database = Database::open(&path).expect("open file database"); + assert_eq!( + database.schema_version().expect("schema version"), + CURRENT_SCHEMA_VERSION + ); + } + let reopened = Database::open(&path).expect("reopen file database"); + assert_eq!( + reopened.schema_version().expect("schema version"), + CURRENT_SCHEMA_VERSION + ); + assert!(fs::metadata(path).expect("database metadata").len() > 0); + } + + #[test] + fn writable_ownership_rejects_a_second_runtime_and_releases_on_drop() { + let directory = tempdir().expect("temporary directory"); + let path = directory.path().join("studio.sqlite3"); + let first = Database::open(&path).expect("first owner"); + let Err(error) = Database::open(&path) else { + panic!("second owner must fail"); + }; + assert_eq!( + error.message().as_str(), + "The application database is already in use." + ); + drop(first); + Database::open(&path).expect("ownership released"); + } + + #[cfg(unix)] + #[test] + fn migration_attempts_owner_only_database_permissions() { + use std::os::unix::fs::PermissionsExt; + + let directory = tempdir().expect("temporary directory"); + let path = directory.path().join("studio.sqlite3"); + let database = Database::open(&path).expect("open file database"); + let mode = fs::metadata(&path) + .expect("database metadata") + .permissions() + .mode() + & 0o777; + + assert_eq!(mode, 0o600); + let directory_mode = fs::metadata(directory.path()) + .expect("directory metadata") + .permissions() + .mode() + & 0o777; + assert_eq!(directory_mode, 0o700); + + let connection = database.connection(); + connection + .execute_batch("CREATE TABLE sidecar_probe (value INTEGER) STRICT; INSERT INTO sidecar_probe VALUES (1);") + .expect("write through WAL"); + drop(connection); + for suffix in ["-wal", "-shm"] { + let sidecar = std::path::PathBuf::from(format!("{}{suffix}", path.display())); + let sidecar_mode = fs::metadata(sidecar) + .expect("sidecar metadata") + .permissions() + .mode() + & 0o777; + assert_eq!(sidecar_mode, 0o600); + } + } +} diff --git a/crates/studio_storage/src/journal.rs b/crates/studio_storage/src/journal.rs @@ -0,0 +1,661 @@ +use radroots_studio_application::{ + AccountOperationKind, AccountOperationPhase, DurableAccountOperation, DurableOperationKind, + DurableOperationPhase, DurableOperationReceipt, DurableOperationRepository, + DurableOperationStart, DurableRequestId, DurableTerminalOutcome, OperationDiagnostic, + OperationId, OperationJournal, OperationPriorState, PendingAccountOperation, +}; +use radroots_studio_domain::{ + BindingAvailability, PublicKey, SafeError, SafeErrorCode, SafeMessage, UnixTimestamp, +}; +use rusqlite::{OptionalExtension, Row, params}; + +use crate::Database; + +impl DurableOperationRepository for Database { + fn begin_durable_operation( + &self, + request_id: &DurableRequestId, + kind: DurableOperationKind, + account: PublicKey, + expected_revision: Option<u64>, + prior: OperationPriorState, + updated_at: UnixTimestamp, + ) -> Result<DurableOperationStart, SafeError> { + let encoded_expected_revision = expected_revision + .map(i64::try_from) + .transpose() + .map_err(|_| operation_conflict())?; + let mut connection = self.connection(); + let transaction = connection.transaction().map_err(|_| storage_error())?; + let inserted = transaction + .execute( + "INSERT OR IGNORE INTO durable_operations (request_id, operation_kind, \ + account_public_key, binding_public_key, expected_revision, phase, \ + prior_selected_public_key, updated_at, prior_binding_availability) \ + VALUES (?1, ?2, ?3, ?3, ?4, 'intent_recorded', ?5, ?6, ?7)", + params![ + request_id.as_str(), + encode_durable_kind(kind), + account.to_hex(), + encoded_expected_revision, + prior.selected_account().map(PublicKey::to_hex), + updated_at.as_seconds(), + prior + .binding_availability() + .map(encode_binding_availability), + ], + ) + .map_err(|_| storage_error())?; + let operation = + query_durable_operation(&transaction, request_id)?.ok_or_else(corrupt_storage_error)?; + if operation.kind() != kind + || operation.account() != account + || operation.expected_revision() != expected_revision + || operation.prior() != prior + { + return Err(operation_conflict()); + } + transaction.commit().map_err(|_| storage_error())?; + Ok(if inserted == 1 { + DurableOperationStart::Started(operation) + } else { + DurableOperationStart::Existing(operation) + }) + } + + fn load_durable_operation( + &self, + request_id: &DurableRequestId, + ) -> Result<Option<DurableAccountOperation>, SafeError> { + query_durable_operation(&self.connection(), request_id) + } + + fn advance_durable_operation( + &self, + request_id: &DurableRequestId, + expected_phase: DurableOperationPhase, + next_phase: DurableOperationPhase, + updated_at: UnixTimestamp, + diagnostic: Option<OperationDiagnostic>, + ) -> Result<DurableAccountOperation, SafeError> { + let mut connection = self.connection(); + let transaction = connection.transaction().map_err(|_| storage_error())?; + let rows = transaction + .execute( + "UPDATE durable_operations SET phase = ?3, updated_at = ?4, diagnostic_code = ?5 \ + WHERE request_id = ?1 AND phase = ?2 AND terminal_outcome IS NULL", + params![ + request_id.as_str(), + encode_durable_phase(expected_phase), + encode_durable_phase(next_phase), + updated_at.as_seconds(), + diagnostic.map(encode_diagnostic), + ], + ) + .map_err(|_| storage_error())?; + if rows != 1 { + return Err(operation_conflict()); + } + let operation = + query_durable_operation(&transaction, request_id)?.ok_or_else(corrupt_storage_error)?; + transaction.commit().map_err(|_| storage_error())?; + Ok(operation) + } + + fn finalize_durable_operation( + &self, + request_id: &DurableRequestId, + expected_phase: DurableOperationPhase, + outcome: DurableTerminalOutcome, + resulting_revision: Option<u64>, + updated_at: UnixTimestamp, + ) -> Result<DurableOperationReceipt, SafeError> { + if let Some(existing) = self.load_durable_operation(request_id)? + && let Some(receipt) = existing.terminal() + { + return if receipt.outcome() == outcome + && receipt.resulting_revision() == resulting_revision + { + Ok(receipt.clone()) + } else { + Err(operation_conflict()) + }; + } + let resulting_revision = resulting_revision + .map(i64::try_from) + .transpose() + .map_err(|_| operation_conflict())?; + let rows = self + .connection() + .execute( + "UPDATE durable_operations SET phase = 'finalized', terminal_outcome = ?3, \ + resulting_revision = ?4, updated_at = ?5 \ + WHERE request_id = ?1 AND phase = ?2 AND terminal_outcome IS NULL", + params![ + request_id.as_str(), + encode_durable_phase(expected_phase), + encode_terminal_outcome(outcome), + resulting_revision, + updated_at.as_seconds(), + ], + ) + .map_err(|_| storage_error())?; + if rows != 1 { + return Err(operation_conflict()); + } + self.load_durable_operation(request_id)? + .and_then(|operation| operation.terminal().cloned()) + .ok_or_else(corrupt_storage_error) + } + + fn list_unfinished_durable_operations( + &self, + ) -> Result<Vec<DurableAccountOperation>, SafeError> { + let connection = self.connection(); + let mut statement = connection + .prepare(&format!( + "{DURABLE_OPERATION_SELECT} WHERE terminal_outcome IS NULL ORDER BY request_id ASC" + )) + .map_err(|_| storage_error())?; + let rows = statement + .query_map([], decode_durable_operation) + .map_err(|_| storage_error())?; + rows.map(|row| row.map_err(|_| corrupt_storage_error())) + .collect() + } +} + +const DURABLE_OPERATION_SELECT: &str = "SELECT request_id, operation_kind, account_public_key, \ + expected_revision, phase, prior_selected_public_key, updated_at, diagnostic_code, \ + terminal_outcome, prior_binding_availability, resulting_revision FROM durable_operations"; + +fn query_durable_operation( + connection: &rusqlite::Connection, + request_id: &DurableRequestId, +) -> Result<Option<DurableAccountOperation>, SafeError> { + connection + .query_row( + &format!("{DURABLE_OPERATION_SELECT} WHERE request_id = ?1"), + [request_id.as_str()], + decode_durable_operation, + ) + .optional() + .map_err(|_| corrupt_storage_error()) +} + +fn decode_durable_operation(row: &Row<'_>) -> rusqlite::Result<DurableAccountOperation> { + let request_id = + DurableRequestId::parse(row.get::<_, String>(0)?).map_err(|_| invalid_column(0))?; + let kind = decode_durable_kind(row.get::<_, String>(1)?.as_str())?; + let account = + PublicKey::from_hex(row.get::<_, String>(2)?.as_str()).map_err(|_| invalid_column(2))?; + let expected_revision = row + .get::<_, Option<i64>>(3)? + .map(|value| u64::try_from(value).map_err(|_| invalid_column(3))) + .transpose()?; + let phase = decode_durable_phase(row.get::<_, String>(4)?.as_str())?; + let prior_selected = row + .get::<_, Option<String>>(5)? + .map(|value| PublicKey::from_hex(&value).map_err(|_| invalid_column(5))) + .transpose()?; + let updated_at = UnixTimestamp::from_seconds(row.get(6)?).ok_or_else(|| invalid_column(6))?; + let diagnostic = row + .get::<_, Option<String>>(7)? + .map(|value| decode_diagnostic(&value)) + .transpose()?; + let outcome = row + .get::<_, Option<String>>(8)? + .map(|value| decode_terminal_outcome(&value)) + .transpose()?; + let prior_availability = row + .get::<_, Option<String>>(9)? + .map(|value| decode_binding_availability(&value)) + .transpose()?; + let resulting_revision = row + .get::<_, Option<i64>>(10)? + .map(|value| u64::try_from(value).map_err(|_| invalid_column(10))) + .transpose()?; + let terminal = outcome.map(|outcome| { + DurableOperationReceipt::new(request_id.clone(), account, outcome, resulting_revision) + }); + Ok(DurableAccountOperation::new( + request_id, + kind, + account, + expected_revision, + phase, + OperationPriorState::new(prior_selected, prior_availability), + updated_at, + diagnostic, + terminal, + )) +} + +impl OperationJournal for Database { + fn begin_operation( + &self, + kind: AccountOperationKind, + subject: PublicKey, + updated_at: UnixTimestamp, + ) -> Result<OperationId, SafeError> { + let connection = self.connection(); + connection + .execute( + "INSERT INTO operation_journal (operation_kind, subject_pubkey, phase, \ + updated_at) VALUES (?1, ?2, 'intent_recorded', ?3)", + params![encode_kind(kind), subject.to_hex(), updated_at.as_seconds()], + ) + .map_err(|_| storage_error())?; + let id = + u64::try_from(connection.last_insert_rowid()).map_err(|_| corrupt_storage_error())?; + Ok(OperationId::from_raw(id)) + } + + fn update_operation( + &self, + id: OperationId, + phase: AccountOperationPhase, + updated_at: UnixTimestamp, + diagnostic: Option<OperationDiagnostic>, + ) -> Result<(), SafeError> { + let encoded_id = i64::try_from(id.as_raw()).map_err(|_| corrupt_storage_error())?; + match self.connection().execute( + "UPDATE operation_journal SET phase = ?2, updated_at = ?3, diagnostic_code = ?4 \ + WHERE operation_id = ?1", + params![ + encoded_id, + encode_phase(phase), + updated_at.as_seconds(), + diagnostic.map(encode_diagnostic) + ], + ) { + Ok(1) => Ok(()), + Ok(0) => Err(operation_not_found()), + Ok(_) | Err(_) => Err(storage_error()), + } + } + + fn list_pending_operations(&self) -> Result<Vec<PendingAccountOperation>, SafeError> { + let connection = self.connection(); + let mut statement = connection + .prepare( + "SELECT operation_id, operation_kind, subject_pubkey, phase, updated_at, \ + diagnostic_code FROM operation_journal ORDER BY operation_id ASC", + ) + .map_err(|_| storage_error())?; + let rows = statement + .query_map([], decode_operation) + .map_err(|_| storage_error())?; + rows.map(|row| row.map_err(|_| corrupt_storage_error())) + .collect() + } + + fn finalize_operation(&self, id: OperationId) -> Result<(), SafeError> { + let encoded_id = i64::try_from(id.as_raw()).map_err(|_| corrupt_storage_error())?; + self.connection() + .execute( + "DELETE FROM operation_journal WHERE operation_id = ?1", + [encoded_id], + ) + .map(|_| ()) + .map_err(|_| storage_error()) + } +} + +fn decode_operation(row: &Row<'_>) -> rusqlite::Result<PendingAccountOperation> { + let id = u64::try_from(row.get::<_, i64>(0)?).map_err(|_| invalid_column(0))?; + let kind = decode_kind(row.get::<_, String>(1)?.as_str())?; + let subject = + PublicKey::from_hex(row.get::<_, String>(2)?.as_str()).map_err(|_| invalid_column(2))?; + let phase = decode_phase(row.get::<_, String>(3)?.as_str())?; + let updated_at = UnixTimestamp::from_seconds(row.get(4)?).ok_or_else(|| invalid_column(4))?; + let diagnostic = row + .get::<_, Option<String>>(5)? + .map(|value| decode_diagnostic(&value)) + .transpose()?; + Ok(PendingAccountOperation::new( + OperationId::from_raw(id), + kind, + subject, + phase, + updated_at, + diagnostic, + )) +} + +const fn encode_durable_kind(value: DurableOperationKind) -> &'static str { + match value { + DurableOperationKind::Create => "create", + DurableOperationKind::Import => "import", + DurableOperationKind::Repair => "repair", + DurableOperationKind::Remove => "remove", + } +} + +fn decode_durable_kind(value: &str) -> rusqlite::Result<DurableOperationKind> { + match value { + "create" => Ok(DurableOperationKind::Create), + "import" => Ok(DurableOperationKind::Import), + "repair" => Ok(DurableOperationKind::Repair), + "remove" => Ok(DurableOperationKind::Remove), + _ => Err(invalid_column(1)), + } +} + +const fn encode_durable_phase(value: DurableOperationPhase) -> &'static str { + match value { + DurableOperationPhase::IntentRecorded => "intent_recorded", + DurableOperationPhase::CredentialWritten => "credential_written", + DurableOperationPhase::MetadataCommitted => "metadata_committed", + DurableOperationPhase::SelectionCommitted => "selection_committed", + DurableOperationPhase::CompensationPending => "compensation_pending", + DurableOperationPhase::CredentialDeleted => "credential_deleted", + DurableOperationPhase::MetadataDeleted => "metadata_deleted", + DurableOperationPhase::Finalized => "finalized", + } +} + +fn decode_durable_phase(value: &str) -> rusqlite::Result<DurableOperationPhase> { + match value { + "intent_recorded" => Ok(DurableOperationPhase::IntentRecorded), + "credential_written" => Ok(DurableOperationPhase::CredentialWritten), + "metadata_committed" => Ok(DurableOperationPhase::MetadataCommitted), + "selection_committed" => Ok(DurableOperationPhase::SelectionCommitted), + "compensation_pending" => Ok(DurableOperationPhase::CompensationPending), + "credential_deleted" => Ok(DurableOperationPhase::CredentialDeleted), + "metadata_deleted" => Ok(DurableOperationPhase::MetadataDeleted), + "finalized" => Ok(DurableOperationPhase::Finalized), + _ => Err(invalid_column(4)), + } +} + +const fn encode_terminal_outcome(value: DurableTerminalOutcome) -> &'static str { + match value { + DurableTerminalOutcome::Completed => "completed", + DurableTerminalOutcome::Cancelled => "cancelled", + DurableTerminalOutcome::Failed => "failed", + } +} + +fn decode_terminal_outcome(value: &str) -> rusqlite::Result<DurableTerminalOutcome> { + match value { + "completed" => Ok(DurableTerminalOutcome::Completed), + "cancelled" => Ok(DurableTerminalOutcome::Cancelled), + "failed" => Ok(DurableTerminalOutcome::Failed), + _ => Err(invalid_column(8)), + } +} + +const fn encode_binding_availability(value: BindingAvailability) -> &'static str { + match value { + BindingAvailability::Available => "available", + BindingAvailability::CredentialMissing => "credential_missing", + BindingAvailability::StoreUnavailable => "store_unavailable", + } +} + +fn decode_binding_availability(value: &str) -> rusqlite::Result<BindingAvailability> { + match value { + "available" => Ok(BindingAvailability::Available), + "credential_missing" => Ok(BindingAvailability::CredentialMissing), + "store_unavailable" => Ok(BindingAvailability::StoreUnavailable), + _ => Err(invalid_column(9)), + } +} + +const fn encode_kind(value: AccountOperationKind) -> &'static str { + match value { + AccountOperationKind::Add => "add", + AccountOperationKind::Import => "import", + AccountOperationKind::Remove => "remove", + } +} + +fn decode_kind(value: &str) -> rusqlite::Result<AccountOperationKind> { + match value { + "add" => Ok(AccountOperationKind::Add), + "import" => Ok(AccountOperationKind::Import), + "remove" => Ok(AccountOperationKind::Remove), + _ => Err(invalid_column(1)), + } +} + +const fn encode_phase(value: AccountOperationPhase) -> &'static str { + match value { + AccountOperationPhase::IntentRecorded => "intent_recorded", + AccountOperationPhase::CredentialWritten => "credential_written", + AccountOperationPhase::MetadataCommitted => "metadata_committed", + AccountOperationPhase::CompensationPending => "compensation_pending", + AccountOperationPhase::CredentialDeleted => "credential_deleted", + AccountOperationPhase::MetadataDeleted => "metadata_deleted", + } +} + +fn decode_phase(value: &str) -> rusqlite::Result<AccountOperationPhase> { + match value { + "intent_recorded" => Ok(AccountOperationPhase::IntentRecorded), + "credential_written" => Ok(AccountOperationPhase::CredentialWritten), + "metadata_committed" => Ok(AccountOperationPhase::MetadataCommitted), + "compensation_pending" => Ok(AccountOperationPhase::CompensationPending), + "credential_deleted" => Ok(AccountOperationPhase::CredentialDeleted), + "metadata_deleted" => Ok(AccountOperationPhase::MetadataDeleted), + _ => Err(invalid_column(3)), + } +} + +const fn encode_diagnostic(value: OperationDiagnostic) -> &'static str { + match value { + OperationDiagnostic::StorageUnavailable => "storage_unavailable", + OperationDiagnostic::KeyringUnavailable => "keyring_unavailable", + OperationDiagnostic::CredentialMissing => "credential_missing", + OperationDiagnostic::CompensationFailed => "compensation_failed", + OperationDiagnostic::Conflict => "conflict", + OperationDiagnostic::Expired => "expired", + } +} + +fn decode_diagnostic(value: &str) -> rusqlite::Result<OperationDiagnostic> { + match value { + "storage_unavailable" => Ok(OperationDiagnostic::StorageUnavailable), + "keyring_unavailable" => Ok(OperationDiagnostic::KeyringUnavailable), + "credential_missing" => Ok(OperationDiagnostic::CredentialMissing), + "compensation_failed" => Ok(OperationDiagnostic::CompensationFailed), + "conflict" => Ok(OperationDiagnostic::Conflict), + "expired" => Ok(OperationDiagnostic::Expired), + _ => Err(invalid_column(5)), + } +} + +fn invalid_column(index: usize) -> rusqlite::Error { + rusqlite::Error::InvalidColumnType( + index, + "account operation journal".to_owned(), + rusqlite::types::Type::Text, + ) +} + +const fn storage_error() -> SafeError { + SafeError::new( + SafeErrorCode::StorageUnavailable, + SafeMessage::new("The account recovery journal is unavailable."), + ) +} + +const fn corrupt_storage_error() -> SafeError { + SafeError::new( + SafeErrorCode::StorageCorrupt, + SafeMessage::new("The account recovery journal could not be read."), + ) +} + +const fn operation_not_found() -> SafeError { + SafeError::new( + SafeErrorCode::PendingOperationRecoveryRequired, + SafeMessage::new("The account recovery operation was not found."), + ) +} + +const fn operation_conflict() -> SafeError { + SafeError::new( + SafeErrorCode::InvalidApplicationState, + SafeMessage::new("The durable account operation conflicts with existing state."), + ) +} + +#[cfg(test)] +mod tests { + use radroots_studio_application::{ + AccountOperationKind, AccountOperationPhase, DurableOperationKind, DurableOperationPhase, + DurableOperationRepository, DurableOperationStart, DurableRequestId, + DurableTerminalOutcome, OperationDiagnostic, OperationJournal, OperationPriorState, + }; + use radroots_studio_domain::{BindingAvailability, PublicKey, UnixTimestamp}; + + use crate::Database; + + #[test] + fn journal_creates_advances_loads_and_finalizes_pending_operations() { + let database = Database::in_memory().expect("database"); + let subject = PublicKey::from_bytes([7; 32]); + let id = database + .begin_operation( + AccountOperationKind::Import, + subject, + UnixTimestamp::from_seconds(10).expect("time"), + ) + .expect("begin"); + database + .update_operation( + id, + AccountOperationPhase::CompensationPending, + UnixTimestamp::from_seconds(11).expect("time"), + Some(OperationDiagnostic::KeyringUnavailable), + ) + .expect("advance"); + + let pending = database.list_pending_operations().expect("pending"); + assert_eq!(pending.len(), 1); + assert_eq!(pending[0].subject(), subject); + assert_eq!(pending[0].kind(), AccountOperationKind::Import); + assert_eq!( + pending[0].phase(), + AccountOperationPhase::CompensationPending + ); + assert_eq!( + pending[0].diagnostic(), + Some(OperationDiagnostic::KeyringUnavailable) + ); + + database.finalize_operation(id).expect("finalize"); + assert!( + database + .list_pending_operations() + .expect("pending") + .is_empty() + ); + } + + #[test] + fn journal_schema_and_rows_exclude_secret_payload_columns() { + let database = Database::in_memory().expect("database"); + database + .begin_operation( + AccountOperationKind::Remove, + PublicKey::from_bytes([8; 32]), + UnixTimestamp::from_seconds(12).expect("time"), + ) + .expect("begin"); + let connection = database.connection(); + let schema: String = connection + .query_row( + "SELECT sql FROM sqlite_master WHERE name = 'operation_journal'", + [], + |row| row.get(0), + ) + .expect("schema"); + assert!(!schema.contains("secret")); + assert!(!schema.contains("payload")); + } + + #[test] + fn durable_repository_replays_matching_requests_and_retains_terminal_receipts() { + let database = Database::in_memory().expect("database"); + let request = DurableRequestId::parse("import:test:1").expect("request"); + let account = PublicKey::from_bytes([9; 32]); + let prior = OperationPriorState::new( + Some(PublicKey::from_bytes([8; 32])), + Some(BindingAvailability::CredentialMissing), + ); + let started = database + .begin_durable_operation( + &request, + DurableOperationKind::Repair, + account, + Some(4), + prior, + UnixTimestamp::from_seconds(10).expect("time"), + ) + .expect("begin"); + assert!(matches!(started, DurableOperationStart::Started(_))); + let replay = database + .begin_durable_operation( + &request, + DurableOperationKind::Repair, + account, + Some(4), + prior, + UnixTimestamp::from_seconds(11).expect("time"), + ) + .expect("replay"); + assert!(matches!(replay, DurableOperationStart::Existing(_))); + assert!( + database + .begin_durable_operation( + &request, + DurableOperationKind::Remove, + account, + Some(4), + prior, + UnixTimestamp::from_seconds(11).expect("time"), + ) + .is_err() + ); + database + .advance_durable_operation( + &request, + DurableOperationPhase::IntentRecorded, + DurableOperationPhase::CredentialWritten, + UnixTimestamp::from_seconds(12).expect("time"), + None, + ) + .expect("advance"); + let receipt = database + .finalize_durable_operation( + &request, + DurableOperationPhase::CredentialWritten, + DurableTerminalOutcome::Completed, + Some(5), + UnixTimestamp::from_seconds(13).expect("time"), + ) + .expect("finalize"); + assert_eq!(receipt.resulting_revision(), Some(5)); + assert_eq!( + database + .finalize_durable_operation( + &request, + DurableOperationPhase::CredentialWritten, + DurableTerminalOutcome::Completed, + Some(5), + UnixTimestamp::from_seconds(14).expect("time"), + ) + .expect("receipt replay"), + receipt + ); + assert!( + database + .list_unfinished_durable_operations() + .expect("unfinished") + .is_empty() + ); + } +} diff --git a/crates/studio_storage/src/lib.rs b/crates/studio_storage/src/lib.rs @@ -0,0 +1,15 @@ +#![doc = "Radroots Studio persistence adapters."] + +pub mod account_namespace; +pub mod accounts; +pub mod application_adapter; +pub mod db; +pub mod journal; +pub mod os_keyring; +pub mod profiles; +pub mod runtime_actor; + +pub use application_adapter::PersistentAppCore; +pub use db::{CURRENT_SCHEMA_VERSION, Database}; +pub use os_keyring::{CREDENTIAL_SERVICE, OsKeyringSecretStore}; +pub use runtime_actor::RuntimeActorHandle; diff --git a/crates/studio_storage/src/os_keyring.rs b/crates/studio_storage/src/os_keyring.rs @@ -0,0 +1,131 @@ +use std::sync::{Mutex, MutexGuard}; + +use keyring::{Entry, Error as KeyringError}; +use radroots_studio_application::SecretStore; +use radroots_studio_domain::{PublicKey, SafeError, SafeErrorCode, SafeMessage, SecretKeyInput}; +use zeroize::Zeroizing; + +pub const CREDENTIAL_SERVICE: &str = "org.radroots.studio.nostr"; + +#[derive(Default)] +pub struct OsKeyringSecretStore { + operation_lock: Mutex<()>, +} + +impl OsKeyringSecretStore { + fn entry(public_key: PublicKey) -> Result<Entry, SafeError> { + Entry::new(CREDENTIAL_SERVICE, &public_key.to_hex()).map_err(|_| keyring_unavailable()) + } + + fn operation(&self) -> MutexGuard<'_, ()> { + self.operation_lock + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + } +} + +impl SecretStore for OsKeyringSecretStore { + fn put(&self, public_key: PublicKey, secret: SecretKeyInput) -> Result<(), SafeError> { + let _operation = self.operation(); + let entry = Self::entry(public_key)?; + match entry.get_password() { + Ok(password) => { + drop(Zeroizing::new(password)); + return Err(credential_exists()); + } + Err(KeyringError::NoEntry) => {} + Err(_) => return Err(keyring_unavailable()), + } + secret + .with_exposed_secret(|value| entry.set_password(value)) + .map_err(|_| keyring_unavailable()) + } + + fn load(&self, public_key: PublicKey) -> Result<SecretKeyInput, SafeError> { + let _operation = self.operation(); + let password = Self::entry(public_key)? + .get_password() + .map_err(|error| map_read_error(&error))?; + SecretKeyInput::parse(password) + } + + fn contains(&self, public_key: PublicKey) -> Result<bool, SafeError> { + let _operation = self.operation(); + match Self::entry(public_key)?.get_password() { + Ok(password) => { + drop(Zeroizing::new(password)); + Ok(true) + } + Err(KeyringError::NoEntry) => Ok(false), + Err(_) => Err(keyring_unavailable()), + } + } + + fn delete(&self, public_key: PublicKey) -> Result<(), SafeError> { + let _operation = self.operation(); + Self::entry(public_key)? + .delete_credential() + .map_err(|error| map_read_error(&error)) + } +} + +const fn map_read_error(error: &KeyringError) -> SafeError { + match error { + KeyringError::NoEntry => credential_missing(), + _ => keyring_unavailable(), + } +} + +const fn credential_exists() -> SafeError { + SafeError::new( + SafeErrorCode::AccountAlreadyExists, + SafeMessage::new("The Nostr account credential already exists."), + ) +} + +const fn credential_missing() -> SafeError { + SafeError::new( + SafeErrorCode::CredentialMissing, + SafeMessage::new("The Nostr account credential is missing."), + ) +} + +const fn keyring_unavailable() -> SafeError { + SafeError::new( + SafeErrorCode::KeyringUnavailable, + SafeMessage::new("The operating system credential store is unavailable."), + ) +} + +#[cfg(test)] +mod tests { + use radroots_studio_application::SecretStore; + use radroots_studio_domain::{PublicKey, SecretKeyInput}; + + use super::{CREDENTIAL_SERVICE, OsKeyringSecretStore}; + + #[test] + fn keyring_coordinates_are_stable_and_public() { + let public_key = PublicKey::from_bytes([0xab; 32]); + assert_eq!(CREDENTIAL_SERVICE, "org.radroots.studio.nostr"); + assert_eq!(public_key.to_hex(), "ab".repeat(32)); + } + + #[test] + #[ignore = "mutates the current user's operating-system credential store"] + fn real_keyring_smoke_round_trips_and_deletes() { + let store = OsKeyringSecretStore::default(); + let public_key = PublicKey::from_bytes([0xcd; 32]); + let _ = store.delete(public_key); + store + .put( + public_key, + SecretKeyInput::parse("11".repeat(32)).expect("secret"), + ) + .expect("keyring put"); + assert!(store.contains(public_key).expect("keyring contains")); + let loaded = store.load(public_key).expect("keyring load"); + assert_eq!(loaded.with_exposed_secret(str::len), 64); + store.delete(public_key).expect("keyring delete"); + } +} diff --git a/crates/studio_storage/src/profiles.rs b/crates/studio_storage/src/profiles.rs @@ -0,0 +1,250 @@ +use radroots_studio_application::{CachedProfile, ProfileRefreshStatus, ProfileRepository}; +use radroots_studio_domain::{ + EventId, Kind0ProfileCandidate, ProfileMetadata, PublicKey, SafeError, SafeErrorCode, + SafeMessage, UnixTimestamp, +}; +use rusqlite::{OptionalExtension, Row, params}; + +use crate::Database; + +impl ProfileRepository for Database { + fn load_profile(&self, public_key: PublicKey) -> Result<Option<CachedProfile>, SafeError> { + self.connection() + .query_row( + "SELECT event_id, event_created_at, name, display_name, nip05, about, picture, \ + refreshed_at, refresh_status FROM profile_cache_v6 WHERE subject_public_key = ?1", + [public_key.to_hex()], + |row| decode_profile(row, public_key), + ) + .optional() + .map_err(|_| corrupt_storage_error()) + } + + fn save_profile(&self, profile: &CachedProfile) -> Result<(), SafeError> { + let candidate = profile.candidate(); + let metadata = candidate.metadata(); + self.connection() + .execute( + "INSERT INTO profile_cache_v6 (subject_public_key, event_id, event_created_at, name, \ + display_name, nip05, about, picture, refreshed_at, refresh_status) \ + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10) \ + ON CONFLICT(subject_public_key) DO UPDATE SET \ + event_id = excluded.event_id, event_created_at = excluded.event_created_at, \ + name = excluded.name, display_name = excluded.display_name, nip05 = excluded.nip05, \ + about = excluded.about, picture = excluded.picture, \ + refreshed_at = excluded.refreshed_at, refresh_status = excluded.refresh_status \ + WHERE excluded.event_created_at > profile_cache_v6.event_created_at \ + OR (excluded.event_created_at = profile_cache_v6.event_created_at \ + AND excluded.event_id < profile_cache_v6.event_id)", + params![ + candidate.author().to_hex(), + candidate.event_id().to_hex(), + candidate.created_at().as_seconds(), + metadata.name(), + metadata.display_name(), + metadata.nip05(), + metadata.about(), + metadata.picture(), + profile.refreshed_at().as_seconds(), + encode_refresh_status(profile.refresh_status()), + ], + ) + .map(|_| ()) + .map_err(|_| storage_error()) + } + + fn record_refresh_status( + &self, + public_key: PublicKey, + refreshed_at: UnixTimestamp, + status: ProfileRefreshStatus, + ) -> Result<(), SafeError> { + self.connection() + .execute( + "UPDATE profile_cache_v6 SET refreshed_at = ?2, refresh_status = ?3 \ + WHERE subject_public_key = ?1", + params![ + public_key.to_hex(), + refreshed_at.as_seconds(), + encode_refresh_status(status) + ], + ) + .map(|_| ()) + .map_err(|_| storage_error()) + } + + fn remove_profile(&self, public_key: PublicKey) -> Result<(), SafeError> { + self.connection() + .execute( + "DELETE FROM profile_cache_v6 WHERE subject_public_key = ?1", + [public_key.to_hex()], + ) + .map(|_| ()) + .map_err(|_| storage_error()) + } +} + +fn decode_profile(row: &Row<'_>, author: PublicKey) -> rusqlite::Result<CachedProfile> { + let event_id = + EventId::from_hex(row.get::<_, String>(0)?.as_str()).map_err(|_| invalid_column(0))?; + let created_at = UnixTimestamp::from_seconds(row.get(1)?).ok_or_else(|| invalid_column(1))?; + let metadata = ProfileMetadata::new( + row.get(2)?, + row.get(3)?, + row.get(4)?, + row.get(5)?, + row.get(6)?, + ) + .map_err(|_| invalid_column(2))?; + let refreshed_at = UnixTimestamp::from_seconds(row.get(7)?).ok_or_else(|| invalid_column(7))?; + let refresh_status = decode_refresh_status(row.get::<_, String>(8)?.as_str())?; + Ok(CachedProfile::new( + Kind0ProfileCandidate::new(event_id, author, created_at, metadata), + refreshed_at, + refresh_status, + )) +} + +const fn encode_refresh_status(status: ProfileRefreshStatus) -> &'static str { + match status { + ProfileRefreshStatus::Success => "success", + ProfileRefreshStatus::Offline => "offline", + ProfileRefreshStatus::InvalidData => "invalid_data", + } +} + +fn decode_refresh_status(value: &str) -> rusqlite::Result<ProfileRefreshStatus> { + match value { + "success" => Ok(ProfileRefreshStatus::Success), + "offline" => Ok(ProfileRefreshStatus::Offline), + "invalid_data" => Ok(ProfileRefreshStatus::InvalidData), + _ => Err(invalid_column(8)), + } +} + +fn invalid_column(index: usize) -> rusqlite::Error { + rusqlite::Error::InvalidColumnType( + index, + "cached Nostr profile".to_owned(), + rusqlite::types::Type::Text, + ) +} + +const fn storage_error() -> SafeError { + SafeError::new( + SafeErrorCode::StorageUnavailable, + SafeMessage::new("The profile cache is unavailable."), + ) +} + +const fn corrupt_storage_error() -> SafeError { + SafeError::new( + SafeErrorCode::StorageCorrupt, + SafeMessage::new("The profile cache could not be read."), + ) +} + +#[cfg(test)] +mod tests { + use radroots_studio_application::{ + AccountRepository, CachedProfile, ProfileRefreshStatus, ProfileRepository, + }; + use radroots_studio_domain::{ + AccountCreatedAt, AccountIdentity, AccountSummary, BindingAvailability, EventId, + Kind0ProfileCandidate, LocalSignerBinding, ProfileMetadata, PublicKey, UnixTimestamp, + }; + + use crate::Database; + + fn account(public_key: PublicKey) -> AccountSummary { + 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") + } + + fn profile(public_key: PublicKey, id: u8, created_at: i64, name: &str) -> CachedProfile { + CachedProfile::new( + Kind0ProfileCandidate::new( + EventId::from_bytes([id; 32]), + public_key, + UnixTimestamp::from_seconds(created_at).expect("time"), + ProfileMetadata::new(Some(name.to_owned()), None, None, None, None) + .expect("metadata"), + ), + UnixTimestamp::from_seconds(created_at + 1).expect("refresh time"), + ProfileRefreshStatus::Success, + ) + } + + #[test] + fn profile_cache_round_trips_and_records_refresh_status() { + let database = Database::in_memory().expect("database"); + let public_key = PublicKey::from_bytes([1; 32]); + database + .insert_account(&account(public_key)) + .expect("account"); + database + .save_profile(&profile(public_key, 1, 10, "Farm")) + .expect("save profile"); + database + .record_refresh_status( + public_key, + UnixTimestamp::from_seconds(20).expect("time"), + ProfileRefreshStatus::Offline, + ) + .expect("record status"); + + let loaded = database + .load_profile(public_key) + .expect("load profile") + .expect("cached profile"); + assert_eq!(loaded.candidate().metadata().name(), Some("Farm")); + assert_eq!(loaded.refreshed_at().as_seconds(), 20); + assert_eq!(loaded.refresh_status(), ProfileRefreshStatus::Offline); + } + + #[test] + fn profile_cache_keeps_newest_then_lowest_event_id() { + let database = Database::in_memory().expect("database"); + let public_key = PublicKey::from_bytes([2; 32]); + database + .insert_account(&account(public_key)) + .expect("account"); + database + .save_profile(&profile(public_key, 9, 20, "High ID")) + .expect("initial"); + database + .save_profile(&profile(public_key, 1, 20, "Low ID")) + .expect("equal newer candidate"); + database + .save_profile(&profile(public_key, 0, 10, "Older")) + .expect("older candidate"); + + let loaded = database + .load_profile(public_key) + .expect("load") + .expect("profile"); + assert_eq!(loaded.candidate().metadata().name(), Some("Low ID")); + assert_eq!(loaded.candidate().event_id(), EventId::from_bytes([1; 32])); + } + + #[test] + fn profile_cache_cascades_with_account_removal() { + let database = Database::in_memory().expect("database"); + let public_key = PublicKey::from_bytes([3; 32]); + database + .insert_account(&account(public_key)) + .expect("account"); + database + .save_profile(&profile(public_key, 1, 10, "Farm")) + .expect("profile"); + database.remove_account(public_key).expect("remove account"); + + assert_eq!(database.load_profile(public_key).expect("load"), None); + } +} diff --git a/crates/studio_storage/src/runtime_actor.rs b/crates/studio_storage/src/runtime_actor.rs @@ -0,0 +1,1693 @@ +use std::collections::BTreeMap; +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, ForegroundSessionBinding, + GenerateAccountReceipt, GeneratedKeyRecoveryHandle, GeneratedKeyStage, ImportAccountReceipt, + LifecycleGate, NostrClient, OrderedSnapshotChanges, ProfileRefreshPlan, RecoveryStageId, + RelayConfiguration, RemovalConfirmationToken, RequestId, RuntimeCommandClass, RuntimeLifecycle, + SecretStore, SessionGeneration, SnapshotChange, SnapshotChangeReceiver, SnapshotRevision, + TaskCorrelation, +}; +use radroots_studio_domain::{ + AccountIdentity, BindingAvailability, Kind0ProfileCandidate, LocalSignerBinding, PublicKey, + SafeError, SafeErrorCode, SafeMessage, SecretKeyInput, +}; +use tokio::runtime::Handle; +use tokio::sync::{mpsc, oneshot}; + +use crate::PersistentAppCore; + +const DEFAULT_COMMAND_TIMEOUT: Duration = Duration::from_secs(30); +const DEFAULT_TASK_CAPACITY: usize = 64; + +enum RuntimeCommand { + Snapshot, + GenerateAccount, + BeginGeneratedKeyStage, + AcknowledgeGeneratedKeyStage(RecoveryStageId), + CancelGeneratedKeyStage, + ImportSecretKey { + input: SecretKeyInput, + durable_request: Option<radroots_studio_application::DurableRequestId>, + durable_expected_revision: Option<u64>, + }, + SelectAccount(PublicKey), + ActivateAccount(PublicKey), + SignOut, + RefreshActiveProfile, + RequestAccountRemoval(PublicKey), + ConfirmAccountRemoval(RemovalConfirmationToken), + 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, + } + } +} + +struct RuntimeActor { + adapter: Arc<PersistentAppCore>, + secrets: Arc<dyn SecretStore>, + clock: Arc<dyn Clock>, + nostr: Arc<dyn NostrClient>, + lifecycle: Arc<Mutex<LifecycleGate>>, + runtime: Handle, + session_generation: SessionGeneration, + published_session_generation: Arc<AtomicU64>, + profile_tasks: BTreeMap<RequestId, PendingProfileTask>, + changes: OrderedSnapshotChanges, + published_foreground_session: Arc<Mutex<Option<ForegroundSessionBinding>>>, + durable_request_namespace: String, + generated_key_stage: GeneratedKeyStage, +} + +struct PendingProfileTask { + correlation: TaskCorrelation, + plan: ProfileRefreshPlan, + reply: oneshot::Sender<CommandReceipt<RuntimeCommandValue>>, + handle: tokio::task::JoinHandle<()>, +} + +struct ProfileCompletion { + request_id: RequestId, + result: Result<Option<Kind0ProfileCandidate>, SafeError>, +} + +#[derive(Clone)] +pub struct RuntimeActorHandle { + mailbox: ActorMailbox<RuntimeCommand, RuntimeCommandValue>, + adapter: Arc<PersistentAppCore>, + lifecycle: Arc<Mutex<LifecycleGate>>, + runtime: Handle, + next_request: Arc<AtomicU64>, + session_generation: Arc<AtomicU64>, + foreground_session: Arc<Mutex<Option<ForegroundSessionBinding>>>, +} + +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 fn open( + path: &Path, + relay_configuration: RelayConfiguration, + secrets: Arc<dyn SecretStore>, + clock: Arc<dyn Clock>, + nostr: Arc<dyn NostrClient>, + capacity: NonZeroUsize, + runtime: &Handle, + ) -> Result<Self, SafeError> { + Self::start( + PersistentAppCore::open(path, relay_configuration)?, + secrets, + clock, + nostr, + capacity, + runtime, + ) + } + + /// Starts one isolated actor-owned in-memory runtime for tests. + /// + /// # Errors + /// + /// Returns a safe storage, recovery, or lifecycle error before publication. + pub fn in_memory( + relay_configuration: RelayConfiguration, + secrets: Arc<dyn SecretStore>, + clock: Arc<dyn Clock>, + nostr: Arc<dyn NostrClient>, + capacity: NonZeroUsize, + runtime: &Handle, + ) -> Result<Self, SafeError> { + Self::start( + PersistentAppCore::in_memory(relay_configuration)?, + secrets, + clock, + nostr, + capacity, + runtime, + ) + } + + fn start( + adapter: PersistentAppCore, + secrets: Arc<dyn SecretStore>, + clock: Arc<dyn Clock>, + nostr: Arc<dyn NostrClient>, + capacity: NonZeroUsize, + runtime: &Handle, + ) -> Result<Self, SafeError> { + let mut gate = LifecycleGate::opening(); + gate.begin_compatibility_check()?; + gate.compatibility_accepted()?; + gate.ownership_acquired()?; + gate.migration_complete()?; + adapter.bootstrap(secrets.as_ref(), clock.as_ref())?; + gate.recovery_complete()?; + + let adapter = Arc::new(adapter); + 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 durable_request_namespace = format!( + "runtime:{}:{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_or(0, |duration| duration.as_nanos()) + ); + let actor = RuntimeActor { + adapter: Arc::clone(&adapter), + secrets, + clock, + nostr, + lifecycle: Arc::clone(&lifecycle), + runtime: runtime.clone(), + session_generation: SessionGeneration::initial(), + published_session_generation: Arc::clone(&session_generation), + profile_tasks: BTreeMap::new(), + changes, + published_foreground_session: Arc::clone(&foreground_session), + durable_request_namespace, + generated_key_stage: GeneratedKeyStage::default(), + }; + drop(runtime.spawn(actor.run(receiver))); + Ok(Self { + mailbox, + adapter, + lifecycle, + runtime: runtime.clone(), + next_request: Arc::new(AtomicU64::new(1)), + session_generation, + foreground_session, + }) + } + + #[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 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) -> Result<GenerateAccountReceipt, SafeError> { + match self.dispatch(RuntimeCommand::GenerateAccount, None).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, + ) -> Result<AppSnapshot, SafeError> { + let value = self + .dispatch(RuntimeCommand::AcknowledgeGeneratedKeyStage(id), None) + .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, + input: SecretKeyInput, + ) -> Result<ImportAccountReceipt, SafeError> { + match self + .dispatch( + RuntimeCommand::ImportSecretKey { + input, + durable_request: None, + durable_expected_revision: None, + }, + None, + ) + .await? + { + RuntimeCommandValue::Imported(receipt) => Ok(receipt), + _ => Err(invalid_actor_response()), + } + } + + /// Imports or repairs with a caller-owned durable request and deadline. + /// + /// # Errors + /// + /// Returns a safe validation, conflict, timeout, persistence, or actor error. + pub async fn import_secret_key_request( + &self, + request: radroots_studio_application::DurableRequestId, + expected_revision: SnapshotRevision, + 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)?; + match self + .dispatch_with_deadline( + RuntimeCommand::ImportSecretKey { + input, + durable_request: Some(request), + durable_expected_revision: Some(expected_revision.value()), + }, + None, + request_id, + Instant::now() + 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, + ) -> Result<AppSnapshot, SafeError> { + let value = self + .dispatch(RuntimeCommand::ConfirmAccountRemoval(token), None) + .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 raw_request = self.next_request.fetch_add(1, Ordering::Relaxed); + let request_id = RequestId::new(raw_request).ok_or_else(request_space_exhausted)?; + match self + .dispatch_with_deadline( + RuntimeCommand::Close, + None, + request_id, + Instant::now() + timeout, + ) + .await? + { + RuntimeCommandValue::Closed => Ok(()), + _ => Err(invalid_actor_response()), + } + } + + /// 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_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()); + let waiting = self + .runtime + .spawn(async move { tokio::time::timeout(remaining, ticket.receipt()).await }); + match waiting.await { + Ok(Ok(receipt)) => receipt, + Ok(Err(_)) => CommandReceipt::new(request_id, CommandResult::TimedOut), + Err(_) => CommandReceipt::new(request_id, CommandResult::Closed), + } + } + 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), + } + } + + #[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)?; + match self + .dispatch_with_deadline( + RuntimeCommand::ImportSecretKey { + input, + durable_request: None, + durable_expected_revision: None, + }, + None, + 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) { + break; + } + } + completion = completions.recv(), if !self.profile_tasks.is_empty() => { + if let Some(completion) = completion { + self.complete_profile_task(completion); + } + } + } + } + self.cancel_profile_tasks(None); + } + + 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()); + return true; + } + if matches!(command, RuntimeCommand::Close) { + let result = self.close_actor(); + 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_sync(context, command); + if begins_generated_recovery && matches!(&result, CommandResult::Completed(_)) { + let snapshot = self.adapter.core().snapshot(); + self.cancel_profile_tasks(Some(&snapshot)); + } + if changes_session && matches!(result, CommandResult::Completed(_)) { + self.advance_session_generation(); + 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 context + .expected_revision() + .is_some_and(|expected| expected != current_revision) + { + return Some(CommandResult::Conflicted { current_revision }); + } + None + } + + fn execute_sync( + &mut self, + context: CommandContext, + command: RuntimeCommand, + ) -> CommandResult<RuntimeCommandValue> { + let durable_request = radroots_studio_application::DurableRequestId::parse(format!( + "{}:{}", + self.durable_request_namespace, + context.request_id().get() + )); + let expected_revision = context + .expected_revision() + .unwrap_or_else(|| self.adapter.core().snapshot().revision()) + .value(); + let result = match command { + RuntimeCommand::Snapshot => Ok(RuntimeCommandValue::Snapshot(Box::new( + self.adapter.core().snapshot(), + ))), + RuntimeCommand::GenerateAccount => durable_request.and_then(|request| { + self.adapter + .generate_account_durable( + &request, + expected_revision, + self.secrets.as_ref(), + self.clock.as_ref(), + ) + .map(RuntimeCommandValue::Generated) + }), + RuntimeCommand::BeginGeneratedKeyStage => self + .generated_key_stage + .begin( + RecoveryStageId::new( + NonZeroU64::new(context.request_id().get()) + .expect("request IDs are always non-zero"), + ), + expected_revision, + self.clock.now(), + ) + .map(RuntimeCommandValue::GeneratedKeyStage), + RuntimeCommand::AcknowledgeGeneratedKeyStage(id) => { + durable_request.and_then(|request| self.commit_generated_key_stage(&request, id)) + } + RuntimeCommand::CancelGeneratedKeyStage => Ok( + RuntimeCommandValue::GeneratedKeyStageCancelled(self.generated_key_stage.cancel()), + ), + RuntimeCommand::ImportSecretKey { + input, + durable_request: caller_request, + durable_expected_revision, + } => self.import_secret_key_command( + input, + caller_request, + durable_request, + durable_expected_revision.unwrap_or(expected_revision), + ), + RuntimeCommand::SelectAccount(public_key) => self + .adapter + .select_account(public_key) + .map(Box::new) + .map(RuntimeCommandValue::Snapshot), + RuntimeCommand::ActivateAccount(public_key) => self + .adapter + .activate_account(public_key, self.secrets.as_ref(), self.clock.as_ref()) + .map(Box::new) + .map(RuntimeCommandValue::Snapshot), + 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.and_then(|request| { + self.adapter + .confirm_account_removal_durable( + &request, + token, + self.secrets.as_ref(), + self.clock.as_ref(), + ) + .map(Box::new) + .map(RuntimeCommandValue::Snapshot) + }), + 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) + } + + fn commit_generated_key_stage( + &mut self, + request: &radroots_studio_application::DurableRequestId, + id: RecoveryStageId, + ) -> Result<RuntimeCommandValue, SafeError> { + let staged = self.generated_key_stage.take(id, self.clock.now())?; + self.adapter.commit_staged_generated_key( + request, + staged, + self.secrets.as_ref(), + self.clock.as_ref(), + )?; + Ok(RuntimeCommandValue::Snapshot(Box::new( + self.adapter.core().snapshot(), + ))) + } + + fn import_secret_key_command( + &self, + input: SecretKeyInput, + caller_request: Option<radroots_studio_application::DurableRequestId>, + fallback_request: Result<radroots_studio_application::DurableRequestId, SafeError>, + expected_revision: u64, + ) -> Result<RuntimeCommandValue, SafeError> { + let request = caller_request.map_or(fallback_request, Ok)?; + self.adapter + .import_secret_key_durable( + &request, + expected_revision, + input, + self.secrets.as_ref(), + self.clock.as_ref(), + ) + .map(RuntimeCommandValue::Imported) + } + + 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).await; + let _ = completion_sender + .send(ProfileCompletion { request_id, result }) + .await; + }); + let previous = self.profile_tasks.insert( + request_id, + PendingProfileTask { + correlation, + plan, + reply, + handle, + }, + ); + debug_assert!(previous.is_none(), "request identifiers are unique"); + } + + 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); + 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), + } + } + + fn complete_profile_task(&mut self, completion: ProfileCompletion) { + let Some(task) = self.profile_tasks.remove(&completion.request_id) else { + return; + }; + 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 { + self.adapter + .core() + .complete_profile_refresh( + &task.plan, + completion.result, + self.adapter.database(), + self.clock.as_ref(), + ) + .map(Box::new) + .map(RuntimeCommandValue::Snapshot) + .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)); + } + + 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); + 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)); + } + + 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; + } + + 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 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, + )); + } + } +} + +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::num::NonZeroUsize; + use std::sync::atomic::{AtomicBool, Ordering}; + use std::sync::{Arc, Condvar, Mutex}; + use std::time::Duration; + + use radroots_studio_application::{ + BoxFuture, Clock, FailureSecretStore, InMemorySecretStore, NostrClient, RelayConfiguration, + RuntimeLifecycle, SecretStore, SecretStoreOperation, SessionState, + }; + use radroots_studio_domain::{ + Kind0ProfileCandidate, PublicKey, RelayUrl, SafeError, SafeErrorCode, SecretKeyInput, + UnixTimestamp, + }; + + use super::RuntimeActorHandle; + + 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], + ) -> BoxFuture<'a, Result<Option<Kind0ProfileCandidate>, SafeError>> { + Box::pin(async { Ok(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], + ) -> BoxFuture<'a, Result<Option<Kind0ProfileCandidate>, SafeError>> { + Box::pin(async move { + self.started.add_permits(1); + let permit = self.release.acquire().await.expect("release"); + permit.forget(); + Ok(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) + } + } + + 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(), + secret_port, + Arc::new(FixedClock), + Arc::new(OfflineNostr), + NonZeroUsize::new(8).expect("capacity"), + &tokio::runtime::Handle::current(), + ) + .expect("actor"); + (actor, secrets) + } + + #[tokio::test(flavor = "multi_thread")] + async fn account_mutations_run_serially_through_one_ready_actor() { + let (actor, secrets) = actor(); + assert_eq!(actor.lifecycle(), RuntimeLifecycle::Ready); + + let imported = actor + .import_secret_key( + 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(removal) + .await + .expect("remove"); + assert!(removed.accounts().is_empty()); + assert!(!secrets.contains(public_key).expect("credential removed")); + } + + #[tokio::test(flavor = "multi_thread")] + async fn generated_key_stage_is_exclusive_cancelable_and_snapshot_free() { + let (actor, secrets) = actor(); + 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(); + 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(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(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(), + secret_port, + Arc::new(FixedClock), + Arc::new(OfflineNostr), + NonZeroUsize::new(8).expect("capacity"), + &tokio::runtime::Handle::current(), + ) + .expect("actor"); + let handle = actor + .begin_generated_key_stage() + .await + .expect("generated key stage"); + + let error = actor + .acknowledge_generated_key_stage(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").expect("relay")]), + Arc::new(InMemorySecretStore::default()), + Arc::new(FixedClock), + client.clone(), + NonZeroUsize::new(8).expect("capacity"), + &tokio::runtime::Handle::current(), + ) + .expect("actor"); + let imported = actor + .import_secret_key( + 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 bounded_runtime_rejects_saturation_without_dropping_accepted_commands() { + let secrets = Arc::new(BlockingSecretStore::new()); + let actor = RuntimeActorHandle::in_memory( + RelayConfiguration::default(), + secrets.clone(), + Arc::new(FixedClock), + Arc::new(OfflineNostr), + NonZeroUsize::new(1).expect("capacity"), + &tokio::runtime::Handle::current(), + ) + .expect("actor"); + + let first_actor = actor.clone(); + let first = tokio::spawn(async move { + first_actor + .import_secret_key(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(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(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"); + second.await.expect("second task").expect("second command"); + assert_eq!( + actor.bootstrap().await.expect("snapshot").accounts().len(), + 2 + ); + } + + #[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(), + secrets.clone(), + Arc::new(FixedClock), + Arc::new(OfflineNostr), + NonZeroUsize::new(1).expect("capacity"), + &tokio::runtime::Handle::current(), + ) + .expect("actor"); + + let first_actor = actor.clone(); + let first = tokio::spawn(async move { + first_actor + .import_secret_key(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(); + actor.close().await.expect("close"); + assert_eq!(actor.lifecycle(), RuntimeLifecycle::Closed); + + for error in [ + actor.bootstrap().await.expect_err("bootstrap after close"), + actor.close().await.expect_err("repeated close"), + ] { + assert_eq!( + error.message().as_str(), + "The application runtime is closed." + ); + } + } + + #[tokio::test(flavor = "multi_thread")] + async fn actor_subscription_atomically_delivers_initial_then_ordered_changes() { + let (actor, _) = actor(); + 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(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") + ); + } + + #[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(), + secrets.clone(), + Arc::new(FixedClock), + Arc::new(OfflineNostr), + NonZeroUsize::new(1).expect("capacity"), + &tokio::runtime::Handle::current(), + ) + .expect("actor"); + let import_actor = actor.clone(); + let import = tokio::spawn(async move { + import_actor + .import_secret_key(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").expect("relay")]), + Arc::new(InMemorySecretStore::default()), + Arc::new(FixedClock), + client.clone(), + NonZeroUsize::new(8).expect("capacity"), + &tokio::runtime::Handle::current(), + ) + .expect("actor"); + let imported = actor + .import_secret_key(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/crates/studio_storage/tests/local_relay_e2e.rs b/crates/studio_storage/tests/local_relay_e2e.rs @@ -0,0 +1,95 @@ +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, SdkNostrClient, SecretStore, SessionState, +}; +use radroots_studio_domain::{RelayUrl, SecretKeyInput, UnixTimestamp}; +use radroots_studio_storage::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()).expect("relay URL"); + let adapter = PersistentAppCore::in_memory(RelayConfiguration::new(vec![relay])) + .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, + ) + .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/crates/studio_storage/tests/redaction.rs b/crates/studio_storage/tests/redaction.rs @@ -0,0 +1,52 @@ +use std::fs; + +use radroots_studio_application::{AccountOperationKind, AccountRepository, OperationJournal}; +use radroots_studio_domain::{ + AccountCreatedAt, AccountIdentity, AccountSummary, BindingAvailability, LocalSignerBinding, + PublicKey, UnixTimestamp, +}; +use radroots_studio_storage::Database; +use tempfile::tempdir; + +const SECRET_HEX: &str = "1111111111111111111111111111111111111111111111111111111111111111"; +const SECRET_NSEC: &str = "nsec1vl029mgpspedva04g90vltkh6fvh240zqtv9k0t9af8935ke9laqsnlfe5"; +fn assert_redacted(bytes: &[u8]) { + assert!( + !bytes + .windows(SECRET_HEX.len()) + .any(|value| value == SECRET_HEX.as_bytes()) + ); + assert!( + !bytes + .windows(SECRET_NSEC.len()) + .any(|value| value == SECRET_NSEC.as_bytes()) + ); + assert!(!bytes.windows(5).any(|value| value == b"nsec1")); +} + +#[test] +fn redaction_guards_sqlite_schema_and_non_secret_records() { + let directory = tempdir().expect("directory"); + let path = directory.path().join("studio.sqlite3"); + { + let database = Database::open(&path).expect("database"); + let public_key = PublicKey::from_bytes([2; 32]); + let account = 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"); + database.insert_account(&account).expect("account"); + database + .begin_operation( + AccountOperationKind::Add, + account.public_key(), + UnixTimestamp::from_seconds(2).expect("time"), + ) + .expect("journal"); + } + assert_redacted(&fs::read(path).expect("database bytes")); +} diff --git a/crates/studio_storage/tests/restart_isolation.rs b/crates/studio_storage/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_storage::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")); +} diff --git a/crates/studio_uniffi_bindgen/Cargo.toml b/crates/studio_uniffi_bindgen/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "radroots-studio-uniffi-bindgen" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +publish = false + +[dependencies] +uniffi = { workspace = true, features = ["cli"] } + +[lints] +workspace = true diff --git a/crates/studio_uniffi_bindgen/src/main.rs b/crates/studio_uniffi_bindgen/src/main.rs @@ -0,0 +1,13 @@ +#![doc = "Pinned `UniFFI` binding generator entry point."] + +fn main() { + uniffi::uniffi_bindgen_main(); +} + +#[cfg(test)] +mod tests { + #[test] + fn tool_is_available_to_the_workspace() { + assert_eq!(env!("CARGO_PKG_NAME"), "radroots-studio-uniffi-bindgen"); + } +} diff --git a/imports/studio_workspace/Cargo.toml b/imports/studio_workspace/Cargo.toml @@ -0,0 +1,43 @@ +[workspace] +members = [ + "crates/application", + "crates/domain", + "crates/ffi", + "crates/nostr", + "crates/storage", + "tools/uniffi-bindgen", +] +resolver = "3" + +[workspace.package] +version = "0.1.0-alpha" +edition = "2024" +rust-version = "1.97.1" +license = "GPL-3.0-only" +repository = "https://github.com/radroots/studio_app" + +[workspace.lints.rust] +unsafe_code = "forbid" + +[workspace.lints.clippy] +all = "deny" +pedantic = "deny" + +[workspace.dependencies] +bech32 = "=0.11.1" +keyring = "=4.1.6" +directories = "=6.0.0" +fs2 = "=0.4.3" +nostr = { git = "https://github.com/rust-nostr/nostr.git", rev = "5bba5163eb77107f82c4a8262cf29d7f33a73219", package = "nostr" } +nostr-sdk = "=0.44.1" +nostr-relay-builder = "=0.44.1" +refinery = { version = "=0.9.2", default-features = false, features = ["rusqlite"] } +rusqlite = { version = "=0.39.0", features = ["bundled"] } +secrecy = "=0.10.3" +url = "=2.5.8" +zeroize = "=1.9.0" +tokio = { version = "=1.47.1", features = ["macros", "rt-multi-thread", "sync", "time"] } +uniffi = "=0.32.0" + +[patch.crates-io] +nostr = { git = "https://github.com/rust-nostr/nostr.git", rev = "5bba5163eb77107f82c4a8262cf29d7f33a73219" }