commit d4cf13e2b3aa0cbcb39ad0dbf652d171d94ff992
parent 773b957ff90cff99804db55c4f365a0a9edf4852
Author: triesap <tyson@radroots.org>
Date: Thu, 6 Aug 2026 08:22:28 +0000
consolidation: import studio rust history
- merge the verified Studio package history without squashing
- preserve original authorship timestamps and commit messages
- retain GPL package boundaries and migration source verbatim
- stage the donor workspace manifest for canonical refactoring
Diffstat:
60 files changed, 15213 insertions(+), 0 deletions(-)
diff --git a/crates/studio_application/Cargo.toml b/crates/studio_application/Cargo.toml
@@ -0,0 +1,22 @@
+[package]
+name = "radroots-studio-application"
+version.workspace = true
+edition.workspace = true
+rust-version.workspace = true
+license.workspace = true
+repository.workspace = true
+
+[dependencies]
+nostr.workspace = true
+nostr-sdk.workspace = true
+radroots-studio-domain = { path = "../domain" }
+radroots-studio-nostr = { path = "../nostr" }
+secrecy.workspace = true
+tokio.workspace = true
+
+[dev-dependencies]
+nostr-relay-builder.workspace = true
+tokio = { workspace = true, features = ["macros", "rt-multi-thread", "sync", "time"] }
+
+[lints]
+workspace = true
diff --git a/crates/studio_application/src/accounts.rs b/crates/studio_application/src/accounts.rs
@@ -0,0 +1,1412 @@
+use std::sync::{Mutex, MutexGuard};
+
+use radroots_studio_domain::{
+ AccountCreatedAt, AccountIdentity, AccountSummary, BindingAvailability, LocalSignerBinding,
+ Nsec, PublicKey, SafeError, SafeErrorCode, SafeMessage, SecretKeyInput,
+};
+use radroots_studio_nostr::{generate_local_keypair, import_secret};
+
+use crate::{
+ AccountOperationKind, AccountOperationPhase, AccountRepository, AppCore, AppStateRepository,
+ Clock, DurableOperationKind, DurableOperationPhase, DurableOperationRepository,
+ DurableOperationStart, DurableRequestId, DurableTerminalOutcome, OperationDiagnostic,
+ OperationId, OperationJournal, OperationPriorState, PendingAccountOperation,
+ RemovalConfirmationToken, SecretStore, StagedGeneratedKey, StateTransition,
+};
+
+pub struct GenerateAccountReceipt {
+ account: AccountSummary,
+ generated_nsec: Nsec,
+}
+
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct ImportAccountReceipt {
+ account: AccountSummary,
+}
+
+impl ImportAccountReceipt {
+ #[must_use]
+ pub const fn account(&self) -> &AccountSummary {
+ &self.account
+ }
+}
+
+impl GenerateAccountReceipt {
+ #[must_use]
+ pub const fn account(&self) -> &AccountSummary {
+ &self.account
+ }
+
+ #[must_use]
+ pub const fn generated_nsec(&self) -> &Nsec {
+ &self.generated_nsec
+ }
+}
+
+impl AppCore {
+ /// Commits a staged generated key only after its recovery acknowledgement.
+ ///
+ /// # Errors
+ ///
+ /// Returns a safe conflict, keyring, persistence, or recovery error.
+ #[allow(clippy::too_many_arguments)]
+ pub fn commit_staged_generated_key(
+ &self,
+ request_id: &DurableRequestId,
+ staged: StagedGeneratedKey,
+ accounts: &(impl AccountRepository + ?Sized),
+ app_state: &(impl AppStateRepository + ?Sized),
+ secrets: &(impl SecretStore + ?Sized),
+ operations: &(impl DurableOperationRepository + ?Sized),
+ clock: &(impl Clock + ?Sized),
+ ) -> Result<ImportAccountReceipt, SafeError> {
+ let expected_revision = staged.expected_revision();
+ self.require_revision(expected_revision)?;
+ let (account, secret) = staged.into_commit_parts();
+ self.persist_account_durable(
+ request_id,
+ DurableOperationKind::Create,
+ expected_revision,
+ &account,
+ secret,
+ None,
+ accounts,
+ app_state,
+ secrets,
+ operations,
+ clock,
+ )?;
+ Ok(ImportAccountReceipt { account })
+ }
+
+ /// Generates and commits one account under a durable caller request.
+ ///
+ /// # Errors
+ ///
+ /// Returns a safe conflict, keyring, persistence, or state error. Staged recovery transport
+ /// replaces this transitional generated-secret receipt in the custody phase.
+ #[allow(clippy::too_many_arguments)]
+ pub fn generate_account_durable(
+ &self,
+ request_id: &DurableRequestId,
+ expected_revision: u64,
+ accounts: &(impl AccountRepository + ?Sized),
+ app_state: &(impl AppStateRepository + ?Sized),
+ secrets: &(impl SecretStore + ?Sized),
+ operations: &(impl DurableOperationRepository + ?Sized),
+ clock: &(impl Clock + ?Sized),
+ ) -> Result<GenerateAccountReceipt, SafeError> {
+ self.require_revision(expected_revision)?;
+ let generated = generate_local_keypair()?;
+ let (public_key, npub, secret, nsec) = generated.into_parts();
+ let account = AccountSummary::new(
+ AccountIdentity::verify(public_key, npub.as_str().to_owned())?,
+ LocalSignerBinding::new(public_key, BindingAvailability::Available),
+ None,
+ AccountCreatedAt::new(clock.now()),
+ None,
+ )?;
+ self.persist_account_durable(
+ request_id,
+ DurableOperationKind::Create,
+ expected_revision,
+ &account,
+ secret,
+ None,
+ accounts,
+ app_state,
+ secrets,
+ operations,
+ clock,
+ )?;
+ Ok(GenerateAccountReceipt {
+ account,
+ generated_nsec: nsec,
+ })
+ }
+
+ /// Imports or explicitly repairs one local account under a durable caller request.
+ ///
+ /// # Errors
+ ///
+ /// Returns a safe conflict, validation, keyring, persistence, or state error.
+ #[allow(clippy::too_many_arguments)]
+ pub fn import_secret_key_durable(
+ &self,
+ request_id: &DurableRequestId,
+ expected_revision: u64,
+ input: SecretKeyInput,
+ accounts: &(impl AccountRepository + ?Sized),
+ app_state: &(impl AppStateRepository + ?Sized),
+ secrets: &(impl SecretStore + ?Sized),
+ operations: &(impl DurableOperationRepository + ?Sized),
+ clock: &(impl Clock + ?Sized),
+ ) -> Result<ImportAccountReceipt, SafeError> {
+ if let Some(existing) = operations.load_durable_operation(request_id)? {
+ return if existing
+ .terminal()
+ .is_some_and(|receipt| receipt.outcome() == DurableTerminalOutcome::Completed)
+ {
+ accounts
+ .find_account(existing.account())?
+ .map(|account| ImportAccountReceipt { account })
+ .ok_or_else(recovery_required)
+ } else {
+ Err(recovery_required())
+ };
+ }
+ self.require_revision(expected_revision)?;
+ let imported = import_secret(input)?;
+ let (public_key, npub, secret) = imported.into_parts();
+ let previous = accounts.find_account(public_key)?;
+ if let Some(existing) = &previous
+ && (existing.signer().availability() != BindingAvailability::CredentialMissing
+ || secrets.contains(public_key)?)
+ {
+ return Err(account_exists());
+ }
+ if previous.is_none() && secrets.contains(public_key)? {
+ return Err(account_exists());
+ }
+ let account = if let Some(existing) = &previous {
+ existing.with_binding_availability(BindingAvailability::Available)
+ } else {
+ AccountSummary::new(
+ AccountIdentity::verify(public_key, npub.as_str().to_owned())?,
+ LocalSignerBinding::new(public_key, BindingAvailability::Available),
+ None,
+ AccountCreatedAt::new(clock.now()),
+ None,
+ )?
+ };
+ let kind = if previous.is_some() {
+ DurableOperationKind::Repair
+ } else {
+ DurableOperationKind::Import
+ };
+ self.persist_account_durable(
+ request_id,
+ kind,
+ expected_revision,
+ &account,
+ secret,
+ previous.as_ref(),
+ accounts,
+ app_state,
+ secrets,
+ operations,
+ clock,
+ )?;
+ Ok(ImportAccountReceipt { account })
+ }
+
+ fn require_revision(&self, expected_revision: u64) -> Result<(), SafeError> {
+ if self.snapshot().revision().value() != expected_revision {
+ return Err(operation_conflict());
+ }
+ Ok(())
+ }
+
+ #[allow(clippy::too_many_arguments)]
+ fn persist_account_durable(
+ &self,
+ request_id: &DurableRequestId,
+ kind: DurableOperationKind,
+ expected_revision: u64,
+ account: &AccountSummary,
+ secret: SecretKeyInput,
+ previous: Option<&AccountSummary>,
+ accounts: &(impl AccountRepository + ?Sized),
+ app_state: &(impl AppStateRepository + ?Sized),
+ secrets: &(impl SecretStore + ?Sized),
+ operations: &(impl DurableOperationRepository + ?Sized),
+ clock: &(impl Clock + ?Sized),
+ ) -> Result<(), SafeError> {
+ let prior = OperationPriorState::new(
+ app_state.load_selected_account()?,
+ previous.map(|account| account.signer().availability()),
+ );
+ match operations.begin_durable_operation(
+ request_id,
+ kind,
+ account.public_key(),
+ Some(expected_revision),
+ prior,
+ clock.now(),
+ )? {
+ DurableOperationStart::Started(_) => {}
+ DurableOperationStart::Existing(operation) => {
+ return if operation
+ .terminal()
+ .is_some_and(|receipt| receipt.outcome() == DurableTerminalOutcome::Completed)
+ {
+ Ok(())
+ } else {
+ Err(recovery_required())
+ };
+ }
+ }
+ secrets.put(account.public_key(), secret)?;
+ operations.advance_durable_operation(
+ request_id,
+ DurableOperationPhase::IntentRecorded,
+ DurableOperationPhase::CredentialWritten,
+ clock.now(),
+ None,
+ )?;
+ previous.map_or_else(
+ || accounts.insert_account(account),
+ |_| accounts.update_account(account),
+ )?;
+ operations.advance_durable_operation(
+ request_id,
+ DurableOperationPhase::CredentialWritten,
+ DurableOperationPhase::MetadataCommitted,
+ clock.now(),
+ None,
+ )?;
+ app_state.save_selected_account(Some(account.public_key()))?;
+ operations.advance_durable_operation(
+ request_id,
+ DurableOperationPhase::MetadataCommitted,
+ DurableOperationPhase::SelectionCommitted,
+ clock.now(),
+ None,
+ )?;
+ let snapshot = self.apply_transition(StateTransition::ReplaceRegistry {
+ accounts: accounts.list_accounts()?,
+ selected: Some(account.public_key()),
+ })?;
+ operations.finalize_durable_operation(
+ request_id,
+ DurableOperationPhase::SelectionCommitted,
+ DurableTerminalOutcome::Completed,
+ Some(snapshot.revision().value()),
+ clock.now(),
+ )?;
+ Ok(())
+ }
+
+ /// Issues a single-use confirmation bound to the target and current revision.
+ ///
+ /// # Errors
+ ///
+ /// Returns a safe account or application-state error.
+ pub fn request_account_removal(
+ &self,
+ public_key: PublicKey,
+ clock: &(impl Clock + ?Sized),
+ ) -> Result<RemovalConfirmationToken, SafeError> {
+ self.issue_removal_token(public_key, clock.now())
+ }
+
+ pub fn cancel_account_removal(&self, token: RemovalConfirmationToken) -> bool {
+ self.cancel_removal_token(token)
+ }
+
+ /// Permanently removes a confirmed account and selects a deterministic fallback.
+ ///
+ /// # Errors
+ ///
+ /// Returns a safe confirmation, credential, persistence, recovery, or state error.
+ pub fn confirm_account_removal(
+ &self,
+ token: RemovalConfirmationToken,
+ accounts: &(impl AccountRepository + ?Sized),
+ app_state: &(impl AppStateRepository + ?Sized),
+ secrets: &(impl SecretStore + ?Sized),
+ journal: &(impl OperationJournal + ?Sized),
+ clock: &(impl Clock + ?Sized),
+ ) -> Result<crate::AppSnapshot, SafeError> {
+ let public_key = self.consume_removal_token(token, clock.now())?;
+ let registry = accounts.list_accounts()?;
+ let index = registry
+ .iter()
+ .position(|account| account.public_key() == public_key)
+ .ok_or_else(account_not_found)?;
+ let selected = if self.snapshot().selected_account() == Some(public_key) {
+ registry
+ .get(index + 1)
+ .or_else(|| index.checked_sub(1).and_then(|before| registry.get(before)))
+ .map(AccountSummary::public_key)
+ } else {
+ self.snapshot().selected_account()
+ };
+ let operation =
+ journal.begin_operation(AccountOperationKind::Remove, public_key, clock.now())?;
+ let was_active = self
+ .snapshot()
+ .active_account()
+ .is_some_and(|active| active.account().public_key() == public_key);
+ if was_active {
+ self.sign_out()?;
+ }
+ let account = ®istry[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 = ®istry[index];
+ match operations.begin_durable_operation(
+ request_id,
+ DurableOperationKind::Remove,
+ public_key,
+ Some(expected_revision),
+ OperationPriorState::new(selected, Some(account.signer().availability())),
+ clock.now(),
+ )? {
+ DurableOperationStart::Started(_) => {}
+ DurableOperationStart::Existing(operation) => {
+ return if operation
+ .terminal()
+ .is_some_and(|receipt| receipt.outcome() == DurableTerminalOutcome::Completed)
+ {
+ Ok(self.snapshot())
+ } else {
+ Err(recovery_required())
+ };
+ }
+ }
+ if self
+ .snapshot()
+ .active_account()
+ .is_some_and(|active| active.account().public_key() == public_key)
+ {
+ self.sign_out()?;
+ }
+ match secrets.delete(public_key) {
+ Ok(()) => {}
+ Err(error)
+ if error.code() == SafeErrorCode::CredentialMissing
+ && account.signer().availability()
+ == BindingAvailability::CredentialMissing => {}
+ Err(error) => return Err(error),
+ }
+ operations.advance_durable_operation(
+ request_id,
+ DurableOperationPhase::IntentRecorded,
+ DurableOperationPhase::CredentialDeleted,
+ clock.now(),
+ None,
+ )?;
+ accounts.remove_account(public_key)?;
+ operations.advance_durable_operation(
+ request_id,
+ DurableOperationPhase::CredentialDeleted,
+ DurableOperationPhase::MetadataDeleted,
+ clock.now(),
+ None,
+ )?;
+ app_state.save_selected_account(selected)?;
+ operations.advance_durable_operation(
+ request_id,
+ DurableOperationPhase::MetadataDeleted,
+ DurableOperationPhase::SelectionCommitted,
+ clock.now(),
+ None,
+ )?;
+ let snapshot =
+ self.apply_transition(StateTransition::ReplaceRegistryPreservingSession {
+ accounts: accounts.list_accounts()?,
+ selected,
+ })?;
+ operations.finalize_durable_operation(
+ request_id,
+ DurableOperationPhase::SelectionCommitted,
+ DurableTerminalOutcome::Completed,
+ Some(snapshot.revision().value()),
+ clock.now(),
+ )?;
+ Ok(snapshot)
+ }
+
+ /// Persists and publishes a saved account selection without activating it.
+ ///
+ /// # Errors
+ ///
+ /// Returns a safe account, persistence, or application-state error.
+ pub fn select_account(
+ &self,
+ public_key: PublicKey,
+ accounts: &(impl AccountRepository + ?Sized),
+ app_state: &(impl AppStateRepository + ?Sized),
+ ) -> Result<crate::AppSnapshot, SafeError> {
+ if accounts.find_account(public_key)?.is_none() {
+ return Err(account_not_found());
+ }
+ app_state.save_selected_account(Some(public_key))?;
+ self.apply_transition(StateTransition::Select(public_key))
+ }
+
+ /// Generates, stores, and selects one local Nostr account without activating it.
+ ///
+ /// # Errors
+ ///
+ /// Returns a safe key, credential, persistence, or application-state error.
+ pub fn generate_account(
+ &self,
+ accounts: &(impl AccountRepository + ?Sized),
+ app_state: &(impl AppStateRepository + ?Sized),
+ secrets: &(impl SecretStore + ?Sized),
+ journal: &(impl OperationJournal + ?Sized),
+ clock: &(impl Clock + ?Sized),
+ ) -> Result<GenerateAccountReceipt, SafeError> {
+ let generated = generate_local_keypair()?;
+ let (public_key, npub, secret, nsec) = generated.into_parts();
+ let account = AccountSummary::new(
+ AccountIdentity::verify(public_key, npub.as_str().to_owned())?,
+ LocalSignerBinding::new(public_key, BindingAvailability::Available),
+ None,
+ AccountCreatedAt::new(clock.now()),
+ None,
+ )?;
+ Self::persist_account_transaction(
+ AccountOperationKind::Add,
+ &account,
+ secret,
+ None,
+ accounts,
+ app_state,
+ secrets,
+ journal,
+ clock,
+ )?;
+ let registry = accounts.list_accounts()?;
+ self.apply_transition(StateTransition::ReplaceRegistry {
+ accounts: registry,
+ selected: Some(public_key),
+ })?;
+ Ok(GenerateAccountReceipt {
+ account,
+ generated_nsec: nsec,
+ })
+ }
+
+ /// Imports, stores, and selects one local Nostr account without activating it.
+ ///
+ /// # Errors
+ ///
+ /// Returns a safe key, credential, persistence, or application-state error.
+ pub fn import_secret_key(
+ &self,
+ input: SecretKeyInput,
+ accounts: &(impl AccountRepository + ?Sized),
+ app_state: &(impl AppStateRepository + ?Sized),
+ secrets: &(impl SecretStore + ?Sized),
+ journal: &(impl OperationJournal + ?Sized),
+ clock: &(impl Clock + ?Sized),
+ ) -> Result<ImportAccountReceipt, SafeError> {
+ let imported = import_secret(input)?;
+ let (public_key, npub, secret) = imported.into_parts();
+ if let Some(existing) = accounts.find_account(public_key)? {
+ if existing.signer().availability() != BindingAvailability::CredentialMissing
+ || secrets.contains(public_key)?
+ {
+ return Err(account_exists());
+ }
+ let repaired = existing.with_binding_availability(BindingAvailability::Available);
+ Self::persist_account_transaction(
+ AccountOperationKind::Import,
+ &repaired,
+ secret,
+ Some(&existing),
+ accounts,
+ app_state,
+ secrets,
+ journal,
+ clock,
+ )?;
+ self.apply_transition(StateTransition::ReplaceRegistry {
+ accounts: accounts.list_accounts()?,
+ selected: Some(public_key),
+ })?;
+ return Ok(ImportAccountReceipt { account: repaired });
+ }
+ if secrets.contains(public_key)? {
+ return Err(account_exists());
+ }
+ let account = AccountSummary::new(
+ AccountIdentity::verify(public_key, npub.as_str().to_owned())?,
+ LocalSignerBinding::new(public_key, BindingAvailability::Available),
+ None,
+ AccountCreatedAt::new(clock.now()),
+ None,
+ )?;
+ Self::persist_account_transaction(
+ AccountOperationKind::Import,
+ &account,
+ secret,
+ None,
+ accounts,
+ app_state,
+ secrets,
+ journal,
+ clock,
+ )?;
+ self.apply_transition(StateTransition::ReplaceRegistry {
+ accounts: accounts.list_accounts()?,
+ selected: Some(public_key),
+ })?;
+ Ok(ImportAccountReceipt { account })
+ }
+
+ #[allow(clippy::too_many_arguments)]
+ fn persist_account_transaction(
+ kind: AccountOperationKind,
+ account: &AccountSummary,
+ secret: SecretKeyInput,
+ previous: Option<&AccountSummary>,
+ accounts: &(impl AccountRepository + ?Sized),
+ app_state: &(impl AppStateRepository + ?Sized),
+ secrets: &(impl SecretStore + ?Sized),
+ journal: &(impl OperationJournal + ?Sized),
+ clock: &(impl Clock + ?Sized),
+ ) -> Result<(), SafeError> {
+ let public_key = account.public_key();
+ let previous_selection = app_state.load_selected_account()?;
+ let operation = journal.begin_operation(kind, public_key, clock.now())?;
+ if let Err(error) = secrets.put(public_key, secret) {
+ let _ = journal.finalize_operation(operation);
+ return Err(error);
+ }
+ if let Err(error) = journal.update_operation(
+ operation,
+ AccountOperationPhase::CredentialWritten,
+ clock.now(),
+ None,
+ ) {
+ return compensate_account_write(
+ operation,
+ public_key,
+ error,
+ None,
+ previous_selection,
+ accounts,
+ app_state,
+ secrets,
+ journal,
+ clock,
+ );
+ }
+ let metadata_result = previous.map_or_else(
+ || accounts.insert_account(account),
+ |_| accounts.update_account(account),
+ );
+ if let Err(error) = metadata_result {
+ return compensate_account_write(
+ operation,
+ public_key,
+ error,
+ previous,
+ previous_selection,
+ accounts,
+ app_state,
+ secrets,
+ journal,
+ clock,
+ );
+ }
+ if let Err(error) = app_state.save_selected_account(Some(public_key)) {
+ return compensate_account_write(
+ operation,
+ public_key,
+ error,
+ previous,
+ previous_selection,
+ accounts,
+ app_state,
+ secrets,
+ journal,
+ clock,
+ );
+ }
+ journal.update_operation(
+ operation,
+ AccountOperationPhase::MetadataCommitted,
+ clock.now(),
+ None,
+ )?;
+ journal.finalize_operation(operation)
+ }
+}
+
+#[allow(clippy::too_many_arguments)]
+fn compensate_account_write(
+ operation: OperationId,
+ public_key: PublicKey,
+ original_error: SafeError,
+ previous: Option<&AccountSummary>,
+ previous_selection: Option<PublicKey>,
+ accounts: &(impl AccountRepository + ?Sized),
+ app_state: &(impl AppStateRepository + ?Sized),
+ secrets: &(impl SecretStore + ?Sized),
+ journal: &(impl OperationJournal + ?Sized),
+ clock: &(impl Clock + ?Sized),
+) -> Result<(), SafeError> {
+ let metadata_rollback = if let Some(previous) = previous {
+ accounts.update_account(previous)
+ } else {
+ accounts.remove_account(public_key)
+ };
+ let selection_rollback = app_state.save_selected_account(previous_selection);
+ let credential_rollback = secrets.delete(public_key);
+ if metadata_rollback.is_err() || selection_rollback.is_err() || credential_rollback.is_err() {
+ let _ = journal.update_operation(
+ operation,
+ AccountOperationPhase::CompensationPending,
+ clock.now(),
+ Some(OperationDiagnostic::CompensationFailed),
+ );
+ return Err(recovery_required());
+ }
+ let _ = journal.finalize_operation(operation);
+ Err(original_error)
+}
+
+#[derive(Default)]
+pub struct InMemoryOperationJournal {
+ state: Mutex<InMemoryJournalState>,
+}
+
+#[derive(Default)]
+struct InMemoryJournalState {
+ next_id: u64,
+ pending: Vec<PendingAccountOperation>,
+}
+
+impl OperationJournal for InMemoryOperationJournal {
+ fn begin_operation(
+ &self,
+ kind: AccountOperationKind,
+ subject: PublicKey,
+ updated_at: radroots_studio_domain::UnixTimestamp,
+ ) -> Result<OperationId, SafeError> {
+ let mut state = self
+ .state
+ .lock()
+ .unwrap_or_else(std::sync::PoisonError::into_inner);
+ state.next_id = state.next_id.checked_add(1).ok_or_else(recovery_required)?;
+ let id = OperationId::from_raw(state.next_id);
+ state.pending.push(PendingAccountOperation::new(
+ id,
+ kind,
+ subject,
+ AccountOperationPhase::IntentRecorded,
+ updated_at,
+ None,
+ ));
+ Ok(id)
+ }
+
+ fn update_operation(
+ &self,
+ id: OperationId,
+ phase: AccountOperationPhase,
+ updated_at: radroots_studio_domain::UnixTimestamp,
+ diagnostic: Option<OperationDiagnostic>,
+ ) -> Result<(), SafeError> {
+ let mut state = self
+ .state
+ .lock()
+ .unwrap_or_else(std::sync::PoisonError::into_inner);
+ let operation = state
+ .pending
+ .iter_mut()
+ .find(|operation| operation.id() == id)
+ .ok_or_else(recovery_required)?;
+ *operation = PendingAccountOperation::new(
+ id,
+ operation.kind(),
+ operation.subject(),
+ phase,
+ updated_at,
+ diagnostic,
+ );
+ Ok(())
+ }
+
+ fn list_pending_operations(&self) -> Result<Vec<PendingAccountOperation>, SafeError> {
+ Ok(self
+ .state
+ .lock()
+ .unwrap_or_else(std::sync::PoisonError::into_inner)
+ .pending
+ .clone())
+ }
+
+ fn finalize_operation(&self, id: OperationId) -> Result<(), SafeError> {
+ self.state
+ .lock()
+ .unwrap_or_else(std::sync::PoisonError::into_inner)
+ .pending
+ .retain(|operation| operation.id() != id);
+ Ok(())
+ }
+}
+
+#[derive(Default)]
+pub struct InMemoryAccountRepository {
+ state: Mutex<InMemoryAccountState>,
+}
+
+#[derive(Default)]
+struct InMemoryAccountState {
+ accounts: Vec<AccountSummary>,
+ selected: Option<PublicKey>,
+}
+
+impl InMemoryAccountRepository {
+ fn state(&self) -> MutexGuard<'_, InMemoryAccountState> {
+ self.state
+ .lock()
+ .unwrap_or_else(std::sync::PoisonError::into_inner)
+ }
+}
+
+impl AccountRepository for InMemoryAccountRepository {
+ fn list_accounts(&self) -> Result<Vec<AccountSummary>, SafeError> {
+ Ok(self.state().accounts.clone())
+ }
+
+ fn find_account(&self, public_key: PublicKey) -> Result<Option<AccountSummary>, SafeError> {
+ Ok(self
+ .state()
+ .accounts
+ .iter()
+ .find(|account| account.public_key() == public_key)
+ .cloned())
+ }
+
+ fn insert_account(&self, account: &AccountSummary) -> Result<(), SafeError> {
+ let mut state = self.state();
+ if state
+ .accounts
+ .iter()
+ .any(|saved| saved.public_key() == account.public_key())
+ {
+ return Err(account_exists());
+ }
+ state.accounts.push(account.clone());
+ state
+ .accounts
+ .sort_by_key(|saved| (saved.created_at().timestamp(), saved.public_key()));
+ Ok(())
+ }
+
+ fn update_account(&self, account: &AccountSummary) -> Result<(), SafeError> {
+ let mut state = self.state();
+ let saved = state
+ .accounts
+ .iter_mut()
+ .find(|saved| saved.public_key() == account.public_key())
+ .ok_or_else(account_not_found)?;
+ *saved = account.clone();
+ Ok(())
+ }
+
+ fn remove_account(&self, public_key: PublicKey) -> Result<(), SafeError> {
+ let mut state = self.state();
+ state
+ .accounts
+ .retain(|account| account.public_key() != public_key);
+ if state.selected == Some(public_key) {
+ state.selected = None;
+ }
+ Ok(())
+ }
+}
+
+impl AppStateRepository for InMemoryAccountRepository {
+ fn load_selected_account(&self) -> Result<Option<PublicKey>, SafeError> {
+ Ok(self.state().selected)
+ }
+
+ fn save_selected_account(&self, public_key: Option<PublicKey>) -> Result<(), SafeError> {
+ let mut state = self.state();
+ if public_key.is_some_and(|key| {
+ !state
+ .accounts
+ .iter()
+ .any(|account| account.public_key() == key)
+ }) {
+ return Err(account_not_found());
+ }
+ state.selected = public_key;
+ Ok(())
+ }
+}
+
+const fn account_exists() -> SafeError {
+ SafeError::new(
+ SafeErrorCode::AccountAlreadyExists,
+ SafeMessage::new("The Nostr account is already saved."),
+ )
+}
+
+const fn account_not_found() -> SafeError {
+ SafeError::new(
+ SafeErrorCode::AccountNotFound,
+ SafeMessage::new("The account was not found."),
+ )
+}
+
+const fn recovery_required() -> SafeError {
+ SafeError::new(
+ SafeErrorCode::PendingOperationRecoveryRequired,
+ SafeMessage::new("Account recovery is required before this operation can continue."),
+ )
+}
+
+const fn operation_conflict() -> SafeError {
+ SafeError::new(
+ SafeErrorCode::InvalidApplicationState,
+ SafeMessage::new("The account operation conflicts with the current application state."),
+ )
+}
+
+#[cfg(test)]
+mod tests {
+ use std::sync::atomic::{AtomicBool, Ordering};
+
+ use radroots_studio_domain::{
+ AccountCreatedAt, AccountIdentity, AccountSummary, BindingAvailability, LocalSignerBinding,
+ PublicKey, SafeError, SafeErrorCode, SafeMessage, SecretKeyInput, UnixTimestamp,
+ };
+
+ use super::InMemoryAccountRepository;
+ use crate::{
+ AccountOperationPhase, AccountRepository, AppCore, AppStateRepository, Clock,
+ FailureSecretStore, InMemoryOperationJournal, InMemorySecretStore, OperationJournal,
+ RelayConfiguration, SecretStore, SecretStoreOperation, SessionState, StateTransition,
+ };
+
+ struct FixedClock;
+
+ impl Clock for FixedClock {
+ fn now(&self) -> UnixTimestamp {
+ UnixTimestamp::from_seconds(10).expect("time")
+ }
+ }
+
+ struct LateClock;
+
+ impl Clock for LateClock {
+ fn now(&self) -> UnixTimestamp {
+ UnixTimestamp::from_seconds(311).expect("time")
+ }
+ }
+
+ #[derive(Default)]
+ struct FailingInsertRepository {
+ inner: InMemoryAccountRepository,
+ }
+
+ #[derive(Default)]
+ struct FailingSelectionRepository {
+ inner: InMemoryAccountRepository,
+ fail_next_selection: AtomicBool,
+ }
+
+ impl AccountRepository for FailingSelectionRepository {
+ fn list_accounts(&self) -> Result<Vec<AccountSummary>, SafeError> {
+ self.inner.list_accounts()
+ }
+
+ fn find_account(&self, public_key: PublicKey) -> Result<Option<AccountSummary>, SafeError> {
+ self.inner.find_account(public_key)
+ }
+
+ fn insert_account(&self, account: &AccountSummary) -> Result<(), SafeError> {
+ self.inner.insert_account(account)
+ }
+
+ fn update_account(&self, account: &AccountSummary) -> Result<(), SafeError> {
+ self.inner.update_account(account)
+ }
+
+ fn remove_account(&self, public_key: PublicKey) -> Result<(), SafeError> {
+ self.inner.remove_account(public_key)
+ }
+ }
+
+ impl AppStateRepository for FailingSelectionRepository {
+ fn load_selected_account(&self) -> Result<Option<PublicKey>, SafeError> {
+ self.inner.load_selected_account()
+ }
+
+ fn save_selected_account(&self, public_key: Option<PublicKey>) -> Result<(), SafeError> {
+ if self.fail_next_selection.swap(false, Ordering::SeqCst) {
+ return Err(SafeError::new(
+ SafeErrorCode::StorageUnavailable,
+ SafeMessage::new("The test selection repository is unavailable."),
+ ));
+ }
+ self.inner.save_selected_account(public_key)
+ }
+ }
+
+ impl AccountRepository for FailingInsertRepository {
+ fn list_accounts(&self) -> Result<Vec<AccountSummary>, SafeError> {
+ self.inner.list_accounts()
+ }
+
+ fn find_account(&self, public_key: PublicKey) -> Result<Option<AccountSummary>, SafeError> {
+ self.inner.find_account(public_key)
+ }
+
+ fn insert_account(&self, _account: &AccountSummary) -> Result<(), SafeError> {
+ Err(SafeError::new(
+ SafeErrorCode::StorageUnavailable,
+ SafeMessage::new("The test account repository is unavailable."),
+ ))
+ }
+
+ fn update_account(&self, account: &AccountSummary) -> Result<(), SafeError> {
+ self.inner.update_account(account)
+ }
+
+ fn remove_account(&self, public_key: PublicKey) -> Result<(), SafeError> {
+ self.inner.remove_account(public_key)
+ }
+ }
+
+ impl AppStateRepository for FailingInsertRepository {
+ fn load_selected_account(&self) -> Result<Option<PublicKey>, SafeError> {
+ self.inner.load_selected_account()
+ }
+
+ fn save_selected_account(&self, public_key: Option<PublicKey>) -> Result<(), SafeError> {
+ self.inner.save_selected_account(public_key)
+ }
+ }
+
+ #[test]
+ fn generate_account_stores_selects_and_returns_one_time_nsec_without_activation() {
+ let core = AppCore::in_memory(RelayConfiguration::default());
+ let accounts = InMemoryAccountRepository::default();
+ let secrets = InMemorySecretStore::default();
+ let journal = InMemoryOperationJournal::default();
+ core.bootstrap().expect("bootstrap");
+
+ let receipt = core
+ .generate_account(&accounts, &accounts, &secrets, &journal, &FixedClock)
+ .expect("generate");
+ let public_key = receipt.account().public_key();
+ assert_eq!(public_key.to_hex().len(), 64);
+ assert!(secrets.contains(public_key).expect("credential"));
+ assert_eq!(
+ accounts.load_selected_account().expect("selection"),
+ Some(public_key)
+ );
+ assert_eq!(core.snapshot().selected_account(), Some(public_key));
+ assert_eq!(core.snapshot().session(), SessionState::SignedOut);
+ assert!(core.snapshot().active_account().is_none());
+ assert_eq!(receipt.generated_nsec().with_exposed_secret(str::len), 63);
+ assert!(!format!("{:?}", core.snapshot()).contains("nsec1"));
+ }
+
+ #[test]
+ fn import_secret_key_accepts_nsec_and_hex_without_exposing_or_activating() {
+ for input in [
+ "nsec1vl029mgpspedva04g90vltkh6fvh240zqtv9k0t9af8935ke9laqsnlfe5",
+ "7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7",
+ ] {
+ let core = AppCore::in_memory(RelayConfiguration::default());
+ let accounts = InMemoryAccountRepository::default();
+ let secrets = InMemorySecretStore::default();
+ let journal = InMemoryOperationJournal::default();
+ core.bootstrap().expect("bootstrap");
+ let receipt = core
+ .import_secret_key(
+ SecretKeyInput::parse(input.to_owned()).expect("input"),
+ &accounts,
+ &accounts,
+ &secrets,
+ &journal,
+ &FixedClock,
+ )
+ .expect("import");
+ let public_key = receipt.account().public_key();
+ assert!(secrets.contains(public_key).expect("credential"));
+ assert_eq!(core.snapshot().selected_account(), Some(public_key));
+ assert_eq!(core.snapshot().session(), SessionState::SignedOut);
+ assert!(!format!("{:?}", core.snapshot()).contains(input));
+ }
+ }
+
+ #[test]
+ fn import_secret_key_rejects_invalid_nsec_checksum_before_persistence() {
+ let core = AppCore::in_memory(RelayConfiguration::default());
+ let accounts = InMemoryAccountRepository::default();
+ let secrets = InMemorySecretStore::default();
+ let journal = InMemoryOperationJournal::default();
+ core.bootstrap().expect("bootstrap");
+ let input = SecretKeyInput::parse(
+ "nsec1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq".to_owned(),
+ )
+ .expect("domain shape");
+ let error = core
+ .import_secret_key(input, &accounts, &accounts, &secrets, &journal, &FixedClock)
+ .expect_err("invalid import");
+ assert_eq!(error.code(), SafeErrorCode::InvalidSecretKey);
+ assert!(core.snapshot().accounts().is_empty());
+ }
+
+ #[test]
+ fn duplicate_import_preserves_existing_credential_and_snapshot() {
+ let core = AppCore::in_memory(RelayConfiguration::default());
+ let accounts = InMemoryAccountRepository::default();
+ let secrets = InMemorySecretStore::default();
+ let journal = InMemoryOperationJournal::default();
+ core.bootstrap().expect("bootstrap");
+ let import = || {
+ SecretKeyInput::parse(
+ "7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7".to_owned(),
+ )
+ .expect("input")
+ };
+ core.import_secret_key(
+ import(),
+ &accounts,
+ &accounts,
+ &secrets,
+ &journal,
+ &FixedClock,
+ )
+ .expect("first import");
+ let before = core.snapshot();
+ let error = core
+ .import_secret_key(
+ import(),
+ &accounts,
+ &accounts,
+ &secrets,
+ &journal,
+ &FixedClock,
+ )
+ .expect_err("duplicate");
+ assert_eq!(error.code(), SafeErrorCode::AccountAlreadyExists);
+ assert_eq!(core.snapshot(), before);
+ assert_eq!(core.snapshot().accounts().len(), 1);
+ }
+
+ #[test]
+ fn duplicate_import_repairs_only_explicit_missing_credential_account() {
+ let core = AppCore::in_memory(RelayConfiguration::default());
+ let accounts = InMemoryAccountRepository::default();
+ let secrets = InMemorySecretStore::default();
+ let journal = InMemoryOperationJournal::default();
+ core.bootstrap().expect("bootstrap");
+ let input = || {
+ SecretKeyInput::parse(
+ "7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7".to_owned(),
+ )
+ .expect("input")
+ };
+ let imported = radroots_studio_nostr::import_secret(input()).expect("derive");
+ let (public_key, npub, _) = imported.into_parts();
+ let missing = AccountSummary::new(
+ AccountIdentity::verify(public_key, npub.as_str().to_owned()).expect("identity"),
+ LocalSignerBinding::new(public_key, BindingAvailability::CredentialMissing),
+ None,
+ AccountCreatedAt::new(FixedClock.now()),
+ None,
+ )
+ .expect("missing account");
+ accounts.insert_account(&missing).expect("missing metadata");
+ accounts
+ .save_selected_account(Some(public_key))
+ .expect("selection");
+ core.apply_transition(StateTransition::ReplaceRegistry {
+ accounts: vec![missing],
+ selected: Some(public_key),
+ })
+ .expect("registry");
+
+ let receipt = core
+ .import_secret_key(
+ input(),
+ &accounts,
+ &accounts,
+ &secrets,
+ &journal,
+ &FixedClock,
+ )
+ .expect("repair");
+ assert_eq!(
+ receipt.account().signer().availability(),
+ BindingAvailability::Available
+ );
+ assert!(secrets.contains(public_key).expect("credential"));
+ assert_eq!(core.snapshot().accounts().len(), 1);
+ }
+
+ #[test]
+ fn account_transaction_publishes_nothing_when_credential_write_fails() {
+ let core = AppCore::in_memory(RelayConfiguration::default());
+ let accounts = InMemoryAccountRepository::default();
+ let secrets = FailureSecretStore::default();
+ let journal = InMemoryOperationJournal::default();
+ core.bootstrap().expect("bootstrap");
+ secrets.fail_next(SecretStoreOperation::Put);
+
+ let error = core
+ .generate_account(&accounts, &accounts, &secrets, &journal, &FixedClock)
+ .err()
+ .expect("credential failure");
+ assert_eq!(error.code(), SafeErrorCode::KeyringUnavailable);
+ assert!(core.snapshot().accounts().is_empty());
+ assert!(
+ journal
+ .list_pending_operations()
+ .expect("journal")
+ .is_empty()
+ );
+ }
+
+ #[test]
+ fn account_transaction_removes_written_credential_when_metadata_fails() {
+ let core = AppCore::in_memory(RelayConfiguration::default());
+ let accounts = FailingInsertRepository::default();
+ let secrets = FailureSecretStore::default();
+ let journal = InMemoryOperationJournal::default();
+ core.bootstrap().expect("bootstrap");
+
+ let error = core
+ .generate_account(&accounts, &accounts, &secrets, &journal, &FixedClock)
+ .err()
+ .expect("metadata failure");
+ assert_eq!(error.code(), SafeErrorCode::StorageUnavailable);
+ let calls = secrets.calls();
+ assert_eq!(calls[0].operation(), SecretStoreOperation::Put);
+ assert_eq!(calls[1].operation(), SecretStoreOperation::Delete);
+ assert_eq!(calls[0].public_key(), calls[1].public_key());
+ assert!(core.snapshot().accounts().is_empty());
+ assert!(
+ journal
+ .list_pending_operations()
+ .expect("journal")
+ .is_empty()
+ );
+ }
+
+ #[test]
+ fn account_transaction_rolls_back_metadata_and_credential_when_selection_fails() {
+ let core = AppCore::in_memory(RelayConfiguration::default());
+ let accounts = FailingSelectionRepository::default();
+ let secrets = FailureSecretStore::default();
+ let journal = InMemoryOperationJournal::default();
+ core.bootstrap().expect("bootstrap");
+ accounts.fail_next_selection.store(true, Ordering::SeqCst);
+
+ let error = core
+ .generate_account(&accounts, &accounts, &secrets, &journal, &FixedClock)
+ .err()
+ .expect("selection failure");
+
+ assert_eq!(error.code(), SafeErrorCode::StorageUnavailable);
+ assert!(accounts.list_accounts().expect("accounts").is_empty());
+ assert_eq!(accounts.load_selected_account().expect("selection"), None);
+ let calls = secrets.calls();
+ assert_eq!(calls[0].operation(), SecretStoreOperation::Put);
+ assert_eq!(calls[1].operation(), SecretStoreOperation::Delete);
+ assert_eq!(calls[0].public_key(), calls[1].public_key());
+ assert!(core.snapshot().accounts().is_empty());
+ assert!(
+ journal
+ .list_pending_operations()
+ .expect("journal")
+ .is_empty()
+ );
+ }
+
+ #[test]
+ fn account_transaction_retains_non_secret_journal_when_compensation_fails() {
+ let core = AppCore::in_memory(RelayConfiguration::default());
+ let accounts = FailingInsertRepository::default();
+ let secrets = FailureSecretStore::default();
+ let journal = InMemoryOperationJournal::default();
+ core.bootstrap().expect("bootstrap");
+ secrets.fail_next(SecretStoreOperation::Delete);
+
+ let error = core
+ .generate_account(&accounts, &accounts, &secrets, &journal, &FixedClock)
+ .err()
+ .expect("recovery required");
+ assert_eq!(
+ error.code(),
+ SafeErrorCode::PendingOperationRecoveryRequired
+ );
+ let pending = journal.list_pending_operations().expect("journal");
+ assert_eq!(pending.len(), 1);
+ assert_eq!(
+ pending[0].phase(),
+ AccountOperationPhase::CompensationPending
+ );
+ assert!(!format!("{pending:?}").contains("nsec1"));
+ assert!(core.snapshot().accounts().is_empty());
+ }
+
+ #[test]
+ fn select_account_persists_existing_choice_without_activating() {
+ let core = AppCore::in_memory(RelayConfiguration::default());
+ let accounts = InMemoryAccountRepository::default();
+ let secrets = InMemorySecretStore::default();
+ let journal = InMemoryOperationJournal::default();
+ core.bootstrap().expect("bootstrap");
+ let first = core
+ .generate_account(&accounts, &accounts, &secrets, &journal, &FixedClock)
+ .expect("first")
+ .account()
+ .public_key();
+ core.generate_account(&accounts, &accounts, &secrets, &journal, &FixedClock)
+ .expect("second");
+
+ let selected = core
+ .select_account(first, &accounts, &accounts)
+ .expect("select first");
+ assert_eq!(selected.selected_account(), Some(first));
+ assert_eq!(selected.session(), SessionState::SignedOut);
+ assert!(selected.active_account().is_none());
+ assert_eq!(
+ accounts.load_selected_account().expect("saved"),
+ Some(first)
+ );
+ let missing = core
+ .select_account(PublicKey::from_bytes([0xff; 32]), &accounts, &accounts)
+ .expect_err("missing account");
+ assert_eq!(missing.code(), SafeErrorCode::AccountNotFound);
+ assert_eq!(core.snapshot(), selected);
+ }
+
+ #[test]
+ fn remove_account_requires_fresh_single_use_confirmation_and_selects_next_fallback() {
+ let core = AppCore::in_memory(RelayConfiguration::default());
+ let accounts = InMemoryAccountRepository::default();
+ let secrets = InMemorySecretStore::default();
+ let journal = InMemoryOperationJournal::default();
+ core.bootstrap().expect("bootstrap");
+ let first = core
+ .generate_account(&accounts, &accounts, &secrets, &journal, &FixedClock)
+ .expect("first")
+ .account()
+ .public_key();
+ let second = core
+ .generate_account(&accounts, &accounts, &secrets, &journal, &FixedClock)
+ .expect("second")
+ .account()
+ .public_key();
+ core.select_account(first, &accounts, &accounts)
+ .expect("select first");
+ let stale = core
+ .request_account_removal(first, &FixedClock)
+ .expect("stale token");
+ core.select_account(second, &accounts, &accounts)
+ .expect("change revision");
+ let stale_error = core
+ .confirm_account_removal(stale, &accounts, &accounts, &secrets, &journal, &FixedClock)
+ .expect_err("stale token");
+ assert_eq!(stale_error.code(), SafeErrorCode::InvalidApplicationState);
+ assert_eq!(core.snapshot().accounts().len(), 2);
+
+ core.select_account(first, &accounts, &accounts)
+ .expect("reselect first");
+ let token = core
+ .request_account_removal(first, &FixedClock)
+ .expect("token");
+ let removed = core
+ .confirm_account_removal(token, &accounts, &accounts, &secrets, &journal, &FixedClock)
+ .expect("remove");
+ assert_eq!(removed.accounts().len(), 1);
+ assert_eq!(removed.selected_account(), Some(second));
+ assert!(!secrets.contains(first).expect("credential removed"));
+ assert_eq!(removed.session(), SessionState::SignedOut);
+ }
+
+ #[test]
+ fn removal_preflight_reports_impact_expires_and_can_be_cancelled() {
+ let core = AppCore::in_memory(RelayConfiguration::default());
+ let accounts = InMemoryAccountRepository::default();
+ let secrets = InMemorySecretStore::default();
+ let journal = InMemoryOperationJournal::default();
+ core.bootstrap().expect("bootstrap");
+ let account = core
+ .generate_account(&accounts, &accounts, &secrets, &journal, &FixedClock)
+ .expect("account")
+ .account()
+ .public_key();
+ let expired = core
+ .request_account_removal(account, &FixedClock)
+ .expect("plan");
+ assert!(expired.impact().deletes_local_credential());
+ assert!(!expired.impact().signs_out());
+ assert!(
+ core.confirm_account_removal(
+ expired, &accounts, &accounts, &secrets, &journal, &LateClock,
+ )
+ .is_err()
+ );
+ let cancelled = core
+ .request_account_removal(account, &FixedClock)
+ .expect("replacement plan");
+ assert!(core.cancel_account_removal(cancelled));
+ assert_eq!(core.snapshot().accounts().len(), 1);
+ }
+}
diff --git a/crates/studio_application/src/actor.rs b/crates/studio_application/src/actor.rs
@@ -0,0 +1,785 @@
+use std::num::{NonZeroU64, NonZeroUsize};
+use std::time::Instant;
+
+use radroots_studio_domain::{
+ AccountIdentity, BindingAvailability, LocalSignerBinding, PublicKey, SafeError, SafeErrorCode,
+ SafeMessage,
+};
+use tokio::sync::{mpsc, oneshot};
+
+use crate::SnapshotRevision;
+
+#[derive(Clone, Copy, Debug, Default, Eq, Hash, Ord, PartialEq, PartialOrd)]
+pub struct SessionGeneration(u64);
+
+impl SessionGeneration {
+ #[must_use]
+ pub const fn initial() -> Self {
+ Self(0)
+ }
+
+ #[must_use]
+ pub const fn from_value(value: u64) -> Self {
+ Self(value)
+ }
+
+ #[must_use]
+ pub const fn value(self) -> u64 {
+ self.0
+ }
+
+ #[must_use]
+ pub const fn next(self) -> Option<Self> {
+ match self.0.checked_add(1) {
+ Some(value) => Some(Self(value)),
+ None => None,
+ }
+ }
+}
+
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct ForegroundSessionBinding {
+ identity: AccountIdentity,
+ signer: LocalSignerBinding,
+ generation: SessionGeneration,
+}
+
+impl ForegroundSessionBinding {
+ /// Binds one foreground session to a ready local signer and generation.
+ ///
+ /// # Errors
+ ///
+ /// Returns a safe state error when account and binding differ or when the
+ /// signer is unavailable.
+ pub fn new(
+ identity: AccountIdentity,
+ signer: LocalSignerBinding,
+ generation: SessionGeneration,
+ ) -> Result<Self, SafeError> {
+ if identity.public_key() != signer.account()
+ || signer.availability() != BindingAvailability::Available
+ {
+ return Err(invalid_foreground_session());
+ }
+ Ok(Self {
+ identity,
+ signer,
+ generation,
+ })
+ }
+
+ #[must_use]
+ pub const fn identity(&self) -> &AccountIdentity {
+ &self.identity
+ }
+
+ #[must_use]
+ pub const fn signer(&self) -> LocalSignerBinding {
+ self.signer
+ }
+
+ #[must_use]
+ pub const fn generation(&self) -> SessionGeneration {
+ self.generation
+ }
+}
+
+const fn invalid_foreground_session() -> SafeError {
+ SafeError::new(
+ SafeErrorCode::InvalidApplicationState,
+ SafeMessage::new("The foreground session binding is invalid."),
+ )
+}
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub struct TaskCorrelation {
+ request_id: RequestId,
+ account: PublicKey,
+ binding: LocalSignerBinding,
+ expected_revision: SnapshotRevision,
+ session_generation: SessionGeneration,
+}
+
+impl TaskCorrelation {
+ #[must_use]
+ pub const fn new(
+ request_id: RequestId,
+ account: PublicKey,
+ binding: LocalSignerBinding,
+ expected_revision: SnapshotRevision,
+ session_generation: SessionGeneration,
+ ) -> Self {
+ Self {
+ request_id,
+ account,
+ binding,
+ expected_revision,
+ session_generation,
+ }
+ }
+
+ #[must_use]
+ pub const fn request_id(self) -> RequestId {
+ self.request_id
+ }
+
+ #[must_use]
+ pub const fn account(self) -> PublicKey {
+ self.account
+ }
+
+ #[must_use]
+ pub const fn binding(self) -> LocalSignerBinding {
+ self.binding
+ }
+
+ #[must_use]
+ pub const fn expected_revision(self) -> SnapshotRevision {
+ self.expected_revision
+ }
+
+ #[must_use]
+ pub const fn session_generation(self) -> SessionGeneration {
+ self.session_generation
+ }
+}
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub enum RuntimeLifecycle {
+ Opening,
+ CompatibilityChecking,
+ AcquiringOwnership,
+ Migrating,
+ Recovering,
+ Ready,
+ Degraded(SafeError),
+ Blocked(SafeError),
+ ShuttingDown,
+ Closed,
+ Fatal(SafeError),
+}
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub enum RuntimeCommandClass {
+ Observe,
+ MutateLocalState,
+ UseCredential,
+ UseRelay,
+ RetryOpening,
+ Shutdown,
+}
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub struct LifecycleGate {
+ lifecycle: RuntimeLifecycle,
+}
+
+impl Default for LifecycleGate {
+ fn default() -> Self {
+ Self::opening()
+ }
+}
+
+impl LifecycleGate {
+ #[must_use]
+ pub const fn opening() -> Self {
+ Self {
+ lifecycle: RuntimeLifecycle::Opening,
+ }
+ }
+
+ #[must_use]
+ pub const fn lifecycle(self) -> RuntimeLifecycle {
+ self.lifecycle
+ }
+
+ #[must_use]
+ pub const fn allows(self, command: RuntimeCommandClass) -> bool {
+ match self.lifecycle {
+ RuntimeLifecycle::Opening
+ | RuntimeLifecycle::CompatibilityChecking
+ | RuntimeLifecycle::AcquiringOwnership
+ | RuntimeLifecycle::Migrating
+ | RuntimeLifecycle::Recovering => {
+ matches!(
+ command,
+ RuntimeCommandClass::Observe | RuntimeCommandClass::Shutdown
+ )
+ }
+ RuntimeLifecycle::Ready => !matches!(command, RuntimeCommandClass::RetryOpening),
+ RuntimeLifecycle::Degraded(_) => !matches!(
+ command,
+ RuntimeCommandClass::UseRelay | RuntimeCommandClass::RetryOpening
+ ),
+ RuntimeLifecycle::Blocked(_) => matches!(
+ command,
+ RuntimeCommandClass::Observe
+ | RuntimeCommandClass::RetryOpening
+ | RuntimeCommandClass::Shutdown
+ ),
+ RuntimeLifecycle::ShuttingDown => matches!(command, RuntimeCommandClass::Observe),
+ RuntimeLifecycle::Closed | RuntimeLifecycle::Fatal(_) => false,
+ }
+ }
+
+ /// Advances the required open sequence to compatibility checking.
+ ///
+ /// # Errors
+ ///
+ /// Returns a safe lifecycle error when the stage is out of order.
+ pub fn begin_compatibility_check(&mut self) -> Result<(), SafeError> {
+ self.advance(
+ RuntimeLifecycle::Opening,
+ RuntimeLifecycle::CompatibilityChecking,
+ )
+ }
+
+ /// Records compatibility acceptance and begins ownership acquisition.
+ ///
+ /// # Errors
+ ///
+ /// Returns a safe lifecycle error when the stage is out of order.
+ pub fn compatibility_accepted(&mut self) -> Result<(), SafeError> {
+ self.advance(
+ RuntimeLifecycle::CompatibilityChecking,
+ RuntimeLifecycle::AcquiringOwnership,
+ )
+ }
+
+ /// Records exclusive ownership and begins migration.
+ ///
+ /// # Errors
+ ///
+ /// Returns a safe lifecycle error when the stage is out of order.
+ pub fn ownership_acquired(&mut self) -> Result<(), SafeError> {
+ self.advance(
+ RuntimeLifecycle::AcquiringOwnership,
+ RuntimeLifecycle::Migrating,
+ )
+ }
+
+ /// Records migration completion and begins recovery.
+ ///
+ /// # Errors
+ ///
+ /// Returns a safe lifecycle error when the stage is out of order.
+ pub fn migration_complete(&mut self) -> Result<(), SafeError> {
+ self.advance(RuntimeLifecycle::Migrating, RuntimeLifecycle::Recovering)
+ }
+
+ /// Records recovery completion and admits normal commands.
+ ///
+ /// # Errors
+ ///
+ /// Returns a safe lifecycle error when the stage is out of order.
+ pub fn recovery_complete(&mut self) -> Result<(), SafeError> {
+ self.advance(RuntimeLifecycle::Recovering, RuntimeLifecycle::Ready)
+ }
+
+ pub fn block(&mut self, error: SafeError) {
+ self.lifecycle = RuntimeLifecycle::Blocked(error);
+ }
+
+ pub fn fail(&mut self, error: SafeError) {
+ self.lifecycle = RuntimeLifecycle::Fatal(error);
+ }
+
+ /// Moves a ready runtime into a nonfatal degraded state.
+ ///
+ /// # Errors
+ ///
+ /// Returns a safe lifecycle error when the runtime is not ready.
+ pub fn degrade(&mut self, error: SafeError) -> Result<(), SafeError> {
+ self.advance(RuntimeLifecycle::Ready, RuntimeLifecycle::Degraded(error))
+ }
+
+ /// Restores local and relay command availability after degradation.
+ ///
+ /// # Errors
+ ///
+ /// Returns a safe lifecycle error when the runtime is not degraded.
+ pub fn restore_ready(&mut self) -> Result<(), SafeError> {
+ if !matches!(self.lifecycle, RuntimeLifecycle::Degraded(_)) {
+ return Err(invalid_lifecycle_transition());
+ }
+ self.lifecycle = RuntimeLifecycle::Ready;
+ Ok(())
+ }
+
+ /// Begins actor-owned shutdown.
+ ///
+ /// # Errors
+ ///
+ /// Returns a safe lifecycle error after shutdown or close has begun.
+ pub fn begin_shutdown(&mut self) -> Result<(), SafeError> {
+ if matches!(
+ self.lifecycle,
+ RuntimeLifecycle::ShuttingDown | RuntimeLifecycle::Closed
+ ) {
+ return Err(invalid_lifecycle_transition());
+ }
+ self.lifecycle = RuntimeLifecycle::ShuttingDown;
+ Ok(())
+ }
+
+ /// Completes actor-owned shutdown.
+ ///
+ /// # Errors
+ ///
+ /// Returns a safe lifecycle error unless shutdown already began.
+ pub fn finish_shutdown(&mut self) -> Result<(), SafeError> {
+ self.advance(RuntimeLifecycle::ShuttingDown, RuntimeLifecycle::Closed)
+ }
+
+ fn advance(
+ &mut self,
+ expected: RuntimeLifecycle,
+ next: RuntimeLifecycle,
+ ) -> Result<(), SafeError> {
+ if self.lifecycle != expected {
+ return Err(invalid_lifecycle_transition());
+ }
+ self.lifecycle = next;
+ Ok(())
+ }
+}
+
+const fn invalid_lifecycle_transition() -> SafeError {
+ SafeError::new(
+ radroots_studio_domain::SafeErrorCode::InvalidApplicationState,
+ radroots_studio_domain::SafeMessage::new("The runtime lifecycle transition is invalid."),
+ )
+}
+
+#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
+pub struct RequestId(NonZeroU64);
+
+impl RequestId {
+ #[must_use]
+ pub const fn new(value: u64) -> Option<Self> {
+ match NonZeroU64::new(value) {
+ Some(value) => Some(Self(value)),
+ None => None,
+ }
+ }
+
+ #[must_use]
+ pub const fn get(self) -> u64 {
+ self.0.get()
+ }
+}
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub struct CommandContext {
+ request_id: RequestId,
+ expected_revision: Option<SnapshotRevision>,
+ deadline: Instant,
+}
+
+impl CommandContext {
+ #[must_use]
+ pub const fn new(
+ request_id: RequestId,
+ expected_revision: Option<SnapshotRevision>,
+ deadline: Instant,
+ ) -> Self {
+ Self {
+ request_id,
+ expected_revision,
+ deadline,
+ }
+ }
+
+ #[must_use]
+ pub const fn request_id(self) -> RequestId {
+ self.request_id
+ }
+
+ #[must_use]
+ pub const fn expected_revision(self) -> Option<SnapshotRevision> {
+ self.expected_revision
+ }
+
+ #[must_use]
+ pub const fn deadline(self) -> Instant {
+ self.deadline
+ }
+
+ #[must_use]
+ pub fn is_expired(self, now: Instant) -> bool {
+ now >= self.deadline
+ }
+}
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub enum CommandRejection {
+ MailboxSaturated,
+}
+
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub enum CommandResult<T> {
+ Completed(T),
+ Rejected(CommandRejection),
+ Conflicted { current_revision: SnapshotRevision },
+ TimedOut,
+ Closed,
+ Failed(SafeError),
+}
+
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct CommandReceipt<T> {
+ request_id: RequestId,
+ result: CommandResult<T>,
+}
+
+impl<T> CommandReceipt<T> {
+ #[must_use]
+ pub const fn new(request_id: RequestId, result: CommandResult<T>) -> Self {
+ Self { request_id, result }
+ }
+
+ #[must_use]
+ pub const fn request_id(&self) -> RequestId {
+ self.request_id
+ }
+
+ #[must_use]
+ pub const fn result(&self) -> &CommandResult<T> {
+ &self.result
+ }
+
+ #[must_use]
+ pub fn into_result(self) -> CommandResult<T> {
+ self.result
+ }
+}
+
+pub struct CommandTicket<T> {
+ request_id: RequestId,
+ receiver: oneshot::Receiver<CommandReceipt<T>>,
+}
+
+impl<T> CommandTicket<T> {
+ #[must_use]
+ pub const fn request_id(&self) -> RequestId {
+ self.request_id
+ }
+
+ pub async fn receipt(self) -> CommandReceipt<T> {
+ self.receiver
+ .await
+ .unwrap_or_else(|_| CommandReceipt::new(self.request_id, CommandResult::Closed))
+ }
+}
+
+pub enum CommandSubmission<T> {
+ Accepted(CommandTicket<T>),
+ Rejected(CommandReceipt<T>),
+}
+
+impl<T> CommandSubmission<T> {
+ #[must_use]
+ pub const fn request_id(&self) -> RequestId {
+ match self {
+ Self::Accepted(ticket) => ticket.request_id(),
+ Self::Rejected(receipt) => receipt.request_id(),
+ }
+ }
+}
+
+pub struct CommandEnvelope<C, R> {
+ context: CommandContext,
+ command: C,
+ reply: oneshot::Sender<CommandReceipt<R>>,
+}
+
+impl<C, R> CommandEnvelope<C, R> {
+ #[must_use]
+ pub const fn context(&self) -> CommandContext {
+ self.context
+ }
+
+ #[must_use]
+ pub const fn command(&self) -> &C {
+ &self.command
+ }
+
+ #[must_use]
+ pub fn into_parts(self) -> (CommandContext, C, oneshot::Sender<CommandReceipt<R>>) {
+ (self.context, self.command, self.reply)
+ }
+}
+
+pub struct ActorMailbox<C, R> {
+ sender: mpsc::Sender<CommandEnvelope<C, R>>,
+}
+
+impl<C, R> Clone for ActorMailbox<C, R> {
+ fn clone(&self) -> Self {
+ Self {
+ sender: self.sender.clone(),
+ }
+ }
+}
+
+impl<C, R> ActorMailbox<C, R> {
+ #[must_use]
+ pub fn bounded(capacity: NonZeroUsize) -> (Self, mpsc::Receiver<CommandEnvelope<C, R>>) {
+ let (sender, receiver) = mpsc::channel(capacity.get());
+ (Self { sender }, receiver)
+ }
+
+ #[must_use]
+ pub fn available_capacity(&self) -> usize {
+ self.sender.capacity()
+ }
+
+ #[must_use]
+ pub fn submit(&self, context: CommandContext, command: C) -> CommandSubmission<R> {
+ let request_id = context.request_id();
+ if context.is_expired(Instant::now()) {
+ return CommandSubmission::Rejected(CommandReceipt::new(
+ request_id,
+ CommandResult::TimedOut,
+ ));
+ }
+ let (reply, receiver) = oneshot::channel();
+ let envelope = CommandEnvelope {
+ context,
+ command,
+ reply,
+ };
+ match self.sender.try_send(envelope) {
+ Ok(()) => CommandSubmission::Accepted(CommandTicket {
+ request_id,
+ receiver,
+ }),
+ Err(mpsc::error::TrySendError::Full(_)) => {
+ CommandSubmission::Rejected(CommandReceipt::new(
+ request_id,
+ CommandResult::Rejected(CommandRejection::MailboxSaturated),
+ ))
+ }
+ Err(mpsc::error::TrySendError::Closed(_)) => {
+ CommandSubmission::Rejected(CommandReceipt::new(request_id, CommandResult::Closed))
+ }
+ }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use std::num::NonZeroUsize;
+ use std::time::{Duration, Instant};
+
+ use radroots_studio_domain::{
+ AccountIdentity, BindingAvailability, LocalSignerBinding, PublicKey,
+ };
+
+ use crate::{
+ ActorMailbox, CommandContext, CommandReceipt, CommandRejection, CommandResult,
+ CommandSubmission, ForegroundSessionBinding, LifecycleGate, RequestId, RuntimeCommandClass,
+ RuntimeLifecycle, SessionGeneration,
+ };
+
+ fn context(id: u64) -> CommandContext {
+ CommandContext::new(
+ RequestId::new(id).expect("nonzero request"),
+ None,
+ Instant::now() + Duration::from_secs(1),
+ )
+ }
+
+ #[test]
+ fn foreground_session_requires_matching_available_binding_and_generation() {
+ let public_key = PublicKey::from_bytes([3_u8; 32]);
+ let identity = AccountIdentity::derive(public_key).expect("identity");
+ let generation = SessionGeneration::from_value(4);
+ let session = ForegroundSessionBinding::new(
+ identity.clone(),
+ LocalSignerBinding::new(public_key, BindingAvailability::Available),
+ generation,
+ )
+ .expect("session");
+ assert_eq!(session.identity(), &identity);
+ assert_eq!(session.signer().account(), public_key);
+ assert_eq!(session.generation(), generation);
+
+ let correlation = super::TaskCorrelation::new(
+ RequestId::new(8).expect("request"),
+ public_key,
+ session.signer(),
+ crate::SnapshotRevision::from_value(9),
+ generation,
+ );
+ assert_eq!(correlation.request_id().get(), 8);
+ assert_eq!(correlation.account(), public_key);
+ assert_eq!(correlation.binding(), session.signer());
+ assert_eq!(correlation.expected_revision().value(), 9);
+ assert_eq!(correlation.session_generation(), generation);
+
+ assert!(
+ ForegroundSessionBinding::new(
+ identity.clone(),
+ LocalSignerBinding::new(
+ PublicKey::from_bytes([4_u8; 32]),
+ BindingAvailability::Available,
+ ),
+ generation,
+ )
+ .is_err()
+ );
+ assert!(
+ ForegroundSessionBinding::new(
+ identity,
+ LocalSignerBinding::new(public_key, BindingAvailability::CredentialMissing),
+ generation,
+ )
+ .is_err()
+ );
+ }
+
+ #[tokio::test]
+ async fn bounded_mailbox_accepts_one_and_rejects_saturation() {
+ let (mailbox, mut receiver) =
+ ActorMailbox::<u8, u8>::bounded(NonZeroUsize::new(1).expect("capacity"));
+ let CommandSubmission::Accepted(ticket) = mailbox.submit(context(1), 7) else {
+ panic!("first command must be accepted");
+ };
+ let CommandSubmission::Rejected(rejected) = mailbox.submit(context(2), 8) else {
+ panic!("second command must be rejected");
+ };
+ assert_eq!(
+ rejected.into_result(),
+ CommandResult::Rejected(CommandRejection::MailboxSaturated)
+ );
+
+ let envelope = receiver.recv().await.expect("command");
+ assert_eq!(envelope.context().request_id().get(), 1);
+ assert_eq!(*envelope.command(), 7);
+ let (context, command, reply) = envelope.into_parts();
+ reply
+ .send(CommandReceipt::new(
+ context.request_id(),
+ CommandResult::Completed(command + 1),
+ ))
+ .expect("ticket remains open");
+ assert_eq!(
+ ticket.receipt().await.into_result(),
+ CommandResult::Completed(8)
+ );
+ }
+
+ #[test]
+ fn expired_and_closed_mailboxes_reject_without_enqueuing() {
+ let (mailbox, receiver) =
+ ActorMailbox::<u8, u8>::bounded(NonZeroUsize::new(1).expect("capacity"));
+ let expired =
+ CommandContext::new(RequestId::new(1).expect("request"), None, Instant::now());
+ let CommandSubmission::Rejected(receipt) = mailbox.submit(expired, 1) else {
+ panic!("expired command must be rejected");
+ };
+ assert_eq!(receipt.into_result(), CommandResult::TimedOut);
+
+ drop(receiver);
+ let CommandSubmission::Rejected(receipt) = mailbox.submit(context(2), 2) else {
+ panic!("closed mailbox must be rejected");
+ };
+ assert_eq!(receipt.into_result(), CommandResult::Closed);
+ }
+
+ #[tokio::test]
+ async fn dropped_actor_reply_becomes_closed_receipt() {
+ let (mailbox, mut receiver) =
+ ActorMailbox::<u8, u8>::bounded(NonZeroUsize::new(1).expect("capacity"));
+ let CommandSubmission::Accepted(ticket) = mailbox.submit(context(1), 1) else {
+ panic!("command must be accepted");
+ };
+ drop(receiver.recv().await.expect("command"));
+
+ assert_eq!(ticket.receipt().await.into_result(), CommandResult::Closed);
+ }
+
+ #[test]
+ fn opening_sequence_gates_mutation_until_recovery_completes() {
+ let mut lifecycle = LifecycleGate::opening();
+ for expected in [
+ RuntimeLifecycle::Opening,
+ RuntimeLifecycle::CompatibilityChecking,
+ RuntimeLifecycle::AcquiringOwnership,
+ RuntimeLifecycle::Migrating,
+ RuntimeLifecycle::Recovering,
+ ] {
+ assert_eq!(lifecycle.lifecycle(), expected);
+ assert!(lifecycle.allows(RuntimeCommandClass::Observe));
+ assert!(lifecycle.allows(RuntimeCommandClass::Shutdown));
+ assert!(!lifecycle.allows(RuntimeCommandClass::MutateLocalState));
+ match expected {
+ RuntimeLifecycle::Opening => {
+ lifecycle
+ .begin_compatibility_check()
+ .expect("compatibility");
+ }
+ RuntimeLifecycle::CompatibilityChecking => {
+ lifecycle
+ .compatibility_accepted()
+ .expect("compatibility accepted");
+ }
+ RuntimeLifecycle::AcquiringOwnership => {
+ lifecycle.ownership_acquired().expect("ownership");
+ }
+ RuntimeLifecycle::Migrating => {
+ lifecycle.migration_complete().expect("migration");
+ }
+ RuntimeLifecycle::Recovering => {
+ lifecycle.recovery_complete().expect("recovery");
+ }
+ _ => unreachable!("opening states only"),
+ }
+ }
+ assert_eq!(lifecycle.lifecycle(), RuntimeLifecycle::Ready);
+ assert!(lifecycle.allows(RuntimeCommandClass::MutateLocalState));
+ assert!(lifecycle.allows(RuntimeCommandClass::UseCredential));
+ assert!(lifecycle.allows(RuntimeCommandClass::UseRelay));
+ }
+
+ #[test]
+ fn blocked_degraded_fatal_and_closed_states_fail_safe() {
+ let problem = radroots_studio_domain::SafeError::new(
+ radroots_studio_domain::SafeErrorCode::StorageUnavailable,
+ radroots_studio_domain::SafeMessage::new("The runtime is unavailable."),
+ );
+ let mut blocked = LifecycleGate::opening();
+ blocked.block(problem);
+ assert!(blocked.allows(RuntimeCommandClass::RetryOpening));
+ assert!(!blocked.allows(RuntimeCommandClass::MutateLocalState));
+
+ let mut degraded = LifecycleGate::opening();
+ degraded.begin_compatibility_check().expect("compatibility");
+ degraded.compatibility_accepted().expect("accepted");
+ degraded.ownership_acquired().expect("ownership");
+ degraded.migration_complete().expect("migration");
+ degraded.recovery_complete().expect("recovery");
+ degraded.degrade(problem).expect("degraded");
+ assert!(degraded.allows(RuntimeCommandClass::MutateLocalState));
+ assert!(!degraded.allows(RuntimeCommandClass::UseRelay));
+ degraded.restore_ready().expect("restored");
+
+ let mut fatal = LifecycleGate::opening();
+ fatal.fail(problem);
+ assert!(!fatal.allows(RuntimeCommandClass::Observe));
+ fatal.begin_shutdown().expect("fatal can close");
+ fatal.finish_shutdown().expect("closed");
+ assert_eq!(fatal.lifecycle(), RuntimeLifecycle::Closed);
+ assert!(!fatal.allows(RuntimeCommandClass::Shutdown));
+ }
+
+ #[test]
+ fn opening_stages_reject_out_of_order_and_repeated_transitions() {
+ let mut lifecycle = LifecycleGate::opening();
+ assert!(lifecycle.migration_complete().is_err());
+ lifecycle.begin_compatibility_check().expect("first stage");
+ assert!(lifecycle.begin_compatibility_check().is_err());
+ assert!(lifecycle.recovery_complete().is_err());
+ }
+}
diff --git a/crates/studio_application/src/app_core.rs b/crates/studio_application/src/app_core.rs
@@ -0,0 +1,300 @@
+use std::collections::BTreeMap;
+use std::sync::{Mutex, MutexGuard};
+
+use radroots_studio_domain::{PublicKey, SafeError, SafeErrorCode, SafeMessage, UnixTimestamp};
+
+use crate::{
+ AccountRepository, AppSnapshot, AppStateRepository, RelayConfiguration, SnapshotRevision,
+ StateMachine, StateTransition,
+};
+
+pub struct RemovalConfirmationToken {
+ id: u64,
+ public_key: PublicKey,
+ revision: SnapshotRevision,
+ expires_at: UnixTimestamp,
+ impact: RemovalImpact,
+}
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub struct RemovalImpact {
+ deletes_local_credential: bool,
+ signs_out: bool,
+}
+
+impl RemovalImpact {
+ #[must_use]
+ pub const fn deletes_local_credential(self) -> bool {
+ self.deletes_local_credential
+ }
+ #[must_use]
+ pub const fn signs_out(self) -> bool {
+ self.signs_out
+ }
+}
+
+impl RemovalConfirmationToken {
+ #[must_use]
+ pub const fn public_key(&self) -> PublicKey {
+ self.public_key
+ }
+ #[must_use]
+ pub const fn revision(&self) -> SnapshotRevision {
+ self.revision
+ }
+ #[must_use]
+ pub const fn expires_at(&self) -> UnixTimestamp {
+ self.expires_at
+ }
+ #[must_use]
+ pub const fn impact(&self) -> RemovalImpact {
+ self.impact
+ }
+}
+
+#[derive(Clone, Copy)]
+struct RemovalTokenState {
+ public_key: PublicKey,
+ revision: SnapshotRevision,
+ expires_at: UnixTimestamp,
+ impact: RemovalImpact,
+}
+
+struct CoreState {
+ state_machine: StateMachine,
+ removal_tokens: BTreeMap<u64, RemovalTokenState>,
+ next_removal_token: u64,
+}
+
+pub struct AppCore {
+ relay_configuration: RelayConfiguration,
+ state: Mutex<CoreState>,
+}
+
+impl AppCore {
+ #[must_use]
+ pub fn in_memory(relay_configuration: RelayConfiguration) -> Self {
+ Self {
+ relay_configuration,
+ state: Mutex::new(CoreState {
+ state_machine: StateMachine::booting(),
+ removal_tokens: BTreeMap::new(),
+ next_removal_token: 1,
+ }),
+ }
+ }
+
+ /// Moves the in-memory core from booting to an empty ready snapshot.
+ ///
+ /// # Errors
+ ///
+ /// Returns a safe application-state error if the ready snapshot invariant
+ /// cannot be constructed.
+ pub fn bootstrap(&self) -> Result<AppSnapshot, SafeError> {
+ self.apply_transition(StateTransition::Bootstrap)
+ }
+
+ /// Loads the durable public registry and selection into a signed-out snapshot.
+ ///
+ /// # Errors
+ ///
+ /// Returns the safe persistence error after publishing a fatal snapshot when
+ /// durable state cannot be read or violates application invariants.
+ pub fn bootstrap_from(
+ &self,
+ accounts: &(impl AccountRepository + ?Sized),
+ app_state: &(impl AppStateRepository + ?Sized),
+ ) -> Result<AppSnapshot, SafeError> {
+ let loaded = accounts.list_accounts().and_then(|accounts| {
+ app_state
+ .load_selected_account()
+ .map(|selected| (accounts, selected))
+ });
+ match loaded {
+ Ok((accounts, selected)) => {
+ self.apply_transition(StateTransition::BootstrapRegistry { accounts, selected })
+ }
+ Err(error) => {
+ self.apply_transition(StateTransition::Fatal(error))?;
+ Err(error)
+ }
+ }
+ }
+
+ #[must_use]
+ pub fn snapshot(&self) -> AppSnapshot {
+ self.lock_state().state_machine.snapshot().clone()
+ }
+
+ pub(crate) fn apply_transition(
+ &self,
+ transition: StateTransition,
+ ) -> Result<AppSnapshot, SafeError> {
+ self.lock_state()
+ .state_machine
+ .apply(transition, &self.relay_configuration)
+ }
+
+ pub(crate) fn issue_removal_token(
+ &self,
+ public_key: PublicKey,
+ now: UnixTimestamp,
+ ) -> Result<RemovalConfirmationToken, SafeError> {
+ let mut state = self.lock_state();
+ let Some(account) = state
+ .state_machine
+ .snapshot()
+ .accounts()
+ .iter()
+ .find(|account| account.public_key() == public_key)
+ else {
+ return Err(account_not_found());
+ };
+ let deletes_local_credential = account.signer().availability()
+ != radroots_studio_domain::BindingAvailability::CredentialMissing;
+ let id = state.next_removal_token;
+ state.next_removal_token = id.checked_add(1).ok_or_else(invalid_application_state)?;
+ let revision = state.state_machine.snapshot().revision();
+ let expires_at = UnixTimestamp::from_seconds(
+ now.as_seconds()
+ .checked_add(300)
+ .ok_or_else(invalid_application_state)?,
+ )
+ .ok_or_else(invalid_application_state)?;
+ let impact = RemovalImpact {
+ deletes_local_credential,
+ signs_out: state
+ .state_machine
+ .snapshot()
+ .active_account()
+ .is_some_and(|active| active.account().public_key() == public_key),
+ };
+ state.removal_tokens.insert(
+ id,
+ RemovalTokenState {
+ public_key,
+ revision,
+ expires_at,
+ impact,
+ },
+ );
+ Ok(RemovalConfirmationToken {
+ id,
+ public_key,
+ revision,
+ expires_at,
+ impact,
+ })
+ }
+
+ #[allow(clippy::needless_pass_by_value)]
+ pub(crate) fn consume_removal_token(
+ &self,
+ token: RemovalConfirmationToken,
+ now: UnixTimestamp,
+ ) -> Result<PublicKey, SafeError> {
+ let RemovalConfirmationToken {
+ id,
+ public_key,
+ revision,
+ expires_at,
+ impact,
+ } = token;
+ let mut state = self.lock_state();
+ let stored = state.removal_tokens.remove(&id);
+ if stored.is_none_or(|stored| {
+ stored.public_key != public_key
+ || stored.revision != revision
+ || stored.expires_at != expires_at
+ || stored.impact != impact
+ }) || state.state_machine.snapshot().revision() != revision
+ || now.as_seconds() > expires_at.as_seconds()
+ {
+ return Err(invalid_application_state());
+ }
+ Ok(public_key)
+ }
+
+ #[allow(clippy::needless_pass_by_value)]
+ pub(crate) fn cancel_removal_token(&self, token: RemovalConfirmationToken) -> bool {
+ self.lock_state().removal_tokens.remove(&token.id).is_some()
+ }
+
+ fn lock_state(&self) -> MutexGuard<'_, CoreState> {
+ self.state
+ .lock()
+ .unwrap_or_else(std::sync::PoisonError::into_inner)
+ }
+}
+
+const fn invalid_application_state() -> SafeError {
+ SafeError::new(
+ SafeErrorCode::InvalidApplicationState,
+ SafeMessage::new("The account removal confirmation is no longer valid."),
+ )
+}
+
+const fn account_not_found() -> SafeError {
+ SafeError::new(
+ SafeErrorCode::AccountNotFound,
+ SafeMessage::new("The account was not found."),
+ )
+}
+
+#[cfg(test)]
+mod tests {
+ use radroots_studio_domain::{
+ AccountCreatedAt, AccountIdentity, AccountSummary, BindingAvailability, LocalSignerBinding,
+ PublicKey, UnixTimestamp,
+ };
+
+ use crate::{AppCore, AppLifecycle, RelayConfiguration, StateTransition};
+
+ #[test]
+ fn bootstrap_is_idempotent_and_advances_only_once() {
+ let core = AppCore::in_memory(RelayConfiguration::default());
+ let ready = core.bootstrap().expect("bootstrap");
+ let repeated = core.bootstrap().expect("idempotent bootstrap");
+
+ assert_eq!(ready.lifecycle(), AppLifecycle::Ready);
+ assert_eq!(ready.revision().value(), 1);
+ assert_eq!(repeated, ready);
+ }
+
+ #[test]
+ fn core_instances_never_share_state() {
+ let first = AppCore::in_memory(RelayConfiguration::default());
+ let second = AppCore::in_memory(RelayConfiguration::default());
+
+ first.bootstrap().expect("first bootstrap");
+
+ assert_eq!(first.snapshot().revision().value(), 1);
+ assert_eq!(second.snapshot().revision().value(), 0);
+ }
+
+ #[test]
+ fn removal_impact_matches_missing_local_binding() {
+ let core = AppCore::in_memory(RelayConfiguration::default());
+ let public_key = PublicKey::from_bytes([9; 32]);
+ let account = AccountSummary::new(
+ AccountIdentity::derive(public_key).expect("identity"),
+ LocalSignerBinding::new(public_key, BindingAvailability::CredentialMissing),
+ None,
+ AccountCreatedAt::new(UnixTimestamp::from_seconds(1).expect("time")),
+ None,
+ )
+ .expect("account");
+ core.apply_transition(StateTransition::BootstrapRegistry {
+ accounts: vec![account],
+ selected: Some(public_key),
+ })
+ .expect("registry");
+
+ let removal = core
+ .issue_removal_token(public_key, UnixTimestamp::from_seconds(2).expect("time"))
+ .expect("removal");
+
+ assert!(!removal.impact().deletes_local_credential());
+ assert!(!removal.impact().signs_out());
+ }
+}
diff --git a/crates/studio_application/src/change_stream.rs b/crates/studio_application/src/change_stream.rs
@@ -0,0 +1,239 @@
+use std::collections::BTreeMap;
+use std::num::{NonZeroU64, NonZeroUsize};
+
+use tokio::sync::mpsc;
+
+use crate::{AppSnapshot, SnapshotRevision};
+
+#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
+pub struct ChangeSubscriptionId(NonZeroU64);
+
+impl ChangeSubscriptionId {
+ #[must_use]
+ pub const fn value(self) -> u64 {
+ self.0.get()
+ }
+}
+
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct SnapshotChange {
+ snapshot: AppSnapshot,
+ previous_revision: Option<SnapshotRevision>,
+}
+
+impl SnapshotChange {
+ #[must_use]
+ pub const fn revision(&self) -> SnapshotRevision {
+ self.snapshot.revision()
+ }
+
+ #[must_use]
+ pub const fn snapshot(&self) -> &AppSnapshot {
+ &self.snapshot
+ }
+
+ #[must_use]
+ pub fn into_snapshot(self) -> AppSnapshot {
+ self.snapshot
+ }
+
+ #[must_use]
+ pub const fn previous_revision(&self) -> Option<SnapshotRevision> {
+ self.previous_revision
+ }
+
+ #[must_use]
+ pub fn recovers_gap_after(&self, observed: SnapshotRevision) -> bool {
+ self.previous_revision
+ .is_some_and(|previous| previous != observed)
+ }
+}
+
+pub struct SnapshotChangeReceiver {
+ receiver: mpsc::Receiver<SnapshotChange>,
+}
+
+impl SnapshotChangeReceiver {
+ pub async fn receive(&mut self) -> Option<SnapshotChange> {
+ self.receiver.recv().await
+ }
+}
+
+pub struct OrderedSnapshotChanges {
+ latest: AppSnapshot,
+ next_subscription: u64,
+ subscribers: BTreeMap<ChangeSubscriptionId, mpsc::Sender<SnapshotChange>>,
+ closed: bool,
+}
+
+impl OrderedSnapshotChanges {
+ #[must_use]
+ pub fn new(initial_snapshot: AppSnapshot) -> Self {
+ Self {
+ latest: initial_snapshot,
+ next_subscription: 1,
+ subscribers: BTreeMap::new(),
+ closed: false,
+ }
+ }
+
+ #[must_use]
+ pub const fn last_revision(&self) -> SnapshotRevision {
+ self.latest.revision()
+ }
+
+ /// Registers a bounded consumer for future changes.
+ ///
+ /// # Errors
+ ///
+ /// Returns `None` if the subscription identifier space is exhausted.
+ pub fn subscribe(
+ &mut self,
+ capacity: NonZeroUsize,
+ ) -> Option<(ChangeSubscriptionId, SnapshotChangeReceiver)> {
+ if self.closed {
+ return None;
+ }
+ let id = ChangeSubscriptionId(NonZeroU64::new(self.next_subscription)?);
+ self.next_subscription = self.next_subscription.checked_add(1)?;
+ let (sender, receiver) = mpsc::channel(capacity.get());
+ sender
+ .try_send(SnapshotChange {
+ snapshot: self.latest.clone(),
+ previous_revision: None,
+ })
+ .ok()?;
+ self.subscribers.insert(id, sender);
+ Some((id, SnapshotChangeReceiver { receiver }))
+ }
+
+ #[must_use]
+ pub fn unsubscribe(&mut self, id: ChangeSubscriptionId) -> bool {
+ self.subscribers.remove(&id).is_some()
+ }
+
+ pub fn publish(&mut self, snapshot: AppSnapshot) {
+ if self.closed || snapshot.revision() <= self.latest.revision() {
+ return;
+ }
+ let change = SnapshotChange {
+ previous_revision: Some(self.latest.revision()),
+ snapshot,
+ };
+ self.latest = change.snapshot.clone();
+ self.subscribers
+ .retain(|_, sender| match sender.try_send(change.clone()) {
+ Ok(()) | Err(mpsc::error::TrySendError::Full(_)) => true,
+ Err(mpsc::error::TrySendError::Closed(_)) => false,
+ });
+ }
+
+ pub fn close(&mut self) {
+ self.closed = true;
+ self.subscribers.clear();
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use std::num::NonZeroUsize;
+
+ use crate::{
+ AppSnapshot, OrderedSnapshotChanges, RelayConfiguration, SessionState, SnapshotRevision,
+ };
+
+ #[tokio::test]
+ async fn change_stream_publishes_monotonic_revisions_to_multiple_consumers() {
+ let mut changes = OrderedSnapshotChanges::new(snapshot(0));
+ let (_, mut first) = changes
+ .subscribe(NonZeroUsize::new(4).expect("capacity"))
+ .expect("first subscription");
+ let (_, mut second) = changes
+ .subscribe(NonZeroUsize::new(4).expect("capacity"))
+ .expect("second subscription");
+
+ assert_eq!(
+ first.receive().await.expect("initial").revision(),
+ revision(0)
+ );
+ assert_eq!(
+ second.receive().await.expect("initial").revision(),
+ revision(0)
+ );
+ changes.publish(snapshot(1));
+ changes.publish(snapshot(1));
+ changes.publish(snapshot(2));
+
+ for receiver in [&mut first, &mut second] {
+ assert_eq!(
+ receiver.receive().await.expect("revision 1").revision(),
+ revision(1)
+ );
+ assert_eq!(
+ receiver.receive().await.expect("revision 2").revision(),
+ revision(2)
+ );
+ }
+ assert_eq!(changes.last_revision(), revision(2));
+ }
+
+ #[tokio::test]
+ async fn slow_consumers_expose_a_revision_gap_without_blocking_publication() {
+ let mut changes = OrderedSnapshotChanges::new(snapshot(0));
+ let (_, mut receiver) = changes
+ .subscribe(NonZeroUsize::new(1).expect("capacity"))
+ .expect("subscription");
+
+ assert_eq!(
+ receiver.receive().await.expect("initial").revision(),
+ revision(0)
+ );
+ changes.publish(snapshot(1));
+ changes.publish(snapshot(2));
+ let first = receiver.receive().await.expect("first");
+ assert_eq!(first.revision(), revision(1));
+ changes.publish(snapshot(3));
+ let recovered = receiver.receive().await.expect("gap recovery");
+ assert_eq!(recovered.revision(), revision(3));
+ assert!(recovered.recovers_gap_after(first.revision()));
+ }
+
+ #[tokio::test]
+ async fn close_terminates_consumers_and_rejects_later_subscriptions() {
+ let mut changes = OrderedSnapshotChanges::new(snapshot(0));
+ let (_, mut receiver) = changes
+ .subscribe(NonZeroUsize::new(1).expect("capacity"))
+ .expect("subscription");
+ receiver.receive().await.expect("initial");
+
+ changes.close();
+ changes.publish(snapshot(1));
+ assert!(receiver.receive().await.is_none());
+ assert!(
+ changes
+ .subscribe(NonZeroUsize::new(1).expect("capacity"))
+ .is_none()
+ );
+ }
+
+ fn revision(value: u64) -> SnapshotRevision {
+ SnapshotRevision::from_value(value)
+ }
+
+ fn snapshot(value: u64) -> AppSnapshot {
+ if value == 0 {
+ AppSnapshot::booting()
+ } else {
+ AppSnapshot::ready(
+ revision(value),
+ RelayConfiguration::default(),
+ Vec::new(),
+ None,
+ SessionState::SignedOut,
+ None,
+ None,
+ )
+ .expect("snapshot")
+ }
+ }
+}
diff --git a/crates/studio_application/src/config.rs b/crates/studio_application/src/config.rs
@@ -0,0 +1,105 @@
+use radroots_studio_domain::{SafeError, SafeErrorCode, SafeMessage, normalize_relay_urls};
+
+use crate::RelayConfiguration;
+
+pub const RELAY_ENVIRONMENT_VARIABLE: &str = "RADROOTS_NOSTR_RELAYS";
+const DEVELOPMENT_RELAY: &str = "ws://localhost:8080";
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub enum RelayRuntimeMode {
+ Development,
+ Packaged,
+}
+
+/// Reads the process relay configuration once through the Rust-owned boundary.
+///
+/// # Errors
+///
+/// Returns a safe configuration error for missing Unicode or invalid relay data.
+pub fn relay_configuration_from_environment(
+ mode: RelayRuntimeMode,
+) -> Result<RelayConfiguration, SafeError> {
+ let value = match std::env::var(RELAY_ENVIRONMENT_VARIABLE) {
+ Ok(value) => Some(value),
+ Err(std::env::VarError::NotPresent) => None,
+ Err(std::env::VarError::NotUnicode(_)) => return Err(invalid_configuration()),
+ };
+ relay_configuration_from_value(value.as_deref(), mode)
+}
+
+/// Parses an injected comma-separated relay list without mutating process state.
+///
+/// # Errors
+///
+/// Returns a safe configuration error when an entry is invalid or packaged mode
+/// has no configured relay.
+pub fn relay_configuration_from_value(
+ value: Option<&str>,
+ mode: RelayRuntimeMode,
+) -> Result<RelayConfiguration, SafeError> {
+ let configured = value.unwrap_or_default().trim();
+ let source = if configured.is_empty() {
+ match mode {
+ RelayRuntimeMode::Development => DEVELOPMENT_RELAY,
+ RelayRuntimeMode::Packaged => return Err(invalid_configuration()),
+ }
+ } else {
+ configured
+ };
+ let normalized = normalize_relay_urls(source.split(',').map(str::trim))?;
+ if normalized.is_empty() {
+ return Err(invalid_configuration());
+ }
+ Ok(RelayConfiguration::new(normalized))
+}
+
+const fn invalid_configuration() -> SafeError {
+ SafeError::new(
+ SafeErrorCode::InvalidRelayConfiguration,
+ SafeMessage::new("The Nostr relay configuration is invalid."),
+ )
+}
+
+#[cfg(test)]
+mod tests {
+ use radroots_studio_domain::SafeErrorCode;
+
+ use super::{RelayRuntimeMode, relay_configuration_from_value};
+
+ #[test]
+ fn relay_config_uses_localhost_fallback_only_for_development() {
+ for value in [None, Some(""), Some(" ")] {
+ let development = relay_configuration_from_value(value, RelayRuntimeMode::Development)
+ .expect("development fallback");
+ assert_eq!(development.relays()[0].as_str(), "ws://localhost:8080/");
+ let packaged = relay_configuration_from_value(value, RelayRuntimeMode::Packaged)
+ .expect_err("packaged configuration required");
+ assert_eq!(packaged.code(), SafeErrorCode::InvalidRelayConfiguration);
+ }
+ }
+
+ #[test]
+ fn relay_config_trims_deduplicates_and_preserves_order() {
+ let configuration = relay_configuration_from_value(
+ Some(" wss://relay.one ,wss://relay.two,wss://relay.one/ "),
+ RelayRuntimeMode::Packaged,
+ )
+ .expect("configuration");
+ let relays = configuration
+ .relays()
+ .iter()
+ .map(radroots_studio_domain::RelayUrl::as_str)
+ .collect::<Vec<_>>();
+ assert_eq!(relays, ["wss://relay.one/", "wss://relay.two/"]);
+ }
+
+ #[test]
+ fn relay_config_rejects_any_invalid_comma_separated_entry() {
+ let error = relay_configuration_from_value(
+ Some("wss://relay.one,https://not-a-relay.test"),
+ RelayRuntimeMode::Packaged,
+ )
+ .expect_err("invalid entry");
+ assert_eq!(error.code(), SafeErrorCode::InvalidRelayConfiguration);
+ }
+}
diff --git a/crates/studio_application/src/custody.rs b/crates/studio_application/src/custody.rs
@@ -0,0 +1,279 @@
+use std::num::NonZeroU64;
+use std::sync::Mutex;
+use std::time::Duration;
+
+use radroots_studio_domain::{
+ AccountCreatedAt, AccountIdentity, AccountSummary, BindingAvailability, LocalSignerBinding,
+ Nsec, SafeError, SafeErrorCode, SafeMessage, SecretKeyInput, UnixTimestamp,
+};
+use radroots_studio_nostr::generate_local_keypair;
+
+pub const GENERATED_KEY_STAGE_TTL: Duration = Duration::from_mins(5);
+
+#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
+pub struct RecoveryStageId(NonZeroU64);
+
+impl RecoveryStageId {
+ #[must_use]
+ pub const fn new(value: NonZeroU64) -> Self {
+ Self(value)
+ }
+
+ #[must_use]
+ pub const fn value(self) -> u64 {
+ self.0.get()
+ }
+}
+
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct GeneratedKeyStageView {
+ account: AccountSummary,
+ expires_at: UnixTimestamp,
+}
+
+impl GeneratedKeyStageView {
+ #[must_use]
+ pub const fn account(&self) -> &AccountSummary {
+ &self.account
+ }
+
+ #[must_use]
+ pub const fn expires_at(&self) -> UnixTimestamp {
+ self.expires_at
+ }
+}
+
+pub struct StagedGeneratedKey {
+ id: RecoveryStageId,
+ account: AccountSummary,
+ secret: SecretKeyInput,
+ expected_revision: u64,
+ expires_at: UnixTimestamp,
+}
+
+impl StagedGeneratedKey {
+ #[must_use]
+ pub fn view(&self) -> GeneratedKeyStageView {
+ GeneratedKeyStageView {
+ account: self.account.clone(),
+ expires_at: self.expires_at,
+ }
+ }
+
+ #[must_use]
+ pub const fn expected_revision(&self) -> u64 {
+ self.expected_revision
+ }
+
+ #[must_use]
+ pub const fn id(&self) -> RecoveryStageId {
+ self.id
+ }
+
+ #[must_use]
+ pub const fn account(&self) -> &AccountSummary {
+ &self.account
+ }
+
+ #[must_use]
+ pub fn into_commit_parts(self) -> (AccountSummary, SecretKeyInput) {
+ (self.account, self.secret)
+ }
+}
+
+pub struct GeneratedKeyRecoveryHandle {
+ id: RecoveryStageId,
+ view: GeneratedKeyStageView,
+ recovery_nsec: Mutex<Option<Nsec>>,
+}
+
+impl GeneratedKeyRecoveryHandle {
+ fn new(id: RecoveryStageId, view: GeneratedKeyStageView, recovery_nsec: Nsec) -> Self {
+ Self {
+ id,
+ view,
+ recovery_nsec: Mutex::new(Some(recovery_nsec)),
+ }
+ }
+
+ #[must_use]
+ pub const fn id(&self) -> RecoveryStageId {
+ self.id
+ }
+
+ #[must_use]
+ pub const fn view(&self) -> &GeneratedKeyStageView {
+ &self.view
+ }
+
+ /// Returns the generated recovery value exactly once.
+ ///
+ /// # Errors
+ ///
+ /// Returns a safe unavailable error after the value was already consumed.
+ pub fn take_recovery_nsec(&self) -> Result<Nsec, SafeError> {
+ self.recovery_nsec
+ .lock()
+ .unwrap_or_else(std::sync::PoisonError::into_inner)
+ .take()
+ .ok_or_else(recovery_not_available)
+ }
+}
+
+#[derive(Default)]
+pub struct GeneratedKeyStage {
+ pending: Option<StagedGeneratedKey>,
+}
+
+impl GeneratedKeyStage {
+ /// Replaces an expired stage or creates the only active generated-key stage.
+ ///
+ /// # Errors
+ ///
+ /// Returns a safe conflict while an unexpired recovery stage is active.
+ pub fn begin(
+ &mut self,
+ id: RecoveryStageId,
+ expected_revision: u64,
+ now: UnixTimestamp,
+ ) -> Result<GeneratedKeyRecoveryHandle, SafeError> {
+ self.expire(now);
+ if self.pending.is_some() {
+ return Err(recovery_in_progress());
+ }
+ let generated = generate_local_keypair()?;
+ let (public_key, npub, secret, recovery_nsec) = generated.into_parts();
+ let account = AccountSummary::new(
+ AccountIdentity::verify(public_key, npub.as_str().to_owned())?,
+ LocalSignerBinding::new(public_key, BindingAvailability::Available),
+ None,
+ AccountCreatedAt::new(now),
+ None,
+ )?;
+ let ttl =
+ i64::try_from(GENERATED_KEY_STAGE_TTL.as_secs()).map_err(|_| invalid_stage_expiry())?;
+ let expires_at = now
+ .as_seconds()
+ .checked_add(ttl)
+ .and_then(UnixTimestamp::from_seconds)
+ .ok_or_else(invalid_stage_expiry)?;
+ let pending = StagedGeneratedKey {
+ id,
+ account,
+ secret,
+ expected_revision,
+ expires_at,
+ };
+ let view = pending.view();
+ self.pending = Some(pending);
+ Ok(GeneratedKeyRecoveryHandle::new(id, view, recovery_nsec))
+ }
+
+ pub fn cancel(&mut self) -> bool {
+ self.pending.take().is_some()
+ }
+
+ pub fn expire(&mut self, now: UnixTimestamp) -> bool {
+ if self
+ .pending
+ .as_ref()
+ .is_some_and(|pending| now >= pending.expires_at)
+ {
+ self.pending = None;
+ true
+ } else {
+ false
+ }
+ }
+
+ #[must_use]
+ pub const fn pending(&self) -> Option<&StagedGeneratedKey> {
+ self.pending.as_ref()
+ }
+
+ /// Consumes the active, unexpired stage for its commit boundary.
+ ///
+ /// # Errors
+ ///
+ /// Returns a safe unavailable error when no live stage remains.
+ pub fn take(
+ &mut self,
+ id: RecoveryStageId,
+ now: UnixTimestamp,
+ ) -> Result<StagedGeneratedKey, SafeError> {
+ self.expire(now);
+ if self.pending.as_ref().map(StagedGeneratedKey::id) != Some(id) {
+ return Err(recovery_not_available());
+ }
+ self.pending.take().ok_or_else(recovery_not_available)
+ }
+}
+
+const fn recovery_in_progress() -> SafeError {
+ SafeError::new(
+ SafeErrorCode::InvalidApplicationState,
+ SafeMessage::new("A generated-key recovery step is already in progress."),
+ )
+}
+
+const fn recovery_not_available() -> SafeError {
+ SafeError::new(
+ SafeErrorCode::InvalidApplicationState,
+ SafeMessage::new("The generated-key recovery step is no longer available."),
+ )
+}
+
+const fn invalid_stage_expiry() -> SafeError {
+ SafeError::new(
+ SafeErrorCode::InvalidApplicationState,
+ SafeMessage::new("The generated-key recovery expiry is invalid."),
+ )
+}
+
+#[cfg(test)]
+mod tests {
+ use std::num::NonZeroU64;
+
+ use radroots_studio_domain::UnixTimestamp;
+
+ use super::{GENERATED_KEY_STAGE_TTL, GeneratedKeyStage, RecoveryStageId};
+
+ fn time(seconds: i64) -> UnixTimestamp {
+ UnixTimestamp::from_seconds(seconds).expect("time")
+ }
+
+ fn id(value: u64) -> RecoveryStageId {
+ RecoveryStageId::new(NonZeroU64::new(value).expect("id"))
+ }
+
+ #[test]
+ fn stage_is_exclusive_cancelable_and_never_publishes_secret_debug() {
+ let mut stage = GeneratedKeyStage::default();
+ let handle = stage.begin(id(1), 4, time(10)).expect("begin");
+ let view = handle.view();
+ assert_eq!(view.expires_at().as_seconds(), 310);
+ assert_eq!(stage.pending().expect("pending").expected_revision(), 4);
+ assert!(stage.begin(id(2), 4, time(11)).is_err());
+ let nsec = handle.take_recovery_nsec().expect("one-use recovery");
+ assert_eq!(nsec.with_exposed_secret(str::len), 63);
+ assert!(handle.take_recovery_nsec().is_err());
+ assert!(stage.cancel());
+ assert!(!stage.cancel());
+ assert!(format!("{view:?}").contains(view.account().npub().as_str()));
+ assert!(!format!("{view:?}").contains("nsec1"));
+ }
+
+ #[test]
+ fn stage_expires_and_is_destroyed_on_owner_drop() {
+ let mut stage = GeneratedKeyStage::default();
+ stage.begin(id(1), 0, time(20)).expect("begin");
+ let expiry = 20 + i64::try_from(GENERATED_KEY_STAGE_TTL.as_secs()).expect("ttl");
+ assert!(stage.expire(time(expiry)));
+ assert!(stage.pending().is_none());
+ assert!(stage.take(id(1), time(expiry)).is_err());
+
+ let mut shutdown_stage = GeneratedKeyStage::default();
+ shutdown_stage.begin(id(2), 0, time(30)).expect("begin");
+ drop(shutdown_stage);
+ }
+}
diff --git a/crates/studio_application/src/lib.rs b/crates/studio_application/src/lib.rs
@@ -0,0 +1,55 @@
+#![doc = "Radroots Studio application runtime."]
+
+pub mod accounts;
+pub mod actor;
+pub mod app_core;
+mod change_stream;
+pub mod config;
+pub mod custody;
+pub mod nostr_client;
+pub mod ports;
+mod profile_refresh;
+pub mod recovery;
+pub mod secrets;
+pub mod session;
+pub mod snapshot;
+pub mod state_machine;
+
+pub use accounts::{
+ GenerateAccountReceipt, ImportAccountReceipt, InMemoryAccountRepository,
+ InMemoryOperationJournal,
+};
+pub use actor::{
+ ActorMailbox, CommandContext, CommandEnvelope, CommandReceipt, CommandRejection, CommandResult,
+ CommandSubmission, CommandTicket, ForegroundSessionBinding, LifecycleGate, RequestId,
+ RuntimeCommandClass, RuntimeLifecycle, SessionGeneration, TaskCorrelation,
+};
+pub use app_core::{AppCore, RemovalConfirmationToken, RemovalImpact};
+pub use change_stream::{
+ ChangeSubscriptionId, OrderedSnapshotChanges, SnapshotChange, SnapshotChangeReceiver,
+};
+pub use config::{
+ RelayRuntimeMode, relay_configuration_from_environment, relay_configuration_from_value,
+};
+pub use custody::{
+ GENERATED_KEY_STAGE_TTL, GeneratedKeyRecoveryHandle, GeneratedKeyStage, GeneratedKeyStageView,
+ RecoveryStageId, StagedGeneratedKey,
+};
+pub use nostr_client::SdkNostrClient;
+pub use ports::{
+ AccountNamespaceRepository, AccountOperationKind, AccountOperationPhase, AccountPreferenceKey,
+ AccountRepository, AppStateRepository, BoxFuture, CachedProfile, Clock,
+ DurableAccountOperation, DurableOperationKind, DurableOperationPhase, DurableOperationReceipt,
+ DurableOperationRepository, DurableOperationStart, DurableRequestId, DurableTerminalOutcome,
+ NostrClient, OperationDiagnostic, OperationId, OperationJournal, OperationPriorState,
+ PendingAccountOperation, ProfileRefreshStatus, ProfileRepository,
+};
+pub use profile_refresh::ProfileRefreshPlan;
+pub use secrets::{
+ FailureSecretStore, InMemorySecretStore, SecretStore, SecretStoreCall, SecretStoreOperation,
+};
+pub use snapshot::{
+ ActiveAccountSnapshot, AppLifecycle, AppSnapshot, ProfileLoadState, RelayConfiguration,
+ RelayConnectionState, SessionState, SnapshotRevision,
+};
+pub use state_machine::{StateMachine, StateTransition};
diff --git a/crates/studio_application/src/nostr_client.rs b/crates/studio_application/src/nostr_client.rs
@@ -0,0 +1,138 @@
+use std::time::Duration;
+
+use nostr::{Filter, JsonUtil, Kind, PublicKey as NostrPublicKey};
+use nostr_sdk::ClientBuilder;
+use radroots_studio_domain::{
+ Kind0ProfileCandidate, PublicKey, RelayUrl, SafeError, SafeErrorCode, SafeMessage,
+ select_latest_kind0,
+};
+
+use crate::{BoxFuture, NostrClient};
+
+pub struct SdkNostrClient {
+ timeout: Duration,
+}
+
+impl SdkNostrClient {
+ #[must_use]
+ pub const fn new(timeout: Duration) -> Self {
+ Self { timeout }
+ }
+}
+
+impl NostrClient for SdkNostrClient {
+ fn fetch_profile<'a>(
+ &'a self,
+ public_key: PublicKey,
+ relays: &'a [RelayUrl],
+ ) -> BoxFuture<'a, Result<Option<Kind0ProfileCandidate>, SafeError>> {
+ Box::pin(async move {
+ if relays.is_empty() {
+ return Err(invalid_relay_configuration());
+ }
+
+ let client = ClientBuilder::new().build();
+ for relay in relays {
+ client
+ .add_relay(relay.as_str())
+ .await
+ .map_err(|_| relay_connection_failed())?;
+ }
+ client.connect().await;
+ client.wait_for_connection(self.timeout).await;
+
+ let author = NostrPublicKey::from_slice(public_key.as_bytes())
+ .map_err(|_| profile_refresh_failed())?;
+ let filter = Filter::new().author(author).kind(Kind::Metadata).limit(64);
+ let fetched = client.fetch_events(filter, self.timeout).await;
+ client.shutdown().await;
+ let events = fetched.map_err(|_| relay_connection_failed())?;
+
+ let mut candidates = Vec::with_capacity(events.len());
+ for event in events.iter() {
+ candidates.push(radroots_studio_nostr::parse_verified_kind0(
+ &event.as_json(),
+ public_key,
+ )?);
+ }
+ Ok(select_latest_kind0(candidates))
+ })
+ }
+}
+
+const fn invalid_relay_configuration() -> SafeError {
+ SafeError::new(
+ SafeErrorCode::InvalidRelayConfiguration,
+ SafeMessage::new("No Nostr relay is configured."),
+ )
+}
+
+const fn relay_connection_failed() -> SafeError {
+ SafeError::new(
+ SafeErrorCode::RelayConnectionFailed,
+ SafeMessage::new("The Nostr relays could not be reached."),
+ )
+}
+
+const fn profile_refresh_failed() -> SafeError {
+ SafeError::new(
+ SafeErrorCode::ProfileRefreshFailed,
+ SafeMessage::new("The Nostr profile could not be refreshed."),
+ )
+}
+
+#[cfg(test)]
+mod tests {
+ use std::time::Duration;
+
+ use nostr::{EventBuilder, Keys, Metadata};
+ use nostr_relay_builder::MockRelay;
+ use nostr_sdk::Client;
+ use radroots_studio_domain::{PublicKey, RelayUrl, SafeErrorCode};
+
+ use crate::{NostrClient, SdkNostrClient};
+
+ #[tokio::test]
+ async fn sdk_client_fetches_verified_profile_from_ephemeral_local_relay() {
+ let relay = MockRelay::run().await.expect("local relay");
+ let relay_url = relay.url().await;
+ let keys = Keys::generate();
+ let publisher = Client::new(keys.clone());
+ publisher
+ .add_relay(relay_url.clone())
+ .await
+ .expect("add relay");
+ publisher.connect().await;
+ publisher.wait_for_connection(Duration::from_secs(2)).await;
+ publisher
+ .send_event_builder(EventBuilder::metadata(
+ &Metadata::new().name("Farmer").display_name("Farm Account"),
+ ))
+ .await
+ .expect("publish metadata");
+
+ let adapter = SdkNostrClient::new(Duration::from_secs(2));
+ let domain_relay = RelayUrl::parse(relay_url.as_str()).expect("domain relay URL");
+ let public_key = PublicKey::from_bytes(keys.public_key().to_bytes());
+ let profile = adapter
+ .fetch_profile(public_key, &[domain_relay])
+ .await
+ .expect("fetch profile")
+ .expect("published profile");
+
+ assert_eq!(profile.author(), public_key);
+ assert_eq!(profile.metadata().preferred_name(), Some("Farm Account"));
+ publisher.shutdown().await;
+ relay.shutdown();
+ }
+
+ #[tokio::test]
+ async fn sdk_client_rejects_empty_configuration_without_network_access() {
+ let error = SdkNostrClient::new(Duration::from_millis(10))
+ .fetch_profile(PublicKey::from_bytes([1; 32]), &[])
+ .await
+ .expect_err("empty relay list");
+
+ assert_eq!(error.code(), SafeErrorCode::InvalidRelayConfiguration);
+ }
+}
diff --git a/crates/studio_application/src/ports.rs b/crates/studio_application/src/ports.rs
@@ -0,0 +1,780 @@
+use std::future::Future;
+use std::pin::Pin;
+
+use radroots_studio_domain::{
+ AccountSummary, BindingAvailability, Kind0ProfileCandidate, PublicKey, RelayUrl, SafeError,
+ SafeErrorCode, SafeMessage, UnixTimestamp,
+};
+
+const MAX_DURABLE_REQUEST_ID_BYTES: usize = 128;
+
+#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
+pub struct DurableRequestId(String);
+
+impl DurableRequestId {
+ /// Validates an opaque caller-generated idempotency key.
+ ///
+ /// # Errors
+ ///
+ /// Returns a safe validation error when the value is empty, oversized, or contains anything
+ /// other than visible ASCII characters.
+ pub fn parse(value: impl Into<String>) -> Result<Self, SafeError> {
+ let value = value.into();
+ if value.is_empty()
+ || value.len() > MAX_DURABLE_REQUEST_ID_BYTES
+ || !value.bytes().all(|byte| byte.is_ascii_graphic())
+ {
+ return Err(invalid_request_id());
+ }
+ Ok(Self(value))
+ }
+
+ #[must_use]
+ pub fn as_str(&self) -> &str {
+ &self.0
+ }
+}
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub enum DurableOperationKind {
+ Create,
+ Import,
+ Repair,
+ Remove,
+}
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub enum DurableOperationPhase {
+ IntentRecorded,
+ CredentialWritten,
+ MetadataCommitted,
+ SelectionCommitted,
+ CompensationPending,
+ CredentialDeleted,
+ MetadataDeleted,
+ Finalized,
+}
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub enum DurableTerminalOutcome {
+ Completed,
+ Cancelled,
+ Failed,
+}
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub struct OperationPriorState {
+ selected_account: Option<PublicKey>,
+ binding_availability: Option<BindingAvailability>,
+}
+
+impl OperationPriorState {
+ #[must_use]
+ pub const fn new(
+ selected_account: Option<PublicKey>,
+ binding_availability: Option<BindingAvailability>,
+ ) -> Self {
+ Self {
+ selected_account,
+ binding_availability,
+ }
+ }
+
+ #[must_use]
+ pub const fn selected_account(self) -> Option<PublicKey> {
+ self.selected_account
+ }
+
+ #[must_use]
+ pub const fn binding_availability(self) -> Option<BindingAvailability> {
+ self.binding_availability
+ }
+}
+
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct DurableOperationReceipt {
+ request_id: DurableRequestId,
+ account: PublicKey,
+ outcome: DurableTerminalOutcome,
+ resulting_revision: Option<u64>,
+}
+
+impl DurableOperationReceipt {
+ #[must_use]
+ pub const fn new(
+ request_id: DurableRequestId,
+ account: PublicKey,
+ outcome: DurableTerminalOutcome,
+ resulting_revision: Option<u64>,
+ ) -> Self {
+ Self {
+ request_id,
+ account,
+ outcome,
+ resulting_revision,
+ }
+ }
+
+ #[must_use]
+ pub const fn request_id(&self) -> &DurableRequestId {
+ &self.request_id
+ }
+
+ #[must_use]
+ pub const fn account(&self) -> PublicKey {
+ self.account
+ }
+
+ #[must_use]
+ pub const fn outcome(&self) -> DurableTerminalOutcome {
+ self.outcome
+ }
+
+ #[must_use]
+ pub const fn resulting_revision(&self) -> Option<u64> {
+ self.resulting_revision
+ }
+}
+
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct DurableAccountOperation {
+ request_id: DurableRequestId,
+ kind: DurableOperationKind,
+ account: PublicKey,
+ expected_revision: Option<u64>,
+ phase: DurableOperationPhase,
+ prior: OperationPriorState,
+ updated_at: UnixTimestamp,
+ diagnostic: Option<OperationDiagnostic>,
+ terminal: Option<DurableOperationReceipt>,
+}
+
+impl DurableAccountOperation {
+ #[allow(clippy::too_many_arguments)]
+ #[must_use]
+ pub const fn new(
+ request_id: DurableRequestId,
+ kind: DurableOperationKind,
+ account: PublicKey,
+ expected_revision: Option<u64>,
+ phase: DurableOperationPhase,
+ prior: OperationPriorState,
+ updated_at: UnixTimestamp,
+ diagnostic: Option<OperationDiagnostic>,
+ terminal: Option<DurableOperationReceipt>,
+ ) -> Self {
+ Self {
+ request_id,
+ kind,
+ account,
+ expected_revision,
+ phase,
+ prior,
+ updated_at,
+ diagnostic,
+ terminal,
+ }
+ }
+
+ #[must_use]
+ pub const fn request_id(&self) -> &DurableRequestId {
+ &self.request_id
+ }
+ #[must_use]
+ pub const fn kind(&self) -> DurableOperationKind {
+ self.kind
+ }
+ #[must_use]
+ pub const fn account(&self) -> PublicKey {
+ self.account
+ }
+ #[must_use]
+ pub const fn expected_revision(&self) -> Option<u64> {
+ self.expected_revision
+ }
+ #[must_use]
+ pub const fn phase(&self) -> DurableOperationPhase {
+ self.phase
+ }
+ #[must_use]
+ pub const fn prior(&self) -> OperationPriorState {
+ self.prior
+ }
+ #[must_use]
+ pub const fn updated_at(&self) -> UnixTimestamp {
+ self.updated_at
+ }
+ #[must_use]
+ pub const fn diagnostic(&self) -> Option<OperationDiagnostic> {
+ self.diagnostic
+ }
+ #[must_use]
+ pub const fn terminal(&self) -> Option<&DurableOperationReceipt> {
+ self.terminal.as_ref()
+ }
+}
+
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub enum DurableOperationStart {
+ Started(DurableAccountOperation),
+ Existing(DurableAccountOperation),
+}
+
+const fn invalid_request_id() -> SafeError {
+ SafeError::new(
+ SafeErrorCode::InvalidApplicationState,
+ SafeMessage::new("The request identifier is invalid."),
+ )
+}
+
+pub type BoxFuture<'a, T> = Pin<Box<dyn Future<Output = T> + Send + 'a>>;
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub enum ProfileRefreshStatus {
+ Success,
+ Offline,
+ InvalidData,
+}
+
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct CachedProfile {
+ candidate: Kind0ProfileCandidate,
+ refreshed_at: UnixTimestamp,
+ refresh_status: ProfileRefreshStatus,
+}
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub enum AccountPreferenceKey {
+ NamespaceProbe,
+}
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub enum AccountOperationKind {
+ Add,
+ Import,
+ Remove,
+}
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub enum AccountOperationPhase {
+ IntentRecorded,
+ CredentialWritten,
+ MetadataCommitted,
+ CompensationPending,
+ CredentialDeleted,
+ MetadataDeleted,
+}
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub enum OperationDiagnostic {
+ StorageUnavailable,
+ KeyringUnavailable,
+ CredentialMissing,
+ CompensationFailed,
+ Conflict,
+ Expired,
+}
+
+#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
+pub struct OperationId(u64);
+
+impl OperationId {
+ #[must_use]
+ pub const fn from_raw(value: u64) -> Self {
+ Self(value)
+ }
+
+ #[must_use]
+ pub const fn as_raw(self) -> u64 {
+ self.0
+ }
+}
+
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct PendingAccountOperation {
+ id: OperationId,
+ kind: AccountOperationKind,
+ subject: PublicKey,
+ phase: AccountOperationPhase,
+ updated_at: UnixTimestamp,
+ diagnostic: Option<OperationDiagnostic>,
+}
+
+impl PendingAccountOperation {
+ #[must_use]
+ pub const fn new(
+ id: OperationId,
+ kind: AccountOperationKind,
+ subject: PublicKey,
+ phase: AccountOperationPhase,
+ updated_at: UnixTimestamp,
+ diagnostic: Option<OperationDiagnostic>,
+ ) -> Self {
+ Self {
+ id,
+ kind,
+ subject,
+ phase,
+ updated_at,
+ diagnostic,
+ }
+ }
+
+ #[must_use]
+ pub const fn id(&self) -> OperationId {
+ self.id
+ }
+ #[must_use]
+ pub const fn kind(&self) -> AccountOperationKind {
+ self.kind
+ }
+ #[must_use]
+ pub const fn subject(&self) -> PublicKey {
+ self.subject
+ }
+ #[must_use]
+ pub const fn phase(&self) -> AccountOperationPhase {
+ self.phase
+ }
+ #[must_use]
+ pub const fn updated_at(&self) -> UnixTimestamp {
+ self.updated_at
+ }
+ #[must_use]
+ pub const fn diagnostic(&self) -> Option<OperationDiagnostic> {
+ self.diagnostic
+ }
+}
+
+impl CachedProfile {
+ #[must_use]
+ pub const fn new(
+ candidate: Kind0ProfileCandidate,
+ refreshed_at: UnixTimestamp,
+ refresh_status: ProfileRefreshStatus,
+ ) -> Self {
+ Self {
+ candidate,
+ refreshed_at,
+ refresh_status,
+ }
+ }
+
+ #[must_use]
+ pub const fn candidate(&self) -> &Kind0ProfileCandidate {
+ &self.candidate
+ }
+
+ #[must_use]
+ pub const fn refreshed_at(&self) -> UnixTimestamp {
+ self.refreshed_at
+ }
+
+ #[must_use]
+ pub const fn refresh_status(&self) -> ProfileRefreshStatus {
+ self.refresh_status
+ }
+}
+
+pub trait AccountRepository: Send + Sync {
+ /// Lists saved public account records in deterministic order.
+ ///
+ /// # Errors
+ ///
+ /// Returns a safe storage error when records cannot be read.
+ fn list_accounts(&self) -> Result<Vec<AccountSummary>, SafeError>;
+ /// Finds one saved public account record.
+ ///
+ /// # Errors
+ ///
+ /// Returns a safe storage error when the lookup cannot complete.
+ fn find_account(&self, public_key: PublicKey) -> Result<Option<AccountSummary>, SafeError>;
+ /// Inserts one public account record.
+ ///
+ /// # Errors
+ ///
+ /// Returns a safe storage error when the durable write fails.
+ fn insert_account(&self, account: &AccountSummary) -> Result<(), SafeError>;
+ /// Updates one existing public account record.
+ ///
+ /// # Errors
+ ///
+ /// Returns a safe storage or account-not-found error when the durable
+ /// update cannot complete.
+ fn update_account(&self, account: &AccountSummary) -> Result<(), SafeError>;
+ /// Removes one public account record.
+ ///
+ /// # Errors
+ ///
+ /// Returns a safe storage error when the durable delete fails.
+ fn remove_account(&self, public_key: PublicKey) -> Result<(), SafeError>;
+}
+
+pub trait ProfileRepository: Send + Sync {
+ /// Loads cached public profile metadata.
+ ///
+ /// # Errors
+ ///
+ /// Returns a safe storage error when the cache cannot be read.
+ fn load_profile(&self, public_key: PublicKey) -> Result<Option<CachedProfile>, SafeError>;
+ /// Saves a verified kind-0 profile candidate.
+ ///
+ /// # Errors
+ ///
+ /// Returns a safe storage error when the cache cannot be committed.
+ fn save_profile(&self, profile: &CachedProfile) -> Result<(), SafeError>;
+ /// Records the result of a profile refresh without replacing cached metadata.
+ ///
+ /// # Errors
+ ///
+ /// Returns a safe storage error when the cache cannot be committed.
+ fn record_refresh_status(
+ &self,
+ public_key: PublicKey,
+ refreshed_at: UnixTimestamp,
+ status: ProfileRefreshStatus,
+ ) -> Result<(), SafeError>;
+ /// Removes cached profile metadata for an account.
+ ///
+ /// # Errors
+ ///
+ /// Returns a safe storage error when the cache cannot be deleted.
+ fn remove_profile(&self, public_key: PublicKey) -> Result<(), SafeError>;
+}
+
+pub trait AccountNamespaceRepository: Send + Sync {
+ /// Reads one internal non-secret account-scoped value.
+ ///
+ /// # Errors
+ ///
+ /// Returns a safe storage error when the value cannot be read.
+ fn get_value(
+ &self,
+ owner: PublicKey,
+ key: AccountPreferenceKey,
+ ) -> Result<Option<String>, SafeError>;
+ /// Writes one internal non-secret account-scoped value.
+ ///
+ /// # Errors
+ ///
+ /// Returns a safe storage error when the value cannot be committed.
+ fn set_value(
+ &self,
+ owner: PublicKey,
+ key: AccountPreferenceKey,
+ value: &str,
+ ) -> Result<(), SafeError>;
+ /// Removes all internal values owned by an account.
+ ///
+ /// # Errors
+ ///
+ /// Returns a safe storage error when cleanup cannot be committed.
+ fn clear_owner(&self, owner: PublicKey) -> Result<(), SafeError>;
+}
+
+pub trait AppStateRepository: Send + Sync {
+ /// Loads the persisted selected account.
+ ///
+ /// # Errors
+ ///
+ /// Returns a safe storage error when application state cannot be read.
+ fn load_selected_account(&self) -> Result<Option<PublicKey>, SafeError>;
+ /// Persists the selected account or the empty selection.
+ ///
+ /// # Errors
+ ///
+ /// Returns a safe storage error when application state cannot be committed.
+ fn save_selected_account(&self, public_key: Option<PublicKey>) -> Result<(), SafeError>;
+}
+
+pub trait OperationJournal: Send + Sync {
+ /// Records one cross-resource account operation intent.
+ ///
+ /// # Errors
+ ///
+ /// Returns a safe storage error when the entry cannot be committed.
+ fn begin_operation(
+ &self,
+ kind: AccountOperationKind,
+ subject: PublicKey,
+ updated_at: UnixTimestamp,
+ ) -> Result<OperationId, SafeError>;
+ /// Advances an operation to a durable recovery phase.
+ ///
+ /// # Errors
+ ///
+ /// Returns a safe storage error when the entry cannot be updated.
+ fn update_operation(
+ &self,
+ id: OperationId,
+ phase: AccountOperationPhase,
+ updated_at: UnixTimestamp,
+ diagnostic: Option<OperationDiagnostic>,
+ ) -> Result<(), SafeError>;
+ /// Loads all unfinished operations in deterministic order.
+ ///
+ /// # Errors
+ ///
+ /// Returns a safe storage error when entries cannot be read.
+ fn list_pending_operations(&self) -> Result<Vec<PendingAccountOperation>, SafeError>;
+ /// Deletes one fully reconciled operation entry.
+ ///
+ /// # Errors
+ ///
+ /// Returns a safe storage error when finalization cannot be committed.
+ fn finalize_operation(&self, id: OperationId) -> Result<(), SafeError>;
+}
+
+pub trait DurableOperationRepository: Send + Sync {
+ /// Records one idempotent durable operation or returns the existing matching request.
+ ///
+ /// # Errors
+ ///
+ /// Returns a safe conflict or storage error when the request cannot be recorded.
+ #[allow(clippy::too_many_arguments)]
+ fn begin_durable_operation(
+ &self,
+ request_id: &DurableRequestId,
+ kind: DurableOperationKind,
+ account: PublicKey,
+ expected_revision: Option<u64>,
+ prior: OperationPriorState,
+ updated_at: UnixTimestamp,
+ ) -> Result<DurableOperationStart, SafeError>;
+ /// Loads one durable operation by its idempotency key.
+ ///
+ /// # Errors
+ ///
+ /// Returns a safe storage error when the lookup cannot complete.
+ fn load_durable_operation(
+ &self,
+ request_id: &DurableRequestId,
+ ) -> Result<Option<DurableAccountOperation>, SafeError>;
+ /// Advances one operation only from the caller's expected phase.
+ ///
+ /// # Errors
+ ///
+ /// Returns a safe conflict or storage error when the transition cannot commit.
+ fn advance_durable_operation(
+ &self,
+ request_id: &DurableRequestId,
+ expected_phase: DurableOperationPhase,
+ next_phase: DurableOperationPhase,
+ updated_at: UnixTimestamp,
+ diagnostic: Option<OperationDiagnostic>,
+ ) -> Result<DurableAccountOperation, SafeError>;
+ /// Finalizes one operation and durably retains its recoverable receipt.
+ ///
+ /// # Errors
+ ///
+ /// Returns a safe conflict or storage error when finalization cannot commit.
+ fn finalize_durable_operation(
+ &self,
+ request_id: &DurableRequestId,
+ expected_phase: DurableOperationPhase,
+ outcome: DurableTerminalOutcome,
+ resulting_revision: Option<u64>,
+ updated_at: UnixTimestamp,
+ ) -> Result<DurableOperationReceipt, SafeError>;
+ /// Lists unfinished operations in deterministic request order.
+ ///
+ /// # Errors
+ ///
+ /// Returns a safe storage error when operations cannot be read.
+ fn list_unfinished_durable_operations(&self)
+ -> Result<Vec<DurableAccountOperation>, SafeError>;
+}
+
+pub trait NostrClient: Send + Sync {
+ fn fetch_profile<'a>(
+ &'a self,
+ public_key: PublicKey,
+ relays: &'a [RelayUrl],
+ ) -> BoxFuture<'a, Result<Option<Kind0ProfileCandidate>, SafeError>>;
+}
+
+pub trait Clock: Send + Sync {
+ fn now(&self) -> UnixTimestamp;
+}
+
+#[cfg(test)]
+mod tests {
+ use std::sync::Mutex;
+
+ use radroots_studio_domain::{
+ AccountSummary, Kind0ProfileCandidate, PublicKey, RelayUrl, SafeError, UnixTimestamp,
+ };
+
+ use super::{
+ AccountNamespaceRepository, AccountOperationKind, AccountOperationPhase,
+ AccountPreferenceKey, AccountRepository, AppStateRepository, BoxFuture, CachedProfile,
+ Clock, DurableOperationReceipt, DurableRequestId, DurableTerminalOutcome, NostrClient,
+ OperationDiagnostic, OperationId, OperationJournal, PendingAccountOperation,
+ ProfileRefreshStatus, ProfileRepository,
+ };
+
+ #[test]
+ fn durable_request_ids_and_terminal_receipts_are_bounded_and_public() {
+ let request = DurableRequestId::parse("create:desktop:0001").expect("request id");
+ let receipt = DurableOperationReceipt::new(
+ request.clone(),
+ PublicKey::from_bytes([3; 32]),
+ DurableTerminalOutcome::Completed,
+ Some(42),
+ );
+ assert_eq!(receipt.request_id(), &request);
+ assert_eq!(receipt.resulting_revision(), Some(42));
+ for invalid in ["", "contains space", &"x".repeat(129)] {
+ assert!(DurableRequestId::parse(invalid).is_err());
+ }
+ }
+
+ #[derive(Default)]
+ struct FakePorts {
+ selected: Mutex<Option<PublicKey>>,
+ }
+
+ impl AccountRepository for FakePorts {
+ fn list_accounts(&self) -> Result<Vec<AccountSummary>, SafeError> {
+ Ok(Vec::new())
+ }
+
+ fn find_account(
+ &self,
+ _public_key: PublicKey,
+ ) -> Result<Option<AccountSummary>, SafeError> {
+ Ok(None)
+ }
+
+ fn insert_account(&self, _account: &AccountSummary) -> Result<(), SafeError> {
+ Ok(())
+ }
+
+ fn update_account(&self, _account: &AccountSummary) -> Result<(), SafeError> {
+ Ok(())
+ }
+
+ fn remove_account(&self, _public_key: PublicKey) -> Result<(), SafeError> {
+ Ok(())
+ }
+ }
+
+ impl ProfileRepository for FakePorts {
+ fn load_profile(&self, _public_key: PublicKey) -> Result<Option<CachedProfile>, SafeError> {
+ Ok(None)
+ }
+
+ fn save_profile(&self, _profile: &CachedProfile) -> Result<(), SafeError> {
+ Ok(())
+ }
+
+ fn record_refresh_status(
+ &self,
+ _public_key: PublicKey,
+ _refreshed_at: UnixTimestamp,
+ _status: ProfileRefreshStatus,
+ ) -> Result<(), SafeError> {
+ Ok(())
+ }
+
+ fn remove_profile(&self, _public_key: PublicKey) -> Result<(), SafeError> {
+ Ok(())
+ }
+ }
+
+ impl AccountNamespaceRepository for FakePorts {
+ fn get_value(
+ &self,
+ _owner: PublicKey,
+ _key: AccountPreferenceKey,
+ ) -> Result<Option<String>, SafeError> {
+ Ok(None)
+ }
+
+ fn set_value(
+ &self,
+ _owner: PublicKey,
+ _key: AccountPreferenceKey,
+ _value: &str,
+ ) -> Result<(), SafeError> {
+ Ok(())
+ }
+
+ fn clear_owner(&self, _owner: PublicKey) -> Result<(), SafeError> {
+ Ok(())
+ }
+ }
+
+ impl AppStateRepository for FakePorts {
+ fn load_selected_account(&self) -> Result<Option<PublicKey>, SafeError> {
+ Ok(*self.selected.lock().expect("selected lock"))
+ }
+
+ fn save_selected_account(&self, public_key: Option<PublicKey>) -> Result<(), SafeError> {
+ *self.selected.lock().expect("selected lock") = public_key;
+ Ok(())
+ }
+ }
+
+ impl OperationJournal for FakePorts {
+ fn begin_operation(
+ &self,
+ _kind: AccountOperationKind,
+ _subject: PublicKey,
+ _updated_at: UnixTimestamp,
+ ) -> Result<OperationId, SafeError> {
+ Ok(OperationId::from_raw(1))
+ }
+
+ fn update_operation(
+ &self,
+ _id: OperationId,
+ _phase: AccountOperationPhase,
+ _updated_at: UnixTimestamp,
+ _diagnostic: Option<OperationDiagnostic>,
+ ) -> Result<(), SafeError> {
+ Ok(())
+ }
+
+ fn list_pending_operations(&self) -> Result<Vec<PendingAccountOperation>, SafeError> {
+ Ok(Vec::new())
+ }
+
+ fn finalize_operation(&self, _id: OperationId) -> Result<(), SafeError> {
+ Ok(())
+ }
+ }
+
+ impl NostrClient for FakePorts {
+ fn fetch_profile<'a>(
+ &'a self,
+ _public_key: PublicKey,
+ _relays: &'a [RelayUrl],
+ ) -> BoxFuture<'a, Result<Option<Kind0ProfileCandidate>, SafeError>> {
+ Box::pin(async { Ok(None) })
+ }
+ }
+
+ impl Clock for FakePorts {
+ fn now(&self) -> UnixTimestamp {
+ UnixTimestamp::from_seconds(1).expect("valid fake time")
+ }
+ }
+
+ fn assert_send_sync<T: Send + Sync>() {}
+
+ #[test]
+ fn ports_accept_send_sync_test_fakes() {
+ assert_send_sync::<FakePorts>();
+
+ let ports = FakePorts::default();
+ ports
+ .save_selected_account(Some(PublicKey::from_bytes([1_u8; 32])))
+ .expect("save selection");
+ assert_eq!(
+ ports.load_selected_account().expect("load selection"),
+ Some(PublicKey::from_bytes([1_u8; 32]))
+ );
+ assert_eq!(ports.now().as_seconds(), 1);
+ }
+}
diff --git a/crates/studio_application/src/profile_refresh.rs b/crates/studio_application/src/profile_refresh.rs
@@ -0,0 +1,559 @@
+use radroots_studio_domain::{PublicKey, RelayUrl, SafeError, SafeErrorCode};
+
+use crate::{
+ ActiveAccountSnapshot, AppCore, AppSnapshot, CachedProfile, Clock, NostrClient,
+ ProfileLoadState, ProfileRefreshStatus, ProfileRepository, RelayConnectionState,
+ SnapshotRevision, StateTransition,
+};
+
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct ProfileRefreshPlan {
+ public_key: PublicKey,
+ active_account: ActiveAccountSnapshot,
+ relays: Vec<RelayUrl>,
+ expected_revision: SnapshotRevision,
+}
+
+impl ProfileRefreshPlan {
+ #[must_use]
+ pub const fn public_key(&self) -> PublicKey {
+ self.public_key
+ }
+
+ #[must_use]
+ pub const fn active_account(&self) -> &ActiveAccountSnapshot {
+ &self.active_account
+ }
+
+ #[must_use]
+ pub fn relays(&self) -> &[RelayUrl] {
+ &self.relays
+ }
+
+ #[must_use]
+ pub const fn expected_revision(&self) -> SnapshotRevision {
+ self.expected_revision
+ }
+}
+
+impl AppCore {
+ /// Manually refreshes the active account's Nostr kind-0 profile.
+ ///
+ /// Cached public metadata remains visible while the asynchronous request is
+ /// running. Calling this command while signed out is an idempotent no-op.
+ ///
+ /// # Errors
+ ///
+ /// Returns a safe storage or application-state error. Relay and invalid-data
+ /// failures are represented as nonfatal snapshot state.
+ pub async fn refresh_active_profile(
+ &self,
+ profiles: &(impl ProfileRepository + ?Sized),
+ client: &(impl NostrClient + ?Sized),
+ clock: &(impl Clock + ?Sized),
+ ) -> Result<AppSnapshot, SafeError> {
+ self.refresh_profile_for_active_account(profiles, client, clock)
+ .await
+ }
+
+ /// Refreshes the current active account while retaining any cached profile.
+ ///
+ /// Stale results are discarded when the account is replaced or signed out.
+ ///
+ /// # Errors
+ ///
+ /// Returns a safe storage or application-state error. Relay and invalid-data
+ /// failures are represented as nonfatal snapshot state.
+ async fn refresh_profile_for_active_account(
+ &self,
+ profiles: &(impl ProfileRepository + ?Sized),
+ client: &(impl NostrClient + ?Sized),
+ clock: &(impl Clock + ?Sized),
+ ) -> Result<AppSnapshot, SafeError> {
+ let Some(plan) = self.begin_profile_refresh()? else {
+ return Ok(self.snapshot());
+ };
+ let result = client.fetch_profile(plan.public_key(), plan.relays()).await;
+ self.complete_profile_refresh(&plan, result, profiles, clock)
+ }
+
+ /// Begins a refresh on the actor and returns the immutable network plan.
+ ///
+ /// # Errors
+ ///
+ /// Returns a safe state error when the loading transition is invalid.
+ pub fn begin_profile_refresh(&self) -> Result<Option<ProfileRefreshPlan>, SafeError> {
+ let Some(active) = self.snapshot().active_account().cloned() else {
+ return Ok(None);
+ };
+ let public_key = active.account().public_key();
+ let loading = self.apply_transition(StateTransition::UpdateActiveAccount {
+ expected: public_key,
+ active_account: Box::new(ActiveAccountSnapshot::new(
+ active.account().clone(),
+ RelayConnectionState::Connecting,
+ ProfileLoadState::Loading,
+ active.profile().cloned(),
+ )),
+ problem: None,
+ })?;
+ Ok(Some(ProfileRefreshPlan {
+ public_key,
+ active_account: active,
+ relays: loading.relay_configuration().relays().to_vec(),
+ expected_revision: loading.revision(),
+ }))
+ }
+
+ /// Applies a correlated refresh result on the actor.
+ ///
+ /// # Errors
+ ///
+ /// Returns a safe storage or application-state error. Stale results are
+ /// discarded without persistence or publication.
+ pub fn complete_profile_refresh(
+ &self,
+ plan: &ProfileRefreshPlan,
+ result: Result<Option<radroots_studio_domain::Kind0ProfileCandidate>, SafeError>,
+ profiles: &(impl ProfileRepository + ?Sized),
+ clock: &(impl Clock + ?Sized),
+ ) -> Result<AppSnapshot, SafeError> {
+ if !is_current_active(self, plan.public_key()) {
+ return Ok(self.snapshot());
+ }
+
+ let current_active = self
+ .snapshot()
+ .active_account()
+ .cloned()
+ .ok_or_else(invalid_profile_completion)?;
+
+ match result {
+ Ok(Some(candidate)) => {
+ let cached = CachedProfile::new(
+ candidate.clone(),
+ clock.now(),
+ ProfileRefreshStatus::Success,
+ );
+ profiles.save_profile(&cached)?;
+ let winning_profile = profiles.load_profile(plan.public_key())?.map_or_else(
+ || candidate.metadata().clone(),
+ |profile| profile.candidate().metadata().clone(),
+ );
+ self.apply_transition(StateTransition::UpdateActiveAccount {
+ expected: plan.public_key(),
+ active_account: Box::new(ActiveAccountSnapshot::new(
+ current_active.account().clone(),
+ RelayConnectionState::Connected,
+ ProfileLoadState::Fresh,
+ Some(winning_profile),
+ )),
+ problem: None,
+ })
+ }
+ Ok(None) => self.apply_transition(StateTransition::UpdateActiveAccount {
+ expected: plan.public_key(),
+ active_account: Box::new(ActiveAccountSnapshot::new(
+ current_active.account().clone(),
+ RelayConnectionState::Connected,
+ if current_active.profile().is_some() {
+ ProfileLoadState::Cached
+ } else {
+ ProfileLoadState::Empty
+ },
+ current_active.profile().cloned(),
+ )),
+ problem: None,
+ }),
+ Err(error) => {
+ let status = refresh_status(error);
+ profiles.record_refresh_status(plan.public_key(), clock.now(), status)?;
+ self.apply_transition(StateTransition::UpdateActiveAccount {
+ expected: plan.public_key(),
+ active_account: Box::new(ActiveAccountSnapshot::new(
+ current_active.account().clone(),
+ RelayConnectionState::Degraded,
+ ProfileLoadState::Error(error),
+ current_active.profile().cloned(),
+ )),
+ problem: Some(error),
+ })
+ }
+ }
+ }
+}
+
+const fn invalid_profile_completion() -> SafeError {
+ SafeError::new(
+ SafeErrorCode::InvalidApplicationState,
+ radroots_studio_domain::SafeMessage::new("The active profile refresh is no longer valid."),
+ )
+}
+
+fn is_current_active(core: &AppCore, public_key: PublicKey) -> bool {
+ core.snapshot()
+ .active_account()
+ .is_some_and(|active| active.account().public_key() == public_key)
+}
+
+const fn refresh_status(error: SafeError) -> ProfileRefreshStatus {
+ match error.code() {
+ SafeErrorCode::InvalidProfileMetadata | SafeErrorCode::ProfileRefreshFailed => {
+ ProfileRefreshStatus::InvalidData
+ }
+ _ => ProfileRefreshStatus::Offline,
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use std::sync::Mutex;
+
+ use radroots_studio_domain::{
+ EventId, Kind0ProfileCandidate, ProfileMetadata, PublicKey, RelayUrl, SafeError,
+ SafeErrorCode, SafeMessage, SecretKeyInput, UnixTimestamp, select_latest_kind0,
+ };
+
+ use crate::{
+ ActiveAccountSnapshot, AppCore, BoxFuture, CachedProfile, Clock, InMemoryAccountRepository,
+ InMemoryOperationJournal, InMemorySecretStore, NostrClient, ProfileLoadState,
+ ProfileRefreshStatus, ProfileRepository, RelayConfiguration, RelayConnectionState,
+ };
+
+ #[derive(Default)]
+ struct MemoryProfiles(Mutex<Option<CachedProfile>>);
+
+ impl ProfileRepository for MemoryProfiles {
+ fn load_profile(&self, _public_key: PublicKey) -> Result<Option<CachedProfile>, SafeError> {
+ Ok(self.0.lock().expect("profiles").clone())
+ }
+ fn save_profile(&self, profile: &CachedProfile) -> Result<(), SafeError> {
+ let mut cached = self.0.lock().expect("profiles");
+ let selected = cached.as_ref().map_or_else(
+ || profile.clone(),
+ |current| {
+ let winner = select_latest_kind0([
+ current.candidate().clone(),
+ profile.candidate().clone(),
+ ])
+ .expect("two candidates");
+ if &winner == current.candidate() {
+ current.clone()
+ } else {
+ profile.clone()
+ }
+ },
+ );
+ *cached = Some(selected);
+ Ok(())
+ }
+ fn record_refresh_status(
+ &self,
+ _public_key: PublicKey,
+ refreshed_at: UnixTimestamp,
+ status: ProfileRefreshStatus,
+ ) -> Result<(), SafeError> {
+ if let Some(profile) = self.0.lock().expect("profiles").as_mut() {
+ *profile = CachedProfile::new(profile.candidate().clone(), refreshed_at, status);
+ }
+ Ok(())
+ }
+ fn remove_profile(&self, _public_key: PublicKey) -> Result<(), SafeError> {
+ *self.0.lock().expect("profiles") = None;
+ Ok(())
+ }
+ }
+
+ struct FixedClock;
+ impl Clock for FixedClock {
+ fn now(&self) -> UnixTimestamp {
+ UnixTimestamp::from_seconds(50).expect("time")
+ }
+ }
+
+ struct FixedClient(Result<Option<Kind0ProfileCandidate>, SafeError>);
+ impl NostrClient for FixedClient {
+ fn fetch_profile<'a>(
+ &'a self,
+ _public_key: PublicKey,
+ _relays: &'a [RelayUrl],
+ ) -> BoxFuture<'a, Result<Option<Kind0ProfileCandidate>, SafeError>> {
+ let result = self.0.clone();
+ Box::pin(async move { result })
+ }
+ }
+
+ struct BlockingClient {
+ started: tokio::sync::Semaphore,
+ release: tokio::sync::Semaphore,
+ result: Result<Option<Kind0ProfileCandidate>, SafeError>,
+ }
+
+ impl BlockingClient {
+ fn new(result: Result<Option<Kind0ProfileCandidate>, SafeError>) -> Self {
+ Self {
+ started: tokio::sync::Semaphore::new(0),
+ release: tokio::sync::Semaphore::new(0),
+ result,
+ }
+ }
+ }
+
+ impl NostrClient for BlockingClient {
+ fn fetch_profile<'a>(
+ &'a self,
+ _public_key: PublicKey,
+ _relays: &'a [RelayUrl],
+ ) -> BoxFuture<'a, Result<Option<Kind0ProfileCandidate>, SafeError>> {
+ Box::pin(async move {
+ self.started.add_permits(1);
+ let permit = self.release.acquire().await.expect("release open");
+ permit.forget();
+ self.result.clone()
+ })
+ }
+ }
+
+ fn profile(public_key: PublicKey, name: &str, timestamp: i64) -> Kind0ProfileCandidate {
+ Kind0ProfileCandidate::new(
+ EventId::from_bytes([u8::try_from(timestamp).expect("small timestamp"); 32]),
+ public_key,
+ UnixTimestamp::from_seconds(timestamp).expect("time"),
+ ProfileMetadata::new(Some(name.to_owned()), None, None, None, None).expect("profile"),
+ )
+ }
+
+ fn active_core(profiles: &MemoryProfiles, cached_name: Option<&str>) -> (AppCore, PublicKey) {
+ let relays =
+ RelayConfiguration::new(vec![RelayUrl::parse("ws://localhost:8080").expect("relay")]);
+ let core = AppCore::in_memory(relays);
+ let accounts = InMemoryAccountRepository::default();
+ let secrets = InMemorySecretStore::default();
+ let journal = InMemoryOperationJournal::default();
+ core.bootstrap().expect("bootstrap");
+ let public_key = core
+ .import_secret_key(
+ SecretKeyInput::parse(
+ "7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7".to_owned(),
+ )
+ .expect("secret"),
+ &accounts,
+ &accounts,
+ &secrets,
+ &journal,
+ &FixedClock,
+ )
+ .expect("import")
+ .account()
+ .public_key();
+ if let Some(name) = cached_name {
+ profiles
+ .save_profile(&CachedProfile::new(
+ profile(public_key, name, 10),
+ UnixTimestamp::from_seconds(11).expect("time"),
+ ProfileRefreshStatus::Success,
+ ))
+ .expect("cache");
+ }
+ core.activate_account(
+ public_key,
+ &accounts,
+ &accounts,
+ profiles,
+ &secrets,
+ &FixedClock,
+ )
+ .expect("activate");
+ (core, public_key)
+ }
+
+ #[tokio::test]
+ async fn refresh_transitions_from_cache_through_loading_to_fresh_profile() {
+ let profiles = MemoryProfiles::default();
+ let (core, public_key) = active_core(&profiles, Some("Cached"));
+ assert_eq!(
+ core.snapshot()
+ .active_account()
+ .map(crate::ActiveAccountSnapshot::profile_state),
+ Some(ProfileLoadState::Cached)
+ );
+ let plan = core
+ .begin_profile_refresh()
+ .expect("begin refresh")
+ .expect("active refresh");
+ let loading = core.snapshot();
+ assert_eq!(
+ loading
+ .active_account()
+ .map(crate::ActiveAccountSnapshot::profile_state),
+ Some(ProfileLoadState::Loading)
+ );
+ assert_eq!(
+ loading
+ .active_account()
+ .map(crate::ActiveAccountSnapshot::relay_state),
+ Some(RelayConnectionState::Connecting)
+ );
+ let client = FixedClient(Ok(Some(profile(public_key, "Fresh", 20))));
+ let result = client.fetch_profile(plan.public_key(), plan.relays()).await;
+ core.complete_profile_refresh(&plan, result, &profiles, &FixedClock)
+ .expect("complete refresh");
+ assert_eq!(
+ core.snapshot()
+ .active_account()
+ .and_then(|active| active.profile())
+ .and_then(ProfileMetadata::name),
+ Some("Fresh")
+ );
+ }
+
+ #[tokio::test]
+ async fn refresh_failure_preserves_cached_profile_as_nonfatal_state() {
+ let profiles = MemoryProfiles::default();
+ let (core, public_key) = active_core(&profiles, Some("Cached"));
+ let cached = profile(public_key, "Cached", 10);
+ let error = SafeError::new(
+ SafeErrorCode::RelayConnectionFailed,
+ SafeMessage::new("The relay is offline."),
+ );
+
+ let snapshot = core
+ .refresh_profile_for_active_account(&profiles, &FixedClient(Err(error)), &FixedClock)
+ .await
+ .expect("nonfatal refresh");
+
+ assert_eq!(snapshot.recoverable_problem(), Some(error));
+ assert_eq!(
+ snapshot
+ .active_account()
+ .map(crate::ActiveAccountSnapshot::relay_state),
+ Some(RelayConnectionState::Degraded)
+ );
+ assert_eq!(
+ profiles
+ .load_profile(public_key)
+ .expect("load")
+ .expect("cache")
+ .candidate(),
+ &cached
+ );
+ }
+
+ #[tokio::test]
+ async fn refresh_discards_stale_completion_after_sign_out() {
+ let profiles = MemoryProfiles::default();
+ let (core, public_key) = active_core(&profiles, Some("Cached"));
+ let client = BlockingClient::new(Ok(Some(profile(public_key, "Stale", 20))));
+
+ let refresh = core.refresh_profile_for_active_account(&profiles, &client, &FixedClock);
+ let sign_out = async {
+ let permit = client.started.acquire().await.expect("refresh starts");
+ permit.forget();
+ core.sign_out().expect("sign out");
+ client.release.add_permits(1);
+ };
+ let (result, ()) = tokio::join!(refresh, sign_out);
+
+ assert!(
+ result
+ .expect("stale result is harmless")
+ .active_account()
+ .is_none()
+ );
+ assert_eq!(
+ profiles
+ .load_profile(public_key)
+ .expect("load")
+ .expect("cached")
+ .candidate()
+ .metadata()
+ .name(),
+ Some("Cached")
+ );
+ }
+
+ #[tokio::test]
+ async fn manual_refresh_is_repeatable_and_signed_out_safe() {
+ let profiles = MemoryProfiles::default();
+ let (core, public_key) = active_core(&profiles, None);
+ let first = core
+ .refresh_active_profile(
+ &profiles,
+ &FixedClient(Ok(Some(profile(public_key, "First", 10)))),
+ &FixedClock,
+ )
+ .await
+ .expect("first refresh");
+ let second = core
+ .refresh_active_profile(
+ &profiles,
+ &FixedClient(Ok(Some(profile(public_key, "Second", 20)))),
+ &FixedClock,
+ )
+ .await
+ .expect("second refresh");
+
+ assert!(second.revision() > first.revision());
+ assert_eq!(
+ second
+ .active_account()
+ .and_then(|active| active.profile())
+ .and_then(ProfileMetadata::name),
+ Some("Second")
+ );
+ let signed_out = core.sign_out().expect("sign out");
+ let no_op = core
+ .refresh_active_profile(&profiles, &FixedClient(Ok(None)), &FixedClock)
+ .await
+ .expect("signed-out no-op");
+ assert_eq!(no_op, signed_out);
+ }
+
+ #[test]
+ fn overlapping_refreshes_keep_the_newest_event_regardless_of_completion_order() {
+ let profiles = MemoryProfiles::default();
+ let (core, public_key) = active_core(&profiles, Some("Cached"));
+ let first = core
+ .begin_profile_refresh()
+ .expect("first")
+ .expect("active");
+ let second = core
+ .begin_profile_refresh()
+ .expect("second")
+ .expect("active");
+
+ core.complete_profile_refresh(
+ &second,
+ Ok(Some(profile(public_key, "Newest", 30))),
+ &profiles,
+ &FixedClock,
+ )
+ .expect("newest completes first");
+ let final_snapshot = core
+ .complete_profile_refresh(
+ &first,
+ Ok(Some(profile(public_key, "Older", 20))),
+ &profiles,
+ &FixedClock,
+ )
+ .expect("older completes last");
+
+ assert_eq!(
+ final_snapshot
+ .active_account()
+ .and_then(ActiveAccountSnapshot::profile)
+ .and_then(ProfileMetadata::name),
+ Some("Newest")
+ );
+ assert_eq!(
+ profiles
+ .load_profile(public_key)
+ .expect("cache")
+ .expect("profile")
+ .candidate()
+ .metadata()
+ .name(),
+ Some("Newest")
+ );
+ }
+}
diff --git a/crates/studio_application/src/recovery.rs b/crates/studio_application/src/recovery.rs
@@ -0,0 +1,358 @@
+use radroots_studio_domain::{PublicKey, SafeError};
+
+use crate::{
+ AccountOperationKind, AccountOperationPhase, AccountRepository, AppCore, AppStateRepository,
+ Clock, DurableAccountOperation, DurableOperationKind, DurableOperationPhase,
+ DurableOperationRepository, DurableTerminalOutcome, OperationJournal, SecretStore,
+};
+
+impl AppCore {
+ /// Reconciles durable request operations before public state is restored.
+ ///
+ /// # Errors
+ ///
+ /// Returns a safe credential, persistence, or recovery error while retaining the operation
+ /// at its last durable phase for a later retry.
+ pub fn recover_durable_operations(
+ &self,
+ accounts: &(impl AccountRepository + ?Sized),
+ app_state: &(impl AppStateRepository + ?Sized),
+ secrets: &(impl SecretStore + ?Sized),
+ operations: &(impl DurableOperationRepository + ?Sized),
+ clock: &(impl Clock + ?Sized),
+ ) -> Result<(), SafeError> {
+ for operation in operations.list_unfinished_durable_operations()? {
+ match operation.kind() {
+ DurableOperationKind::Create
+ | DurableOperationKind::Import
+ | DurableOperationKind::Repair => recover_durable_addition(
+ &operation, accounts, app_state, secrets, operations, clock,
+ )?,
+ DurableOperationKind::Remove => recover_durable_removal(
+ &operation, accounts, app_state, secrets, operations, clock,
+ )?,
+ }
+ }
+ Ok(())
+ }
+
+ /// Reconciles non-secret cross-resource journal entries before bootstrap.
+ ///
+ /// An empty journal does not access the credential store.
+ ///
+ /// # Errors
+ ///
+ /// Returns a safe credential, persistence, or recovery error while retaining
+ /// the unfinished journal entry for a later retry.
+ pub fn recover_pending_operations(
+ &self,
+ accounts: &(impl AccountRepository + ?Sized),
+ app_state: &(impl AppStateRepository + ?Sized),
+ secrets: &(impl SecretStore + ?Sized),
+ journal: &(impl OperationJournal + ?Sized),
+ clock: &(impl Clock + ?Sized),
+ ) -> Result<(), SafeError> {
+ for operation in journal.list_pending_operations()? {
+ match operation.kind() {
+ AccountOperationKind::Remove => {
+ recover_removal(&operation, accounts, app_state, secrets, journal, clock)?;
+ }
+ AccountOperationKind::Add | AccountOperationKind::Import => {
+ recover_addition(&operation, accounts, secrets, journal, clock)?;
+ }
+ }
+ }
+ Ok(())
+ }
+}
+
+fn recover_durable_removal(
+ operation: &DurableAccountOperation,
+ accounts: &(impl AccountRepository + ?Sized),
+ app_state: &(impl AppStateRepository + ?Sized),
+ secrets: &(impl SecretStore + ?Sized),
+ operations: &(impl DurableOperationRepository + ?Sized),
+ clock: &(impl Clock + ?Sized),
+) -> Result<(), SafeError> {
+ let request = operation.request_id();
+ let account = operation.account();
+ let mut phase = operation.phase();
+ if phase == DurableOperationPhase::IntentRecorded {
+ if secrets.contains(account)? {
+ secrets.delete(account)?;
+ }
+ operations.advance_durable_operation(
+ request,
+ phase,
+ DurableOperationPhase::CredentialDeleted,
+ clock.now(),
+ None,
+ )?;
+ phase = DurableOperationPhase::CredentialDeleted;
+ }
+ if phase == DurableOperationPhase::CredentialDeleted {
+ if accounts.find_account(account)?.is_some() {
+ accounts.remove_account(account)?;
+ }
+ operations.advance_durable_operation(
+ request,
+ phase,
+ DurableOperationPhase::MetadataDeleted,
+ clock.now(),
+ None,
+ )?;
+ phase = DurableOperationPhase::MetadataDeleted;
+ }
+ if phase == DurableOperationPhase::MetadataDeleted {
+ app_state.save_selected_account(operation.prior().selected_account())?;
+ operations.advance_durable_operation(
+ request,
+ phase,
+ DurableOperationPhase::SelectionCommitted,
+ clock.now(),
+ None,
+ )?;
+ phase = DurableOperationPhase::SelectionCommitted;
+ }
+ if phase == DurableOperationPhase::SelectionCommitted {
+ operations.finalize_durable_operation(
+ request,
+ phase,
+ DurableTerminalOutcome::Completed,
+ None,
+ clock.now(),
+ )?;
+ }
+ Ok(())
+}
+
+fn recover_durable_addition(
+ operation: &DurableAccountOperation,
+ accounts: &(impl AccountRepository + ?Sized),
+ app_state: &(impl AppStateRepository + ?Sized),
+ secrets: &(impl SecretStore + ?Sized),
+ operations: &(impl DurableOperationRepository + ?Sized),
+ clock: &(impl Clock + ?Sized),
+) -> Result<(), SafeError> {
+ let request = operation.request_id();
+ let account = operation.account();
+ match operation.phase() {
+ DurableOperationPhase::IntentRecorded => {
+ if secrets.contains(account)? {
+ secrets.delete(account)?;
+ }
+ operations.finalize_durable_operation(
+ request,
+ DurableOperationPhase::IntentRecorded,
+ DurableTerminalOutcome::Failed,
+ None,
+ clock.now(),
+ )?;
+ }
+ DurableOperationPhase::CredentialWritten => {
+ let metadata = accounts.find_account(account)?;
+ let committed = metadata.as_ref().is_some_and(|saved| {
+ saved.signer().availability()
+ == radroots_studio_domain::BindingAvailability::Available
+ });
+ if committed {
+ operations.advance_durable_operation(
+ request,
+ DurableOperationPhase::CredentialWritten,
+ DurableOperationPhase::MetadataCommitted,
+ clock.now(),
+ None,
+ )?;
+ finish_durable_selection(operation, app_state, operations, clock)?;
+ } else {
+ operations.advance_durable_operation(
+ request,
+ DurableOperationPhase::CredentialWritten,
+ DurableOperationPhase::CompensationPending,
+ clock.now(),
+ None,
+ )?;
+ compensate_durable_addition(
+ operation, accounts, app_state, secrets, operations, clock,
+ )?;
+ }
+ }
+ DurableOperationPhase::MetadataCommitted => {
+ finish_durable_selection(operation, app_state, operations, clock)?;
+ }
+ DurableOperationPhase::SelectionCommitted => {
+ operations.finalize_durable_operation(
+ request,
+ DurableOperationPhase::SelectionCommitted,
+ DurableTerminalOutcome::Completed,
+ None,
+ clock.now(),
+ )?;
+ }
+ DurableOperationPhase::CompensationPending => {
+ compensate_durable_addition(
+ operation, accounts, app_state, secrets, operations, clock,
+ )?;
+ }
+ DurableOperationPhase::CredentialDeleted | DurableOperationPhase::MetadataDeleted => {
+ operations.finalize_durable_operation(
+ request,
+ operation.phase(),
+ DurableTerminalOutcome::Failed,
+ None,
+ clock.now(),
+ )?;
+ }
+ DurableOperationPhase::Finalized => {}
+ }
+ Ok(())
+}
+
+fn finish_durable_selection(
+ operation: &DurableAccountOperation,
+ app_state: &(impl AppStateRepository + ?Sized),
+ operations: &(impl DurableOperationRepository + ?Sized),
+ clock: &(impl Clock + ?Sized),
+) -> Result<(), SafeError> {
+ app_state.save_selected_account(Some(operation.account()))?;
+ operations.advance_durable_operation(
+ operation.request_id(),
+ DurableOperationPhase::MetadataCommitted,
+ DurableOperationPhase::SelectionCommitted,
+ clock.now(),
+ None,
+ )?;
+ operations.finalize_durable_operation(
+ operation.request_id(),
+ DurableOperationPhase::SelectionCommitted,
+ DurableTerminalOutcome::Completed,
+ None,
+ clock.now(),
+ )?;
+ Ok(())
+}
+
+fn compensate_durable_addition(
+ operation: &DurableAccountOperation,
+ accounts: &(impl AccountRepository + ?Sized),
+ app_state: &(impl AppStateRepository + ?Sized),
+ secrets: &(impl SecretStore + ?Sized),
+ operations: &(impl DurableOperationRepository + ?Sized),
+ clock: &(impl Clock + ?Sized),
+) -> Result<(), SafeError> {
+ if secrets.contains(operation.account())? {
+ secrets.delete(operation.account())?;
+ }
+ if let Some(availability) = operation.prior().binding_availability() {
+ if let Some(previous) = accounts.find_account(operation.account())? {
+ accounts.update_account(&previous.with_binding_availability(availability))?;
+ }
+ } else if accounts.find_account(operation.account())?.is_some() {
+ accounts.remove_account(operation.account())?;
+ }
+ app_state.save_selected_account(operation.prior().selected_account())?;
+ operations.advance_durable_operation(
+ operation.request_id(),
+ DurableOperationPhase::CompensationPending,
+ DurableOperationPhase::CredentialDeleted,
+ clock.now(),
+ None,
+ )?;
+ operations.finalize_durable_operation(
+ operation.request_id(),
+ DurableOperationPhase::CredentialDeleted,
+ DurableTerminalOutcome::Failed,
+ None,
+ clock.now(),
+ )?;
+ Ok(())
+}
+
+fn recover_removal(
+ operation: &crate::PendingAccountOperation,
+ accounts: &(impl AccountRepository + ?Sized),
+ app_state: &(impl AppStateRepository + ?Sized),
+ secrets: &(impl SecretStore + ?Sized),
+ journal: &(impl OperationJournal + ?Sized),
+ clock: &(impl Clock + ?Sized),
+) -> Result<(), SafeError> {
+ let public_key = operation.subject();
+ if operation.phase() == AccountOperationPhase::IntentRecorded {
+ match secrets.delete(public_key) {
+ Ok(()) => {}
+ Err(error)
+ if error.code() == radroots_studio_domain::SafeErrorCode::CredentialMissing => {}
+ Err(error) => return Err(error),
+ }
+ journal.update_operation(
+ operation.id(),
+ AccountOperationPhase::CredentialDeleted,
+ clock.now(),
+ None,
+ )?;
+ }
+ if matches!(
+ operation.phase(),
+ AccountOperationPhase::IntentRecorded | AccountOperationPhase::CredentialDeleted
+ ) {
+ let registry = accounts.list_accounts()?;
+ let selected = removal_fallback(®istry, app_state.load_selected_account()?, public_key);
+ accounts.remove_account(public_key)?;
+ app_state.save_selected_account(selected)?;
+ journal.update_operation(
+ operation.id(),
+ AccountOperationPhase::MetadataDeleted,
+ clock.now(),
+ None,
+ )?;
+ }
+ journal.finalize_operation(operation.id())
+}
+
+fn recover_addition(
+ operation: &crate::PendingAccountOperation,
+ accounts: &(impl AccountRepository + ?Sized),
+ secrets: &(impl SecretStore + ?Sized),
+ journal: &(impl OperationJournal + ?Sized),
+ clock: &(impl Clock + ?Sized),
+) -> Result<(), SafeError> {
+ let has_metadata = accounts.find_account(operation.subject())?.is_some();
+ match operation.phase() {
+ AccountOperationPhase::CredentialWritten | AccountOperationPhase::CompensationPending
+ if !has_metadata =>
+ {
+ match secrets.delete(operation.subject()) {
+ Ok(()) => {}
+ Err(error)
+ if error.code() == radroots_studio_domain::SafeErrorCode::CredentialMissing => {
+ }
+ Err(error) => return Err(error),
+ }
+ journal.update_operation(
+ operation.id(),
+ AccountOperationPhase::MetadataDeleted,
+ clock.now(),
+ None,
+ )?;
+ }
+ _ => {}
+ }
+ journal.finalize_operation(operation.id())
+}
+
+fn removal_fallback(
+ registry: &[radroots_studio_domain::AccountSummary],
+ selected: Option<PublicKey>,
+ removed: PublicKey,
+) -> Option<PublicKey> {
+ if selected != Some(removed) {
+ return selected;
+ }
+ let index = registry
+ .iter()
+ .position(|account| account.public_key() == removed)?;
+ registry
+ .get(index + 1)
+ .or_else(|| index.checked_sub(1).and_then(|before| registry.get(before)))
+ .map(radroots_studio_domain::AccountSummary::public_key)
+}
diff --git a/crates/studio_application/src/secrets.rs b/crates/studio_application/src/secrets.rs
@@ -0,0 +1,308 @@
+use std::collections::BTreeMap;
+use std::sync::{Mutex, MutexGuard};
+
+use radroots_studio_domain::{PublicKey, SafeError, SafeErrorCode, SafeMessage, SecretKeyInput};
+use secrecy::{ExposeSecret, SecretString};
+
+pub trait SecretStore: Send + Sync {
+ /// Stores a credential under its canonical public key without overwriting.
+ ///
+ /// # Errors
+ ///
+ /// Returns a safe duplicate or keyring error without exposing the credential.
+ fn put(&self, public_key: PublicKey, secret: SecretKeyInput) -> Result<(), SafeError>;
+ /// Loads a credential into a non-cloneable redacted boundary value.
+ ///
+ /// # Errors
+ ///
+ /// Returns a safe missing-credential or keyring error.
+ fn load(&self, public_key: PublicKey) -> Result<SecretKeyInput, SafeError>;
+ /// Reports whether a credential exists without exposing it.
+ ///
+ /// # Errors
+ ///
+ /// Returns a safe keyring error when availability cannot be determined.
+ fn contains(&self, public_key: PublicKey) -> Result<bool, SafeError>;
+ /// Deletes a credential without affecting public account metadata.
+ ///
+ /// # Errors
+ ///
+ /// Returns a safe missing-credential or keyring error.
+ fn delete(&self, public_key: PublicKey) -> Result<(), SafeError>;
+}
+
+#[derive(Default)]
+pub struct InMemorySecretStore {
+ credentials: Mutex<BTreeMap<PublicKey, SecretString>>,
+}
+
+#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
+pub enum SecretStoreOperation {
+ Put,
+ Load,
+ Contains,
+ Delete,
+}
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub struct SecretStoreCall {
+ operation: SecretStoreOperation,
+ public_key: PublicKey,
+}
+
+impl SecretStoreCall {
+ #[must_use]
+ pub const fn operation(self) -> SecretStoreOperation {
+ self.operation
+ }
+
+ #[must_use]
+ pub const fn public_key(self) -> PublicKey {
+ self.public_key
+ }
+}
+
+#[derive(Default)]
+pub struct FailureSecretStore {
+ inner: InMemorySecretStore,
+ remaining_failures: Mutex<BTreeMap<SecretStoreOperation, usize>>,
+ calls: Mutex<Vec<SecretStoreCall>>,
+}
+
+impl FailureSecretStore {
+ pub fn fail_next(&self, operation: SecretStoreOperation) {
+ *self
+ .remaining_failures
+ .lock()
+ .unwrap_or_else(std::sync::PoisonError::into_inner)
+ .entry(operation)
+ .or_default() += 1;
+ }
+
+ #[must_use]
+ pub fn calls(&self) -> Vec<SecretStoreCall> {
+ self.calls
+ .lock()
+ .unwrap_or_else(std::sync::PoisonError::into_inner)
+ .clone()
+ }
+
+ fn record_and_should_fail(
+ &self,
+ operation: SecretStoreOperation,
+ public_key: PublicKey,
+ ) -> bool {
+ self.calls
+ .lock()
+ .unwrap_or_else(std::sync::PoisonError::into_inner)
+ .push(SecretStoreCall {
+ operation,
+ public_key,
+ });
+ let mut failures = self
+ .remaining_failures
+ .lock()
+ .unwrap_or_else(std::sync::PoisonError::into_inner);
+ let remaining = failures.entry(operation).or_default();
+ let should_fail = *remaining > 0;
+ *remaining = remaining.saturating_sub(1);
+ should_fail
+ }
+}
+
+impl SecretStore for FailureSecretStore {
+ fn put(&self, public_key: PublicKey, secret: SecretKeyInput) -> Result<(), SafeError> {
+ if self.record_and_should_fail(SecretStoreOperation::Put, public_key) {
+ return Err(keyring_unavailable());
+ }
+ self.inner.put(public_key, secret)
+ }
+
+ fn load(&self, public_key: PublicKey) -> Result<SecretKeyInput, SafeError> {
+ if self.record_and_should_fail(SecretStoreOperation::Load, public_key) {
+ return Err(keyring_unavailable());
+ }
+ self.inner.load(public_key)
+ }
+
+ fn contains(&self, public_key: PublicKey) -> Result<bool, SafeError> {
+ if self.record_and_should_fail(SecretStoreOperation::Contains, public_key) {
+ return Err(keyring_unavailable());
+ }
+ self.inner.contains(public_key)
+ }
+
+ fn delete(&self, public_key: PublicKey) -> Result<(), SafeError> {
+ if self.record_and_should_fail(SecretStoreOperation::Delete, public_key) {
+ return Err(keyring_unavailable());
+ }
+ self.inner.delete(public_key)
+ }
+}
+
+impl InMemorySecretStore {
+ fn credentials(&self) -> MutexGuard<'_, BTreeMap<PublicKey, SecretString>> {
+ self.credentials
+ .lock()
+ .unwrap_or_else(std::sync::PoisonError::into_inner)
+ }
+}
+
+impl SecretStore for InMemorySecretStore {
+ fn put(&self, public_key: PublicKey, secret: SecretKeyInput) -> Result<(), SafeError> {
+ let mut credentials = self.credentials();
+ if credentials.contains_key(&public_key) {
+ return Err(credential_exists());
+ }
+ let value = secret.with_exposed_secret(ToOwned::to_owned);
+ credentials.insert(public_key, SecretString::from(value));
+ Ok(())
+ }
+
+ fn load(&self, public_key: PublicKey) -> Result<SecretKeyInput, SafeError> {
+ let credentials = self.credentials();
+ let secret = credentials
+ .get(&public_key)
+ .ok_or_else(credential_missing)?;
+ SecretKeyInput::parse(secret.expose_secret().to_owned()).map_err(|_| credential_missing())
+ }
+
+ fn contains(&self, public_key: PublicKey) -> Result<bool, SafeError> {
+ Ok(self.credentials().contains_key(&public_key))
+ }
+
+ fn delete(&self, public_key: PublicKey) -> Result<(), SafeError> {
+ self.credentials()
+ .remove(&public_key)
+ .map(|_| ())
+ .ok_or_else(credential_missing)
+ }
+}
+
+const fn credential_exists() -> SafeError {
+ SafeError::new(
+ SafeErrorCode::AccountAlreadyExists,
+ SafeMessage::new("The Nostr account credential already exists."),
+ )
+}
+
+const fn credential_missing() -> SafeError {
+ SafeError::new(
+ SafeErrorCode::CredentialMissing,
+ SafeMessage::new("The Nostr account credential is missing."),
+ )
+}
+
+const fn keyring_unavailable() -> SafeError {
+ SafeError::new(
+ SafeErrorCode::KeyringUnavailable,
+ SafeMessage::new("The operating system credential store is unavailable."),
+ )
+}
+
+#[cfg(test)]
+mod tests {
+ use radroots_studio_domain::{PublicKey, SafeErrorCode, SecretKeyInput};
+
+ use super::{FailureSecretStore, InMemorySecretStore, SecretStore, SecretStoreOperation};
+
+ const SECRET: &str = "7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7";
+
+ #[test]
+ fn secret_store_puts_loads_checks_and_deletes_redacted_credentials() {
+ let store = InMemorySecretStore::default();
+ let public_key = PublicKey::from_bytes([1; 32]);
+ assert!(!store.contains(public_key).expect("contains"));
+ store
+ .put(
+ public_key,
+ SecretKeyInput::parse(SECRET.to_owned()).expect("secret"),
+ )
+ .expect("put");
+ assert!(store.contains(public_key).expect("contains"));
+ let loaded = store.load(public_key).expect("load");
+ assert_eq!(loaded.with_exposed_secret(str::len), 64);
+ store.delete(public_key).expect("delete");
+ assert!(!store.contains(public_key).expect("contains"));
+ }
+
+ #[test]
+ fn secret_store_rejects_duplicates_and_reports_missing_credentials() {
+ let store = InMemorySecretStore::default();
+ let public_key = PublicKey::from_bytes([2; 32]);
+ let Err(missing) = store.load(public_key) else {
+ panic!("missing credential was returned");
+ };
+ assert_eq!(missing.code(), SafeErrorCode::CredentialMissing);
+ store
+ .put(
+ public_key,
+ SecretKeyInput::parse(SECRET.to_owned()).expect("secret"),
+ )
+ .expect("put");
+ let duplicate = store
+ .put(
+ public_key,
+ SecretKeyInput::parse(SECRET.to_owned()).expect("secret"),
+ )
+ .expect_err("duplicate");
+ assert_eq!(duplicate.code(), SafeErrorCode::AccountAlreadyExists);
+ store.delete(public_key).expect("delete");
+ let missing = store.delete(public_key).expect_err("missing delete");
+ assert_eq!(missing.code(), SafeErrorCode::CredentialMissing);
+ }
+
+ #[test]
+ fn failure_secret_store_injects_each_boundary_without_mutating_state() {
+ let store = FailureSecretStore::default();
+ let public_key = PublicKey::from_bytes([3; 32]);
+ store.fail_next(SecretStoreOperation::Put);
+ let error = store
+ .put(
+ public_key,
+ SecretKeyInput::parse(SECRET.to_owned()).expect("secret"),
+ )
+ .expect_err("put failure");
+ assert_eq!(error.code(), SafeErrorCode::KeyringUnavailable);
+ assert!(!store.contains(public_key).expect("not written"));
+
+ store
+ .put(
+ public_key,
+ SecretKeyInput::parse(SECRET.to_owned()).expect("secret"),
+ )
+ .expect("put");
+ for operation in [
+ SecretStoreOperation::Load,
+ SecretStoreOperation::Contains,
+ SecretStoreOperation::Delete,
+ ] {
+ store.fail_next(operation);
+ let error = match operation {
+ SecretStoreOperation::Load => store.load(public_key).map(|_| ()),
+ SecretStoreOperation::Contains => store.contains(public_key).map(|_| ()),
+ SecretStoreOperation::Delete => store.delete(public_key),
+ SecretStoreOperation::Put => unreachable!("put tested separately"),
+ }
+ .expect_err("injected failure");
+ assert_eq!(error.code(), SafeErrorCode::KeyringUnavailable);
+ }
+ assert!(store.contains(public_key).expect("credential retained"));
+ }
+
+ #[test]
+ fn failure_secret_store_call_log_contains_only_public_identity() {
+ let store = FailureSecretStore::default();
+ let public_key = PublicKey::from_bytes([4; 32]);
+ store
+ .put(
+ public_key,
+ SecretKeyInput::parse(SECRET.to_owned()).expect("secret"),
+ )
+ .expect("put");
+ let calls = store.calls();
+ assert_eq!(calls[0].operation(), SecretStoreOperation::Put);
+ assert_eq!(calls[0].public_key(), public_key);
+ assert!(!format!("{calls:?}").contains(SECRET));
+ }
+}
diff --git a/crates/studio_application/src/session.rs b/crates/studio_application/src/session.rs
@@ -0,0 +1,250 @@
+use radroots_studio_domain::{PublicKey, SafeError, SafeErrorCode, SafeMessage};
+use radroots_studio_nostr::import_secret;
+
+use crate::{
+ AccountRepository, ActiveAccountSnapshot, AppCore, AppSnapshot, AppStateRepository, Clock,
+ ProfileLoadState, ProfileRepository, RelayConnectionState, SecretStore, StateTransition,
+};
+
+impl AppCore {
+ /// Drops the active session while retaining accounts, selection, and credentials.
+ ///
+ /// # Errors
+ ///
+ /// Returns a safe application-state error if the transition cannot be applied.
+ pub fn sign_out(&self) -> Result<AppSnapshot, SafeError> {
+ if matches!(self.snapshot().session(), crate::SessionState::SignedOut) {
+ return Ok(self.snapshot());
+ }
+ self.apply_transition(StateTransition::SignOut)
+ }
+
+ /// Validates and prepares a saved local account before replacing the active session.
+ ///
+ /// # Errors
+ ///
+ /// Returns a safe account, credential, profile-cache, persistence, or state
+ /// error while preserving any previously active session.
+ pub fn activate_account(
+ &self,
+ public_key: PublicKey,
+ accounts: &(impl AccountRepository + ?Sized),
+ app_state: &(impl AppStateRepository + ?Sized),
+ profiles: &(impl ProfileRepository + ?Sized),
+ secrets: &(impl SecretStore + ?Sized),
+ clock: &(impl Clock + ?Sized),
+ ) -> Result<AppSnapshot, SafeError> {
+ let account = accounts
+ .find_account(public_key)?
+ .ok_or_else(account_not_found)?;
+ self.apply_transition(StateTransition::BeginActivation(public_key))?;
+ let prepared = (|| {
+ let credential = secrets.load(public_key)?;
+ let imported = import_secret(credential)?;
+ let (derived_public_key, _npub, canonical_secret) = imported.into_parts();
+ drop(canonical_secret);
+ if derived_public_key != public_key {
+ return Err(invalid_credential());
+ }
+ let cached = profiles.load_profile(public_key)?;
+ let active = ActiveAccountSnapshot::new(
+ account.with_last_used_at(clock.now()),
+ RelayConnectionState::Disconnected,
+ if cached.is_some() {
+ ProfileLoadState::Cached
+ } else {
+ ProfileLoadState::Empty
+ },
+ cached.map(|profile| profile.candidate().metadata().clone()),
+ );
+ accounts.update_account(active.account())?;
+ app_state.save_selected_account(Some(public_key))?;
+ Ok(active)
+ })();
+ match prepared {
+ Ok(active) => {
+ self.apply_transition(StateTransition::ActivationSucceeded(Box::new(active)))
+ }
+ Err(error) => {
+ self.apply_transition(StateTransition::ActivationFailed(error))?;
+ Err(error)
+ }
+ }
+ }
+}
+
+const fn account_not_found() -> SafeError {
+ SafeError::new(
+ SafeErrorCode::AccountNotFound,
+ SafeMessage::new("The account was not found."),
+ )
+}
+
+const fn invalid_credential() -> SafeError {
+ SafeError::new(
+ SafeErrorCode::InvalidSecretKey,
+ SafeMessage::new("The Nostr account credential is invalid."),
+ )
+}
+
+#[cfg(test)]
+mod tests {
+ use radroots_studio_domain::{PublicKey, SafeError, SecretKeyInput, UnixTimestamp};
+
+ use crate::{
+ AppCore, CachedProfile, Clock, InMemoryAccountRepository, InMemoryOperationJournal,
+ InMemorySecretStore, ProfileRefreshStatus, ProfileRepository, RelayConfiguration,
+ SecretStore, SessionState,
+ };
+
+ #[derive(Default)]
+ struct EmptyProfiles;
+
+ impl ProfileRepository for EmptyProfiles {
+ fn load_profile(&self, _public_key: PublicKey) -> Result<Option<CachedProfile>, SafeError> {
+ Ok(None)
+ }
+
+ fn save_profile(&self, _profile: &CachedProfile) -> Result<(), SafeError> {
+ Ok(())
+ }
+
+ fn record_refresh_status(
+ &self,
+ _public_key: PublicKey,
+ _refreshed_at: UnixTimestamp,
+ _status: ProfileRefreshStatus,
+ ) -> Result<(), SafeError> {
+ Ok(())
+ }
+
+ fn remove_profile(&self, _public_key: PublicKey) -> Result<(), SafeError> {
+ Ok(())
+ }
+ }
+
+ struct FixedClock;
+
+ impl Clock for FixedClock {
+ fn now(&self) -> UnixTimestamp {
+ UnixTimestamp::from_seconds(30).expect("time")
+ }
+ }
+
+ fn input(value: &str) -> SecretKeyInput {
+ SecretKeyInput::parse(value.to_owned()).expect("input")
+ }
+
+ #[test]
+ fn activate_account_switches_only_after_candidate_is_ready() {
+ let core = AppCore::in_memory(RelayConfiguration::default());
+ let accounts = InMemoryAccountRepository::default();
+ let secrets = InMemorySecretStore::default();
+ let journal = InMemoryOperationJournal::default();
+ let profiles = EmptyProfiles;
+ core.bootstrap().expect("bootstrap");
+ let first = core
+ .import_secret_key(
+ input("7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7"),
+ &accounts,
+ &accounts,
+ &secrets,
+ &journal,
+ &FixedClock,
+ )
+ .expect("first")
+ .account()
+ .public_key();
+ let second = core
+ .import_secret_key(
+ input("1111111111111111111111111111111111111111111111111111111111111111"),
+ &accounts,
+ &accounts,
+ &secrets,
+ &journal,
+ &FixedClock,
+ )
+ .expect("second")
+ .account()
+ .public_key();
+ core.activate_account(
+ first,
+ &accounts,
+ &accounts,
+ &profiles,
+ &secrets,
+ &FixedClock,
+ )
+ .expect("activate first");
+ assert_eq!(core.snapshot().session(), SessionState::Active);
+ assert_eq!(
+ core.snapshot()
+ .active_account()
+ .map(|active| active.account().public_key()),
+ Some(first)
+ );
+
+ secrets.delete(second).expect("remove second credential");
+ let error = core
+ .activate_account(
+ second,
+ &accounts,
+ &accounts,
+ &profiles,
+ &secrets,
+ &FixedClock,
+ )
+ .expect_err("missing credential");
+ assert_eq!(
+ error.code(),
+ radroots_studio_domain::SafeErrorCode::CredentialMissing
+ );
+ assert_eq!(core.snapshot().session(), SessionState::Active);
+ assert_eq!(
+ core.snapshot()
+ .active_account()
+ .map(|active| active.account().public_key()),
+ Some(first)
+ );
+ }
+
+ #[test]
+ fn sign_out_retains_saved_account_selection_and_credential() {
+ let core = AppCore::in_memory(RelayConfiguration::default());
+ let accounts = InMemoryAccountRepository::default();
+ let secrets = InMemorySecretStore::default();
+ let journal = InMemoryOperationJournal::default();
+ let profiles = EmptyProfiles;
+ core.bootstrap().expect("bootstrap");
+ let public_key = core
+ .import_secret_key(
+ input("7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7"),
+ &accounts,
+ &accounts,
+ &secrets,
+ &journal,
+ &FixedClock,
+ )
+ .expect("import")
+ .account()
+ .public_key();
+ core.activate_account(
+ public_key,
+ &accounts,
+ &accounts,
+ &profiles,
+ &secrets,
+ &FixedClock,
+ )
+ .expect("activate");
+
+ let signed_out = core.sign_out().expect("sign out");
+ let repeated = core.sign_out().expect("idempotent sign out");
+ assert_eq!(signed_out, repeated);
+ assert_eq!(signed_out.session(), SessionState::SignedOut);
+ assert!(signed_out.active_account().is_none());
+ assert_eq!(signed_out.accounts().len(), 1);
+ assert_eq!(signed_out.selected_account(), Some(public_key));
+ assert!(secrets.contains(public_key).expect("credential retained"));
+ }
+}
diff --git a/crates/studio_application/src/snapshot.rs b/crates/studio_application/src/snapshot.rs
@@ -0,0 +1,385 @@
+use std::collections::HashSet;
+
+use radroots_studio_domain::{
+ AccountSummary, ProfileMetadata, PublicKey, RelayUrl, SafeError, SafeErrorCode, SafeMessage,
+};
+
+#[derive(Clone, Copy, Debug, Default, Eq, Ord, PartialEq, PartialOrd)]
+pub struct SnapshotRevision(u64);
+
+impl SnapshotRevision {
+ #[must_use]
+ pub const fn initial() -> Self {
+ Self(0)
+ }
+
+ #[must_use]
+ pub const fn from_value(value: u64) -> Self {
+ Self(value)
+ }
+
+ #[must_use]
+ pub const fn value(self) -> u64 {
+ self.0
+ }
+
+ #[must_use]
+ pub const fn next(self) -> Option<Self> {
+ match self.0.checked_add(1) {
+ Some(value) => Some(Self(value)),
+ None => None,
+ }
+ }
+}
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub enum AppLifecycle {
+ Booting,
+ Ready,
+ Fatal(SafeError),
+}
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub enum SessionState {
+ SignedOut,
+ Activating(PublicKey),
+ Active,
+ SigningOut,
+ Failed(SafeError),
+}
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub enum RelayConnectionState {
+ Disconnected,
+ Connecting,
+ Connected,
+ Degraded,
+ Error(SafeError),
+}
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub enum ProfileLoadState {
+ Empty,
+ Loading,
+ Cached,
+ Fresh,
+ Error(SafeError),
+}
+
+#[derive(Clone, Debug, Default, Eq, PartialEq)]
+pub struct RelayConfiguration(Vec<RelayUrl>);
+
+impl RelayConfiguration {
+ #[must_use]
+ pub fn new(relays: Vec<RelayUrl>) -> Self {
+ Self(relays)
+ }
+
+ #[must_use]
+ pub fn relays(&self) -> &[RelayUrl] {
+ &self.0
+ }
+}
+
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct ActiveAccountSnapshot {
+ account: AccountSummary,
+ relay_state: RelayConnectionState,
+ profile_state: ProfileLoadState,
+ profile: Option<ProfileMetadata>,
+}
+
+impl ActiveAccountSnapshot {
+ #[must_use]
+ pub const fn new(
+ account: AccountSummary,
+ relay_state: RelayConnectionState,
+ profile_state: ProfileLoadState,
+ profile: Option<ProfileMetadata>,
+ ) -> Self {
+ Self {
+ account,
+ relay_state,
+ profile_state,
+ profile,
+ }
+ }
+
+ #[must_use]
+ pub const fn account(&self) -> &AccountSummary {
+ &self.account
+ }
+
+ #[must_use]
+ pub const fn relay_state(&self) -> RelayConnectionState {
+ self.relay_state
+ }
+
+ #[must_use]
+ pub const fn profile_state(&self) -> ProfileLoadState {
+ self.profile_state
+ }
+
+ #[must_use]
+ pub const fn profile(&self) -> Option<&ProfileMetadata> {
+ self.profile.as_ref()
+ }
+}
+
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct AppSnapshot {
+ revision: SnapshotRevision,
+ lifecycle: AppLifecycle,
+ relay_configuration: RelayConfiguration,
+ accounts: Vec<AccountSummary>,
+ selected_account: Option<PublicKey>,
+ session: SessionState,
+ active_account: Option<ActiveAccountSnapshot>,
+ recoverable_problem: Option<SafeError>,
+}
+
+impl AppSnapshot {
+ #[must_use]
+ pub fn booting() -> Self {
+ Self {
+ revision: SnapshotRevision::initial(),
+ lifecycle: AppLifecycle::Booting,
+ relay_configuration: RelayConfiguration::default(),
+ accounts: Vec::new(),
+ selected_account: None,
+ session: SessionState::SignedOut,
+ active_account: None,
+ recoverable_problem: None,
+ }
+ }
+
+ #[must_use]
+ pub fn fatal(
+ revision: SnapshotRevision,
+ relay_configuration: RelayConfiguration,
+ error: SafeError,
+ ) -> Self {
+ Self {
+ revision,
+ lifecycle: AppLifecycle::Fatal(error),
+ relay_configuration,
+ accounts: Vec::new(),
+ selected_account: None,
+ session: SessionState::SignedOut,
+ active_account: None,
+ recoverable_problem: None,
+ }
+ }
+
+ /// Constructs a ready immutable snapshot after validating state invariants.
+ ///
+ /// # Errors
+ ///
+ /// Returns a safe invalid-state error for duplicate accounts, invalid
+ /// selection, or inconsistent active-session state.
+ pub fn ready(
+ revision: SnapshotRevision,
+ relay_configuration: RelayConfiguration,
+ accounts: Vec<AccountSummary>,
+ selected_account: Option<PublicKey>,
+ session: SessionState,
+ active_account: Option<ActiveAccountSnapshot>,
+ recoverable_problem: Option<SafeError>,
+ ) -> Result<Self, SafeError> {
+ validate_snapshot(
+ &accounts,
+ selected_account,
+ session,
+ active_account.as_ref(),
+ )?;
+ Ok(Self {
+ revision,
+ lifecycle: AppLifecycle::Ready,
+ relay_configuration,
+ accounts,
+ selected_account,
+ session,
+ active_account,
+ recoverable_problem,
+ })
+ }
+
+ #[must_use]
+ pub const fn revision(&self) -> SnapshotRevision {
+ self.revision
+ }
+
+ #[must_use]
+ pub const fn lifecycle(&self) -> AppLifecycle {
+ self.lifecycle
+ }
+
+ #[must_use]
+ pub const fn relay_configuration(&self) -> &RelayConfiguration {
+ &self.relay_configuration
+ }
+
+ #[must_use]
+ pub fn accounts(&self) -> &[AccountSummary] {
+ &self.accounts
+ }
+
+ #[must_use]
+ pub const fn selected_account(&self) -> Option<PublicKey> {
+ self.selected_account
+ }
+
+ #[must_use]
+ pub const fn session(&self) -> SessionState {
+ self.session
+ }
+
+ #[must_use]
+ pub const fn active_account(&self) -> Option<&ActiveAccountSnapshot> {
+ self.active_account.as_ref()
+ }
+
+ #[must_use]
+ pub const fn recoverable_problem(&self) -> Option<SafeError> {
+ self.recoverable_problem
+ }
+}
+
+fn validate_snapshot(
+ accounts: &[AccountSummary],
+ selected_account: Option<PublicKey>,
+ session: SessionState,
+ active_account: Option<&ActiveAccountSnapshot>,
+) -> Result<(), SafeError> {
+ let unique_accounts = accounts
+ .iter()
+ .map(AccountSummary::public_key)
+ .collect::<HashSet<_>>();
+ if unique_accounts.len() != accounts.len()
+ || (accounts.is_empty() != selected_account.is_none())
+ || selected_account.is_some_and(|key| !unique_accounts.contains(&key))
+ || active_account
+ .is_some_and(|active| !unique_accounts.contains(&active.account().public_key()))
+ || (matches!(session, SessionState::Active) && active_account.is_none())
+ || (matches!(session, SessionState::SignedOut) && active_account.is_some())
+ {
+ return Err(invalid_snapshot());
+ }
+ Ok(())
+}
+
+const fn invalid_snapshot() -> SafeError {
+ SafeError::new(
+ SafeErrorCode::InvalidApplicationState,
+ SafeMessage::new("The application state is invalid."),
+ )
+}
+
+#[cfg(test)]
+mod tests {
+ use radroots_studio_domain::{
+ AccountCreatedAt, AccountIdentity, AccountSummary, BindingAvailability, LocalSignerBinding,
+ PublicKey, UnixTimestamp,
+ };
+
+ use super::{
+ ActiveAccountSnapshot, AppLifecycle, AppSnapshot, ProfileLoadState, RelayConfiguration,
+ RelayConnectionState, SessionState, SnapshotRevision,
+ };
+
+ fn account(key_byte: u8) -> AccountSummary {
+ let public_key = PublicKey::from_bytes([key_byte; 32]);
+ AccountSummary::new(
+ AccountIdentity::derive(public_key).expect("identity"),
+ LocalSignerBinding::new(public_key, BindingAvailability::Available),
+ None,
+ AccountCreatedAt::new(UnixTimestamp::from_seconds(1).expect("valid time")),
+ None,
+ )
+ .expect("account")
+ }
+
+ #[test]
+ fn snapshot_boots_empty_and_secret_free() {
+ let snapshot = AppSnapshot::booting();
+ let debug = format!("{snapshot:?}");
+
+ assert_eq!(snapshot.revision(), SnapshotRevision::initial());
+ assert_eq!(snapshot.lifecycle(), AppLifecycle::Booting);
+ assert_eq!(snapshot.session(), SessionState::SignedOut);
+ assert!(snapshot.accounts().is_empty());
+ assert!(snapshot.selected_account().is_none());
+ assert!(snapshot.active_account().is_none());
+ assert!(snapshot.relay_configuration().relays().is_empty());
+ assert!(snapshot.recoverable_problem().is_none());
+ assert!(!debug.contains("nsec1"));
+ assert!(!debug.contains(&"11".repeat(32)));
+ }
+
+ #[test]
+ fn revision_helper_is_monotonic_and_checked() {
+ assert_eq!(
+ SnapshotRevision::initial()
+ .next()
+ .map(SnapshotRevision::value),
+ Some(1)
+ );
+ assert_eq!(SnapshotRevision::from_value(u64::MAX).next(), None);
+ }
+
+ #[test]
+ fn ready_snapshot_requires_valid_selection_and_active_session() {
+ let first = account(1);
+ let second = account(2);
+ let active = ActiveAccountSnapshot::new(
+ second.clone(),
+ RelayConnectionState::Disconnected,
+ ProfileLoadState::Empty,
+ None,
+ );
+ let valid = AppSnapshot::ready(
+ SnapshotRevision::from_value(1),
+ RelayConfiguration::default(),
+ vec![first.clone(), second.clone()],
+ Some(first.public_key()),
+ SessionState::Active,
+ Some(active),
+ None,
+ )
+ .expect("valid ready snapshot");
+
+ assert_eq!(valid.lifecycle(), AppLifecycle::Ready);
+ assert_eq!(valid.selected_account(), Some(first.public_key()));
+ assert_eq!(
+ valid
+ .active_account()
+ .map(|value| value.account().public_key()),
+ Some(second.public_key())
+ );
+
+ assert!(
+ AppSnapshot::ready(
+ SnapshotRevision::initial(),
+ RelayConfiguration::default(),
+ vec![first.clone(), first],
+ Some(second.public_key()),
+ SessionState::SignedOut,
+ None,
+ None,
+ )
+ .is_err()
+ );
+ assert!(
+ AppSnapshot::ready(
+ SnapshotRevision::initial(),
+ RelayConfiguration::default(),
+ vec![second.clone()],
+ Some(second.public_key()),
+ SessionState::Active,
+ None,
+ None,
+ )
+ .is_err()
+ );
+ }
+}
diff --git a/crates/studio_application/src/state_machine.rs b/crates/studio_application/src/state_machine.rs
@@ -0,0 +1,506 @@
+use radroots_studio_domain::{AccountSummary, PublicKey, SafeError, SafeErrorCode, SafeMessage};
+
+use crate::{ActiveAccountSnapshot, AppLifecycle, AppSnapshot, RelayConfiguration, SessionState};
+
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub enum StateTransition {
+ Bootstrap,
+ BootstrapRegistry {
+ accounts: Vec<AccountSummary>,
+ selected: Option<PublicKey>,
+ },
+ Fatal(SafeError),
+ ReplaceRegistry {
+ accounts: Vec<AccountSummary>,
+ selected: Option<PublicKey>,
+ },
+ ReplaceRegistryPreservingSession {
+ accounts: Vec<AccountSummary>,
+ selected: Option<PublicKey>,
+ },
+ Select(PublicKey),
+ BeginActivation(PublicKey),
+ ActivationSucceeded(Box<ActiveAccountSnapshot>),
+ ActivationFailed(SafeError),
+ UpdateActiveAccount {
+ expected: PublicKey,
+ active_account: Box<ActiveAccountSnapshot>,
+ problem: Option<SafeError>,
+ },
+ SignOut,
+ SetProblem(Option<SafeError>),
+}
+
+#[derive(Clone)]
+struct PreviousSession {
+ session: SessionState,
+ active_account: Option<ActiveAccountSnapshot>,
+}
+
+pub struct StateMachine {
+ snapshot: AppSnapshot,
+ pending_activation: Option<(PublicKey, PreviousSession)>,
+}
+
+impl StateMachine {
+ #[must_use]
+ pub fn booting() -> Self {
+ Self {
+ snapshot: AppSnapshot::booting(),
+ pending_activation: None,
+ }
+ }
+
+ #[must_use]
+ pub const fn snapshot(&self) -> &AppSnapshot {
+ &self.snapshot
+ }
+
+ /// Applies one deterministic state transition and returns the new snapshot.
+ ///
+ /// # Errors
+ ///
+ /// Returns a safe application error when the transition violates account,
+ /// revision, activation, or snapshot invariants.
+ pub fn apply(
+ &mut self,
+ transition: StateTransition,
+ relay_configuration: &RelayConfiguration,
+ ) -> Result<AppSnapshot, SafeError> {
+ let next_revision = self
+ .snapshot
+ .revision()
+ .next()
+ .ok_or_else(invalid_application_state)?;
+
+ let next = match transition {
+ StateTransition::Bootstrap => self.bootstrap(next_revision, relay_configuration)?,
+ StateTransition::BootstrapRegistry { accounts, selected } => {
+ self.bootstrap_registry(next_revision, relay_configuration, accounts, selected)?
+ }
+ StateTransition::Fatal(error) => {
+ AppSnapshot::fatal(next_revision, relay_configuration.clone(), error)
+ }
+ StateTransition::ReplaceRegistry { accounts, selected } => {
+ self.replace_registry(next_revision, accounts, selected)?
+ }
+ StateTransition::ReplaceRegistryPreservingSession { accounts, selected } => {
+ self.replace_registry_preserving_session(next_revision, accounts, selected)?
+ }
+ StateTransition::Select(public_key) => self.select(next_revision, public_key)?,
+ StateTransition::BeginActivation(public_key) => {
+ self.begin_activation(next_revision, public_key)?
+ }
+ StateTransition::ActivationSucceeded(active_account) => {
+ self.activation_succeeded(next_revision, *active_account)?
+ }
+ StateTransition::ActivationFailed(problem) => {
+ self.activation_failed(next_revision, problem)?
+ }
+ StateTransition::UpdateActiveAccount {
+ expected,
+ active_account,
+ problem,
+ } => self.update_active_account(next_revision, expected, *active_account, problem)?,
+ StateTransition::SignOut => self.sign_out(next_revision)?,
+ StateTransition::SetProblem(problem) => self.copy_ready(
+ next_revision,
+ self.snapshot.selected_account(),
+ self.snapshot.session(),
+ self.snapshot.active_account().cloned(),
+ problem,
+ )?,
+ };
+ self.snapshot = next.clone();
+ Ok(next)
+ }
+
+ fn bootstrap(
+ &self,
+ revision: crate::SnapshotRevision,
+ relay_configuration: &RelayConfiguration,
+ ) -> Result<AppSnapshot, SafeError> {
+ if !matches!(self.snapshot.lifecycle(), AppLifecycle::Booting) {
+ return Ok(self.snapshot.clone());
+ }
+ AppSnapshot::ready(
+ revision,
+ relay_configuration.clone(),
+ Vec::new(),
+ None,
+ SessionState::SignedOut,
+ None,
+ None,
+ )
+ }
+
+ fn bootstrap_registry(
+ &self,
+ revision: crate::SnapshotRevision,
+ relay_configuration: &RelayConfiguration,
+ accounts: Vec<AccountSummary>,
+ selected: Option<PublicKey>,
+ ) -> Result<AppSnapshot, SafeError> {
+ if !matches!(self.snapshot.lifecycle(), AppLifecycle::Booting) {
+ return Ok(self.snapshot.clone());
+ }
+ AppSnapshot::ready(
+ revision,
+ relay_configuration.clone(),
+ accounts,
+ selected,
+ SessionState::SignedOut,
+ None,
+ None,
+ )
+ }
+
+ fn replace_registry(
+ &mut self,
+ revision: crate::SnapshotRevision,
+ accounts: Vec<AccountSummary>,
+ selected: Option<PublicKey>,
+ ) -> Result<AppSnapshot, SafeError> {
+ self.pending_activation = None;
+ AppSnapshot::ready(
+ revision,
+ self.snapshot.relay_configuration().clone(),
+ accounts,
+ selected,
+ SessionState::SignedOut,
+ None,
+ None,
+ )
+ }
+
+ fn replace_registry_preserving_session(
+ &mut self,
+ revision: crate::SnapshotRevision,
+ accounts: Vec<AccountSummary>,
+ selected: Option<PublicKey>,
+ ) -> Result<AppSnapshot, SafeError> {
+ self.pending_activation = None;
+ AppSnapshot::ready(
+ revision,
+ self.snapshot.relay_configuration().clone(),
+ accounts,
+ selected,
+ self.snapshot.session(),
+ self.snapshot.active_account().cloned(),
+ None,
+ )
+ }
+
+ fn select(
+ &self,
+ revision: crate::SnapshotRevision,
+ public_key: PublicKey,
+ ) -> Result<AppSnapshot, SafeError> {
+ self.require_account(public_key)?;
+ self.copy_ready(
+ revision,
+ Some(public_key),
+ self.snapshot.session(),
+ self.snapshot.active_account().cloned(),
+ None,
+ )
+ }
+
+ fn begin_activation(
+ &mut self,
+ revision: crate::SnapshotRevision,
+ public_key: PublicKey,
+ ) -> Result<AppSnapshot, SafeError> {
+ self.require_account(public_key)?;
+ if self.pending_activation.is_some() {
+ return Err(invalid_application_state());
+ }
+ self.pending_activation = Some((
+ public_key,
+ PreviousSession {
+ session: self.snapshot.session(),
+ active_account: self.snapshot.active_account().cloned(),
+ },
+ ));
+ self.copy_ready(
+ revision,
+ self.snapshot.selected_account(),
+ SessionState::Activating(public_key),
+ self.snapshot.active_account().cloned(),
+ None,
+ )
+ }
+
+ fn activation_succeeded(
+ &mut self,
+ revision: crate::SnapshotRevision,
+ active_account: ActiveAccountSnapshot,
+ ) -> Result<AppSnapshot, SafeError> {
+ let Some((target, _previous)) = self.pending_activation.as_ref() else {
+ return Err(invalid_application_state());
+ };
+ if active_account.account().public_key() != *target {
+ return Err(invalid_application_state());
+ }
+ let target = *target;
+ self.pending_activation = None;
+ self.copy_ready(
+ revision,
+ Some(target),
+ SessionState::Active,
+ Some(active_account),
+ None,
+ )
+ }
+
+ fn activation_failed(
+ &mut self,
+ revision: crate::SnapshotRevision,
+ problem: SafeError,
+ ) -> Result<AppSnapshot, SafeError> {
+ let Some((_target, previous)) = self.pending_activation.take() else {
+ return Err(invalid_application_state());
+ };
+ self.copy_ready(
+ revision,
+ self.snapshot.selected_account(),
+ previous.session,
+ previous.active_account,
+ Some(problem),
+ )
+ }
+
+ fn sign_out(&mut self, revision: crate::SnapshotRevision) -> Result<AppSnapshot, SafeError> {
+ self.pending_activation = None;
+ self.copy_ready(
+ revision,
+ self.snapshot.selected_account(),
+ SessionState::SignedOut,
+ None,
+ None,
+ )
+ }
+
+ fn update_active_account(
+ &self,
+ revision: crate::SnapshotRevision,
+ expected: PublicKey,
+ active_account: ActiveAccountSnapshot,
+ problem: Option<SafeError>,
+ ) -> Result<AppSnapshot, SafeError> {
+ if !matches!(self.snapshot.session(), SessionState::Active)
+ || self
+ .snapshot
+ .active_account()
+ .map(|active| active.account().public_key())
+ != Some(expected)
+ || active_account.account().public_key() != expected
+ {
+ return Err(invalid_application_state());
+ }
+ self.copy_ready(
+ revision,
+ self.snapshot.selected_account(),
+ SessionState::Active,
+ Some(active_account),
+ problem,
+ )
+ }
+
+ fn require_account(&self, public_key: PublicKey) -> Result<(), SafeError> {
+ if self
+ .snapshot
+ .accounts()
+ .iter()
+ .any(|account| account.public_key() == public_key)
+ {
+ Ok(())
+ } else {
+ Err(account_not_found())
+ }
+ }
+
+ fn copy_ready(
+ &self,
+ revision: crate::SnapshotRevision,
+ selected_account: Option<PublicKey>,
+ session: SessionState,
+ active_account: Option<ActiveAccountSnapshot>,
+ recoverable_problem: Option<SafeError>,
+ ) -> Result<AppSnapshot, SafeError> {
+ AppSnapshot::ready(
+ revision,
+ self.snapshot.relay_configuration().clone(),
+ self.snapshot.accounts().to_vec(),
+ selected_account,
+ session,
+ active_account,
+ recoverable_problem,
+ )
+ }
+}
+
+const fn invalid_application_state() -> SafeError {
+ SafeError::new(
+ SafeErrorCode::InvalidApplicationState,
+ SafeMessage::new("The application state is invalid."),
+ )
+}
+
+const fn account_not_found() -> SafeError {
+ SafeError::new(
+ SafeErrorCode::AccountNotFound,
+ SafeMessage::new("The account was not found."),
+ )
+}
+
+#[cfg(test)]
+mod tests {
+ use radroots_studio_domain::{
+ AccountCreatedAt, AccountIdentity, AccountSummary, BindingAvailability, LocalSignerBinding,
+ PublicKey, SafeError, SafeErrorCode, SafeMessage, UnixTimestamp,
+ };
+
+ use crate::{
+ ActiveAccountSnapshot, ProfileLoadState, RelayConfiguration, RelayConnectionState,
+ SessionState, StateMachine, StateTransition,
+ };
+
+ fn account(key_byte: u8) -> AccountSummary {
+ let public_key = PublicKey::from_bytes([key_byte; 32]);
+ AccountSummary::new(
+ AccountIdentity::derive(public_key).expect("identity"),
+ LocalSignerBinding::new(public_key, BindingAvailability::Available),
+ None,
+ AccountCreatedAt::new(UnixTimestamp::from_seconds(1).expect("valid time")),
+ None,
+ )
+ .expect("account")
+ }
+
+ fn active(account: AccountSummary) -> ActiveAccountSnapshot {
+ ActiveAccountSnapshot::new(
+ account,
+ RelayConnectionState::Disconnected,
+ ProfileLoadState::Empty,
+ None,
+ )
+ }
+
+ #[test]
+ fn state_machine_command_trace_preserves_working_session_on_failed_replacement() {
+ let first = account(1);
+ let second = account(2);
+ let mut machine = StateMachine::booting();
+ let relays = RelayConfiguration::default();
+ let problem = SafeError::new(
+ SafeErrorCode::CredentialMissing,
+ SafeMessage::new("The account credential is missing."),
+ );
+
+ machine
+ .apply(StateTransition::Bootstrap, &relays)
+ .expect("bootstrap");
+ machine
+ .apply(
+ StateTransition::ReplaceRegistry {
+ accounts: vec![first.clone(), second.clone()],
+ selected: Some(first.public_key()),
+ },
+ &relays,
+ )
+ .expect("load registry");
+ machine
+ .apply(
+ StateTransition::BeginActivation(first.public_key()),
+ &relays,
+ )
+ .expect("begin first activation");
+ machine
+ .apply(
+ StateTransition::ActivationSucceeded(Box::new(active(first.clone()))),
+ &relays,
+ )
+ .expect("activate first");
+ machine
+ .apply(StateTransition::Select(second.public_key()), &relays)
+ .expect("select second");
+ let pending = machine
+ .apply(
+ StateTransition::BeginActivation(second.public_key()),
+ &relays,
+ )
+ .expect("begin replacement");
+ let restored = machine
+ .apply(StateTransition::ActivationFailed(problem), &relays)
+ .expect("fail replacement");
+
+ assert_eq!(
+ pending.session(),
+ SessionState::Activating(second.public_key())
+ );
+ assert_eq!(
+ pending
+ .active_account()
+ .map(|value| value.account().public_key()),
+ Some(first.public_key())
+ );
+ assert_eq!(restored.session(), SessionState::Active);
+ assert_eq!(restored.selected_account(), Some(second.public_key()));
+ assert_eq!(
+ restored
+ .active_account()
+ .map(|value| value.account().public_key()),
+ Some(first.public_key())
+ );
+ assert_eq!(restored.recoverable_problem(), Some(problem));
+ assert_eq!(restored.revision().value(), 7);
+ }
+
+ #[test]
+ fn state_machine_rejects_missing_targets_and_signs_out_without_deleting() {
+ let account = account(1);
+ let mut machine = StateMachine::booting();
+ let relays = RelayConfiguration::default();
+ machine
+ .apply(StateTransition::Bootstrap, &relays)
+ .expect("bootstrap");
+ machine
+ .apply(
+ StateTransition::ReplaceRegistry {
+ accounts: vec![account.clone()],
+ selected: Some(account.public_key()),
+ },
+ &relays,
+ )
+ .expect("load registry");
+
+ let error = machine
+ .apply(
+ StateTransition::Select(PublicKey::from_bytes([9_u8; 32])),
+ &relays,
+ )
+ .expect_err("missing account");
+ assert_eq!(error.code(), SafeErrorCode::AccountNotFound);
+
+ machine
+ .apply(
+ StateTransition::BeginActivation(account.public_key()),
+ &relays,
+ )
+ .expect("begin activation");
+ machine
+ .apply(
+ StateTransition::ActivationSucceeded(Box::new(active(account.clone()))),
+ &relays,
+ )
+ .expect("activate");
+ let signed_out = machine
+ .apply(StateTransition::SignOut, &relays)
+ .expect("sign out");
+
+ assert_eq!(signed_out.accounts(), &[account]);
+ assert_eq!(signed_out.session(), SessionState::SignedOut);
+ assert!(signed_out.active_account().is_none());
+ }
+}
diff --git a/crates/studio_application/tests/redaction.rs b/crates/studio_application/tests/redaction.rs
@@ -0,0 +1,47 @@
+use radroots_studio_application::{
+ AppSnapshot, RelayConfiguration, SessionState, SnapshotRevision,
+};
+use radroots_studio_domain::{
+ AccountCreatedAt, AccountIdentity, AccountSummary, BindingAvailability, LocalSignerBinding,
+ PublicKey, SafeError, SafeErrorCode, SafeMessage, UnixTimestamp,
+};
+
+const SECRET_HEX: &str = "1111111111111111111111111111111111111111111111111111111111111111";
+const SECRET_NSEC: &str = "nsec1vl029mgpspedva04g90vltkh6fvh240zqtv9k0t9af8935ke9laqsnlfe5";
+fn assert_redacted(text: &str) {
+ assert!(!text.contains(SECRET_HEX));
+ assert!(!text.contains(SECRET_NSEC));
+ assert!(!text.contains("nsec1"));
+}
+
+#[test]
+fn redaction_guards_public_snapshot_and_safe_error_debug() {
+ let account = AccountSummary::new(
+ AccountIdentity::derive(PublicKey::from_bytes([2; 32])).expect("identity"),
+ LocalSignerBinding::new(
+ PublicKey::from_bytes([2; 32]),
+ BindingAvailability::Available,
+ ),
+ None,
+ AccountCreatedAt::new(UnixTimestamp::from_seconds(1).expect("time")),
+ None,
+ )
+ .expect("account");
+ let snapshot = AppSnapshot::ready(
+ SnapshotRevision::from_value(1),
+ RelayConfiguration::default(),
+ vec![account.clone()],
+ Some(account.public_key()),
+ SessionState::SignedOut,
+ None,
+ None,
+ )
+ .expect("snapshot");
+ let error = SafeError::new(
+ SafeErrorCode::KeyringUnavailable,
+ SafeMessage::new("The operating system credential store is unavailable."),
+ );
+
+ assert_redacted(&format!("{snapshot:?}"));
+ assert_redacted(&format!("{error:?} {error}"));
+}
diff --git a/crates/studio_domain/Cargo.toml b/crates/studio_domain/Cargo.toml
@@ -0,0 +1,16 @@
+[package]
+name = "radroots-studio-domain"
+version.workspace = true
+edition.workspace = true
+rust-version.workspace = true
+license.workspace = true
+repository.workspace = true
+
+[dependencies]
+bech32.workspace = true
+secrecy.workspace = true
+zeroize.workspace = true
+url.workspace = true
+
+[lints]
+workspace = true
diff --git a/crates/studio_domain/src/account.rs b/crates/studio_domain/src/account.rs
@@ -0,0 +1,423 @@
+//! Public account metadata and lifecycle values.
+
+use crate::time::UnixTimestamp;
+use crate::{Npub, PublicKey, SafeError, SafeErrorCode, SafeMessage};
+
+const MAX_ACCOUNT_LABEL_CHARS: usize = 80;
+
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct AccountIdentity {
+ public_key: PublicKey,
+ npub: Npub,
+}
+
+impl AccountIdentity {
+ /// Constructs one canonical Nostr account identity and derives its npub.
+ ///
+ /// # Errors
+ ///
+ /// Returns a safe public-key error if canonical NIP-19 encoding fails.
+ pub fn derive(public_key: PublicKey) -> Result<Self, SafeError> {
+ Ok(Self {
+ public_key,
+ npub: Npub::derive(public_key)?,
+ })
+ }
+
+ /// Reconstitutes persisted identity only when its public forms agree.
+ ///
+ /// # Errors
+ ///
+ /// Returns a safe public-key error for a mismatched or malformed npub.
+ pub fn verify(public_key: PublicKey, npub: String) -> Result<Self, SafeError> {
+ Ok(Self {
+ public_key,
+ npub: Npub::verify(public_key, npub)?,
+ })
+ }
+
+ #[must_use]
+ pub const fn public_key(&self) -> PublicKey {
+ self.public_key
+ }
+
+ #[must_use]
+ pub const fn npub(&self) -> &Npub {
+ &self.npub
+ }
+}
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub struct LocalSignerBinding {
+ account: PublicKey,
+ availability: BindingAvailability,
+}
+
+impl LocalSignerBinding {
+ #[must_use]
+ pub const fn new(account: PublicKey, availability: BindingAvailability) -> Self {
+ Self {
+ account,
+ availability,
+ }
+ }
+
+ #[must_use]
+ pub const fn account(self) -> PublicKey {
+ self.account
+ }
+
+ #[must_use]
+ pub const fn availability(self) -> BindingAvailability {
+ self.availability
+ }
+
+ #[must_use]
+ pub const fn repair_action(self) -> Option<BindingRepairAction> {
+ match self.availability {
+ BindingAvailability::Available => None,
+ BindingAvailability::CredentialMissing => Some(BindingRepairAction::ImportCredential),
+ BindingAvailability::StoreUnavailable => {
+ Some(BindingRepairAction::RetryCredentialStore)
+ }
+ }
+ }
+
+ /// Records a missing credential after a successful store lookup.
+ ///
+ /// # Errors
+ ///
+ /// Returns a safe state error unless the binding was previously available.
+ pub fn mark_credential_missing(&mut self) -> Result<(), SafeError> {
+ self.transition(
+ BindingAvailability::Available,
+ BindingAvailability::CredentialMissing,
+ )
+ }
+
+ pub fn mark_store_unavailable(&mut self) {
+ self.availability = BindingAvailability::StoreUnavailable;
+ }
+
+ /// Completes an explicit credential repair.
+ ///
+ /// # Errors
+ ///
+ /// Returns a safe state error unless a credential was missing.
+ pub fn repair_credential(&mut self) -> Result<(), SafeError> {
+ self.transition(
+ BindingAvailability::CredentialMissing,
+ BindingAvailability::Available,
+ )
+ }
+
+ /// Resolves a recovered store lookup to its observed credential state.
+ ///
+ /// # Errors
+ ///
+ /// Returns a safe state error unless the credential store was unavailable.
+ pub fn resolve_store_recovery(&mut self, credential_present: bool) -> Result<(), SafeError> {
+ if self.availability != BindingAvailability::StoreUnavailable {
+ return Err(invalid_account_metadata());
+ }
+ self.availability = if credential_present {
+ BindingAvailability::Available
+ } else {
+ BindingAvailability::CredentialMissing
+ };
+ Ok(())
+ }
+
+ fn transition(
+ &mut self,
+ expected: BindingAvailability,
+ next: BindingAvailability,
+ ) -> Result<(), SafeError> {
+ if self.availability != expected {
+ return Err(invalid_account_metadata());
+ }
+ self.availability = next;
+ Ok(())
+ }
+}
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub enum BindingAvailability {
+ Available,
+ CredentialMissing,
+ StoreUnavailable,
+}
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub enum BindingRepairAction {
+ ImportCredential,
+ RetryCredentialStore,
+}
+
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct AccountLabel(String);
+
+impl AccountLabel {
+ /// Trims and validates an optional human-assigned account label value.
+ ///
+ /// # Errors
+ ///
+ /// Returns a safe metadata error when the resulting label is empty, too
+ /// long, or contains a control character.
+ pub fn parse(value: &str) -> Result<Self, SafeError> {
+ let normalized = value.trim();
+ if normalized.is_empty()
+ || normalized.chars().count() > MAX_ACCOUNT_LABEL_CHARS
+ || normalized.chars().any(char::is_control)
+ {
+ return Err(invalid_account_metadata());
+ }
+ Ok(Self(normalized.to_owned()))
+ }
+
+ #[must_use]
+ pub fn as_str(&self) -> &str {
+ &self.0
+ }
+}
+
+#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
+pub struct AccountCreatedAt(UnixTimestamp);
+
+impl AccountCreatedAt {
+ #[must_use]
+ pub const fn new(timestamp: UnixTimestamp) -> Self {
+ Self(timestamp)
+ }
+
+ #[must_use]
+ pub const fn timestamp(self) -> UnixTimestamp {
+ self.0
+ }
+}
+
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct AccountSummary {
+ identity: AccountIdentity,
+ signer: LocalSignerBinding,
+ label: Option<AccountLabel>,
+ created_at: AccountCreatedAt,
+ last_used_at: Option<UnixTimestamp>,
+}
+
+impl AccountSummary {
+ /// Creates an account summary whose identity and signer binding refer to the same account.
+ ///
+ /// # Errors
+ ///
+ /// Returns an invalid-account-metadata error when the signer binding belongs to a different
+ /// public key.
+ pub fn new(
+ identity: AccountIdentity,
+ signer: LocalSignerBinding,
+ label: Option<AccountLabel>,
+ created_at: AccountCreatedAt,
+ last_used_at: Option<UnixTimestamp>,
+ ) -> Result<Self, SafeError> {
+ if identity.public_key() != signer.account() {
+ return Err(invalid_account_metadata());
+ }
+ Ok(Self {
+ identity,
+ signer,
+ label,
+ created_at,
+ last_used_at,
+ })
+ }
+
+ #[must_use]
+ pub const fn public_key(&self) -> PublicKey {
+ self.identity.public_key()
+ }
+
+ #[must_use]
+ pub fn npub(&self) -> &Npub {
+ self.identity.npub()
+ }
+
+ #[must_use]
+ pub const fn signer(&self) -> LocalSignerBinding {
+ self.signer
+ }
+
+ #[must_use]
+ pub fn label(&self) -> Option<&AccountLabel> {
+ self.label.as_ref()
+ }
+
+ #[must_use]
+ pub const fn created_at(&self) -> AccountCreatedAt {
+ self.created_at
+ }
+
+ #[must_use]
+ pub const fn last_used_at(&self) -> Option<UnixTimestamp> {
+ self.last_used_at
+ }
+
+ #[must_use]
+ pub fn with_binding_availability(&self, availability: BindingAvailability) -> Self {
+ Self {
+ identity: self.identity.clone(),
+ signer: LocalSignerBinding::new(self.public_key(), availability),
+ label: self.label.clone(),
+ created_at: self.created_at,
+ last_used_at: self.last_used_at,
+ }
+ }
+
+ #[must_use]
+ pub fn with_last_used_at(&self, last_used_at: UnixTimestamp) -> Self {
+ Self {
+ identity: self.identity.clone(),
+ signer: self.signer,
+ label: self.label.clone(),
+ created_at: self.created_at,
+ last_used_at: Some(last_used_at),
+ }
+ }
+
+ #[must_use]
+ pub fn display_label(&self) -> String {
+ self.label
+ .as_ref()
+ .map_or_else(|| self.npub().short(), |label| label.as_str().to_owned())
+ }
+}
+
+const fn invalid_account_metadata() -> SafeError {
+ SafeError::new(
+ SafeErrorCode::InvalidAccountMetadata,
+ SafeMessage::new("The account metadata is invalid."),
+ )
+}
+
+#[cfg(test)]
+mod tests {
+ use crate::PublicKey;
+ use crate::time::UnixTimestamp;
+
+ use super::{
+ AccountCreatedAt, AccountIdentity, AccountLabel, AccountSummary, BindingAvailability,
+ BindingRepairAction, LocalSignerBinding,
+ };
+
+ const DERIVED_NPUB: &str = "npub1qurswpc8qurswpc8qurswpc8qurswpc8qurswpc8qurswpc8qursnvjvl7";
+ const MISMATCHED_NPUB: &str = "npub10elfcs4fr0l0r8af98jlmgdh9c8tcxjvz9qkw038js35mp4dma8qzvjptg";
+
+ fn account(label: Option<AccountLabel>) -> AccountSummary {
+ let public_key = PublicKey::from_bytes([7_u8; 32]);
+ AccountSummary::new(
+ AccountIdentity::derive(public_key).expect("identity"),
+ LocalSignerBinding::new(public_key, BindingAvailability::Available),
+ label,
+ AccountCreatedAt::new(UnixTimestamp::from_seconds(10).expect("valid time")),
+ None,
+ )
+ .expect("account")
+ }
+
+ #[test]
+ fn account_label_is_trimmed_bounded_and_control_free() {
+ let label = AccountLabel::parse(" Farm account ").expect("valid label");
+ assert_eq!(label.as_str(), "Farm account");
+
+ for invalid in ["", " ", "line\nbreak", &"x".repeat(81)] {
+ assert!(AccountLabel::parse(invalid).is_err());
+ }
+ }
+
+ #[test]
+ fn account_display_prefers_label_then_shortened_npub() {
+ let labelled = account(Some(AccountLabel::parse("Farm").expect("valid label")));
+ let unlabelled = account(None);
+
+ assert_eq!(labelled.display_label(), "Farm");
+ assert_eq!(unlabelled.display_label(), "npub1qurswpc…rsnvjvl7");
+ }
+
+ #[test]
+ fn local_account_summary_contains_public_metadata_only() {
+ let account = account(None);
+ let debug = format!("{account:?}");
+
+ assert_eq!(
+ account.signer().availability(),
+ BindingAvailability::Available
+ );
+ assert!(account.label().is_none());
+ assert!(account.last_used_at().is_none());
+ assert_eq!(account.created_at().timestamp().as_seconds(), 10);
+ assert_eq!(account.public_key(), PublicKey::from_bytes([7_u8; 32]));
+ assert_eq!(account.npub().as_str(), DERIVED_NPUB);
+ assert!(!debug.contains("nsec1"));
+ assert!(!debug.contains(&"11".repeat(32)));
+ }
+
+ #[test]
+ fn account_identity_derives_npub_and_rejects_mismatched_persisted_forms() {
+ let public_key = PublicKey::from_bytes([7_u8; 32]);
+ let identity = AccountIdentity::derive(public_key).expect("identity");
+ assert_eq!(identity.public_key(), public_key);
+ assert_eq!(identity.npub().as_str(), DERIVED_NPUB);
+ assert_eq!(
+ AccountIdentity::verify(public_key, DERIVED_NPUB.to_owned()).expect("verified"),
+ identity
+ );
+ assert!(AccountIdentity::verify(public_key, MISMATCHED_NPUB.to_owned()).is_err());
+ assert!(
+ AccountIdentity::verify(
+ PublicKey::from_bytes([8_u8; 32]),
+ MISMATCHED_NPUB.to_owned()
+ )
+ .is_err()
+ );
+ }
+
+ #[test]
+ fn local_signer_binding_carries_only_canonical_account_identity() {
+ let public_key = PublicKey::from_bytes([9_u8; 32]);
+ let identity = AccountIdentity::derive(public_key).expect("identity");
+ let binding = LocalSignerBinding::new(public_key, BindingAvailability::Available);
+
+ assert_eq!(binding.account(), identity.public_key());
+ assert!(!format!("{binding:?}").contains("nsec1"));
+ }
+
+ #[test]
+ fn local_binding_repair_transitions_are_typed_and_fail_closed() {
+ let public_key = PublicKey::from_bytes([9_u8; 32]);
+ let mut binding = LocalSignerBinding::new(public_key, BindingAvailability::Available);
+ assert_eq!(binding.repair_action(), None);
+ assert!(binding.repair_credential().is_err());
+
+ binding
+ .mark_credential_missing()
+ .expect("missing credential");
+ assert_eq!(
+ binding.repair_action(),
+ Some(BindingRepairAction::ImportCredential)
+ );
+ binding.repair_credential().expect("repair");
+
+ binding.mark_store_unavailable();
+ assert_eq!(
+ binding.repair_action(),
+ Some(BindingRepairAction::RetryCredentialStore)
+ );
+ binding
+ .resolve_store_recovery(false)
+ .expect("store recovery");
+ assert_eq!(
+ binding.availability(),
+ BindingAvailability::CredentialMissing
+ );
+ assert!(binding.resolve_store_recovery(true).is_err());
+ }
+}
diff --git a/crates/studio_domain/src/error.rs b/crates/studio_domain/src/error.rs
@@ -0,0 +1,110 @@
+use std::error::Error;
+use std::fmt::{self, Debug, Display, Formatter};
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+#[non_exhaustive]
+pub enum SafeErrorCode {
+ InvalidPublicKey,
+ InvalidSecretKey,
+ InvalidAccountMetadata,
+ InvalidProfileMetadata,
+ InvalidApplicationState,
+ AccountAlreadyExists,
+ AccountNotFound,
+ KeyringUnavailable,
+ CredentialMissing,
+ StorageUnavailable,
+ StorageCorrupt,
+ PendingOperationRecoveryRequired,
+ InvalidRelayConfiguration,
+ RelayConnectionFailed,
+ ProfileRefreshFailed,
+ ObserverRegistrationFailed,
+ NativeLibraryLoadFailed,
+}
+
+#[derive(Clone, Copy, Eq, PartialEq)]
+pub struct SafeMessage(&'static str);
+
+impl SafeMessage {
+ #[must_use]
+ pub const fn new(message: &'static str) -> Self {
+ Self(message)
+ }
+
+ #[must_use]
+ pub const fn as_str(self) -> &'static str {
+ self.0
+ }
+}
+
+impl Debug for SafeMessage {
+ fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result {
+ formatter.debug_tuple("SafeMessage").field(&self.0).finish()
+ }
+}
+
+impl Display for SafeMessage {
+ fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result {
+ formatter.write_str(self.0)
+ }
+}
+
+#[derive(Clone, Copy, Eq, PartialEq)]
+pub struct SafeError {
+ code: SafeErrorCode,
+ message: SafeMessage,
+}
+
+impl SafeError {
+ #[must_use]
+ pub const fn new(code: SafeErrorCode, message: SafeMessage) -> Self {
+ Self { code, message }
+ }
+
+ #[must_use]
+ pub const fn code(self) -> SafeErrorCode {
+ self.code
+ }
+
+ #[must_use]
+ pub const fn message(self) -> SafeMessage {
+ self.message
+ }
+}
+
+impl Debug for SafeError {
+ fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result {
+ formatter
+ .debug_struct("SafeError")
+ .field("code", &self.code)
+ .field("message", &self.message)
+ .finish()
+ }
+}
+
+impl Display for SafeError {
+ fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result {
+ Display::fmt(&self.message, formatter)
+ }
+}
+
+impl Error for SafeError {}
+
+#[cfg(test)]
+mod tests {
+ use super::{SafeError, SafeErrorCode, SafeMessage};
+
+ #[test]
+ fn safe_error_formats_only_a_static_public_message() {
+ let error = SafeError::new(
+ SafeErrorCode::InvalidSecretKey,
+ SafeMessage::new("The secret key is invalid."),
+ );
+
+ assert_eq!(error.to_string(), "The secret key is invalid.");
+ assert_eq!(error.code(), SafeErrorCode::InvalidSecretKey);
+ assert_eq!(error.message().as_str(), "The secret key is invalid.");
+ assert!(!format!("{error:?}").contains("nsec1unsafe-test-value"));
+ }
+}
diff --git a/crates/studio_domain/src/key.rs b/crates/studio_domain/src/key.rs
@@ -0,0 +1,391 @@
+//! Validated Nostr public and secret-key boundary values.
+
+use std::fmt::{self, Display, Formatter};
+use std::str::FromStr;
+
+use secrecy::{ExposeSecret, SecretString};
+use zeroize::Zeroizing;
+
+use crate::{SafeError, SafeErrorCode, SafeMessage};
+
+pub const PUBLIC_KEY_BYTE_LENGTH: usize = 32;
+pub const PUBLIC_KEY_HEX_LENGTH: usize = PUBLIC_KEY_BYTE_LENGTH * 2;
+pub const MAX_SECRET_KEY_INPUT_BYTES: usize = 128;
+const NIP19_KEY_LENGTH: usize = 63;
+const BECH32_DATA_CHARSET: &[u8] = b"qpzry9x8gf2tvdw0s3jn54khce6mua7l";
+
+#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
+pub struct Npub(String);
+
+impl Npub {
+ /// Constructs a human-facing npub after structural validation.
+ ///
+ /// Cryptographic conversion and checksum validation are performed by the
+ /// selected Nostr adapter before this domain value is created in runtime
+ /// flows.
+ ///
+ /// # Errors
+ ///
+ /// Returns a safe invalid-public-key error for a malformed npub shape.
+ pub fn from_encoded(value: String) -> Result<Self, SafeError> {
+ if !is_nip19_key_shape(&value, "npub1") {
+ return Err(invalid_public_key());
+ }
+ Ok(Self(value))
+ }
+
+ /// Derives the canonical NIP-19 display identity from a public key.
+ ///
+ /// # Errors
+ ///
+ /// Returns a safe public-key error if canonical encoding fails.
+ pub fn derive(public_key: PublicKey) -> Result<Self, SafeError> {
+ let hrp = bech32::Hrp::parse("npub").map_err(|_| invalid_public_key())?;
+ bech32::encode::<bech32::Bech32>(hrp, public_key.as_bytes())
+ .map_err(|_| invalid_public_key())
+ .and_then(Self::from_encoded)
+ }
+
+ /// Validates that encoded display identity belongs to the canonical key.
+ ///
+ /// # Errors
+ ///
+ /// Returns a safe public-key error when the values do not match.
+ pub fn verify(public_key: PublicKey, encoded: String) -> Result<Self, SafeError> {
+ let candidate = Self::from_encoded(encoded)?;
+ if candidate != Self::derive(public_key)? {
+ return Err(invalid_public_key());
+ }
+ Ok(candidate)
+ }
+
+ #[must_use]
+ pub fn as_str(&self) -> &str {
+ &self.0
+ }
+
+ #[must_use]
+ pub fn short(&self) -> String {
+ format!("{}…{}", &self.0[..12], &self.0[self.0.len() - 8..])
+ }
+}
+
+impl Display for Npub {
+ fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result {
+ formatter.write_str(&self.0)
+ }
+}
+
+pub struct Nsec(SecretString);
+
+impl Nsec {
+ /// Constructs a secret nsec display value after structural validation.
+ ///
+ /// # Errors
+ ///
+ /// Returns a safe invalid-secret-key error for a malformed nsec shape.
+ pub fn from_encoded(value: String) -> Result<Self, SafeError> {
+ if !is_nip19_key_shape(&value, "nsec1") {
+ return Err(invalid_secret_key());
+ }
+ Ok(Self(SecretString::from(value)))
+ }
+
+ pub fn with_exposed_secret<T>(&self, operation: impl FnOnce(&str) -> T) -> T {
+ operation(self.0.expose_secret())
+ }
+}
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub enum SecretKeyInputKind {
+ Nsec,
+ Hex,
+}
+
+pub struct SecretKeyInput {
+ value: SecretString,
+ kind: SecretKeyInputKind,
+}
+
+impl SecretKeyInput {
+ /// Moves bounded transport bytes into the zeroizing secret boundary.
+ ///
+ /// The source byte allocation is cleared on every return path.
+ ///
+ /// # Errors
+ ///
+ /// Returns a safe invalid-secret-key error for oversized, non-UTF-8, or
+ /// structurally invalid input.
+ pub fn parse_bytes(value: Vec<u8>) -> Result<Self, SafeError> {
+ let value = Zeroizing::new(value);
+ if value.len() > MAX_SECRET_KEY_INPUT_BYTES {
+ return Err(invalid_secret_key());
+ }
+ let encoded = std::str::from_utf8(&value).map_err(|_| invalid_secret_key())?;
+ Self::parse(encoded.to_owned())
+ }
+
+ /// Moves one secret input string into a zeroizing boundary.
+ ///
+ /// Nsec inputs receive complete NIP-19 validation in the Nostr adapter.
+ /// Hex input is structurally validated here to prevent ambiguous fallback.
+ ///
+ /// # Errors
+ ///
+ /// Returns a safe invalid-secret-key error when the input is neither an
+ /// nsec-looking value nor exactly 64 lowercase hexadecimal characters.
+ pub fn parse(value: String) -> Result<Self, SafeError> {
+ let kind = if value.len() == PUBLIC_KEY_HEX_LENGTH
+ && value
+ .bytes()
+ .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
+ {
+ SecretKeyInputKind::Hex
+ } else if is_nip19_key_shape(&value, "nsec1") {
+ SecretKeyInputKind::Nsec
+ } else {
+ return Err(invalid_secret_key());
+ };
+
+ Ok(Self {
+ value: SecretString::from(value),
+ kind,
+ })
+ }
+
+ #[must_use]
+ pub const fn kind(&self) -> SecretKeyInputKind {
+ self.kind
+ }
+
+ pub fn with_exposed_secret<T>(&self, operation: impl FnOnce(&str) -> T) -> T {
+ operation(self.value.expose_secret())
+ }
+}
+
+#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
+pub struct PublicKey([u8; PUBLIC_KEY_BYTE_LENGTH]);
+
+impl PublicKey {
+ #[must_use]
+ pub const fn from_bytes(bytes: [u8; PUBLIC_KEY_BYTE_LENGTH]) -> Self {
+ Self(bytes)
+ }
+
+ /// Parses a canonical lowercase hexadecimal Nostr public key.
+ ///
+ /// # Errors
+ ///
+ /// Returns a safe invalid-public-key error when the value is not exactly
+ /// 64 lowercase hexadecimal characters.
+ pub fn from_hex(value: &str) -> Result<Self, SafeError> {
+ if value.len() != PUBLIC_KEY_HEX_LENGTH
+ || !value
+ .bytes()
+ .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
+ {
+ return Err(invalid_public_key());
+ }
+
+ let mut bytes = [0_u8; PUBLIC_KEY_BYTE_LENGTH];
+ for (index, pair) in value.as_bytes().chunks_exact(2).enumerate() {
+ let high = decode_hex_digit(pair[0]).ok_or_else(invalid_public_key)?;
+ let low = decode_hex_digit(pair[1]).ok_or_else(invalid_public_key)?;
+ bytes[index] = (high << 4) | low;
+ }
+ Ok(Self(bytes))
+ }
+
+ #[must_use]
+ pub const fn as_bytes(&self) -> &[u8; PUBLIC_KEY_BYTE_LENGTH] {
+ &self.0
+ }
+
+ #[must_use]
+ pub fn to_hex(self) -> String {
+ const HEX: &[u8; 16] = b"0123456789abcdef";
+ let mut output = String::with_capacity(PUBLIC_KEY_HEX_LENGTH);
+ for byte in self.0 {
+ output.push(char::from(HEX[usize::from(byte >> 4)]));
+ output.push(char::from(HEX[usize::from(byte & 0x0f)]));
+ }
+ output
+ }
+
+ #[must_use]
+ pub fn short_hex(self) -> String {
+ let hex = self.to_hex();
+ format!("{}…{}", &hex[..8], &hex[PUBLIC_KEY_HEX_LENGTH - 8..])
+ }
+}
+
+impl Display for PublicKey {
+ fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result {
+ formatter.write_str(&self.to_hex())
+ }
+}
+
+impl From<[u8; PUBLIC_KEY_BYTE_LENGTH]> for PublicKey {
+ fn from(bytes: [u8; PUBLIC_KEY_BYTE_LENGTH]) -> Self {
+ Self::from_bytes(bytes)
+ }
+}
+
+impl FromStr for PublicKey {
+ type Err = SafeError;
+
+ fn from_str(value: &str) -> Result<Self, Self::Err> {
+ Self::from_hex(value)
+ }
+}
+
+const fn invalid_public_key() -> SafeError {
+ SafeError::new(
+ SafeErrorCode::InvalidPublicKey,
+ SafeMessage::new("The Nostr public key is invalid."),
+ )
+}
+
+const fn invalid_secret_key() -> SafeError {
+ SafeError::new(
+ SafeErrorCode::InvalidSecretKey,
+ SafeMessage::new("The Nostr secret key is invalid."),
+ )
+}
+
+const fn decode_hex_digit(byte: u8) -> Option<u8> {
+ match byte {
+ b'0'..=b'9' => Some(byte - b'0'),
+ b'a'..=b'f' => Some(byte - b'a' + 10),
+ _ => None,
+ }
+}
+
+fn is_nip19_key_shape(value: &str, prefix: &str) -> bool {
+ value.len() == NIP19_KEY_LENGTH
+ && value.starts_with(prefix)
+ && value[prefix.len()..]
+ .bytes()
+ .all(|byte| BECH32_DATA_CHARSET.contains(&byte))
+}
+
+#[cfg(test)]
+mod tests {
+ use std::str::FromStr;
+
+ use super::{
+ MAX_SECRET_KEY_INPUT_BYTES, Npub, Nsec, PUBLIC_KEY_BYTE_LENGTH, PublicKey, SecretKeyInput,
+ SecretKeyInputKind,
+ };
+ use crate::SafeErrorCode;
+
+ const HEX: &str = "7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7";
+ const NPUB: &str = "npub10elfcs4fr0l0r8af98jlmgdh9c8tcxjvz9qkw038js35mp4dma8qzvjptg";
+ const NSEC: &str = "nsec1vl029mgpspedva04g90vltkh6fvh240zqtv9k0t9af8935ke9laqsnlfe5";
+
+ #[test]
+ fn public_key_round_trips_canonical_hex_and_bytes() {
+ let key = PublicKey::from_str(HEX).expect("valid public key");
+
+ assert_eq!(key.to_hex(), HEX);
+ assert_eq!(key.to_string(), HEX);
+ assert_eq!(key.short_hex(), "7e7e9c42…2107f6d7");
+ assert_eq!(PublicKey::from_bytes(*key.as_bytes()), key);
+ assert_eq!(key.as_bytes().len(), PUBLIC_KEY_BYTE_LENGTH);
+ }
+
+ #[test]
+ fn public_key_rejects_noncanonical_or_malformed_hex() {
+ for value in [
+ "",
+ "00",
+ "7E7E9C42A91BFEF19FA7EA99D52D8AFDB67D893A8FEFBA1F5CB9793F2107F6D7",
+ "ze7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7",
+ " 7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7",
+ ] {
+ let error = PublicKey::from_hex(value).expect_err("invalid public key");
+ assert_eq!(error.code(), SafeErrorCode::InvalidPublicKey);
+ }
+ }
+
+ #[test]
+ fn public_keys_are_ordered_by_canonical_bytes() {
+ let low = PublicKey::from_bytes([0_u8; PUBLIC_KEY_BYTE_LENGTH]);
+ let high = PublicKey::from_bytes([1_u8; PUBLIC_KEY_BYTE_LENGTH]);
+
+ assert!(low < high);
+ }
+
+ #[test]
+ fn secret_input_is_redacted_and_exposed_only_to_a_scoped_operation() {
+ let secret = "11".repeat(PUBLIC_KEY_BYTE_LENGTH);
+ let input = SecretKeyInput::parse(secret.clone()).expect("valid secret hex");
+
+ assert_eq!(input.kind(), SecretKeyInputKind::Hex);
+ assert_eq!(input.with_exposed_secret(str::len), secret.len());
+ assert_eq!(input.with_exposed_secret(str::len), 64);
+ }
+
+ #[test]
+ fn secret_input_accepts_nsec_shape_without_exposing_it() {
+ let secret = NSEC.to_owned();
+ let input = SecretKeyInput::parse(secret.clone()).expect("nsec-shaped input");
+
+ assert_eq!(input.kind(), SecretKeyInputKind::Nsec);
+ assert_eq!(input.with_exposed_secret(str::len), secret.len());
+ }
+
+ #[test]
+ fn secret_input_rejects_invalid_hex_and_arbitrary_text() {
+ for value in [
+ "",
+ "very-sensitive-input",
+ &"GG".repeat(PUBLIC_KEY_BYTE_LENGTH),
+ ] {
+ let Err(error) = SecretKeyInput::parse(value.to_owned()) else {
+ panic!("invalid secret accepted");
+ };
+ assert_eq!(error.code(), SafeErrorCode::InvalidSecretKey);
+ if !value.is_empty() {
+ assert!(!format!("{error:?}").contains(value));
+ }
+ }
+ }
+
+ #[test]
+ fn secret_byte_transport_is_bounded_and_validated() {
+ let parsed = SecretKeyInput::parse_bytes(HEX.as_bytes().to_vec()).expect("bytes");
+ assert_eq!(parsed.with_exposed_secret(str::len), 64);
+ assert!(SecretKeyInput::parse_bytes(vec![0xff]).is_err());
+ assert!(SecretKeyInput::parse_bytes(vec![b'a'; MAX_SECRET_KEY_INPUT_BYTES + 1]).is_err());
+ }
+
+ #[test]
+ fn npub_is_public_display_data_but_not_canonical_identity() {
+ let npub = Npub::from_encoded(NPUB.to_owned()).expect("valid npub shape");
+
+ assert_eq!(npub.as_str(), NPUB);
+ assert_eq!(npub.to_string(), NPUB);
+ }
+
+ #[test]
+ fn nsec_is_redacted_and_exposed_only_to_a_scoped_operation() {
+ let nsec = Nsec::from_encoded(NSEC.to_owned()).expect("valid nsec shape");
+
+ assert_eq!(nsec.with_exposed_secret(str::len), NSEC.len());
+ assert_eq!(nsec.with_exposed_secret(str::len), NSEC.len());
+ }
+
+ #[test]
+ fn nip19_display_types_reject_wrong_prefix_length_and_charset() {
+ for invalid in [
+ "",
+ "npub1short",
+ "nsec1short",
+ "npub10elfcs4fr0l0r8af98jlmgdh9c8tcxjvz9qkw038js35mp4dma8qzvjp!g",
+ ] {
+ assert!(Npub::from_encoded(invalid.to_owned()).is_err());
+ assert!(Nsec::from_encoded(invalid.to_owned()).is_err());
+ }
+ }
+}
diff --git a/crates/studio_domain/src/lib.rs b/crates/studio_domain/src/lib.rs
@@ -0,0 +1,20 @@
+#![doc = "Radroots Studio Nostr account domain types."]
+
+pub mod account;
+pub mod error;
+pub mod key;
+pub mod profile;
+pub mod relay;
+pub mod time;
+
+pub use account::{
+ AccountCreatedAt, AccountIdentity, AccountLabel, AccountSummary, BindingAvailability,
+ BindingRepairAction, LocalSignerBinding,
+};
+pub use error::{SafeError, SafeErrorCode, SafeMessage};
+pub use key::{
+ MAX_SECRET_KEY_INPUT_BYTES, Npub, Nsec, PublicKey, SecretKeyInput, SecretKeyInputKind,
+};
+pub use profile::{EventId, Kind0ProfileCandidate, ProfileMetadata, select_latest_kind0};
+pub use relay::{RelayUrl, normalize_relay_urls};
+pub use time::UnixTimestamp;
diff --git a/crates/studio_domain/src/profile.rs b/crates/studio_domain/src/profile.rs
@@ -0,0 +1,295 @@
+//! Public Nostr profile metadata values.
+
+use crate::{PublicKey, SafeError, SafeErrorCode, SafeMessage, UnixTimestamp};
+
+const EVENT_ID_BYTES: usize = 32;
+const EVENT_ID_HEX: usize = EVENT_ID_BYTES * 2;
+const MAX_NAME_CHARS: usize = 128;
+const MAX_NIP05_CHARS: usize = 320;
+const MAX_ABOUT_CHARS: usize = 4_096;
+const MAX_PICTURE_CHARS: usize = 2_048;
+
+#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
+pub struct EventId([u8; EVENT_ID_BYTES]);
+
+impl EventId {
+ /// Parses a canonical lowercase hexadecimal Nostr event ID.
+ ///
+ /// # Errors
+ ///
+ /// Returns a safe profile error for malformed input.
+ pub fn from_hex(value: &str) -> Result<Self, SafeError> {
+ if value.len() != EVENT_ID_HEX
+ || !value
+ .bytes()
+ .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
+ {
+ return Err(invalid_profile_metadata());
+ }
+
+ let mut bytes = [0_u8; EVENT_ID_BYTES];
+ for (index, pair) in value.as_bytes().chunks_exact(2).enumerate() {
+ let high = decode_hex(pair[0]).ok_or_else(invalid_profile_metadata)?;
+ let low = decode_hex(pair[1]).ok_or_else(invalid_profile_metadata)?;
+ bytes[index] = (high << 4) | low;
+ }
+ Ok(Self(bytes))
+ }
+
+ #[must_use]
+ pub const fn from_bytes(bytes: [u8; EVENT_ID_BYTES]) -> Self {
+ Self(bytes)
+ }
+
+ #[must_use]
+ pub const fn as_bytes(self) -> [u8; EVENT_ID_BYTES] {
+ self.0
+ }
+
+ #[must_use]
+ pub fn to_hex(self) -> String {
+ const HEX: &[u8; 16] = b"0123456789abcdef";
+ let mut output = String::with_capacity(EVENT_ID_HEX);
+ for byte in self.0 {
+ output.push(char::from(HEX[usize::from(byte >> 4)]));
+ output.push(char::from(HEX[usize::from(byte & 0x0f)]));
+ }
+ output
+ }
+}
+
+#[derive(Clone, Debug, Default, Eq, PartialEq)]
+pub struct ProfileMetadata {
+ name: Option<String>,
+ display_name: Option<String>,
+ nip05: Option<String>,
+ about: Option<String>,
+ picture: Option<String>,
+}
+
+impl ProfileMetadata {
+ /// Normalizes and bounds public kind-0 profile fields.
+ ///
+ /// # Errors
+ ///
+ /// Returns a safe profile error when a field exceeds its limit or contains
+ /// a forbidden control character.
+ pub fn new(
+ name: Option<String>,
+ display_name: Option<String>,
+ nip05: Option<String>,
+ about: Option<String>,
+ picture: Option<String>,
+ ) -> Result<Self, SafeError> {
+ Ok(Self {
+ name: normalize_field(name, MAX_NAME_CHARS, false)?,
+ display_name: normalize_field(display_name, MAX_NAME_CHARS, false)?,
+ nip05: normalize_field(nip05, MAX_NIP05_CHARS, false)?,
+ about: normalize_field(about, MAX_ABOUT_CHARS, true)?,
+ picture: normalize_field(picture, MAX_PICTURE_CHARS, false)?,
+ })
+ }
+
+ #[must_use]
+ pub fn name(&self) -> Option<&str> {
+ self.name.as_deref()
+ }
+
+ #[must_use]
+ pub fn display_name(&self) -> Option<&str> {
+ self.display_name.as_deref()
+ }
+
+ #[must_use]
+ pub fn nip05(&self) -> Option<&str> {
+ self.nip05.as_deref()
+ }
+
+ #[must_use]
+ pub fn about(&self) -> Option<&str> {
+ self.about.as_deref()
+ }
+
+ #[must_use]
+ pub fn picture(&self) -> Option<&str> {
+ self.picture.as_deref()
+ }
+
+ #[must_use]
+ pub fn preferred_name(&self) -> Option<&str> {
+ self.display_name().or_else(|| self.name())
+ }
+}
+
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct Kind0ProfileCandidate {
+ event_id: EventId,
+ author: PublicKey,
+ created_at: UnixTimestamp,
+ metadata: ProfileMetadata,
+}
+
+impl Kind0ProfileCandidate {
+ #[must_use]
+ pub const fn new(
+ event_id: EventId,
+ author: PublicKey,
+ created_at: UnixTimestamp,
+ metadata: ProfileMetadata,
+ ) -> Self {
+ Self {
+ event_id,
+ author,
+ created_at,
+ metadata,
+ }
+ }
+
+ #[must_use]
+ pub const fn event_id(&self) -> EventId {
+ self.event_id
+ }
+
+ #[must_use]
+ pub const fn author(&self) -> PublicKey {
+ self.author
+ }
+
+ #[must_use]
+ pub const fn created_at(&self) -> UnixTimestamp {
+ self.created_at
+ }
+
+ #[must_use]
+ pub const fn metadata(&self) -> &ProfileMetadata {
+ &self.metadata
+ }
+}
+
+#[must_use]
+pub fn select_latest_kind0(
+ candidates: impl IntoIterator<Item = Kind0ProfileCandidate>,
+) -> Option<Kind0ProfileCandidate> {
+ candidates.into_iter().reduce(|selected, candidate| {
+ if candidate.created_at > selected.created_at
+ || (candidate.created_at == selected.created_at
+ && candidate.event_id < selected.event_id)
+ {
+ candidate
+ } else {
+ selected
+ }
+ })
+}
+
+fn normalize_field(
+ value: Option<String>,
+ max_chars: usize,
+ allow_layout_controls: bool,
+) -> Result<Option<String>, SafeError> {
+ let Some(value) = value else {
+ return Ok(None);
+ };
+ let normalized = value.trim();
+ if normalized.is_empty() {
+ return Ok(None);
+ }
+ if normalized.chars().count() > max_chars
+ || normalized.chars().any(|character| {
+ character.is_control()
+ && !(allow_layout_controls && matches!(character, '\n' | '\r' | '\t'))
+ })
+ {
+ return Err(invalid_profile_metadata());
+ }
+ Ok(Some(normalized.to_owned()))
+}
+
+const fn invalid_profile_metadata() -> SafeError {
+ SafeError::new(
+ SafeErrorCode::InvalidProfileMetadata,
+ SafeMessage::new("The Nostr profile metadata is invalid."),
+ )
+}
+
+const fn decode_hex(byte: u8) -> Option<u8> {
+ match byte {
+ b'0'..=b'9' => Some(byte - b'0'),
+ b'a'..=b'f' => Some(byte - b'a' + 10),
+ _ => None,
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use crate::{PublicKey, UnixTimestamp};
+
+ use super::{EventId, Kind0ProfileCandidate, ProfileMetadata, select_latest_kind0};
+
+ fn profile(name: &str) -> ProfileMetadata {
+ ProfileMetadata::new(Some(name.to_owned()), None, None, None, None).expect("valid profile")
+ }
+
+ fn candidate(id_byte: u8, created_at: i64, name: &str) -> Kind0ProfileCandidate {
+ Kind0ProfileCandidate::new(
+ EventId::from_bytes([id_byte; 32]),
+ PublicKey::from_bytes([9_u8; 32]),
+ UnixTimestamp::from_seconds(created_at).expect("valid timestamp"),
+ profile(name),
+ )
+ }
+
+ #[test]
+ fn profile_fields_are_trimmed_bounded_and_public() {
+ let metadata = ProfileMetadata::new(
+ Some(" farmer ".to_owned()),
+ Some(" Farm Account ".to_owned()),
+ Some("farmer@example.test".to_owned()),
+ Some("First line\nSecond line".to_owned()),
+ Some("https://images.example.test/profile.png".to_owned()),
+ )
+ .expect("valid profile");
+
+ assert_eq!(metadata.name(), Some("farmer"));
+ assert_eq!(metadata.display_name(), Some("Farm Account"));
+ assert_eq!(metadata.preferred_name(), Some("Farm Account"));
+ assert_eq!(metadata.nip05(), Some("farmer@example.test"));
+ assert_eq!(metadata.about(), Some("First line\nSecond line"));
+ assert_eq!(
+ metadata.picture(),
+ Some("https://images.example.test/profile.png")
+ );
+ }
+
+ #[test]
+ fn profile_fields_reject_forbidden_controls_and_oversize_values() {
+ assert!(
+ ProfileMetadata::new(Some("bad\0name".to_owned()), None, None, None, None).is_err()
+ );
+ assert!(ProfileMetadata::new(Some("x".repeat(129)), None, None, None, None).is_err());
+ }
+
+ #[test]
+ fn latest_kind0_uses_timestamp_then_lowest_event_id() {
+ let older = candidate(0, 10, "older");
+ let equal_high_id = candidate(9, 20, "high-id");
+ let equal_low_id = candidate(1, 20, "low-id");
+
+ let selected =
+ select_latest_kind0([older, equal_high_id, equal_low_id]).expect("selected profile");
+
+ assert_eq!(selected.metadata().name(), Some("low-id"));
+ assert_eq!(selected.event_id().as_bytes(), [1_u8; 32]);
+ assert_eq!(selected.author(), PublicKey::from_bytes([9_u8; 32]));
+ assert_eq!(selected.created_at().as_seconds(), 20);
+ }
+
+ #[test]
+ fn event_id_rejects_noncanonical_hex_and_round_trips() {
+ let hex = "12".repeat(32);
+ let event_id = EventId::from_hex(&hex).expect("valid event id");
+
+ assert_eq!(event_id.to_hex(), hex);
+ assert!(EventId::from_hex(&"GG".repeat(32)).is_err());
+ }
+}
diff --git a/crates/studio_domain/src/relay.rs b/crates/studio_domain/src/relay.rs
@@ -0,0 +1,157 @@
+//! Validated Nostr relay values.
+
+use std::collections::HashSet;
+use std::fmt::{self, Display, Formatter};
+use std::str::FromStr;
+
+use url::{Host, Url};
+
+use crate::{SafeError, SafeErrorCode, SafeMessage};
+
+#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
+pub struct RelayUrl(String);
+
+impl RelayUrl {
+ /// Parses and normalizes an allowed WebSocket relay URL.
+ ///
+ /// # Errors
+ ///
+ /// Returns a safe configuration error for empty or malformed input,
+ /// forbidden schemes, credentials, fragments, or non-loopback `ws://`.
+ pub fn parse(value: &str) -> Result<Self, SafeError> {
+ let trimmed = value.trim();
+ if trimmed.is_empty() || trimmed.chars().any(char::is_control) {
+ return Err(invalid_relay());
+ }
+
+ let parsed = Url::parse(trimmed).map_err(|_| invalid_relay())?;
+ if !parsed.username().is_empty()
+ || parsed.password().is_some()
+ || parsed.fragment().is_some()
+ {
+ return Err(invalid_relay());
+ }
+
+ match parsed.scheme() {
+ "wss" => {}
+ "ws" if is_loopback(&parsed) => {}
+ _ => return Err(invalid_relay()),
+ }
+
+ if parsed.host().is_none() {
+ return Err(invalid_relay());
+ }
+
+ Ok(Self(parsed.to_string()))
+ }
+
+ #[must_use]
+ pub fn as_str(&self) -> &str {
+ &self.0
+ }
+}
+
+impl Display for RelayUrl {
+ fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result {
+ formatter.write_str(&self.0)
+ }
+}
+
+impl FromStr for RelayUrl {
+ type Err = SafeError;
+
+ fn from_str(value: &str) -> Result<Self, Self::Err> {
+ Self::parse(value)
+ }
+}
+
+/// Parses relay values and removes duplicates without changing first-seen order.
+///
+/// # Errors
+///
+/// Returns the first safe relay validation error.
+pub fn normalize_relay_urls<I, S>(values: I) -> Result<Vec<RelayUrl>, SafeError>
+where
+ I: IntoIterator<Item = S>,
+ S: AsRef<str>,
+{
+ let mut seen = HashSet::new();
+ let mut relays = Vec::new();
+ for value in values {
+ let relay = RelayUrl::parse(value.as_ref())?;
+ if seen.insert(relay.clone()) {
+ relays.push(relay);
+ }
+ }
+ Ok(relays)
+}
+
+fn is_loopback(url: &Url) -> bool {
+ match url.host() {
+ Some(Host::Domain(domain)) => domain == "localhost",
+ Some(Host::Ipv4(address)) => address.octets()[0] == 127,
+ Some(Host::Ipv6(address)) => address.is_loopback(),
+ None => false,
+ }
+}
+
+const fn invalid_relay() -> SafeError {
+ SafeError::new(
+ SafeErrorCode::InvalidRelayConfiguration,
+ SafeMessage::new("The Nostr relay URL is invalid."),
+ )
+}
+
+#[cfg(test)]
+mod tests {
+ use super::{RelayUrl, normalize_relay_urls};
+ use crate::SafeErrorCode;
+
+ #[test]
+ fn relay_accepts_secure_remote_and_loopback_development_urls() {
+ for (input, expected) in [
+ (" wss://Relay.Example/path ", "wss://relay.example/path"),
+ ("ws://localhost:8080", "ws://localhost:8080/"),
+ ("ws://127.42.1.9:8080", "ws://127.42.1.9:8080/"),
+ ("ws://[::1]:8080", "ws://[::1]:8080/"),
+ ] {
+ let relay = RelayUrl::parse(input).expect("allowed relay");
+ assert_eq!(relay.as_str(), expected);
+ assert_eq!(relay.to_string(), expected);
+ }
+ }
+
+ #[test]
+ fn relay_rejects_non_websocket_credentials_fragments_and_remote_plaintext() {
+ for input in [
+ "",
+ "https://relay.example",
+ "http://localhost:8080",
+ "wss://user:password@relay.example",
+ "wss://relay.example/#fragment",
+ "ws://relay.example",
+ "ws://192.168.1.2:8080",
+ "ws://localhost.evil.example:8080",
+ "wss://relay.example/\nunsafe",
+ ] {
+ let error = RelayUrl::parse(input).expect_err("forbidden relay");
+ assert_eq!(error.code(), SafeErrorCode::InvalidRelayConfiguration);
+ }
+ }
+
+ #[test]
+ fn relay_deduplication_preserves_normalized_first_seen_order() {
+ let relays = normalize_relay_urls([
+ "wss://relay.example",
+ " wss://second.example/path ",
+ "wss://RELAY.example/",
+ "wss://second.example/path",
+ ])
+ .expect("valid relays");
+
+ assert_eq!(
+ relays.iter().map(RelayUrl::as_str).collect::<Vec<_>>(),
+ vec!["wss://relay.example/", "wss://second.example/path"]
+ );
+ }
+}
diff --git a/crates/studio_domain/src/time.rs b/crates/studio_domain/src/time.rs
@@ -0,0 +1,34 @@
+//! Time values shared by account and profile records.
+
+#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
+pub struct UnixTimestamp(i64);
+
+impl UnixTimestamp {
+ #[must_use]
+ pub const fn from_seconds(seconds: i64) -> Option<Self> {
+ if seconds < 0 {
+ None
+ } else {
+ Some(Self(seconds))
+ }
+ }
+
+ #[must_use]
+ pub const fn as_seconds(self) -> i64 {
+ self.0
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::UnixTimestamp;
+
+ #[test]
+ fn timestamp_rejects_negative_seconds() {
+ assert_eq!(UnixTimestamp::from_seconds(-1), None);
+ assert_eq!(
+ UnixTimestamp::from_seconds(0).map(UnixTimestamp::as_seconds),
+ Some(0)
+ );
+ }
+}
diff --git a/crates/studio_ffi/Cargo.toml b/crates/studio_ffi/Cargo.toml
@@ -0,0 +1,27 @@
+[package]
+name = "radroots-studio-ffi"
+version.workspace = true
+edition.workspace = true
+rust-version.workspace = true
+license.workspace = true
+repository.workspace = true
+
+[lib]
+crate-type = ["cdylib", "rlib"]
+
+[dependencies]
+directories.workspace = true
+radroots-studio-application = { path = "../application" }
+radroots-studio-domain = { path = "../domain" }
+radroots-studio-storage = { path = "../storage" }
+tokio.workspace = true
+uniffi.workspace = true
+
+[dev-dependencies]
+nostr.workspace = true
+nostr-relay-builder.workspace = true
+nostr-sdk.workspace = true
+tempfile = "=3.23.0"
+
+[lints]
+workspace = true
diff --git a/crates/studio_ffi/src/commands.rs b/crates/studio_ffi/src/commands.rs
@@ -0,0 +1,853 @@
+use std::collections::BTreeMap;
+use std::fmt::{self, Display, Formatter};
+use std::num::NonZeroUsize;
+use std::path::{Path, PathBuf};
+use std::sync::atomic::{AtomicBool, Ordering};
+use std::sync::{Arc, Mutex, OnceLock};
+use std::time::{Duration, SystemTime, UNIX_EPOCH};
+
+use directories::ProjectDirs;
+use radroots_studio_application::{
+ Clock, DurableRequestId, GeneratedKeyRecoveryHandle, RelayConfiguration, RelayRuntimeMode,
+ RemovalConfirmationToken, SdkNostrClient, relay_configuration_from_environment,
+};
+use radroots_studio_domain::{PublicKey, SafeError, SecretKeyInput, UnixTimestamp};
+use radroots_studio_storage::{OsKeyringSecretStore, RuntimeActorHandle};
+
+use crate::{
+ AccountDto, AppSnapshotDto, WireErrorCategory, WireErrorCode, WireRecoveryAction,
+ dto::error_policy,
+};
+
+const DATABASE_QUALIFIER: &str = "org";
+const DATABASE_ORGANIZATION: &str = "radroots";
+const DATABASE_APPLICATION: &str = "studio";
+const DATABASE_FILENAME: &str = "studio.sqlite3";
+const DEVELOPMENT_DATA_DIR_ENVIRONMENT: &str = "RADROOTS_STUDIO_DEVELOPMENT_DATA_DIR";
+pub(crate) const ACTOR_MAILBOX_CAPACITY: usize = 64;
+pub const FFI_CONTRACT_MAJOR: u16 = 2;
+pub const FFI_CONTRACT_MINOR: u16 = 0;
+pub const FFI_CONTRACT_HASH: &str = "radroots-studio-native-v2-2026-08-03";
+const MAX_COMMAND_DEADLINE_MILLIS: u64 = 30_000;
+
+#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)]
+pub struct RequestContextDto {
+ pub request_id: String,
+ pub expected_revision: u64,
+ pub deadline_millis: u64,
+}
+
+#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)]
+pub struct AccountCommandReceiptDto {
+ pub request_id: String,
+ pub committed_revision: u64,
+ pub snapshot: AppSnapshotDto,
+}
+
+#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)]
+pub struct CompatibilityDescriptor {
+ pub contract_major: u16,
+ pub contract_minor: u16,
+ pub contract_hash: String,
+ pub minimum_schema_version: u32,
+ pub current_schema_version: u32,
+}
+
+#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)]
+pub struct CompatibilityExpectation {
+ pub contract_major: u16,
+ pub minimum_contract_minor: u16,
+ pub contract_hash: String,
+ pub minimum_schema_version: u32,
+ pub maximum_schema_version: u32,
+}
+
+#[uniffi::export]
+pub fn compatibility_descriptor() -> CompatibilityDescriptor {
+ CompatibilityDescriptor {
+ contract_major: FFI_CONTRACT_MAJOR,
+ contract_minor: FFI_CONTRACT_MINOR,
+ contract_hash: FFI_CONTRACT_HASH.to_owned(),
+ minimum_schema_version: 5,
+ current_schema_version: radroots_studio_storage::CURRENT_SCHEMA_VERSION,
+ }
+}
+
+#[derive(Debug, uniffi::Error)]
+pub enum StudioError {
+ Failure {
+ code: WireErrorCode,
+ category: WireErrorCategory,
+ retryable: bool,
+ recovery_action: WireRecoveryAction,
+ correlation_id: Option<String>,
+ safe_message: String,
+ },
+}
+
+impl Display for StudioError {
+ fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result {
+ match self {
+ Self::Failure { safe_message, .. } => formatter.write_str(safe_message),
+ }
+ }
+}
+
+impl std::error::Error for StudioError {}
+
+impl From<SafeError> for StudioError {
+ fn from(error: SafeError) -> Self {
+ let (category, retryable, recovery_action) = error_policy(error.code());
+ Self::Failure {
+ code: error.code().into(),
+ category,
+ retryable,
+ recovery_action,
+ correlation_id: None,
+ safe_message: error.message().as_str().to_owned(),
+ }
+ }
+}
+
+impl StudioError {
+ fn correlated(error: SafeError, correlation_id: &str) -> Self {
+ let (category, retryable, recovery_action) = error_policy(error.code());
+ Self::Failure {
+ code: error.code().into(),
+ category,
+ retryable,
+ recovery_action,
+ correlation_id: Some(correlation_id.to_owned()),
+ safe_message: error.message().as_str().to_owned(),
+ }
+ }
+}
+
+#[derive(uniffi::Object)]
+pub struct GeneratedRecoveryRequest {
+ handle: GeneratedKeyRecoveryHandle,
+ resolved: AtomicBool,
+}
+
+#[uniffi::export]
+impl GeneratedRecoveryRequest {
+ pub fn account(&self) -> AccountDto {
+ self.handle.view().account().into()
+ }
+
+ pub fn expires_at_seconds(&self) -> i64 {
+ self.handle.view().expires_at().as_seconds()
+ }
+
+ /// Returns the recovery secret exactly once.
+ ///
+ /// # Errors
+ ///
+ /// Returns a safe unavailable error after the first read.
+ pub fn take_recovery_nsec(&self) -> Result<String, StudioError> {
+ self.handle
+ .take_recovery_nsec()
+ .map(|nsec| nsec.with_exposed_secret(str::to_owned))
+ .map_err(StudioError::from)
+ }
+}
+
+#[derive(uniffi::Object)]
+pub struct RemovalRequest {
+ public_key_hex: String,
+ deletes_local_credential: bool,
+ signs_out: bool,
+ expires_at_seconds: i64,
+ token: Mutex<Option<RemovalConfirmationToken>>,
+}
+
+#[uniffi::export]
+impl RemovalRequest {
+ pub fn public_key_hex(&self) -> String {
+ self.public_key_hex.clone()
+ }
+
+ pub fn deletes_local_credential(&self) -> bool {
+ self.deletes_local_credential
+ }
+
+ pub fn signs_out(&self) -> bool {
+ self.signs_out
+ }
+
+ pub fn expires_at_seconds(&self) -> i64 {
+ self.expires_at_seconds
+ }
+}
+
+pub(crate) struct RuntimeCore {
+ pub(crate) actor: RuntimeActorHandle,
+ pub(crate) observers: Mutex<
+ BTreeMap<radroots_studio_application::ChangeSubscriptionId, tokio::task::JoinHandle<()>>,
+ >,
+ pub(crate) closed: AtomicBool,
+ pub(crate) startup_relay_problem: Option<SafeError>,
+}
+
+impl RuntimeCore {
+ pub(crate) fn snapshot_dto(&self) -> AppSnapshotDto {
+ AppSnapshotDto::from_runtime(&self.actor.snapshot(), self.effective_lifecycle())
+ }
+
+ pub(crate) fn dto_for(
+ &self,
+ snapshot: &radroots_studio_application::AppSnapshot,
+ ) -> AppSnapshotDto {
+ AppSnapshotDto::from_runtime(snapshot, self.effective_lifecycle())
+ }
+
+ pub(crate) fn effective_lifecycle(&self) -> radroots_studio_application::RuntimeLifecycle {
+ let lifecycle = self.actor.lifecycle();
+ match (lifecycle, self.startup_relay_problem) {
+ (radroots_studio_application::RuntimeLifecycle::Ready, Some(problem)) => {
+ radroots_studio_application::RuntimeLifecycle::Degraded(problem)
+ }
+ _ => lifecycle,
+ }
+ }
+}
+
+#[derive(uniffi::Object)]
+pub struct StudioAppCore {
+ pub(crate) inner: Arc<RuntimeCore>,
+}
+
+#[uniffi::export]
+impl StudioAppCore {
+ /// Verifies the static contract before touching the application data path.
+ ///
+ /// # Errors
+ ///
+ /// Returns a safe compatibility error without opening or migrating storage.
+ #[uniffi::constructor]
+ #[allow(clippy::needless_pass_by_value)]
+ pub fn open_compatible(
+ expectation: CompatibilityExpectation,
+ development_mode: bool,
+ ) -> Result<Arc<Self>, StudioError> {
+ let path = application_database_path(development_mode)?;
+ Self::open_path_compatible(&path, &expectation, development_mode)
+ }
+
+ /// Restores durable public application state.
+ ///
+ /// # Errors
+ ///
+ /// Returns a safe storage, recovery, or application-state error.
+ pub async fn bootstrap(&self) -> Result<AppSnapshotDto, StudioError> {
+ self.inner
+ .actor
+ .bootstrap()
+ .await
+ .map(|snapshot| self.inner.dto_for(&snapshot))
+ .map_err(StudioError::from)
+ }
+
+ #[must_use]
+ pub fn snapshot(&self) -> AppSnapshotDto {
+ self.inner.snapshot_dto()
+ }
+
+ /// Begins the exclusive generated-account recovery flow without persistence.
+ ///
+ /// # Errors
+ ///
+ /// Returns a safe key-generation, conflict, timeout, or lifecycle error.
+ pub async fn begin_generated_account_v2(
+ &self,
+ ) -> Result<Arc<GeneratedRecoveryRequest>, StudioError> {
+ self.inner
+ .actor
+ .begin_generated_key_stage()
+ .await
+ .map(|handle| {
+ Arc::new(GeneratedRecoveryRequest {
+ handle,
+ resolved: AtomicBool::new(false),
+ })
+ })
+ .map_err(StudioError::from)
+ }
+
+ /// Acknowledges recovery and commits the generated account once.
+ ///
+ /// # Errors
+ ///
+ /// Returns a terminal safe recovery, credential, persistence, timeout, or lifecycle error.
+ /// A failed commit must be recovered by importing the already-saved recovery key.
+ pub async fn acknowledge_generated_account_v2(
+ &self,
+ request: Arc<GeneratedRecoveryRequest>,
+ ) -> Result<AppSnapshotDto, StudioError> {
+ if request.resolved.swap(true, Ordering::AcqRel) {
+ return Err(generated_recovery_expired());
+ }
+ self.inner
+ .actor
+ .acknowledge_generated_key_stage(request.handle.id())
+ .await
+ .map(|snapshot| self.inner.dto_for(&snapshot))
+ .map_err(generated_commit_failed)
+ }
+
+ /// Cancels the exclusive generated-account recovery flow.
+ ///
+ /// # Errors
+ ///
+ /// Returns a safe timeout or lifecycle error.
+ pub async fn cancel_generated_account_v2(
+ &self,
+ request: Arc<GeneratedRecoveryRequest>,
+ ) -> Result<bool, StudioError> {
+ if request.resolved.swap(true, Ordering::AcqRel) {
+ return Ok(false);
+ }
+ self.inner
+ .actor
+ .cancel_generated_key_stage()
+ .await
+ .map_err(StudioError::from)
+ }
+
+ /// Imports or repairs an account using a caller-owned idempotency key.
+ ///
+ /// # Errors
+ ///
+ /// Returns a correlated validation, conflict, timeout, credential, or storage error.
+ pub async fn import_account_v2(
+ &self,
+ context: RequestContextDto,
+ secret_key: Vec<u8>,
+ ) -> Result<AccountCommandReceiptDto, StudioError> {
+ let request_id = DurableRequestId::parse(context.request_id.clone())
+ .map_err(|error| StudioError::correlated(error, &context.request_id))?;
+ let timeout = command_timeout(context.deadline_millis, &context.request_id)?;
+ let input = SecretKeyInput::parse_bytes(secret_key)
+ .map_err(|error| StudioError::correlated(error, &context.request_id))?;
+ self.inner
+ .actor
+ .import_secret_key_request(
+ request_id,
+ radroots_studio_application::SnapshotRevision::from_value(
+ context.expected_revision,
+ ),
+ input,
+ timeout,
+ )
+ .await
+ .map(|_| {
+ let snapshot = self.inner.snapshot_dto();
+ AccountCommandReceiptDto {
+ request_id: context.request_id.clone(),
+ committed_revision: snapshot.revision,
+ snapshot,
+ }
+ })
+ .map_err(|error| StudioError::correlated(error, &context.request_id))
+ }
+
+ /// Selects one saved account without activating it.
+ ///
+ /// # Errors
+ ///
+ /// Returns a safe public-key, account, or storage error.
+ pub async fn select_account(
+ &self,
+ public_key_hex: String,
+ ) -> Result<AppSnapshotDto, StudioError> {
+ let public_key = parse_public_key(&public_key_hex)?;
+ self.inner
+ .actor
+ .select_account(public_key)
+ .await
+ .map(|snapshot| self.inner.dto_for(&snapshot))
+ .map_err(StudioError::from)
+ }
+
+ /// Activates one saved account after validating its credential.
+ ///
+ /// # Errors
+ ///
+ /// Returns a safe public-key, credential, account, or storage error.
+ pub async fn activate_account(
+ &self,
+ public_key_hex: String,
+ ) -> Result<AppSnapshotDto, StudioError> {
+ let public_key = parse_public_key(&public_key_hex)?;
+ self.inner
+ .actor
+ .activate_account(public_key)
+ .await
+ .map(|snapshot| self.inner.dto_for(&snapshot))
+ .map_err(StudioError::from)
+ }
+
+ /// Signs out while retaining accounts and credentials.
+ ///
+ /// # Errors
+ ///
+ /// Returns a safe application-state error.
+ pub async fn sign_out(&self) -> Result<AppSnapshotDto, StudioError> {
+ self.inner
+ .actor
+ .sign_out()
+ .await
+ .map(|snapshot| self.inner.dto_for(&snapshot))
+ .map_err(StudioError::from)
+ }
+
+ /// Refreshes the active Nostr profile from configured relays.
+ ///
+ /// # Errors
+ ///
+ /// Returns a safe storage or application-state error.
+ pub async fn refresh_active_profile(&self) -> Result<AppSnapshotDto, StudioError> {
+ self.inner
+ .actor
+ .refresh_active_profile()
+ .await
+ .map(|snapshot| self.inner.dto_for(&snapshot))
+ .map_err(StudioError::from)
+ }
+
+ /// Issues a revision-bound removal confirmation object.
+ ///
+ /// # Errors
+ ///
+ /// Returns a safe public-key or account error.
+ pub async fn request_account_removal(
+ &self,
+ public_key_hex: String,
+ ) -> Result<Arc<RemovalRequest>, StudioError> {
+ let public_key = parse_public_key(&public_key_hex)?;
+ self.inner
+ .actor
+ .request_account_removal(public_key)
+ .await
+ .map(|token| {
+ let impact = token.impact();
+ Arc::new(RemovalRequest {
+ public_key_hex,
+ deletes_local_credential: impact.deletes_local_credential(),
+ signs_out: impact.signs_out(),
+ expires_at_seconds: token.expires_at().as_seconds(),
+ token: Mutex::new(Some(token)),
+ })
+ })
+ .map_err(StudioError::from)
+ }
+
+ /// Permanently removes the account represented by a one-time request.
+ ///
+ /// # Errors
+ ///
+ /// Returns a safe confirmation, credential, recovery, or storage error.
+ pub async fn confirm_account_removal(
+ &self,
+ request: Arc<RemovalRequest>,
+ ) -> Result<AppSnapshotDto, StudioError> {
+ let token = request
+ .token
+ .lock()
+ .unwrap_or_else(std::sync::PoisonError::into_inner)
+ .take()
+ .ok_or_else(confirmation_expired)?;
+ self.inner
+ .actor
+ .confirm_account_removal(token)
+ .await
+ .map(|snapshot| self.inner.dto_for(&snapshot))
+ .map_err(StudioError::from)
+ }
+}
+
+fn verify_compatibility(expectation: &CompatibilityExpectation) -> Result<(), StudioError> {
+ let actual = compatibility_descriptor();
+ if expectation.contract_major != actual.contract_major
+ || expectation.minimum_contract_minor > actual.contract_minor
+ || expectation.contract_hash != actual.contract_hash
+ || expectation.minimum_schema_version > actual.current_schema_version
+ || expectation.maximum_schema_version < actual.minimum_schema_version
+ {
+ return Err(compatibility_mismatch());
+ }
+ Ok(())
+}
+
+impl StudioAppCore {
+ fn open_path_compatible(
+ path: &Path,
+ expectation: &CompatibilityExpectation,
+ development_mode: bool,
+ ) -> Result<Arc<Self>, StudioError> {
+ verify_compatibility(expectation)?;
+ std::fs::create_dir_all(path.parent().ok_or_else(path_unavailable)?)
+ .map_err(|_| path_unavailable())?;
+ Self::open_path(path, development_mode)
+ }
+
+ fn open_path(path: &Path, development_mode: bool) -> Result<Arc<Self>, StudioError> {
+ let mode = if development_mode {
+ RelayRuntimeMode::Development
+ } else {
+ RelayRuntimeMode::Packaged
+ };
+ let (relays, startup_relay_problem) =
+ local_first_relay_configuration(relay_configuration_from_environment(mode));
+ let actor = RuntimeActorHandle::open(
+ path,
+ relays,
+ Arc::new(OsKeyringSecretStore::default()),
+ Arc::new(SystemClock),
+ Arc::new(SdkNostrClient::new(Duration::from_secs(5))),
+ NonZeroUsize::new(ACTOR_MAILBOX_CAPACITY).expect("nonzero actor mailbox capacity"),
+ runtime().handle(),
+ )?;
+ Ok(Arc::new(Self {
+ inner: Arc::new(RuntimeCore {
+ actor,
+ observers: Mutex::new(BTreeMap::new()),
+ closed: AtomicBool::new(false),
+ startup_relay_problem,
+ }),
+ }))
+ }
+}
+
+fn local_first_relay_configuration(
+ configured: Result<RelayConfiguration, SafeError>,
+) -> (RelayConfiguration, Option<SafeError>) {
+ match configured {
+ Ok(relays) => (relays, None),
+ Err(problem) => (RelayConfiguration::default(), Some(problem)),
+ }
+}
+
+#[derive(Clone, Copy)]
+pub(crate) struct SystemClock;
+
+impl Clock for SystemClock {
+ fn now(&self) -> UnixTimestamp {
+ let seconds = SystemTime::now()
+ .duration_since(UNIX_EPOCH)
+ .map_or(0, |duration| {
+ i64::try_from(duration.as_secs()).unwrap_or(i64::MAX)
+ });
+ UnixTimestamp::from_seconds(seconds).expect("system time is nonnegative")
+ }
+}
+
+fn application_database_path(development_mode: bool) -> Result<PathBuf, StudioError> {
+ if development_mode && let Some(directory) = std::env::var_os(DEVELOPMENT_DATA_DIR_ENVIRONMENT)
+ {
+ return Ok(PathBuf::from(directory).join(DATABASE_FILENAME));
+ }
+ ProjectDirs::from(
+ DATABASE_QUALIFIER,
+ DATABASE_ORGANIZATION,
+ DATABASE_APPLICATION,
+ )
+ .map(|project| project.data_dir().join(DATABASE_FILENAME))
+ .ok_or_else(path_unavailable)
+}
+
+fn parse_public_key(value: &str) -> Result<PublicKey, StudioError> {
+ PublicKey::from_hex(value).map_err(StudioError::from)
+}
+
+fn command_timeout(millis: u64, correlation_id: &str) -> Result<Duration, StudioError> {
+ if millis == 0 || millis > MAX_COMMAND_DEADLINE_MILLIS {
+ return Err(StudioError::Failure {
+ code: WireErrorCode::InvalidApplicationState,
+ category: WireErrorCategory::Input,
+ retryable: false,
+ recovery_action: WireRecoveryAction::None,
+ correlation_id: Some(correlation_id.to_owned()),
+ safe_message: "The command deadline is invalid.".to_owned(),
+ });
+ }
+ Ok(Duration::from_millis(millis))
+}
+
+pub(crate) fn runtime() -> &'static tokio::runtime::Runtime {
+ static RUNTIME: OnceLock<tokio::runtime::Runtime> = OnceLock::new();
+ RUNTIME.get_or_init(|| {
+ tokio::runtime::Builder::new_multi_thread()
+ .enable_all()
+ .thread_name("radroots-studio-core")
+ .build()
+ .expect("Tokio runtime construction")
+ })
+}
+
+fn path_unavailable() -> StudioError {
+ StudioError::Failure {
+ code: WireErrorCode::StorageUnavailable,
+ category: WireErrorCategory::Storage,
+ retryable: true,
+ recovery_action: WireRecoveryAction::RestartApplication,
+ correlation_id: None,
+ safe_message: "The application data directory is unavailable.".to_owned(),
+ }
+}
+
+fn confirmation_expired() -> StudioError {
+ StudioError::Failure {
+ code: WireErrorCode::InvalidApplicationState,
+ category: WireErrorCategory::Lifecycle,
+ retryable: false,
+ recovery_action: WireRecoveryAction::None,
+ correlation_id: None,
+ safe_message: "The account removal confirmation is no longer valid.".to_owned(),
+ }
+}
+
+fn generated_recovery_expired() -> StudioError {
+ StudioError::Failure {
+ code: WireErrorCode::InvalidApplicationState,
+ category: WireErrorCategory::Lifecycle,
+ retryable: false,
+ recovery_action: WireRecoveryAction::None,
+ correlation_id: None,
+ safe_message: "The generated-key recovery step is no longer valid.".to_owned(),
+ }
+}
+
+fn generated_commit_failed(error: SafeError) -> StudioError {
+ let (category, _, _) = error_policy(error.code());
+ StudioError::Failure {
+ code: error.code().into(),
+ category,
+ retryable: false,
+ recovery_action: WireRecoveryAction::None,
+ correlation_id: None,
+ safe_message:
+ "The generated account could not be saved. Import the recovery key you saved to try again."
+ .to_owned(),
+ }
+}
+
+fn compatibility_mismatch() -> StudioError {
+ StudioError::Failure {
+ code: WireErrorCode::CompatibilityMismatch,
+ category: WireErrorCategory::Compatibility,
+ retryable: false,
+ recovery_action: WireRecoveryAction::UpdateApplication,
+ correlation_id: None,
+ safe_message: "The application and native runtime are incompatible.".to_owned(),
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use std::num::NonZeroUsize;
+ use std::sync::Arc;
+
+ use radroots_studio_application::{InMemorySecretStore, RelayConfiguration, SdkNostrClient};
+ use radroots_studio_domain::SafeError;
+ use radroots_studio_storage::RuntimeActorHandle;
+
+ use radroots_studio_storage::{CREDENTIAL_SERVICE, CURRENT_SCHEMA_VERSION};
+
+ use super::{
+ ACTOR_MAILBOX_CAPACITY, CompatibilityExpectation, DATABASE_APPLICATION, DATABASE_FILENAME,
+ DATABASE_ORGANIZATION, DATABASE_QUALIFIER, FFI_CONTRACT_HASH, FFI_CONTRACT_MAJOR,
+ FFI_CONTRACT_MINOR, RequestContextDto, RuntimeCore, StudioAppCore, StudioError,
+ SystemClock, compatibility_descriptor, local_first_relay_configuration, runtime,
+ verify_compatibility,
+ };
+
+ fn in_memory_core() -> Arc<StudioAppCore> {
+ let actor = RuntimeActorHandle::in_memory(
+ RelayConfiguration::default(),
+ Arc::new(InMemorySecretStore::default()),
+ Arc::new(SystemClock),
+ Arc::new(SdkNostrClient::new(std::time::Duration::from_millis(10))),
+ NonZeroUsize::new(ACTOR_MAILBOX_CAPACITY).expect("capacity"),
+ runtime().handle(),
+ )
+ .expect("in-memory actor");
+ Arc::new(StudioAppCore {
+ inner: Arc::new(RuntimeCore {
+ actor,
+ observers: std::sync::Mutex::new(std::collections::BTreeMap::new()),
+ closed: std::sync::atomic::AtomicBool::new(false),
+ startup_relay_problem: None,
+ }),
+ })
+ }
+
+ #[tokio::test]
+ async fn exported_bootstrap_and_snapshot_are_revisioned() {
+ let core = in_memory_core();
+ let bootstrapped = core.bootstrap().await.expect("bootstrap");
+ let current = core.snapshot();
+
+ assert_eq!(bootstrapped, current);
+ assert_eq!(current.revision, 1);
+ }
+
+ #[tokio::test]
+ async fn request_context_import_replays_one_committed_receipt() {
+ let core = in_memory_core();
+ let initial = core.snapshot();
+ let context = RequestContextDto {
+ request_id: "ffi-test-import-1".to_owned(),
+ expected_revision: initial.revision,
+ deadline_millis: 5_000,
+ };
+ let secret = b"7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7";
+ let first = core
+ .import_account_v2(context.clone(), secret.to_vec())
+ .await
+ .expect("first import");
+ let replay = core
+ .import_account_v2(context, secret.to_vec())
+ .await
+ .expect("replayed import");
+
+ assert_eq!(first, replay);
+ assert_eq!(first.snapshot.accounts.len(), 1);
+ assert_eq!(first.request_id, "ffi-test-import-1");
+ }
+
+ #[tokio::test]
+ async fn generated_recovery_handle_is_one_use_and_acknowledgement_gated() {
+ let core = in_memory_core();
+ let initial = core.snapshot();
+ let recovery = core
+ .begin_generated_account_v2()
+ .await
+ .expect("begin recovery");
+
+ assert_eq!(core.snapshot(), initial);
+ let nsec = recovery.take_recovery_nsec().expect("one-use nsec");
+ assert!(nsec.starts_with("nsec1"));
+ assert!(recovery.take_recovery_nsec().is_err());
+ let committed = core
+ .acknowledge_generated_account_v2(Arc::clone(&recovery))
+ .await
+ .expect("acknowledge");
+ assert_eq!(committed.accounts.len(), 1);
+ let repeated = core
+ .acknowledge_generated_account_v2(recovery)
+ .await
+ .expect_err("repeated acknowledgement");
+ assert!(matches!(
+ repeated,
+ StudioError::Failure { safe_message, .. }
+ if safe_message == "The generated-key recovery step is no longer valid."
+ ));
+ }
+
+ #[test]
+ fn compatibility_matrix_rejects_before_storage_mutation() {
+ let actual = compatibility_descriptor();
+ let compatible = CompatibilityExpectation {
+ contract_major: FFI_CONTRACT_MAJOR,
+ minimum_contract_minor: FFI_CONTRACT_MINOR,
+ contract_hash: FFI_CONTRACT_HASH.to_owned(),
+ minimum_schema_version: 5,
+ maximum_schema_version: CURRENT_SCHEMA_VERSION,
+ };
+ verify_compatibility(&compatible).expect("compatible");
+
+ for incompatible in [
+ CompatibilityExpectation {
+ contract_major: FFI_CONTRACT_MAJOR + 1,
+ ..compatible.clone()
+ },
+ CompatibilityExpectation {
+ minimum_contract_minor: FFI_CONTRACT_MINOR + 1,
+ ..compatible.clone()
+ },
+ CompatibilityExpectation {
+ contract_hash: "wrong-contract".to_owned(),
+ ..compatible.clone()
+ },
+ CompatibilityExpectation {
+ minimum_schema_version: actual.current_schema_version + 1,
+ ..compatible.clone()
+ },
+ CompatibilityExpectation {
+ maximum_schema_version: actual.minimum_schema_version - 1,
+ ..compatible.clone()
+ },
+ ] {
+ assert!(verify_compatibility(&incompatible).is_err());
+ }
+
+ let directory = tempfile::tempdir().expect("directory");
+ let rejected = directory.path().join("rejected").join("studio.sqlite3");
+ let incompatible = CompatibilityExpectation {
+ contract_major: FFI_CONTRACT_MAJOR + 1,
+ ..compatible
+ };
+ assert!(StudioAppCore::open_path_compatible(&rejected, &incompatible, true).is_err());
+ assert!(!rejected.parent().expect("parent").exists());
+ }
+
+ #[test]
+ fn v5_compatibility_fixture_preserves_external_coordinates() {
+ let fixture = include_str!("../../../compatibility/v5-baseline.properties");
+ let property = |key: &str| {
+ fixture.lines().find_map(|line| {
+ line.split_once('=')
+ .filter(|(candidate, _)| *candidate == key)
+ .map(|(_, value)| value)
+ })
+ };
+
+ assert_eq!(property("baseline.id"), Some("studio-runtime-v5"));
+ assert_eq!(property("schema.version"), Some("5"));
+ assert_eq!(CURRENT_SCHEMA_VERSION, 9);
+ assert_eq!(property("ffi.contract"), Some("legacy-unversioned-v1"));
+ assert_eq!(property("ffi.snapshot.schema"), Some("1"));
+ assert_eq!(property("ffi.runtime.version"), Some("0.1.0-alpha"));
+ assert_eq!(property("database.qualifier"), Some(DATABASE_QUALIFIER));
+ assert_eq!(
+ property("database.organization"),
+ Some(DATABASE_ORGANIZATION)
+ );
+ assert_eq!(property("database.application"), Some(DATABASE_APPLICATION));
+ assert_eq!(property("database.filename"), Some(DATABASE_FILENAME));
+ assert_eq!(property("keyring.service"), Some(CREDENTIAL_SERVICE));
+ assert_eq!(
+ property("keyring.account"),
+ Some("canonical-lowercase-public-key-hex")
+ );
+ }
+
+ #[test]
+ fn superseded_v1_ffi_commands_are_absent() {
+ let commands = include_str!("commands.rs");
+ let observer = include_str!("observer.rs");
+ for forbidden in [
+ format!("pub async fn {}_account(", "generate"),
+ format!("pub async fn {}_secret_key(", "import"),
+ format!("pub fn {}(development_mode", "open"),
+ format!("pub async fn {}(", "subscribe"),
+ format!("pub fn {}(&self)", "shutdown"),
+ ] {
+ assert!(!commands.contains(&forbidden));
+ assert!(!observer.contains(&forbidden));
+ }
+ }
+
+ #[test]
+ fn invalid_relay_configuration_preserves_local_startup_as_degraded() {
+ let problem = SafeError::new(
+ radroots_studio_domain::SafeErrorCode::InvalidRelayConfiguration,
+ radroots_studio_domain::SafeMessage::new("The Nostr relay configuration is invalid."),
+ );
+ let (relays, degraded) = local_first_relay_configuration(Err(problem));
+
+ assert!(relays.relays().is_empty());
+ assert_eq!(degraded, Some(problem));
+ }
+}
diff --git a/crates/studio_ffi/src/dto.rs b/crates/studio_ffi/src/dto.rs
@@ -0,0 +1,419 @@
+use radroots_studio_application::{
+ ActiveAccountSnapshot, AppLifecycle, AppSnapshot, ProfileLoadState, RelayConnectionState,
+ RuntimeLifecycle, SessionState,
+};
+use radroots_studio_domain::{
+ AccountSummary, BindingAvailability, ProfileMetadata, SafeError, SafeErrorCode,
+};
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)]
+pub enum WireErrorCode {
+ InvalidPublicKey,
+ InvalidSecretKey,
+ InvalidAccountMetadata,
+ InvalidProfileMetadata,
+ InvalidApplicationState,
+ AccountAlreadyExists,
+ AccountNotFound,
+ KeyringUnavailable,
+ CredentialMissing,
+ StorageUnavailable,
+ StorageCorrupt,
+ PendingOperationRecoveryRequired,
+ InvalidRelayConfiguration,
+ RelayConnectionFailed,
+ ProfileRefreshFailed,
+ ObserverRegistrationFailed,
+ NativeLibraryLoadFailed,
+ CompatibilityMismatch,
+ Internal,
+}
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)]
+pub enum WireErrorCategory {
+ Input,
+ Conflict,
+ Credential,
+ Storage,
+ Network,
+ Lifecycle,
+ Compatibility,
+ Internal,
+}
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)]
+pub enum WireRecoveryAction {
+ None,
+ Retry,
+ RepairCredential,
+ CheckConfiguration,
+ RestartApplication,
+ UpdateApplication,
+}
+
+#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)]
+pub struct SafeErrorDto {
+ pub code: WireErrorCode,
+ pub category: WireErrorCategory,
+ pub retryable: bool,
+ pub recovery_action: WireRecoveryAction,
+ pub message: String,
+}
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)]
+pub enum AppLifecycleDto {
+ Opening,
+ CompatibilityChecking,
+ AcquiringOwnership,
+ Migrating,
+ Recovering,
+ Ready,
+ Degraded,
+ Blocked,
+ ShuttingDown,
+ Closed,
+ Fatal,
+}
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)]
+pub enum SessionStateDto {
+ SignedOut,
+ Activating,
+ Active,
+ SigningOut,
+ Failed,
+}
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)]
+pub enum RelayConnectionStateDto {
+ Disconnected,
+ Connecting,
+ Connected,
+ Degraded,
+ Error,
+}
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)]
+pub enum ProfileLoadStateDto {
+ Empty,
+ Loading,
+ Cached,
+ Fresh,
+ Error,
+}
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)]
+pub enum SignerKindDto {
+ LocalSecret,
+ WatchOnly,
+ RemoteNip46,
+}
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)]
+pub enum KeyAvailabilityDto {
+ Available,
+ CredentialMissing,
+ StoreUnavailable,
+ NotRequired,
+}
+
+#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)]
+pub struct ProfileDto {
+ pub name: Option<String>,
+ pub display_name: Option<String>,
+ pub nip05: Option<String>,
+ pub about: Option<String>,
+ pub picture: Option<String>,
+}
+
+#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)]
+pub struct AccountDto {
+ pub public_key_hex: String,
+ pub npub: String,
+ pub display_label: String,
+ pub signer_kind: SignerKindDto,
+ pub key_availability: KeyAvailabilityDto,
+ pub created_at_seconds: i64,
+ pub last_used_at_seconds: Option<i64>,
+}
+
+#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)]
+pub struct ActiveAccountDto {
+ pub account: AccountDto,
+ pub relay_state: RelayConnectionStateDto,
+ pub profile_state: ProfileLoadStateDto,
+ pub profile: Option<ProfileDto>,
+}
+
+#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)]
+pub struct AppSnapshotDto {
+ pub revision: u64,
+ pub lifecycle: AppLifecycleDto,
+ pub lifecycle_error: Option<SafeErrorDto>,
+ pub configured_relays: Vec<String>,
+ pub accounts: Vec<AccountDto>,
+ pub selected_public_key_hex: Option<String>,
+ pub session: SessionStateDto,
+ pub session_subject_public_key_hex: Option<String>,
+ pub session_error: Option<SafeErrorDto>,
+ pub active_account: Option<ActiveAccountDto>,
+ pub recoverable_problem: Option<SafeErrorDto>,
+}
+
+impl From<&AppSnapshot> for AppSnapshotDto {
+ fn from(snapshot: &AppSnapshot) -> Self {
+ let (lifecycle, lifecycle_error) = match snapshot.lifecycle() {
+ AppLifecycle::Booting => (AppLifecycleDto::Opening, None),
+ AppLifecycle::Ready => (AppLifecycleDto::Ready, None),
+ AppLifecycle::Fatal(error) => (AppLifecycleDto::Fatal, Some(error.into())),
+ };
+ let (session, session_subject_public_key_hex, session_error) = match snapshot.session() {
+ SessionState::SignedOut => (SessionStateDto::SignedOut, None, None),
+ SessionState::Activating(public_key) => {
+ (SessionStateDto::Activating, Some(public_key.to_hex()), None)
+ }
+ SessionState::Active => (SessionStateDto::Active, None, None),
+ SessionState::SigningOut => (SessionStateDto::SigningOut, None, None),
+ SessionState::Failed(error) => (SessionStateDto::Failed, None, Some(error.into())),
+ };
+ Self {
+ revision: snapshot.revision().value(),
+ lifecycle,
+ lifecycle_error,
+ configured_relays: snapshot
+ .relay_configuration()
+ .relays()
+ .iter()
+ .map(|relay| relay.as_str().to_owned())
+ .collect(),
+ accounts: snapshot.accounts().iter().map(AccountDto::from).collect(),
+ selected_public_key_hex: snapshot
+ .selected_account()
+ .map(radroots_studio_domain::PublicKey::to_hex),
+ session,
+ session_subject_public_key_hex,
+ session_error,
+ active_account: snapshot.active_account().map(ActiveAccountDto::from),
+ recoverable_problem: snapshot.recoverable_problem().map(SafeErrorDto::from),
+ }
+ }
+}
+
+impl AppSnapshotDto {
+ pub(crate) fn from_runtime(snapshot: &AppSnapshot, runtime: RuntimeLifecycle) -> Self {
+ let mut dto = Self::from(snapshot);
+ let (lifecycle, problem) = match runtime {
+ RuntimeLifecycle::Opening => (AppLifecycleDto::Opening, None),
+ RuntimeLifecycle::CompatibilityChecking => {
+ (AppLifecycleDto::CompatibilityChecking, None)
+ }
+ RuntimeLifecycle::AcquiringOwnership => (AppLifecycleDto::AcquiringOwnership, None),
+ RuntimeLifecycle::Migrating => (AppLifecycleDto::Migrating, None),
+ RuntimeLifecycle::Recovering => (AppLifecycleDto::Recovering, None),
+ RuntimeLifecycle::Ready => (AppLifecycleDto::Ready, None),
+ RuntimeLifecycle::Degraded(error) => {
+ (AppLifecycleDto::Degraded, Some(SafeErrorDto::from(error)))
+ }
+ RuntimeLifecycle::Blocked(error) => {
+ (AppLifecycleDto::Blocked, Some(SafeErrorDto::from(error)))
+ }
+ RuntimeLifecycle::ShuttingDown => (AppLifecycleDto::ShuttingDown, None),
+ RuntimeLifecycle::Closed => (AppLifecycleDto::Closed, None),
+ RuntimeLifecycle::Fatal(error) => {
+ (AppLifecycleDto::Fatal, Some(SafeErrorDto::from(error)))
+ }
+ };
+ dto.lifecycle = lifecycle;
+ dto.lifecycle_error = problem;
+ dto
+ }
+}
+
+impl From<&AccountSummary> for AccountDto {
+ fn from(account: &AccountSummary) -> Self {
+ Self {
+ public_key_hex: account.public_key().to_hex(),
+ npub: account.npub().as_str().to_owned(),
+ display_label: account.display_label(),
+ signer_kind: SignerKindDto::LocalSecret,
+ key_availability: account.signer().availability().into(),
+ created_at_seconds: account.created_at().timestamp().as_seconds(),
+ last_used_at_seconds: account
+ .last_used_at()
+ .map(radroots_studio_domain::UnixTimestamp::as_seconds),
+ }
+ }
+}
+
+impl From<&ActiveAccountSnapshot> for ActiveAccountDto {
+ fn from(active: &ActiveAccountSnapshot) -> Self {
+ Self {
+ account: active.account().into(),
+ relay_state: active.relay_state().into(),
+ profile_state: active.profile_state().into(),
+ profile: active.profile().map(ProfileDto::from),
+ }
+ }
+}
+
+impl From<&ProfileMetadata> for ProfileDto {
+ fn from(profile: &ProfileMetadata) -> Self {
+ Self {
+ name: profile.name().map(str::to_owned),
+ display_name: profile.display_name().map(str::to_owned),
+ nip05: profile.nip05().map(str::to_owned),
+ about: profile.about().map(str::to_owned),
+ picture: profile.picture().map(str::to_owned),
+ }
+ }
+}
+
+impl From<SafeError> for SafeErrorDto {
+ fn from(error: SafeError) -> Self {
+ let (category, retryable, recovery_action) = error_policy(error.code());
+ Self {
+ code: error.code().into(),
+ category,
+ retryable,
+ recovery_action,
+ message: error.message().as_str().to_owned(),
+ }
+ }
+}
+
+impl From<SafeErrorCode> for WireErrorCode {
+ fn from(code: SafeErrorCode) -> Self {
+ match code {
+ SafeErrorCode::InvalidPublicKey => Self::InvalidPublicKey,
+ SafeErrorCode::InvalidSecretKey => Self::InvalidSecretKey,
+ SafeErrorCode::InvalidAccountMetadata => Self::InvalidAccountMetadata,
+ SafeErrorCode::InvalidProfileMetadata => Self::InvalidProfileMetadata,
+ SafeErrorCode::InvalidApplicationState => Self::InvalidApplicationState,
+ SafeErrorCode::AccountAlreadyExists => Self::AccountAlreadyExists,
+ SafeErrorCode::AccountNotFound => Self::AccountNotFound,
+ SafeErrorCode::KeyringUnavailable => Self::KeyringUnavailable,
+ SafeErrorCode::CredentialMissing => Self::CredentialMissing,
+ SafeErrorCode::StorageUnavailable => Self::StorageUnavailable,
+ SafeErrorCode::StorageCorrupt => Self::StorageCorrupt,
+ SafeErrorCode::PendingOperationRecoveryRequired => {
+ Self::PendingOperationRecoveryRequired
+ }
+ SafeErrorCode::InvalidRelayConfiguration => Self::InvalidRelayConfiguration,
+ SafeErrorCode::RelayConnectionFailed => Self::RelayConnectionFailed,
+ SafeErrorCode::ProfileRefreshFailed => Self::ProfileRefreshFailed,
+ SafeErrorCode::ObserverRegistrationFailed => Self::ObserverRegistrationFailed,
+ SafeErrorCode::NativeLibraryLoadFailed => Self::NativeLibraryLoadFailed,
+ _ => Self::Internal,
+ }
+ }
+}
+
+pub(crate) const fn error_policy(
+ code: SafeErrorCode,
+) -> (WireErrorCategory, bool, WireRecoveryAction) {
+ match code {
+ SafeErrorCode::InvalidPublicKey
+ | SafeErrorCode::InvalidSecretKey
+ | SafeErrorCode::InvalidAccountMetadata
+ | SafeErrorCode::InvalidProfileMetadata => {
+ (WireErrorCategory::Input, false, WireRecoveryAction::None)
+ }
+ SafeErrorCode::AccountAlreadyExists | SafeErrorCode::AccountNotFound => {
+ (WireErrorCategory::Conflict, false, WireRecoveryAction::None)
+ }
+ SafeErrorCode::KeyringUnavailable => (
+ WireErrorCategory::Credential,
+ true,
+ WireRecoveryAction::Retry,
+ ),
+ SafeErrorCode::CredentialMissing => (
+ WireErrorCategory::Credential,
+ false,
+ WireRecoveryAction::RepairCredential,
+ ),
+ SafeErrorCode::StorageUnavailable => (
+ WireErrorCategory::Storage,
+ true,
+ WireRecoveryAction::RestartApplication,
+ ),
+ SafeErrorCode::StorageCorrupt | SafeErrorCode::PendingOperationRecoveryRequired => (
+ WireErrorCategory::Storage,
+ false,
+ WireRecoveryAction::RestartApplication,
+ ),
+ SafeErrorCode::InvalidRelayConfiguration => (
+ WireErrorCategory::Network,
+ false,
+ WireRecoveryAction::CheckConfiguration,
+ ),
+ SafeErrorCode::RelayConnectionFailed | SafeErrorCode::ProfileRefreshFailed => {
+ (WireErrorCategory::Network, true, WireRecoveryAction::Retry)
+ }
+ SafeErrorCode::InvalidApplicationState | SafeErrorCode::ObserverRegistrationFailed => (
+ WireErrorCategory::Lifecycle,
+ true,
+ WireRecoveryAction::Retry,
+ ),
+ SafeErrorCode::NativeLibraryLoadFailed => (
+ WireErrorCategory::Internal,
+ false,
+ WireRecoveryAction::RestartApplication,
+ ),
+ _ => (WireErrorCategory::Internal, false, WireRecoveryAction::None),
+ }
+}
+
+impl From<BindingAvailability> for KeyAvailabilityDto {
+ fn from(value: BindingAvailability) -> Self {
+ match value {
+ BindingAvailability::Available => Self::Available,
+ BindingAvailability::CredentialMissing => Self::CredentialMissing,
+ BindingAvailability::StoreUnavailable => Self::StoreUnavailable,
+ }
+ }
+}
+
+impl From<RelayConnectionState> for RelayConnectionStateDto {
+ fn from(value: RelayConnectionState) -> Self {
+ match value {
+ RelayConnectionState::Disconnected => Self::Disconnected,
+ RelayConnectionState::Connecting => Self::Connecting,
+ RelayConnectionState::Connected => Self::Connected,
+ RelayConnectionState::Degraded => Self::Degraded,
+ RelayConnectionState::Error(_) => Self::Error,
+ }
+ }
+}
+
+impl From<ProfileLoadState> for ProfileLoadStateDto {
+ fn from(value: ProfileLoadState) -> Self {
+ match value {
+ ProfileLoadState::Empty => Self::Empty,
+ ProfileLoadState::Loading => Self::Loading,
+ ProfileLoadState::Cached => Self::Cached,
+ ProfileLoadState::Fresh => Self::Fresh,
+ ProfileLoadState::Error(_) => Self::Error,
+ }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use radroots_studio_application::{AppCore, RelayConfiguration};
+
+ use super::AppSnapshotDto;
+
+ #[test]
+ fn snapshot_dto_is_revisioned_public_and_secret_free() {
+ let core = AppCore::in_memory(RelayConfiguration::default());
+ let snapshot = core.bootstrap().expect("bootstrap");
+ let dto = AppSnapshotDto::from(&snapshot);
+ let debug = format!("{dto:?}");
+
+ assert_eq!(dto.revision, 1);
+ assert!(dto.accounts.is_empty());
+ assert!(!debug.contains("nsec"));
+ assert!(!debug.contains("secret_key"));
+ assert!(!debug.contains("server_url"));
+ }
+}
diff --git a/crates/studio_ffi/src/lib.rs b/crates/studio_ffi/src/lib.rs
@@ -0,0 +1,34 @@
+#![doc = "Radroots Studio `UniFFI` boundary."]
+
+mod commands;
+mod dto;
+mod observer;
+
+pub use commands::{
+ AccountCommandReceiptDto, GeneratedRecoveryRequest, RemovalRequest, RequestContextDto,
+ StudioAppCore, StudioError,
+};
+pub use dto::{
+ AccountDto, ActiveAccountDto, AppLifecycleDto, AppSnapshotDto, KeyAvailabilityDto, ProfileDto,
+ ProfileLoadStateDto, RelayConnectionStateDto, SafeErrorDto, SessionStateDto, SignerKindDto,
+ WireErrorCategory, WireErrorCode, WireRecoveryAction,
+};
+pub use observer::{
+ ObserverSubscription, ShutdownReceiptDto, SnapshotChangeDto, StudioChangeObserver,
+};
+
+uniffi::setup_scaffolding!();
+
+#[uniffi::export]
+#[must_use]
+pub fn native_runtime_version() -> String {
+ env!("CARGO_PKG_VERSION").to_owned()
+}
+
+#[cfg(test)]
+mod tests {
+ #[test]
+ fn native_runtime_reports_the_crate_version() {
+ assert_eq!(super::native_runtime_version(), "0.1.0-alpha");
+ }
+}
diff --git a/crates/studio_ffi/src/observer.rs b/crates/studio_ffi/src/observer.rs
@@ -0,0 +1,363 @@
+use std::num::NonZeroUsize;
+use std::sync::atomic::Ordering;
+use std::sync::{Arc, Mutex, Weak};
+
+use radroots_studio_application::ChangeSubscriptionId;
+
+use crate::commands::RuntimeCore;
+use crate::{AppSnapshotDto, StudioAppCore, StudioError};
+
+const OBSERVER_CHANGE_CAPACITY: NonZeroUsize = match NonZeroUsize::new(64) {
+ Some(capacity) => capacity,
+ None => unreachable!(),
+};
+
+#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)]
+pub struct SnapshotChangeDto {
+ pub snapshot: AppSnapshotDto,
+ pub previous_revision: Option<u64>,
+}
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Record)]
+pub struct ShutdownReceiptDto {
+ pub final_revision: u64,
+ pub closed: bool,
+}
+
+#[uniffi::export(callback_interface)]
+pub trait StudioChangeObserver: Send + Sync {
+ fn on_change(&self, change: SnapshotChangeDto);
+}
+
+#[derive(uniffi::Object)]
+pub struct ObserverSubscription {
+ core: Weak<RuntimeCore>,
+ id: Mutex<Option<ChangeSubscriptionId>>,
+}
+
+#[uniffi::export]
+impl ObserverSubscription {
+ pub fn unsubscribe(&self) {
+ let id = self
+ .id
+ .lock()
+ .unwrap_or_else(std::sync::PoisonError::into_inner)
+ .take();
+ let (Some(core), Some(id)) = (self.core.upgrade(), id) else {
+ return;
+ };
+ if let Some(task) = core
+ .observers
+ .lock()
+ .unwrap_or_else(std::sync::PoisonError::into_inner)
+ .remove(&id)
+ {
+ task.abort();
+ }
+ let actor = core.actor.clone();
+ crate::commands::runtime().spawn(async move {
+ let _ = actor.unsubscribe_changes(id).await;
+ });
+ }
+}
+
+impl Drop for ObserverSubscription {
+ fn drop(&mut self) {
+ self.unsubscribe();
+ }
+}
+
+#[uniffi::export]
+impl StudioAppCore {
+ /// Subscribes to ordered revision changes including predecessor metadata.
+ ///
+ /// # Errors
+ ///
+ /// Returns a safe observer or lifecycle error.
+ pub async fn subscribe_changes_v2(
+ &self,
+ observer: Box<dyn StudioChangeObserver>,
+ ) -> Result<Arc<ObserverSubscription>, StudioError> {
+ if self.inner.closed.load(Ordering::Acquire) {
+ return Err(closed_error());
+ }
+ let mut subscription = self
+ .inner
+ .actor
+ .subscribe_changes(OBSERVER_CHANGE_CAPACITY)
+ .await
+ .map_err(StudioError::from)?;
+ let id = subscription.id();
+ let observer: Arc<dyn StudioChangeObserver> = Arc::from(observer);
+ let runtime_core = Arc::clone(&self.inner);
+ let task = crate::commands::runtime().spawn(async move {
+ while let Some(change) = subscription.receive().await {
+ observer.on_change(SnapshotChangeDto {
+ snapshot: AppSnapshotDto::from_runtime(
+ change.snapshot(),
+ runtime_core.effective_lifecycle(),
+ ),
+ previous_revision: change
+ .previous_revision()
+ .map(radroots_studio_application::SnapshotRevision::value),
+ });
+ }
+ });
+ self.inner
+ .observers
+ .lock()
+ .unwrap_or_else(std::sync::PoisonError::into_inner)
+ .insert(id, task);
+ Ok(Arc::new(ObserverSubscription {
+ core: Arc::downgrade(&self.inner),
+ id: Mutex::new(Some(id)),
+ }))
+ }
+
+ /// Stops observer delivery and waits for actor-owned shutdown.
+ ///
+ /// # Errors
+ ///
+ /// Returns a safe closed or timeout error when shutdown cannot complete.
+ pub async fn shutdown_v2(&self) -> Result<ShutdownReceiptDto, StudioError> {
+ if self.inner.closed.swap(true, Ordering::AcqRel) {
+ return Err(closed_error());
+ }
+ let handles = std::mem::take(
+ &mut *self
+ .inner
+ .observers
+ .lock()
+ .unwrap_or_else(std::sync::PoisonError::into_inner),
+ );
+ for (_, task) in handles {
+ task.abort();
+ }
+ self.inner.actor.close().await.map_err(StudioError::from)?;
+ Ok(ShutdownReceiptDto {
+ final_revision: self.inner.actor.snapshot().revision().value(),
+ closed: true,
+ })
+ }
+}
+
+fn closed_error() -> StudioError {
+ StudioError::Failure {
+ code: crate::WireErrorCode::InvalidApplicationState,
+ category: crate::WireErrorCategory::Lifecycle,
+ retryable: false,
+ recovery_action: crate::WireRecoveryAction::None,
+ correlation_id: None,
+ safe_message: "The application runtime is closed.".to_owned(),
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use std::num::NonZeroUsize;
+ use std::sync::{Arc, Mutex};
+ use std::time::Duration;
+
+ use nostr::{EventBuilder, Keys, Metadata};
+ use nostr_relay_builder::MockRelay;
+ use nostr_sdk::Client;
+ use radroots_studio_application::{InMemorySecretStore, RelayConfiguration, SdkNostrClient};
+ use radroots_studio_domain::RelayUrl;
+ use radroots_studio_storage::RuntimeActorHandle;
+
+ use crate::commands::{ACTOR_MAILBOX_CAPACITY, RuntimeCore, SystemClock, runtime};
+ use crate::{
+ AppSnapshotDto, ProfileLoadStateDto, SnapshotChangeDto, StudioAppCore, StudioChangeObserver,
+ };
+
+ const SECRET_HEX: &str = "7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7";
+
+ #[derive(Default)]
+ struct RecordingObserver {
+ snapshots: Mutex<Vec<AppSnapshotDto>>,
+ core: Mutex<Option<Arc<StudioAppCore>>>,
+ }
+
+ impl StudioChangeObserver for RecordingObserver {
+ fn on_change(&self, change: SnapshotChangeDto) {
+ let snapshot = change.snapshot;
+ if let Some(core) = self.core.lock().expect("core").as_ref() {
+ assert_eq!(core.snapshot().revision, snapshot.revision);
+ }
+ self.snapshots.lock().expect("snapshots").push(snapshot);
+ }
+ }
+
+ fn core() -> Arc<StudioAppCore> {
+ core_with_relays(RelayConfiguration::default())
+ }
+
+ fn core_with_relays(relays: RelayConfiguration) -> Arc<StudioAppCore> {
+ let actor = RuntimeActorHandle::in_memory(
+ relays,
+ Arc::new(InMemorySecretStore::default()),
+ Arc::new(SystemClock),
+ Arc::new(SdkNostrClient::new(std::time::Duration::from_millis(10))),
+ NonZeroUsize::new(ACTOR_MAILBOX_CAPACITY).expect("capacity"),
+ runtime().handle(),
+ )
+ .expect("actor");
+ Arc::new(StudioAppCore {
+ inner: Arc::new(RuntimeCore {
+ actor,
+ observers: Mutex::new(std::collections::BTreeMap::new()),
+ closed: std::sync::atomic::AtomicBool::new(false),
+ startup_relay_problem: None,
+ }),
+ })
+ }
+
+ #[test]
+ fn callbacks_allow_reentry_and_stop_after_subscription_close() {
+ runtime().block_on(async {
+ let core = core();
+ let observer = Arc::new(RecordingObserver::default());
+ *observer.core.lock().expect("core") = Some(Arc::clone(&core));
+ let subscription = core
+ .subscribe_changes_v2(Box::new(ArcObserver(observer.clone())))
+ .await
+ .expect("subscribe");
+
+ wait_for_snapshot_count(&observer, 1).await;
+ core.inner
+ .actor
+ .bootstrap()
+ .await
+ .expect("idempotent bootstrap");
+ assert_eq!(observer.snapshots.lock().expect("snapshots").len(), 1);
+ subscription.unsubscribe();
+ core.inner.actor.sign_out().await.expect("sign out");
+ assert_eq!(observer.snapshots.lock().expect("snapshots").len(), 1);
+ });
+ }
+
+ #[test]
+ fn core_close_deregisters_all_observers_and_rejects_new_subscriptions() {
+ let core = core();
+ let observer = Arc::new(RecordingObserver::default());
+ let _subscription = runtime()
+ .block_on(core.subscribe_changes_v2(Box::new(ArcObserver(observer.clone()))))
+ .expect("subscribe");
+
+ runtime().block_on(core.shutdown_v2()).expect("shutdown");
+
+ assert!(
+ runtime()
+ .block_on(core.subscribe_changes_v2(Box::new(ArcObserver(observer))))
+ .is_err()
+ );
+ assert!(core.inner.observers.lock().expect("observers").is_empty());
+ }
+
+ #[tokio::test]
+ async fn ffi_callback_receives_async_profile_refresh_and_stops_after_unsubscribe() {
+ let local_relay = MockRelay::run().await.expect("local relay");
+ let relay_url = local_relay.url().await;
+ let publisher = Client::new(Keys::parse(SECRET_HEX).expect("known key"));
+ publisher
+ .add_relay(relay_url.clone())
+ .await
+ .expect("publisher relay");
+ publisher.connect().await;
+ publisher.wait_for_connection(Duration::from_secs(2)).await;
+ publisher
+ .send_event_builder(EventBuilder::metadata(
+ &Metadata::new().display_name("FFI Profile"),
+ ))
+ .await
+ .expect("publish profile");
+
+ let core = core_with_relays(RelayConfiguration::new(vec![
+ RelayUrl::parse(relay_url.as_str()).expect("relay URL"),
+ ]));
+ core.bootstrap().await.expect("bootstrap");
+ let observer = Arc::new(RecordingObserver::default());
+ *observer.core.lock().expect("core") = Some(Arc::clone(&core));
+ let subscription = core
+ .subscribe_changes_v2(Box::new(ArcObserver(observer.clone())))
+ .await
+ .expect("subscribe");
+ let imported = core
+ .import_account_v2(
+ crate::RequestContextDto {
+ request_id: "observer-import".to_owned(),
+ expected_revision: core.snapshot().revision,
+ deadline_millis: 5_000,
+ },
+ SECRET_HEX.as_bytes().to_vec(),
+ )
+ .await
+ .expect("import")
+ .snapshot;
+ let public_key = imported.selected_public_key_hex.expect("selection");
+ core.activate_account(public_key).await.expect("activate");
+ core.refresh_active_profile().await.expect("refresh");
+
+ wait_for_fresh_profile(&observer).await;
+ let snapshots = observer.snapshots.lock().expect("snapshots").clone();
+ assert!(snapshots.iter().any(|snapshot| {
+ snapshot.active_account.as_ref().is_some_and(|active| {
+ active.profile_state == ProfileLoadStateDto::Fresh
+ && active
+ .profile
+ .as_ref()
+ .and_then(|profile| profile.display_name.as_deref())
+ == Some("FFI Profile")
+ })
+ }));
+ subscription.unsubscribe();
+ let count = observer.snapshots.lock().expect("snapshots").len();
+ core.sign_out().await.expect("sign out");
+ assert_eq!(observer.snapshots.lock().expect("snapshots").len(), count);
+
+ core.shutdown_v2().await.expect("shutdown");
+ publisher.shutdown().await;
+ local_relay.shutdown();
+ }
+
+ struct ArcObserver(Arc<RecordingObserver>);
+
+ impl StudioChangeObserver for ArcObserver {
+ fn on_change(&self, change: SnapshotChangeDto) {
+ self.0.on_change(change);
+ }
+ }
+
+ async fn wait_for_snapshot_count(observer: &RecordingObserver, minimum: usize) {
+ tokio::time::timeout(Duration::from_secs(1), async {
+ while observer.snapshots.lock().expect("snapshots").len() < minimum {
+ tokio::task::yield_now().await;
+ }
+ })
+ .await
+ .expect("snapshot delivery");
+ }
+
+ async fn wait_for_fresh_profile(observer: &RecordingObserver) {
+ tokio::time::timeout(Duration::from_secs(1), async {
+ loop {
+ let fresh = observer
+ .snapshots
+ .lock()
+ .expect("snapshots")
+ .iter()
+ .any(|snapshot| {
+ snapshot.active_account.as_ref().is_some_and(|active| {
+ active.profile_state == ProfileLoadStateDto::Fresh
+ })
+ });
+ if fresh {
+ break;
+ }
+ tokio::task::yield_now().await;
+ }
+ })
+ .await
+ .expect("fresh profile delivery");
+ }
+}
diff --git a/crates/studio_ffi/uniffi.toml b/crates/studio_ffi/uniffi.toml
@@ -0,0 +1,3 @@
+[crates.radroots_studio_ffi.bindings.kotlin]
+package_name = "org.radroots.studio.ffi"
+cdylib_name = "radroots_studio_ffi"
diff --git a/crates/studio_nostr/Cargo.toml b/crates/studio_nostr/Cargo.toml
@@ -0,0 +1,14 @@
+[package]
+name = "radroots-studio-nostr"
+version.workspace = true
+edition.workspace = true
+rust-version.workspace = true
+license.workspace = true
+repository.workspace = true
+
+[dependencies]
+nostr.workspace = true
+radroots-studio-domain = { path = "../domain" }
+
+[lints]
+workspace = true
diff --git a/crates/studio_nostr/src/keys.rs b/crates/studio_nostr/src/keys.rs
@@ -0,0 +1,152 @@
+use nostr::{Keys, ToBech32};
+use radroots_studio_domain::{
+ Npub, Nsec, PublicKey, SafeError, SafeErrorCode, SafeMessage, SecretKeyInput,
+};
+
+pub struct GeneratedKeyMaterial {
+ public_key: PublicKey,
+ npub: Npub,
+ secret: SecretKeyInput,
+ nsec: Nsec,
+}
+
+impl GeneratedKeyMaterial {
+ #[must_use]
+ pub fn into_parts(self) -> (PublicKey, Npub, SecretKeyInput, Nsec) {
+ (self.public_key, self.npub, self.secret, self.nsec)
+ }
+}
+
+pub struct ImportedKeyMaterial {
+ public_key: PublicKey,
+ npub: Npub,
+ secret: SecretKeyInput,
+}
+
+impl ImportedKeyMaterial {
+ #[must_use]
+ pub fn into_parts(self) -> (PublicKey, Npub, SecretKeyInput) {
+ (self.public_key, self.npub, self.secret)
+ }
+}
+
+/// Generates one cryptographically random local Nostr keypair.
+///
+/// # Errors
+///
+/// Returns a safe key error if an upstream encoding cannot be represented by
+/// the stricter Radroots domain boundary.
+pub fn generate_local_keypair() -> Result<GeneratedKeyMaterial, SafeError> {
+ let keys = Keys::generate();
+ let (public_key, npub, secret, nsec) = encode_keys(&keys)?;
+ Ok(GeneratedKeyMaterial {
+ public_key,
+ npub,
+ secret,
+ nsec,
+ })
+}
+
+/// Parses nsec or canonical secret hex and derives public Nostr identity.
+///
+/// # Errors
+///
+/// Returns a safe invalid-secret-key error for checksum, scalar, or encoding
+/// failures without exposing the rejected input.
+pub fn import_secret(input: SecretKeyInput) -> Result<ImportedKeyMaterial, SafeError> {
+ let keys = input
+ .with_exposed_secret(Keys::parse)
+ .map_err(|_| invalid_secret_key())?;
+ drop(input);
+ let public_key = PublicKey::from_bytes(keys.public_key().to_bytes());
+ let npub = keys
+ .public_key()
+ .to_bech32()
+ .map_err(|_| invalid_public_key())
+ .and_then(Npub::from_encoded)?;
+ let secret = SecretKeyInput::parse(keys.secret_key().to_secret_hex())?;
+ Ok(ImportedKeyMaterial {
+ public_key,
+ npub,
+ secret,
+ })
+}
+
+fn encode_keys(keys: &Keys) -> Result<(PublicKey, Npub, SecretKeyInput, Nsec), SafeError> {
+ let public_key = PublicKey::from_bytes(keys.public_key().to_bytes());
+ let npub = keys
+ .public_key()
+ .to_bech32()
+ .map_err(|_| invalid_public_key())
+ .and_then(Npub::from_encoded)?;
+ let secret = SecretKeyInput::parse(keys.secret_key().to_secret_hex())?;
+ let nsec = keys
+ .secret_key()
+ .to_bech32()
+ .map_err(|_| invalid_secret_key())
+ .and_then(Nsec::from_encoded)?;
+ Ok((public_key, npub, secret, nsec))
+}
+
+const fn invalid_secret_key() -> SafeError {
+ SafeError::new(
+ SafeErrorCode::InvalidSecretKey,
+ SafeMessage::new("The Nostr secret key is invalid."),
+ )
+}
+
+const fn invalid_public_key() -> SafeError {
+ SafeError::new(
+ SafeErrorCode::InvalidPublicKey,
+ SafeMessage::new("The Nostr public key is invalid."),
+ )
+}
+
+#[cfg(test)]
+mod tests {
+ use radroots_studio_domain::{SafeErrorCode, SecretKeyInput};
+
+ use super::{generate_local_keypair, import_secret};
+
+ const SECRET_HEX: &str = "7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7";
+ const NSEC: &str = "nsec1vl029mgpspedva04g90vltkh6fvh240zqtv9k0t9af8935ke9laqsnlfe5";
+ const NSEC_PUBLIC_HEX: &str =
+ "7e7e9c42a91bfef19fa929e5fda1b72e0ebc1a4c1141673e2794234d86addf4e";
+ const HEX_PUBLIC_HEX: &str = "0cfda0afa91cc2fbbd6050c285802fe95c7a1755e0f68323999e13760501dc40";
+
+ #[test]
+ fn keys_generate_valid_redacted_material() {
+ let generated = generate_local_keypair().expect("generated");
+ let (public_key, npub, secret, nsec) = generated.into_parts();
+ assert_eq!(public_key.to_hex().len(), 64);
+ assert!(npub.as_str().starts_with("npub1"));
+ assert_eq!(secret.with_exposed_secret(str::len), 64);
+ assert_eq!(nsec.with_exposed_secret(str::len), 63);
+ assert_eq!(secret.with_exposed_secret(str::len), 64);
+ assert_eq!(nsec.with_exposed_secret(str::len), 63);
+ }
+
+ #[test]
+ fn keys_import_known_nsec_and_hex_vectors() {
+ let from_nsec = import_secret(SecretKeyInput::parse(NSEC.to_owned()).expect("nsec"))
+ .expect("import nsec");
+ let from_hex = import_secret(SecretKeyInput::parse(SECRET_HEX.to_owned()).expect("hex"))
+ .expect("import hex");
+ let (nsec_public, nsec_npub, _) = from_nsec.into_parts();
+ let (hex_public, hex_npub, _) = from_hex.into_parts();
+ assert_eq!(nsec_public.to_hex(), NSEC_PUBLIC_HEX);
+ assert_eq!(hex_public.to_hex(), HEX_PUBLIC_HEX);
+ assert!(nsec_npub.as_str().starts_with("npub1"));
+ assert!(hex_npub.as_str().starts_with("npub1"));
+ }
+
+ #[test]
+ fn keys_reject_structurally_plausible_nsec_with_invalid_checksum() {
+ let input = SecretKeyInput::parse(
+ "nsec1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq".to_owned(),
+ )
+ .expect("domain shape");
+ let error = import_secret(input).err().expect("invalid checksum");
+ assert_eq!(error.code(), SafeErrorCode::InvalidSecretKey);
+ }
+}
diff --git a/crates/studio_nostr/src/lib.rs b/crates/studio_nostr/src/lib.rs
@@ -0,0 +1,7 @@
+#![doc = "Radroots Studio Nostr protocol adapters."]
+
+pub mod keys;
+pub mod profile;
+
+pub use keys::{GeneratedKeyMaterial, ImportedKeyMaterial, generate_local_keypair, import_secret};
+pub use profile::parse_verified_kind0;
diff --git a/crates/studio_nostr/src/profile.rs b/crates/studio_nostr/src/profile.rs
@@ -0,0 +1,165 @@
+use nostr::{Event, JsonUtil, Kind, Metadata};
+use radroots_studio_domain::{
+ EventId, Kind0ProfileCandidate, ProfileMetadata, PublicKey, SafeError, SafeErrorCode,
+ SafeMessage, UnixTimestamp,
+};
+
+const MAX_EVENT_JSON_BYTES: usize = 64 * 1_024;
+const MAX_PROFILE_CONTENT_BYTES: usize = 16 * 1_024;
+
+/// Verifies and converts one serialized Nostr kind-0 event.
+///
+/// # Errors
+///
+/// Returns a safe profile-refresh error when the event is oversized,
+/// malformed, invalidly signed, authored by another key, or not kind 0.
+pub fn parse_verified_kind0(
+ event_json: &str,
+ expected_author: PublicKey,
+) -> Result<Kind0ProfileCandidate, SafeError> {
+ if event_json.len() > MAX_EVENT_JSON_BYTES {
+ return Err(invalid_event());
+ }
+
+ let event = Event::from_json(event_json).map_err(|_| invalid_event())?;
+ event.verify().map_err(|_| invalid_event())?;
+ if event.kind != Kind::Metadata
+ || event.pubkey.to_bytes() != *expected_author.as_bytes()
+ || event.content.len() > MAX_PROFILE_CONTENT_BYTES
+ {
+ return Err(invalid_event());
+ }
+
+ let metadata = Metadata::from_json(&event.content).map_err(|_| invalid_metadata())?;
+ let profile = ProfileMetadata::new(
+ metadata.name,
+ metadata.display_name,
+ metadata.nip05,
+ metadata.about,
+ metadata.picture,
+ )?;
+ let created_at = i64::try_from(event.created_at.as_secs())
+ .ok()
+ .and_then(UnixTimestamp::from_seconds)
+ .ok_or_else(invalid_event)?;
+
+ Ok(Kind0ProfileCandidate::new(
+ EventId::from_bytes(event.id.to_bytes()),
+ expected_author,
+ created_at,
+ profile,
+ ))
+}
+
+const fn invalid_event() -> SafeError {
+ SafeError::new(
+ SafeErrorCode::ProfileRefreshFailed,
+ SafeMessage::new("The Nostr profile event is invalid."),
+ )
+}
+
+const fn invalid_metadata() -> SafeError {
+ SafeError::new(
+ SafeErrorCode::InvalidProfileMetadata,
+ SafeMessage::new("The Nostr profile metadata is invalid."),
+ )
+}
+
+#[cfg(test)]
+mod tests {
+ use nostr::{EventBuilder, JsonUtil, Keys, Metadata, Url};
+ use radroots_studio_domain::{PublicKey, SafeErrorCode};
+
+ use super::parse_verified_kind0;
+
+ fn signed_profile() -> (Keys, String) {
+ let keys = Keys::generate();
+ let event = EventBuilder::metadata(
+ &Metadata::new()
+ .name(" farmer ")
+ .display_name(" Farm Account ")
+ .nip05("farmer@example.test")
+ .about("Local grower")
+ .picture(
+ Url::parse("https://images.example.test/farmer.png")
+ .expect("valid picture URL"),
+ ),
+ )
+ .sign_with_keys(&keys)
+ .expect("signed metadata event");
+ (keys, event.as_json())
+ }
+
+ #[test]
+ fn profile_event_verifies_signature_author_kind_and_metadata() {
+ let (keys, json) = signed_profile();
+ let expected_author = PublicKey::from_bytes(keys.public_key().to_bytes());
+
+ let candidate = parse_verified_kind0(&json, expected_author).expect("verified profile");
+
+ assert_eq!(candidate.author(), expected_author);
+ assert_eq!(candidate.metadata().name(), Some("farmer"));
+ assert_eq!(candidate.metadata().display_name(), Some("Farm Account"));
+ assert_eq!(candidate.metadata().nip05(), Some("farmer@example.test"));
+ assert_eq!(candidate.metadata().about(), Some("Local grower"));
+ assert_eq!(
+ candidate.metadata().picture(),
+ Some("https://images.example.test/farmer.png")
+ );
+ }
+
+ #[test]
+ fn profile_event_rejects_tampering_wrong_author_kind_and_oversize_content() {
+ let (keys, json) = signed_profile();
+ let expected_author = PublicKey::from_bytes(keys.public_key().to_bytes());
+ let wrong_author = PublicKey::from_bytes(Keys::generate().public_key().to_bytes());
+ let tampered = json.replace("Local grower", "Remote grower");
+ let note = EventBuilder::text_note("not metadata")
+ .sign_with_keys(&keys)
+ .expect("signed note")
+ .as_json();
+ let oversized = EventBuilder::metadata(&Metadata::new().about("x".repeat(16 * 1_024 + 1)))
+ .sign_with_keys(&keys)
+ .expect("signed oversized profile")
+ .as_json();
+
+ for rejected in [
+ parse_verified_kind0(&tampered, expected_author),
+ parse_verified_kind0(&json, wrong_author),
+ parse_verified_kind0(¬e, expected_author),
+ parse_verified_kind0(&oversized, expected_author),
+ ] {
+ assert_eq!(
+ rejected.expect_err("invalid event").code(),
+ SafeErrorCode::ProfileRefreshFailed
+ );
+ }
+ }
+
+ #[test]
+ fn profile_event_rejects_malformed_and_bounded_invalid_metadata() {
+ let keys = Keys::generate();
+ let malformed = EventBuilder::new(nostr::Kind::Metadata, "not json")
+ .sign_with_keys(&keys)
+ .expect("signed malformed metadata")
+ .as_json();
+ let invalid = EventBuilder::metadata(&Metadata::new().name("x".repeat(129)))
+ .sign_with_keys(&keys)
+ .expect("signed invalid metadata")
+ .as_json();
+ let author = PublicKey::from_bytes(keys.public_key().to_bytes());
+
+ assert_eq!(
+ parse_verified_kind0(&malformed, author)
+ .expect_err("malformed metadata")
+ .code(),
+ SafeErrorCode::InvalidProfileMetadata
+ );
+ assert_eq!(
+ parse_verified_kind0(&invalid, author)
+ .expect_err("bounded metadata")
+ .code(),
+ SafeErrorCode::InvalidProfileMetadata
+ );
+ }
+}
diff --git a/crates/studio_storage/Cargo.toml b/crates/studio_storage/Cargo.toml
@@ -0,0 +1,27 @@
+[package]
+name = "radroots-studio-storage"
+version.workspace = true
+edition.workspace = true
+rust-version.workspace = true
+license.workspace = true
+repository.workspace = true
+
+[dependencies]
+radroots-studio-application = { path = "../application" }
+radroots-studio-domain = { path = "../domain" }
+fs2.workspace = true
+keyring.workspace = true
+zeroize.workspace = true
+refinery.workspace = true
+rusqlite.workspace = true
+tokio.workspace = true
+
+[dev-dependencies]
+nostr.workspace = true
+nostr-relay-builder.workspace = true
+nostr-sdk.workspace = true
+tempfile = "=3.23.0"
+tokio = { workspace = true, features = ["macros", "rt-multi-thread", "time"] }
+
+[lints]
+workspace = true
diff --git a/crates/studio_storage/migrations/V1__initialize.sql b/crates/studio_storage/migrations/V1__initialize.sql
@@ -0,0 +1,6 @@
+CREATE TABLE application_schema (
+ singleton INTEGER PRIMARY KEY CHECK (singleton = 1),
+ schema_version INTEGER NOT NULL CHECK (schema_version >= 1)
+);
+
+INSERT INTO application_schema (singleton, schema_version) VALUES (1, 1);
diff --git a/crates/studio_storage/migrations/V2__accounts.sql b/crates/studio_storage/migrations/V2__accounts.sql
@@ -0,0 +1,28 @@
+CREATE TABLE accounts (
+ pubkey TEXT PRIMARY KEY NOT NULL CHECK (
+ length(pubkey) = 64 AND pubkey = lower(pubkey)
+ ),
+ npub TEXT NOT NULL CHECK (length(npub) = 63),
+ signer_kind TEXT NOT NULL CHECK (
+ signer_kind IN ('local_secret', 'watch_only', 'remote_nip46')
+ ),
+ key_availability TEXT NOT NULL CHECK (
+ key_availability IN (
+ 'available',
+ 'credential_missing',
+ 'store_unavailable',
+ 'not_required'
+ )
+ ),
+ label TEXT,
+ created_at INTEGER NOT NULL CHECK (created_at >= 0),
+ last_used_at INTEGER CHECK (last_used_at >= 0)
+);
+
+CREATE TABLE app_state (
+ singleton INTEGER PRIMARY KEY CHECK (singleton = 1),
+ selected_pubkey TEXT REFERENCES accounts(pubkey) ON DELETE SET NULL
+);
+
+INSERT INTO app_state (singleton, selected_pubkey) VALUES (1, NULL);
+UPDATE application_schema SET schema_version = 2 WHERE singleton = 1;
diff --git a/crates/studio_storage/migrations/V3__profile_cache.sql b/crates/studio_storage/migrations/V3__profile_cache.sql
@@ -0,0 +1,12 @@
+CREATE TABLE profile_cache (
+ subject_pubkey TEXT PRIMARY KEY NOT NULL REFERENCES accounts(pubkey) ON DELETE CASCADE,
+ event_id TEXT NOT NULL,
+ event_created_at INTEGER NOT NULL,
+ name TEXT,
+ display_name TEXT,
+ nip05 TEXT,
+ about TEXT,
+ picture TEXT,
+ refreshed_at INTEGER NOT NULL,
+ refresh_status TEXT NOT NULL CHECK (refresh_status IN ('success', 'offline', 'invalid_data'))
+) STRICT;
diff --git a/crates/studio_storage/migrations/V4__account_namespace.sql b/crates/studio_storage/migrations/V4__account_namespace.sql
@@ -0,0 +1,6 @@
+CREATE TABLE account_namespace (
+ owner_pubkey TEXT NOT NULL REFERENCES accounts(pubkey) ON DELETE CASCADE,
+ preference_key TEXT NOT NULL CHECK (preference_key IN ('namespace_probe')),
+ preference_value TEXT NOT NULL CHECK (length(preference_value) <= 4096),
+ PRIMARY KEY (owner_pubkey, preference_key)
+) STRICT;
diff --git a/crates/studio_storage/migrations/V5__operation_journal.sql b/crates/studio_storage/migrations/V5__operation_journal.sql
@@ -0,0 +1,8 @@
+CREATE TABLE operation_journal (
+ operation_id INTEGER PRIMARY KEY AUTOINCREMENT,
+ operation_kind TEXT NOT NULL CHECK (operation_kind IN ('add', 'import', 'remove')),
+ subject_pubkey TEXT NOT NULL,
+ phase TEXT NOT NULL CHECK (phase IN ('intent_recorded', 'credential_written', 'metadata_committed', 'compensation_pending', 'credential_deleted', 'metadata_deleted')),
+ updated_at INTEGER NOT NULL,
+ diagnostic_code TEXT CHECK (diagnostic_code IN ('storage_unavailable', 'keyring_unavailable', 'credential_missing', 'compensation_failed'))
+) STRICT;
diff --git a/crates/studio_storage/migrations/V6__normalized_runtime_schema.sql b/crates/studio_storage/migrations/V6__normalized_runtime_schema.sql
@@ -0,0 +1,100 @@
+CREATE TABLE account_identities (
+ public_key TEXT PRIMARY KEY NOT NULL CHECK (
+ length(public_key) = 64 AND public_key = lower(public_key)
+ ),
+ npub TEXT NOT NULL UNIQUE CHECK (length(npub) = 63),
+ label TEXT CHECK (label IS NULL OR length(label) BETWEEN 1 AND 80),
+ created_at INTEGER NOT NULL CHECK (created_at >= 0),
+ last_used_at INTEGER CHECK (last_used_at IS NULL OR last_used_at >= 0)
+) STRICT;
+
+CREATE TABLE local_signer_bindings (
+ account_public_key TEXT NOT NULL,
+ binding_public_key TEXT NOT NULL,
+ binding_kind TEXT NOT NULL CHECK (binding_kind = 'local_secret'),
+ availability TEXT NOT NULL CHECK (
+ availability IN ('available', 'credential_missing', 'store_unavailable')
+ ),
+ PRIMARY KEY (account_public_key, binding_public_key),
+ UNIQUE (account_public_key, binding_kind),
+ FOREIGN KEY (account_public_key) REFERENCES account_identities(public_key) ON DELETE CASCADE,
+ CHECK (account_public_key = binding_public_key)
+) STRICT;
+
+CREATE TABLE runtime_state (
+ singleton INTEGER PRIMARY KEY CHECK (singleton = 1),
+ selected_public_key TEXT REFERENCES account_identities(public_key) ON DELETE SET NULL,
+ active_account_public_key TEXT,
+ active_binding_public_key TEXT,
+ session_generation INTEGER NOT NULL DEFAULT 0 CHECK (session_generation >= 0),
+ FOREIGN KEY (active_account_public_key, active_binding_public_key)
+ REFERENCES local_signer_bindings(account_public_key, binding_public_key)
+ ON DELETE SET NULL,
+ CHECK (
+ (active_account_public_key IS NULL AND active_binding_public_key IS NULL)
+ OR
+ (active_account_public_key IS NOT NULL AND active_binding_public_key IS NOT NULL)
+ )
+) STRICT;
+
+INSERT INTO runtime_state (singleton) VALUES (1);
+
+CREATE TABLE profile_cache_v6 (
+ subject_public_key TEXT PRIMARY KEY NOT NULL
+ REFERENCES account_identities(public_key) ON DELETE CASCADE,
+ event_id TEXT NOT NULL CHECK (length(event_id) = 64 AND event_id = lower(event_id)),
+ event_created_at INTEGER NOT NULL CHECK (event_created_at >= 0),
+ name TEXT,
+ display_name TEXT,
+ nip05 TEXT,
+ about TEXT,
+ picture TEXT,
+ refreshed_at INTEGER NOT NULL CHECK (refreshed_at >= 0),
+ refresh_status TEXT NOT NULL CHECK (
+ refresh_status IN ('success', 'offline', 'invalid_data')
+ )
+) STRICT;
+
+CREATE TABLE durable_operations (
+ request_id TEXT PRIMARY KEY NOT NULL CHECK (length(request_id) BETWEEN 1 AND 128),
+ operation_kind TEXT NOT NULL CHECK (
+ operation_kind IN ('create', 'import', 'repair', 'remove')
+ ),
+ account_public_key TEXT NOT NULL CHECK (
+ length(account_public_key) = 64 AND account_public_key = lower(account_public_key)
+ ),
+ binding_public_key TEXT NOT NULL CHECK (binding_public_key = account_public_key),
+ expected_revision INTEGER CHECK (expected_revision IS NULL OR expected_revision >= 0),
+ phase TEXT NOT NULL CHECK (
+ phase IN (
+ 'intent_recorded',
+ 'credential_written',
+ 'metadata_committed',
+ 'selection_committed',
+ 'compensation_pending',
+ 'credential_deleted',
+ 'metadata_deleted',
+ 'finalized'
+ )
+ ),
+ terminal_outcome TEXT CHECK (
+ terminal_outcome IS NULL OR terminal_outcome IN ('completed', 'cancelled', 'failed')
+ ),
+ prior_selected_public_key TEXT,
+ updated_at INTEGER NOT NULL CHECK (updated_at >= 0),
+ diagnostic_code TEXT CHECK (
+ diagnostic_code IS NULL OR diagnostic_code IN (
+ 'storage_unavailable',
+ 'keyring_unavailable',
+ 'credential_missing',
+ 'compensation_failed',
+ 'conflict',
+ 'expired'
+ )
+ ),
+ CHECK (
+ (phase = 'finalized' AND terminal_outcome IS NOT NULL)
+ OR
+ (phase <> 'finalized' AND terminal_outcome IS NULL)
+ )
+) STRICT;
diff --git a/crates/studio_storage/migrations/V7__migrate_v5_runtime_data.sql b/crates/studio_storage/migrations/V7__migrate_v5_runtime_data.sql
@@ -0,0 +1,68 @@
+INSERT INTO account_identities (
+ public_key,
+ npub,
+ label,
+ created_at,
+ last_used_at
+)
+SELECT pubkey, npub, label, created_at, last_used_at
+FROM accounts;
+
+INSERT INTO local_signer_bindings (
+ account_public_key,
+ binding_public_key,
+ binding_kind,
+ availability
+)
+SELECT pubkey, pubkey, 'local_secret', key_availability
+FROM accounts;
+
+UPDATE runtime_state
+SET selected_public_key = (
+ SELECT selected_pubkey FROM app_state WHERE singleton = 1
+)
+WHERE singleton = 1;
+
+INSERT INTO profile_cache_v6 (
+ subject_public_key,
+ event_id,
+ event_created_at,
+ name,
+ display_name,
+ nip05,
+ about,
+ picture,
+ refreshed_at,
+ refresh_status
+)
+SELECT
+ subject_pubkey,
+ event_id,
+ event_created_at,
+ name,
+ display_name,
+ nip05,
+ about,
+ picture,
+ refreshed_at,
+ refresh_status
+FROM profile_cache;
+
+INSERT INTO durable_operations (
+ request_id,
+ operation_kind,
+ account_public_key,
+ binding_public_key,
+ phase,
+ updated_at,
+ diagnostic_code
+)
+SELECT
+ 'legacy-v5-' || operation_id,
+ CASE operation_kind WHEN 'add' THEN 'create' ELSE operation_kind END,
+ subject_pubkey,
+ subject_pubkey,
+ phase,
+ updated_at,
+ diagnostic_code
+FROM operation_journal;
diff --git a/crates/studio_storage/migrations/V8__normalized_account_preferences.sql b/crates/studio_storage/migrations/V8__normalized_account_preferences.sql
@@ -0,0 +1,10 @@
+CREATE TABLE account_preferences (
+ owner_public_key TEXT NOT NULL REFERENCES account_identities(public_key) ON DELETE CASCADE,
+ preference_key TEXT NOT NULL CHECK (preference_key = 'namespace_probe'),
+ preference_value TEXT NOT NULL CHECK (length(preference_value) <= 4096),
+ PRIMARY KEY (owner_public_key, preference_key)
+) STRICT;
+
+INSERT INTO account_preferences (owner_public_key, preference_key, preference_value)
+SELECT owner_pubkey, preference_key, preference_value
+FROM account_namespace;
diff --git a/crates/studio_storage/migrations/V9__durable_operation_receipts.sql b/crates/studio_storage/migrations/V9__durable_operation_receipts.sql
@@ -0,0 +1,11 @@
+ALTER TABLE durable_operations ADD COLUMN prior_binding_availability TEXT CHECK (
+ prior_binding_availability IS NULL OR prior_binding_availability IN (
+ 'available',
+ 'credential_missing',
+ 'store_unavailable'
+ )
+);
+
+ALTER TABLE durable_operations ADD COLUMN resulting_revision INTEGER CHECK (
+ resulting_revision IS NULL OR resulting_revision >= 0
+);
diff --git a/crates/studio_storage/src/account_namespace.rs b/crates/studio_storage/src/account_namespace.rs
@@ -0,0 +1,162 @@
+use radroots_studio_application::{AccountNamespaceRepository, AccountPreferenceKey};
+use radroots_studio_domain::{PublicKey, SafeError, SafeErrorCode, SafeMessage};
+use rusqlite::{OptionalExtension, params};
+
+use crate::Database;
+
+const MAX_VALUE_CHARS: usize = 4_096;
+
+impl AccountNamespaceRepository for Database {
+ fn get_value(
+ &self,
+ owner: PublicKey,
+ key: AccountPreferenceKey,
+ ) -> Result<Option<String>, SafeError> {
+ self.connection()
+ .query_row(
+ "SELECT preference_value FROM account_preferences \
+ WHERE owner_public_key = ?1 AND preference_key = ?2",
+ params![owner.to_hex(), encode_key(key)],
+ |row| row.get(0),
+ )
+ .optional()
+ .map_err(|_| storage_error())
+ }
+
+ fn set_value(
+ &self,
+ owner: PublicKey,
+ key: AccountPreferenceKey,
+ value: &str,
+ ) -> Result<(), SafeError> {
+ if value.chars().count() > MAX_VALUE_CHARS || value.chars().any(char::is_control) {
+ return Err(invalid_preference());
+ }
+ self.connection()
+ .execute(
+ "INSERT INTO account_preferences (owner_public_key, preference_key, preference_value) \
+ VALUES (?1, ?2, ?3) ON CONFLICT(owner_public_key, preference_key) DO UPDATE SET \
+ preference_value = excluded.preference_value",
+ params![owner.to_hex(), encode_key(key), value],
+ )
+ .map(|_| ())
+ .map_err(|_| storage_error())
+ }
+
+ fn clear_owner(&self, owner: PublicKey) -> Result<(), SafeError> {
+ self.connection()
+ .execute(
+ "DELETE FROM account_preferences WHERE owner_public_key = ?1",
+ [owner.to_hex()],
+ )
+ .map(|_| ())
+ .map_err(|_| storage_error())
+ }
+}
+
+const fn encode_key(key: AccountPreferenceKey) -> &'static str {
+ match key {
+ AccountPreferenceKey::NamespaceProbe => "namespace_probe",
+ }
+}
+
+const fn storage_error() -> SafeError {
+ SafeError::new(
+ SafeErrorCode::StorageUnavailable,
+ SafeMessage::new("The account preference is unavailable."),
+ )
+}
+
+const fn invalid_preference() -> SafeError {
+ SafeError::new(
+ SafeErrorCode::InvalidAccountMetadata,
+ SafeMessage::new("The account preference is invalid."),
+ )
+}
+
+#[cfg(test)]
+mod tests {
+ use radroots_studio_application::{
+ AccountNamespaceRepository, AccountPreferenceKey, AccountRepository, AppStateRepository,
+ };
+ use radroots_studio_domain::{
+ AccountCreatedAt, AccountIdentity, AccountSummary, BindingAvailability, LocalSignerBinding,
+ PublicKey, UnixTimestamp,
+ };
+
+ use crate::Database;
+
+ fn account(byte: u8) -> AccountSummary {
+ let public_key = PublicKey::from_bytes([byte; 32]);
+ AccountSummary::new(
+ AccountIdentity::derive(public_key).expect("identity"),
+ LocalSignerBinding::new(public_key, BindingAvailability::Available),
+ None,
+ AccountCreatedAt::new(UnixTimestamp::from_seconds(i64::from(byte)).expect("time")),
+ None,
+ )
+ .expect("account")
+ }
+
+ #[test]
+ fn namespace_partitions_same_typed_key_by_owner_and_selection() {
+ let database = Database::in_memory().expect("database");
+ let owner_a = PublicKey::from_bytes([1; 32]);
+ let owner_b = PublicKey::from_bytes([2; 32]);
+ database.insert_account(&account(1)).expect("account a");
+ database.insert_account(&account(2)).expect("account b");
+ database
+ .set_value(owner_a, AccountPreferenceKey::NamespaceProbe, "A")
+ .expect("set a");
+ database
+ .set_value(owner_b, AccountPreferenceKey::NamespaceProbe, "B")
+ .expect("set b");
+
+ database
+ .save_selected_account(Some(owner_b))
+ .expect("select b");
+ let selected = database
+ .load_selected_account()
+ .expect("selection")
+ .expect("selected owner");
+ assert_eq!(
+ database
+ .get_value(selected, AccountPreferenceKey::NamespaceProbe)
+ .expect("selected value"),
+ Some("B".to_owned())
+ );
+ assert_eq!(
+ database
+ .get_value(owner_a, AccountPreferenceKey::NamespaceProbe)
+ .expect("owner a value"),
+ Some("A".to_owned())
+ );
+ }
+
+ #[test]
+ fn namespace_updates_and_cascades_with_owner_removal() {
+ let database = Database::in_memory().expect("database");
+ let owner = PublicKey::from_bytes([3; 32]);
+ database.insert_account(&account(3)).expect("account");
+ database
+ .set_value(owner, AccountPreferenceKey::NamespaceProbe, "before")
+ .expect("set");
+ database
+ .set_value(owner, AccountPreferenceKey::NamespaceProbe, "after")
+ .expect("update");
+ assert_eq!(
+ database
+ .get_value(owner, AccountPreferenceKey::NamespaceProbe)
+ .expect("value"),
+ Some("after".to_owned())
+ );
+
+ database.remove_account(owner).expect("remove");
+ assert_eq!(
+ database
+ .get_value(owner, AccountPreferenceKey::NamespaceProbe)
+ .expect("deleted value"),
+ None
+ );
+ }
+}
diff --git a/crates/studio_storage/src/accounts.rs b/crates/studio_storage/src/accounts.rs
@@ -0,0 +1,394 @@
+use radroots_studio_application::{AccountRepository, AppStateRepository};
+use radroots_studio_domain::{
+ AccountCreatedAt, AccountIdentity, AccountLabel, AccountSummary, BindingAvailability,
+ LocalSignerBinding, PublicKey, SafeError, SafeErrorCode, SafeMessage, UnixTimestamp,
+};
+use rusqlite::{OptionalExtension, Row, params};
+
+use crate::Database;
+
+impl AccountRepository for Database {
+ fn list_accounts(&self) -> Result<Vec<AccountSummary>, SafeError> {
+ let connection = self.connection();
+ let mut statement = connection
+ .prepare(
+ "SELECT identity.public_key, identity.npub, binding.binding_kind, \
+ binding.availability, identity.label, identity.created_at, identity.last_used_at \
+ FROM account_identities AS identity \
+ JOIN local_signer_bindings AS binding \
+ ON binding.account_public_key = identity.public_key \
+ ORDER BY identity.created_at ASC, identity.public_key ASC",
+ )
+ .map_err(|_| storage_error())?;
+ let rows = statement
+ .query_map([], decode_account)
+ .map_err(|_| storage_error())?;
+ rows.map(|row| row.map_err(|_| corrupt_storage_error()))
+ .collect()
+ }
+
+ fn find_account(&self, public_key: PublicKey) -> Result<Option<AccountSummary>, SafeError> {
+ self.connection()
+ .query_row(
+ "SELECT identity.public_key, identity.npub, binding.binding_kind, \
+ binding.availability, identity.label, identity.created_at, identity.last_used_at \
+ FROM account_identities AS identity \
+ JOIN local_signer_bindings AS binding \
+ ON binding.account_public_key = identity.public_key \
+ WHERE identity.public_key = ?1",
+ [public_key.to_hex()],
+ decode_account,
+ )
+ .optional()
+ .map_err(|_| storage_error())
+ }
+
+ fn insert_account(&self, account: &AccountSummary) -> Result<(), SafeError> {
+ let encoded = EncodedAccount::from(account);
+ let mut connection = self.connection();
+ let transaction = connection.transaction().map_err(|_| storage_error())?;
+ let result = transaction.execute(
+ "INSERT INTO account_identities (public_key, npub, label, created_at, last_used_at) \
+ VALUES (?1, ?2, ?3, ?4, ?5)",
+ params![
+ encoded.public_key,
+ encoded.npub,
+ encoded.label,
+ encoded.created_at,
+ encoded.last_used_at
+ ],
+ );
+ match result {
+ Ok(1) => {}
+ Err(error) if is_constraint_violation(&error) => return Err(account_exists()),
+ Ok(_) | Err(_) => return Err(storage_error()),
+ }
+ if transaction
+ .execute(
+ "INSERT INTO local_signer_bindings (account_public_key, binding_public_key, \
+ binding_kind, availability) VALUES (?1, ?1, ?2, ?3)",
+ params![
+ encoded.public_key,
+ encoded.signer_kind,
+ encoded.key_availability
+ ],
+ )
+ .map_err(|_| storage_error())?
+ != 1
+ {
+ return Err(storage_error());
+ }
+ transaction.commit().map_err(|_| storage_error())
+ }
+
+ fn update_account(&self, account: &AccountSummary) -> Result<(), SafeError> {
+ let encoded = EncodedAccount::from(account);
+ let mut connection = self.connection();
+ let transaction = connection.transaction().map_err(|_| storage_error())?;
+ let identity_rows = transaction
+ .execute(
+ "UPDATE account_identities SET npub = ?2, label = ?5, created_at = ?6, \
+ last_used_at = ?7 WHERE public_key = ?1",
+ params![
+ encoded.public_key,
+ encoded.npub,
+ encoded.signer_kind,
+ encoded.key_availability,
+ encoded.label,
+ encoded.created_at,
+ encoded.last_used_at,
+ ],
+ )
+ .map_err(|_| storage_error())?;
+ if identity_rows == 0 {
+ return Err(account_not_found());
+ }
+ if identity_rows != 1 {
+ return Err(storage_error());
+ }
+ let binding_rows = transaction
+ .execute(
+ "UPDATE local_signer_bindings SET binding_kind = ?2, availability = ?3 \
+ WHERE account_public_key = ?1 AND binding_public_key = ?1",
+ params![
+ encoded.public_key,
+ encoded.signer_kind,
+ encoded.key_availability
+ ],
+ )
+ .map_err(|_| storage_error())?;
+ if binding_rows != 1 {
+ return Err(corrupt_storage_error());
+ }
+ transaction.commit().map_err(|_| storage_error())
+ }
+
+ fn remove_account(&self, public_key: PublicKey) -> Result<(), SafeError> {
+ match self.connection().execute(
+ "DELETE FROM account_identities WHERE public_key = ?1",
+ [public_key.to_hex()],
+ ) {
+ Ok(1) => Ok(()),
+ Ok(0) => Err(account_not_found()),
+ Ok(_) | Err(_) => Err(storage_error()),
+ }
+ }
+}
+
+impl AppStateRepository for Database {
+ fn load_selected_account(&self) -> Result<Option<PublicKey>, SafeError> {
+ let value = self
+ .connection()
+ .query_row(
+ "SELECT selected_public_key FROM runtime_state WHERE singleton = 1",
+ [],
+ |row| row.get::<_, Option<String>>(0),
+ )
+ .map_err(|_| corrupt_storage_error())?;
+ value
+ .map(|hex| PublicKey::from_hex(&hex).map_err(|_| corrupt_storage_error()))
+ .transpose()
+ }
+
+ fn save_selected_account(&self, public_key: Option<PublicKey>) -> Result<(), SafeError> {
+ let mut connection = self.connection();
+ let transaction = connection.transaction().map_err(|_| storage_error())?;
+ if let Some(public_key) = public_key {
+ let exists = transaction
+ .query_row(
+ "SELECT EXISTS(SELECT 1 FROM account_identities WHERE public_key = ?1)",
+ [public_key.to_hex()],
+ |row| row.get::<_, bool>(0),
+ )
+ .map_err(|_| storage_error())?;
+ if !exists {
+ return Err(account_not_found());
+ }
+ }
+ let rows = transaction
+ .execute(
+ "UPDATE runtime_state SET selected_public_key = ?1 WHERE singleton = 1",
+ [public_key.map(PublicKey::to_hex)],
+ )
+ .map_err(|_| storage_error())?;
+ if rows != 1 {
+ return Err(corrupt_storage_error());
+ }
+ transaction.commit().map_err(|_| storage_error())
+ }
+}
+
+struct EncodedAccount {
+ public_key: String,
+ npub: String,
+ signer_kind: &'static str,
+ key_availability: &'static str,
+ label: Option<String>,
+ created_at: i64,
+ last_used_at: Option<i64>,
+}
+
+impl From<&AccountSummary> for EncodedAccount {
+ fn from(account: &AccountSummary) -> Self {
+ Self {
+ public_key: account.public_key().to_hex(),
+ npub: account.npub().as_str().to_owned(),
+ signer_kind: "local_secret",
+ key_availability: encode_key_availability(account.signer().availability()),
+ label: account.label().map(|label| label.as_str().to_owned()),
+ created_at: account.created_at().timestamp().as_seconds(),
+ last_used_at: account.last_used_at().map(UnixTimestamp::as_seconds),
+ }
+ }
+}
+
+fn decode_account(row: &Row<'_>) -> rusqlite::Result<AccountSummary> {
+ let public_key =
+ PublicKey::from_hex(row.get::<_, String>(0)?.as_str()).map_err(|_| invalid_column(0))?;
+ let npub: String = row.get(1)?;
+ if row.get::<_, String>(2)?.as_str() != "local_secret" {
+ return Err(invalid_column(2));
+ }
+ let key_availability = decode_key_availability(row.get::<_, String>(3)?.as_str())?;
+ let label = row
+ .get::<_, Option<String>>(4)?
+ .map(|value| AccountLabel::parse(&value).map_err(|_| invalid_column(4)))
+ .transpose()?;
+ let created_at = UnixTimestamp::from_seconds(row.get(5)?).ok_or_else(|| invalid_column(5))?;
+ let last_used_at = row
+ .get::<_, Option<i64>>(6)?
+ .map(|value| UnixTimestamp::from_seconds(value).ok_or_else(|| invalid_column(6)))
+ .transpose()?;
+
+ AccountSummary::new(
+ AccountIdentity::verify(public_key, npub).map_err(|_| invalid_column(1))?,
+ LocalSignerBinding::new(public_key, key_availability),
+ label,
+ AccountCreatedAt::new(created_at),
+ last_used_at,
+ )
+ .map_err(|_| invalid_column(0))
+}
+
+const fn encode_key_availability(value: BindingAvailability) -> &'static str {
+ match value {
+ BindingAvailability::Available => "available",
+ BindingAvailability::CredentialMissing => "credential_missing",
+ BindingAvailability::StoreUnavailable => "store_unavailable",
+ }
+}
+
+fn decode_key_availability(value: &str) -> rusqlite::Result<BindingAvailability> {
+ match value {
+ "available" => Ok(BindingAvailability::Available),
+ "credential_missing" => Ok(BindingAvailability::CredentialMissing),
+ "store_unavailable" => Ok(BindingAvailability::StoreUnavailable),
+ _ => Err(invalid_column(3)),
+ }
+}
+
+fn invalid_column(index: usize) -> rusqlite::Error {
+ rusqlite::Error::InvalidColumnType(
+ index,
+ "public account metadata".to_owned(),
+ rusqlite::types::Type::Text,
+ )
+}
+
+fn is_constraint_violation(error: &rusqlite::Error) -> bool {
+ matches!(
+ error,
+ rusqlite::Error::SqliteFailure(
+ rusqlite::ffi::Error {
+ code: rusqlite::ErrorCode::ConstraintViolation,
+ ..
+ },
+ _
+ )
+ )
+}
+
+const fn storage_error() -> SafeError {
+ SafeError::new(
+ SafeErrorCode::StorageUnavailable,
+ SafeMessage::new("The application database is unavailable."),
+ )
+}
+
+const fn corrupt_storage_error() -> SafeError {
+ SafeError::new(
+ SafeErrorCode::StorageCorrupt,
+ SafeMessage::new("The application database could not be read."),
+ )
+}
+
+const fn account_exists() -> SafeError {
+ SafeError::new(
+ SafeErrorCode::AccountAlreadyExists,
+ SafeMessage::new("The Nostr account is already saved."),
+ )
+}
+
+const fn account_not_found() -> SafeError {
+ SafeError::new(
+ SafeErrorCode::AccountNotFound,
+ SafeMessage::new("The account was not found."),
+ )
+}
+
+#[cfg(test)]
+mod tests {
+ use std::fs;
+
+ use radroots_studio_application::{AccountRepository, AppStateRepository};
+ use radroots_studio_domain::{
+ AccountCreatedAt, AccountIdentity, AccountLabel, AccountSummary, BindingAvailability,
+ LocalSignerBinding, PublicKey, SafeErrorCode, UnixTimestamp,
+ };
+ use tempfile::tempdir;
+
+ use crate::Database;
+
+ fn account(key_byte: u8, created_at: i64) -> AccountSummary {
+ let public_key = PublicKey::from_bytes([key_byte; 32]);
+ AccountSummary::new(
+ AccountIdentity::derive(public_key).expect("identity"),
+ LocalSignerBinding::new(public_key, BindingAvailability::Available),
+ Some(AccountLabel::parse("Farm account").expect("valid label")),
+ AccountCreatedAt::new(
+ UnixTimestamp::from_seconds(created_at).expect("valid timestamp"),
+ ),
+ None,
+ )
+ .expect("account")
+ }
+
+ #[test]
+ fn accounts_insert_list_update_and_reject_duplicates() {
+ let database = Database::in_memory().expect("database");
+ let first = account(1, 20);
+ let second = account(2, 10);
+
+ database.insert_account(&first).expect("insert first");
+ database.insert_account(&second).expect("insert second");
+ let duplicate = database.insert_account(&first).expect_err("duplicate");
+
+ assert_eq!(duplicate.code(), SafeErrorCode::AccountAlreadyExists);
+ assert_eq!(
+ database.list_accounts().expect("list"),
+ vec![second, first.clone()]
+ );
+ assert_eq!(
+ database.find_account(first.public_key()).expect("find"),
+ Some(first)
+ );
+ }
+
+ #[test]
+ fn accounts_and_selection_survive_restart_without_secret_text() {
+ let directory = tempdir().expect("temporary directory");
+ let path = directory.path().join("studio.sqlite3");
+ let account = account(3, 30);
+
+ {
+ let database = Database::open(&path).expect("database");
+ database.insert_account(&account).expect("insert");
+ database
+ .save_selected_account(Some(account.public_key()))
+ .expect("select");
+ }
+ let reopened = Database::open(&path).expect("reopen");
+
+ assert_eq!(
+ reopened.list_accounts().expect("list"),
+ vec![account.clone()]
+ );
+ assert_eq!(
+ reopened.load_selected_account().expect("selection"),
+ Some(account.public_key())
+ );
+ let bytes = fs::read(path).expect("database bytes");
+ assert!(!String::from_utf8_lossy(&bytes).contains("nsec1known-test-secret"));
+ }
+
+ #[test]
+ fn selection_requires_an_existing_account_and_clears_on_delete() {
+ let database = Database::in_memory().expect("database");
+ let account = account(4, 40);
+
+ let missing = database
+ .save_selected_account(Some(account.public_key()))
+ .expect_err("missing account");
+ assert_eq!(missing.code(), SafeErrorCode::AccountNotFound);
+
+ database.insert_account(&account).expect("insert");
+ database
+ .save_selected_account(Some(account.public_key()))
+ .expect("select");
+ database
+ .remove_account(account.public_key())
+ .expect("remove");
+
+ assert_eq!(database.load_selected_account().expect("selection"), None);
+ }
+}
diff --git a/crates/studio_storage/src/application_adapter.rs b/crates/studio_storage/src/application_adapter.rs
@@ -0,0 +1,718 @@
+use std::path::Path;
+
+use radroots_studio_application::{
+ AppCore, AppSnapshot, Clock, DurableRequestId, GenerateAccountReceipt, ImportAccountReceipt,
+ RelayConfiguration, RemovalConfirmationToken, SecretStore, StagedGeneratedKey,
+};
+use radroots_studio_domain::{PublicKey, SafeError, SecretKeyInput};
+
+use crate::Database;
+
+pub struct PersistentAppCore {
+ core: AppCore,
+ database: Database,
+}
+
+impl PersistentAppCore {
+ /// Commits an acknowledged generated-key stage through the durable coordinator.
+ ///
+ /// # Errors
+ ///
+ /// Returns a safe conflict, credential, storage, or recovery error.
+ pub fn commit_staged_generated_key(
+ &self,
+ request_id: &DurableRequestId,
+ staged: StagedGeneratedKey,
+ secrets: &(impl SecretStore + ?Sized),
+ clock: &(impl Clock + ?Sized),
+ ) -> Result<ImportAccountReceipt, SafeError> {
+ self.core.commit_staged_generated_key(
+ request_id,
+ staged,
+ &self.database,
+ &self.database,
+ secrets,
+ &self.database,
+ clock,
+ )
+ }
+
+ /// Opens the application database without accessing credentials or relays.
+ ///
+ /// # Errors
+ ///
+ /// Returns a safe storage error when the database cannot be opened or migrated.
+ pub fn open(path: &Path, relay_configuration: RelayConfiguration) -> Result<Self, SafeError> {
+ Ok(Self {
+ core: AppCore::in_memory(relay_configuration),
+ database: Database::open(path)?,
+ })
+ }
+
+ /// Creates an isolated persistent-core adapter for tests.
+ ///
+ /// # Errors
+ ///
+ /// Returns a safe storage error when the database cannot be initialized.
+ pub fn in_memory(relay_configuration: RelayConfiguration) -> Result<Self, SafeError> {
+ Ok(Self {
+ core: AppCore::in_memory(relay_configuration),
+ database: Database::in_memory()?,
+ })
+ }
+
+ /// Restores public accounts and selection while keeping the session signed out.
+ ///
+ /// # Errors
+ ///
+ /// Returns a safe storage or application-state error after publishing a fatal
+ /// snapshot when durable state cannot be restored.
+ pub fn bootstrap(
+ &self,
+ secrets: &(impl SecretStore + ?Sized),
+ clock: &(impl Clock + ?Sized),
+ ) -> Result<AppSnapshot, SafeError> {
+ self.core.recover_durable_operations(
+ &self.database,
+ &self.database,
+ secrets,
+ &self.database,
+ clock,
+ )?;
+ self.core.recover_pending_operations(
+ &self.database,
+ &self.database,
+ secrets,
+ &self.database,
+ clock,
+ )?;
+ self.core.bootstrap_from(&self.database, &self.database)
+ }
+
+ /// Generates and durably persists one selected, signed-out local account.
+ ///
+ /// # Errors
+ ///
+ /// Returns a safe credential, storage, key, or application-state error.
+ pub fn generate_account(
+ &self,
+ secrets: &(impl SecretStore + ?Sized),
+ clock: &(impl Clock + ?Sized),
+ ) -> Result<GenerateAccountReceipt, SafeError> {
+ self.core.generate_account(
+ &self.database,
+ &self.database,
+ secrets,
+ &self.database,
+ clock,
+ )
+ }
+
+ /// Imports and durably persists one selected, signed-out local account.
+ ///
+ /// # Errors
+ ///
+ /// Returns a safe credential, storage, key, or application-state error.
+ pub fn import_secret_key(
+ &self,
+ input: SecretKeyInput,
+ secrets: &(impl SecretStore + ?Sized),
+ clock: &(impl Clock + ?Sized),
+ ) -> Result<ImportAccountReceipt, SafeError> {
+ self.core.import_secret_key(
+ input,
+ &self.database,
+ &self.database,
+ secrets,
+ &self.database,
+ clock,
+ )
+ }
+
+ /// Generates an account through the durable request coordinator.
+ ///
+ /// # Errors
+ ///
+ /// Returns a safe conflict, credential, storage, or application-state error.
+ pub fn generate_account_durable(
+ &self,
+ request_id: &DurableRequestId,
+ expected_revision: u64,
+ secrets: &(impl SecretStore + ?Sized),
+ clock: &(impl Clock + ?Sized),
+ ) -> Result<GenerateAccountReceipt, SafeError> {
+ self.core.generate_account_durable(
+ request_id,
+ expected_revision,
+ &self.database,
+ &self.database,
+ secrets,
+ &self.database,
+ clock,
+ )
+ }
+
+ /// Imports or repairs an account through the durable request coordinator.
+ ///
+ /// # Errors
+ ///
+ /// Returns a safe conflict, validation, credential, storage, or state error.
+ pub fn import_secret_key_durable(
+ &self,
+ request_id: &DurableRequestId,
+ expected_revision: u64,
+ input: SecretKeyInput,
+ secrets: &(impl SecretStore + ?Sized),
+ clock: &(impl Clock + ?Sized),
+ ) -> Result<ImportAccountReceipt, SafeError> {
+ self.core.import_secret_key_durable(
+ request_id,
+ expected_revision,
+ input,
+ &self.database,
+ &self.database,
+ secrets,
+ &self.database,
+ clock,
+ )
+ }
+
+ /// Persists and publishes one saved-account selection without activation.
+ ///
+ /// # Errors
+ ///
+ /// Returns a safe account, storage, or application-state error.
+ pub fn select_account(&self, public_key: PublicKey) -> Result<AppSnapshot, SafeError> {
+ self.core
+ .select_account(public_key, &self.database, &self.database)
+ }
+
+ /// Activates a saved account after validating its credential and cached profile.
+ ///
+ /// # Errors
+ ///
+ /// Returns a safe account, credential, storage, or application-state error.
+ pub fn activate_account(
+ &self,
+ public_key: PublicKey,
+ secrets: &(impl SecretStore + ?Sized),
+ clock: &(impl Clock + ?Sized),
+ ) -> Result<AppSnapshot, SafeError> {
+ self.core.activate_account(
+ public_key,
+ &self.database,
+ &self.database,
+ &self.database,
+ secrets,
+ clock,
+ )
+ }
+
+ /// Signs out while retaining durable account data and credentials.
+ ///
+ /// # Errors
+ ///
+ /// Returns a safe application-state error if sign out cannot complete.
+ pub fn sign_out(&self) -> Result<AppSnapshot, SafeError> {
+ self.core.sign_out()
+ }
+
+ /// Issues a revision-bound, single-use account-removal confirmation.
+ ///
+ /// # Errors
+ ///
+ /// Returns a safe error when the target account is not saved.
+ pub fn request_account_removal(
+ &self,
+ public_key: PublicKey,
+ clock: &(impl Clock + ?Sized),
+ ) -> Result<RemovalConfirmationToken, SafeError> {
+ self.core.request_account_removal(public_key, clock)
+ }
+
+ /// Permanently removes one confirmed account and its credential.
+ ///
+ /// # Errors
+ ///
+ /// Returns a safe confirmation, credential, storage, recovery, or state error.
+ pub fn confirm_account_removal(
+ &self,
+ token: RemovalConfirmationToken,
+ secrets: &(impl SecretStore + ?Sized),
+ clock: &(impl Clock + ?Sized),
+ ) -> Result<AppSnapshot, SafeError> {
+ self.core.confirm_account_removal(
+ token,
+ &self.database,
+ &self.database,
+ secrets,
+ &self.database,
+ clock,
+ )
+ }
+
+ /// Executes a confirmed removal through the durable request coordinator.
+ ///
+ /// # Errors
+ ///
+ /// Returns a safe expiry, conflict, credential, storage, recovery, or state error.
+ pub fn confirm_account_removal_durable(
+ &self,
+ request_id: &DurableRequestId,
+ token: RemovalConfirmationToken,
+ secrets: &(impl SecretStore + ?Sized),
+ clock: &(impl Clock + ?Sized),
+ ) -> Result<AppSnapshot, SafeError> {
+ self.core.confirm_account_removal_durable(
+ request_id,
+ token,
+ &self.database,
+ &self.database,
+ secrets,
+ &self.database,
+ clock,
+ )
+ }
+
+ #[must_use]
+ pub const fn core(&self) -> &AppCore {
+ &self.core
+ }
+
+ #[must_use]
+ pub const fn database(&self) -> &Database {
+ &self.database
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use std::fs;
+
+ use radroots_studio_application::{
+ AccountOperationKind, AccountOperationPhase, AccountRepository, AppLifecycle,
+ AppStateRepository, Clock, DurableOperationKind, DurableOperationPhase,
+ DurableOperationRepository, DurableRequestId, DurableTerminalOutcome, FailureSecretStore,
+ InMemorySecretStore, OperationJournal, OperationPriorState, RelayConfiguration,
+ SecretStore, SecretStoreOperation, SessionState,
+ };
+ use radroots_studio_domain::{
+ AccountCreatedAt, AccountIdentity, AccountSummary, BindingAvailability, LocalSignerBinding,
+ PublicKey, SafeErrorCode, SecretKeyInput, UnixTimestamp,
+ };
+ use tempfile::tempdir;
+
+ use super::PersistentAppCore;
+
+ fn account() -> AccountSummary {
+ let public_key = PublicKey::from_bytes([4; 32]);
+ AccountSummary::new(
+ AccountIdentity::derive(public_key).expect("identity"),
+ LocalSignerBinding::new(public_key, BindingAvailability::Available),
+ None,
+ AccountCreatedAt::new(UnixTimestamp::from_seconds(1).expect("time")),
+ None,
+ )
+ .expect("account")
+ }
+
+ struct FixedClock;
+
+ impl Clock for FixedClock {
+ fn now(&self) -> UnixTimestamp {
+ UnixTimestamp::from_seconds(25).expect("time")
+ }
+ }
+
+ #[test]
+ fn persistent_bootstrap_handles_fresh_and_existing_signed_out_state() {
+ let directory = tempdir().expect("directory");
+ let path = directory.path().join("studio.sqlite3");
+ let public_key = account().public_key();
+ let secrets = InMemorySecretStore::default();
+ {
+ let adapter = PersistentAppCore::open(&path, RelayConfiguration::default())
+ .expect("open adapter");
+ let fresh = adapter
+ .bootstrap(&secrets, &FixedClock)
+ .expect("fresh bootstrap");
+ assert!(fresh.accounts().is_empty());
+ adapter
+ .database()
+ .insert_account(&account())
+ .expect("account");
+ adapter
+ .database()
+ .save_selected_account(Some(public_key))
+ .expect("selection");
+ }
+
+ let adapter =
+ PersistentAppCore::open(&path, RelayConfiguration::default()).expect("reopen adapter");
+ let restored = adapter.bootstrap(&secrets, &FixedClock).expect("restore");
+ assert_eq!(restored.lifecycle(), AppLifecycle::Ready);
+ assert_eq!(restored.accounts().len(), 1);
+ assert_eq!(restored.selected_account(), Some(public_key));
+ assert_eq!(restored.session(), SessionState::SignedOut);
+ assert!(restored.active_account().is_none());
+ }
+
+ #[test]
+ fn corrupt_database_fails_safely_without_recreation() {
+ let directory = tempdir().expect("directory");
+ let path = directory.path().join("studio.sqlite3");
+ fs::write(&path, b"not a sqlite database").expect("corrupt file");
+
+ let error = PersistentAppCore::open(&path, RelayConfiguration::default())
+ .err()
+ .expect("safe failure");
+ assert_eq!(error.code(), SafeErrorCode::StorageCorrupt);
+ assert_eq!(
+ fs::read(&path).expect("unchanged file"),
+ b"not a sqlite database"
+ );
+ }
+
+ #[test]
+ fn persisted_generate_and_import_survive_restart_without_secret_bytes() {
+ let directory = tempdir().expect("directory");
+ let path = directory.path().join("studio.sqlite3");
+ let secrets = InMemorySecretStore::default();
+ let selected;
+ {
+ let adapter =
+ PersistentAppCore::open(&path, RelayConfiguration::default()).expect("adapter");
+ adapter.bootstrap(&secrets, &FixedClock).expect("bootstrap");
+ let generated = adapter
+ .generate_account(&secrets, &FixedClock)
+ .expect("generate");
+ assert!(
+ secrets
+ .contains(generated.account().public_key())
+ .expect("generated credential")
+ );
+ let imported = adapter
+ .import_secret_key(
+ SecretKeyInput::parse(
+ "7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7"
+ .to_owned(),
+ )
+ .expect("secret"),
+ &secrets,
+ &FixedClock,
+ )
+ .expect("import");
+ selected = imported.account().public_key();
+ assert_eq!(adapter.core().snapshot().accounts().len(), 2);
+ }
+
+ let bytes = fs::read(&path).expect("database bytes");
+ assert!(!bytes.windows(5).any(|value| value == b"nsec1"));
+ assert!(!bytes.windows(64).any(|value| {
+ value == b"7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7"
+ }));
+ let reopened =
+ PersistentAppCore::open(&path, RelayConfiguration::default()).expect("reopen");
+ let restored = reopened.bootstrap(&secrets, &FixedClock).expect("restore");
+ assert_eq!(restored.accounts().len(), 2);
+ assert_eq!(restored.selected_account(), Some(selected));
+ assert_eq!(restored.session(), SessionState::SignedOut);
+ }
+
+ #[test]
+ fn durable_import_commits_each_phase_and_recovers_the_terminal_receipt() {
+ let adapter = PersistentAppCore::in_memory(RelayConfiguration::default()).expect("adapter");
+ let secrets = InMemorySecretStore::default();
+ let snapshot = adapter.bootstrap(&secrets, &FixedClock).expect("bootstrap");
+ let request = DurableRequestId::parse("import:adapter:1").expect("request");
+ let imported = adapter
+ .import_secret_key_durable(
+ &request,
+ snapshot.revision().value(),
+ SecretKeyInput::parse(
+ "7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7".to_owned(),
+ )
+ .expect("secret"),
+ &secrets,
+ &FixedClock,
+ )
+ .expect("durable import");
+ let operation = adapter
+ .database()
+ .load_durable_operation(&request)
+ .expect("operation")
+ .expect("durable record");
+ let receipt = operation.terminal().expect("terminal receipt");
+ assert_eq!(receipt.account(), imported.account().public_key());
+ assert_eq!(
+ receipt.resulting_revision(),
+ Some(adapter.core().snapshot().revision().value())
+ );
+ }
+
+ #[test]
+ fn durable_recovery_preserves_repair_metadata_and_deletes_orphan_credentials() {
+ let adapter = PersistentAppCore::in_memory(RelayConfiguration::default()).expect("adapter");
+ let secrets = InMemorySecretStore::default();
+ let missing = account().with_binding_availability(BindingAvailability::CredentialMissing);
+ adapter
+ .database()
+ .insert_account(&missing)
+ .expect("account");
+ adapter
+ .database()
+ .save_selected_account(Some(missing.public_key()))
+ .expect("selection");
+ let request = DurableRequestId::parse("repair:recovery:1").expect("request");
+ adapter
+ .database()
+ .begin_durable_operation(
+ &request,
+ DurableOperationKind::Repair,
+ missing.public_key(),
+ Some(0),
+ OperationPriorState::new(
+ Some(missing.public_key()),
+ Some(BindingAvailability::CredentialMissing),
+ ),
+ FixedClock.now(),
+ )
+ .expect("intent");
+ secrets
+ .put(
+ missing.public_key(),
+ SecretKeyInput::parse(
+ "7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7".to_owned(),
+ )
+ .expect("secret"),
+ )
+ .expect("credential");
+ adapter
+ .database()
+ .advance_durable_operation(
+ &request,
+ DurableOperationPhase::IntentRecorded,
+ DurableOperationPhase::CredentialWritten,
+ FixedClock.now(),
+ None,
+ )
+ .expect("credential phase");
+
+ adapter.bootstrap(&secrets, &FixedClock).expect("recovery");
+ let repaired = adapter
+ .database()
+ .find_account(missing.public_key())
+ .expect("lookup")
+ .expect("preserved account");
+ assert_eq!(
+ repaired.signer().availability(),
+ BindingAvailability::CredentialMissing
+ );
+ assert!(!secrets.contains(missing.public_key()).expect("credential"));
+ assert_eq!(
+ adapter
+ .database()
+ .load_durable_operation(&request)
+ .expect("operation")
+ .expect("record")
+ .terminal()
+ .expect("receipt")
+ .outcome(),
+ DurableTerminalOutcome::Failed
+ );
+ }
+
+ #[test]
+ fn durable_recovery_covers_response_loss_and_irreversible_removal_windows() {
+ let secrets = InMemorySecretStore::default();
+ let adapter = PersistentAppCore::in_memory(RelayConfiguration::default()).expect("adapter");
+ let saved = account();
+ adapter.database().insert_account(&saved).expect("account");
+ let import = DurableRequestId::parse("import:response-loss:1").expect("request");
+ adapter
+ .database()
+ .begin_durable_operation(
+ &import,
+ DurableOperationKind::Import,
+ saved.public_key(),
+ Some(0),
+ OperationPriorState::new(None, None),
+ FixedClock.now(),
+ )
+ .expect("intent");
+ adapter
+ .database()
+ .advance_durable_operation(
+ &import,
+ DurableOperationPhase::IntentRecorded,
+ DurableOperationPhase::CredentialWritten,
+ FixedClock.now(),
+ None,
+ )
+ .expect("credential");
+ adapter
+ .database()
+ .advance_durable_operation(
+ &import,
+ DurableOperationPhase::CredentialWritten,
+ DurableOperationPhase::MetadataCommitted,
+ FixedClock.now(),
+ None,
+ )
+ .expect("metadata");
+ let restored = adapter
+ .bootstrap(&secrets, &FixedClock)
+ .expect("response recovery");
+ assert_eq!(restored.selected_account(), Some(saved.public_key()));
+ assert_eq!(
+ adapter
+ .database()
+ .load_durable_operation(&import)
+ .expect("operation")
+ .expect("record")
+ .terminal()
+ .expect("receipt")
+ .outcome(),
+ DurableTerminalOutcome::Completed
+ );
+
+ let removal_adapter =
+ PersistentAppCore::in_memory(RelayConfiguration::default()).expect("remove adapter");
+ removal_adapter
+ .database()
+ .insert_account(&saved)
+ .expect("remove account");
+ removal_adapter
+ .database()
+ .save_selected_account(Some(saved.public_key()))
+ .expect("remove selection");
+ let removal = DurableRequestId::parse("remove:response-loss:1").expect("request");
+ removal_adapter
+ .database()
+ .begin_durable_operation(
+ &removal,
+ DurableOperationKind::Remove,
+ saved.public_key(),
+ Some(0),
+ OperationPriorState::new(None, Some(BindingAvailability::Available)),
+ FixedClock.now(),
+ )
+ .expect("remove intent");
+ removal_adapter
+ .database()
+ .advance_durable_operation(
+ &removal,
+ DurableOperationPhase::IntentRecorded,
+ DurableOperationPhase::CredentialDeleted,
+ FixedClock.now(),
+ None,
+ )
+ .expect("credential deleted");
+ let removed = removal_adapter
+ .bootstrap(&secrets, &FixedClock)
+ .expect("removal recovery");
+ assert!(removed.accounts().is_empty());
+ assert_eq!(removed.selected_account(), None);
+ }
+
+ #[test]
+ fn bootstrap_recovery_completes_credential_deleted_removal_and_fallback() {
+ let directory = tempdir().expect("directory");
+ let path = directory.path().join("studio.sqlite3");
+ let secrets = InMemorySecretStore::default();
+ let first;
+ let removed;
+ {
+ let adapter =
+ PersistentAppCore::open(&path, RelayConfiguration::default()).expect("adapter");
+ adapter.bootstrap(&secrets, &FixedClock).expect("bootstrap");
+ first = adapter
+ .generate_account(&secrets, &FixedClock)
+ .expect("first")
+ .account()
+ .public_key();
+ removed = adapter
+ .generate_account(&secrets, &FixedClock)
+ .expect("removed")
+ .account()
+ .public_key();
+ let operation = adapter
+ .database()
+ .begin_operation(AccountOperationKind::Remove, removed, FixedClock.now())
+ .expect("intent");
+ secrets.delete(removed).expect("credential deletion");
+ adapter
+ .database()
+ .update_operation(
+ operation,
+ AccountOperationPhase::CredentialDeleted,
+ FixedClock.now(),
+ None,
+ )
+ .expect("phase");
+ }
+
+ let reopened =
+ PersistentAppCore::open(&path, RelayConfiguration::default()).expect("reopen");
+ let restored = reopened
+ .bootstrap(&secrets, &FixedClock)
+ .expect("recover and bootstrap");
+ assert_eq!(restored.accounts().len(), 1);
+ assert_eq!(restored.selected_account(), Some(first));
+ assert_eq!(restored.session(), SessionState::SignedOut);
+ assert!(
+ reopened
+ .database()
+ .list_pending_operations()
+ .expect("journal")
+ .is_empty()
+ );
+ assert!(
+ reopened
+ .database()
+ .find_account(removed)
+ .expect("removed")
+ .is_none()
+ );
+ }
+
+ #[test]
+ fn bootstrap_skips_keyring_when_journal_empty_and_retains_failed_intent() {
+ let empty = PersistentAppCore::in_memory(RelayConfiguration::default()).expect("empty");
+ let unavailable = FailureSecretStore::default();
+ unavailable.fail_next(SecretStoreOperation::Delete);
+ empty
+ .bootstrap(&unavailable, &FixedClock)
+ .expect("empty journal does not access keyring");
+
+ let adapter = PersistentAppCore::in_memory(RelayConfiguration::default()).expect("adapter");
+ adapter
+ .database()
+ .insert_account(&account())
+ .expect("account");
+ adapter
+ .database()
+ .save_selected_account(Some(account().public_key()))
+ .expect("selection");
+ adapter
+ .database()
+ .begin_operation(
+ AccountOperationKind::Remove,
+ account().public_key(),
+ FixedClock.now(),
+ )
+ .expect("intent");
+ let failing = FailureSecretStore::default();
+ failing.fail_next(SecretStoreOperation::Delete);
+ let error = adapter
+ .bootstrap(&failing, &FixedClock)
+ .expect_err("keyring unavailable");
+ assert_eq!(error.code(), SafeErrorCode::KeyringUnavailable);
+ let pending = adapter
+ .database()
+ .list_pending_operations()
+ .expect("pending");
+ assert_eq!(pending.len(), 1);
+ assert_eq!(pending[0].phase(), AccountOperationPhase::IntentRecorded);
+ }
+}
diff --git a/crates/studio_storage/src/db.rs b/crates/studio_storage/src/db.rs
@@ -0,0 +1,590 @@
+use std::fs::{self, File, OpenOptions};
+use std::ops::{Deref, DerefMut};
+use std::path::{Path, PathBuf};
+use std::sync::{Mutex, MutexGuard};
+use std::time::Duration;
+
+use fs2::FileExt;
+use radroots_studio_domain::{AccountIdentity, PublicKey, SafeError, SafeErrorCode, SafeMessage};
+use refinery::embed_migrations;
+use rusqlite::{Connection, OpenFlags};
+
+pub const CURRENT_SCHEMA_VERSION: u32 = 9;
+
+mod migrations {
+ use super::embed_migrations;
+
+ embed_migrations!("migrations");
+}
+
+pub struct Database {
+ connection: Mutex<Connection>,
+ path: Option<PathBuf>,
+ _ownership: Option<WritableOwnership>,
+}
+
+pub(crate) struct DatabaseConnection<'a> {
+ connection: MutexGuard<'a, Connection>,
+ path: Option<&'a Path>,
+}
+
+struct WritableOwnership {
+ _file: File,
+}
+
+impl Database {
+ /// Opens, configures, and migrates a file-backed `SQLite` database.
+ ///
+ /// # Errors
+ ///
+ /// Returns a safe storage error when the file, connection configuration,
+ /// permission update, or migration cannot complete.
+ pub fn open(path: &Path) -> Result<Self, SafeError> {
+ let parent = path.parent().ok_or_else(storage_error)?;
+ create_secure_directory(parent)?;
+ let ownership = WritableOwnership::acquire(path)?;
+ let flags = OpenFlags::SQLITE_OPEN_READ_WRITE
+ | OpenFlags::SQLITE_OPEN_CREATE
+ | OpenFlags::SQLITE_OPEN_NO_MUTEX;
+ let mut connection =
+ Connection::open_with_flags(path, flags).map_err(|_| storage_error())?;
+ configure(&connection).map_err(|_| corrupt_storage_error())?;
+ validate_legacy_account_identities(&connection)?;
+ migrations::migrations::runner()
+ .run(&mut connection)
+ .map_err(|_| corrupt_storage_error())?;
+ restrict_file_permissions(path)?;
+ restrict_sqlite_sidecars(path)?;
+ Ok(Self {
+ connection: Mutex::new(connection),
+ path: Some(path.to_path_buf()),
+ _ownership: Some(ownership),
+ })
+ }
+
+ /// Opens and migrates an isolated in-memory `SQLite` database.
+ ///
+ /// # Errors
+ ///
+ /// Returns a safe storage error when configuration or migration fails.
+ pub fn in_memory() -> Result<Self, SafeError> {
+ let mut connection = Connection::open_in_memory().map_err(|_| storage_error())?;
+ configure(&connection)?;
+ migrations::migrations::runner()
+ .run(&mut connection)
+ .map_err(|_| corrupt_storage_error())?;
+ Ok(Self {
+ connection: Mutex::new(connection),
+ path: None,
+ _ownership: None,
+ })
+ }
+
+ /// Returns the highest successfully applied migration version.
+ ///
+ /// # Errors
+ ///
+ /// Returns a safe storage error when migration history cannot be read.
+ pub fn schema_version(&self) -> Result<u32, SafeError> {
+ self.connection()
+ .query_row(
+ "SELECT COALESCE(MAX(version), 0) FROM refinery_schema_history",
+ [],
+ |row| row.get(0),
+ )
+ .map_err(|_| corrupt_storage_error())
+ }
+
+ pub(crate) fn connection(&self) -> DatabaseConnection<'_> {
+ DatabaseConnection {
+ connection: self
+ .connection
+ .lock()
+ .unwrap_or_else(std::sync::PoisonError::into_inner),
+ path: self.path.as_deref(),
+ }
+ }
+}
+
+impl Deref for DatabaseConnection<'_> {
+ type Target = Connection;
+
+ fn deref(&self) -> &Self::Target {
+ &self.connection
+ }
+}
+
+impl DerefMut for DatabaseConnection<'_> {
+ fn deref_mut(&mut self) -> &mut Self::Target {
+ &mut self.connection
+ }
+}
+
+impl Drop for DatabaseConnection<'_> {
+ fn drop(&mut self) {
+ if let Some(path) = self.path {
+ let _ = restrict_sqlite_sidecars(path);
+ }
+ }
+}
+
+impl WritableOwnership {
+ fn acquire(database_path: &Path) -> Result<Self, SafeError> {
+ let lock_path = database_path.with_extension("sqlite3.lock");
+ let file = OpenOptions::new()
+ .read(true)
+ .write(true)
+ .create(true)
+ .truncate(false)
+ .open(&lock_path)
+ .map_err(|_| storage_error())?;
+ restrict_file_permissions(&lock_path)?;
+ file.try_lock_exclusive().map_err(|_| ownership_error())?;
+ Ok(Self { _file: file })
+ }
+}
+
+fn create_secure_directory(path: &Path) -> Result<(), SafeError> {
+ fs::create_dir_all(path).map_err(|_| storage_error())?;
+ restrict_directory_permissions(path)
+}
+
+fn configure(connection: &Connection) -> Result<(), SafeError> {
+ connection
+ .pragma_update(None, "foreign_keys", "ON")
+ .and_then(|()| connection.pragma_update(None, "trusted_schema", "OFF"))
+ .and_then(|()| connection.pragma_update(None, "journal_mode", "WAL"))
+ .and_then(|()| connection.pragma_update(None, "synchronous", "FULL"))
+ .and_then(|()| connection.pragma_update(None, "secure_delete", "ON"))
+ .and_then(|()| connection.pragma_update(None, "wal_autocheckpoint", 1_000))
+ .and_then(|()| connection.busy_timeout(Duration::from_secs(5)))
+ .map_err(|_| storage_error())
+}
+
+fn validate_legacy_account_identities(connection: &Connection) -> Result<(), SafeError> {
+ let has_accounts = connection
+ .query_row(
+ "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'accounts')",
+ [],
+ |row| row.get::<_, bool>(0),
+ )
+ .map_err(|_| corrupt_storage_error())?;
+ if !has_accounts {
+ return Ok(());
+ }
+
+ let mut statement = connection
+ .prepare("SELECT pubkey, npub, signer_kind, key_availability FROM accounts")
+ .map_err(|_| corrupt_storage_error())?;
+ let rows = statement
+ .query_map([], |row| {
+ Ok((
+ row.get::<_, String>(0)?,
+ row.get::<_, String>(1)?,
+ row.get::<_, String>(2)?,
+ row.get::<_, String>(3)?,
+ ))
+ })
+ .map_err(|_| corrupt_storage_error())?;
+ for row in rows {
+ let (public_key, npub, signer_kind, availability) =
+ row.map_err(|_| corrupt_storage_error())?;
+ let public_key = PublicKey::from_hex(&public_key).map_err(|_| corrupt_storage_error())?;
+ AccountIdentity::verify(public_key, npub).map_err(|_| corrupt_storage_error())?;
+ if signer_kind != "local_secret"
+ || !matches!(
+ availability.as_str(),
+ "available" | "credential_missing" | "store_unavailable"
+ )
+ {
+ return Err(corrupt_storage_error());
+ }
+ }
+ Ok(())
+}
+
+fn restrict_sqlite_sidecars(path: &Path) -> Result<(), SafeError> {
+ for suffix in ["-wal", "-shm"] {
+ let sidecar = PathBuf::from(format!("{}{suffix}", path.display()));
+ if sidecar.exists() {
+ restrict_file_permissions(&sidecar)?;
+ }
+ }
+ Ok(())
+}
+
+#[cfg(unix)]
+fn restrict_file_permissions(path: &Path) -> Result<(), SafeError> {
+ use std::os::unix::fs::PermissionsExt;
+
+ fs::set_permissions(path, fs::Permissions::from_mode(0o600)).map_err(|_| storage_error())
+}
+
+#[cfg(unix)]
+fn restrict_directory_permissions(path: &Path) -> Result<(), SafeError> {
+ use std::os::unix::fs::PermissionsExt;
+
+ fs::set_permissions(path, fs::Permissions::from_mode(0o700)).map_err(|_| storage_error())
+}
+
+#[cfg(not(unix))]
+fn restrict_file_permissions(_path: &Path) -> Result<(), SafeError> {
+ Ok(())
+}
+
+#[cfg(not(unix))]
+fn restrict_directory_permissions(_path: &Path) -> Result<(), SafeError> {
+ Ok(())
+}
+
+const fn storage_error() -> SafeError {
+ SafeError::new(
+ SafeErrorCode::StorageUnavailable,
+ SafeMessage::new("The application database is unavailable."),
+ )
+}
+
+const fn corrupt_storage_error() -> SafeError {
+ SafeError::new(
+ SafeErrorCode::StorageCorrupt,
+ SafeMessage::new("The application database could not be read."),
+ )
+}
+
+const fn ownership_error() -> SafeError {
+ SafeError::new(
+ SafeErrorCode::StorageUnavailable,
+ SafeMessage::new("The application database is already in use."),
+ )
+}
+
+#[cfg(test)]
+mod tests {
+ use std::fs;
+ use std::path::Path;
+ use std::process::Command;
+
+ use tempfile::tempdir;
+
+ use radroots_studio_application::{AccountRepository, AppStateRepository};
+ use radroots_studio_domain::PublicKey;
+ use refinery::Target;
+ use rusqlite::Connection;
+
+ use super::{CURRENT_SCHEMA_VERSION, Database, configure, migrations};
+
+ #[test]
+ fn migration_opens_fresh_memory_database_once() {
+ let database = Database::in_memory().expect("open memory database");
+
+ assert_eq!(
+ database.schema_version().expect("schema version"),
+ CURRENT_SCHEMA_VERSION
+ );
+ assert_eq!(
+ database.schema_version().expect("repeat schema version"),
+ CURRENT_SCHEMA_VERSION
+ );
+ }
+
+ #[test]
+ fn sqlite_connection_enforces_trust_durability_and_busy_policy() {
+ let database = Database::in_memory().expect("open memory database");
+ let connection = database.connection();
+
+ assert_eq!(
+ connection
+ .pragma_query_value(None, "foreign_keys", |row| row.get::<_, u8>(0))
+ .expect("foreign keys"),
+ 1
+ );
+ assert_eq!(
+ connection
+ .pragma_query_value(None, "trusted_schema", |row| row.get::<_, u8>(0))
+ .expect("trusted schema"),
+ 0
+ );
+ assert_eq!(
+ connection
+ .pragma_query_value(None, "synchronous", |row| row.get::<_, u8>(0))
+ .expect("synchronous"),
+ 2
+ );
+ assert_eq!(
+ connection
+ .pragma_query_value(None, "busy_timeout", |row| row.get::<_, i64>(0))
+ .expect("busy timeout"),
+ 5_000
+ );
+ }
+
+ #[test]
+ fn normalized_schema_is_strict_and_enforces_same_account_bindings() {
+ let database = Database::in_memory().expect("open memory database");
+ let connection = database.connection();
+ let strict_tables: i64 = connection
+ .query_row(
+ "SELECT COUNT(*) FROM pragma_table_list WHERE name IN ('account_identities', 'local_signer_bindings', 'runtime_state', 'profile_cache_v6', 'durable_operations') AND strict = 1",
+ [],
+ |row| row.get(0),
+ )
+ .expect("strict table inventory");
+ assert_eq!(strict_tables, 5);
+
+ connection
+ .execute(
+ "INSERT INTO account_identities (public_key, npub, created_at) VALUES (?1, ?2, 1)",
+ [
+ "07".repeat(32),
+ "npub1qurswpc8qurswpc8qurswpc8qurswpc8qurswpc8qurswpc8qursnvjvl7".to_owned(),
+ ],
+ )
+ .expect("identity");
+ assert!(
+ connection
+ .execute(
+ "INSERT INTO local_signer_bindings (account_public_key, binding_public_key, binding_kind, availability) VALUES (?1, ?2, 'local_secret', 'available')",
+ ["07".repeat(32), "08".repeat(32)],
+ )
+ .is_err()
+ );
+ }
+
+ #[test]
+ fn v5_data_migrates_append_only_with_identity_profile_and_selection() {
+ let directory = tempdir().expect("temporary directory");
+ let path = directory.path().join("studio.sqlite3");
+ let public_key = "07".repeat(32);
+ {
+ let mut connection = Connection::open(&path).expect("legacy database");
+ configure(&connection).expect("configuration");
+ migrations::migrations::runner()
+ .set_target(Target::Version(5))
+ .run(&mut connection)
+ .expect("V5 schema");
+ connection
+ .execute(
+ "INSERT INTO accounts (pubkey, npub, signer_kind, key_availability, created_at) VALUES (?1, ?2, 'local_secret', 'available', 10)",
+ [&public_key, "npub1qurswpc8qurswpc8qurswpc8qurswpc8qurswpc8qurswpc8qursnvjvl7"],
+ )
+ .expect("legacy account");
+ connection
+ .execute(
+ "UPDATE app_state SET selected_pubkey = ?1 WHERE singleton = 1",
+ [&public_key],
+ )
+ .expect("legacy selection");
+ connection
+ .execute(
+ "INSERT INTO profile_cache (subject_pubkey, event_id, event_created_at, name, refreshed_at, refresh_status) VALUES (?1, ?2, 11, 'Farm', 12, 'success')",
+ [&public_key, &"01".repeat(32)],
+ )
+ .expect("legacy profile");
+ }
+
+ let database = Database::open(&path).expect("migrated database");
+ assert_eq!(database.schema_version().expect("version"), 9);
+ assert_eq!(database.list_accounts().expect("accounts").len(), 1);
+ assert_eq!(
+ database.load_selected_account().expect("selection"),
+ Some(PublicKey::from_bytes([7; 32]))
+ );
+ let connection = database.connection();
+ let migrated: (i64, i64, i64) = connection
+ .query_row(
+ "SELECT (SELECT COUNT(*) FROM account_identities), (SELECT COUNT(*) FROM local_signer_bindings), (SELECT COUNT(*) FROM profile_cache_v6)",
+ [],
+ |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
+ )
+ .expect("migrated inventory");
+ assert_eq!(migrated, (1, 1, 1));
+ }
+
+ #[test]
+ fn corrupt_v5_identity_fails_before_migration_without_recreation() {
+ let directory = tempdir().expect("temporary directory");
+ let path = directory.path().join("studio.sqlite3");
+ {
+ let mut connection = Connection::open(&path).expect("legacy database");
+ configure(&connection).expect("configuration");
+ migrations::migrations::runner()
+ .set_target(Target::Version(5))
+ .run(&mut connection)
+ .expect("V5 schema");
+ connection
+ .execute(
+ "INSERT INTO accounts (pubkey, npub, signer_kind, key_availability, created_at) VALUES (?1, ?2, 'local_secret', 'available', 10)",
+ ["07".repeat(32), "npub10elfcs4fr0l0r8af98jlmgdh9c8tcxjvz9qkw038js35mp4dma8qzvjptg".to_owned()],
+ )
+ .expect("mismatched legacy account");
+ }
+
+ assert!(Database::open(&path).is_err());
+ let connection = Connection::open(&path).expect("inspect legacy database");
+ let version: u32 = connection
+ .query_row(
+ "SELECT MAX(version) FROM refinery_schema_history",
+ [],
+ |row| row.get(0),
+ )
+ .expect("legacy version");
+ let accounts: i64 = connection
+ .query_row("SELECT COUNT(*) FROM accounts", [], |row| row.get(0))
+ .expect("legacy accounts");
+ assert_eq!((version, accounts), (5, 1));
+ }
+
+ #[test]
+ fn failed_v5_copy_rolls_back_the_active_migration() {
+ let directory = tempdir().expect("temporary directory");
+ let path = directory.path().join("studio.sqlite3");
+ let public_key = "07".repeat(32);
+ {
+ let mut connection = Connection::open(&path).expect("legacy database");
+ configure(&connection).expect("configuration");
+ migrations::migrations::runner()
+ .set_target(Target::Version(5))
+ .run(&mut connection)
+ .expect("V5 schema");
+ connection
+ .execute(
+ "INSERT INTO accounts (pubkey, npub, signer_kind, key_availability, created_at) VALUES (?1, ?2, 'local_secret', 'available', 10)",
+ [&public_key, "npub1qurswpc8qurswpc8qurswpc8qurswpc8qurswpc8qurswpc8qursnvjvl7"],
+ )
+ .expect("legacy account");
+ connection
+ .execute(
+ "INSERT INTO profile_cache (subject_pubkey, event_id, event_created_at, refreshed_at, refresh_status) VALUES (?1, 'invalid', 11, 12, 'success')",
+ [&public_key],
+ )
+ .expect("legacy corrupt profile");
+ }
+
+ assert!(Database::open(&path).is_err());
+ let connection = Connection::open(&path).expect("inspect interrupted migration");
+ let version: u32 = connection
+ .query_row(
+ "SELECT MAX(version) FROM refinery_schema_history",
+ [],
+ |row| row.get(0),
+ )
+ .expect("migration version");
+ let copied: i64 = connection
+ .query_row("SELECT COUNT(*) FROM account_identities", [], |row| {
+ row.get(0)
+ })
+ .expect("normalized accounts");
+ assert_eq!((version, copied), (6, 0));
+ }
+
+ #[test]
+ fn foreign_keys_reject_orphan_normalized_records() {
+ let database = Database::in_memory().expect("database");
+ let connection = database.connection();
+ assert!(
+ connection
+ .execute(
+ "INSERT INTO local_signer_bindings (account_public_key, binding_public_key, binding_kind, availability) VALUES (?1, ?1, 'local_secret', 'available')",
+ ["09".repeat(32)],
+ )
+ .is_err()
+ );
+ }
+
+ #[test]
+ fn second_process_cannot_acquire_writable_ownership() {
+ let directory = tempdir().expect("temporary directory");
+ let path = directory.path().join("studio.sqlite3");
+ let _owner = Database::open(&path).expect("parent owner");
+ let status = Command::new(std::env::current_exe().expect("test executable"))
+ .arg("--exact")
+ .arg("db::tests::writable_ownership_child_probe")
+ .arg("--nocapture")
+ .env("RADROOTS_STUDIO_LOCK_PROBE_PATH", &path)
+ .status()
+ .expect("child process");
+ assert!(status.success());
+ }
+
+ #[test]
+ fn writable_ownership_child_probe() {
+ let Ok(path) = std::env::var("RADROOTS_STUDIO_LOCK_PROBE_PATH") else {
+ return;
+ };
+ assert!(Database::open(Path::new(&path)).is_err());
+ }
+
+ #[test]
+ fn migration_persists_schema_version_across_file_reopen() {
+ let directory = tempdir().expect("temporary directory");
+ let path = directory.path().join("studio.sqlite3");
+
+ {
+ let database = Database::open(&path).expect("open file database");
+ assert_eq!(
+ database.schema_version().expect("schema version"),
+ CURRENT_SCHEMA_VERSION
+ );
+ }
+ let reopened = Database::open(&path).expect("reopen file database");
+ assert_eq!(
+ reopened.schema_version().expect("schema version"),
+ CURRENT_SCHEMA_VERSION
+ );
+ assert!(fs::metadata(path).expect("database metadata").len() > 0);
+ }
+
+ #[test]
+ fn writable_ownership_rejects_a_second_runtime_and_releases_on_drop() {
+ let directory = tempdir().expect("temporary directory");
+ let path = directory.path().join("studio.sqlite3");
+ let first = Database::open(&path).expect("first owner");
+ let Err(error) = Database::open(&path) else {
+ panic!("second owner must fail");
+ };
+ assert_eq!(
+ error.message().as_str(),
+ "The application database is already in use."
+ );
+ drop(first);
+ Database::open(&path).expect("ownership released");
+ }
+
+ #[cfg(unix)]
+ #[test]
+ fn migration_attempts_owner_only_database_permissions() {
+ use std::os::unix::fs::PermissionsExt;
+
+ let directory = tempdir().expect("temporary directory");
+ let path = directory.path().join("studio.sqlite3");
+ let database = Database::open(&path).expect("open file database");
+ let mode = fs::metadata(&path)
+ .expect("database metadata")
+ .permissions()
+ .mode()
+ & 0o777;
+
+ assert_eq!(mode, 0o600);
+ let directory_mode = fs::metadata(directory.path())
+ .expect("directory metadata")
+ .permissions()
+ .mode()
+ & 0o777;
+ assert_eq!(directory_mode, 0o700);
+
+ let connection = database.connection();
+ connection
+ .execute_batch("CREATE TABLE sidecar_probe (value INTEGER) STRICT; INSERT INTO sidecar_probe VALUES (1);")
+ .expect("write through WAL");
+ drop(connection);
+ for suffix in ["-wal", "-shm"] {
+ let sidecar = std::path::PathBuf::from(format!("{}{suffix}", path.display()));
+ let sidecar_mode = fs::metadata(sidecar)
+ .expect("sidecar metadata")
+ .permissions()
+ .mode()
+ & 0o777;
+ assert_eq!(sidecar_mode, 0o600);
+ }
+ }
+}
diff --git a/crates/studio_storage/src/journal.rs b/crates/studio_storage/src/journal.rs
@@ -0,0 +1,661 @@
+use radroots_studio_application::{
+ AccountOperationKind, AccountOperationPhase, DurableAccountOperation, DurableOperationKind,
+ DurableOperationPhase, DurableOperationReceipt, DurableOperationRepository,
+ DurableOperationStart, DurableRequestId, DurableTerminalOutcome, OperationDiagnostic,
+ OperationId, OperationJournal, OperationPriorState, PendingAccountOperation,
+};
+use radroots_studio_domain::{
+ BindingAvailability, PublicKey, SafeError, SafeErrorCode, SafeMessage, UnixTimestamp,
+};
+use rusqlite::{OptionalExtension, Row, params};
+
+use crate::Database;
+
+impl DurableOperationRepository for Database {
+ fn begin_durable_operation(
+ &self,
+ request_id: &DurableRequestId,
+ kind: DurableOperationKind,
+ account: PublicKey,
+ expected_revision: Option<u64>,
+ prior: OperationPriorState,
+ updated_at: UnixTimestamp,
+ ) -> Result<DurableOperationStart, SafeError> {
+ let encoded_expected_revision = expected_revision
+ .map(i64::try_from)
+ .transpose()
+ .map_err(|_| operation_conflict())?;
+ let mut connection = self.connection();
+ let transaction = connection.transaction().map_err(|_| storage_error())?;
+ let inserted = transaction
+ .execute(
+ "INSERT OR IGNORE INTO durable_operations (request_id, operation_kind, \
+ account_public_key, binding_public_key, expected_revision, phase, \
+ prior_selected_public_key, updated_at, prior_binding_availability) \
+ VALUES (?1, ?2, ?3, ?3, ?4, 'intent_recorded', ?5, ?6, ?7)",
+ params![
+ request_id.as_str(),
+ encode_durable_kind(kind),
+ account.to_hex(),
+ encoded_expected_revision,
+ prior.selected_account().map(PublicKey::to_hex),
+ updated_at.as_seconds(),
+ prior
+ .binding_availability()
+ .map(encode_binding_availability),
+ ],
+ )
+ .map_err(|_| storage_error())?;
+ let operation =
+ query_durable_operation(&transaction, request_id)?.ok_or_else(corrupt_storage_error)?;
+ if operation.kind() != kind
+ || operation.account() != account
+ || operation.expected_revision() != expected_revision
+ || operation.prior() != prior
+ {
+ return Err(operation_conflict());
+ }
+ transaction.commit().map_err(|_| storage_error())?;
+ Ok(if inserted == 1 {
+ DurableOperationStart::Started(operation)
+ } else {
+ DurableOperationStart::Existing(operation)
+ })
+ }
+
+ fn load_durable_operation(
+ &self,
+ request_id: &DurableRequestId,
+ ) -> Result<Option<DurableAccountOperation>, SafeError> {
+ query_durable_operation(&self.connection(), request_id)
+ }
+
+ fn advance_durable_operation(
+ &self,
+ request_id: &DurableRequestId,
+ expected_phase: DurableOperationPhase,
+ next_phase: DurableOperationPhase,
+ updated_at: UnixTimestamp,
+ diagnostic: Option<OperationDiagnostic>,
+ ) -> Result<DurableAccountOperation, SafeError> {
+ let mut connection = self.connection();
+ let transaction = connection.transaction().map_err(|_| storage_error())?;
+ let rows = transaction
+ .execute(
+ "UPDATE durable_operations SET phase = ?3, updated_at = ?4, diagnostic_code = ?5 \
+ WHERE request_id = ?1 AND phase = ?2 AND terminal_outcome IS NULL",
+ params![
+ request_id.as_str(),
+ encode_durable_phase(expected_phase),
+ encode_durable_phase(next_phase),
+ updated_at.as_seconds(),
+ diagnostic.map(encode_diagnostic),
+ ],
+ )
+ .map_err(|_| storage_error())?;
+ if rows != 1 {
+ return Err(operation_conflict());
+ }
+ let operation =
+ query_durable_operation(&transaction, request_id)?.ok_or_else(corrupt_storage_error)?;
+ transaction.commit().map_err(|_| storage_error())?;
+ Ok(operation)
+ }
+
+ fn finalize_durable_operation(
+ &self,
+ request_id: &DurableRequestId,
+ expected_phase: DurableOperationPhase,
+ outcome: DurableTerminalOutcome,
+ resulting_revision: Option<u64>,
+ updated_at: UnixTimestamp,
+ ) -> Result<DurableOperationReceipt, SafeError> {
+ if let Some(existing) = self.load_durable_operation(request_id)?
+ && let Some(receipt) = existing.terminal()
+ {
+ return if receipt.outcome() == outcome
+ && receipt.resulting_revision() == resulting_revision
+ {
+ Ok(receipt.clone())
+ } else {
+ Err(operation_conflict())
+ };
+ }
+ let resulting_revision = resulting_revision
+ .map(i64::try_from)
+ .transpose()
+ .map_err(|_| operation_conflict())?;
+ let rows = self
+ .connection()
+ .execute(
+ "UPDATE durable_operations SET phase = 'finalized', terminal_outcome = ?3, \
+ resulting_revision = ?4, updated_at = ?5 \
+ WHERE request_id = ?1 AND phase = ?2 AND terminal_outcome IS NULL",
+ params![
+ request_id.as_str(),
+ encode_durable_phase(expected_phase),
+ encode_terminal_outcome(outcome),
+ resulting_revision,
+ updated_at.as_seconds(),
+ ],
+ )
+ .map_err(|_| storage_error())?;
+ if rows != 1 {
+ return Err(operation_conflict());
+ }
+ self.load_durable_operation(request_id)?
+ .and_then(|operation| operation.terminal().cloned())
+ .ok_or_else(corrupt_storage_error)
+ }
+
+ fn list_unfinished_durable_operations(
+ &self,
+ ) -> Result<Vec<DurableAccountOperation>, SafeError> {
+ let connection = self.connection();
+ let mut statement = connection
+ .prepare(&format!(
+ "{DURABLE_OPERATION_SELECT} WHERE terminal_outcome IS NULL ORDER BY request_id ASC"
+ ))
+ .map_err(|_| storage_error())?;
+ let rows = statement
+ .query_map([], decode_durable_operation)
+ .map_err(|_| storage_error())?;
+ rows.map(|row| row.map_err(|_| corrupt_storage_error()))
+ .collect()
+ }
+}
+
+const DURABLE_OPERATION_SELECT: &str = "SELECT request_id, operation_kind, account_public_key, \
+ expected_revision, phase, prior_selected_public_key, updated_at, diagnostic_code, \
+ terminal_outcome, prior_binding_availability, resulting_revision FROM durable_operations";
+
+fn query_durable_operation(
+ connection: &rusqlite::Connection,
+ request_id: &DurableRequestId,
+) -> Result<Option<DurableAccountOperation>, SafeError> {
+ connection
+ .query_row(
+ &format!("{DURABLE_OPERATION_SELECT} WHERE request_id = ?1"),
+ [request_id.as_str()],
+ decode_durable_operation,
+ )
+ .optional()
+ .map_err(|_| corrupt_storage_error())
+}
+
+fn decode_durable_operation(row: &Row<'_>) -> rusqlite::Result<DurableAccountOperation> {
+ let request_id =
+ DurableRequestId::parse(row.get::<_, String>(0)?).map_err(|_| invalid_column(0))?;
+ let kind = decode_durable_kind(row.get::<_, String>(1)?.as_str())?;
+ let account =
+ PublicKey::from_hex(row.get::<_, String>(2)?.as_str()).map_err(|_| invalid_column(2))?;
+ let expected_revision = row
+ .get::<_, Option<i64>>(3)?
+ .map(|value| u64::try_from(value).map_err(|_| invalid_column(3)))
+ .transpose()?;
+ let phase = decode_durable_phase(row.get::<_, String>(4)?.as_str())?;
+ let prior_selected = row
+ .get::<_, Option<String>>(5)?
+ .map(|value| PublicKey::from_hex(&value).map_err(|_| invalid_column(5)))
+ .transpose()?;
+ let updated_at = UnixTimestamp::from_seconds(row.get(6)?).ok_or_else(|| invalid_column(6))?;
+ let diagnostic = row
+ .get::<_, Option<String>>(7)?
+ .map(|value| decode_diagnostic(&value))
+ .transpose()?;
+ let outcome = row
+ .get::<_, Option<String>>(8)?
+ .map(|value| decode_terminal_outcome(&value))
+ .transpose()?;
+ let prior_availability = row
+ .get::<_, Option<String>>(9)?
+ .map(|value| decode_binding_availability(&value))
+ .transpose()?;
+ let resulting_revision = row
+ .get::<_, Option<i64>>(10)?
+ .map(|value| u64::try_from(value).map_err(|_| invalid_column(10)))
+ .transpose()?;
+ let terminal = outcome.map(|outcome| {
+ DurableOperationReceipt::new(request_id.clone(), account, outcome, resulting_revision)
+ });
+ Ok(DurableAccountOperation::new(
+ request_id,
+ kind,
+ account,
+ expected_revision,
+ phase,
+ OperationPriorState::new(prior_selected, prior_availability),
+ updated_at,
+ diagnostic,
+ terminal,
+ ))
+}
+
+impl OperationJournal for Database {
+ fn begin_operation(
+ &self,
+ kind: AccountOperationKind,
+ subject: PublicKey,
+ updated_at: UnixTimestamp,
+ ) -> Result<OperationId, SafeError> {
+ let connection = self.connection();
+ connection
+ .execute(
+ "INSERT INTO operation_journal (operation_kind, subject_pubkey, phase, \
+ updated_at) VALUES (?1, ?2, 'intent_recorded', ?3)",
+ params![encode_kind(kind), subject.to_hex(), updated_at.as_seconds()],
+ )
+ .map_err(|_| storage_error())?;
+ let id =
+ u64::try_from(connection.last_insert_rowid()).map_err(|_| corrupt_storage_error())?;
+ Ok(OperationId::from_raw(id))
+ }
+
+ fn update_operation(
+ &self,
+ id: OperationId,
+ phase: AccountOperationPhase,
+ updated_at: UnixTimestamp,
+ diagnostic: Option<OperationDiagnostic>,
+ ) -> Result<(), SafeError> {
+ let encoded_id = i64::try_from(id.as_raw()).map_err(|_| corrupt_storage_error())?;
+ match self.connection().execute(
+ "UPDATE operation_journal SET phase = ?2, updated_at = ?3, diagnostic_code = ?4 \
+ WHERE operation_id = ?1",
+ params![
+ encoded_id,
+ encode_phase(phase),
+ updated_at.as_seconds(),
+ diagnostic.map(encode_diagnostic)
+ ],
+ ) {
+ Ok(1) => Ok(()),
+ Ok(0) => Err(operation_not_found()),
+ Ok(_) | Err(_) => Err(storage_error()),
+ }
+ }
+
+ fn list_pending_operations(&self) -> Result<Vec<PendingAccountOperation>, SafeError> {
+ let connection = self.connection();
+ let mut statement = connection
+ .prepare(
+ "SELECT operation_id, operation_kind, subject_pubkey, phase, updated_at, \
+ diagnostic_code FROM operation_journal ORDER BY operation_id ASC",
+ )
+ .map_err(|_| storage_error())?;
+ let rows = statement
+ .query_map([], decode_operation)
+ .map_err(|_| storage_error())?;
+ rows.map(|row| row.map_err(|_| corrupt_storage_error()))
+ .collect()
+ }
+
+ fn finalize_operation(&self, id: OperationId) -> Result<(), SafeError> {
+ let encoded_id = i64::try_from(id.as_raw()).map_err(|_| corrupt_storage_error())?;
+ self.connection()
+ .execute(
+ "DELETE FROM operation_journal WHERE operation_id = ?1",
+ [encoded_id],
+ )
+ .map(|_| ())
+ .map_err(|_| storage_error())
+ }
+}
+
+fn decode_operation(row: &Row<'_>) -> rusqlite::Result<PendingAccountOperation> {
+ let id = u64::try_from(row.get::<_, i64>(0)?).map_err(|_| invalid_column(0))?;
+ let kind = decode_kind(row.get::<_, String>(1)?.as_str())?;
+ let subject =
+ PublicKey::from_hex(row.get::<_, String>(2)?.as_str()).map_err(|_| invalid_column(2))?;
+ let phase = decode_phase(row.get::<_, String>(3)?.as_str())?;
+ let updated_at = UnixTimestamp::from_seconds(row.get(4)?).ok_or_else(|| invalid_column(4))?;
+ let diagnostic = row
+ .get::<_, Option<String>>(5)?
+ .map(|value| decode_diagnostic(&value))
+ .transpose()?;
+ Ok(PendingAccountOperation::new(
+ OperationId::from_raw(id),
+ kind,
+ subject,
+ phase,
+ updated_at,
+ diagnostic,
+ ))
+}
+
+const fn encode_durable_kind(value: DurableOperationKind) -> &'static str {
+ match value {
+ DurableOperationKind::Create => "create",
+ DurableOperationKind::Import => "import",
+ DurableOperationKind::Repair => "repair",
+ DurableOperationKind::Remove => "remove",
+ }
+}
+
+fn decode_durable_kind(value: &str) -> rusqlite::Result<DurableOperationKind> {
+ match value {
+ "create" => Ok(DurableOperationKind::Create),
+ "import" => Ok(DurableOperationKind::Import),
+ "repair" => Ok(DurableOperationKind::Repair),
+ "remove" => Ok(DurableOperationKind::Remove),
+ _ => Err(invalid_column(1)),
+ }
+}
+
+const fn encode_durable_phase(value: DurableOperationPhase) -> &'static str {
+ match value {
+ DurableOperationPhase::IntentRecorded => "intent_recorded",
+ DurableOperationPhase::CredentialWritten => "credential_written",
+ DurableOperationPhase::MetadataCommitted => "metadata_committed",
+ DurableOperationPhase::SelectionCommitted => "selection_committed",
+ DurableOperationPhase::CompensationPending => "compensation_pending",
+ DurableOperationPhase::CredentialDeleted => "credential_deleted",
+ DurableOperationPhase::MetadataDeleted => "metadata_deleted",
+ DurableOperationPhase::Finalized => "finalized",
+ }
+}
+
+fn decode_durable_phase(value: &str) -> rusqlite::Result<DurableOperationPhase> {
+ match value {
+ "intent_recorded" => Ok(DurableOperationPhase::IntentRecorded),
+ "credential_written" => Ok(DurableOperationPhase::CredentialWritten),
+ "metadata_committed" => Ok(DurableOperationPhase::MetadataCommitted),
+ "selection_committed" => Ok(DurableOperationPhase::SelectionCommitted),
+ "compensation_pending" => Ok(DurableOperationPhase::CompensationPending),
+ "credential_deleted" => Ok(DurableOperationPhase::CredentialDeleted),
+ "metadata_deleted" => Ok(DurableOperationPhase::MetadataDeleted),
+ "finalized" => Ok(DurableOperationPhase::Finalized),
+ _ => Err(invalid_column(4)),
+ }
+}
+
+const fn encode_terminal_outcome(value: DurableTerminalOutcome) -> &'static str {
+ match value {
+ DurableTerminalOutcome::Completed => "completed",
+ DurableTerminalOutcome::Cancelled => "cancelled",
+ DurableTerminalOutcome::Failed => "failed",
+ }
+}
+
+fn decode_terminal_outcome(value: &str) -> rusqlite::Result<DurableTerminalOutcome> {
+ match value {
+ "completed" => Ok(DurableTerminalOutcome::Completed),
+ "cancelled" => Ok(DurableTerminalOutcome::Cancelled),
+ "failed" => Ok(DurableTerminalOutcome::Failed),
+ _ => Err(invalid_column(8)),
+ }
+}
+
+const fn encode_binding_availability(value: BindingAvailability) -> &'static str {
+ match value {
+ BindingAvailability::Available => "available",
+ BindingAvailability::CredentialMissing => "credential_missing",
+ BindingAvailability::StoreUnavailable => "store_unavailable",
+ }
+}
+
+fn decode_binding_availability(value: &str) -> rusqlite::Result<BindingAvailability> {
+ match value {
+ "available" => Ok(BindingAvailability::Available),
+ "credential_missing" => Ok(BindingAvailability::CredentialMissing),
+ "store_unavailable" => Ok(BindingAvailability::StoreUnavailable),
+ _ => Err(invalid_column(9)),
+ }
+}
+
+const fn encode_kind(value: AccountOperationKind) -> &'static str {
+ match value {
+ AccountOperationKind::Add => "add",
+ AccountOperationKind::Import => "import",
+ AccountOperationKind::Remove => "remove",
+ }
+}
+
+fn decode_kind(value: &str) -> rusqlite::Result<AccountOperationKind> {
+ match value {
+ "add" => Ok(AccountOperationKind::Add),
+ "import" => Ok(AccountOperationKind::Import),
+ "remove" => Ok(AccountOperationKind::Remove),
+ _ => Err(invalid_column(1)),
+ }
+}
+
+const fn encode_phase(value: AccountOperationPhase) -> &'static str {
+ match value {
+ AccountOperationPhase::IntentRecorded => "intent_recorded",
+ AccountOperationPhase::CredentialWritten => "credential_written",
+ AccountOperationPhase::MetadataCommitted => "metadata_committed",
+ AccountOperationPhase::CompensationPending => "compensation_pending",
+ AccountOperationPhase::CredentialDeleted => "credential_deleted",
+ AccountOperationPhase::MetadataDeleted => "metadata_deleted",
+ }
+}
+
+fn decode_phase(value: &str) -> rusqlite::Result<AccountOperationPhase> {
+ match value {
+ "intent_recorded" => Ok(AccountOperationPhase::IntentRecorded),
+ "credential_written" => Ok(AccountOperationPhase::CredentialWritten),
+ "metadata_committed" => Ok(AccountOperationPhase::MetadataCommitted),
+ "compensation_pending" => Ok(AccountOperationPhase::CompensationPending),
+ "credential_deleted" => Ok(AccountOperationPhase::CredentialDeleted),
+ "metadata_deleted" => Ok(AccountOperationPhase::MetadataDeleted),
+ _ => Err(invalid_column(3)),
+ }
+}
+
+const fn encode_diagnostic(value: OperationDiagnostic) -> &'static str {
+ match value {
+ OperationDiagnostic::StorageUnavailable => "storage_unavailable",
+ OperationDiagnostic::KeyringUnavailable => "keyring_unavailable",
+ OperationDiagnostic::CredentialMissing => "credential_missing",
+ OperationDiagnostic::CompensationFailed => "compensation_failed",
+ OperationDiagnostic::Conflict => "conflict",
+ OperationDiagnostic::Expired => "expired",
+ }
+}
+
+fn decode_diagnostic(value: &str) -> rusqlite::Result<OperationDiagnostic> {
+ match value {
+ "storage_unavailable" => Ok(OperationDiagnostic::StorageUnavailable),
+ "keyring_unavailable" => Ok(OperationDiagnostic::KeyringUnavailable),
+ "credential_missing" => Ok(OperationDiagnostic::CredentialMissing),
+ "compensation_failed" => Ok(OperationDiagnostic::CompensationFailed),
+ "conflict" => Ok(OperationDiagnostic::Conflict),
+ "expired" => Ok(OperationDiagnostic::Expired),
+ _ => Err(invalid_column(5)),
+ }
+}
+
+fn invalid_column(index: usize) -> rusqlite::Error {
+ rusqlite::Error::InvalidColumnType(
+ index,
+ "account operation journal".to_owned(),
+ rusqlite::types::Type::Text,
+ )
+}
+
+const fn storage_error() -> SafeError {
+ SafeError::new(
+ SafeErrorCode::StorageUnavailable,
+ SafeMessage::new("The account recovery journal is unavailable."),
+ )
+}
+
+const fn corrupt_storage_error() -> SafeError {
+ SafeError::new(
+ SafeErrorCode::StorageCorrupt,
+ SafeMessage::new("The account recovery journal could not be read."),
+ )
+}
+
+const fn operation_not_found() -> SafeError {
+ SafeError::new(
+ SafeErrorCode::PendingOperationRecoveryRequired,
+ SafeMessage::new("The account recovery operation was not found."),
+ )
+}
+
+const fn operation_conflict() -> SafeError {
+ SafeError::new(
+ SafeErrorCode::InvalidApplicationState,
+ SafeMessage::new("The durable account operation conflicts with existing state."),
+ )
+}
+
+#[cfg(test)]
+mod tests {
+ use radroots_studio_application::{
+ AccountOperationKind, AccountOperationPhase, DurableOperationKind, DurableOperationPhase,
+ DurableOperationRepository, DurableOperationStart, DurableRequestId,
+ DurableTerminalOutcome, OperationDiagnostic, OperationJournal, OperationPriorState,
+ };
+ use radroots_studio_domain::{BindingAvailability, PublicKey, UnixTimestamp};
+
+ use crate::Database;
+
+ #[test]
+ fn journal_creates_advances_loads_and_finalizes_pending_operations() {
+ let database = Database::in_memory().expect("database");
+ let subject = PublicKey::from_bytes([7; 32]);
+ let id = database
+ .begin_operation(
+ AccountOperationKind::Import,
+ subject,
+ UnixTimestamp::from_seconds(10).expect("time"),
+ )
+ .expect("begin");
+ database
+ .update_operation(
+ id,
+ AccountOperationPhase::CompensationPending,
+ UnixTimestamp::from_seconds(11).expect("time"),
+ Some(OperationDiagnostic::KeyringUnavailable),
+ )
+ .expect("advance");
+
+ let pending = database.list_pending_operations().expect("pending");
+ assert_eq!(pending.len(), 1);
+ assert_eq!(pending[0].subject(), subject);
+ assert_eq!(pending[0].kind(), AccountOperationKind::Import);
+ assert_eq!(
+ pending[0].phase(),
+ AccountOperationPhase::CompensationPending
+ );
+ assert_eq!(
+ pending[0].diagnostic(),
+ Some(OperationDiagnostic::KeyringUnavailable)
+ );
+
+ database.finalize_operation(id).expect("finalize");
+ assert!(
+ database
+ .list_pending_operations()
+ .expect("pending")
+ .is_empty()
+ );
+ }
+
+ #[test]
+ fn journal_schema_and_rows_exclude_secret_payload_columns() {
+ let database = Database::in_memory().expect("database");
+ database
+ .begin_operation(
+ AccountOperationKind::Remove,
+ PublicKey::from_bytes([8; 32]),
+ UnixTimestamp::from_seconds(12).expect("time"),
+ )
+ .expect("begin");
+ let connection = database.connection();
+ let schema: String = connection
+ .query_row(
+ "SELECT sql FROM sqlite_master WHERE name = 'operation_journal'",
+ [],
+ |row| row.get(0),
+ )
+ .expect("schema");
+ assert!(!schema.contains("secret"));
+ assert!(!schema.contains("payload"));
+ }
+
+ #[test]
+ fn durable_repository_replays_matching_requests_and_retains_terminal_receipts() {
+ let database = Database::in_memory().expect("database");
+ let request = DurableRequestId::parse("import:test:1").expect("request");
+ let account = PublicKey::from_bytes([9; 32]);
+ let prior = OperationPriorState::new(
+ Some(PublicKey::from_bytes([8; 32])),
+ Some(BindingAvailability::CredentialMissing),
+ );
+ let started = database
+ .begin_durable_operation(
+ &request,
+ DurableOperationKind::Repair,
+ account,
+ Some(4),
+ prior,
+ UnixTimestamp::from_seconds(10).expect("time"),
+ )
+ .expect("begin");
+ assert!(matches!(started, DurableOperationStart::Started(_)));
+ let replay = database
+ .begin_durable_operation(
+ &request,
+ DurableOperationKind::Repair,
+ account,
+ Some(4),
+ prior,
+ UnixTimestamp::from_seconds(11).expect("time"),
+ )
+ .expect("replay");
+ assert!(matches!(replay, DurableOperationStart::Existing(_)));
+ assert!(
+ database
+ .begin_durable_operation(
+ &request,
+ DurableOperationKind::Remove,
+ account,
+ Some(4),
+ prior,
+ UnixTimestamp::from_seconds(11).expect("time"),
+ )
+ .is_err()
+ );
+ database
+ .advance_durable_operation(
+ &request,
+ DurableOperationPhase::IntentRecorded,
+ DurableOperationPhase::CredentialWritten,
+ UnixTimestamp::from_seconds(12).expect("time"),
+ None,
+ )
+ .expect("advance");
+ let receipt = database
+ .finalize_durable_operation(
+ &request,
+ DurableOperationPhase::CredentialWritten,
+ DurableTerminalOutcome::Completed,
+ Some(5),
+ UnixTimestamp::from_seconds(13).expect("time"),
+ )
+ .expect("finalize");
+ assert_eq!(receipt.resulting_revision(), Some(5));
+ assert_eq!(
+ database
+ .finalize_durable_operation(
+ &request,
+ DurableOperationPhase::CredentialWritten,
+ DurableTerminalOutcome::Completed,
+ Some(5),
+ UnixTimestamp::from_seconds(14).expect("time"),
+ )
+ .expect("receipt replay"),
+ receipt
+ );
+ assert!(
+ database
+ .list_unfinished_durable_operations()
+ .expect("unfinished")
+ .is_empty()
+ );
+ }
+}
diff --git a/crates/studio_storage/src/lib.rs b/crates/studio_storage/src/lib.rs
@@ -0,0 +1,15 @@
+#![doc = "Radroots Studio persistence adapters."]
+
+pub mod account_namespace;
+pub mod accounts;
+pub mod application_adapter;
+pub mod db;
+pub mod journal;
+pub mod os_keyring;
+pub mod profiles;
+pub mod runtime_actor;
+
+pub use application_adapter::PersistentAppCore;
+pub use db::{CURRENT_SCHEMA_VERSION, Database};
+pub use os_keyring::{CREDENTIAL_SERVICE, OsKeyringSecretStore};
+pub use runtime_actor::RuntimeActorHandle;
diff --git a/crates/studio_storage/src/os_keyring.rs b/crates/studio_storage/src/os_keyring.rs
@@ -0,0 +1,131 @@
+use std::sync::{Mutex, MutexGuard};
+
+use keyring::{Entry, Error as KeyringError};
+use radroots_studio_application::SecretStore;
+use radroots_studio_domain::{PublicKey, SafeError, SafeErrorCode, SafeMessage, SecretKeyInput};
+use zeroize::Zeroizing;
+
+pub const CREDENTIAL_SERVICE: &str = "org.radroots.studio.nostr";
+
+#[derive(Default)]
+pub struct OsKeyringSecretStore {
+ operation_lock: Mutex<()>,
+}
+
+impl OsKeyringSecretStore {
+ fn entry(public_key: PublicKey) -> Result<Entry, SafeError> {
+ Entry::new(CREDENTIAL_SERVICE, &public_key.to_hex()).map_err(|_| keyring_unavailable())
+ }
+
+ fn operation(&self) -> MutexGuard<'_, ()> {
+ self.operation_lock
+ .lock()
+ .unwrap_or_else(std::sync::PoisonError::into_inner)
+ }
+}
+
+impl SecretStore for OsKeyringSecretStore {
+ fn put(&self, public_key: PublicKey, secret: SecretKeyInput) -> Result<(), SafeError> {
+ let _operation = self.operation();
+ let entry = Self::entry(public_key)?;
+ match entry.get_password() {
+ Ok(password) => {
+ drop(Zeroizing::new(password));
+ return Err(credential_exists());
+ }
+ Err(KeyringError::NoEntry) => {}
+ Err(_) => return Err(keyring_unavailable()),
+ }
+ secret
+ .with_exposed_secret(|value| entry.set_password(value))
+ .map_err(|_| keyring_unavailable())
+ }
+
+ fn load(&self, public_key: PublicKey) -> Result<SecretKeyInput, SafeError> {
+ let _operation = self.operation();
+ let password = Self::entry(public_key)?
+ .get_password()
+ .map_err(|error| map_read_error(&error))?;
+ SecretKeyInput::parse(password)
+ }
+
+ fn contains(&self, public_key: PublicKey) -> Result<bool, SafeError> {
+ let _operation = self.operation();
+ match Self::entry(public_key)?.get_password() {
+ Ok(password) => {
+ drop(Zeroizing::new(password));
+ Ok(true)
+ }
+ Err(KeyringError::NoEntry) => Ok(false),
+ Err(_) => Err(keyring_unavailable()),
+ }
+ }
+
+ fn delete(&self, public_key: PublicKey) -> Result<(), SafeError> {
+ let _operation = self.operation();
+ Self::entry(public_key)?
+ .delete_credential()
+ .map_err(|error| map_read_error(&error))
+ }
+}
+
+const fn map_read_error(error: &KeyringError) -> SafeError {
+ match error {
+ KeyringError::NoEntry => credential_missing(),
+ _ => keyring_unavailable(),
+ }
+}
+
+const fn credential_exists() -> SafeError {
+ SafeError::new(
+ SafeErrorCode::AccountAlreadyExists,
+ SafeMessage::new("The Nostr account credential already exists."),
+ )
+}
+
+const fn credential_missing() -> SafeError {
+ SafeError::new(
+ SafeErrorCode::CredentialMissing,
+ SafeMessage::new("The Nostr account credential is missing."),
+ )
+}
+
+const fn keyring_unavailable() -> SafeError {
+ SafeError::new(
+ SafeErrorCode::KeyringUnavailable,
+ SafeMessage::new("The operating system credential store is unavailable."),
+ )
+}
+
+#[cfg(test)]
+mod tests {
+ use radroots_studio_application::SecretStore;
+ use radroots_studio_domain::{PublicKey, SecretKeyInput};
+
+ use super::{CREDENTIAL_SERVICE, OsKeyringSecretStore};
+
+ #[test]
+ fn keyring_coordinates_are_stable_and_public() {
+ let public_key = PublicKey::from_bytes([0xab; 32]);
+ assert_eq!(CREDENTIAL_SERVICE, "org.radroots.studio.nostr");
+ assert_eq!(public_key.to_hex(), "ab".repeat(32));
+ }
+
+ #[test]
+ #[ignore = "mutates the current user's operating-system credential store"]
+ fn real_keyring_smoke_round_trips_and_deletes() {
+ let store = OsKeyringSecretStore::default();
+ let public_key = PublicKey::from_bytes([0xcd; 32]);
+ let _ = store.delete(public_key);
+ store
+ .put(
+ public_key,
+ SecretKeyInput::parse("11".repeat(32)).expect("secret"),
+ )
+ .expect("keyring put");
+ assert!(store.contains(public_key).expect("keyring contains"));
+ let loaded = store.load(public_key).expect("keyring load");
+ assert_eq!(loaded.with_exposed_secret(str::len), 64);
+ store.delete(public_key).expect("keyring delete");
+ }
+}
diff --git a/crates/studio_storage/src/profiles.rs b/crates/studio_storage/src/profiles.rs
@@ -0,0 +1,250 @@
+use radroots_studio_application::{CachedProfile, ProfileRefreshStatus, ProfileRepository};
+use radroots_studio_domain::{
+ EventId, Kind0ProfileCandidate, ProfileMetadata, PublicKey, SafeError, SafeErrorCode,
+ SafeMessage, UnixTimestamp,
+};
+use rusqlite::{OptionalExtension, Row, params};
+
+use crate::Database;
+
+impl ProfileRepository for Database {
+ fn load_profile(&self, public_key: PublicKey) -> Result<Option<CachedProfile>, SafeError> {
+ self.connection()
+ .query_row(
+ "SELECT event_id, event_created_at, name, display_name, nip05, about, picture, \
+ refreshed_at, refresh_status FROM profile_cache_v6 WHERE subject_public_key = ?1",
+ [public_key.to_hex()],
+ |row| decode_profile(row, public_key),
+ )
+ .optional()
+ .map_err(|_| corrupt_storage_error())
+ }
+
+ fn save_profile(&self, profile: &CachedProfile) -> Result<(), SafeError> {
+ let candidate = profile.candidate();
+ let metadata = candidate.metadata();
+ self.connection()
+ .execute(
+ "INSERT INTO profile_cache_v6 (subject_public_key, event_id, event_created_at, name, \
+ display_name, nip05, about, picture, refreshed_at, refresh_status) \
+ VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10) \
+ ON CONFLICT(subject_public_key) DO UPDATE SET \
+ event_id = excluded.event_id, event_created_at = excluded.event_created_at, \
+ name = excluded.name, display_name = excluded.display_name, nip05 = excluded.nip05, \
+ about = excluded.about, picture = excluded.picture, \
+ refreshed_at = excluded.refreshed_at, refresh_status = excluded.refresh_status \
+ WHERE excluded.event_created_at > profile_cache_v6.event_created_at \
+ OR (excluded.event_created_at = profile_cache_v6.event_created_at \
+ AND excluded.event_id < profile_cache_v6.event_id)",
+ params![
+ candidate.author().to_hex(),
+ candidate.event_id().to_hex(),
+ candidate.created_at().as_seconds(),
+ metadata.name(),
+ metadata.display_name(),
+ metadata.nip05(),
+ metadata.about(),
+ metadata.picture(),
+ profile.refreshed_at().as_seconds(),
+ encode_refresh_status(profile.refresh_status()),
+ ],
+ )
+ .map(|_| ())
+ .map_err(|_| storage_error())
+ }
+
+ fn record_refresh_status(
+ &self,
+ public_key: PublicKey,
+ refreshed_at: UnixTimestamp,
+ status: ProfileRefreshStatus,
+ ) -> Result<(), SafeError> {
+ self.connection()
+ .execute(
+ "UPDATE profile_cache_v6 SET refreshed_at = ?2, refresh_status = ?3 \
+ WHERE subject_public_key = ?1",
+ params![
+ public_key.to_hex(),
+ refreshed_at.as_seconds(),
+ encode_refresh_status(status)
+ ],
+ )
+ .map(|_| ())
+ .map_err(|_| storage_error())
+ }
+
+ fn remove_profile(&self, public_key: PublicKey) -> Result<(), SafeError> {
+ self.connection()
+ .execute(
+ "DELETE FROM profile_cache_v6 WHERE subject_public_key = ?1",
+ [public_key.to_hex()],
+ )
+ .map(|_| ())
+ .map_err(|_| storage_error())
+ }
+}
+
+fn decode_profile(row: &Row<'_>, author: PublicKey) -> rusqlite::Result<CachedProfile> {
+ let event_id =
+ EventId::from_hex(row.get::<_, String>(0)?.as_str()).map_err(|_| invalid_column(0))?;
+ let created_at = UnixTimestamp::from_seconds(row.get(1)?).ok_or_else(|| invalid_column(1))?;
+ let metadata = ProfileMetadata::new(
+ row.get(2)?,
+ row.get(3)?,
+ row.get(4)?,
+ row.get(5)?,
+ row.get(6)?,
+ )
+ .map_err(|_| invalid_column(2))?;
+ let refreshed_at = UnixTimestamp::from_seconds(row.get(7)?).ok_or_else(|| invalid_column(7))?;
+ let refresh_status = decode_refresh_status(row.get::<_, String>(8)?.as_str())?;
+ Ok(CachedProfile::new(
+ Kind0ProfileCandidate::new(event_id, author, created_at, metadata),
+ refreshed_at,
+ refresh_status,
+ ))
+}
+
+const fn encode_refresh_status(status: ProfileRefreshStatus) -> &'static str {
+ match status {
+ ProfileRefreshStatus::Success => "success",
+ ProfileRefreshStatus::Offline => "offline",
+ ProfileRefreshStatus::InvalidData => "invalid_data",
+ }
+}
+
+fn decode_refresh_status(value: &str) -> rusqlite::Result<ProfileRefreshStatus> {
+ match value {
+ "success" => Ok(ProfileRefreshStatus::Success),
+ "offline" => Ok(ProfileRefreshStatus::Offline),
+ "invalid_data" => Ok(ProfileRefreshStatus::InvalidData),
+ _ => Err(invalid_column(8)),
+ }
+}
+
+fn invalid_column(index: usize) -> rusqlite::Error {
+ rusqlite::Error::InvalidColumnType(
+ index,
+ "cached Nostr profile".to_owned(),
+ rusqlite::types::Type::Text,
+ )
+}
+
+const fn storage_error() -> SafeError {
+ SafeError::new(
+ SafeErrorCode::StorageUnavailable,
+ SafeMessage::new("The profile cache is unavailable."),
+ )
+}
+
+const fn corrupt_storage_error() -> SafeError {
+ SafeError::new(
+ SafeErrorCode::StorageCorrupt,
+ SafeMessage::new("The profile cache could not be read."),
+ )
+}
+
+#[cfg(test)]
+mod tests {
+ use radroots_studio_application::{
+ AccountRepository, CachedProfile, ProfileRefreshStatus, ProfileRepository,
+ };
+ use radroots_studio_domain::{
+ AccountCreatedAt, AccountIdentity, AccountSummary, BindingAvailability, EventId,
+ Kind0ProfileCandidate, LocalSignerBinding, ProfileMetadata, PublicKey, UnixTimestamp,
+ };
+
+ use crate::Database;
+
+ fn account(public_key: PublicKey) -> AccountSummary {
+ AccountSummary::new(
+ AccountIdentity::derive(public_key).expect("identity"),
+ LocalSignerBinding::new(public_key, BindingAvailability::Available),
+ None,
+ AccountCreatedAt::new(UnixTimestamp::from_seconds(1).expect("time")),
+ None,
+ )
+ .expect("account")
+ }
+
+ fn profile(public_key: PublicKey, id: u8, created_at: i64, name: &str) -> CachedProfile {
+ CachedProfile::new(
+ Kind0ProfileCandidate::new(
+ EventId::from_bytes([id; 32]),
+ public_key,
+ UnixTimestamp::from_seconds(created_at).expect("time"),
+ ProfileMetadata::new(Some(name.to_owned()), None, None, None, None)
+ .expect("metadata"),
+ ),
+ UnixTimestamp::from_seconds(created_at + 1).expect("refresh time"),
+ ProfileRefreshStatus::Success,
+ )
+ }
+
+ #[test]
+ fn profile_cache_round_trips_and_records_refresh_status() {
+ let database = Database::in_memory().expect("database");
+ let public_key = PublicKey::from_bytes([1; 32]);
+ database
+ .insert_account(&account(public_key))
+ .expect("account");
+ database
+ .save_profile(&profile(public_key, 1, 10, "Farm"))
+ .expect("save profile");
+ database
+ .record_refresh_status(
+ public_key,
+ UnixTimestamp::from_seconds(20).expect("time"),
+ ProfileRefreshStatus::Offline,
+ )
+ .expect("record status");
+
+ let loaded = database
+ .load_profile(public_key)
+ .expect("load profile")
+ .expect("cached profile");
+ assert_eq!(loaded.candidate().metadata().name(), Some("Farm"));
+ assert_eq!(loaded.refreshed_at().as_seconds(), 20);
+ assert_eq!(loaded.refresh_status(), ProfileRefreshStatus::Offline);
+ }
+
+ #[test]
+ fn profile_cache_keeps_newest_then_lowest_event_id() {
+ let database = Database::in_memory().expect("database");
+ let public_key = PublicKey::from_bytes([2; 32]);
+ database
+ .insert_account(&account(public_key))
+ .expect("account");
+ database
+ .save_profile(&profile(public_key, 9, 20, "High ID"))
+ .expect("initial");
+ database
+ .save_profile(&profile(public_key, 1, 20, "Low ID"))
+ .expect("equal newer candidate");
+ database
+ .save_profile(&profile(public_key, 0, 10, "Older"))
+ .expect("older candidate");
+
+ let loaded = database
+ .load_profile(public_key)
+ .expect("load")
+ .expect("profile");
+ assert_eq!(loaded.candidate().metadata().name(), Some("Low ID"));
+ assert_eq!(loaded.candidate().event_id(), EventId::from_bytes([1; 32]));
+ }
+
+ #[test]
+ fn profile_cache_cascades_with_account_removal() {
+ let database = Database::in_memory().expect("database");
+ let public_key = PublicKey::from_bytes([3; 32]);
+ database
+ .insert_account(&account(public_key))
+ .expect("account");
+ database
+ .save_profile(&profile(public_key, 1, 10, "Farm"))
+ .expect("profile");
+ database.remove_account(public_key).expect("remove account");
+
+ assert_eq!(database.load_profile(public_key).expect("load"), None);
+ }
+}
diff --git a/crates/studio_storage/src/runtime_actor.rs b/crates/studio_storage/src/runtime_actor.rs
@@ -0,0 +1,1693 @@
+use std::collections::BTreeMap;
+use std::num::{NonZeroU64, NonZeroUsize};
+use std::path::Path;
+use std::sync::atomic::{AtomicU64, Ordering};
+use std::sync::{Arc, Mutex};
+use std::time::{Duration, Instant};
+
+use radroots_studio_application::{
+ ActorMailbox, AppSnapshot, ChangeSubscriptionId, Clock, CommandContext, CommandEnvelope,
+ CommandReceipt, CommandResult, CommandSubmission, ForegroundSessionBinding,
+ GenerateAccountReceipt, GeneratedKeyRecoveryHandle, GeneratedKeyStage, ImportAccountReceipt,
+ LifecycleGate, NostrClient, OrderedSnapshotChanges, ProfileRefreshPlan, RecoveryStageId,
+ RelayConfiguration, RemovalConfirmationToken, RequestId, RuntimeCommandClass, RuntimeLifecycle,
+ SecretStore, SessionGeneration, SnapshotChange, SnapshotChangeReceiver, SnapshotRevision,
+ TaskCorrelation,
+};
+use radroots_studio_domain::{
+ AccountIdentity, BindingAvailability, Kind0ProfileCandidate, LocalSignerBinding, PublicKey,
+ SafeError, SafeErrorCode, SafeMessage, SecretKeyInput,
+};
+use tokio::runtime::Handle;
+use tokio::sync::{mpsc, oneshot};
+
+use crate::PersistentAppCore;
+
+const DEFAULT_COMMAND_TIMEOUT: Duration = Duration::from_secs(30);
+const DEFAULT_TASK_CAPACITY: usize = 64;
+
+enum RuntimeCommand {
+ Snapshot,
+ GenerateAccount,
+ BeginGeneratedKeyStage,
+ AcknowledgeGeneratedKeyStage(RecoveryStageId),
+ CancelGeneratedKeyStage,
+ ImportSecretKey {
+ input: SecretKeyInput,
+ durable_request: Option<radroots_studio_application::DurableRequestId>,
+ durable_expected_revision: Option<u64>,
+ },
+ SelectAccount(PublicKey),
+ ActivateAccount(PublicKey),
+ SignOut,
+ RefreshActiveProfile,
+ RequestAccountRemoval(PublicKey),
+ ConfirmAccountRemoval(RemovalConfirmationToken),
+ SubscribeChanges(NonZeroUsize),
+ UnsubscribeChanges(ChangeSubscriptionId),
+ Close,
+}
+
+enum RuntimeCommandValue {
+ Snapshot(Box<AppSnapshot>),
+ Generated(GenerateAccountReceipt),
+ GeneratedKeyStage(GeneratedKeyRecoveryHandle),
+ GeneratedKeyStageCancelled(bool),
+ Imported(ImportAccountReceipt),
+ RemovalRequest(RemovalConfirmationToken),
+ Subscription(RuntimeChangeSubscription),
+ Unsubscribed(bool),
+ Closed,
+}
+
+impl RuntimeCommand {
+ const fn class(&self) -> RuntimeCommandClass {
+ match self {
+ Self::Snapshot | Self::SubscribeChanges(_) | Self::UnsubscribeChanges(_) => {
+ RuntimeCommandClass::Observe
+ }
+ Self::GenerateAccount
+ | Self::BeginGeneratedKeyStage
+ | Self::AcknowledgeGeneratedKeyStage(_)
+ | Self::ImportSecretKey { .. }
+ | Self::ActivateAccount(_)
+ | Self::ConfirmAccountRemoval(_) => RuntimeCommandClass::UseCredential,
+ Self::SelectAccount(_)
+ | Self::SignOut
+ | Self::RequestAccountRemoval(_)
+ | Self::CancelGeneratedKeyStage => RuntimeCommandClass::MutateLocalState,
+ Self::RefreshActiveProfile => RuntimeCommandClass::UseRelay,
+ Self::Close => RuntimeCommandClass::Shutdown,
+ }
+ }
+}
+
+struct RuntimeActor {
+ adapter: Arc<PersistentAppCore>,
+ secrets: Arc<dyn SecretStore>,
+ clock: Arc<dyn Clock>,
+ nostr: Arc<dyn NostrClient>,
+ lifecycle: Arc<Mutex<LifecycleGate>>,
+ runtime: Handle,
+ session_generation: SessionGeneration,
+ published_session_generation: Arc<AtomicU64>,
+ profile_tasks: BTreeMap<RequestId, PendingProfileTask>,
+ changes: OrderedSnapshotChanges,
+ published_foreground_session: Arc<Mutex<Option<ForegroundSessionBinding>>>,
+ durable_request_namespace: String,
+ generated_key_stage: GeneratedKeyStage,
+}
+
+struct PendingProfileTask {
+ correlation: TaskCorrelation,
+ plan: ProfileRefreshPlan,
+ reply: oneshot::Sender<CommandReceipt<RuntimeCommandValue>>,
+ handle: tokio::task::JoinHandle<()>,
+}
+
+struct ProfileCompletion {
+ request_id: RequestId,
+ result: Result<Option<Kind0ProfileCandidate>, SafeError>,
+}
+
+#[derive(Clone)]
+pub struct RuntimeActorHandle {
+ mailbox: ActorMailbox<RuntimeCommand, RuntimeCommandValue>,
+ adapter: Arc<PersistentAppCore>,
+ lifecycle: Arc<Mutex<LifecycleGate>>,
+ runtime: Handle,
+ next_request: Arc<AtomicU64>,
+ session_generation: Arc<AtomicU64>,
+ foreground_session: Arc<Mutex<Option<ForegroundSessionBinding>>>,
+}
+
+pub struct RuntimeChangeSubscription {
+ id: ChangeSubscriptionId,
+ receiver: SnapshotChangeReceiver,
+}
+
+impl RuntimeChangeSubscription {
+ #[must_use]
+ pub const fn id(&self) -> ChangeSubscriptionId {
+ self.id
+ }
+
+ pub async fn receive(&mut self) -> Option<SnapshotChange> {
+ self.receiver.receive().await
+ }
+}
+
+impl RuntimeActorHandle {
+ /// Opens, migrates, recovers, and starts one actor-owned file-backed runtime.
+ ///
+ /// # Errors
+ ///
+ /// Returns a safe storage, recovery, or lifecycle error before the actor is
+ /// published when opening cannot reach ready state.
+ pub fn open(
+ path: &Path,
+ relay_configuration: RelayConfiguration,
+ secrets: Arc<dyn SecretStore>,
+ clock: Arc<dyn Clock>,
+ nostr: Arc<dyn NostrClient>,
+ capacity: NonZeroUsize,
+ runtime: &Handle,
+ ) -> Result<Self, SafeError> {
+ Self::start(
+ PersistentAppCore::open(path, relay_configuration)?,
+ secrets,
+ clock,
+ nostr,
+ capacity,
+ runtime,
+ )
+ }
+
+ /// Starts one isolated actor-owned in-memory runtime for tests.
+ ///
+ /// # Errors
+ ///
+ /// Returns a safe storage, recovery, or lifecycle error before publication.
+ pub fn in_memory(
+ relay_configuration: RelayConfiguration,
+ secrets: Arc<dyn SecretStore>,
+ clock: Arc<dyn Clock>,
+ nostr: Arc<dyn NostrClient>,
+ capacity: NonZeroUsize,
+ runtime: &Handle,
+ ) -> Result<Self, SafeError> {
+ Self::start(
+ PersistentAppCore::in_memory(relay_configuration)?,
+ secrets,
+ clock,
+ nostr,
+ capacity,
+ runtime,
+ )
+ }
+
+ fn start(
+ adapter: PersistentAppCore,
+ secrets: Arc<dyn SecretStore>,
+ clock: Arc<dyn Clock>,
+ nostr: Arc<dyn NostrClient>,
+ capacity: NonZeroUsize,
+ runtime: &Handle,
+ ) -> Result<Self, SafeError> {
+ let mut gate = LifecycleGate::opening();
+ gate.begin_compatibility_check()?;
+ gate.compatibility_accepted()?;
+ gate.ownership_acquired()?;
+ gate.migration_complete()?;
+ adapter.bootstrap(secrets.as_ref(), clock.as_ref())?;
+ gate.recovery_complete()?;
+
+ let adapter = Arc::new(adapter);
+ let lifecycle = Arc::new(Mutex::new(gate));
+ let (mailbox, receiver) = ActorMailbox::bounded(capacity);
+ let session_generation = Arc::new(AtomicU64::new(SessionGeneration::initial().value()));
+ let foreground_session = Arc::new(Mutex::new(None));
+ let changes = OrderedSnapshotChanges::new(adapter.core().snapshot());
+ let durable_request_namespace = format!(
+ "runtime:{}:{}",
+ std::process::id(),
+ std::time::SystemTime::now()
+ .duration_since(std::time::UNIX_EPOCH)
+ .map_or(0, |duration| duration.as_nanos())
+ );
+ let actor = RuntimeActor {
+ adapter: Arc::clone(&adapter),
+ secrets,
+ clock,
+ nostr,
+ lifecycle: Arc::clone(&lifecycle),
+ runtime: runtime.clone(),
+ session_generation: SessionGeneration::initial(),
+ published_session_generation: Arc::clone(&session_generation),
+ profile_tasks: BTreeMap::new(),
+ changes,
+ published_foreground_session: Arc::clone(&foreground_session),
+ durable_request_namespace,
+ generated_key_stage: GeneratedKeyStage::default(),
+ };
+ drop(runtime.spawn(actor.run(receiver)));
+ Ok(Self {
+ mailbox,
+ adapter,
+ lifecycle,
+ runtime: runtime.clone(),
+ next_request: Arc::new(AtomicU64::new(1)),
+ session_generation,
+ foreground_session,
+ })
+ }
+
+ #[must_use]
+ pub fn lifecycle(&self) -> RuntimeLifecycle {
+ self.lifecycle
+ .lock()
+ .unwrap_or_else(std::sync::PoisonError::into_inner)
+ .lifecycle()
+ }
+
+ #[must_use]
+ pub fn session_generation(&self) -> SessionGeneration {
+ SessionGeneration::from_value(self.session_generation.load(Ordering::Acquire))
+ }
+
+ #[must_use]
+ pub fn foreground_session(&self) -> Option<ForegroundSessionBinding> {
+ self.foreground_session
+ .lock()
+ .unwrap_or_else(std::sync::PoisonError::into_inner)
+ .clone()
+ }
+
+ #[must_use]
+ pub fn snapshot(&self) -> AppSnapshot {
+ self.adapter.core().snapshot()
+ }
+
+ /// Returns the ready snapshot through the actor command boundary.
+ ///
+ /// # Errors
+ ///
+ /// Returns a typed safe actor error.
+ pub async fn bootstrap(&self) -> Result<AppSnapshot, SafeError> {
+ Self::expect_snapshot(self.dispatch(RuntimeCommand::Snapshot, None).await?)
+ }
+
+ /// Generates one account through the serialized actor boundary.
+ ///
+ /// # Errors
+ ///
+ /// Returns a safe account, storage, keyring, timeout, or actor error.
+ pub async fn generate_account(&self) -> Result<GenerateAccountReceipt, SafeError> {
+ match self.dispatch(RuntimeCommand::GenerateAccount, None).await? {
+ RuntimeCommandValue::Generated(receipt) => Ok(receipt),
+ _ => Err(invalid_actor_response()),
+ }
+ }
+
+ /// Begins the only actor-owned generated-key recovery stage.
+ ///
+ /// # Errors
+ ///
+ /// Returns a safe conflict, timeout, key-generation, or actor error.
+ pub async fn begin_generated_key_stage(&self) -> Result<GeneratedKeyRecoveryHandle, SafeError> {
+ match self
+ .dispatch(RuntimeCommand::BeginGeneratedKeyStage, None)
+ .await?
+ {
+ RuntimeCommandValue::GeneratedKeyStage(view) => Ok(view),
+ _ => Err(invalid_actor_response()),
+ }
+ }
+
+ /// Acknowledges recovery and commits the staged account and credential once.
+ ///
+ /// # Errors
+ ///
+ /// Returns a safe unavailable, conflict, keyring, storage, timeout, or actor error.
+ pub async fn acknowledge_generated_key_stage(
+ &self,
+ id: RecoveryStageId,
+ ) -> Result<AppSnapshot, SafeError> {
+ let value = self
+ .dispatch(RuntimeCommand::AcknowledgeGeneratedKeyStage(id), None)
+ .await?;
+ Self::expect_snapshot(value)
+ }
+
+ /// Cancels and zeroizes the active generated-key stage, if present.
+ ///
+ /// # Errors
+ ///
+ /// Returns a safe timeout or actor error.
+ pub async fn cancel_generated_key_stage(&self) -> Result<bool, SafeError> {
+ match self
+ .dispatch(RuntimeCommand::CancelGeneratedKeyStage, None)
+ .await?
+ {
+ RuntimeCommandValue::GeneratedKeyStageCancelled(cancelled) => Ok(cancelled),
+ _ => Err(invalid_actor_response()),
+ }
+ }
+
+ /// Imports one account through the serialized actor boundary.
+ ///
+ /// # Errors
+ ///
+ /// Returns a safe account, storage, keyring, timeout, or actor error.
+ pub async fn import_secret_key(
+ &self,
+ input: SecretKeyInput,
+ ) -> Result<ImportAccountReceipt, SafeError> {
+ match self
+ .dispatch(
+ RuntimeCommand::ImportSecretKey {
+ input,
+ durable_request: None,
+ durable_expected_revision: None,
+ },
+ None,
+ )
+ .await?
+ {
+ RuntimeCommandValue::Imported(receipt) => Ok(receipt),
+ _ => Err(invalid_actor_response()),
+ }
+ }
+
+ /// Imports or repairs with a caller-owned durable request and deadline.
+ ///
+ /// # Errors
+ ///
+ /// Returns a safe validation, conflict, timeout, persistence, or actor error.
+ pub async fn import_secret_key_request(
+ &self,
+ request: radroots_studio_application::DurableRequestId,
+ expected_revision: SnapshotRevision,
+ input: SecretKeyInput,
+ timeout: Duration,
+ ) -> Result<ImportAccountReceipt, SafeError> {
+ let raw_request = self.next_request.fetch_add(1, Ordering::Relaxed);
+ let request_id = RequestId::new(raw_request).ok_or_else(request_space_exhausted)?;
+ match self
+ .dispatch_with_deadline(
+ RuntimeCommand::ImportSecretKey {
+ input,
+ durable_request: Some(request),
+ durable_expected_revision: Some(expected_revision.value()),
+ },
+ None,
+ request_id,
+ Instant::now() + timeout,
+ )
+ .await?
+ {
+ RuntimeCommandValue::Imported(receipt) => Ok(receipt),
+ _ => Err(invalid_actor_response()),
+ }
+ }
+
+ /// Selects one account through the serialized actor boundary.
+ ///
+ /// # Errors
+ ///
+ /// Returns a safe account, storage, timeout, or actor error.
+ pub async fn select_account(&self, public_key: PublicKey) -> Result<AppSnapshot, SafeError> {
+ let value = self
+ .dispatch(RuntimeCommand::SelectAccount(public_key), None)
+ .await?;
+ Self::expect_snapshot(value)
+ }
+
+ /// Activates one account through the serialized actor boundary.
+ ///
+ /// # Errors
+ ///
+ /// Returns a safe account, credential, storage, timeout, or actor error.
+ pub async fn activate_account(&self, public_key: PublicKey) -> Result<AppSnapshot, SafeError> {
+ let value = self
+ .dispatch(RuntimeCommand::ActivateAccount(public_key), None)
+ .await?;
+ Self::expect_snapshot(value)
+ }
+
+ /// Signs out through the serialized actor boundary.
+ ///
+ /// # Errors
+ ///
+ /// Returns a safe timeout or actor error.
+ pub async fn sign_out(&self) -> Result<AppSnapshot, SafeError> {
+ let value = self.dispatch(RuntimeCommand::SignOut, None).await?;
+ Self::expect_snapshot(value)
+ }
+
+ /// Refreshes the active profile through the serialized actor boundary.
+ ///
+ /// # Errors
+ ///
+ /// Returns a safe relay, storage, timeout, or actor error.
+ pub async fn refresh_active_profile(&self) -> Result<AppSnapshot, SafeError> {
+ let value = self
+ .dispatch(RuntimeCommand::RefreshActiveProfile, None)
+ .await?;
+ Self::expect_snapshot(value)
+ }
+
+ /// Creates one removal request through the serialized actor boundary.
+ ///
+ /// # Errors
+ ///
+ /// Returns a safe account, timeout, or actor error.
+ pub async fn request_account_removal(
+ &self,
+ public_key: PublicKey,
+ ) -> Result<RemovalConfirmationToken, SafeError> {
+ match self
+ .dispatch(RuntimeCommand::RequestAccountRemoval(public_key), None)
+ .await?
+ {
+ RuntimeCommandValue::RemovalRequest(token) => Ok(token),
+ _ => Err(invalid_actor_response()),
+ }
+ }
+
+ /// Confirms one removal through the serialized actor boundary.
+ ///
+ /// # Errors
+ ///
+ /// Returns a safe account, credential, storage, timeout, or actor error.
+ pub async fn confirm_account_removal(
+ &self,
+ token: RemovalConfirmationToken,
+ ) -> Result<AppSnapshot, SafeError> {
+ let value = self
+ .dispatch(RuntimeCommand::ConfirmAccountRemoval(token), None)
+ .await?;
+ Self::expect_snapshot(value)
+ }
+
+ /// Closes command admission and cancels supervised work.
+ ///
+ /// # Errors
+ ///
+ /// Returns a safe timeout or actor error. Repeated calls return closed.
+ pub async fn close(&self) -> Result<(), SafeError> {
+ self.close_with_timeout(DEFAULT_COMMAND_TIMEOUT).await
+ }
+
+ /// Closes the runtime within the supplied command deadline.
+ ///
+ /// # Errors
+ ///
+ /// Returns a safe timeout or actor error. An expired queued close cannot
+ /// later change runtime state.
+ pub async fn close_with_timeout(&self, timeout: Duration) -> Result<(), SafeError> {
+ let raw_request = self.next_request.fetch_add(1, Ordering::Relaxed);
+ let request_id = RequestId::new(raw_request).ok_or_else(request_space_exhausted)?;
+ match self
+ .dispatch_with_deadline(
+ RuntimeCommand::Close,
+ None,
+ request_id,
+ Instant::now() + timeout,
+ )
+ .await?
+ {
+ RuntimeCommandValue::Closed => Ok(()),
+ _ => Err(invalid_actor_response()),
+ }
+ }
+
+ /// Atomically registers a bounded ordered change consumer with its initial snapshot.
+ ///
+ /// # Errors
+ ///
+ /// Returns a safe actor or subscription error.
+ pub async fn subscribe_changes(
+ &self,
+ capacity: NonZeroUsize,
+ ) -> Result<RuntimeChangeSubscription, SafeError> {
+ match self
+ .dispatch(RuntimeCommand::SubscribeChanges(capacity), None)
+ .await?
+ {
+ RuntimeCommandValue::Subscription(subscription) => Ok(subscription),
+ _ => Err(invalid_actor_response()),
+ }
+ }
+
+ /// Removes a change consumer through the serialized actor boundary.
+ ///
+ /// # Errors
+ ///
+ /// Returns a safe actor error.
+ pub async fn unsubscribe_changes(&self, id: ChangeSubscriptionId) -> Result<bool, SafeError> {
+ match self
+ .dispatch(RuntimeCommand::UnsubscribeChanges(id), None)
+ .await?
+ {
+ RuntimeCommandValue::Unsubscribed(removed) => Ok(removed),
+ _ => Err(invalid_actor_response()),
+ }
+ }
+
+ async fn dispatch(
+ &self,
+ command: RuntimeCommand,
+ expected_revision: Option<SnapshotRevision>,
+ ) -> Result<RuntimeCommandValue, SafeError> {
+ let raw_request = self.next_request.fetch_add(1, Ordering::Relaxed);
+ let request_id = RequestId::new(raw_request).ok_or_else(request_space_exhausted)?;
+ self.dispatch_with_deadline(
+ command,
+ expected_revision,
+ request_id,
+ Instant::now() + DEFAULT_COMMAND_TIMEOUT,
+ )
+ .await
+ }
+
+ async fn dispatch_with_deadline(
+ &self,
+ command: RuntimeCommand,
+ expected_revision: Option<SnapshotRevision>,
+ request_id: RequestId,
+ deadline: Instant,
+ ) -> Result<RuntimeCommandValue, SafeError> {
+ let context = CommandContext::new(request_id, expected_revision, deadline);
+ let receipt = match self.mailbox.submit(context, command) {
+ CommandSubmission::Accepted(ticket) => {
+ let remaining = deadline.saturating_duration_since(Instant::now());
+ let waiting = self
+ .runtime
+ .spawn(async move { tokio::time::timeout(remaining, ticket.receipt()).await });
+ match waiting.await {
+ Ok(Ok(receipt)) => receipt,
+ Ok(Err(_)) => CommandReceipt::new(request_id, CommandResult::TimedOut),
+ Err(_) => CommandReceipt::new(request_id, CommandResult::Closed),
+ }
+ }
+ CommandSubmission::Rejected(receipt) => receipt,
+ };
+ match receipt.into_result() {
+ CommandResult::Completed(value) => Ok(value),
+ CommandResult::Conflicted { .. } => Err(command_conflicted()),
+ CommandResult::Rejected(_) => Err(command_rejected()),
+ CommandResult::TimedOut => Err(command_timed_out()),
+ CommandResult::Closed => Err(runtime_closed()),
+ CommandResult::Failed(error) => Err(error),
+ }
+ }
+
+ #[cfg(test)]
+ async fn import_secret_key_with_timeout(
+ &self,
+ input: SecretKeyInput,
+ timeout: Duration,
+ ) -> Result<ImportAccountReceipt, SafeError> {
+ let raw_request = self.next_request.fetch_add(1, Ordering::Relaxed);
+ let request_id = RequestId::new(raw_request).ok_or_else(request_space_exhausted)?;
+ match self
+ .dispatch_with_deadline(
+ RuntimeCommand::ImportSecretKey {
+ input,
+ durable_request: None,
+ durable_expected_revision: None,
+ },
+ None,
+ request_id,
+ Instant::now() + timeout,
+ )
+ .await?
+ {
+ RuntimeCommandValue::Imported(receipt) => Ok(receipt),
+ _ => Err(invalid_actor_response()),
+ }
+ }
+
+ fn expect_snapshot(value: RuntimeCommandValue) -> Result<AppSnapshot, SafeError> {
+ match value {
+ RuntimeCommandValue::Snapshot(snapshot) => Ok(*snapshot),
+ _ => Err(invalid_actor_response()),
+ }
+ }
+}
+
+impl RuntimeActor {
+ async fn run(
+ mut self,
+ mut receiver: mpsc::Receiver<CommandEnvelope<RuntimeCommand, RuntimeCommandValue>>,
+ ) {
+ let (completion_sender, mut completions) = mpsc::channel(DEFAULT_TASK_CAPACITY);
+ loop {
+ tokio::select! {
+ envelope = receiver.recv() => {
+ let Some(envelope) = envelope else {
+ break;
+ };
+ if !self.handle_command(envelope, &completion_sender) {
+ break;
+ }
+ }
+ completion = completions.recv(), if !self.profile_tasks.is_empty() => {
+ if let Some(completion) = completion {
+ self.complete_profile_task(completion);
+ }
+ }
+ }
+ }
+ self.cancel_profile_tasks(None);
+ }
+
+ fn handle_command(
+ &mut self,
+ envelope: CommandEnvelope<RuntimeCommand, RuntimeCommandValue>,
+ completion_sender: &mpsc::Sender<ProfileCompletion>,
+ ) -> bool {
+ let (context, command, reply) = envelope.into_parts();
+ if let Some(result) = self.preflight(context, &command) {
+ let _ = reply.send(CommandReceipt::new(context.request_id(), result));
+ return true;
+ }
+ if matches!(command, RuntimeCommand::RefreshActiveProfile) {
+ self.start_profile_task(context, reply, completion_sender.clone());
+ return true;
+ }
+ if matches!(command, RuntimeCommand::Close) {
+ let result = self.close_actor();
+ let closed = matches!(result, CommandResult::Completed(_));
+ let _ = reply.send(CommandReceipt::new(context.request_id(), result));
+ return !closed;
+ }
+ let changes_session = matches!(
+ command,
+ RuntimeCommand::ActivateAccount(_)
+ | RuntimeCommand::SignOut
+ | RuntimeCommand::ConfirmAccountRemoval(_)
+ );
+ let begins_generated_recovery = matches!(&command, RuntimeCommand::BeginGeneratedKeyStage);
+ let result = self.execute_sync(context, command);
+ if begins_generated_recovery && matches!(&result, CommandResult::Completed(_)) {
+ let snapshot = self.adapter.core().snapshot();
+ self.cancel_profile_tasks(Some(&snapshot));
+ }
+ if changes_session && matches!(result, CommandResult::Completed(_)) {
+ self.advance_session_generation();
+ self.synchronize_foreground_session();
+ }
+ if matches!(result, CommandResult::Completed(_)) {
+ self.changes.publish(self.adapter.core().snapshot());
+ }
+ let _ = reply.send(CommandReceipt::new(context.request_id(), result));
+ true
+ }
+
+ fn preflight(
+ &self,
+ context: CommandContext,
+ command: &RuntimeCommand,
+ ) -> Option<CommandResult<RuntimeCommandValue>> {
+ if context.is_expired(Instant::now()) {
+ return Some(CommandResult::TimedOut);
+ }
+ let lifecycle = self
+ .lifecycle
+ .lock()
+ .unwrap_or_else(std::sync::PoisonError::into_inner)
+ .to_owned();
+ if matches!(lifecycle.lifecycle(), RuntimeLifecycle::Closed) {
+ return Some(CommandResult::Closed);
+ }
+ if !lifecycle.allows(command.class()) {
+ return Some(CommandResult::Failed(command_unavailable()));
+ }
+ if self.generated_key_stage.pending().is_some()
+ && !matches!(
+ command,
+ RuntimeCommand::Snapshot
+ | RuntimeCommand::AcknowledgeGeneratedKeyStage(_)
+ | RuntimeCommand::CancelGeneratedKeyStage
+ | RuntimeCommand::SubscribeChanges(_)
+ | RuntimeCommand::UnsubscribeChanges(_)
+ | RuntimeCommand::Close
+ )
+ {
+ return Some(CommandResult::Failed(generated_recovery_route_active()));
+ }
+ let current_revision = self.adapter.core().snapshot().revision();
+ if context
+ .expected_revision()
+ .is_some_and(|expected| expected != current_revision)
+ {
+ return Some(CommandResult::Conflicted { current_revision });
+ }
+ None
+ }
+
+ fn execute_sync(
+ &mut self,
+ context: CommandContext,
+ command: RuntimeCommand,
+ ) -> CommandResult<RuntimeCommandValue> {
+ let durable_request = radroots_studio_application::DurableRequestId::parse(format!(
+ "{}:{}",
+ self.durable_request_namespace,
+ context.request_id().get()
+ ));
+ let expected_revision = context
+ .expected_revision()
+ .unwrap_or_else(|| self.adapter.core().snapshot().revision())
+ .value();
+ let result = match command {
+ RuntimeCommand::Snapshot => Ok(RuntimeCommandValue::Snapshot(Box::new(
+ self.adapter.core().snapshot(),
+ ))),
+ RuntimeCommand::GenerateAccount => durable_request.and_then(|request| {
+ self.adapter
+ .generate_account_durable(
+ &request,
+ expected_revision,
+ self.secrets.as_ref(),
+ self.clock.as_ref(),
+ )
+ .map(RuntimeCommandValue::Generated)
+ }),
+ RuntimeCommand::BeginGeneratedKeyStage => self
+ .generated_key_stage
+ .begin(
+ RecoveryStageId::new(
+ NonZeroU64::new(context.request_id().get())
+ .expect("request IDs are always non-zero"),
+ ),
+ expected_revision,
+ self.clock.now(),
+ )
+ .map(RuntimeCommandValue::GeneratedKeyStage),
+ RuntimeCommand::AcknowledgeGeneratedKeyStage(id) => {
+ durable_request.and_then(|request| self.commit_generated_key_stage(&request, id))
+ }
+ RuntimeCommand::CancelGeneratedKeyStage => Ok(
+ RuntimeCommandValue::GeneratedKeyStageCancelled(self.generated_key_stage.cancel()),
+ ),
+ RuntimeCommand::ImportSecretKey {
+ input,
+ durable_request: caller_request,
+ durable_expected_revision,
+ } => self.import_secret_key_command(
+ input,
+ caller_request,
+ durable_request,
+ durable_expected_revision.unwrap_or(expected_revision),
+ ),
+ RuntimeCommand::SelectAccount(public_key) => self
+ .adapter
+ .select_account(public_key)
+ .map(Box::new)
+ .map(RuntimeCommandValue::Snapshot),
+ RuntimeCommand::ActivateAccount(public_key) => self
+ .adapter
+ .activate_account(public_key, self.secrets.as_ref(), self.clock.as_ref())
+ .map(Box::new)
+ .map(RuntimeCommandValue::Snapshot),
+ RuntimeCommand::SignOut => self
+ .adapter
+ .sign_out()
+ .map(Box::new)
+ .map(RuntimeCommandValue::Snapshot),
+ RuntimeCommand::RequestAccountRemoval(public_key) => self
+ .adapter
+ .request_account_removal(public_key, self.clock.as_ref())
+ .map(RuntimeCommandValue::RemovalRequest),
+ RuntimeCommand::ConfirmAccountRemoval(token) => durable_request.and_then(|request| {
+ self.adapter
+ .confirm_account_removal_durable(
+ &request,
+ token,
+ self.secrets.as_ref(),
+ self.clock.as_ref(),
+ )
+ .map(Box::new)
+ .map(RuntimeCommandValue::Snapshot)
+ }),
+ RuntimeCommand::SubscribeChanges(capacity) => self
+ .changes
+ .subscribe(capacity)
+ .map(|(id, receiver)| {
+ RuntimeCommandValue::Subscription(RuntimeChangeSubscription { id, receiver })
+ })
+ .ok_or_else(observer_registration_failed),
+ RuntimeCommand::UnsubscribeChanges(id) => Ok(RuntimeCommandValue::Unsubscribed(
+ self.changes.unsubscribe(id),
+ )),
+ RuntimeCommand::Close | RuntimeCommand::RefreshActiveProfile => {
+ Err(invalid_actor_response())
+ }
+ };
+ result.map_or_else(CommandResult::Failed, CommandResult::Completed)
+ }
+
+ fn commit_generated_key_stage(
+ &mut self,
+ request: &radroots_studio_application::DurableRequestId,
+ id: RecoveryStageId,
+ ) -> Result<RuntimeCommandValue, SafeError> {
+ let staged = self.generated_key_stage.take(id, self.clock.now())?;
+ self.adapter.commit_staged_generated_key(
+ request,
+ staged,
+ self.secrets.as_ref(),
+ self.clock.as_ref(),
+ )?;
+ Ok(RuntimeCommandValue::Snapshot(Box::new(
+ self.adapter.core().snapshot(),
+ )))
+ }
+
+ fn import_secret_key_command(
+ &self,
+ input: SecretKeyInput,
+ caller_request: Option<radroots_studio_application::DurableRequestId>,
+ fallback_request: Result<radroots_studio_application::DurableRequestId, SafeError>,
+ expected_revision: u64,
+ ) -> Result<RuntimeCommandValue, SafeError> {
+ let request = caller_request.map_or(fallback_request, Ok)?;
+ self.adapter
+ .import_secret_key_durable(
+ &request,
+ expected_revision,
+ input,
+ self.secrets.as_ref(),
+ self.clock.as_ref(),
+ )
+ .map(RuntimeCommandValue::Imported)
+ }
+
+ fn start_profile_task(
+ &mut self,
+ context: CommandContext,
+ reply: oneshot::Sender<CommandReceipt<RuntimeCommandValue>>,
+ completion_sender: mpsc::Sender<ProfileCompletion>,
+ ) {
+ let foreground = self
+ .published_foreground_session
+ .lock()
+ .unwrap_or_else(std::sync::PoisonError::into_inner)
+ .clone();
+ let plan = match self.adapter.core().begin_profile_refresh() {
+ Ok(Some(plan)) => plan,
+ Ok(None) => {
+ let _ = reply.send(CommandReceipt::new(
+ context.request_id(),
+ CommandResult::Completed(RuntimeCommandValue::Snapshot(Box::new(
+ self.adapter.core().snapshot(),
+ ))),
+ ));
+ return;
+ }
+ Err(error) => {
+ let _ = reply.send(CommandReceipt::new(
+ context.request_id(),
+ CommandResult::Failed(error),
+ ));
+ return;
+ }
+ };
+ let Some(foreground) = foreground.filter(|binding| {
+ binding.identity().public_key() == plan.public_key()
+ && binding.generation() == self.session_generation
+ }) else {
+ let _ = reply.send(CommandReceipt::new(
+ context.request_id(),
+ CommandResult::Failed(stale_profile_binding()),
+ ));
+ return;
+ };
+ let correlation = TaskCorrelation::new(
+ context.request_id(),
+ plan.public_key(),
+ foreground.signer(),
+ plan.expected_revision(),
+ self.session_generation,
+ );
+ let client = Arc::clone(&self.nostr);
+ let relays = plan.relays().to_vec();
+ let request_id = context.request_id();
+ let handle = self.runtime.spawn(async move {
+ let result = client.fetch_profile(correlation.account(), &relays).await;
+ let _ = completion_sender
+ .send(ProfileCompletion { request_id, result })
+ .await;
+ });
+ let previous = self.profile_tasks.insert(
+ request_id,
+ PendingProfileTask {
+ correlation,
+ plan,
+ reply,
+ handle,
+ },
+ );
+ debug_assert!(previous.is_none(), "request identifiers are unique");
+ }
+
+ fn close_actor(&mut self) -> CommandResult<RuntimeCommandValue> {
+ let transition = (|| {
+ let mut lifecycle = self
+ .lifecycle
+ .lock()
+ .unwrap_or_else(std::sync::PoisonError::into_inner);
+ lifecycle.begin_shutdown()?;
+ lifecycle.finish_shutdown()
+ })();
+ match transition {
+ Ok(()) => {
+ self.generated_key_stage.cancel();
+ self.cancel_profile_tasks(None);
+ self.changes.close();
+ *self
+ .published_foreground_session
+ .lock()
+ .unwrap_or_else(std::sync::PoisonError::into_inner) = None;
+ CommandResult::Completed(RuntimeCommandValue::Closed)
+ }
+ Err(error) => CommandResult::Failed(error),
+ }
+ }
+
+ fn complete_profile_task(&mut self, completion: ProfileCompletion) {
+ let Some(task) = self.profile_tasks.remove(&completion.request_id) else {
+ return;
+ };
+ let current = self.adapter.core().snapshot();
+ let foreground = self
+ .published_foreground_session
+ .lock()
+ .unwrap_or_else(std::sync::PoisonError::into_inner)
+ .clone();
+ let correlated = task.correlation.session_generation() == self.session_generation
+ && foreground.is_some_and(|binding| {
+ binding.generation() == task.correlation.session_generation()
+ && binding.identity().public_key() == task.correlation.account()
+ && binding.signer() == task.correlation.binding()
+ })
+ && current
+ .active_account()
+ .is_some_and(|active| active.account().public_key() == task.correlation.account());
+ let result = if correlated {
+ self.adapter
+ .core()
+ .complete_profile_refresh(
+ &task.plan,
+ completion.result,
+ self.adapter.database(),
+ self.clock.as_ref(),
+ )
+ .map(Box::new)
+ .map(RuntimeCommandValue::Snapshot)
+ .map_or_else(CommandResult::Failed, CommandResult::Completed)
+ } else {
+ CommandResult::Completed(RuntimeCommandValue::Snapshot(Box::new(current)))
+ };
+ if matches!(result, CommandResult::Completed(_)) {
+ self.changes.publish(self.adapter.core().snapshot());
+ }
+ let _ = task
+ .reply
+ .send(CommandReceipt::new(task.correlation.request_id(), result));
+ }
+
+ fn advance_session_generation(&mut self) {
+ let Some(next) = self.session_generation.next() else {
+ self.lifecycle
+ .lock()
+ .unwrap_or_else(std::sync::PoisonError::into_inner)
+ .fail(request_space_exhausted());
+ self.cancel_profile_tasks(None);
+ return;
+ };
+ self.session_generation = next;
+ self.published_session_generation
+ .store(next.value(), Ordering::Release);
+ let snapshot = self.adapter.core().snapshot();
+ self.cancel_profile_tasks(Some(&snapshot));
+ }
+
+ fn synchronize_foreground_session(&mut self) {
+ let session = self
+ .adapter
+ .core()
+ .snapshot()
+ .active_account()
+ .map(|active| {
+ let public_key = active.account().public_key();
+ ForegroundSessionBinding::new(
+ AccountIdentity::derive(public_key)?,
+ LocalSignerBinding::new(public_key, BindingAvailability::Available),
+ self.session_generation,
+ )
+ });
+ let session = match session.transpose() {
+ Ok(session) => session,
+ Err(error) => {
+ self.lifecycle
+ .lock()
+ .unwrap_or_else(std::sync::PoisonError::into_inner)
+ .fail(error);
+ None
+ }
+ };
+ *self
+ .published_foreground_session
+ .lock()
+ .unwrap_or_else(std::sync::PoisonError::into_inner) = session;
+ }
+
+ fn cancel_profile_tasks(&mut self, snapshot: Option<&AppSnapshot>) {
+ let tasks = std::mem::take(&mut self.profile_tasks);
+ for (_, task) in tasks {
+ task.handle.abort();
+ let receipt_result = snapshot.map_or(CommandResult::Closed, |snapshot| {
+ CommandResult::Completed(RuntimeCommandValue::Snapshot(Box::new(snapshot.clone())))
+ });
+ let _ = task.reply.send(CommandReceipt::new(
+ task.correlation.request_id(),
+ receipt_result,
+ ));
+ }
+ }
+}
+
+const fn request_space_exhausted() -> SafeError {
+ SafeError::new(
+ SafeErrorCode::InvalidApplicationState,
+ SafeMessage::new("The runtime request identifier space is exhausted."),
+ )
+}
+
+const fn stale_profile_binding() -> SafeError {
+ SafeError::new(
+ SafeErrorCode::InvalidApplicationState,
+ SafeMessage::new("The active account binding changed before profile refresh."),
+ )
+}
+
+const fn command_conflicted() -> SafeError {
+ SafeError::new(
+ SafeErrorCode::InvalidApplicationState,
+ SafeMessage::new("The command conflicts with newer application state."),
+ )
+}
+
+const fn command_rejected() -> SafeError {
+ SafeError::new(
+ SafeErrorCode::InvalidApplicationState,
+ SafeMessage::new("The runtime is busy. Try again."),
+ )
+}
+
+const fn command_timed_out() -> SafeError {
+ SafeError::new(
+ SafeErrorCode::InvalidApplicationState,
+ SafeMessage::new("The runtime command timed out."),
+ )
+}
+
+const fn runtime_closed() -> SafeError {
+ SafeError::new(
+ SafeErrorCode::InvalidApplicationState,
+ SafeMessage::new("The application runtime is closed."),
+ )
+}
+
+const fn command_unavailable() -> SafeError {
+ SafeError::new(
+ SafeErrorCode::InvalidApplicationState,
+ SafeMessage::new("The command is unavailable in the current runtime state."),
+ )
+}
+
+const fn generated_recovery_route_active() -> SafeError {
+ SafeError::new(
+ SafeErrorCode::InvalidApplicationState,
+ SafeMessage::new("Complete or cancel generated-key recovery before another action."),
+ )
+}
+
+const fn invalid_actor_response() -> SafeError {
+ SafeError::new(
+ SafeErrorCode::InvalidApplicationState,
+ SafeMessage::new("The runtime returned an invalid command response."),
+ )
+}
+
+const fn observer_registration_failed() -> SafeError {
+ SafeError::new(
+ SafeErrorCode::ObserverRegistrationFailed,
+ SafeMessage::new("The application change subscription could not be registered."),
+ )
+}
+
+#[cfg(test)]
+mod tests {
+ use std::num::NonZeroUsize;
+ use std::sync::atomic::{AtomicBool, Ordering};
+ use std::sync::{Arc, Condvar, Mutex};
+ use std::time::Duration;
+
+ use radroots_studio_application::{
+ BoxFuture, Clock, FailureSecretStore, InMemorySecretStore, NostrClient, RelayConfiguration,
+ RuntimeLifecycle, SecretStore, SecretStoreOperation, SessionState,
+ };
+ use radroots_studio_domain::{
+ Kind0ProfileCandidate, PublicKey, RelayUrl, SafeError, SafeErrorCode, SecretKeyInput,
+ UnixTimestamp,
+ };
+
+ use super::RuntimeActorHandle;
+
+ struct FixedClock;
+
+ impl Clock for FixedClock {
+ fn now(&self) -> UnixTimestamp {
+ UnixTimestamp::from_seconds(50).expect("time")
+ }
+ }
+
+ struct OfflineNostr;
+
+ impl NostrClient for OfflineNostr {
+ fn fetch_profile<'a>(
+ &'a self,
+ _public_key: PublicKey,
+ _relays: &'a [RelayUrl],
+ ) -> BoxFuture<'a, Result<Option<Kind0ProfileCandidate>, SafeError>> {
+ Box::pin(async { Ok(None) })
+ }
+ }
+
+ struct BlockingNostr {
+ started: tokio::sync::Semaphore,
+ release: tokio::sync::Semaphore,
+ }
+
+ impl BlockingNostr {
+ fn new() -> Self {
+ Self {
+ started: tokio::sync::Semaphore::new(0),
+ release: tokio::sync::Semaphore::new(0),
+ }
+ }
+ }
+
+ impl NostrClient for BlockingNostr {
+ fn fetch_profile<'a>(
+ &'a self,
+ _public_key: PublicKey,
+ _relays: &'a [RelayUrl],
+ ) -> BoxFuture<'a, Result<Option<Kind0ProfileCandidate>, SafeError>> {
+ Box::pin(async move {
+ self.started.add_permits(1);
+ let permit = self.release.acquire().await.expect("release");
+ permit.forget();
+ Ok(None)
+ })
+ }
+ }
+
+ struct BlockingSecretStore {
+ inner: InMemorySecretStore,
+ block_next_put: AtomicBool,
+ put_started: AtomicBool,
+ released: Mutex<bool>,
+ release_signal: Condvar,
+ }
+
+ impl BlockingSecretStore {
+ fn new() -> Self {
+ Self {
+ inner: InMemorySecretStore::default(),
+ block_next_put: AtomicBool::new(true),
+ put_started: AtomicBool::new(false),
+ released: Mutex::new(false),
+ release_signal: Condvar::new(),
+ }
+ }
+
+ async fn wait_until_put_started(&self) {
+ while !self.put_started.load(Ordering::Acquire) {
+ tokio::task::yield_now().await;
+ }
+ }
+
+ fn release(&self) {
+ *self
+ .released
+ .lock()
+ .unwrap_or_else(std::sync::PoisonError::into_inner) = true;
+ self.release_signal.notify_all();
+ }
+ }
+
+ impl SecretStore for BlockingSecretStore {
+ fn put(&self, public_key: PublicKey, secret: SecretKeyInput) -> Result<(), SafeError> {
+ if self.block_next_put.swap(false, Ordering::AcqRel) {
+ self.put_started.store(true, Ordering::Release);
+ let released = self
+ .released
+ .lock()
+ .unwrap_or_else(std::sync::PoisonError::into_inner);
+ drop(
+ self.release_signal
+ .wait_while(released, |released| !*released)
+ .unwrap_or_else(std::sync::PoisonError::into_inner),
+ );
+ }
+ self.inner.put(public_key, secret)
+ }
+
+ fn load(&self, public_key: PublicKey) -> Result<SecretKeyInput, SafeError> {
+ self.inner.load(public_key)
+ }
+
+ fn contains(&self, public_key: PublicKey) -> Result<bool, SafeError> {
+ self.inner.contains(public_key)
+ }
+
+ fn delete(&self, public_key: PublicKey) -> Result<(), SafeError> {
+ self.inner.delete(public_key)
+ }
+ }
+
+ fn actor() -> (RuntimeActorHandle, Arc<InMemorySecretStore>) {
+ let secrets = Arc::new(InMemorySecretStore::default());
+ let secret_port: Arc<dyn SecretStore> = secrets.clone();
+ let actor = RuntimeActorHandle::in_memory(
+ RelayConfiguration::default(),
+ secret_port,
+ Arc::new(FixedClock),
+ Arc::new(OfflineNostr),
+ NonZeroUsize::new(8).expect("capacity"),
+ &tokio::runtime::Handle::current(),
+ )
+ .expect("actor");
+ (actor, secrets)
+ }
+
+ #[tokio::test(flavor = "multi_thread")]
+ async fn account_mutations_run_serially_through_one_ready_actor() {
+ let (actor, secrets) = actor();
+ assert_eq!(actor.lifecycle(), RuntimeLifecycle::Ready);
+
+ let imported = actor
+ .import_secret_key(
+ SecretKeyInput::parse(
+ "7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7".to_owned(),
+ )
+ .expect("input"),
+ )
+ .await
+ .expect("import");
+ let public_key = imported.account().public_key();
+ let activated = actor.activate_account(public_key).await.expect("activate");
+ assert_eq!(activated.session(), SessionState::Active);
+ let foreground = actor.foreground_session().expect("foreground session");
+ assert_eq!(foreground.identity().public_key(), public_key);
+ assert_eq!(foreground.signer().account(), public_key);
+ assert_eq!(foreground.generation(), actor.session_generation());
+ assert!(secrets.contains(public_key).expect("credential"));
+
+ let signed_out = actor.sign_out().await.expect("sign out");
+ assert_eq!(signed_out.session(), SessionState::SignedOut);
+ assert!(actor.foreground_session().is_none());
+ let removal = actor
+ .request_account_removal(public_key)
+ .await
+ .expect("removal request");
+ let removed = actor
+ .confirm_account_removal(removal)
+ .await
+ .expect("remove");
+ assert!(removed.accounts().is_empty());
+ assert!(!secrets.contains(public_key).expect("credential removed"));
+ }
+
+ #[tokio::test(flavor = "multi_thread")]
+ async fn generated_key_stage_is_exclusive_cancelable_and_snapshot_free() {
+ let (actor, secrets) = actor();
+ let initial = actor.snapshot();
+ let stage = actor
+ .begin_generated_key_stage()
+ .await
+ .expect("generated key stage");
+
+ assert!(actor.begin_generated_key_stage().await.is_err());
+ assert_eq!(actor.snapshot(), initial);
+ assert!(
+ !secrets
+ .contains(stage.view().account().public_key())
+ .expect("keyring")
+ );
+ assert!(actor.sign_out().await.is_err());
+ assert_eq!(actor.snapshot(), initial);
+ assert!(actor.cancel_generated_key_stage().await.expect("cancel"));
+ assert!(
+ !actor
+ .cancel_generated_key_stage()
+ .await
+ .expect("cancel empty")
+ );
+ assert_eq!(actor.snapshot(), initial);
+
+ actor
+ .begin_generated_key_stage()
+ .await
+ .expect("replacement stage");
+ actor.close().await.expect("close clears stage");
+ assert_eq!(actor.lifecycle(), RuntimeLifecycle::Closed);
+ }
+
+ #[tokio::test(flavor = "multi_thread")]
+ async fn recovery_handle_is_one_use_and_acknowledgement_commits_once() {
+ let (actor, secrets) = actor();
+ let initial = actor.snapshot();
+ let handle = actor
+ .begin_generated_key_stage()
+ .await
+ .expect("generated key stage");
+ let public_key = handle.view().account().public_key();
+ let recovery = handle.take_recovery_nsec().expect("recovery material");
+ assert_eq!(recovery.with_exposed_secret(str::len), 63);
+ assert!(handle.take_recovery_nsec().is_err());
+ assert_eq!(actor.snapshot(), initial);
+ assert!(!secrets.contains(public_key).expect("not committed"));
+
+ let committed = actor
+ .acknowledge_generated_key_stage(handle.id())
+ .await
+ .expect("acknowledge");
+ assert_eq!(committed.accounts().len(), 1);
+ assert_eq!(committed.selected_account(), Some(public_key));
+ assert!(secrets.contains(public_key).expect("credential committed"));
+ assert!(
+ actor
+ .acknowledge_generated_key_stage(handle.id())
+ .await
+ .is_err()
+ );
+ }
+
+ #[tokio::test(flavor = "multi_thread")]
+ async fn failed_generated_commit_consumes_the_stage_without_poisoning_the_actor() {
+ let secrets = Arc::new(FailureSecretStore::default());
+ secrets.fail_next(SecretStoreOperation::Put);
+ let secret_port: Arc<dyn SecretStore> = secrets.clone();
+ let actor = RuntimeActorHandle::in_memory(
+ RelayConfiguration::default(),
+ secret_port,
+ Arc::new(FixedClock),
+ Arc::new(OfflineNostr),
+ NonZeroUsize::new(8).expect("capacity"),
+ &tokio::runtime::Handle::current(),
+ )
+ .expect("actor");
+ let handle = actor
+ .begin_generated_key_stage()
+ .await
+ .expect("generated key stage");
+
+ let error = actor
+ .acknowledge_generated_key_stage(handle.id())
+ .await
+ .expect_err("injected keyring failure");
+
+ assert_eq!(error.code(), SafeErrorCode::KeyringUnavailable);
+ assert!(actor.snapshot().accounts().is_empty());
+ actor
+ .begin_generated_key_stage()
+ .await
+ .expect("fresh recovery after terminal failure");
+ assert!(actor.cancel_generated_key_stage().await.expect("cancel"));
+ }
+
+ #[tokio::test(flavor = "multi_thread")]
+ async fn session_generation_cancels_correlated_profile_work_on_sign_out() {
+ let client = Arc::new(BlockingNostr::new());
+ let actor = RuntimeActorHandle::in_memory(
+ RelayConfiguration::new(vec![RelayUrl::parse("ws://localhost:8080").expect("relay")]),
+ Arc::new(InMemorySecretStore::default()),
+ Arc::new(FixedClock),
+ client.clone(),
+ NonZeroUsize::new(8).expect("capacity"),
+ &tokio::runtime::Handle::current(),
+ )
+ .expect("actor");
+ let imported = actor
+ .import_secret_key(
+ SecretKeyInput::parse(
+ "7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7".to_owned(),
+ )
+ .expect("input"),
+ )
+ .await
+ .expect("import");
+ actor
+ .activate_account(imported.account().public_key())
+ .await
+ .expect("activate");
+ assert_eq!(actor.session_generation().value(), 1);
+
+ let refresh_actor = actor.clone();
+ let refresh = tokio::spawn(async move { refresh_actor.refresh_active_profile().await });
+ let started = client.started.acquire().await.expect("refresh started");
+ started.forget();
+ let signed_out = actor.sign_out().await.expect("sign out");
+ let cancelled = refresh
+ .await
+ .expect("refresh task")
+ .expect("safe cancellation");
+
+ assert_eq!(actor.session_generation().value(), 2);
+ assert_eq!(signed_out.session(), SessionState::SignedOut);
+ assert_eq!(cancelled.session(), SessionState::SignedOut);
+ assert!(cancelled.active_account().is_none());
+ }
+
+ #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
+ async fn bounded_runtime_rejects_saturation_without_dropping_accepted_commands() {
+ let secrets = Arc::new(BlockingSecretStore::new());
+ let actor = RuntimeActorHandle::in_memory(
+ RelayConfiguration::default(),
+ secrets.clone(),
+ Arc::new(FixedClock),
+ Arc::new(OfflineNostr),
+ NonZeroUsize::new(1).expect("capacity"),
+ &tokio::runtime::Handle::current(),
+ )
+ .expect("actor");
+
+ let first_actor = actor.clone();
+ let first = tokio::spawn(async move {
+ first_actor
+ .import_secret_key(secret(
+ "7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7",
+ ))
+ .await
+ });
+ secrets.wait_until_put_started().await;
+
+ let second_actor = actor.clone();
+ let second = tokio::spawn(async move {
+ second_actor
+ .import_secret_key(secret(
+ "0000000000000000000000000000000000000000000000000000000000000001",
+ ))
+ .await
+ });
+ while actor.mailbox.available_capacity() != 0 {
+ assert!(
+ !second.is_finished(),
+ "second command must enter the mailbox"
+ );
+ tokio::task::yield_now().await;
+ }
+ let rejected = actor
+ .import_secret_key(secret(
+ "0000000000000000000000000000000000000000000000000000000000000002",
+ ))
+ .await
+ .expect_err("full mailbox must reject");
+ assert_eq!(
+ rejected.message().as_str(),
+ "The runtime is busy. Try again."
+ );
+
+ secrets.release();
+ first.await.expect("first task").expect("first command");
+ second.await.expect("second task").expect("second command");
+ assert_eq!(
+ actor.bootstrap().await.expect("snapshot").accounts().len(),
+ 2
+ );
+ }
+
+ #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
+ async fn queued_command_expiry_returns_timeout_and_prevents_late_mutation() {
+ let secrets = Arc::new(BlockingSecretStore::new());
+ let actor = RuntimeActorHandle::in_memory(
+ RelayConfiguration::default(),
+ secrets.clone(),
+ Arc::new(FixedClock),
+ Arc::new(OfflineNostr),
+ NonZeroUsize::new(1).expect("capacity"),
+ &tokio::runtime::Handle::current(),
+ )
+ .expect("actor");
+
+ let first_actor = actor.clone();
+ let first = tokio::spawn(async move {
+ first_actor
+ .import_secret_key(secret(
+ "7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7",
+ ))
+ .await
+ });
+ secrets.wait_until_put_started().await;
+
+ let expired = actor
+ .import_secret_key_with_timeout(
+ secret("0000000000000000000000000000000000000000000000000000000000000001"),
+ Duration::from_millis(10),
+ )
+ .await
+ .expect_err("queued command must time out");
+ assert_eq!(expired.message().as_str(), "The runtime command timed out.");
+
+ secrets.release();
+ first.await.expect("first task").expect("first command");
+ assert_eq!(
+ actor.bootstrap().await.expect("snapshot").accounts().len(),
+ 1
+ );
+ }
+
+ #[tokio::test(flavor = "multi_thread")]
+ async fn close_is_terminal_and_every_later_command_is_rejected_as_closed() {
+ let (actor, _) = actor();
+ actor.close().await.expect("close");
+ assert_eq!(actor.lifecycle(), RuntimeLifecycle::Closed);
+
+ for error in [
+ actor.bootstrap().await.expect_err("bootstrap after close"),
+ actor.close().await.expect_err("repeated close"),
+ ] {
+ assert_eq!(
+ error.message().as_str(),
+ "The application runtime is closed."
+ );
+ }
+ }
+
+ #[tokio::test(flavor = "multi_thread")]
+ async fn actor_subscription_atomically_delivers_initial_then_ordered_changes() {
+ let (actor, _) = actor();
+ let mut subscription = actor
+ .subscribe_changes(NonZeroUsize::new(4).expect("capacity"))
+ .await
+ .expect("subscribe");
+ let initial = subscription.receive().await.expect("initial snapshot");
+ assert_eq!(initial.revision(), actor.snapshot().revision());
+ assert!(initial.previous_revision().is_none());
+
+ actor
+ .import_secret_key(secret(
+ "7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7",
+ ))
+ .await
+ .expect("import");
+ let changed = subscription.receive().await.expect("change");
+ assert!(changed.revision() > initial.revision());
+ assert_eq!(changed.previous_revision(), Some(initial.revision()));
+ assert!(
+ actor
+ .unsubscribe_changes(subscription.id())
+ .await
+ .expect("unsubscribe")
+ );
+ }
+
+ #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
+ async fn expired_queued_shutdown_does_not_close_runtime_later() {
+ let secrets = Arc::new(BlockingSecretStore::new());
+ let actor = RuntimeActorHandle::in_memory(
+ RelayConfiguration::default(),
+ secrets.clone(),
+ Arc::new(FixedClock),
+ Arc::new(OfflineNostr),
+ NonZeroUsize::new(1).expect("capacity"),
+ &tokio::runtime::Handle::current(),
+ )
+ .expect("actor");
+ let import_actor = actor.clone();
+ let import = tokio::spawn(async move {
+ import_actor
+ .import_secret_key(secret(
+ "7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7",
+ ))
+ .await
+ });
+ secrets.wait_until_put_started().await;
+
+ let timeout = actor
+ .close_with_timeout(Duration::from_millis(10))
+ .await
+ .expect_err("queued shutdown must expire");
+ assert_eq!(timeout.message().as_str(), "The runtime command timed out.");
+ secrets.release();
+ import.await.expect("import task").expect("import");
+ assert_eq!(actor.lifecycle(), RuntimeLifecycle::Ready);
+ assert_eq!(
+ actor
+ .bootstrap()
+ .await
+ .expect("still open")
+ .accounts()
+ .len(),
+ 1
+ );
+ actor.close().await.expect("later close");
+ }
+
+ #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
+ async fn shutdown_cancels_in_flight_work_and_terminates_publication() {
+ let client = Arc::new(BlockingNostr::new());
+ let actor = RuntimeActorHandle::in_memory(
+ RelayConfiguration::new(vec![RelayUrl::parse("ws://localhost:8080").expect("relay")]),
+ Arc::new(InMemorySecretStore::default()),
+ Arc::new(FixedClock),
+ client.clone(),
+ NonZeroUsize::new(8).expect("capacity"),
+ &tokio::runtime::Handle::current(),
+ )
+ .expect("actor");
+ let imported = actor
+ .import_secret_key(secret(
+ "7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7",
+ ))
+ .await
+ .expect("import");
+ actor
+ .activate_account(imported.account().public_key())
+ .await
+ .expect("activate");
+ let mut changes = actor
+ .subscribe_changes(NonZeroUsize::new(4).expect("capacity"))
+ .await
+ .expect("subscribe");
+ changes.receive().await.expect("initial");
+
+ let refresh_actor = actor.clone();
+ let refresh = tokio::spawn(async move { refresh_actor.refresh_active_profile().await });
+ let started = client.started.acquire().await.expect("refresh started");
+ started.forget();
+ actor.close().await.expect("close");
+
+ let cancelled = refresh
+ .await
+ .expect("refresh task")
+ .expect_err("refresh closes");
+ assert_eq!(
+ cancelled.message().as_str(),
+ "The application runtime is closed."
+ );
+ assert!(changes.receive().await.is_none());
+ assert_eq!(actor.lifecycle(), RuntimeLifecycle::Closed);
+ }
+
+ fn secret(value: &str) -> SecretKeyInput {
+ SecretKeyInput::parse(value.to_owned()).expect("valid test secret")
+ }
+}
diff --git a/crates/studio_storage/tests/local_relay_e2e.rs b/crates/studio_storage/tests/local_relay_e2e.rs
@@ -0,0 +1,95 @@
+use std::time::Duration;
+
+use nostr::{EventBuilder, Keys, Metadata};
+use nostr_relay_builder::MockRelay;
+use nostr_sdk::Client;
+use radroots_studio_application::{
+ Clock, InMemorySecretStore, ProfileLoadState, ProfileRepository, RelayConfiguration,
+ RelayConnectionState, SdkNostrClient, SecretStore, SessionState,
+};
+use radroots_studio_domain::{RelayUrl, SecretKeyInput, UnixTimestamp};
+use radroots_studio_storage::PersistentAppCore;
+
+const SECRET_HEX: &str = "7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7";
+
+struct FixedClock;
+
+impl Clock for FixedClock {
+ fn now(&self) -> UnixTimestamp {
+ UnixTimestamp::from_seconds(100).expect("fixed timestamp")
+ }
+}
+
+#[tokio::test]
+async fn local_relay_e2e_imports_activates_refreshes_and_caches_profile() {
+ let local_relay = MockRelay::run().await.expect("local relay");
+ let relay_url = local_relay.url().await;
+ let keys = Keys::parse(SECRET_HEX).expect("known secret key");
+ let publisher = Client::new(keys);
+ publisher
+ .add_relay(relay_url.clone())
+ .await
+ .expect("publisher relay");
+ publisher.connect().await;
+ publisher.wait_for_connection(Duration::from_secs(2)).await;
+ publisher
+ .send_event_builder(EventBuilder::metadata(
+ &Metadata::new()
+ .name("farmer")
+ .display_name("Farm Account")
+ .about("Local food profile"),
+ ))
+ .await
+ .expect("publish profile");
+
+ let relay = RelayUrl::parse(relay_url.as_str()).expect("relay URL");
+ let adapter = PersistentAppCore::in_memory(RelayConfiguration::new(vec![relay]))
+ .expect("persistent adapter");
+ let secrets = InMemorySecretStore::default();
+ adapter.bootstrap(&secrets, &FixedClock).expect("bootstrap");
+ let imported = adapter
+ .import_secret_key(
+ SecretKeyInput::parse(SECRET_HEX.to_owned()).expect("secret input"),
+ &secrets,
+ &FixedClock,
+ )
+ .expect("import account");
+ let public_key = imported.account().public_key();
+ assert!(secrets.contains(public_key).expect("credential exists"));
+ adapter
+ .activate_account(public_key, &secrets, &FixedClock)
+ .expect("activate account");
+
+ let refreshed = adapter
+ .core()
+ .refresh_active_profile(
+ adapter.database(),
+ &SdkNostrClient::new(Duration::from_secs(2)),
+ &FixedClock,
+ )
+ .await
+ .expect("refresh profile");
+
+ assert_eq!(refreshed.session(), SessionState::Active);
+ let active = refreshed.active_account().expect("active account");
+ assert_eq!(active.relay_state(), RelayConnectionState::Connected);
+ assert_eq!(active.profile_state(), ProfileLoadState::Fresh);
+ assert_eq!(
+ active.profile().and_then(|profile| profile.display_name()),
+ Some("Farm Account")
+ );
+ let cached = adapter
+ .database()
+ .load_profile(public_key)
+ .expect("load cache")
+ .expect("cached profile");
+ assert_eq!(
+ cached.candidate().metadata().preferred_name(),
+ Some("Farm Account")
+ );
+ let public_debug = format!("{refreshed:?}");
+ assert!(!public_debug.contains(SECRET_HEX));
+ assert!(!public_debug.contains("nsec1"));
+ publisher.shutdown().await;
+ local_relay.shutdown();
+}
diff --git a/crates/studio_storage/tests/redaction.rs b/crates/studio_storage/tests/redaction.rs
@@ -0,0 +1,52 @@
+use std::fs;
+
+use radroots_studio_application::{AccountOperationKind, AccountRepository, OperationJournal};
+use radroots_studio_domain::{
+ AccountCreatedAt, AccountIdentity, AccountSummary, BindingAvailability, LocalSignerBinding,
+ PublicKey, UnixTimestamp,
+};
+use radroots_studio_storage::Database;
+use tempfile::tempdir;
+
+const SECRET_HEX: &str = "1111111111111111111111111111111111111111111111111111111111111111";
+const SECRET_NSEC: &str = "nsec1vl029mgpspedva04g90vltkh6fvh240zqtv9k0t9af8935ke9laqsnlfe5";
+fn assert_redacted(bytes: &[u8]) {
+ assert!(
+ !bytes
+ .windows(SECRET_HEX.len())
+ .any(|value| value == SECRET_HEX.as_bytes())
+ );
+ assert!(
+ !bytes
+ .windows(SECRET_NSEC.len())
+ .any(|value| value == SECRET_NSEC.as_bytes())
+ );
+ assert!(!bytes.windows(5).any(|value| value == b"nsec1"));
+}
+
+#[test]
+fn redaction_guards_sqlite_schema_and_non_secret_records() {
+ let directory = tempdir().expect("directory");
+ let path = directory.path().join("studio.sqlite3");
+ {
+ let database = Database::open(&path).expect("database");
+ let public_key = PublicKey::from_bytes([2; 32]);
+ let account = AccountSummary::new(
+ AccountIdentity::derive(public_key).expect("identity"),
+ LocalSignerBinding::new(public_key, BindingAvailability::Available),
+ None,
+ AccountCreatedAt::new(UnixTimestamp::from_seconds(1).expect("time")),
+ None,
+ )
+ .expect("account");
+ database.insert_account(&account).expect("account");
+ database
+ .begin_operation(
+ AccountOperationKind::Add,
+ account.public_key(),
+ UnixTimestamp::from_seconds(2).expect("time"),
+ )
+ .expect("journal");
+ }
+ assert_redacted(&fs::read(path).expect("database bytes"));
+}
diff --git a/crates/studio_storage/tests/restart_isolation.rs b/crates/studio_storage/tests/restart_isolation.rs
@@ -0,0 +1,95 @@
+use std::fs;
+
+use radroots_studio_application::{
+ AccountNamespaceRepository, AccountPreferenceKey, Clock, InMemorySecretStore,
+ RelayConfiguration, SessionState,
+};
+use radroots_studio_domain::{SecretKeyInput, UnixTimestamp};
+use radroots_studio_storage::PersistentAppCore;
+use tempfile::tempdir;
+
+const SECRET_A: &str = "7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7";
+const SECRET_B: &str = "0101010101010101010101010101010101010101010101010101010101010101";
+
+struct FixedClock;
+
+impl Clock for FixedClock {
+ fn now(&self) -> UnixTimestamp {
+ UnixTimestamp::from_seconds(200).expect("fixed timestamp")
+ }
+}
+
+#[test]
+fn restart_restores_selection_and_keeps_account_namespaces_isolated() {
+ let directory = tempdir().expect("temporary directory");
+ let path = directory.path().join("studio.sqlite3");
+ let secrets = InMemorySecretStore::default();
+ let (owner_a, owner_b);
+
+ {
+ let adapter = PersistentAppCore::open(&path, RelayConfiguration::default())
+ .expect("persistent adapter");
+ adapter.bootstrap(&secrets, &FixedClock).expect("bootstrap");
+ owner_a = adapter
+ .import_secret_key(
+ SecretKeyInput::parse(SECRET_A.to_owned()).expect("secret A"),
+ &secrets,
+ &FixedClock,
+ )
+ .expect("account A")
+ .account()
+ .public_key();
+ owner_b = adapter
+ .import_secret_key(
+ SecretKeyInput::parse(SECRET_B.to_owned()).expect("secret B"),
+ &secrets,
+ &FixedClock,
+ )
+ .expect("account B")
+ .account()
+ .public_key();
+ adapter
+ .database()
+ .set_value(owner_a, AccountPreferenceKey::NamespaceProbe, "account-a")
+ .expect("namespace A");
+ adapter
+ .database()
+ .set_value(owner_b, AccountPreferenceKey::NamespaceProbe, "account-b")
+ .expect("namespace B");
+ adapter.select_account(owner_b).expect("select B");
+ }
+
+ let reopened =
+ PersistentAppCore::open(&path, RelayConfiguration::default()).expect("reopen adapter");
+ let restored = reopened.bootstrap(&secrets, &FixedClock).expect("restore");
+ assert_eq!(restored.accounts().len(), 2);
+ assert_eq!(restored.selected_account(), Some(owner_b));
+ assert_eq!(restored.session(), SessionState::SignedOut);
+ assert_eq!(
+ reopened
+ .database()
+ .get_value(owner_a, AccountPreferenceKey::NamespaceProbe)
+ .expect("read A"),
+ Some("account-a".to_owned())
+ );
+ assert_eq!(
+ reopened
+ .database()
+ .get_value(owner_b, AccountPreferenceKey::NamespaceProbe)
+ .expect("read B"),
+ Some("account-b".to_owned())
+ );
+
+ let database = fs::read(path).expect("database bytes");
+ assert!(
+ !database
+ .windows(SECRET_A.len())
+ .any(|bytes| bytes == SECRET_A.as_bytes())
+ );
+ assert!(
+ !database
+ .windows(SECRET_B.len())
+ .any(|bytes| bytes == SECRET_B.as_bytes())
+ );
+ assert!(!database.windows(5).any(|bytes| bytes == b"nsec1"));
+}
diff --git a/crates/studio_uniffi_bindgen/Cargo.toml b/crates/studio_uniffi_bindgen/Cargo.toml
@@ -0,0 +1,14 @@
+[package]
+name = "radroots-studio-uniffi-bindgen"
+version.workspace = true
+edition.workspace = true
+rust-version.workspace = true
+license.workspace = true
+repository.workspace = true
+publish = false
+
+[dependencies]
+uniffi = { workspace = true, features = ["cli"] }
+
+[lints]
+workspace = true
diff --git a/crates/studio_uniffi_bindgen/src/main.rs b/crates/studio_uniffi_bindgen/src/main.rs
@@ -0,0 +1,13 @@
+#![doc = "Pinned `UniFFI` binding generator entry point."]
+
+fn main() {
+ uniffi::uniffi_bindgen_main();
+}
+
+#[cfg(test)]
+mod tests {
+ #[test]
+ fn tool_is_available_to_the_workspace() {
+ assert_eq!(env!("CARGO_PKG_NAME"), "radroots-studio-uniffi-bindgen");
+ }
+}
diff --git a/imports/studio_workspace/Cargo.toml b/imports/studio_workspace/Cargo.toml
@@ -0,0 +1,43 @@
+[workspace]
+members = [
+ "crates/application",
+ "crates/domain",
+ "crates/ffi",
+ "crates/nostr",
+ "crates/storage",
+ "tools/uniffi-bindgen",
+]
+resolver = "3"
+
+[workspace.package]
+version = "0.1.0-alpha"
+edition = "2024"
+rust-version = "1.97.1"
+license = "GPL-3.0-only"
+repository = "https://github.com/radroots/studio_app"
+
+[workspace.lints.rust]
+unsafe_code = "forbid"
+
+[workspace.lints.clippy]
+all = "deny"
+pedantic = "deny"
+
+[workspace.dependencies]
+bech32 = "=0.11.1"
+keyring = "=4.1.6"
+directories = "=6.0.0"
+fs2 = "=0.4.3"
+nostr = { git = "https://github.com/rust-nostr/nostr.git", rev = "5bba5163eb77107f82c4a8262cf29d7f33a73219", package = "nostr" }
+nostr-sdk = "=0.44.1"
+nostr-relay-builder = "=0.44.1"
+refinery = { version = "=0.9.2", default-features = false, features = ["rusqlite"] }
+rusqlite = { version = "=0.39.0", features = ["bundled"] }
+secrecy = "=0.10.3"
+url = "=2.5.8"
+zeroize = "=1.9.0"
+tokio = { version = "=1.47.1", features = ["macros", "rt-multi-thread", "sync", "time"] }
+uniffi = "=0.32.0"
+
+[patch.crates-io]
+nostr = { git = "https://github.com/rust-nostr/nostr.git", rev = "5bba5163eb77107f82c4a8262cf29d7f33a73219" }