app

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

commit 43f274a1d97f8044c751192548384c897455ba1c
parent 7fdf93c81beeec1fc35ee38b537fea29886729b2
Author: triesap <tyson@radroots.org>
Date:   Fri,  7 Aug 2026 00:16:29 +0000

consolidation: remove retired studio implementation source

- remove duplicate domain application adapter ffi and bindgen trees
- retain the exact canonical lib source-lock capsule and compatibility data
- build native ffi and Kotlin bindings from the immutable lib revision
- verify locked Rust checks and the macOS arm64 package matrix

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

diff --git a/core/Cargo.toml b/core/Cargo.toml @@ -1,13 +1,5 @@ [workspace] members = ["crates/source_lock"] -exclude = [ - "crates/application", - "crates/domain", - "crates/ffi", - "crates/nostr", - "crates/storage", - "tools/uniffi-bindgen", -] resolver = "3" [workspace.package] diff --git a/core/crates/application/Cargo.toml b/core/crates/application/Cargo.toml @@ -1,22 +0,0 @@ -[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/core/crates/application/src/accounts.rs b/core/crates/application/src/accounts.rs @@ -1,1412 +0,0 @@ -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/core/crates/application/src/actor.rs b/core/crates/application/src/actor.rs @@ -1,785 +0,0 @@ -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/core/crates/application/src/app_core.rs b/core/crates/application/src/app_core.rs @@ -1,300 +0,0 @@ -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/core/crates/application/src/change_stream.rs b/core/crates/application/src/change_stream.rs @@ -1,239 +0,0 @@ -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/core/crates/application/src/config.rs b/core/crates/application/src/config.rs @@ -1,105 +0,0 @@ -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/core/crates/application/src/custody.rs b/core/crates/application/src/custody.rs @@ -1,279 +0,0 @@ -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/core/crates/application/src/lib.rs b/core/crates/application/src/lib.rs @@ -1,55 +0,0 @@ -#![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/core/crates/application/src/nostr_client.rs b/core/crates/application/src/nostr_client.rs @@ -1,138 +0,0 @@ -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/core/crates/application/src/ports.rs b/core/crates/application/src/ports.rs @@ -1,780 +0,0 @@ -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/core/crates/application/src/profile_refresh.rs b/core/crates/application/src/profile_refresh.rs @@ -1,559 +0,0 @@ -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/core/crates/application/src/recovery.rs b/core/crates/application/src/recovery.rs @@ -1,358 +0,0 @@ -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/core/crates/application/src/secrets.rs b/core/crates/application/src/secrets.rs @@ -1,308 +0,0 @@ -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/core/crates/application/src/session.rs b/core/crates/application/src/session.rs @@ -1,250 +0,0 @@ -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/core/crates/application/src/snapshot.rs b/core/crates/application/src/snapshot.rs @@ -1,385 +0,0 @@ -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/core/crates/application/src/state_machine.rs b/core/crates/application/src/state_machine.rs @@ -1,506 +0,0 @@ -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/core/crates/application/tests/redaction.rs b/core/crates/application/tests/redaction.rs @@ -1,47 +0,0 @@ -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/core/crates/domain/Cargo.toml b/core/crates/domain/Cargo.toml @@ -1,16 +0,0 @@ -[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/core/crates/domain/src/account.rs b/core/crates/domain/src/account.rs @@ -1,423 +0,0 @@ -//! 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/core/crates/domain/src/error.rs b/core/crates/domain/src/error.rs @@ -1,110 +0,0 @@ -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/core/crates/domain/src/key.rs b/core/crates/domain/src/key.rs @@ -1,391 +0,0 @@ -//! 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/core/crates/domain/src/lib.rs b/core/crates/domain/src/lib.rs @@ -1,20 +0,0 @@ -#![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/core/crates/domain/src/profile.rs b/core/crates/domain/src/profile.rs @@ -1,295 +0,0 @@ -//! 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/core/crates/domain/src/relay.rs b/core/crates/domain/src/relay.rs @@ -1,157 +0,0 @@ -//! 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/core/crates/domain/src/time.rs b/core/crates/domain/src/time.rs @@ -1,34 +0,0 @@ -//! 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/core/crates/ffi/Cargo.toml b/core/crates/ffi/Cargo.toml @@ -1,27 +0,0 @@ -[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/core/crates/ffi/src/commands.rs b/core/crates/ffi/src/commands.rs @@ -1,853 +0,0 @@ -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/core/crates/ffi/src/dto.rs b/core/crates/ffi/src/dto.rs @@ -1,419 +0,0 @@ -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/core/crates/ffi/src/lib.rs b/core/crates/ffi/src/lib.rs @@ -1,34 +0,0 @@ -#![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/core/crates/ffi/src/observer.rs b/core/crates/ffi/src/observer.rs @@ -1,363 +0,0 @@ -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/core/crates/ffi/uniffi.toml b/core/crates/ffi/uniffi.toml @@ -1,3 +0,0 @@ -[crates.radroots_studio_ffi.bindings.kotlin] -package_name = "org.radroots.studio.ffi" -cdylib_name = "radroots_studio_ffi" diff --git a/core/crates/nostr/Cargo.toml b/core/crates/nostr/Cargo.toml @@ -1,14 +0,0 @@ -[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/core/crates/nostr/src/keys.rs b/core/crates/nostr/src/keys.rs @@ -1,152 +0,0 @@ -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/core/crates/nostr/src/lib.rs b/core/crates/nostr/src/lib.rs @@ -1,7 +0,0 @@ -#![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/core/crates/nostr/src/profile.rs b/core/crates/nostr/src/profile.rs @@ -1,165 +0,0 @@ -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/core/crates/storage/Cargo.toml b/core/crates/storage/Cargo.toml @@ -1,27 +0,0 @@ -[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/core/crates/storage/migrations/V1__initialize.sql b/core/crates/storage/migrations/V1__initialize.sql @@ -1,6 +0,0 @@ -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/core/crates/storage/migrations/V2__accounts.sql b/core/crates/storage/migrations/V2__accounts.sql @@ -1,28 +0,0 @@ -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/core/crates/storage/migrations/V3__profile_cache.sql b/core/crates/storage/migrations/V3__profile_cache.sql @@ -1,12 +0,0 @@ -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/core/crates/storage/migrations/V4__account_namespace.sql b/core/crates/storage/migrations/V4__account_namespace.sql @@ -1,6 +0,0 @@ -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/core/crates/storage/migrations/V5__operation_journal.sql b/core/crates/storage/migrations/V5__operation_journal.sql @@ -1,8 +0,0 @@ -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/core/crates/storage/migrations/V6__normalized_runtime_schema.sql b/core/crates/storage/migrations/V6__normalized_runtime_schema.sql @@ -1,100 +0,0 @@ -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/core/crates/storage/migrations/V7__migrate_v5_runtime_data.sql b/core/crates/storage/migrations/V7__migrate_v5_runtime_data.sql @@ -1,68 +0,0 @@ -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/core/crates/storage/migrations/V8__normalized_account_preferences.sql b/core/crates/storage/migrations/V8__normalized_account_preferences.sql @@ -1,10 +0,0 @@ -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/core/crates/storage/migrations/V9__durable_operation_receipts.sql b/core/crates/storage/migrations/V9__durable_operation_receipts.sql @@ -1,11 +0,0 @@ -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/core/crates/storage/src/account_namespace.rs b/core/crates/storage/src/account_namespace.rs @@ -1,162 +0,0 @@ -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/core/crates/storage/src/accounts.rs b/core/crates/storage/src/accounts.rs @@ -1,394 +0,0 @@ -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/core/crates/storage/src/application_adapter.rs b/core/crates/storage/src/application_adapter.rs @@ -1,718 +0,0 @@ -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/core/crates/storage/src/db.rs b/core/crates/storage/src/db.rs @@ -1,590 +0,0 @@ -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/core/crates/storage/src/journal.rs b/core/crates/storage/src/journal.rs @@ -1,661 +0,0 @@ -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/core/crates/storage/src/lib.rs b/core/crates/storage/src/lib.rs @@ -1,15 +0,0 @@ -#![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/core/crates/storage/src/os_keyring.rs b/core/crates/storage/src/os_keyring.rs @@ -1,131 +0,0 @@ -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/core/crates/storage/src/profiles.rs b/core/crates/storage/src/profiles.rs @@ -1,250 +0,0 @@ -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/core/crates/storage/src/runtime_actor.rs b/core/crates/storage/src/runtime_actor.rs @@ -1,1693 +0,0 @@ -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/core/crates/storage/tests/local_relay_e2e.rs b/core/crates/storage/tests/local_relay_e2e.rs @@ -1,95 +0,0 @@ -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/core/crates/storage/tests/redaction.rs b/core/crates/storage/tests/redaction.rs @@ -1,52 +0,0 @@ -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/core/crates/storage/tests/restart_isolation.rs b/core/crates/storage/tests/restart_isolation.rs @@ -1,95 +0,0 @@ -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/core/tools/uniffi-bindgen/Cargo.toml b/core/tools/uniffi-bindgen/Cargo.toml @@ -1,14 +0,0 @@ -[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/core/tools/uniffi-bindgen/src/main.rs b/core/tools/uniffi-bindgen/src/main.rs @@ -1,13 +0,0 @@ -#![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"); - } -}