commit 43f274a1d97f8044c751192548384c897455ba1c
parent 7fdf93c81beeec1fc35ee38b537fea29886729b2
Author: triesap <tyson@radroots.org>
Date: Fri, 7 Aug 2026 00:16:29 +0000
consolidation: remove retired studio implementation source
- remove duplicate domain application adapter ffi and bindgen trees
- retain the exact canonical lib source-lock capsule and compatibility data
- build native ffi and Kotlin bindings from the immutable lib revision
- verify locked Rust checks and the macOS arm64 package matrix
Diffstat:
60 files changed, 0 insertions(+), 15178 deletions(-)
diff --git a/core/Cargo.toml b/core/Cargo.toml
@@ -1,13 +1,5 @@
[workspace]
members = ["crates/source_lock"]
-exclude = [
- "crates/application",
- "crates/domain",
- "crates/ffi",
- "crates/nostr",
- "crates/storage",
- "tools/uniffi-bindgen",
-]
resolver = "3"
[workspace.package]
diff --git a/core/crates/application/Cargo.toml b/core/crates/application/Cargo.toml
@@ -1,22 +0,0 @@
-[package]
-name = "radroots-studio-application"
-version.workspace = true
-edition.workspace = true
-rust-version.workspace = true
-license.workspace = true
-repository.workspace = true
-
-[dependencies]
-nostr.workspace = true
-nostr-sdk.workspace = true
-radroots-studio-domain = { path = "../domain" }
-radroots-studio-nostr = { path = "../nostr" }
-secrecy.workspace = true
-tokio.workspace = true
-
-[dev-dependencies]
-nostr-relay-builder.workspace = true
-tokio = { workspace = true, features = ["macros", "rt-multi-thread", "sync", "time"] }
-
-[lints]
-workspace = true
diff --git a/core/crates/application/src/accounts.rs b/core/crates/application/src/accounts.rs
@@ -1,1412 +0,0 @@
-use std::sync::{Mutex, MutexGuard};
-
-use radroots_studio_domain::{
- AccountCreatedAt, AccountIdentity, AccountSummary, BindingAvailability, LocalSignerBinding,
- Nsec, PublicKey, SafeError, SafeErrorCode, SafeMessage, SecretKeyInput,
-};
-use radroots_studio_nostr::{generate_local_keypair, import_secret};
-
-use crate::{
- AccountOperationKind, AccountOperationPhase, AccountRepository, AppCore, AppStateRepository,
- Clock, DurableOperationKind, DurableOperationPhase, DurableOperationRepository,
- DurableOperationStart, DurableRequestId, DurableTerminalOutcome, OperationDiagnostic,
- OperationId, OperationJournal, OperationPriorState, PendingAccountOperation,
- RemovalConfirmationToken, SecretStore, StagedGeneratedKey, StateTransition,
-};
-
-pub struct GenerateAccountReceipt {
- account: AccountSummary,
- generated_nsec: Nsec,
-}
-
-#[derive(Clone, Debug, Eq, PartialEq)]
-pub struct ImportAccountReceipt {
- account: AccountSummary,
-}
-
-impl ImportAccountReceipt {
- #[must_use]
- pub const fn account(&self) -> &AccountSummary {
- &self.account
- }
-}
-
-impl GenerateAccountReceipt {
- #[must_use]
- pub const fn account(&self) -> &AccountSummary {
- &self.account
- }
-
- #[must_use]
- pub const fn generated_nsec(&self) -> &Nsec {
- &self.generated_nsec
- }
-}
-
-impl AppCore {
- /// Commits a staged generated key only after its recovery acknowledgement.
- ///
- /// # Errors
- ///
- /// Returns a safe conflict, keyring, persistence, or recovery error.
- #[allow(clippy::too_many_arguments)]
- pub fn commit_staged_generated_key(
- &self,
- request_id: &DurableRequestId,
- staged: StagedGeneratedKey,
- accounts: &(impl AccountRepository + ?Sized),
- app_state: &(impl AppStateRepository + ?Sized),
- secrets: &(impl SecretStore + ?Sized),
- operations: &(impl DurableOperationRepository + ?Sized),
- clock: &(impl Clock + ?Sized),
- ) -> Result<ImportAccountReceipt, SafeError> {
- let expected_revision = staged.expected_revision();
- self.require_revision(expected_revision)?;
- let (account, secret) = staged.into_commit_parts();
- self.persist_account_durable(
- request_id,
- DurableOperationKind::Create,
- expected_revision,
- &account,
- secret,
- None,
- accounts,
- app_state,
- secrets,
- operations,
- clock,
- )?;
- Ok(ImportAccountReceipt { account })
- }
-
- /// Generates and commits one account under a durable caller request.
- ///
- /// # Errors
- ///
- /// Returns a safe conflict, keyring, persistence, or state error. Staged recovery transport
- /// replaces this transitional generated-secret receipt in the custody phase.
- #[allow(clippy::too_many_arguments)]
- pub fn generate_account_durable(
- &self,
- request_id: &DurableRequestId,
- expected_revision: u64,
- accounts: &(impl AccountRepository + ?Sized),
- app_state: &(impl AppStateRepository + ?Sized),
- secrets: &(impl SecretStore + ?Sized),
- operations: &(impl DurableOperationRepository + ?Sized),
- clock: &(impl Clock + ?Sized),
- ) -> Result<GenerateAccountReceipt, SafeError> {
- self.require_revision(expected_revision)?;
- let generated = generate_local_keypair()?;
- let (public_key, npub, secret, nsec) = generated.into_parts();
- let account = AccountSummary::new(
- AccountIdentity::verify(public_key, npub.as_str().to_owned())?,
- LocalSignerBinding::new(public_key, BindingAvailability::Available),
- None,
- AccountCreatedAt::new(clock.now()),
- None,
- )?;
- self.persist_account_durable(
- request_id,
- DurableOperationKind::Create,
- expected_revision,
- &account,
- secret,
- None,
- accounts,
- app_state,
- secrets,
- operations,
- clock,
- )?;
- Ok(GenerateAccountReceipt {
- account,
- generated_nsec: nsec,
- })
- }
-
- /// Imports or explicitly repairs one local account under a durable caller request.
- ///
- /// # Errors
- ///
- /// Returns a safe conflict, validation, keyring, persistence, or state error.
- #[allow(clippy::too_many_arguments)]
- pub fn import_secret_key_durable(
- &self,
- request_id: &DurableRequestId,
- expected_revision: u64,
- input: SecretKeyInput,
- accounts: &(impl AccountRepository + ?Sized),
- app_state: &(impl AppStateRepository + ?Sized),
- secrets: &(impl SecretStore + ?Sized),
- operations: &(impl DurableOperationRepository + ?Sized),
- clock: &(impl Clock + ?Sized),
- ) -> Result<ImportAccountReceipt, SafeError> {
- if let Some(existing) = operations.load_durable_operation(request_id)? {
- return if existing
- .terminal()
- .is_some_and(|receipt| receipt.outcome() == DurableTerminalOutcome::Completed)
- {
- accounts
- .find_account(existing.account())?
- .map(|account| ImportAccountReceipt { account })
- .ok_or_else(recovery_required)
- } else {
- Err(recovery_required())
- };
- }
- self.require_revision(expected_revision)?;
- let imported = import_secret(input)?;
- let (public_key, npub, secret) = imported.into_parts();
- let previous = accounts.find_account(public_key)?;
- if let Some(existing) = &previous
- && (existing.signer().availability() != BindingAvailability::CredentialMissing
- || secrets.contains(public_key)?)
- {
- return Err(account_exists());
- }
- if previous.is_none() && secrets.contains(public_key)? {
- return Err(account_exists());
- }
- let account = if let Some(existing) = &previous {
- existing.with_binding_availability(BindingAvailability::Available)
- } else {
- AccountSummary::new(
- AccountIdentity::verify(public_key, npub.as_str().to_owned())?,
- LocalSignerBinding::new(public_key, BindingAvailability::Available),
- None,
- AccountCreatedAt::new(clock.now()),
- None,
- )?
- };
- let kind = if previous.is_some() {
- DurableOperationKind::Repair
- } else {
- DurableOperationKind::Import
- };
- self.persist_account_durable(
- request_id,
- kind,
- expected_revision,
- &account,
- secret,
- previous.as_ref(),
- accounts,
- app_state,
- secrets,
- operations,
- clock,
- )?;
- Ok(ImportAccountReceipt { account })
- }
-
- fn require_revision(&self, expected_revision: u64) -> Result<(), SafeError> {
- if self.snapshot().revision().value() != expected_revision {
- return Err(operation_conflict());
- }
- Ok(())
- }
-
- #[allow(clippy::too_many_arguments)]
- fn persist_account_durable(
- &self,
- request_id: &DurableRequestId,
- kind: DurableOperationKind,
- expected_revision: u64,
- account: &AccountSummary,
- secret: SecretKeyInput,
- previous: Option<&AccountSummary>,
- accounts: &(impl AccountRepository + ?Sized),
- app_state: &(impl AppStateRepository + ?Sized),
- secrets: &(impl SecretStore + ?Sized),
- operations: &(impl DurableOperationRepository + ?Sized),
- clock: &(impl Clock + ?Sized),
- ) -> Result<(), SafeError> {
- let prior = OperationPriorState::new(
- app_state.load_selected_account()?,
- previous.map(|account| account.signer().availability()),
- );
- match operations.begin_durable_operation(
- request_id,
- kind,
- account.public_key(),
- Some(expected_revision),
- prior,
- clock.now(),
- )? {
- DurableOperationStart::Started(_) => {}
- DurableOperationStart::Existing(operation) => {
- return if operation
- .terminal()
- .is_some_and(|receipt| receipt.outcome() == DurableTerminalOutcome::Completed)
- {
- Ok(())
- } else {
- Err(recovery_required())
- };
- }
- }
- secrets.put(account.public_key(), secret)?;
- operations.advance_durable_operation(
- request_id,
- DurableOperationPhase::IntentRecorded,
- DurableOperationPhase::CredentialWritten,
- clock.now(),
- None,
- )?;
- previous.map_or_else(
- || accounts.insert_account(account),
- |_| accounts.update_account(account),
- )?;
- operations.advance_durable_operation(
- request_id,
- DurableOperationPhase::CredentialWritten,
- DurableOperationPhase::MetadataCommitted,
- clock.now(),
- None,
- )?;
- app_state.save_selected_account(Some(account.public_key()))?;
- operations.advance_durable_operation(
- request_id,
- DurableOperationPhase::MetadataCommitted,
- DurableOperationPhase::SelectionCommitted,
- clock.now(),
- None,
- )?;
- let snapshot = self.apply_transition(StateTransition::ReplaceRegistry {
- accounts: accounts.list_accounts()?,
- selected: Some(account.public_key()),
- })?;
- operations.finalize_durable_operation(
- request_id,
- DurableOperationPhase::SelectionCommitted,
- DurableTerminalOutcome::Completed,
- Some(snapshot.revision().value()),
- clock.now(),
- )?;
- Ok(())
- }
-
- /// Issues a single-use confirmation bound to the target and current revision.
- ///
- /// # Errors
- ///
- /// Returns a safe account or application-state error.
- pub fn request_account_removal(
- &self,
- public_key: PublicKey,
- clock: &(impl Clock + ?Sized),
- ) -> Result<RemovalConfirmationToken, SafeError> {
- self.issue_removal_token(public_key, clock.now())
- }
-
- pub fn cancel_account_removal(&self, token: RemovalConfirmationToken) -> bool {
- self.cancel_removal_token(token)
- }
-
- /// Permanently removes a confirmed account and selects a deterministic fallback.
- ///
- /// # Errors
- ///
- /// Returns a safe confirmation, credential, persistence, recovery, or state error.
- pub fn confirm_account_removal(
- &self,
- token: RemovalConfirmationToken,
- accounts: &(impl AccountRepository + ?Sized),
- app_state: &(impl AppStateRepository + ?Sized),
- secrets: &(impl SecretStore + ?Sized),
- journal: &(impl OperationJournal + ?Sized),
- clock: &(impl Clock + ?Sized),
- ) -> Result<crate::AppSnapshot, SafeError> {
- let public_key = self.consume_removal_token(token, clock.now())?;
- let registry = accounts.list_accounts()?;
- let index = registry
- .iter()
- .position(|account| account.public_key() == public_key)
- .ok_or_else(account_not_found)?;
- let selected = if self.snapshot().selected_account() == Some(public_key) {
- registry
- .get(index + 1)
- .or_else(|| index.checked_sub(1).and_then(|before| registry.get(before)))
- .map(AccountSummary::public_key)
- } else {
- self.snapshot().selected_account()
- };
- let operation =
- journal.begin_operation(AccountOperationKind::Remove, public_key, clock.now())?;
- let was_active = self
- .snapshot()
- .active_account()
- .is_some_and(|active| active.account().public_key() == public_key);
- if was_active {
- self.sign_out()?;
- }
- let account = ®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/core/crates/application/src/actor.rs b/core/crates/application/src/actor.rs
@@ -1,785 +0,0 @@
-use std::num::{NonZeroU64, NonZeroUsize};
-use std::time::Instant;
-
-use radroots_studio_domain::{
- AccountIdentity, BindingAvailability, LocalSignerBinding, PublicKey, SafeError, SafeErrorCode,
- SafeMessage,
-};
-use tokio::sync::{mpsc, oneshot};
-
-use crate::SnapshotRevision;
-
-#[derive(Clone, Copy, Debug, Default, Eq, Hash, Ord, PartialEq, PartialOrd)]
-pub struct SessionGeneration(u64);
-
-impl SessionGeneration {
- #[must_use]
- pub const fn initial() -> Self {
- Self(0)
- }
-
- #[must_use]
- pub const fn from_value(value: u64) -> Self {
- Self(value)
- }
-
- #[must_use]
- pub const fn value(self) -> u64 {
- self.0
- }
-
- #[must_use]
- pub const fn next(self) -> Option<Self> {
- match self.0.checked_add(1) {
- Some(value) => Some(Self(value)),
- None => None,
- }
- }
-}
-
-#[derive(Clone, Debug, Eq, PartialEq)]
-pub struct ForegroundSessionBinding {
- identity: AccountIdentity,
- signer: LocalSignerBinding,
- generation: SessionGeneration,
-}
-
-impl ForegroundSessionBinding {
- /// Binds one foreground session to a ready local signer and generation.
- ///
- /// # Errors
- ///
- /// Returns a safe state error when account and binding differ or when the
- /// signer is unavailable.
- pub fn new(
- identity: AccountIdentity,
- signer: LocalSignerBinding,
- generation: SessionGeneration,
- ) -> Result<Self, SafeError> {
- if identity.public_key() != signer.account()
- || signer.availability() != BindingAvailability::Available
- {
- return Err(invalid_foreground_session());
- }
- Ok(Self {
- identity,
- signer,
- generation,
- })
- }
-
- #[must_use]
- pub const fn identity(&self) -> &AccountIdentity {
- &self.identity
- }
-
- #[must_use]
- pub const fn signer(&self) -> LocalSignerBinding {
- self.signer
- }
-
- #[must_use]
- pub const fn generation(&self) -> SessionGeneration {
- self.generation
- }
-}
-
-const fn invalid_foreground_session() -> SafeError {
- SafeError::new(
- SafeErrorCode::InvalidApplicationState,
- SafeMessage::new("The foreground session binding is invalid."),
- )
-}
-
-#[derive(Clone, Copy, Debug, Eq, PartialEq)]
-pub struct TaskCorrelation {
- request_id: RequestId,
- account: PublicKey,
- binding: LocalSignerBinding,
- expected_revision: SnapshotRevision,
- session_generation: SessionGeneration,
-}
-
-impl TaskCorrelation {
- #[must_use]
- pub const fn new(
- request_id: RequestId,
- account: PublicKey,
- binding: LocalSignerBinding,
- expected_revision: SnapshotRevision,
- session_generation: SessionGeneration,
- ) -> Self {
- Self {
- request_id,
- account,
- binding,
- expected_revision,
- session_generation,
- }
- }
-
- #[must_use]
- pub const fn request_id(self) -> RequestId {
- self.request_id
- }
-
- #[must_use]
- pub const fn account(self) -> PublicKey {
- self.account
- }
-
- #[must_use]
- pub const fn binding(self) -> LocalSignerBinding {
- self.binding
- }
-
- #[must_use]
- pub const fn expected_revision(self) -> SnapshotRevision {
- self.expected_revision
- }
-
- #[must_use]
- pub const fn session_generation(self) -> SessionGeneration {
- self.session_generation
- }
-}
-
-#[derive(Clone, Copy, Debug, Eq, PartialEq)]
-pub enum RuntimeLifecycle {
- Opening,
- CompatibilityChecking,
- AcquiringOwnership,
- Migrating,
- Recovering,
- Ready,
- Degraded(SafeError),
- Blocked(SafeError),
- ShuttingDown,
- Closed,
- Fatal(SafeError),
-}
-
-#[derive(Clone, Copy, Debug, Eq, PartialEq)]
-pub enum RuntimeCommandClass {
- Observe,
- MutateLocalState,
- UseCredential,
- UseRelay,
- RetryOpening,
- Shutdown,
-}
-
-#[derive(Clone, Copy, Debug, Eq, PartialEq)]
-pub struct LifecycleGate {
- lifecycle: RuntimeLifecycle,
-}
-
-impl Default for LifecycleGate {
- fn default() -> Self {
- Self::opening()
- }
-}
-
-impl LifecycleGate {
- #[must_use]
- pub const fn opening() -> Self {
- Self {
- lifecycle: RuntimeLifecycle::Opening,
- }
- }
-
- #[must_use]
- pub const fn lifecycle(self) -> RuntimeLifecycle {
- self.lifecycle
- }
-
- #[must_use]
- pub const fn allows(self, command: RuntimeCommandClass) -> bool {
- match self.lifecycle {
- RuntimeLifecycle::Opening
- | RuntimeLifecycle::CompatibilityChecking
- | RuntimeLifecycle::AcquiringOwnership
- | RuntimeLifecycle::Migrating
- | RuntimeLifecycle::Recovering => {
- matches!(
- command,
- RuntimeCommandClass::Observe | RuntimeCommandClass::Shutdown
- )
- }
- RuntimeLifecycle::Ready => !matches!(command, RuntimeCommandClass::RetryOpening),
- RuntimeLifecycle::Degraded(_) => !matches!(
- command,
- RuntimeCommandClass::UseRelay | RuntimeCommandClass::RetryOpening
- ),
- RuntimeLifecycle::Blocked(_) => matches!(
- command,
- RuntimeCommandClass::Observe
- | RuntimeCommandClass::RetryOpening
- | RuntimeCommandClass::Shutdown
- ),
- RuntimeLifecycle::ShuttingDown => matches!(command, RuntimeCommandClass::Observe),
- RuntimeLifecycle::Closed | RuntimeLifecycle::Fatal(_) => false,
- }
- }
-
- /// Advances the required open sequence to compatibility checking.
- ///
- /// # Errors
- ///
- /// Returns a safe lifecycle error when the stage is out of order.
- pub fn begin_compatibility_check(&mut self) -> Result<(), SafeError> {
- self.advance(
- RuntimeLifecycle::Opening,
- RuntimeLifecycle::CompatibilityChecking,
- )
- }
-
- /// Records compatibility acceptance and begins ownership acquisition.
- ///
- /// # Errors
- ///
- /// Returns a safe lifecycle error when the stage is out of order.
- pub fn compatibility_accepted(&mut self) -> Result<(), SafeError> {
- self.advance(
- RuntimeLifecycle::CompatibilityChecking,
- RuntimeLifecycle::AcquiringOwnership,
- )
- }
-
- /// Records exclusive ownership and begins migration.
- ///
- /// # Errors
- ///
- /// Returns a safe lifecycle error when the stage is out of order.
- pub fn ownership_acquired(&mut self) -> Result<(), SafeError> {
- self.advance(
- RuntimeLifecycle::AcquiringOwnership,
- RuntimeLifecycle::Migrating,
- )
- }
-
- /// Records migration completion and begins recovery.
- ///
- /// # Errors
- ///
- /// Returns a safe lifecycle error when the stage is out of order.
- pub fn migration_complete(&mut self) -> Result<(), SafeError> {
- self.advance(RuntimeLifecycle::Migrating, RuntimeLifecycle::Recovering)
- }
-
- /// Records recovery completion and admits normal commands.
- ///
- /// # Errors
- ///
- /// Returns a safe lifecycle error when the stage is out of order.
- pub fn recovery_complete(&mut self) -> Result<(), SafeError> {
- self.advance(RuntimeLifecycle::Recovering, RuntimeLifecycle::Ready)
- }
-
- pub fn block(&mut self, error: SafeError) {
- self.lifecycle = RuntimeLifecycle::Blocked(error);
- }
-
- pub fn fail(&mut self, error: SafeError) {
- self.lifecycle = RuntimeLifecycle::Fatal(error);
- }
-
- /// Moves a ready runtime into a nonfatal degraded state.
- ///
- /// # Errors
- ///
- /// Returns a safe lifecycle error when the runtime is not ready.
- pub fn degrade(&mut self, error: SafeError) -> Result<(), SafeError> {
- self.advance(RuntimeLifecycle::Ready, RuntimeLifecycle::Degraded(error))
- }
-
- /// Restores local and relay command availability after degradation.
- ///
- /// # Errors
- ///
- /// Returns a safe lifecycle error when the runtime is not degraded.
- pub fn restore_ready(&mut self) -> Result<(), SafeError> {
- if !matches!(self.lifecycle, RuntimeLifecycle::Degraded(_)) {
- return Err(invalid_lifecycle_transition());
- }
- self.lifecycle = RuntimeLifecycle::Ready;
- Ok(())
- }
-
- /// Begins actor-owned shutdown.
- ///
- /// # Errors
- ///
- /// Returns a safe lifecycle error after shutdown or close has begun.
- pub fn begin_shutdown(&mut self) -> Result<(), SafeError> {
- if matches!(
- self.lifecycle,
- RuntimeLifecycle::ShuttingDown | RuntimeLifecycle::Closed
- ) {
- return Err(invalid_lifecycle_transition());
- }
- self.lifecycle = RuntimeLifecycle::ShuttingDown;
- Ok(())
- }
-
- /// Completes actor-owned shutdown.
- ///
- /// # Errors
- ///
- /// Returns a safe lifecycle error unless shutdown already began.
- pub fn finish_shutdown(&mut self) -> Result<(), SafeError> {
- self.advance(RuntimeLifecycle::ShuttingDown, RuntimeLifecycle::Closed)
- }
-
- fn advance(
- &mut self,
- expected: RuntimeLifecycle,
- next: RuntimeLifecycle,
- ) -> Result<(), SafeError> {
- if self.lifecycle != expected {
- return Err(invalid_lifecycle_transition());
- }
- self.lifecycle = next;
- Ok(())
- }
-}
-
-const fn invalid_lifecycle_transition() -> SafeError {
- SafeError::new(
- radroots_studio_domain::SafeErrorCode::InvalidApplicationState,
- radroots_studio_domain::SafeMessage::new("The runtime lifecycle transition is invalid."),
- )
-}
-
-#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
-pub struct RequestId(NonZeroU64);
-
-impl RequestId {
- #[must_use]
- pub const fn new(value: u64) -> Option<Self> {
- match NonZeroU64::new(value) {
- Some(value) => Some(Self(value)),
- None => None,
- }
- }
-
- #[must_use]
- pub const fn get(self) -> u64 {
- self.0.get()
- }
-}
-
-#[derive(Clone, Copy, Debug, Eq, PartialEq)]
-pub struct CommandContext {
- request_id: RequestId,
- expected_revision: Option<SnapshotRevision>,
- deadline: Instant,
-}
-
-impl CommandContext {
- #[must_use]
- pub const fn new(
- request_id: RequestId,
- expected_revision: Option<SnapshotRevision>,
- deadline: Instant,
- ) -> Self {
- Self {
- request_id,
- expected_revision,
- deadline,
- }
- }
-
- #[must_use]
- pub const fn request_id(self) -> RequestId {
- self.request_id
- }
-
- #[must_use]
- pub const fn expected_revision(self) -> Option<SnapshotRevision> {
- self.expected_revision
- }
-
- #[must_use]
- pub const fn deadline(self) -> Instant {
- self.deadline
- }
-
- #[must_use]
- pub fn is_expired(self, now: Instant) -> bool {
- now >= self.deadline
- }
-}
-
-#[derive(Clone, Copy, Debug, Eq, PartialEq)]
-pub enum CommandRejection {
- MailboxSaturated,
-}
-
-#[derive(Clone, Debug, Eq, PartialEq)]
-pub enum CommandResult<T> {
- Completed(T),
- Rejected(CommandRejection),
- Conflicted { current_revision: SnapshotRevision },
- TimedOut,
- Closed,
- Failed(SafeError),
-}
-
-#[derive(Clone, Debug, Eq, PartialEq)]
-pub struct CommandReceipt<T> {
- request_id: RequestId,
- result: CommandResult<T>,
-}
-
-impl<T> CommandReceipt<T> {
- #[must_use]
- pub const fn new(request_id: RequestId, result: CommandResult<T>) -> Self {
- Self { request_id, result }
- }
-
- #[must_use]
- pub const fn request_id(&self) -> RequestId {
- self.request_id
- }
-
- #[must_use]
- pub const fn result(&self) -> &CommandResult<T> {
- &self.result
- }
-
- #[must_use]
- pub fn into_result(self) -> CommandResult<T> {
- self.result
- }
-}
-
-pub struct CommandTicket<T> {
- request_id: RequestId,
- receiver: oneshot::Receiver<CommandReceipt<T>>,
-}
-
-impl<T> CommandTicket<T> {
- #[must_use]
- pub const fn request_id(&self) -> RequestId {
- self.request_id
- }
-
- pub async fn receipt(self) -> CommandReceipt<T> {
- self.receiver
- .await
- .unwrap_or_else(|_| CommandReceipt::new(self.request_id, CommandResult::Closed))
- }
-}
-
-pub enum CommandSubmission<T> {
- Accepted(CommandTicket<T>),
- Rejected(CommandReceipt<T>),
-}
-
-impl<T> CommandSubmission<T> {
- #[must_use]
- pub const fn request_id(&self) -> RequestId {
- match self {
- Self::Accepted(ticket) => ticket.request_id(),
- Self::Rejected(receipt) => receipt.request_id(),
- }
- }
-}
-
-pub struct CommandEnvelope<C, R> {
- context: CommandContext,
- command: C,
- reply: oneshot::Sender<CommandReceipt<R>>,
-}
-
-impl<C, R> CommandEnvelope<C, R> {
- #[must_use]
- pub const fn context(&self) -> CommandContext {
- self.context
- }
-
- #[must_use]
- pub const fn command(&self) -> &C {
- &self.command
- }
-
- #[must_use]
- pub fn into_parts(self) -> (CommandContext, C, oneshot::Sender<CommandReceipt<R>>) {
- (self.context, self.command, self.reply)
- }
-}
-
-pub struct ActorMailbox<C, R> {
- sender: mpsc::Sender<CommandEnvelope<C, R>>,
-}
-
-impl<C, R> Clone for ActorMailbox<C, R> {
- fn clone(&self) -> Self {
- Self {
- sender: self.sender.clone(),
- }
- }
-}
-
-impl<C, R> ActorMailbox<C, R> {
- #[must_use]
- pub fn bounded(capacity: NonZeroUsize) -> (Self, mpsc::Receiver<CommandEnvelope<C, R>>) {
- let (sender, receiver) = mpsc::channel(capacity.get());
- (Self { sender }, receiver)
- }
-
- #[must_use]
- pub fn available_capacity(&self) -> usize {
- self.sender.capacity()
- }
-
- #[must_use]
- pub fn submit(&self, context: CommandContext, command: C) -> CommandSubmission<R> {
- let request_id = context.request_id();
- if context.is_expired(Instant::now()) {
- return CommandSubmission::Rejected(CommandReceipt::new(
- request_id,
- CommandResult::TimedOut,
- ));
- }
- let (reply, receiver) = oneshot::channel();
- let envelope = CommandEnvelope {
- context,
- command,
- reply,
- };
- match self.sender.try_send(envelope) {
- Ok(()) => CommandSubmission::Accepted(CommandTicket {
- request_id,
- receiver,
- }),
- Err(mpsc::error::TrySendError::Full(_)) => {
- CommandSubmission::Rejected(CommandReceipt::new(
- request_id,
- CommandResult::Rejected(CommandRejection::MailboxSaturated),
- ))
- }
- Err(mpsc::error::TrySendError::Closed(_)) => {
- CommandSubmission::Rejected(CommandReceipt::new(request_id, CommandResult::Closed))
- }
- }
- }
-}
-
-#[cfg(test)]
-mod tests {
- use std::num::NonZeroUsize;
- use std::time::{Duration, Instant};
-
- use radroots_studio_domain::{
- AccountIdentity, BindingAvailability, LocalSignerBinding, PublicKey,
- };
-
- use crate::{
- ActorMailbox, CommandContext, CommandReceipt, CommandRejection, CommandResult,
- CommandSubmission, ForegroundSessionBinding, LifecycleGate, RequestId, RuntimeCommandClass,
- RuntimeLifecycle, SessionGeneration,
- };
-
- fn context(id: u64) -> CommandContext {
- CommandContext::new(
- RequestId::new(id).expect("nonzero request"),
- None,
- Instant::now() + Duration::from_secs(1),
- )
- }
-
- #[test]
- fn foreground_session_requires_matching_available_binding_and_generation() {
- let public_key = PublicKey::from_bytes([3_u8; 32]);
- let identity = AccountIdentity::derive(public_key).expect("identity");
- let generation = SessionGeneration::from_value(4);
- let session = ForegroundSessionBinding::new(
- identity.clone(),
- LocalSignerBinding::new(public_key, BindingAvailability::Available),
- generation,
- )
- .expect("session");
- assert_eq!(session.identity(), &identity);
- assert_eq!(session.signer().account(), public_key);
- assert_eq!(session.generation(), generation);
-
- let correlation = super::TaskCorrelation::new(
- RequestId::new(8).expect("request"),
- public_key,
- session.signer(),
- crate::SnapshotRevision::from_value(9),
- generation,
- );
- assert_eq!(correlation.request_id().get(), 8);
- assert_eq!(correlation.account(), public_key);
- assert_eq!(correlation.binding(), session.signer());
- assert_eq!(correlation.expected_revision().value(), 9);
- assert_eq!(correlation.session_generation(), generation);
-
- assert!(
- ForegroundSessionBinding::new(
- identity.clone(),
- LocalSignerBinding::new(
- PublicKey::from_bytes([4_u8; 32]),
- BindingAvailability::Available,
- ),
- generation,
- )
- .is_err()
- );
- assert!(
- ForegroundSessionBinding::new(
- identity,
- LocalSignerBinding::new(public_key, BindingAvailability::CredentialMissing),
- generation,
- )
- .is_err()
- );
- }
-
- #[tokio::test]
- async fn bounded_mailbox_accepts_one_and_rejects_saturation() {
- let (mailbox, mut receiver) =
- ActorMailbox::<u8, u8>::bounded(NonZeroUsize::new(1).expect("capacity"));
- let CommandSubmission::Accepted(ticket) = mailbox.submit(context(1), 7) else {
- panic!("first command must be accepted");
- };
- let CommandSubmission::Rejected(rejected) = mailbox.submit(context(2), 8) else {
- panic!("second command must be rejected");
- };
- assert_eq!(
- rejected.into_result(),
- CommandResult::Rejected(CommandRejection::MailboxSaturated)
- );
-
- let envelope = receiver.recv().await.expect("command");
- assert_eq!(envelope.context().request_id().get(), 1);
- assert_eq!(*envelope.command(), 7);
- let (context, command, reply) = envelope.into_parts();
- reply
- .send(CommandReceipt::new(
- context.request_id(),
- CommandResult::Completed(command + 1),
- ))
- .expect("ticket remains open");
- assert_eq!(
- ticket.receipt().await.into_result(),
- CommandResult::Completed(8)
- );
- }
-
- #[test]
- fn expired_and_closed_mailboxes_reject_without_enqueuing() {
- let (mailbox, receiver) =
- ActorMailbox::<u8, u8>::bounded(NonZeroUsize::new(1).expect("capacity"));
- let expired =
- CommandContext::new(RequestId::new(1).expect("request"), None, Instant::now());
- let CommandSubmission::Rejected(receipt) = mailbox.submit(expired, 1) else {
- panic!("expired command must be rejected");
- };
- assert_eq!(receipt.into_result(), CommandResult::TimedOut);
-
- drop(receiver);
- let CommandSubmission::Rejected(receipt) = mailbox.submit(context(2), 2) else {
- panic!("closed mailbox must be rejected");
- };
- assert_eq!(receipt.into_result(), CommandResult::Closed);
- }
-
- #[tokio::test]
- async fn dropped_actor_reply_becomes_closed_receipt() {
- let (mailbox, mut receiver) =
- ActorMailbox::<u8, u8>::bounded(NonZeroUsize::new(1).expect("capacity"));
- let CommandSubmission::Accepted(ticket) = mailbox.submit(context(1), 1) else {
- panic!("command must be accepted");
- };
- drop(receiver.recv().await.expect("command"));
-
- assert_eq!(ticket.receipt().await.into_result(), CommandResult::Closed);
- }
-
- #[test]
- fn opening_sequence_gates_mutation_until_recovery_completes() {
- let mut lifecycle = LifecycleGate::opening();
- for expected in [
- RuntimeLifecycle::Opening,
- RuntimeLifecycle::CompatibilityChecking,
- RuntimeLifecycle::AcquiringOwnership,
- RuntimeLifecycle::Migrating,
- RuntimeLifecycle::Recovering,
- ] {
- assert_eq!(lifecycle.lifecycle(), expected);
- assert!(lifecycle.allows(RuntimeCommandClass::Observe));
- assert!(lifecycle.allows(RuntimeCommandClass::Shutdown));
- assert!(!lifecycle.allows(RuntimeCommandClass::MutateLocalState));
- match expected {
- RuntimeLifecycle::Opening => {
- lifecycle
- .begin_compatibility_check()
- .expect("compatibility");
- }
- RuntimeLifecycle::CompatibilityChecking => {
- lifecycle
- .compatibility_accepted()
- .expect("compatibility accepted");
- }
- RuntimeLifecycle::AcquiringOwnership => {
- lifecycle.ownership_acquired().expect("ownership");
- }
- RuntimeLifecycle::Migrating => {
- lifecycle.migration_complete().expect("migration");
- }
- RuntimeLifecycle::Recovering => {
- lifecycle.recovery_complete().expect("recovery");
- }
- _ => unreachable!("opening states only"),
- }
- }
- assert_eq!(lifecycle.lifecycle(), RuntimeLifecycle::Ready);
- assert!(lifecycle.allows(RuntimeCommandClass::MutateLocalState));
- assert!(lifecycle.allows(RuntimeCommandClass::UseCredential));
- assert!(lifecycle.allows(RuntimeCommandClass::UseRelay));
- }
-
- #[test]
- fn blocked_degraded_fatal_and_closed_states_fail_safe() {
- let problem = radroots_studio_domain::SafeError::new(
- radroots_studio_domain::SafeErrorCode::StorageUnavailable,
- radroots_studio_domain::SafeMessage::new("The runtime is unavailable."),
- );
- let mut blocked = LifecycleGate::opening();
- blocked.block(problem);
- assert!(blocked.allows(RuntimeCommandClass::RetryOpening));
- assert!(!blocked.allows(RuntimeCommandClass::MutateLocalState));
-
- let mut degraded = LifecycleGate::opening();
- degraded.begin_compatibility_check().expect("compatibility");
- degraded.compatibility_accepted().expect("accepted");
- degraded.ownership_acquired().expect("ownership");
- degraded.migration_complete().expect("migration");
- degraded.recovery_complete().expect("recovery");
- degraded.degrade(problem).expect("degraded");
- assert!(degraded.allows(RuntimeCommandClass::MutateLocalState));
- assert!(!degraded.allows(RuntimeCommandClass::UseRelay));
- degraded.restore_ready().expect("restored");
-
- let mut fatal = LifecycleGate::opening();
- fatal.fail(problem);
- assert!(!fatal.allows(RuntimeCommandClass::Observe));
- fatal.begin_shutdown().expect("fatal can close");
- fatal.finish_shutdown().expect("closed");
- assert_eq!(fatal.lifecycle(), RuntimeLifecycle::Closed);
- assert!(!fatal.allows(RuntimeCommandClass::Shutdown));
- }
-
- #[test]
- fn opening_stages_reject_out_of_order_and_repeated_transitions() {
- let mut lifecycle = LifecycleGate::opening();
- assert!(lifecycle.migration_complete().is_err());
- lifecycle.begin_compatibility_check().expect("first stage");
- assert!(lifecycle.begin_compatibility_check().is_err());
- assert!(lifecycle.recovery_complete().is_err());
- }
-}
diff --git a/core/crates/application/src/app_core.rs b/core/crates/application/src/app_core.rs
@@ -1,300 +0,0 @@
-use std::collections::BTreeMap;
-use std::sync::{Mutex, MutexGuard};
-
-use radroots_studio_domain::{PublicKey, SafeError, SafeErrorCode, SafeMessage, UnixTimestamp};
-
-use crate::{
- AccountRepository, AppSnapshot, AppStateRepository, RelayConfiguration, SnapshotRevision,
- StateMachine, StateTransition,
-};
-
-pub struct RemovalConfirmationToken {
- id: u64,
- public_key: PublicKey,
- revision: SnapshotRevision,
- expires_at: UnixTimestamp,
- impact: RemovalImpact,
-}
-
-#[derive(Clone, Copy, Debug, Eq, PartialEq)]
-pub struct RemovalImpact {
- deletes_local_credential: bool,
- signs_out: bool,
-}
-
-impl RemovalImpact {
- #[must_use]
- pub const fn deletes_local_credential(self) -> bool {
- self.deletes_local_credential
- }
- #[must_use]
- pub const fn signs_out(self) -> bool {
- self.signs_out
- }
-}
-
-impl RemovalConfirmationToken {
- #[must_use]
- pub const fn public_key(&self) -> PublicKey {
- self.public_key
- }
- #[must_use]
- pub const fn revision(&self) -> SnapshotRevision {
- self.revision
- }
- #[must_use]
- pub const fn expires_at(&self) -> UnixTimestamp {
- self.expires_at
- }
- #[must_use]
- pub const fn impact(&self) -> RemovalImpact {
- self.impact
- }
-}
-
-#[derive(Clone, Copy)]
-struct RemovalTokenState {
- public_key: PublicKey,
- revision: SnapshotRevision,
- expires_at: UnixTimestamp,
- impact: RemovalImpact,
-}
-
-struct CoreState {
- state_machine: StateMachine,
- removal_tokens: BTreeMap<u64, RemovalTokenState>,
- next_removal_token: u64,
-}
-
-pub struct AppCore {
- relay_configuration: RelayConfiguration,
- state: Mutex<CoreState>,
-}
-
-impl AppCore {
- #[must_use]
- pub fn in_memory(relay_configuration: RelayConfiguration) -> Self {
- Self {
- relay_configuration,
- state: Mutex::new(CoreState {
- state_machine: StateMachine::booting(),
- removal_tokens: BTreeMap::new(),
- next_removal_token: 1,
- }),
- }
- }
-
- /// Moves the in-memory core from booting to an empty ready snapshot.
- ///
- /// # Errors
- ///
- /// Returns a safe application-state error if the ready snapshot invariant
- /// cannot be constructed.
- pub fn bootstrap(&self) -> Result<AppSnapshot, SafeError> {
- self.apply_transition(StateTransition::Bootstrap)
- }
-
- /// Loads the durable public registry and selection into a signed-out snapshot.
- ///
- /// # Errors
- ///
- /// Returns the safe persistence error after publishing a fatal snapshot when
- /// durable state cannot be read or violates application invariants.
- pub fn bootstrap_from(
- &self,
- accounts: &(impl AccountRepository + ?Sized),
- app_state: &(impl AppStateRepository + ?Sized),
- ) -> Result<AppSnapshot, SafeError> {
- let loaded = accounts.list_accounts().and_then(|accounts| {
- app_state
- .load_selected_account()
- .map(|selected| (accounts, selected))
- });
- match loaded {
- Ok((accounts, selected)) => {
- self.apply_transition(StateTransition::BootstrapRegistry { accounts, selected })
- }
- Err(error) => {
- self.apply_transition(StateTransition::Fatal(error))?;
- Err(error)
- }
- }
- }
-
- #[must_use]
- pub fn snapshot(&self) -> AppSnapshot {
- self.lock_state().state_machine.snapshot().clone()
- }
-
- pub(crate) fn apply_transition(
- &self,
- transition: StateTransition,
- ) -> Result<AppSnapshot, SafeError> {
- self.lock_state()
- .state_machine
- .apply(transition, &self.relay_configuration)
- }
-
- pub(crate) fn issue_removal_token(
- &self,
- public_key: PublicKey,
- now: UnixTimestamp,
- ) -> Result<RemovalConfirmationToken, SafeError> {
- let mut state = self.lock_state();
- let Some(account) = state
- .state_machine
- .snapshot()
- .accounts()
- .iter()
- .find(|account| account.public_key() == public_key)
- else {
- return Err(account_not_found());
- };
- let deletes_local_credential = account.signer().availability()
- != radroots_studio_domain::BindingAvailability::CredentialMissing;
- let id = state.next_removal_token;
- state.next_removal_token = id.checked_add(1).ok_or_else(invalid_application_state)?;
- let revision = state.state_machine.snapshot().revision();
- let expires_at = UnixTimestamp::from_seconds(
- now.as_seconds()
- .checked_add(300)
- .ok_or_else(invalid_application_state)?,
- )
- .ok_or_else(invalid_application_state)?;
- let impact = RemovalImpact {
- deletes_local_credential,
- signs_out: state
- .state_machine
- .snapshot()
- .active_account()
- .is_some_and(|active| active.account().public_key() == public_key),
- };
- state.removal_tokens.insert(
- id,
- RemovalTokenState {
- public_key,
- revision,
- expires_at,
- impact,
- },
- );
- Ok(RemovalConfirmationToken {
- id,
- public_key,
- revision,
- expires_at,
- impact,
- })
- }
-
- #[allow(clippy::needless_pass_by_value)]
- pub(crate) fn consume_removal_token(
- &self,
- token: RemovalConfirmationToken,
- now: UnixTimestamp,
- ) -> Result<PublicKey, SafeError> {
- let RemovalConfirmationToken {
- id,
- public_key,
- revision,
- expires_at,
- impact,
- } = token;
- let mut state = self.lock_state();
- let stored = state.removal_tokens.remove(&id);
- if stored.is_none_or(|stored| {
- stored.public_key != public_key
- || stored.revision != revision
- || stored.expires_at != expires_at
- || stored.impact != impact
- }) || state.state_machine.snapshot().revision() != revision
- || now.as_seconds() > expires_at.as_seconds()
- {
- return Err(invalid_application_state());
- }
- Ok(public_key)
- }
-
- #[allow(clippy::needless_pass_by_value)]
- pub(crate) fn cancel_removal_token(&self, token: RemovalConfirmationToken) -> bool {
- self.lock_state().removal_tokens.remove(&token.id).is_some()
- }
-
- fn lock_state(&self) -> MutexGuard<'_, CoreState> {
- self.state
- .lock()
- .unwrap_or_else(std::sync::PoisonError::into_inner)
- }
-}
-
-const fn invalid_application_state() -> SafeError {
- SafeError::new(
- SafeErrorCode::InvalidApplicationState,
- SafeMessage::new("The account removal confirmation is no longer valid."),
- )
-}
-
-const fn account_not_found() -> SafeError {
- SafeError::new(
- SafeErrorCode::AccountNotFound,
- SafeMessage::new("The account was not found."),
- )
-}
-
-#[cfg(test)]
-mod tests {
- use radroots_studio_domain::{
- AccountCreatedAt, AccountIdentity, AccountSummary, BindingAvailability, LocalSignerBinding,
- PublicKey, UnixTimestamp,
- };
-
- use crate::{AppCore, AppLifecycle, RelayConfiguration, StateTransition};
-
- #[test]
- fn bootstrap_is_idempotent_and_advances_only_once() {
- let core = AppCore::in_memory(RelayConfiguration::default());
- let ready = core.bootstrap().expect("bootstrap");
- let repeated = core.bootstrap().expect("idempotent bootstrap");
-
- assert_eq!(ready.lifecycle(), AppLifecycle::Ready);
- assert_eq!(ready.revision().value(), 1);
- assert_eq!(repeated, ready);
- }
-
- #[test]
- fn core_instances_never_share_state() {
- let first = AppCore::in_memory(RelayConfiguration::default());
- let second = AppCore::in_memory(RelayConfiguration::default());
-
- first.bootstrap().expect("first bootstrap");
-
- assert_eq!(first.snapshot().revision().value(), 1);
- assert_eq!(second.snapshot().revision().value(), 0);
- }
-
- #[test]
- fn removal_impact_matches_missing_local_binding() {
- let core = AppCore::in_memory(RelayConfiguration::default());
- let public_key = PublicKey::from_bytes([9; 32]);
- let account = AccountSummary::new(
- AccountIdentity::derive(public_key).expect("identity"),
- LocalSignerBinding::new(public_key, BindingAvailability::CredentialMissing),
- None,
- AccountCreatedAt::new(UnixTimestamp::from_seconds(1).expect("time")),
- None,
- )
- .expect("account");
- core.apply_transition(StateTransition::BootstrapRegistry {
- accounts: vec![account],
- selected: Some(public_key),
- })
- .expect("registry");
-
- let removal = core
- .issue_removal_token(public_key, UnixTimestamp::from_seconds(2).expect("time"))
- .expect("removal");
-
- assert!(!removal.impact().deletes_local_credential());
- assert!(!removal.impact().signs_out());
- }
-}
diff --git a/core/crates/application/src/change_stream.rs b/core/crates/application/src/change_stream.rs
@@ -1,239 +0,0 @@
-use std::collections::BTreeMap;
-use std::num::{NonZeroU64, NonZeroUsize};
-
-use tokio::sync::mpsc;
-
-use crate::{AppSnapshot, SnapshotRevision};
-
-#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
-pub struct ChangeSubscriptionId(NonZeroU64);
-
-impl ChangeSubscriptionId {
- #[must_use]
- pub const fn value(self) -> u64 {
- self.0.get()
- }
-}
-
-#[derive(Clone, Debug, Eq, PartialEq)]
-pub struct SnapshotChange {
- snapshot: AppSnapshot,
- previous_revision: Option<SnapshotRevision>,
-}
-
-impl SnapshotChange {
- #[must_use]
- pub const fn revision(&self) -> SnapshotRevision {
- self.snapshot.revision()
- }
-
- #[must_use]
- pub const fn snapshot(&self) -> &AppSnapshot {
- &self.snapshot
- }
-
- #[must_use]
- pub fn into_snapshot(self) -> AppSnapshot {
- self.snapshot
- }
-
- #[must_use]
- pub const fn previous_revision(&self) -> Option<SnapshotRevision> {
- self.previous_revision
- }
-
- #[must_use]
- pub fn recovers_gap_after(&self, observed: SnapshotRevision) -> bool {
- self.previous_revision
- .is_some_and(|previous| previous != observed)
- }
-}
-
-pub struct SnapshotChangeReceiver {
- receiver: mpsc::Receiver<SnapshotChange>,
-}
-
-impl SnapshotChangeReceiver {
- pub async fn receive(&mut self) -> Option<SnapshotChange> {
- self.receiver.recv().await
- }
-}
-
-pub struct OrderedSnapshotChanges {
- latest: AppSnapshot,
- next_subscription: u64,
- subscribers: BTreeMap<ChangeSubscriptionId, mpsc::Sender<SnapshotChange>>,
- closed: bool,
-}
-
-impl OrderedSnapshotChanges {
- #[must_use]
- pub fn new(initial_snapshot: AppSnapshot) -> Self {
- Self {
- latest: initial_snapshot,
- next_subscription: 1,
- subscribers: BTreeMap::new(),
- closed: false,
- }
- }
-
- #[must_use]
- pub const fn last_revision(&self) -> SnapshotRevision {
- self.latest.revision()
- }
-
- /// Registers a bounded consumer for future changes.
- ///
- /// # Errors
- ///
- /// Returns `None` if the subscription identifier space is exhausted.
- pub fn subscribe(
- &mut self,
- capacity: NonZeroUsize,
- ) -> Option<(ChangeSubscriptionId, SnapshotChangeReceiver)> {
- if self.closed {
- return None;
- }
- let id = ChangeSubscriptionId(NonZeroU64::new(self.next_subscription)?);
- self.next_subscription = self.next_subscription.checked_add(1)?;
- let (sender, receiver) = mpsc::channel(capacity.get());
- sender
- .try_send(SnapshotChange {
- snapshot: self.latest.clone(),
- previous_revision: None,
- })
- .ok()?;
- self.subscribers.insert(id, sender);
- Some((id, SnapshotChangeReceiver { receiver }))
- }
-
- #[must_use]
- pub fn unsubscribe(&mut self, id: ChangeSubscriptionId) -> bool {
- self.subscribers.remove(&id).is_some()
- }
-
- pub fn publish(&mut self, snapshot: AppSnapshot) {
- if self.closed || snapshot.revision() <= self.latest.revision() {
- return;
- }
- let change = SnapshotChange {
- previous_revision: Some(self.latest.revision()),
- snapshot,
- };
- self.latest = change.snapshot.clone();
- self.subscribers
- .retain(|_, sender| match sender.try_send(change.clone()) {
- Ok(()) | Err(mpsc::error::TrySendError::Full(_)) => true,
- Err(mpsc::error::TrySendError::Closed(_)) => false,
- });
- }
-
- pub fn close(&mut self) {
- self.closed = true;
- self.subscribers.clear();
- }
-}
-
-#[cfg(test)]
-mod tests {
- use std::num::NonZeroUsize;
-
- use crate::{
- AppSnapshot, OrderedSnapshotChanges, RelayConfiguration, SessionState, SnapshotRevision,
- };
-
- #[tokio::test]
- async fn change_stream_publishes_monotonic_revisions_to_multiple_consumers() {
- let mut changes = OrderedSnapshotChanges::new(snapshot(0));
- let (_, mut first) = changes
- .subscribe(NonZeroUsize::new(4).expect("capacity"))
- .expect("first subscription");
- let (_, mut second) = changes
- .subscribe(NonZeroUsize::new(4).expect("capacity"))
- .expect("second subscription");
-
- assert_eq!(
- first.receive().await.expect("initial").revision(),
- revision(0)
- );
- assert_eq!(
- second.receive().await.expect("initial").revision(),
- revision(0)
- );
- changes.publish(snapshot(1));
- changes.publish(snapshot(1));
- changes.publish(snapshot(2));
-
- for receiver in [&mut first, &mut second] {
- assert_eq!(
- receiver.receive().await.expect("revision 1").revision(),
- revision(1)
- );
- assert_eq!(
- receiver.receive().await.expect("revision 2").revision(),
- revision(2)
- );
- }
- assert_eq!(changes.last_revision(), revision(2));
- }
-
- #[tokio::test]
- async fn slow_consumers_expose_a_revision_gap_without_blocking_publication() {
- let mut changes = OrderedSnapshotChanges::new(snapshot(0));
- let (_, mut receiver) = changes
- .subscribe(NonZeroUsize::new(1).expect("capacity"))
- .expect("subscription");
-
- assert_eq!(
- receiver.receive().await.expect("initial").revision(),
- revision(0)
- );
- changes.publish(snapshot(1));
- changes.publish(snapshot(2));
- let first = receiver.receive().await.expect("first");
- assert_eq!(first.revision(), revision(1));
- changes.publish(snapshot(3));
- let recovered = receiver.receive().await.expect("gap recovery");
- assert_eq!(recovered.revision(), revision(3));
- assert!(recovered.recovers_gap_after(first.revision()));
- }
-
- #[tokio::test]
- async fn close_terminates_consumers_and_rejects_later_subscriptions() {
- let mut changes = OrderedSnapshotChanges::new(snapshot(0));
- let (_, mut receiver) = changes
- .subscribe(NonZeroUsize::new(1).expect("capacity"))
- .expect("subscription");
- receiver.receive().await.expect("initial");
-
- changes.close();
- changes.publish(snapshot(1));
- assert!(receiver.receive().await.is_none());
- assert!(
- changes
- .subscribe(NonZeroUsize::new(1).expect("capacity"))
- .is_none()
- );
- }
-
- fn revision(value: u64) -> SnapshotRevision {
- SnapshotRevision::from_value(value)
- }
-
- fn snapshot(value: u64) -> AppSnapshot {
- if value == 0 {
- AppSnapshot::booting()
- } else {
- AppSnapshot::ready(
- revision(value),
- RelayConfiguration::default(),
- Vec::new(),
- None,
- SessionState::SignedOut,
- None,
- None,
- )
- .expect("snapshot")
- }
- }
-}
diff --git a/core/crates/application/src/config.rs b/core/crates/application/src/config.rs
@@ -1,105 +0,0 @@
-use radroots_studio_domain::{SafeError, SafeErrorCode, SafeMessage, normalize_relay_urls};
-
-use crate::RelayConfiguration;
-
-pub const RELAY_ENVIRONMENT_VARIABLE: &str = "RADROOTS_NOSTR_RELAYS";
-const DEVELOPMENT_RELAY: &str = "ws://localhost:8080";
-
-#[derive(Clone, Copy, Debug, Eq, PartialEq)]
-pub enum RelayRuntimeMode {
- Development,
- Packaged,
-}
-
-/// Reads the process relay configuration once through the Rust-owned boundary.
-///
-/// # Errors
-///
-/// Returns a safe configuration error for missing Unicode or invalid relay data.
-pub fn relay_configuration_from_environment(
- mode: RelayRuntimeMode,
-) -> Result<RelayConfiguration, SafeError> {
- let value = match std::env::var(RELAY_ENVIRONMENT_VARIABLE) {
- Ok(value) => Some(value),
- Err(std::env::VarError::NotPresent) => None,
- Err(std::env::VarError::NotUnicode(_)) => return Err(invalid_configuration()),
- };
- relay_configuration_from_value(value.as_deref(), mode)
-}
-
-/// Parses an injected comma-separated relay list without mutating process state.
-///
-/// # Errors
-///
-/// Returns a safe configuration error when an entry is invalid or packaged mode
-/// has no configured relay.
-pub fn relay_configuration_from_value(
- value: Option<&str>,
- mode: RelayRuntimeMode,
-) -> Result<RelayConfiguration, SafeError> {
- let configured = value.unwrap_or_default().trim();
- let source = if configured.is_empty() {
- match mode {
- RelayRuntimeMode::Development => DEVELOPMENT_RELAY,
- RelayRuntimeMode::Packaged => return Err(invalid_configuration()),
- }
- } else {
- configured
- };
- let normalized = normalize_relay_urls(source.split(',').map(str::trim))?;
- if normalized.is_empty() {
- return Err(invalid_configuration());
- }
- Ok(RelayConfiguration::new(normalized))
-}
-
-const fn invalid_configuration() -> SafeError {
- SafeError::new(
- SafeErrorCode::InvalidRelayConfiguration,
- SafeMessage::new("The Nostr relay configuration is invalid."),
- )
-}
-
-#[cfg(test)]
-mod tests {
- use radroots_studio_domain::SafeErrorCode;
-
- use super::{RelayRuntimeMode, relay_configuration_from_value};
-
- #[test]
- fn relay_config_uses_localhost_fallback_only_for_development() {
- for value in [None, Some(""), Some(" ")] {
- let development = relay_configuration_from_value(value, RelayRuntimeMode::Development)
- .expect("development fallback");
- assert_eq!(development.relays()[0].as_str(), "ws://localhost:8080/");
- let packaged = relay_configuration_from_value(value, RelayRuntimeMode::Packaged)
- .expect_err("packaged configuration required");
- assert_eq!(packaged.code(), SafeErrorCode::InvalidRelayConfiguration);
- }
- }
-
- #[test]
- fn relay_config_trims_deduplicates_and_preserves_order() {
- let configuration = relay_configuration_from_value(
- Some(" wss://relay.one ,wss://relay.two,wss://relay.one/ "),
- RelayRuntimeMode::Packaged,
- )
- .expect("configuration");
- let relays = configuration
- .relays()
- .iter()
- .map(radroots_studio_domain::RelayUrl::as_str)
- .collect::<Vec<_>>();
- assert_eq!(relays, ["wss://relay.one/", "wss://relay.two/"]);
- }
-
- #[test]
- fn relay_config_rejects_any_invalid_comma_separated_entry() {
- let error = relay_configuration_from_value(
- Some("wss://relay.one,https://not-a-relay.test"),
- RelayRuntimeMode::Packaged,
- )
- .expect_err("invalid entry");
- assert_eq!(error.code(), SafeErrorCode::InvalidRelayConfiguration);
- }
-}
diff --git a/core/crates/application/src/custody.rs b/core/crates/application/src/custody.rs
@@ -1,279 +0,0 @@
-use std::num::NonZeroU64;
-use std::sync::Mutex;
-use std::time::Duration;
-
-use radroots_studio_domain::{
- AccountCreatedAt, AccountIdentity, AccountSummary, BindingAvailability, LocalSignerBinding,
- Nsec, SafeError, SafeErrorCode, SafeMessage, SecretKeyInput, UnixTimestamp,
-};
-use radroots_studio_nostr::generate_local_keypair;
-
-pub const GENERATED_KEY_STAGE_TTL: Duration = Duration::from_mins(5);
-
-#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
-pub struct RecoveryStageId(NonZeroU64);
-
-impl RecoveryStageId {
- #[must_use]
- pub const fn new(value: NonZeroU64) -> Self {
- Self(value)
- }
-
- #[must_use]
- pub const fn value(self) -> u64 {
- self.0.get()
- }
-}
-
-#[derive(Clone, Debug, Eq, PartialEq)]
-pub struct GeneratedKeyStageView {
- account: AccountSummary,
- expires_at: UnixTimestamp,
-}
-
-impl GeneratedKeyStageView {
- #[must_use]
- pub const fn account(&self) -> &AccountSummary {
- &self.account
- }
-
- #[must_use]
- pub const fn expires_at(&self) -> UnixTimestamp {
- self.expires_at
- }
-}
-
-pub struct StagedGeneratedKey {
- id: RecoveryStageId,
- account: AccountSummary,
- secret: SecretKeyInput,
- expected_revision: u64,
- expires_at: UnixTimestamp,
-}
-
-impl StagedGeneratedKey {
- #[must_use]
- pub fn view(&self) -> GeneratedKeyStageView {
- GeneratedKeyStageView {
- account: self.account.clone(),
- expires_at: self.expires_at,
- }
- }
-
- #[must_use]
- pub const fn expected_revision(&self) -> u64 {
- self.expected_revision
- }
-
- #[must_use]
- pub const fn id(&self) -> RecoveryStageId {
- self.id
- }
-
- #[must_use]
- pub const fn account(&self) -> &AccountSummary {
- &self.account
- }
-
- #[must_use]
- pub fn into_commit_parts(self) -> (AccountSummary, SecretKeyInput) {
- (self.account, self.secret)
- }
-}
-
-pub struct GeneratedKeyRecoveryHandle {
- id: RecoveryStageId,
- view: GeneratedKeyStageView,
- recovery_nsec: Mutex<Option<Nsec>>,
-}
-
-impl GeneratedKeyRecoveryHandle {
- fn new(id: RecoveryStageId, view: GeneratedKeyStageView, recovery_nsec: Nsec) -> Self {
- Self {
- id,
- view,
- recovery_nsec: Mutex::new(Some(recovery_nsec)),
- }
- }
-
- #[must_use]
- pub const fn id(&self) -> RecoveryStageId {
- self.id
- }
-
- #[must_use]
- pub const fn view(&self) -> &GeneratedKeyStageView {
- &self.view
- }
-
- /// Returns the generated recovery value exactly once.
- ///
- /// # Errors
- ///
- /// Returns a safe unavailable error after the value was already consumed.
- pub fn take_recovery_nsec(&self) -> Result<Nsec, SafeError> {
- self.recovery_nsec
- .lock()
- .unwrap_or_else(std::sync::PoisonError::into_inner)
- .take()
- .ok_or_else(recovery_not_available)
- }
-}
-
-#[derive(Default)]
-pub struct GeneratedKeyStage {
- pending: Option<StagedGeneratedKey>,
-}
-
-impl GeneratedKeyStage {
- /// Replaces an expired stage or creates the only active generated-key stage.
- ///
- /// # Errors
- ///
- /// Returns a safe conflict while an unexpired recovery stage is active.
- pub fn begin(
- &mut self,
- id: RecoveryStageId,
- expected_revision: u64,
- now: UnixTimestamp,
- ) -> Result<GeneratedKeyRecoveryHandle, SafeError> {
- self.expire(now);
- if self.pending.is_some() {
- return Err(recovery_in_progress());
- }
- let generated = generate_local_keypair()?;
- let (public_key, npub, secret, recovery_nsec) = generated.into_parts();
- let account = AccountSummary::new(
- AccountIdentity::verify(public_key, npub.as_str().to_owned())?,
- LocalSignerBinding::new(public_key, BindingAvailability::Available),
- None,
- AccountCreatedAt::new(now),
- None,
- )?;
- let ttl =
- i64::try_from(GENERATED_KEY_STAGE_TTL.as_secs()).map_err(|_| invalid_stage_expiry())?;
- let expires_at = now
- .as_seconds()
- .checked_add(ttl)
- .and_then(UnixTimestamp::from_seconds)
- .ok_or_else(invalid_stage_expiry)?;
- let pending = StagedGeneratedKey {
- id,
- account,
- secret,
- expected_revision,
- expires_at,
- };
- let view = pending.view();
- self.pending = Some(pending);
- Ok(GeneratedKeyRecoveryHandle::new(id, view, recovery_nsec))
- }
-
- pub fn cancel(&mut self) -> bool {
- self.pending.take().is_some()
- }
-
- pub fn expire(&mut self, now: UnixTimestamp) -> bool {
- if self
- .pending
- .as_ref()
- .is_some_and(|pending| now >= pending.expires_at)
- {
- self.pending = None;
- true
- } else {
- false
- }
- }
-
- #[must_use]
- pub const fn pending(&self) -> Option<&StagedGeneratedKey> {
- self.pending.as_ref()
- }
-
- /// Consumes the active, unexpired stage for its commit boundary.
- ///
- /// # Errors
- ///
- /// Returns a safe unavailable error when no live stage remains.
- pub fn take(
- &mut self,
- id: RecoveryStageId,
- now: UnixTimestamp,
- ) -> Result<StagedGeneratedKey, SafeError> {
- self.expire(now);
- if self.pending.as_ref().map(StagedGeneratedKey::id) != Some(id) {
- return Err(recovery_not_available());
- }
- self.pending.take().ok_or_else(recovery_not_available)
- }
-}
-
-const fn recovery_in_progress() -> SafeError {
- SafeError::new(
- SafeErrorCode::InvalidApplicationState,
- SafeMessage::new("A generated-key recovery step is already in progress."),
- )
-}
-
-const fn recovery_not_available() -> SafeError {
- SafeError::new(
- SafeErrorCode::InvalidApplicationState,
- SafeMessage::new("The generated-key recovery step is no longer available."),
- )
-}
-
-const fn invalid_stage_expiry() -> SafeError {
- SafeError::new(
- SafeErrorCode::InvalidApplicationState,
- SafeMessage::new("The generated-key recovery expiry is invalid."),
- )
-}
-
-#[cfg(test)]
-mod tests {
- use std::num::NonZeroU64;
-
- use radroots_studio_domain::UnixTimestamp;
-
- use super::{GENERATED_KEY_STAGE_TTL, GeneratedKeyStage, RecoveryStageId};
-
- fn time(seconds: i64) -> UnixTimestamp {
- UnixTimestamp::from_seconds(seconds).expect("time")
- }
-
- fn id(value: u64) -> RecoveryStageId {
- RecoveryStageId::new(NonZeroU64::new(value).expect("id"))
- }
-
- #[test]
- fn stage_is_exclusive_cancelable_and_never_publishes_secret_debug() {
- let mut stage = GeneratedKeyStage::default();
- let handle = stage.begin(id(1), 4, time(10)).expect("begin");
- let view = handle.view();
- assert_eq!(view.expires_at().as_seconds(), 310);
- assert_eq!(stage.pending().expect("pending").expected_revision(), 4);
- assert!(stage.begin(id(2), 4, time(11)).is_err());
- let nsec = handle.take_recovery_nsec().expect("one-use recovery");
- assert_eq!(nsec.with_exposed_secret(str::len), 63);
- assert!(handle.take_recovery_nsec().is_err());
- assert!(stage.cancel());
- assert!(!stage.cancel());
- assert!(format!("{view:?}").contains(view.account().npub().as_str()));
- assert!(!format!("{view:?}").contains("nsec1"));
- }
-
- #[test]
- fn stage_expires_and_is_destroyed_on_owner_drop() {
- let mut stage = GeneratedKeyStage::default();
- stage.begin(id(1), 0, time(20)).expect("begin");
- let expiry = 20 + i64::try_from(GENERATED_KEY_STAGE_TTL.as_secs()).expect("ttl");
- assert!(stage.expire(time(expiry)));
- assert!(stage.pending().is_none());
- assert!(stage.take(id(1), time(expiry)).is_err());
-
- let mut shutdown_stage = GeneratedKeyStage::default();
- shutdown_stage.begin(id(2), 0, time(30)).expect("begin");
- drop(shutdown_stage);
- }
-}
diff --git a/core/crates/application/src/lib.rs b/core/crates/application/src/lib.rs
@@ -1,55 +0,0 @@
-#![doc = "Radroots Studio application runtime."]
-
-pub mod accounts;
-pub mod actor;
-pub mod app_core;
-mod change_stream;
-pub mod config;
-pub mod custody;
-pub mod nostr_client;
-pub mod ports;
-mod profile_refresh;
-pub mod recovery;
-pub mod secrets;
-pub mod session;
-pub mod snapshot;
-pub mod state_machine;
-
-pub use accounts::{
- GenerateAccountReceipt, ImportAccountReceipt, InMemoryAccountRepository,
- InMemoryOperationJournal,
-};
-pub use actor::{
- ActorMailbox, CommandContext, CommandEnvelope, CommandReceipt, CommandRejection, CommandResult,
- CommandSubmission, CommandTicket, ForegroundSessionBinding, LifecycleGate, RequestId,
- RuntimeCommandClass, RuntimeLifecycle, SessionGeneration, TaskCorrelation,
-};
-pub use app_core::{AppCore, RemovalConfirmationToken, RemovalImpact};
-pub use change_stream::{
- ChangeSubscriptionId, OrderedSnapshotChanges, SnapshotChange, SnapshotChangeReceiver,
-};
-pub use config::{
- RelayRuntimeMode, relay_configuration_from_environment, relay_configuration_from_value,
-};
-pub use custody::{
- GENERATED_KEY_STAGE_TTL, GeneratedKeyRecoveryHandle, GeneratedKeyStage, GeneratedKeyStageView,
- RecoveryStageId, StagedGeneratedKey,
-};
-pub use nostr_client::SdkNostrClient;
-pub use ports::{
- AccountNamespaceRepository, AccountOperationKind, AccountOperationPhase, AccountPreferenceKey,
- AccountRepository, AppStateRepository, BoxFuture, CachedProfile, Clock,
- DurableAccountOperation, DurableOperationKind, DurableOperationPhase, DurableOperationReceipt,
- DurableOperationRepository, DurableOperationStart, DurableRequestId, DurableTerminalOutcome,
- NostrClient, OperationDiagnostic, OperationId, OperationJournal, OperationPriorState,
- PendingAccountOperation, ProfileRefreshStatus, ProfileRepository,
-};
-pub use profile_refresh::ProfileRefreshPlan;
-pub use secrets::{
- FailureSecretStore, InMemorySecretStore, SecretStore, SecretStoreCall, SecretStoreOperation,
-};
-pub use snapshot::{
- ActiveAccountSnapshot, AppLifecycle, AppSnapshot, ProfileLoadState, RelayConfiguration,
- RelayConnectionState, SessionState, SnapshotRevision,
-};
-pub use state_machine::{StateMachine, StateTransition};
diff --git a/core/crates/application/src/nostr_client.rs b/core/crates/application/src/nostr_client.rs
@@ -1,138 +0,0 @@
-use std::time::Duration;
-
-use nostr::{Filter, JsonUtil, Kind, PublicKey as NostrPublicKey};
-use nostr_sdk::ClientBuilder;
-use radroots_studio_domain::{
- Kind0ProfileCandidate, PublicKey, RelayUrl, SafeError, SafeErrorCode, SafeMessage,
- select_latest_kind0,
-};
-
-use crate::{BoxFuture, NostrClient};
-
-pub struct SdkNostrClient {
- timeout: Duration,
-}
-
-impl SdkNostrClient {
- #[must_use]
- pub const fn new(timeout: Duration) -> Self {
- Self { timeout }
- }
-}
-
-impl NostrClient for SdkNostrClient {
- fn fetch_profile<'a>(
- &'a self,
- public_key: PublicKey,
- relays: &'a [RelayUrl],
- ) -> BoxFuture<'a, Result<Option<Kind0ProfileCandidate>, SafeError>> {
- Box::pin(async move {
- if relays.is_empty() {
- return Err(invalid_relay_configuration());
- }
-
- let client = ClientBuilder::new().build();
- for relay in relays {
- client
- .add_relay(relay.as_str())
- .await
- .map_err(|_| relay_connection_failed())?;
- }
- client.connect().await;
- client.wait_for_connection(self.timeout).await;
-
- let author = NostrPublicKey::from_slice(public_key.as_bytes())
- .map_err(|_| profile_refresh_failed())?;
- let filter = Filter::new().author(author).kind(Kind::Metadata).limit(64);
- let fetched = client.fetch_events(filter, self.timeout).await;
- client.shutdown().await;
- let events = fetched.map_err(|_| relay_connection_failed())?;
-
- let mut candidates = Vec::with_capacity(events.len());
- for event in events.iter() {
- candidates.push(radroots_studio_nostr::parse_verified_kind0(
- &event.as_json(),
- public_key,
- )?);
- }
- Ok(select_latest_kind0(candidates))
- })
- }
-}
-
-const fn invalid_relay_configuration() -> SafeError {
- SafeError::new(
- SafeErrorCode::InvalidRelayConfiguration,
- SafeMessage::new("No Nostr relay is configured."),
- )
-}
-
-const fn relay_connection_failed() -> SafeError {
- SafeError::new(
- SafeErrorCode::RelayConnectionFailed,
- SafeMessage::new("The Nostr relays could not be reached."),
- )
-}
-
-const fn profile_refresh_failed() -> SafeError {
- SafeError::new(
- SafeErrorCode::ProfileRefreshFailed,
- SafeMessage::new("The Nostr profile could not be refreshed."),
- )
-}
-
-#[cfg(test)]
-mod tests {
- use std::time::Duration;
-
- use nostr::{EventBuilder, Keys, Metadata};
- use nostr_relay_builder::MockRelay;
- use nostr_sdk::Client;
- use radroots_studio_domain::{PublicKey, RelayUrl, SafeErrorCode};
-
- use crate::{NostrClient, SdkNostrClient};
-
- #[tokio::test]
- async fn sdk_client_fetches_verified_profile_from_ephemeral_local_relay() {
- let relay = MockRelay::run().await.expect("local relay");
- let relay_url = relay.url().await;
- let keys = Keys::generate();
- let publisher = Client::new(keys.clone());
- publisher
- .add_relay(relay_url.clone())
- .await
- .expect("add relay");
- publisher.connect().await;
- publisher.wait_for_connection(Duration::from_secs(2)).await;
- publisher
- .send_event_builder(EventBuilder::metadata(
- &Metadata::new().name("Farmer").display_name("Farm Account"),
- ))
- .await
- .expect("publish metadata");
-
- let adapter = SdkNostrClient::new(Duration::from_secs(2));
- let domain_relay = RelayUrl::parse(relay_url.as_str()).expect("domain relay URL");
- let public_key = PublicKey::from_bytes(keys.public_key().to_bytes());
- let profile = adapter
- .fetch_profile(public_key, &[domain_relay])
- .await
- .expect("fetch profile")
- .expect("published profile");
-
- assert_eq!(profile.author(), public_key);
- assert_eq!(profile.metadata().preferred_name(), Some("Farm Account"));
- publisher.shutdown().await;
- relay.shutdown();
- }
-
- #[tokio::test]
- async fn sdk_client_rejects_empty_configuration_without_network_access() {
- let error = SdkNostrClient::new(Duration::from_millis(10))
- .fetch_profile(PublicKey::from_bytes([1; 32]), &[])
- .await
- .expect_err("empty relay list");
-
- assert_eq!(error.code(), SafeErrorCode::InvalidRelayConfiguration);
- }
-}
diff --git a/core/crates/application/src/ports.rs b/core/crates/application/src/ports.rs
@@ -1,780 +0,0 @@
-use std::future::Future;
-use std::pin::Pin;
-
-use radroots_studio_domain::{
- AccountSummary, BindingAvailability, Kind0ProfileCandidate, PublicKey, RelayUrl, SafeError,
- SafeErrorCode, SafeMessage, UnixTimestamp,
-};
-
-const MAX_DURABLE_REQUEST_ID_BYTES: usize = 128;
-
-#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
-pub struct DurableRequestId(String);
-
-impl DurableRequestId {
- /// Validates an opaque caller-generated idempotency key.
- ///
- /// # Errors
- ///
- /// Returns a safe validation error when the value is empty, oversized, or contains anything
- /// other than visible ASCII characters.
- pub fn parse(value: impl Into<String>) -> Result<Self, SafeError> {
- let value = value.into();
- if value.is_empty()
- || value.len() > MAX_DURABLE_REQUEST_ID_BYTES
- || !value.bytes().all(|byte| byte.is_ascii_graphic())
- {
- return Err(invalid_request_id());
- }
- Ok(Self(value))
- }
-
- #[must_use]
- pub fn as_str(&self) -> &str {
- &self.0
- }
-}
-
-#[derive(Clone, Copy, Debug, Eq, PartialEq)]
-pub enum DurableOperationKind {
- Create,
- Import,
- Repair,
- Remove,
-}
-
-#[derive(Clone, Copy, Debug, Eq, PartialEq)]
-pub enum DurableOperationPhase {
- IntentRecorded,
- CredentialWritten,
- MetadataCommitted,
- SelectionCommitted,
- CompensationPending,
- CredentialDeleted,
- MetadataDeleted,
- Finalized,
-}
-
-#[derive(Clone, Copy, Debug, Eq, PartialEq)]
-pub enum DurableTerminalOutcome {
- Completed,
- Cancelled,
- Failed,
-}
-
-#[derive(Clone, Copy, Debug, Eq, PartialEq)]
-pub struct OperationPriorState {
- selected_account: Option<PublicKey>,
- binding_availability: Option<BindingAvailability>,
-}
-
-impl OperationPriorState {
- #[must_use]
- pub const fn new(
- selected_account: Option<PublicKey>,
- binding_availability: Option<BindingAvailability>,
- ) -> Self {
- Self {
- selected_account,
- binding_availability,
- }
- }
-
- #[must_use]
- pub const fn selected_account(self) -> Option<PublicKey> {
- self.selected_account
- }
-
- #[must_use]
- pub const fn binding_availability(self) -> Option<BindingAvailability> {
- self.binding_availability
- }
-}
-
-#[derive(Clone, Debug, Eq, PartialEq)]
-pub struct DurableOperationReceipt {
- request_id: DurableRequestId,
- account: PublicKey,
- outcome: DurableTerminalOutcome,
- resulting_revision: Option<u64>,
-}
-
-impl DurableOperationReceipt {
- #[must_use]
- pub const fn new(
- request_id: DurableRequestId,
- account: PublicKey,
- outcome: DurableTerminalOutcome,
- resulting_revision: Option<u64>,
- ) -> Self {
- Self {
- request_id,
- account,
- outcome,
- resulting_revision,
- }
- }
-
- #[must_use]
- pub const fn request_id(&self) -> &DurableRequestId {
- &self.request_id
- }
-
- #[must_use]
- pub const fn account(&self) -> PublicKey {
- self.account
- }
-
- #[must_use]
- pub const fn outcome(&self) -> DurableTerminalOutcome {
- self.outcome
- }
-
- #[must_use]
- pub const fn resulting_revision(&self) -> Option<u64> {
- self.resulting_revision
- }
-}
-
-#[derive(Clone, Debug, Eq, PartialEq)]
-pub struct DurableAccountOperation {
- request_id: DurableRequestId,
- kind: DurableOperationKind,
- account: PublicKey,
- expected_revision: Option<u64>,
- phase: DurableOperationPhase,
- prior: OperationPriorState,
- updated_at: UnixTimestamp,
- diagnostic: Option<OperationDiagnostic>,
- terminal: Option<DurableOperationReceipt>,
-}
-
-impl DurableAccountOperation {
- #[allow(clippy::too_many_arguments)]
- #[must_use]
- pub const fn new(
- request_id: DurableRequestId,
- kind: DurableOperationKind,
- account: PublicKey,
- expected_revision: Option<u64>,
- phase: DurableOperationPhase,
- prior: OperationPriorState,
- updated_at: UnixTimestamp,
- diagnostic: Option<OperationDiagnostic>,
- terminal: Option<DurableOperationReceipt>,
- ) -> Self {
- Self {
- request_id,
- kind,
- account,
- expected_revision,
- phase,
- prior,
- updated_at,
- diagnostic,
- terminal,
- }
- }
-
- #[must_use]
- pub const fn request_id(&self) -> &DurableRequestId {
- &self.request_id
- }
- #[must_use]
- pub const fn kind(&self) -> DurableOperationKind {
- self.kind
- }
- #[must_use]
- pub const fn account(&self) -> PublicKey {
- self.account
- }
- #[must_use]
- pub const fn expected_revision(&self) -> Option<u64> {
- self.expected_revision
- }
- #[must_use]
- pub const fn phase(&self) -> DurableOperationPhase {
- self.phase
- }
- #[must_use]
- pub const fn prior(&self) -> OperationPriorState {
- self.prior
- }
- #[must_use]
- pub const fn updated_at(&self) -> UnixTimestamp {
- self.updated_at
- }
- #[must_use]
- pub const fn diagnostic(&self) -> Option<OperationDiagnostic> {
- self.diagnostic
- }
- #[must_use]
- pub const fn terminal(&self) -> Option<&DurableOperationReceipt> {
- self.terminal.as_ref()
- }
-}
-
-#[derive(Clone, Debug, Eq, PartialEq)]
-pub enum DurableOperationStart {
- Started(DurableAccountOperation),
- Existing(DurableAccountOperation),
-}
-
-const fn invalid_request_id() -> SafeError {
- SafeError::new(
- SafeErrorCode::InvalidApplicationState,
- SafeMessage::new("The request identifier is invalid."),
- )
-}
-
-pub type BoxFuture<'a, T> = Pin<Box<dyn Future<Output = T> + Send + 'a>>;
-
-#[derive(Clone, Copy, Debug, Eq, PartialEq)]
-pub enum ProfileRefreshStatus {
- Success,
- Offline,
- InvalidData,
-}
-
-#[derive(Clone, Debug, Eq, PartialEq)]
-pub struct CachedProfile {
- candidate: Kind0ProfileCandidate,
- refreshed_at: UnixTimestamp,
- refresh_status: ProfileRefreshStatus,
-}
-
-#[derive(Clone, Copy, Debug, Eq, PartialEq)]
-pub enum AccountPreferenceKey {
- NamespaceProbe,
-}
-
-#[derive(Clone, Copy, Debug, Eq, PartialEq)]
-pub enum AccountOperationKind {
- Add,
- Import,
- Remove,
-}
-
-#[derive(Clone, Copy, Debug, Eq, PartialEq)]
-pub enum AccountOperationPhase {
- IntentRecorded,
- CredentialWritten,
- MetadataCommitted,
- CompensationPending,
- CredentialDeleted,
- MetadataDeleted,
-}
-
-#[derive(Clone, Copy, Debug, Eq, PartialEq)]
-pub enum OperationDiagnostic {
- StorageUnavailable,
- KeyringUnavailable,
- CredentialMissing,
- CompensationFailed,
- Conflict,
- Expired,
-}
-
-#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
-pub struct OperationId(u64);
-
-impl OperationId {
- #[must_use]
- pub const fn from_raw(value: u64) -> Self {
- Self(value)
- }
-
- #[must_use]
- pub const fn as_raw(self) -> u64 {
- self.0
- }
-}
-
-#[derive(Clone, Debug, Eq, PartialEq)]
-pub struct PendingAccountOperation {
- id: OperationId,
- kind: AccountOperationKind,
- subject: PublicKey,
- phase: AccountOperationPhase,
- updated_at: UnixTimestamp,
- diagnostic: Option<OperationDiagnostic>,
-}
-
-impl PendingAccountOperation {
- #[must_use]
- pub const fn new(
- id: OperationId,
- kind: AccountOperationKind,
- subject: PublicKey,
- phase: AccountOperationPhase,
- updated_at: UnixTimestamp,
- diagnostic: Option<OperationDiagnostic>,
- ) -> Self {
- Self {
- id,
- kind,
- subject,
- phase,
- updated_at,
- diagnostic,
- }
- }
-
- #[must_use]
- pub const fn id(&self) -> OperationId {
- self.id
- }
- #[must_use]
- pub const fn kind(&self) -> AccountOperationKind {
- self.kind
- }
- #[must_use]
- pub const fn subject(&self) -> PublicKey {
- self.subject
- }
- #[must_use]
- pub const fn phase(&self) -> AccountOperationPhase {
- self.phase
- }
- #[must_use]
- pub const fn updated_at(&self) -> UnixTimestamp {
- self.updated_at
- }
- #[must_use]
- pub const fn diagnostic(&self) -> Option<OperationDiagnostic> {
- self.diagnostic
- }
-}
-
-impl CachedProfile {
- #[must_use]
- pub const fn new(
- candidate: Kind0ProfileCandidate,
- refreshed_at: UnixTimestamp,
- refresh_status: ProfileRefreshStatus,
- ) -> Self {
- Self {
- candidate,
- refreshed_at,
- refresh_status,
- }
- }
-
- #[must_use]
- pub const fn candidate(&self) -> &Kind0ProfileCandidate {
- &self.candidate
- }
-
- #[must_use]
- pub const fn refreshed_at(&self) -> UnixTimestamp {
- self.refreshed_at
- }
-
- #[must_use]
- pub const fn refresh_status(&self) -> ProfileRefreshStatus {
- self.refresh_status
- }
-}
-
-pub trait AccountRepository: Send + Sync {
- /// Lists saved public account records in deterministic order.
- ///
- /// # Errors
- ///
- /// Returns a safe storage error when records cannot be read.
- fn list_accounts(&self) -> Result<Vec<AccountSummary>, SafeError>;
- /// Finds one saved public account record.
- ///
- /// # Errors
- ///
- /// Returns a safe storage error when the lookup cannot complete.
- fn find_account(&self, public_key: PublicKey) -> Result<Option<AccountSummary>, SafeError>;
- /// Inserts one public account record.
- ///
- /// # Errors
- ///
- /// Returns a safe storage error when the durable write fails.
- fn insert_account(&self, account: &AccountSummary) -> Result<(), SafeError>;
- /// Updates one existing public account record.
- ///
- /// # Errors
- ///
- /// Returns a safe storage or account-not-found error when the durable
- /// update cannot complete.
- fn update_account(&self, account: &AccountSummary) -> Result<(), SafeError>;
- /// Removes one public account record.
- ///
- /// # Errors
- ///
- /// Returns a safe storage error when the durable delete fails.
- fn remove_account(&self, public_key: PublicKey) -> Result<(), SafeError>;
-}
-
-pub trait ProfileRepository: Send + Sync {
- /// Loads cached public profile metadata.
- ///
- /// # Errors
- ///
- /// Returns a safe storage error when the cache cannot be read.
- fn load_profile(&self, public_key: PublicKey) -> Result<Option<CachedProfile>, SafeError>;
- /// Saves a verified kind-0 profile candidate.
- ///
- /// # Errors
- ///
- /// Returns a safe storage error when the cache cannot be committed.
- fn save_profile(&self, profile: &CachedProfile) -> Result<(), SafeError>;
- /// Records the result of a profile refresh without replacing cached metadata.
- ///
- /// # Errors
- ///
- /// Returns a safe storage error when the cache cannot be committed.
- fn record_refresh_status(
- &self,
- public_key: PublicKey,
- refreshed_at: UnixTimestamp,
- status: ProfileRefreshStatus,
- ) -> Result<(), SafeError>;
- /// Removes cached profile metadata for an account.
- ///
- /// # Errors
- ///
- /// Returns a safe storage error when the cache cannot be deleted.
- fn remove_profile(&self, public_key: PublicKey) -> Result<(), SafeError>;
-}
-
-pub trait AccountNamespaceRepository: Send + Sync {
- /// Reads one internal non-secret account-scoped value.
- ///
- /// # Errors
- ///
- /// Returns a safe storage error when the value cannot be read.
- fn get_value(
- &self,
- owner: PublicKey,
- key: AccountPreferenceKey,
- ) -> Result<Option<String>, SafeError>;
- /// Writes one internal non-secret account-scoped value.
- ///
- /// # Errors
- ///
- /// Returns a safe storage error when the value cannot be committed.
- fn set_value(
- &self,
- owner: PublicKey,
- key: AccountPreferenceKey,
- value: &str,
- ) -> Result<(), SafeError>;
- /// Removes all internal values owned by an account.
- ///
- /// # Errors
- ///
- /// Returns a safe storage error when cleanup cannot be committed.
- fn clear_owner(&self, owner: PublicKey) -> Result<(), SafeError>;
-}
-
-pub trait AppStateRepository: Send + Sync {
- /// Loads the persisted selected account.
- ///
- /// # Errors
- ///
- /// Returns a safe storage error when application state cannot be read.
- fn load_selected_account(&self) -> Result<Option<PublicKey>, SafeError>;
- /// Persists the selected account or the empty selection.
- ///
- /// # Errors
- ///
- /// Returns a safe storage error when application state cannot be committed.
- fn save_selected_account(&self, public_key: Option<PublicKey>) -> Result<(), SafeError>;
-}
-
-pub trait OperationJournal: Send + Sync {
- /// Records one cross-resource account operation intent.
- ///
- /// # Errors
- ///
- /// Returns a safe storage error when the entry cannot be committed.
- fn begin_operation(
- &self,
- kind: AccountOperationKind,
- subject: PublicKey,
- updated_at: UnixTimestamp,
- ) -> Result<OperationId, SafeError>;
- /// Advances an operation to a durable recovery phase.
- ///
- /// # Errors
- ///
- /// Returns a safe storage error when the entry cannot be updated.
- fn update_operation(
- &self,
- id: OperationId,
- phase: AccountOperationPhase,
- updated_at: UnixTimestamp,
- diagnostic: Option<OperationDiagnostic>,
- ) -> Result<(), SafeError>;
- /// Loads all unfinished operations in deterministic order.
- ///
- /// # Errors
- ///
- /// Returns a safe storage error when entries cannot be read.
- fn list_pending_operations(&self) -> Result<Vec<PendingAccountOperation>, SafeError>;
- /// Deletes one fully reconciled operation entry.
- ///
- /// # Errors
- ///
- /// Returns a safe storage error when finalization cannot be committed.
- fn finalize_operation(&self, id: OperationId) -> Result<(), SafeError>;
-}
-
-pub trait DurableOperationRepository: Send + Sync {
- /// Records one idempotent durable operation or returns the existing matching request.
- ///
- /// # Errors
- ///
- /// Returns a safe conflict or storage error when the request cannot be recorded.
- #[allow(clippy::too_many_arguments)]
- fn begin_durable_operation(
- &self,
- request_id: &DurableRequestId,
- kind: DurableOperationKind,
- account: PublicKey,
- expected_revision: Option<u64>,
- prior: OperationPriorState,
- updated_at: UnixTimestamp,
- ) -> Result<DurableOperationStart, SafeError>;
- /// Loads one durable operation by its idempotency key.
- ///
- /// # Errors
- ///
- /// Returns a safe storage error when the lookup cannot complete.
- fn load_durable_operation(
- &self,
- request_id: &DurableRequestId,
- ) -> Result<Option<DurableAccountOperation>, SafeError>;
- /// Advances one operation only from the caller's expected phase.
- ///
- /// # Errors
- ///
- /// Returns a safe conflict or storage error when the transition cannot commit.
- fn advance_durable_operation(
- &self,
- request_id: &DurableRequestId,
- expected_phase: DurableOperationPhase,
- next_phase: DurableOperationPhase,
- updated_at: UnixTimestamp,
- diagnostic: Option<OperationDiagnostic>,
- ) -> Result<DurableAccountOperation, SafeError>;
- /// Finalizes one operation and durably retains its recoverable receipt.
- ///
- /// # Errors
- ///
- /// Returns a safe conflict or storage error when finalization cannot commit.
- fn finalize_durable_operation(
- &self,
- request_id: &DurableRequestId,
- expected_phase: DurableOperationPhase,
- outcome: DurableTerminalOutcome,
- resulting_revision: Option<u64>,
- updated_at: UnixTimestamp,
- ) -> Result<DurableOperationReceipt, SafeError>;
- /// Lists unfinished operations in deterministic request order.
- ///
- /// # Errors
- ///
- /// Returns a safe storage error when operations cannot be read.
- fn list_unfinished_durable_operations(&self)
- -> Result<Vec<DurableAccountOperation>, SafeError>;
-}
-
-pub trait NostrClient: Send + Sync {
- fn fetch_profile<'a>(
- &'a self,
- public_key: PublicKey,
- relays: &'a [RelayUrl],
- ) -> BoxFuture<'a, Result<Option<Kind0ProfileCandidate>, SafeError>>;
-}
-
-pub trait Clock: Send + Sync {
- fn now(&self) -> UnixTimestamp;
-}
-
-#[cfg(test)]
-mod tests {
- use std::sync::Mutex;
-
- use radroots_studio_domain::{
- AccountSummary, Kind0ProfileCandidate, PublicKey, RelayUrl, SafeError, UnixTimestamp,
- };
-
- use super::{
- AccountNamespaceRepository, AccountOperationKind, AccountOperationPhase,
- AccountPreferenceKey, AccountRepository, AppStateRepository, BoxFuture, CachedProfile,
- Clock, DurableOperationReceipt, DurableRequestId, DurableTerminalOutcome, NostrClient,
- OperationDiagnostic, OperationId, OperationJournal, PendingAccountOperation,
- ProfileRefreshStatus, ProfileRepository,
- };
-
- #[test]
- fn durable_request_ids_and_terminal_receipts_are_bounded_and_public() {
- let request = DurableRequestId::parse("create:desktop:0001").expect("request id");
- let receipt = DurableOperationReceipt::new(
- request.clone(),
- PublicKey::from_bytes([3; 32]),
- DurableTerminalOutcome::Completed,
- Some(42),
- );
- assert_eq!(receipt.request_id(), &request);
- assert_eq!(receipt.resulting_revision(), Some(42));
- for invalid in ["", "contains space", &"x".repeat(129)] {
- assert!(DurableRequestId::parse(invalid).is_err());
- }
- }
-
- #[derive(Default)]
- struct FakePorts {
- selected: Mutex<Option<PublicKey>>,
- }
-
- impl AccountRepository for FakePorts {
- fn list_accounts(&self) -> Result<Vec<AccountSummary>, SafeError> {
- Ok(Vec::new())
- }
-
- fn find_account(
- &self,
- _public_key: PublicKey,
- ) -> Result<Option<AccountSummary>, SafeError> {
- Ok(None)
- }
-
- fn insert_account(&self, _account: &AccountSummary) -> Result<(), SafeError> {
- Ok(())
- }
-
- fn update_account(&self, _account: &AccountSummary) -> Result<(), SafeError> {
- Ok(())
- }
-
- fn remove_account(&self, _public_key: PublicKey) -> Result<(), SafeError> {
- Ok(())
- }
- }
-
- impl ProfileRepository for FakePorts {
- fn load_profile(&self, _public_key: PublicKey) -> Result<Option<CachedProfile>, SafeError> {
- Ok(None)
- }
-
- fn save_profile(&self, _profile: &CachedProfile) -> Result<(), SafeError> {
- Ok(())
- }
-
- fn record_refresh_status(
- &self,
- _public_key: PublicKey,
- _refreshed_at: UnixTimestamp,
- _status: ProfileRefreshStatus,
- ) -> Result<(), SafeError> {
- Ok(())
- }
-
- fn remove_profile(&self, _public_key: PublicKey) -> Result<(), SafeError> {
- Ok(())
- }
- }
-
- impl AccountNamespaceRepository for FakePorts {
- fn get_value(
- &self,
- _owner: PublicKey,
- _key: AccountPreferenceKey,
- ) -> Result<Option<String>, SafeError> {
- Ok(None)
- }
-
- fn set_value(
- &self,
- _owner: PublicKey,
- _key: AccountPreferenceKey,
- _value: &str,
- ) -> Result<(), SafeError> {
- Ok(())
- }
-
- fn clear_owner(&self, _owner: PublicKey) -> Result<(), SafeError> {
- Ok(())
- }
- }
-
- impl AppStateRepository for FakePorts {
- fn load_selected_account(&self) -> Result<Option<PublicKey>, SafeError> {
- Ok(*self.selected.lock().expect("selected lock"))
- }
-
- fn save_selected_account(&self, public_key: Option<PublicKey>) -> Result<(), SafeError> {
- *self.selected.lock().expect("selected lock") = public_key;
- Ok(())
- }
- }
-
- impl OperationJournal for FakePorts {
- fn begin_operation(
- &self,
- _kind: AccountOperationKind,
- _subject: PublicKey,
- _updated_at: UnixTimestamp,
- ) -> Result<OperationId, SafeError> {
- Ok(OperationId::from_raw(1))
- }
-
- fn update_operation(
- &self,
- _id: OperationId,
- _phase: AccountOperationPhase,
- _updated_at: UnixTimestamp,
- _diagnostic: Option<OperationDiagnostic>,
- ) -> Result<(), SafeError> {
- Ok(())
- }
-
- fn list_pending_operations(&self) -> Result<Vec<PendingAccountOperation>, SafeError> {
- Ok(Vec::new())
- }
-
- fn finalize_operation(&self, _id: OperationId) -> Result<(), SafeError> {
- Ok(())
- }
- }
-
- impl NostrClient for FakePorts {
- fn fetch_profile<'a>(
- &'a self,
- _public_key: PublicKey,
- _relays: &'a [RelayUrl],
- ) -> BoxFuture<'a, Result<Option<Kind0ProfileCandidate>, SafeError>> {
- Box::pin(async { Ok(None) })
- }
- }
-
- impl Clock for FakePorts {
- fn now(&self) -> UnixTimestamp {
- UnixTimestamp::from_seconds(1).expect("valid fake time")
- }
- }
-
- fn assert_send_sync<T: Send + Sync>() {}
-
- #[test]
- fn ports_accept_send_sync_test_fakes() {
- assert_send_sync::<FakePorts>();
-
- let ports = FakePorts::default();
- ports
- .save_selected_account(Some(PublicKey::from_bytes([1_u8; 32])))
- .expect("save selection");
- assert_eq!(
- ports.load_selected_account().expect("load selection"),
- Some(PublicKey::from_bytes([1_u8; 32]))
- );
- assert_eq!(ports.now().as_seconds(), 1);
- }
-}
diff --git a/core/crates/application/src/profile_refresh.rs b/core/crates/application/src/profile_refresh.rs
@@ -1,559 +0,0 @@
-use radroots_studio_domain::{PublicKey, RelayUrl, SafeError, SafeErrorCode};
-
-use crate::{
- ActiveAccountSnapshot, AppCore, AppSnapshot, CachedProfile, Clock, NostrClient,
- ProfileLoadState, ProfileRefreshStatus, ProfileRepository, RelayConnectionState,
- SnapshotRevision, StateTransition,
-};
-
-#[derive(Clone, Debug, Eq, PartialEq)]
-pub struct ProfileRefreshPlan {
- public_key: PublicKey,
- active_account: ActiveAccountSnapshot,
- relays: Vec<RelayUrl>,
- expected_revision: SnapshotRevision,
-}
-
-impl ProfileRefreshPlan {
- #[must_use]
- pub const fn public_key(&self) -> PublicKey {
- self.public_key
- }
-
- #[must_use]
- pub const fn active_account(&self) -> &ActiveAccountSnapshot {
- &self.active_account
- }
-
- #[must_use]
- pub fn relays(&self) -> &[RelayUrl] {
- &self.relays
- }
-
- #[must_use]
- pub const fn expected_revision(&self) -> SnapshotRevision {
- self.expected_revision
- }
-}
-
-impl AppCore {
- /// Manually refreshes the active account's Nostr kind-0 profile.
- ///
- /// Cached public metadata remains visible while the asynchronous request is
- /// running. Calling this command while signed out is an idempotent no-op.
- ///
- /// # Errors
- ///
- /// Returns a safe storage or application-state error. Relay and invalid-data
- /// failures are represented as nonfatal snapshot state.
- pub async fn refresh_active_profile(
- &self,
- profiles: &(impl ProfileRepository + ?Sized),
- client: &(impl NostrClient + ?Sized),
- clock: &(impl Clock + ?Sized),
- ) -> Result<AppSnapshot, SafeError> {
- self.refresh_profile_for_active_account(profiles, client, clock)
- .await
- }
-
- /// Refreshes the current active account while retaining any cached profile.
- ///
- /// Stale results are discarded when the account is replaced or signed out.
- ///
- /// # Errors
- ///
- /// Returns a safe storage or application-state error. Relay and invalid-data
- /// failures are represented as nonfatal snapshot state.
- async fn refresh_profile_for_active_account(
- &self,
- profiles: &(impl ProfileRepository + ?Sized),
- client: &(impl NostrClient + ?Sized),
- clock: &(impl Clock + ?Sized),
- ) -> Result<AppSnapshot, SafeError> {
- let Some(plan) = self.begin_profile_refresh()? else {
- return Ok(self.snapshot());
- };
- let result = client.fetch_profile(plan.public_key(), plan.relays()).await;
- self.complete_profile_refresh(&plan, result, profiles, clock)
- }
-
- /// Begins a refresh on the actor and returns the immutable network plan.
- ///
- /// # Errors
- ///
- /// Returns a safe state error when the loading transition is invalid.
- pub fn begin_profile_refresh(&self) -> Result<Option<ProfileRefreshPlan>, SafeError> {
- let Some(active) = self.snapshot().active_account().cloned() else {
- return Ok(None);
- };
- let public_key = active.account().public_key();
- let loading = self.apply_transition(StateTransition::UpdateActiveAccount {
- expected: public_key,
- active_account: Box::new(ActiveAccountSnapshot::new(
- active.account().clone(),
- RelayConnectionState::Connecting,
- ProfileLoadState::Loading,
- active.profile().cloned(),
- )),
- problem: None,
- })?;
- Ok(Some(ProfileRefreshPlan {
- public_key,
- active_account: active,
- relays: loading.relay_configuration().relays().to_vec(),
- expected_revision: loading.revision(),
- }))
- }
-
- /// Applies a correlated refresh result on the actor.
- ///
- /// # Errors
- ///
- /// Returns a safe storage or application-state error. Stale results are
- /// discarded without persistence or publication.
- pub fn complete_profile_refresh(
- &self,
- plan: &ProfileRefreshPlan,
- result: Result<Option<radroots_studio_domain::Kind0ProfileCandidate>, SafeError>,
- profiles: &(impl ProfileRepository + ?Sized),
- clock: &(impl Clock + ?Sized),
- ) -> Result<AppSnapshot, SafeError> {
- if !is_current_active(self, plan.public_key()) {
- return Ok(self.snapshot());
- }
-
- let current_active = self
- .snapshot()
- .active_account()
- .cloned()
- .ok_or_else(invalid_profile_completion)?;
-
- match result {
- Ok(Some(candidate)) => {
- let cached = CachedProfile::new(
- candidate.clone(),
- clock.now(),
- ProfileRefreshStatus::Success,
- );
- profiles.save_profile(&cached)?;
- let winning_profile = profiles.load_profile(plan.public_key())?.map_or_else(
- || candidate.metadata().clone(),
- |profile| profile.candidate().metadata().clone(),
- );
- self.apply_transition(StateTransition::UpdateActiveAccount {
- expected: plan.public_key(),
- active_account: Box::new(ActiveAccountSnapshot::new(
- current_active.account().clone(),
- RelayConnectionState::Connected,
- ProfileLoadState::Fresh,
- Some(winning_profile),
- )),
- problem: None,
- })
- }
- Ok(None) => self.apply_transition(StateTransition::UpdateActiveAccount {
- expected: plan.public_key(),
- active_account: Box::new(ActiveAccountSnapshot::new(
- current_active.account().clone(),
- RelayConnectionState::Connected,
- if current_active.profile().is_some() {
- ProfileLoadState::Cached
- } else {
- ProfileLoadState::Empty
- },
- current_active.profile().cloned(),
- )),
- problem: None,
- }),
- Err(error) => {
- let status = refresh_status(error);
- profiles.record_refresh_status(plan.public_key(), clock.now(), status)?;
- self.apply_transition(StateTransition::UpdateActiveAccount {
- expected: plan.public_key(),
- active_account: Box::new(ActiveAccountSnapshot::new(
- current_active.account().clone(),
- RelayConnectionState::Degraded,
- ProfileLoadState::Error(error),
- current_active.profile().cloned(),
- )),
- problem: Some(error),
- })
- }
- }
- }
-}
-
-const fn invalid_profile_completion() -> SafeError {
- SafeError::new(
- SafeErrorCode::InvalidApplicationState,
- radroots_studio_domain::SafeMessage::new("The active profile refresh is no longer valid."),
- )
-}
-
-fn is_current_active(core: &AppCore, public_key: PublicKey) -> bool {
- core.snapshot()
- .active_account()
- .is_some_and(|active| active.account().public_key() == public_key)
-}
-
-const fn refresh_status(error: SafeError) -> ProfileRefreshStatus {
- match error.code() {
- SafeErrorCode::InvalidProfileMetadata | SafeErrorCode::ProfileRefreshFailed => {
- ProfileRefreshStatus::InvalidData
- }
- _ => ProfileRefreshStatus::Offline,
- }
-}
-
-#[cfg(test)]
-mod tests {
- use std::sync::Mutex;
-
- use radroots_studio_domain::{
- EventId, Kind0ProfileCandidate, ProfileMetadata, PublicKey, RelayUrl, SafeError,
- SafeErrorCode, SafeMessage, SecretKeyInput, UnixTimestamp, select_latest_kind0,
- };
-
- use crate::{
- ActiveAccountSnapshot, AppCore, BoxFuture, CachedProfile, Clock, InMemoryAccountRepository,
- InMemoryOperationJournal, InMemorySecretStore, NostrClient, ProfileLoadState,
- ProfileRefreshStatus, ProfileRepository, RelayConfiguration, RelayConnectionState,
- };
-
- #[derive(Default)]
- struct MemoryProfiles(Mutex<Option<CachedProfile>>);
-
- impl ProfileRepository for MemoryProfiles {
- fn load_profile(&self, _public_key: PublicKey) -> Result<Option<CachedProfile>, SafeError> {
- Ok(self.0.lock().expect("profiles").clone())
- }
- fn save_profile(&self, profile: &CachedProfile) -> Result<(), SafeError> {
- let mut cached = self.0.lock().expect("profiles");
- let selected = cached.as_ref().map_or_else(
- || profile.clone(),
- |current| {
- let winner = select_latest_kind0([
- current.candidate().clone(),
- profile.candidate().clone(),
- ])
- .expect("two candidates");
- if &winner == current.candidate() {
- current.clone()
- } else {
- profile.clone()
- }
- },
- );
- *cached = Some(selected);
- Ok(())
- }
- fn record_refresh_status(
- &self,
- _public_key: PublicKey,
- refreshed_at: UnixTimestamp,
- status: ProfileRefreshStatus,
- ) -> Result<(), SafeError> {
- if let Some(profile) = self.0.lock().expect("profiles").as_mut() {
- *profile = CachedProfile::new(profile.candidate().clone(), refreshed_at, status);
- }
- Ok(())
- }
- fn remove_profile(&self, _public_key: PublicKey) -> Result<(), SafeError> {
- *self.0.lock().expect("profiles") = None;
- Ok(())
- }
- }
-
- struct FixedClock;
- impl Clock for FixedClock {
- fn now(&self) -> UnixTimestamp {
- UnixTimestamp::from_seconds(50).expect("time")
- }
- }
-
- struct FixedClient(Result<Option<Kind0ProfileCandidate>, SafeError>);
- impl NostrClient for FixedClient {
- fn fetch_profile<'a>(
- &'a self,
- _public_key: PublicKey,
- _relays: &'a [RelayUrl],
- ) -> BoxFuture<'a, Result<Option<Kind0ProfileCandidate>, SafeError>> {
- let result = self.0.clone();
- Box::pin(async move { result })
- }
- }
-
- struct BlockingClient {
- started: tokio::sync::Semaphore,
- release: tokio::sync::Semaphore,
- result: Result<Option<Kind0ProfileCandidate>, SafeError>,
- }
-
- impl BlockingClient {
- fn new(result: Result<Option<Kind0ProfileCandidate>, SafeError>) -> Self {
- Self {
- started: tokio::sync::Semaphore::new(0),
- release: tokio::sync::Semaphore::new(0),
- result,
- }
- }
- }
-
- impl NostrClient for BlockingClient {
- fn fetch_profile<'a>(
- &'a self,
- _public_key: PublicKey,
- _relays: &'a [RelayUrl],
- ) -> BoxFuture<'a, Result<Option<Kind0ProfileCandidate>, SafeError>> {
- Box::pin(async move {
- self.started.add_permits(1);
- let permit = self.release.acquire().await.expect("release open");
- permit.forget();
- self.result.clone()
- })
- }
- }
-
- fn profile(public_key: PublicKey, name: &str, timestamp: i64) -> Kind0ProfileCandidate {
- Kind0ProfileCandidate::new(
- EventId::from_bytes([u8::try_from(timestamp).expect("small timestamp"); 32]),
- public_key,
- UnixTimestamp::from_seconds(timestamp).expect("time"),
- ProfileMetadata::new(Some(name.to_owned()), None, None, None, None).expect("profile"),
- )
- }
-
- fn active_core(profiles: &MemoryProfiles, cached_name: Option<&str>) -> (AppCore, PublicKey) {
- let relays =
- RelayConfiguration::new(vec![RelayUrl::parse("ws://localhost:8080").expect("relay")]);
- let core = AppCore::in_memory(relays);
- let accounts = InMemoryAccountRepository::default();
- let secrets = InMemorySecretStore::default();
- let journal = InMemoryOperationJournal::default();
- core.bootstrap().expect("bootstrap");
- let public_key = core
- .import_secret_key(
- SecretKeyInput::parse(
- "7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7".to_owned(),
- )
- .expect("secret"),
- &accounts,
- &accounts,
- &secrets,
- &journal,
- &FixedClock,
- )
- .expect("import")
- .account()
- .public_key();
- if let Some(name) = cached_name {
- profiles
- .save_profile(&CachedProfile::new(
- profile(public_key, name, 10),
- UnixTimestamp::from_seconds(11).expect("time"),
- ProfileRefreshStatus::Success,
- ))
- .expect("cache");
- }
- core.activate_account(
- public_key,
- &accounts,
- &accounts,
- profiles,
- &secrets,
- &FixedClock,
- )
- .expect("activate");
- (core, public_key)
- }
-
- #[tokio::test]
- async fn refresh_transitions_from_cache_through_loading_to_fresh_profile() {
- let profiles = MemoryProfiles::default();
- let (core, public_key) = active_core(&profiles, Some("Cached"));
- assert_eq!(
- core.snapshot()
- .active_account()
- .map(crate::ActiveAccountSnapshot::profile_state),
- Some(ProfileLoadState::Cached)
- );
- let plan = core
- .begin_profile_refresh()
- .expect("begin refresh")
- .expect("active refresh");
- let loading = core.snapshot();
- assert_eq!(
- loading
- .active_account()
- .map(crate::ActiveAccountSnapshot::profile_state),
- Some(ProfileLoadState::Loading)
- );
- assert_eq!(
- loading
- .active_account()
- .map(crate::ActiveAccountSnapshot::relay_state),
- Some(RelayConnectionState::Connecting)
- );
- let client = FixedClient(Ok(Some(profile(public_key, "Fresh", 20))));
- let result = client.fetch_profile(plan.public_key(), plan.relays()).await;
- core.complete_profile_refresh(&plan, result, &profiles, &FixedClock)
- .expect("complete refresh");
- assert_eq!(
- core.snapshot()
- .active_account()
- .and_then(|active| active.profile())
- .and_then(ProfileMetadata::name),
- Some("Fresh")
- );
- }
-
- #[tokio::test]
- async fn refresh_failure_preserves_cached_profile_as_nonfatal_state() {
- let profiles = MemoryProfiles::default();
- let (core, public_key) = active_core(&profiles, Some("Cached"));
- let cached = profile(public_key, "Cached", 10);
- let error = SafeError::new(
- SafeErrorCode::RelayConnectionFailed,
- SafeMessage::new("The relay is offline."),
- );
-
- let snapshot = core
- .refresh_profile_for_active_account(&profiles, &FixedClient(Err(error)), &FixedClock)
- .await
- .expect("nonfatal refresh");
-
- assert_eq!(snapshot.recoverable_problem(), Some(error));
- assert_eq!(
- snapshot
- .active_account()
- .map(crate::ActiveAccountSnapshot::relay_state),
- Some(RelayConnectionState::Degraded)
- );
- assert_eq!(
- profiles
- .load_profile(public_key)
- .expect("load")
- .expect("cache")
- .candidate(),
- &cached
- );
- }
-
- #[tokio::test]
- async fn refresh_discards_stale_completion_after_sign_out() {
- let profiles = MemoryProfiles::default();
- let (core, public_key) = active_core(&profiles, Some("Cached"));
- let client = BlockingClient::new(Ok(Some(profile(public_key, "Stale", 20))));
-
- let refresh = core.refresh_profile_for_active_account(&profiles, &client, &FixedClock);
- let sign_out = async {
- let permit = client.started.acquire().await.expect("refresh starts");
- permit.forget();
- core.sign_out().expect("sign out");
- client.release.add_permits(1);
- };
- let (result, ()) = tokio::join!(refresh, sign_out);
-
- assert!(
- result
- .expect("stale result is harmless")
- .active_account()
- .is_none()
- );
- assert_eq!(
- profiles
- .load_profile(public_key)
- .expect("load")
- .expect("cached")
- .candidate()
- .metadata()
- .name(),
- Some("Cached")
- );
- }
-
- #[tokio::test]
- async fn manual_refresh_is_repeatable_and_signed_out_safe() {
- let profiles = MemoryProfiles::default();
- let (core, public_key) = active_core(&profiles, None);
- let first = core
- .refresh_active_profile(
- &profiles,
- &FixedClient(Ok(Some(profile(public_key, "First", 10)))),
- &FixedClock,
- )
- .await
- .expect("first refresh");
- let second = core
- .refresh_active_profile(
- &profiles,
- &FixedClient(Ok(Some(profile(public_key, "Second", 20)))),
- &FixedClock,
- )
- .await
- .expect("second refresh");
-
- assert!(second.revision() > first.revision());
- assert_eq!(
- second
- .active_account()
- .and_then(|active| active.profile())
- .and_then(ProfileMetadata::name),
- Some("Second")
- );
- let signed_out = core.sign_out().expect("sign out");
- let no_op = core
- .refresh_active_profile(&profiles, &FixedClient(Ok(None)), &FixedClock)
- .await
- .expect("signed-out no-op");
- assert_eq!(no_op, signed_out);
- }
-
- #[test]
- fn overlapping_refreshes_keep_the_newest_event_regardless_of_completion_order() {
- let profiles = MemoryProfiles::default();
- let (core, public_key) = active_core(&profiles, Some("Cached"));
- let first = core
- .begin_profile_refresh()
- .expect("first")
- .expect("active");
- let second = core
- .begin_profile_refresh()
- .expect("second")
- .expect("active");
-
- core.complete_profile_refresh(
- &second,
- Ok(Some(profile(public_key, "Newest", 30))),
- &profiles,
- &FixedClock,
- )
- .expect("newest completes first");
- let final_snapshot = core
- .complete_profile_refresh(
- &first,
- Ok(Some(profile(public_key, "Older", 20))),
- &profiles,
- &FixedClock,
- )
- .expect("older completes last");
-
- assert_eq!(
- final_snapshot
- .active_account()
- .and_then(ActiveAccountSnapshot::profile)
- .and_then(ProfileMetadata::name),
- Some("Newest")
- );
- assert_eq!(
- profiles
- .load_profile(public_key)
- .expect("cache")
- .expect("profile")
- .candidate()
- .metadata()
- .name(),
- Some("Newest")
- );
- }
-}
diff --git a/core/crates/application/src/recovery.rs b/core/crates/application/src/recovery.rs
@@ -1,358 +0,0 @@
-use radroots_studio_domain::{PublicKey, SafeError};
-
-use crate::{
- AccountOperationKind, AccountOperationPhase, AccountRepository, AppCore, AppStateRepository,
- Clock, DurableAccountOperation, DurableOperationKind, DurableOperationPhase,
- DurableOperationRepository, DurableTerminalOutcome, OperationJournal, SecretStore,
-};
-
-impl AppCore {
- /// Reconciles durable request operations before public state is restored.
- ///
- /// # Errors
- ///
- /// Returns a safe credential, persistence, or recovery error while retaining the operation
- /// at its last durable phase for a later retry.
- pub fn recover_durable_operations(
- &self,
- accounts: &(impl AccountRepository + ?Sized),
- app_state: &(impl AppStateRepository + ?Sized),
- secrets: &(impl SecretStore + ?Sized),
- operations: &(impl DurableOperationRepository + ?Sized),
- clock: &(impl Clock + ?Sized),
- ) -> Result<(), SafeError> {
- for operation in operations.list_unfinished_durable_operations()? {
- match operation.kind() {
- DurableOperationKind::Create
- | DurableOperationKind::Import
- | DurableOperationKind::Repair => recover_durable_addition(
- &operation, accounts, app_state, secrets, operations, clock,
- )?,
- DurableOperationKind::Remove => recover_durable_removal(
- &operation, accounts, app_state, secrets, operations, clock,
- )?,
- }
- }
- Ok(())
- }
-
- /// Reconciles non-secret cross-resource journal entries before bootstrap.
- ///
- /// An empty journal does not access the credential store.
- ///
- /// # Errors
- ///
- /// Returns a safe credential, persistence, or recovery error while retaining
- /// the unfinished journal entry for a later retry.
- pub fn recover_pending_operations(
- &self,
- accounts: &(impl AccountRepository + ?Sized),
- app_state: &(impl AppStateRepository + ?Sized),
- secrets: &(impl SecretStore + ?Sized),
- journal: &(impl OperationJournal + ?Sized),
- clock: &(impl Clock + ?Sized),
- ) -> Result<(), SafeError> {
- for operation in journal.list_pending_operations()? {
- match operation.kind() {
- AccountOperationKind::Remove => {
- recover_removal(&operation, accounts, app_state, secrets, journal, clock)?;
- }
- AccountOperationKind::Add | AccountOperationKind::Import => {
- recover_addition(&operation, accounts, secrets, journal, clock)?;
- }
- }
- }
- Ok(())
- }
-}
-
-fn recover_durable_removal(
- operation: &DurableAccountOperation,
- accounts: &(impl AccountRepository + ?Sized),
- app_state: &(impl AppStateRepository + ?Sized),
- secrets: &(impl SecretStore + ?Sized),
- operations: &(impl DurableOperationRepository + ?Sized),
- clock: &(impl Clock + ?Sized),
-) -> Result<(), SafeError> {
- let request = operation.request_id();
- let account = operation.account();
- let mut phase = operation.phase();
- if phase == DurableOperationPhase::IntentRecorded {
- if secrets.contains(account)? {
- secrets.delete(account)?;
- }
- operations.advance_durable_operation(
- request,
- phase,
- DurableOperationPhase::CredentialDeleted,
- clock.now(),
- None,
- )?;
- phase = DurableOperationPhase::CredentialDeleted;
- }
- if phase == DurableOperationPhase::CredentialDeleted {
- if accounts.find_account(account)?.is_some() {
- accounts.remove_account(account)?;
- }
- operations.advance_durable_operation(
- request,
- phase,
- DurableOperationPhase::MetadataDeleted,
- clock.now(),
- None,
- )?;
- phase = DurableOperationPhase::MetadataDeleted;
- }
- if phase == DurableOperationPhase::MetadataDeleted {
- app_state.save_selected_account(operation.prior().selected_account())?;
- operations.advance_durable_operation(
- request,
- phase,
- DurableOperationPhase::SelectionCommitted,
- clock.now(),
- None,
- )?;
- phase = DurableOperationPhase::SelectionCommitted;
- }
- if phase == DurableOperationPhase::SelectionCommitted {
- operations.finalize_durable_operation(
- request,
- phase,
- DurableTerminalOutcome::Completed,
- None,
- clock.now(),
- )?;
- }
- Ok(())
-}
-
-fn recover_durable_addition(
- operation: &DurableAccountOperation,
- accounts: &(impl AccountRepository + ?Sized),
- app_state: &(impl AppStateRepository + ?Sized),
- secrets: &(impl SecretStore + ?Sized),
- operations: &(impl DurableOperationRepository + ?Sized),
- clock: &(impl Clock + ?Sized),
-) -> Result<(), SafeError> {
- let request = operation.request_id();
- let account = operation.account();
- match operation.phase() {
- DurableOperationPhase::IntentRecorded => {
- if secrets.contains(account)? {
- secrets.delete(account)?;
- }
- operations.finalize_durable_operation(
- request,
- DurableOperationPhase::IntentRecorded,
- DurableTerminalOutcome::Failed,
- None,
- clock.now(),
- )?;
- }
- DurableOperationPhase::CredentialWritten => {
- let metadata = accounts.find_account(account)?;
- let committed = metadata.as_ref().is_some_and(|saved| {
- saved.signer().availability()
- == radroots_studio_domain::BindingAvailability::Available
- });
- if committed {
- operations.advance_durable_operation(
- request,
- DurableOperationPhase::CredentialWritten,
- DurableOperationPhase::MetadataCommitted,
- clock.now(),
- None,
- )?;
- finish_durable_selection(operation, app_state, operations, clock)?;
- } else {
- operations.advance_durable_operation(
- request,
- DurableOperationPhase::CredentialWritten,
- DurableOperationPhase::CompensationPending,
- clock.now(),
- None,
- )?;
- compensate_durable_addition(
- operation, accounts, app_state, secrets, operations, clock,
- )?;
- }
- }
- DurableOperationPhase::MetadataCommitted => {
- finish_durable_selection(operation, app_state, operations, clock)?;
- }
- DurableOperationPhase::SelectionCommitted => {
- operations.finalize_durable_operation(
- request,
- DurableOperationPhase::SelectionCommitted,
- DurableTerminalOutcome::Completed,
- None,
- clock.now(),
- )?;
- }
- DurableOperationPhase::CompensationPending => {
- compensate_durable_addition(
- operation, accounts, app_state, secrets, operations, clock,
- )?;
- }
- DurableOperationPhase::CredentialDeleted | DurableOperationPhase::MetadataDeleted => {
- operations.finalize_durable_operation(
- request,
- operation.phase(),
- DurableTerminalOutcome::Failed,
- None,
- clock.now(),
- )?;
- }
- DurableOperationPhase::Finalized => {}
- }
- Ok(())
-}
-
-fn finish_durable_selection(
- operation: &DurableAccountOperation,
- app_state: &(impl AppStateRepository + ?Sized),
- operations: &(impl DurableOperationRepository + ?Sized),
- clock: &(impl Clock + ?Sized),
-) -> Result<(), SafeError> {
- app_state.save_selected_account(Some(operation.account()))?;
- operations.advance_durable_operation(
- operation.request_id(),
- DurableOperationPhase::MetadataCommitted,
- DurableOperationPhase::SelectionCommitted,
- clock.now(),
- None,
- )?;
- operations.finalize_durable_operation(
- operation.request_id(),
- DurableOperationPhase::SelectionCommitted,
- DurableTerminalOutcome::Completed,
- None,
- clock.now(),
- )?;
- Ok(())
-}
-
-fn compensate_durable_addition(
- operation: &DurableAccountOperation,
- accounts: &(impl AccountRepository + ?Sized),
- app_state: &(impl AppStateRepository + ?Sized),
- secrets: &(impl SecretStore + ?Sized),
- operations: &(impl DurableOperationRepository + ?Sized),
- clock: &(impl Clock + ?Sized),
-) -> Result<(), SafeError> {
- if secrets.contains(operation.account())? {
- secrets.delete(operation.account())?;
- }
- if let Some(availability) = operation.prior().binding_availability() {
- if let Some(previous) = accounts.find_account(operation.account())? {
- accounts.update_account(&previous.with_binding_availability(availability))?;
- }
- } else if accounts.find_account(operation.account())?.is_some() {
- accounts.remove_account(operation.account())?;
- }
- app_state.save_selected_account(operation.prior().selected_account())?;
- operations.advance_durable_operation(
- operation.request_id(),
- DurableOperationPhase::CompensationPending,
- DurableOperationPhase::CredentialDeleted,
- clock.now(),
- None,
- )?;
- operations.finalize_durable_operation(
- operation.request_id(),
- DurableOperationPhase::CredentialDeleted,
- DurableTerminalOutcome::Failed,
- None,
- clock.now(),
- )?;
- Ok(())
-}
-
-fn recover_removal(
- operation: &crate::PendingAccountOperation,
- accounts: &(impl AccountRepository + ?Sized),
- app_state: &(impl AppStateRepository + ?Sized),
- secrets: &(impl SecretStore + ?Sized),
- journal: &(impl OperationJournal + ?Sized),
- clock: &(impl Clock + ?Sized),
-) -> Result<(), SafeError> {
- let public_key = operation.subject();
- if operation.phase() == AccountOperationPhase::IntentRecorded {
- match secrets.delete(public_key) {
- Ok(()) => {}
- Err(error)
- if error.code() == radroots_studio_domain::SafeErrorCode::CredentialMissing => {}
- Err(error) => return Err(error),
- }
- journal.update_operation(
- operation.id(),
- AccountOperationPhase::CredentialDeleted,
- clock.now(),
- None,
- )?;
- }
- if matches!(
- operation.phase(),
- AccountOperationPhase::IntentRecorded | AccountOperationPhase::CredentialDeleted
- ) {
- let registry = accounts.list_accounts()?;
- let selected = removal_fallback(®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/core/crates/application/src/secrets.rs b/core/crates/application/src/secrets.rs
@@ -1,308 +0,0 @@
-use std::collections::BTreeMap;
-use std::sync::{Mutex, MutexGuard};
-
-use radroots_studio_domain::{PublicKey, SafeError, SafeErrorCode, SafeMessage, SecretKeyInput};
-use secrecy::{ExposeSecret, SecretString};
-
-pub trait SecretStore: Send + Sync {
- /// Stores a credential under its canonical public key without overwriting.
- ///
- /// # Errors
- ///
- /// Returns a safe duplicate or keyring error without exposing the credential.
- fn put(&self, public_key: PublicKey, secret: SecretKeyInput) -> Result<(), SafeError>;
- /// Loads a credential into a non-cloneable redacted boundary value.
- ///
- /// # Errors
- ///
- /// Returns a safe missing-credential or keyring error.
- fn load(&self, public_key: PublicKey) -> Result<SecretKeyInput, SafeError>;
- /// Reports whether a credential exists without exposing it.
- ///
- /// # Errors
- ///
- /// Returns a safe keyring error when availability cannot be determined.
- fn contains(&self, public_key: PublicKey) -> Result<bool, SafeError>;
- /// Deletes a credential without affecting public account metadata.
- ///
- /// # Errors
- ///
- /// Returns a safe missing-credential or keyring error.
- fn delete(&self, public_key: PublicKey) -> Result<(), SafeError>;
-}
-
-#[derive(Default)]
-pub struct InMemorySecretStore {
- credentials: Mutex<BTreeMap<PublicKey, SecretString>>,
-}
-
-#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
-pub enum SecretStoreOperation {
- Put,
- Load,
- Contains,
- Delete,
-}
-
-#[derive(Clone, Copy, Debug, Eq, PartialEq)]
-pub struct SecretStoreCall {
- operation: SecretStoreOperation,
- public_key: PublicKey,
-}
-
-impl SecretStoreCall {
- #[must_use]
- pub const fn operation(self) -> SecretStoreOperation {
- self.operation
- }
-
- #[must_use]
- pub const fn public_key(self) -> PublicKey {
- self.public_key
- }
-}
-
-#[derive(Default)]
-pub struct FailureSecretStore {
- inner: InMemorySecretStore,
- remaining_failures: Mutex<BTreeMap<SecretStoreOperation, usize>>,
- calls: Mutex<Vec<SecretStoreCall>>,
-}
-
-impl FailureSecretStore {
- pub fn fail_next(&self, operation: SecretStoreOperation) {
- *self
- .remaining_failures
- .lock()
- .unwrap_or_else(std::sync::PoisonError::into_inner)
- .entry(operation)
- .or_default() += 1;
- }
-
- #[must_use]
- pub fn calls(&self) -> Vec<SecretStoreCall> {
- self.calls
- .lock()
- .unwrap_or_else(std::sync::PoisonError::into_inner)
- .clone()
- }
-
- fn record_and_should_fail(
- &self,
- operation: SecretStoreOperation,
- public_key: PublicKey,
- ) -> bool {
- self.calls
- .lock()
- .unwrap_or_else(std::sync::PoisonError::into_inner)
- .push(SecretStoreCall {
- operation,
- public_key,
- });
- let mut failures = self
- .remaining_failures
- .lock()
- .unwrap_or_else(std::sync::PoisonError::into_inner);
- let remaining = failures.entry(operation).or_default();
- let should_fail = *remaining > 0;
- *remaining = remaining.saturating_sub(1);
- should_fail
- }
-}
-
-impl SecretStore for FailureSecretStore {
- fn put(&self, public_key: PublicKey, secret: SecretKeyInput) -> Result<(), SafeError> {
- if self.record_and_should_fail(SecretStoreOperation::Put, public_key) {
- return Err(keyring_unavailable());
- }
- self.inner.put(public_key, secret)
- }
-
- fn load(&self, public_key: PublicKey) -> Result<SecretKeyInput, SafeError> {
- if self.record_and_should_fail(SecretStoreOperation::Load, public_key) {
- return Err(keyring_unavailable());
- }
- self.inner.load(public_key)
- }
-
- fn contains(&self, public_key: PublicKey) -> Result<bool, SafeError> {
- if self.record_and_should_fail(SecretStoreOperation::Contains, public_key) {
- return Err(keyring_unavailable());
- }
- self.inner.contains(public_key)
- }
-
- fn delete(&self, public_key: PublicKey) -> Result<(), SafeError> {
- if self.record_and_should_fail(SecretStoreOperation::Delete, public_key) {
- return Err(keyring_unavailable());
- }
- self.inner.delete(public_key)
- }
-}
-
-impl InMemorySecretStore {
- fn credentials(&self) -> MutexGuard<'_, BTreeMap<PublicKey, SecretString>> {
- self.credentials
- .lock()
- .unwrap_or_else(std::sync::PoisonError::into_inner)
- }
-}
-
-impl SecretStore for InMemorySecretStore {
- fn put(&self, public_key: PublicKey, secret: SecretKeyInput) -> Result<(), SafeError> {
- let mut credentials = self.credentials();
- if credentials.contains_key(&public_key) {
- return Err(credential_exists());
- }
- let value = secret.with_exposed_secret(ToOwned::to_owned);
- credentials.insert(public_key, SecretString::from(value));
- Ok(())
- }
-
- fn load(&self, public_key: PublicKey) -> Result<SecretKeyInput, SafeError> {
- let credentials = self.credentials();
- let secret = credentials
- .get(&public_key)
- .ok_or_else(credential_missing)?;
- SecretKeyInput::parse(secret.expose_secret().to_owned()).map_err(|_| credential_missing())
- }
-
- fn contains(&self, public_key: PublicKey) -> Result<bool, SafeError> {
- Ok(self.credentials().contains_key(&public_key))
- }
-
- fn delete(&self, public_key: PublicKey) -> Result<(), SafeError> {
- self.credentials()
- .remove(&public_key)
- .map(|_| ())
- .ok_or_else(credential_missing)
- }
-}
-
-const fn credential_exists() -> SafeError {
- SafeError::new(
- SafeErrorCode::AccountAlreadyExists,
- SafeMessage::new("The Nostr account credential already exists."),
- )
-}
-
-const fn credential_missing() -> SafeError {
- SafeError::new(
- SafeErrorCode::CredentialMissing,
- SafeMessage::new("The Nostr account credential is missing."),
- )
-}
-
-const fn keyring_unavailable() -> SafeError {
- SafeError::new(
- SafeErrorCode::KeyringUnavailable,
- SafeMessage::new("The operating system credential store is unavailable."),
- )
-}
-
-#[cfg(test)]
-mod tests {
- use radroots_studio_domain::{PublicKey, SafeErrorCode, SecretKeyInput};
-
- use super::{FailureSecretStore, InMemorySecretStore, SecretStore, SecretStoreOperation};
-
- const SECRET: &str = "7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7";
-
- #[test]
- fn secret_store_puts_loads_checks_and_deletes_redacted_credentials() {
- let store = InMemorySecretStore::default();
- let public_key = PublicKey::from_bytes([1; 32]);
- assert!(!store.contains(public_key).expect("contains"));
- store
- .put(
- public_key,
- SecretKeyInput::parse(SECRET.to_owned()).expect("secret"),
- )
- .expect("put");
- assert!(store.contains(public_key).expect("contains"));
- let loaded = store.load(public_key).expect("load");
- assert_eq!(loaded.with_exposed_secret(str::len), 64);
- store.delete(public_key).expect("delete");
- assert!(!store.contains(public_key).expect("contains"));
- }
-
- #[test]
- fn secret_store_rejects_duplicates_and_reports_missing_credentials() {
- let store = InMemorySecretStore::default();
- let public_key = PublicKey::from_bytes([2; 32]);
- let Err(missing) = store.load(public_key) else {
- panic!("missing credential was returned");
- };
- assert_eq!(missing.code(), SafeErrorCode::CredentialMissing);
- store
- .put(
- public_key,
- SecretKeyInput::parse(SECRET.to_owned()).expect("secret"),
- )
- .expect("put");
- let duplicate = store
- .put(
- public_key,
- SecretKeyInput::parse(SECRET.to_owned()).expect("secret"),
- )
- .expect_err("duplicate");
- assert_eq!(duplicate.code(), SafeErrorCode::AccountAlreadyExists);
- store.delete(public_key).expect("delete");
- let missing = store.delete(public_key).expect_err("missing delete");
- assert_eq!(missing.code(), SafeErrorCode::CredentialMissing);
- }
-
- #[test]
- fn failure_secret_store_injects_each_boundary_without_mutating_state() {
- let store = FailureSecretStore::default();
- let public_key = PublicKey::from_bytes([3; 32]);
- store.fail_next(SecretStoreOperation::Put);
- let error = store
- .put(
- public_key,
- SecretKeyInput::parse(SECRET.to_owned()).expect("secret"),
- )
- .expect_err("put failure");
- assert_eq!(error.code(), SafeErrorCode::KeyringUnavailable);
- assert!(!store.contains(public_key).expect("not written"));
-
- store
- .put(
- public_key,
- SecretKeyInput::parse(SECRET.to_owned()).expect("secret"),
- )
- .expect("put");
- for operation in [
- SecretStoreOperation::Load,
- SecretStoreOperation::Contains,
- SecretStoreOperation::Delete,
- ] {
- store.fail_next(operation);
- let error = match operation {
- SecretStoreOperation::Load => store.load(public_key).map(|_| ()),
- SecretStoreOperation::Contains => store.contains(public_key).map(|_| ()),
- SecretStoreOperation::Delete => store.delete(public_key),
- SecretStoreOperation::Put => unreachable!("put tested separately"),
- }
- .expect_err("injected failure");
- assert_eq!(error.code(), SafeErrorCode::KeyringUnavailable);
- }
- assert!(store.contains(public_key).expect("credential retained"));
- }
-
- #[test]
- fn failure_secret_store_call_log_contains_only_public_identity() {
- let store = FailureSecretStore::default();
- let public_key = PublicKey::from_bytes([4; 32]);
- store
- .put(
- public_key,
- SecretKeyInput::parse(SECRET.to_owned()).expect("secret"),
- )
- .expect("put");
- let calls = store.calls();
- assert_eq!(calls[0].operation(), SecretStoreOperation::Put);
- assert_eq!(calls[0].public_key(), public_key);
- assert!(!format!("{calls:?}").contains(SECRET));
- }
-}
diff --git a/core/crates/application/src/session.rs b/core/crates/application/src/session.rs
@@ -1,250 +0,0 @@
-use radroots_studio_domain::{PublicKey, SafeError, SafeErrorCode, SafeMessage};
-use radroots_studio_nostr::import_secret;
-
-use crate::{
- AccountRepository, ActiveAccountSnapshot, AppCore, AppSnapshot, AppStateRepository, Clock,
- ProfileLoadState, ProfileRepository, RelayConnectionState, SecretStore, StateTransition,
-};
-
-impl AppCore {
- /// Drops the active session while retaining accounts, selection, and credentials.
- ///
- /// # Errors
- ///
- /// Returns a safe application-state error if the transition cannot be applied.
- pub fn sign_out(&self) -> Result<AppSnapshot, SafeError> {
- if matches!(self.snapshot().session(), crate::SessionState::SignedOut) {
- return Ok(self.snapshot());
- }
- self.apply_transition(StateTransition::SignOut)
- }
-
- /// Validates and prepares a saved local account before replacing the active session.
- ///
- /// # Errors
- ///
- /// Returns a safe account, credential, profile-cache, persistence, or state
- /// error while preserving any previously active session.
- pub fn activate_account(
- &self,
- public_key: PublicKey,
- accounts: &(impl AccountRepository + ?Sized),
- app_state: &(impl AppStateRepository + ?Sized),
- profiles: &(impl ProfileRepository + ?Sized),
- secrets: &(impl SecretStore + ?Sized),
- clock: &(impl Clock + ?Sized),
- ) -> Result<AppSnapshot, SafeError> {
- let account = accounts
- .find_account(public_key)?
- .ok_or_else(account_not_found)?;
- self.apply_transition(StateTransition::BeginActivation(public_key))?;
- let prepared = (|| {
- let credential = secrets.load(public_key)?;
- let imported = import_secret(credential)?;
- let (derived_public_key, _npub, canonical_secret) = imported.into_parts();
- drop(canonical_secret);
- if derived_public_key != public_key {
- return Err(invalid_credential());
- }
- let cached = profiles.load_profile(public_key)?;
- let active = ActiveAccountSnapshot::new(
- account.with_last_used_at(clock.now()),
- RelayConnectionState::Disconnected,
- if cached.is_some() {
- ProfileLoadState::Cached
- } else {
- ProfileLoadState::Empty
- },
- cached.map(|profile| profile.candidate().metadata().clone()),
- );
- accounts.update_account(active.account())?;
- app_state.save_selected_account(Some(public_key))?;
- Ok(active)
- })();
- match prepared {
- Ok(active) => {
- self.apply_transition(StateTransition::ActivationSucceeded(Box::new(active)))
- }
- Err(error) => {
- self.apply_transition(StateTransition::ActivationFailed(error))?;
- Err(error)
- }
- }
- }
-}
-
-const fn account_not_found() -> SafeError {
- SafeError::new(
- SafeErrorCode::AccountNotFound,
- SafeMessage::new("The account was not found."),
- )
-}
-
-const fn invalid_credential() -> SafeError {
- SafeError::new(
- SafeErrorCode::InvalidSecretKey,
- SafeMessage::new("The Nostr account credential is invalid."),
- )
-}
-
-#[cfg(test)]
-mod tests {
- use radroots_studio_domain::{PublicKey, SafeError, SecretKeyInput, UnixTimestamp};
-
- use crate::{
- AppCore, CachedProfile, Clock, InMemoryAccountRepository, InMemoryOperationJournal,
- InMemorySecretStore, ProfileRefreshStatus, ProfileRepository, RelayConfiguration,
- SecretStore, SessionState,
- };
-
- #[derive(Default)]
- struct EmptyProfiles;
-
- impl ProfileRepository for EmptyProfiles {
- fn load_profile(&self, _public_key: PublicKey) -> Result<Option<CachedProfile>, SafeError> {
- Ok(None)
- }
-
- fn save_profile(&self, _profile: &CachedProfile) -> Result<(), SafeError> {
- Ok(())
- }
-
- fn record_refresh_status(
- &self,
- _public_key: PublicKey,
- _refreshed_at: UnixTimestamp,
- _status: ProfileRefreshStatus,
- ) -> Result<(), SafeError> {
- Ok(())
- }
-
- fn remove_profile(&self, _public_key: PublicKey) -> Result<(), SafeError> {
- Ok(())
- }
- }
-
- struct FixedClock;
-
- impl Clock for FixedClock {
- fn now(&self) -> UnixTimestamp {
- UnixTimestamp::from_seconds(30).expect("time")
- }
- }
-
- fn input(value: &str) -> SecretKeyInput {
- SecretKeyInput::parse(value.to_owned()).expect("input")
- }
-
- #[test]
- fn activate_account_switches_only_after_candidate_is_ready() {
- let core = AppCore::in_memory(RelayConfiguration::default());
- let accounts = InMemoryAccountRepository::default();
- let secrets = InMemorySecretStore::default();
- let journal = InMemoryOperationJournal::default();
- let profiles = EmptyProfiles;
- core.bootstrap().expect("bootstrap");
- let first = core
- .import_secret_key(
- input("7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7"),
- &accounts,
- &accounts,
- &secrets,
- &journal,
- &FixedClock,
- )
- .expect("first")
- .account()
- .public_key();
- let second = core
- .import_secret_key(
- input("1111111111111111111111111111111111111111111111111111111111111111"),
- &accounts,
- &accounts,
- &secrets,
- &journal,
- &FixedClock,
- )
- .expect("second")
- .account()
- .public_key();
- core.activate_account(
- first,
- &accounts,
- &accounts,
- &profiles,
- &secrets,
- &FixedClock,
- )
- .expect("activate first");
- assert_eq!(core.snapshot().session(), SessionState::Active);
- assert_eq!(
- core.snapshot()
- .active_account()
- .map(|active| active.account().public_key()),
- Some(first)
- );
-
- secrets.delete(second).expect("remove second credential");
- let error = core
- .activate_account(
- second,
- &accounts,
- &accounts,
- &profiles,
- &secrets,
- &FixedClock,
- )
- .expect_err("missing credential");
- assert_eq!(
- error.code(),
- radroots_studio_domain::SafeErrorCode::CredentialMissing
- );
- assert_eq!(core.snapshot().session(), SessionState::Active);
- assert_eq!(
- core.snapshot()
- .active_account()
- .map(|active| active.account().public_key()),
- Some(first)
- );
- }
-
- #[test]
- fn sign_out_retains_saved_account_selection_and_credential() {
- let core = AppCore::in_memory(RelayConfiguration::default());
- let accounts = InMemoryAccountRepository::default();
- let secrets = InMemorySecretStore::default();
- let journal = InMemoryOperationJournal::default();
- let profiles = EmptyProfiles;
- core.bootstrap().expect("bootstrap");
- let public_key = core
- .import_secret_key(
- input("7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7"),
- &accounts,
- &accounts,
- &secrets,
- &journal,
- &FixedClock,
- )
- .expect("import")
- .account()
- .public_key();
- core.activate_account(
- public_key,
- &accounts,
- &accounts,
- &profiles,
- &secrets,
- &FixedClock,
- )
- .expect("activate");
-
- let signed_out = core.sign_out().expect("sign out");
- let repeated = core.sign_out().expect("idempotent sign out");
- assert_eq!(signed_out, repeated);
- assert_eq!(signed_out.session(), SessionState::SignedOut);
- assert!(signed_out.active_account().is_none());
- assert_eq!(signed_out.accounts().len(), 1);
- assert_eq!(signed_out.selected_account(), Some(public_key));
- assert!(secrets.contains(public_key).expect("credential retained"));
- }
-}
diff --git a/core/crates/application/src/snapshot.rs b/core/crates/application/src/snapshot.rs
@@ -1,385 +0,0 @@
-use std::collections::HashSet;
-
-use radroots_studio_domain::{
- AccountSummary, ProfileMetadata, PublicKey, RelayUrl, SafeError, SafeErrorCode, SafeMessage,
-};
-
-#[derive(Clone, Copy, Debug, Default, Eq, Ord, PartialEq, PartialOrd)]
-pub struct SnapshotRevision(u64);
-
-impl SnapshotRevision {
- #[must_use]
- pub const fn initial() -> Self {
- Self(0)
- }
-
- #[must_use]
- pub const fn from_value(value: u64) -> Self {
- Self(value)
- }
-
- #[must_use]
- pub const fn value(self) -> u64 {
- self.0
- }
-
- #[must_use]
- pub const fn next(self) -> Option<Self> {
- match self.0.checked_add(1) {
- Some(value) => Some(Self(value)),
- None => None,
- }
- }
-}
-
-#[derive(Clone, Copy, Debug, Eq, PartialEq)]
-pub enum AppLifecycle {
- Booting,
- Ready,
- Fatal(SafeError),
-}
-
-#[derive(Clone, Copy, Debug, Eq, PartialEq)]
-pub enum SessionState {
- SignedOut,
- Activating(PublicKey),
- Active,
- SigningOut,
- Failed(SafeError),
-}
-
-#[derive(Clone, Copy, Debug, Eq, PartialEq)]
-pub enum RelayConnectionState {
- Disconnected,
- Connecting,
- Connected,
- Degraded,
- Error(SafeError),
-}
-
-#[derive(Clone, Copy, Debug, Eq, PartialEq)]
-pub enum ProfileLoadState {
- Empty,
- Loading,
- Cached,
- Fresh,
- Error(SafeError),
-}
-
-#[derive(Clone, Debug, Default, Eq, PartialEq)]
-pub struct RelayConfiguration(Vec<RelayUrl>);
-
-impl RelayConfiguration {
- #[must_use]
- pub fn new(relays: Vec<RelayUrl>) -> Self {
- Self(relays)
- }
-
- #[must_use]
- pub fn relays(&self) -> &[RelayUrl] {
- &self.0
- }
-}
-
-#[derive(Clone, Debug, Eq, PartialEq)]
-pub struct ActiveAccountSnapshot {
- account: AccountSummary,
- relay_state: RelayConnectionState,
- profile_state: ProfileLoadState,
- profile: Option<ProfileMetadata>,
-}
-
-impl ActiveAccountSnapshot {
- #[must_use]
- pub const fn new(
- account: AccountSummary,
- relay_state: RelayConnectionState,
- profile_state: ProfileLoadState,
- profile: Option<ProfileMetadata>,
- ) -> Self {
- Self {
- account,
- relay_state,
- profile_state,
- profile,
- }
- }
-
- #[must_use]
- pub const fn account(&self) -> &AccountSummary {
- &self.account
- }
-
- #[must_use]
- pub const fn relay_state(&self) -> RelayConnectionState {
- self.relay_state
- }
-
- #[must_use]
- pub const fn profile_state(&self) -> ProfileLoadState {
- self.profile_state
- }
-
- #[must_use]
- pub const fn profile(&self) -> Option<&ProfileMetadata> {
- self.profile.as_ref()
- }
-}
-
-#[derive(Clone, Debug, Eq, PartialEq)]
-pub struct AppSnapshot {
- revision: SnapshotRevision,
- lifecycle: AppLifecycle,
- relay_configuration: RelayConfiguration,
- accounts: Vec<AccountSummary>,
- selected_account: Option<PublicKey>,
- session: SessionState,
- active_account: Option<ActiveAccountSnapshot>,
- recoverable_problem: Option<SafeError>,
-}
-
-impl AppSnapshot {
- #[must_use]
- pub fn booting() -> Self {
- Self {
- revision: SnapshotRevision::initial(),
- lifecycle: AppLifecycle::Booting,
- relay_configuration: RelayConfiguration::default(),
- accounts: Vec::new(),
- selected_account: None,
- session: SessionState::SignedOut,
- active_account: None,
- recoverable_problem: None,
- }
- }
-
- #[must_use]
- pub fn fatal(
- revision: SnapshotRevision,
- relay_configuration: RelayConfiguration,
- error: SafeError,
- ) -> Self {
- Self {
- revision,
- lifecycle: AppLifecycle::Fatal(error),
- relay_configuration,
- accounts: Vec::new(),
- selected_account: None,
- session: SessionState::SignedOut,
- active_account: None,
- recoverable_problem: None,
- }
- }
-
- /// Constructs a ready immutable snapshot after validating state invariants.
- ///
- /// # Errors
- ///
- /// Returns a safe invalid-state error for duplicate accounts, invalid
- /// selection, or inconsistent active-session state.
- pub fn ready(
- revision: SnapshotRevision,
- relay_configuration: RelayConfiguration,
- accounts: Vec<AccountSummary>,
- selected_account: Option<PublicKey>,
- session: SessionState,
- active_account: Option<ActiveAccountSnapshot>,
- recoverable_problem: Option<SafeError>,
- ) -> Result<Self, SafeError> {
- validate_snapshot(
- &accounts,
- selected_account,
- session,
- active_account.as_ref(),
- )?;
- Ok(Self {
- revision,
- lifecycle: AppLifecycle::Ready,
- relay_configuration,
- accounts,
- selected_account,
- session,
- active_account,
- recoverable_problem,
- })
- }
-
- #[must_use]
- pub const fn revision(&self) -> SnapshotRevision {
- self.revision
- }
-
- #[must_use]
- pub const fn lifecycle(&self) -> AppLifecycle {
- self.lifecycle
- }
-
- #[must_use]
- pub const fn relay_configuration(&self) -> &RelayConfiguration {
- &self.relay_configuration
- }
-
- #[must_use]
- pub fn accounts(&self) -> &[AccountSummary] {
- &self.accounts
- }
-
- #[must_use]
- pub const fn selected_account(&self) -> Option<PublicKey> {
- self.selected_account
- }
-
- #[must_use]
- pub const fn session(&self) -> SessionState {
- self.session
- }
-
- #[must_use]
- pub const fn active_account(&self) -> Option<&ActiveAccountSnapshot> {
- self.active_account.as_ref()
- }
-
- #[must_use]
- pub const fn recoverable_problem(&self) -> Option<SafeError> {
- self.recoverable_problem
- }
-}
-
-fn validate_snapshot(
- accounts: &[AccountSummary],
- selected_account: Option<PublicKey>,
- session: SessionState,
- active_account: Option<&ActiveAccountSnapshot>,
-) -> Result<(), SafeError> {
- let unique_accounts = accounts
- .iter()
- .map(AccountSummary::public_key)
- .collect::<HashSet<_>>();
- if unique_accounts.len() != accounts.len()
- || (accounts.is_empty() != selected_account.is_none())
- || selected_account.is_some_and(|key| !unique_accounts.contains(&key))
- || active_account
- .is_some_and(|active| !unique_accounts.contains(&active.account().public_key()))
- || (matches!(session, SessionState::Active) && active_account.is_none())
- || (matches!(session, SessionState::SignedOut) && active_account.is_some())
- {
- return Err(invalid_snapshot());
- }
- Ok(())
-}
-
-const fn invalid_snapshot() -> SafeError {
- SafeError::new(
- SafeErrorCode::InvalidApplicationState,
- SafeMessage::new("The application state is invalid."),
- )
-}
-
-#[cfg(test)]
-mod tests {
- use radroots_studio_domain::{
- AccountCreatedAt, AccountIdentity, AccountSummary, BindingAvailability, LocalSignerBinding,
- PublicKey, UnixTimestamp,
- };
-
- use super::{
- ActiveAccountSnapshot, AppLifecycle, AppSnapshot, ProfileLoadState, RelayConfiguration,
- RelayConnectionState, SessionState, SnapshotRevision,
- };
-
- fn account(key_byte: u8) -> AccountSummary {
- let public_key = PublicKey::from_bytes([key_byte; 32]);
- AccountSummary::new(
- AccountIdentity::derive(public_key).expect("identity"),
- LocalSignerBinding::new(public_key, BindingAvailability::Available),
- None,
- AccountCreatedAt::new(UnixTimestamp::from_seconds(1).expect("valid time")),
- None,
- )
- .expect("account")
- }
-
- #[test]
- fn snapshot_boots_empty_and_secret_free() {
- let snapshot = AppSnapshot::booting();
- let debug = format!("{snapshot:?}");
-
- assert_eq!(snapshot.revision(), SnapshotRevision::initial());
- assert_eq!(snapshot.lifecycle(), AppLifecycle::Booting);
- assert_eq!(snapshot.session(), SessionState::SignedOut);
- assert!(snapshot.accounts().is_empty());
- assert!(snapshot.selected_account().is_none());
- assert!(snapshot.active_account().is_none());
- assert!(snapshot.relay_configuration().relays().is_empty());
- assert!(snapshot.recoverable_problem().is_none());
- assert!(!debug.contains("nsec1"));
- assert!(!debug.contains(&"11".repeat(32)));
- }
-
- #[test]
- fn revision_helper_is_monotonic_and_checked() {
- assert_eq!(
- SnapshotRevision::initial()
- .next()
- .map(SnapshotRevision::value),
- Some(1)
- );
- assert_eq!(SnapshotRevision::from_value(u64::MAX).next(), None);
- }
-
- #[test]
- fn ready_snapshot_requires_valid_selection_and_active_session() {
- let first = account(1);
- let second = account(2);
- let active = ActiveAccountSnapshot::new(
- second.clone(),
- RelayConnectionState::Disconnected,
- ProfileLoadState::Empty,
- None,
- );
- let valid = AppSnapshot::ready(
- SnapshotRevision::from_value(1),
- RelayConfiguration::default(),
- vec![first.clone(), second.clone()],
- Some(first.public_key()),
- SessionState::Active,
- Some(active),
- None,
- )
- .expect("valid ready snapshot");
-
- assert_eq!(valid.lifecycle(), AppLifecycle::Ready);
- assert_eq!(valid.selected_account(), Some(first.public_key()));
- assert_eq!(
- valid
- .active_account()
- .map(|value| value.account().public_key()),
- Some(second.public_key())
- );
-
- assert!(
- AppSnapshot::ready(
- SnapshotRevision::initial(),
- RelayConfiguration::default(),
- vec![first.clone(), first],
- Some(second.public_key()),
- SessionState::SignedOut,
- None,
- None,
- )
- .is_err()
- );
- assert!(
- AppSnapshot::ready(
- SnapshotRevision::initial(),
- RelayConfiguration::default(),
- vec![second.clone()],
- Some(second.public_key()),
- SessionState::Active,
- None,
- None,
- )
- .is_err()
- );
- }
-}
diff --git a/core/crates/application/src/state_machine.rs b/core/crates/application/src/state_machine.rs
@@ -1,506 +0,0 @@
-use radroots_studio_domain::{AccountSummary, PublicKey, SafeError, SafeErrorCode, SafeMessage};
-
-use crate::{ActiveAccountSnapshot, AppLifecycle, AppSnapshot, RelayConfiguration, SessionState};
-
-#[derive(Clone, Debug, Eq, PartialEq)]
-pub enum StateTransition {
- Bootstrap,
- BootstrapRegistry {
- accounts: Vec<AccountSummary>,
- selected: Option<PublicKey>,
- },
- Fatal(SafeError),
- ReplaceRegistry {
- accounts: Vec<AccountSummary>,
- selected: Option<PublicKey>,
- },
- ReplaceRegistryPreservingSession {
- accounts: Vec<AccountSummary>,
- selected: Option<PublicKey>,
- },
- Select(PublicKey),
- BeginActivation(PublicKey),
- ActivationSucceeded(Box<ActiveAccountSnapshot>),
- ActivationFailed(SafeError),
- UpdateActiveAccount {
- expected: PublicKey,
- active_account: Box<ActiveAccountSnapshot>,
- problem: Option<SafeError>,
- },
- SignOut,
- SetProblem(Option<SafeError>),
-}
-
-#[derive(Clone)]
-struct PreviousSession {
- session: SessionState,
- active_account: Option<ActiveAccountSnapshot>,
-}
-
-pub struct StateMachine {
- snapshot: AppSnapshot,
- pending_activation: Option<(PublicKey, PreviousSession)>,
-}
-
-impl StateMachine {
- #[must_use]
- pub fn booting() -> Self {
- Self {
- snapshot: AppSnapshot::booting(),
- pending_activation: None,
- }
- }
-
- #[must_use]
- pub const fn snapshot(&self) -> &AppSnapshot {
- &self.snapshot
- }
-
- /// Applies one deterministic state transition and returns the new snapshot.
- ///
- /// # Errors
- ///
- /// Returns a safe application error when the transition violates account,
- /// revision, activation, or snapshot invariants.
- pub fn apply(
- &mut self,
- transition: StateTransition,
- relay_configuration: &RelayConfiguration,
- ) -> Result<AppSnapshot, SafeError> {
- let next_revision = self
- .snapshot
- .revision()
- .next()
- .ok_or_else(invalid_application_state)?;
-
- let next = match transition {
- StateTransition::Bootstrap => self.bootstrap(next_revision, relay_configuration)?,
- StateTransition::BootstrapRegistry { accounts, selected } => {
- self.bootstrap_registry(next_revision, relay_configuration, accounts, selected)?
- }
- StateTransition::Fatal(error) => {
- AppSnapshot::fatal(next_revision, relay_configuration.clone(), error)
- }
- StateTransition::ReplaceRegistry { accounts, selected } => {
- self.replace_registry(next_revision, accounts, selected)?
- }
- StateTransition::ReplaceRegistryPreservingSession { accounts, selected } => {
- self.replace_registry_preserving_session(next_revision, accounts, selected)?
- }
- StateTransition::Select(public_key) => self.select(next_revision, public_key)?,
- StateTransition::BeginActivation(public_key) => {
- self.begin_activation(next_revision, public_key)?
- }
- StateTransition::ActivationSucceeded(active_account) => {
- self.activation_succeeded(next_revision, *active_account)?
- }
- StateTransition::ActivationFailed(problem) => {
- self.activation_failed(next_revision, problem)?
- }
- StateTransition::UpdateActiveAccount {
- expected,
- active_account,
- problem,
- } => self.update_active_account(next_revision, expected, *active_account, problem)?,
- StateTransition::SignOut => self.sign_out(next_revision)?,
- StateTransition::SetProblem(problem) => self.copy_ready(
- next_revision,
- self.snapshot.selected_account(),
- self.snapshot.session(),
- self.snapshot.active_account().cloned(),
- problem,
- )?,
- };
- self.snapshot = next.clone();
- Ok(next)
- }
-
- fn bootstrap(
- &self,
- revision: crate::SnapshotRevision,
- relay_configuration: &RelayConfiguration,
- ) -> Result<AppSnapshot, SafeError> {
- if !matches!(self.snapshot.lifecycle(), AppLifecycle::Booting) {
- return Ok(self.snapshot.clone());
- }
- AppSnapshot::ready(
- revision,
- relay_configuration.clone(),
- Vec::new(),
- None,
- SessionState::SignedOut,
- None,
- None,
- )
- }
-
- fn bootstrap_registry(
- &self,
- revision: crate::SnapshotRevision,
- relay_configuration: &RelayConfiguration,
- accounts: Vec<AccountSummary>,
- selected: Option<PublicKey>,
- ) -> Result<AppSnapshot, SafeError> {
- if !matches!(self.snapshot.lifecycle(), AppLifecycle::Booting) {
- return Ok(self.snapshot.clone());
- }
- AppSnapshot::ready(
- revision,
- relay_configuration.clone(),
- accounts,
- selected,
- SessionState::SignedOut,
- None,
- None,
- )
- }
-
- fn replace_registry(
- &mut self,
- revision: crate::SnapshotRevision,
- accounts: Vec<AccountSummary>,
- selected: Option<PublicKey>,
- ) -> Result<AppSnapshot, SafeError> {
- self.pending_activation = None;
- AppSnapshot::ready(
- revision,
- self.snapshot.relay_configuration().clone(),
- accounts,
- selected,
- SessionState::SignedOut,
- None,
- None,
- )
- }
-
- fn replace_registry_preserving_session(
- &mut self,
- revision: crate::SnapshotRevision,
- accounts: Vec<AccountSummary>,
- selected: Option<PublicKey>,
- ) -> Result<AppSnapshot, SafeError> {
- self.pending_activation = None;
- AppSnapshot::ready(
- revision,
- self.snapshot.relay_configuration().clone(),
- accounts,
- selected,
- self.snapshot.session(),
- self.snapshot.active_account().cloned(),
- None,
- )
- }
-
- fn select(
- &self,
- revision: crate::SnapshotRevision,
- public_key: PublicKey,
- ) -> Result<AppSnapshot, SafeError> {
- self.require_account(public_key)?;
- self.copy_ready(
- revision,
- Some(public_key),
- self.snapshot.session(),
- self.snapshot.active_account().cloned(),
- None,
- )
- }
-
- fn begin_activation(
- &mut self,
- revision: crate::SnapshotRevision,
- public_key: PublicKey,
- ) -> Result<AppSnapshot, SafeError> {
- self.require_account(public_key)?;
- if self.pending_activation.is_some() {
- return Err(invalid_application_state());
- }
- self.pending_activation = Some((
- public_key,
- PreviousSession {
- session: self.snapshot.session(),
- active_account: self.snapshot.active_account().cloned(),
- },
- ));
- self.copy_ready(
- revision,
- self.snapshot.selected_account(),
- SessionState::Activating(public_key),
- self.snapshot.active_account().cloned(),
- None,
- )
- }
-
- fn activation_succeeded(
- &mut self,
- revision: crate::SnapshotRevision,
- active_account: ActiveAccountSnapshot,
- ) -> Result<AppSnapshot, SafeError> {
- let Some((target, _previous)) = self.pending_activation.as_ref() else {
- return Err(invalid_application_state());
- };
- if active_account.account().public_key() != *target {
- return Err(invalid_application_state());
- }
- let target = *target;
- self.pending_activation = None;
- self.copy_ready(
- revision,
- Some(target),
- SessionState::Active,
- Some(active_account),
- None,
- )
- }
-
- fn activation_failed(
- &mut self,
- revision: crate::SnapshotRevision,
- problem: SafeError,
- ) -> Result<AppSnapshot, SafeError> {
- let Some((_target, previous)) = self.pending_activation.take() else {
- return Err(invalid_application_state());
- };
- self.copy_ready(
- revision,
- self.snapshot.selected_account(),
- previous.session,
- previous.active_account,
- Some(problem),
- )
- }
-
- fn sign_out(&mut self, revision: crate::SnapshotRevision) -> Result<AppSnapshot, SafeError> {
- self.pending_activation = None;
- self.copy_ready(
- revision,
- self.snapshot.selected_account(),
- SessionState::SignedOut,
- None,
- None,
- )
- }
-
- fn update_active_account(
- &self,
- revision: crate::SnapshotRevision,
- expected: PublicKey,
- active_account: ActiveAccountSnapshot,
- problem: Option<SafeError>,
- ) -> Result<AppSnapshot, SafeError> {
- if !matches!(self.snapshot.session(), SessionState::Active)
- || self
- .snapshot
- .active_account()
- .map(|active| active.account().public_key())
- != Some(expected)
- || active_account.account().public_key() != expected
- {
- return Err(invalid_application_state());
- }
- self.copy_ready(
- revision,
- self.snapshot.selected_account(),
- SessionState::Active,
- Some(active_account),
- problem,
- )
- }
-
- fn require_account(&self, public_key: PublicKey) -> Result<(), SafeError> {
- if self
- .snapshot
- .accounts()
- .iter()
- .any(|account| account.public_key() == public_key)
- {
- Ok(())
- } else {
- Err(account_not_found())
- }
- }
-
- fn copy_ready(
- &self,
- revision: crate::SnapshotRevision,
- selected_account: Option<PublicKey>,
- session: SessionState,
- active_account: Option<ActiveAccountSnapshot>,
- recoverable_problem: Option<SafeError>,
- ) -> Result<AppSnapshot, SafeError> {
- AppSnapshot::ready(
- revision,
- self.snapshot.relay_configuration().clone(),
- self.snapshot.accounts().to_vec(),
- selected_account,
- session,
- active_account,
- recoverable_problem,
- )
- }
-}
-
-const fn invalid_application_state() -> SafeError {
- SafeError::new(
- SafeErrorCode::InvalidApplicationState,
- SafeMessage::new("The application state is invalid."),
- )
-}
-
-const fn account_not_found() -> SafeError {
- SafeError::new(
- SafeErrorCode::AccountNotFound,
- SafeMessage::new("The account was not found."),
- )
-}
-
-#[cfg(test)]
-mod tests {
- use radroots_studio_domain::{
- AccountCreatedAt, AccountIdentity, AccountSummary, BindingAvailability, LocalSignerBinding,
- PublicKey, SafeError, SafeErrorCode, SafeMessage, UnixTimestamp,
- };
-
- use crate::{
- ActiveAccountSnapshot, ProfileLoadState, RelayConfiguration, RelayConnectionState,
- SessionState, StateMachine, StateTransition,
- };
-
- fn account(key_byte: u8) -> AccountSummary {
- let public_key = PublicKey::from_bytes([key_byte; 32]);
- AccountSummary::new(
- AccountIdentity::derive(public_key).expect("identity"),
- LocalSignerBinding::new(public_key, BindingAvailability::Available),
- None,
- AccountCreatedAt::new(UnixTimestamp::from_seconds(1).expect("valid time")),
- None,
- )
- .expect("account")
- }
-
- fn active(account: AccountSummary) -> ActiveAccountSnapshot {
- ActiveAccountSnapshot::new(
- account,
- RelayConnectionState::Disconnected,
- ProfileLoadState::Empty,
- None,
- )
- }
-
- #[test]
- fn state_machine_command_trace_preserves_working_session_on_failed_replacement() {
- let first = account(1);
- let second = account(2);
- let mut machine = StateMachine::booting();
- let relays = RelayConfiguration::default();
- let problem = SafeError::new(
- SafeErrorCode::CredentialMissing,
- SafeMessage::new("The account credential is missing."),
- );
-
- machine
- .apply(StateTransition::Bootstrap, &relays)
- .expect("bootstrap");
- machine
- .apply(
- StateTransition::ReplaceRegistry {
- accounts: vec![first.clone(), second.clone()],
- selected: Some(first.public_key()),
- },
- &relays,
- )
- .expect("load registry");
- machine
- .apply(
- StateTransition::BeginActivation(first.public_key()),
- &relays,
- )
- .expect("begin first activation");
- machine
- .apply(
- StateTransition::ActivationSucceeded(Box::new(active(first.clone()))),
- &relays,
- )
- .expect("activate first");
- machine
- .apply(StateTransition::Select(second.public_key()), &relays)
- .expect("select second");
- let pending = machine
- .apply(
- StateTransition::BeginActivation(second.public_key()),
- &relays,
- )
- .expect("begin replacement");
- let restored = machine
- .apply(StateTransition::ActivationFailed(problem), &relays)
- .expect("fail replacement");
-
- assert_eq!(
- pending.session(),
- SessionState::Activating(second.public_key())
- );
- assert_eq!(
- pending
- .active_account()
- .map(|value| value.account().public_key()),
- Some(first.public_key())
- );
- assert_eq!(restored.session(), SessionState::Active);
- assert_eq!(restored.selected_account(), Some(second.public_key()));
- assert_eq!(
- restored
- .active_account()
- .map(|value| value.account().public_key()),
- Some(first.public_key())
- );
- assert_eq!(restored.recoverable_problem(), Some(problem));
- assert_eq!(restored.revision().value(), 7);
- }
-
- #[test]
- fn state_machine_rejects_missing_targets_and_signs_out_without_deleting() {
- let account = account(1);
- let mut machine = StateMachine::booting();
- let relays = RelayConfiguration::default();
- machine
- .apply(StateTransition::Bootstrap, &relays)
- .expect("bootstrap");
- machine
- .apply(
- StateTransition::ReplaceRegistry {
- accounts: vec![account.clone()],
- selected: Some(account.public_key()),
- },
- &relays,
- )
- .expect("load registry");
-
- let error = machine
- .apply(
- StateTransition::Select(PublicKey::from_bytes([9_u8; 32])),
- &relays,
- )
- .expect_err("missing account");
- assert_eq!(error.code(), SafeErrorCode::AccountNotFound);
-
- machine
- .apply(
- StateTransition::BeginActivation(account.public_key()),
- &relays,
- )
- .expect("begin activation");
- machine
- .apply(
- StateTransition::ActivationSucceeded(Box::new(active(account.clone()))),
- &relays,
- )
- .expect("activate");
- let signed_out = machine
- .apply(StateTransition::SignOut, &relays)
- .expect("sign out");
-
- assert_eq!(signed_out.accounts(), &[account]);
- assert_eq!(signed_out.session(), SessionState::SignedOut);
- assert!(signed_out.active_account().is_none());
- }
-}
diff --git a/core/crates/application/tests/redaction.rs b/core/crates/application/tests/redaction.rs
@@ -1,47 +0,0 @@
-use radroots_studio_application::{
- AppSnapshot, RelayConfiguration, SessionState, SnapshotRevision,
-};
-use radroots_studio_domain::{
- AccountCreatedAt, AccountIdentity, AccountSummary, BindingAvailability, LocalSignerBinding,
- PublicKey, SafeError, SafeErrorCode, SafeMessage, UnixTimestamp,
-};
-
-const SECRET_HEX: &str = "1111111111111111111111111111111111111111111111111111111111111111";
-const SECRET_NSEC: &str = "nsec1vl029mgpspedva04g90vltkh6fvh240zqtv9k0t9af8935ke9laqsnlfe5";
-fn assert_redacted(text: &str) {
- assert!(!text.contains(SECRET_HEX));
- assert!(!text.contains(SECRET_NSEC));
- assert!(!text.contains("nsec1"));
-}
-
-#[test]
-fn redaction_guards_public_snapshot_and_safe_error_debug() {
- let account = AccountSummary::new(
- AccountIdentity::derive(PublicKey::from_bytes([2; 32])).expect("identity"),
- LocalSignerBinding::new(
- PublicKey::from_bytes([2; 32]),
- BindingAvailability::Available,
- ),
- None,
- AccountCreatedAt::new(UnixTimestamp::from_seconds(1).expect("time")),
- None,
- )
- .expect("account");
- let snapshot = AppSnapshot::ready(
- SnapshotRevision::from_value(1),
- RelayConfiguration::default(),
- vec![account.clone()],
- Some(account.public_key()),
- SessionState::SignedOut,
- None,
- None,
- )
- .expect("snapshot");
- let error = SafeError::new(
- SafeErrorCode::KeyringUnavailable,
- SafeMessage::new("The operating system credential store is unavailable."),
- );
-
- assert_redacted(&format!("{snapshot:?}"));
- assert_redacted(&format!("{error:?} {error}"));
-}
diff --git a/core/crates/domain/Cargo.toml b/core/crates/domain/Cargo.toml
@@ -1,16 +0,0 @@
-[package]
-name = "radroots-studio-domain"
-version.workspace = true
-edition.workspace = true
-rust-version.workspace = true
-license.workspace = true
-repository.workspace = true
-
-[dependencies]
-bech32.workspace = true
-secrecy.workspace = true
-zeroize.workspace = true
-url.workspace = true
-
-[lints]
-workspace = true
diff --git a/core/crates/domain/src/account.rs b/core/crates/domain/src/account.rs
@@ -1,423 +0,0 @@
-//! Public account metadata and lifecycle values.
-
-use crate::time::UnixTimestamp;
-use crate::{Npub, PublicKey, SafeError, SafeErrorCode, SafeMessage};
-
-const MAX_ACCOUNT_LABEL_CHARS: usize = 80;
-
-#[derive(Clone, Debug, Eq, PartialEq)]
-pub struct AccountIdentity {
- public_key: PublicKey,
- npub: Npub,
-}
-
-impl AccountIdentity {
- /// Constructs one canonical Nostr account identity and derives its npub.
- ///
- /// # Errors
- ///
- /// Returns a safe public-key error if canonical NIP-19 encoding fails.
- pub fn derive(public_key: PublicKey) -> Result<Self, SafeError> {
- Ok(Self {
- public_key,
- npub: Npub::derive(public_key)?,
- })
- }
-
- /// Reconstitutes persisted identity only when its public forms agree.
- ///
- /// # Errors
- ///
- /// Returns a safe public-key error for a mismatched or malformed npub.
- pub fn verify(public_key: PublicKey, npub: String) -> Result<Self, SafeError> {
- Ok(Self {
- public_key,
- npub: Npub::verify(public_key, npub)?,
- })
- }
-
- #[must_use]
- pub const fn public_key(&self) -> PublicKey {
- self.public_key
- }
-
- #[must_use]
- pub const fn npub(&self) -> &Npub {
- &self.npub
- }
-}
-
-#[derive(Clone, Copy, Debug, Eq, PartialEq)]
-pub struct LocalSignerBinding {
- account: PublicKey,
- availability: BindingAvailability,
-}
-
-impl LocalSignerBinding {
- #[must_use]
- pub const fn new(account: PublicKey, availability: BindingAvailability) -> Self {
- Self {
- account,
- availability,
- }
- }
-
- #[must_use]
- pub const fn account(self) -> PublicKey {
- self.account
- }
-
- #[must_use]
- pub const fn availability(self) -> BindingAvailability {
- self.availability
- }
-
- #[must_use]
- pub const fn repair_action(self) -> Option<BindingRepairAction> {
- match self.availability {
- BindingAvailability::Available => None,
- BindingAvailability::CredentialMissing => Some(BindingRepairAction::ImportCredential),
- BindingAvailability::StoreUnavailable => {
- Some(BindingRepairAction::RetryCredentialStore)
- }
- }
- }
-
- /// Records a missing credential after a successful store lookup.
- ///
- /// # Errors
- ///
- /// Returns a safe state error unless the binding was previously available.
- pub fn mark_credential_missing(&mut self) -> Result<(), SafeError> {
- self.transition(
- BindingAvailability::Available,
- BindingAvailability::CredentialMissing,
- )
- }
-
- pub fn mark_store_unavailable(&mut self) {
- self.availability = BindingAvailability::StoreUnavailable;
- }
-
- /// Completes an explicit credential repair.
- ///
- /// # Errors
- ///
- /// Returns a safe state error unless a credential was missing.
- pub fn repair_credential(&mut self) -> Result<(), SafeError> {
- self.transition(
- BindingAvailability::CredentialMissing,
- BindingAvailability::Available,
- )
- }
-
- /// Resolves a recovered store lookup to its observed credential state.
- ///
- /// # Errors
- ///
- /// Returns a safe state error unless the credential store was unavailable.
- pub fn resolve_store_recovery(&mut self, credential_present: bool) -> Result<(), SafeError> {
- if self.availability != BindingAvailability::StoreUnavailable {
- return Err(invalid_account_metadata());
- }
- self.availability = if credential_present {
- BindingAvailability::Available
- } else {
- BindingAvailability::CredentialMissing
- };
- Ok(())
- }
-
- fn transition(
- &mut self,
- expected: BindingAvailability,
- next: BindingAvailability,
- ) -> Result<(), SafeError> {
- if self.availability != expected {
- return Err(invalid_account_metadata());
- }
- self.availability = next;
- Ok(())
- }
-}
-
-#[derive(Clone, Copy, Debug, Eq, PartialEq)]
-pub enum BindingAvailability {
- Available,
- CredentialMissing,
- StoreUnavailable,
-}
-
-#[derive(Clone, Copy, Debug, Eq, PartialEq)]
-pub enum BindingRepairAction {
- ImportCredential,
- RetryCredentialStore,
-}
-
-#[derive(Clone, Debug, Eq, PartialEq)]
-pub struct AccountLabel(String);
-
-impl AccountLabel {
- /// Trims and validates an optional human-assigned account label value.
- ///
- /// # Errors
- ///
- /// Returns a safe metadata error when the resulting label is empty, too
- /// long, or contains a control character.
- pub fn parse(value: &str) -> Result<Self, SafeError> {
- let normalized = value.trim();
- if normalized.is_empty()
- || normalized.chars().count() > MAX_ACCOUNT_LABEL_CHARS
- || normalized.chars().any(char::is_control)
- {
- return Err(invalid_account_metadata());
- }
- Ok(Self(normalized.to_owned()))
- }
-
- #[must_use]
- pub fn as_str(&self) -> &str {
- &self.0
- }
-}
-
-#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
-pub struct AccountCreatedAt(UnixTimestamp);
-
-impl AccountCreatedAt {
- #[must_use]
- pub const fn new(timestamp: UnixTimestamp) -> Self {
- Self(timestamp)
- }
-
- #[must_use]
- pub const fn timestamp(self) -> UnixTimestamp {
- self.0
- }
-}
-
-#[derive(Clone, Debug, Eq, PartialEq)]
-pub struct AccountSummary {
- identity: AccountIdentity,
- signer: LocalSignerBinding,
- label: Option<AccountLabel>,
- created_at: AccountCreatedAt,
- last_used_at: Option<UnixTimestamp>,
-}
-
-impl AccountSummary {
- /// Creates an account summary whose identity and signer binding refer to the same account.
- ///
- /// # Errors
- ///
- /// Returns an invalid-account-metadata error when the signer binding belongs to a different
- /// public key.
- pub fn new(
- identity: AccountIdentity,
- signer: LocalSignerBinding,
- label: Option<AccountLabel>,
- created_at: AccountCreatedAt,
- last_used_at: Option<UnixTimestamp>,
- ) -> Result<Self, SafeError> {
- if identity.public_key() != signer.account() {
- return Err(invalid_account_metadata());
- }
- Ok(Self {
- identity,
- signer,
- label,
- created_at,
- last_used_at,
- })
- }
-
- #[must_use]
- pub const fn public_key(&self) -> PublicKey {
- self.identity.public_key()
- }
-
- #[must_use]
- pub fn npub(&self) -> &Npub {
- self.identity.npub()
- }
-
- #[must_use]
- pub const fn signer(&self) -> LocalSignerBinding {
- self.signer
- }
-
- #[must_use]
- pub fn label(&self) -> Option<&AccountLabel> {
- self.label.as_ref()
- }
-
- #[must_use]
- pub const fn created_at(&self) -> AccountCreatedAt {
- self.created_at
- }
-
- #[must_use]
- pub const fn last_used_at(&self) -> Option<UnixTimestamp> {
- self.last_used_at
- }
-
- #[must_use]
- pub fn with_binding_availability(&self, availability: BindingAvailability) -> Self {
- Self {
- identity: self.identity.clone(),
- signer: LocalSignerBinding::new(self.public_key(), availability),
- label: self.label.clone(),
- created_at: self.created_at,
- last_used_at: self.last_used_at,
- }
- }
-
- #[must_use]
- pub fn with_last_used_at(&self, last_used_at: UnixTimestamp) -> Self {
- Self {
- identity: self.identity.clone(),
- signer: self.signer,
- label: self.label.clone(),
- created_at: self.created_at,
- last_used_at: Some(last_used_at),
- }
- }
-
- #[must_use]
- pub fn display_label(&self) -> String {
- self.label
- .as_ref()
- .map_or_else(|| self.npub().short(), |label| label.as_str().to_owned())
- }
-}
-
-const fn invalid_account_metadata() -> SafeError {
- SafeError::new(
- SafeErrorCode::InvalidAccountMetadata,
- SafeMessage::new("The account metadata is invalid."),
- )
-}
-
-#[cfg(test)]
-mod tests {
- use crate::PublicKey;
- use crate::time::UnixTimestamp;
-
- use super::{
- AccountCreatedAt, AccountIdentity, AccountLabel, AccountSummary, BindingAvailability,
- BindingRepairAction, LocalSignerBinding,
- };
-
- const DERIVED_NPUB: &str = "npub1qurswpc8qurswpc8qurswpc8qurswpc8qurswpc8qurswpc8qursnvjvl7";
- const MISMATCHED_NPUB: &str = "npub10elfcs4fr0l0r8af98jlmgdh9c8tcxjvz9qkw038js35mp4dma8qzvjptg";
-
- fn account(label: Option<AccountLabel>) -> AccountSummary {
- let public_key = PublicKey::from_bytes([7_u8; 32]);
- AccountSummary::new(
- AccountIdentity::derive(public_key).expect("identity"),
- LocalSignerBinding::new(public_key, BindingAvailability::Available),
- label,
- AccountCreatedAt::new(UnixTimestamp::from_seconds(10).expect("valid time")),
- None,
- )
- .expect("account")
- }
-
- #[test]
- fn account_label_is_trimmed_bounded_and_control_free() {
- let label = AccountLabel::parse(" Farm account ").expect("valid label");
- assert_eq!(label.as_str(), "Farm account");
-
- for invalid in ["", " ", "line\nbreak", &"x".repeat(81)] {
- assert!(AccountLabel::parse(invalid).is_err());
- }
- }
-
- #[test]
- fn account_display_prefers_label_then_shortened_npub() {
- let labelled = account(Some(AccountLabel::parse("Farm").expect("valid label")));
- let unlabelled = account(None);
-
- assert_eq!(labelled.display_label(), "Farm");
- assert_eq!(unlabelled.display_label(), "npub1qurswpc…rsnvjvl7");
- }
-
- #[test]
- fn local_account_summary_contains_public_metadata_only() {
- let account = account(None);
- let debug = format!("{account:?}");
-
- assert_eq!(
- account.signer().availability(),
- BindingAvailability::Available
- );
- assert!(account.label().is_none());
- assert!(account.last_used_at().is_none());
- assert_eq!(account.created_at().timestamp().as_seconds(), 10);
- assert_eq!(account.public_key(), PublicKey::from_bytes([7_u8; 32]));
- assert_eq!(account.npub().as_str(), DERIVED_NPUB);
- assert!(!debug.contains("nsec1"));
- assert!(!debug.contains(&"11".repeat(32)));
- }
-
- #[test]
- fn account_identity_derives_npub_and_rejects_mismatched_persisted_forms() {
- let public_key = PublicKey::from_bytes([7_u8; 32]);
- let identity = AccountIdentity::derive(public_key).expect("identity");
- assert_eq!(identity.public_key(), public_key);
- assert_eq!(identity.npub().as_str(), DERIVED_NPUB);
- assert_eq!(
- AccountIdentity::verify(public_key, DERIVED_NPUB.to_owned()).expect("verified"),
- identity
- );
- assert!(AccountIdentity::verify(public_key, MISMATCHED_NPUB.to_owned()).is_err());
- assert!(
- AccountIdentity::verify(
- PublicKey::from_bytes([8_u8; 32]),
- MISMATCHED_NPUB.to_owned()
- )
- .is_err()
- );
- }
-
- #[test]
- fn local_signer_binding_carries_only_canonical_account_identity() {
- let public_key = PublicKey::from_bytes([9_u8; 32]);
- let identity = AccountIdentity::derive(public_key).expect("identity");
- let binding = LocalSignerBinding::new(public_key, BindingAvailability::Available);
-
- assert_eq!(binding.account(), identity.public_key());
- assert!(!format!("{binding:?}").contains("nsec1"));
- }
-
- #[test]
- fn local_binding_repair_transitions_are_typed_and_fail_closed() {
- let public_key = PublicKey::from_bytes([9_u8; 32]);
- let mut binding = LocalSignerBinding::new(public_key, BindingAvailability::Available);
- assert_eq!(binding.repair_action(), None);
- assert!(binding.repair_credential().is_err());
-
- binding
- .mark_credential_missing()
- .expect("missing credential");
- assert_eq!(
- binding.repair_action(),
- Some(BindingRepairAction::ImportCredential)
- );
- binding.repair_credential().expect("repair");
-
- binding.mark_store_unavailable();
- assert_eq!(
- binding.repair_action(),
- Some(BindingRepairAction::RetryCredentialStore)
- );
- binding
- .resolve_store_recovery(false)
- .expect("store recovery");
- assert_eq!(
- binding.availability(),
- BindingAvailability::CredentialMissing
- );
- assert!(binding.resolve_store_recovery(true).is_err());
- }
-}
diff --git a/core/crates/domain/src/error.rs b/core/crates/domain/src/error.rs
@@ -1,110 +0,0 @@
-use std::error::Error;
-use std::fmt::{self, Debug, Display, Formatter};
-
-#[derive(Clone, Copy, Debug, Eq, PartialEq)]
-#[non_exhaustive]
-pub enum SafeErrorCode {
- InvalidPublicKey,
- InvalidSecretKey,
- InvalidAccountMetadata,
- InvalidProfileMetadata,
- InvalidApplicationState,
- AccountAlreadyExists,
- AccountNotFound,
- KeyringUnavailable,
- CredentialMissing,
- StorageUnavailable,
- StorageCorrupt,
- PendingOperationRecoveryRequired,
- InvalidRelayConfiguration,
- RelayConnectionFailed,
- ProfileRefreshFailed,
- ObserverRegistrationFailed,
- NativeLibraryLoadFailed,
-}
-
-#[derive(Clone, Copy, Eq, PartialEq)]
-pub struct SafeMessage(&'static str);
-
-impl SafeMessage {
- #[must_use]
- pub const fn new(message: &'static str) -> Self {
- Self(message)
- }
-
- #[must_use]
- pub const fn as_str(self) -> &'static str {
- self.0
- }
-}
-
-impl Debug for SafeMessage {
- fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result {
- formatter.debug_tuple("SafeMessage").field(&self.0).finish()
- }
-}
-
-impl Display for SafeMessage {
- fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result {
- formatter.write_str(self.0)
- }
-}
-
-#[derive(Clone, Copy, Eq, PartialEq)]
-pub struct SafeError {
- code: SafeErrorCode,
- message: SafeMessage,
-}
-
-impl SafeError {
- #[must_use]
- pub const fn new(code: SafeErrorCode, message: SafeMessage) -> Self {
- Self { code, message }
- }
-
- #[must_use]
- pub const fn code(self) -> SafeErrorCode {
- self.code
- }
-
- #[must_use]
- pub const fn message(self) -> SafeMessage {
- self.message
- }
-}
-
-impl Debug for SafeError {
- fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result {
- formatter
- .debug_struct("SafeError")
- .field("code", &self.code)
- .field("message", &self.message)
- .finish()
- }
-}
-
-impl Display for SafeError {
- fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result {
- Display::fmt(&self.message, formatter)
- }
-}
-
-impl Error for SafeError {}
-
-#[cfg(test)]
-mod tests {
- use super::{SafeError, SafeErrorCode, SafeMessage};
-
- #[test]
- fn safe_error_formats_only_a_static_public_message() {
- let error = SafeError::new(
- SafeErrorCode::InvalidSecretKey,
- SafeMessage::new("The secret key is invalid."),
- );
-
- assert_eq!(error.to_string(), "The secret key is invalid.");
- assert_eq!(error.code(), SafeErrorCode::InvalidSecretKey);
- assert_eq!(error.message().as_str(), "The secret key is invalid.");
- assert!(!format!("{error:?}").contains("nsec1unsafe-test-value"));
- }
-}
diff --git a/core/crates/domain/src/key.rs b/core/crates/domain/src/key.rs
@@ -1,391 +0,0 @@
-//! Validated Nostr public and secret-key boundary values.
-
-use std::fmt::{self, Display, Formatter};
-use std::str::FromStr;
-
-use secrecy::{ExposeSecret, SecretString};
-use zeroize::Zeroizing;
-
-use crate::{SafeError, SafeErrorCode, SafeMessage};
-
-pub const PUBLIC_KEY_BYTE_LENGTH: usize = 32;
-pub const PUBLIC_KEY_HEX_LENGTH: usize = PUBLIC_KEY_BYTE_LENGTH * 2;
-pub const MAX_SECRET_KEY_INPUT_BYTES: usize = 128;
-const NIP19_KEY_LENGTH: usize = 63;
-const BECH32_DATA_CHARSET: &[u8] = b"qpzry9x8gf2tvdw0s3jn54khce6mua7l";
-
-#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
-pub struct Npub(String);
-
-impl Npub {
- /// Constructs a human-facing npub after structural validation.
- ///
- /// Cryptographic conversion and checksum validation are performed by the
- /// selected Nostr adapter before this domain value is created in runtime
- /// flows.
- ///
- /// # Errors
- ///
- /// Returns a safe invalid-public-key error for a malformed npub shape.
- pub fn from_encoded(value: String) -> Result<Self, SafeError> {
- if !is_nip19_key_shape(&value, "npub1") {
- return Err(invalid_public_key());
- }
- Ok(Self(value))
- }
-
- /// Derives the canonical NIP-19 display identity from a public key.
- ///
- /// # Errors
- ///
- /// Returns a safe public-key error if canonical encoding fails.
- pub fn derive(public_key: PublicKey) -> Result<Self, SafeError> {
- let hrp = bech32::Hrp::parse("npub").map_err(|_| invalid_public_key())?;
- bech32::encode::<bech32::Bech32>(hrp, public_key.as_bytes())
- .map_err(|_| invalid_public_key())
- .and_then(Self::from_encoded)
- }
-
- /// Validates that encoded display identity belongs to the canonical key.
- ///
- /// # Errors
- ///
- /// Returns a safe public-key error when the values do not match.
- pub fn verify(public_key: PublicKey, encoded: String) -> Result<Self, SafeError> {
- let candidate = Self::from_encoded(encoded)?;
- if candidate != Self::derive(public_key)? {
- return Err(invalid_public_key());
- }
- Ok(candidate)
- }
-
- #[must_use]
- pub fn as_str(&self) -> &str {
- &self.0
- }
-
- #[must_use]
- pub fn short(&self) -> String {
- format!("{}…{}", &self.0[..12], &self.0[self.0.len() - 8..])
- }
-}
-
-impl Display for Npub {
- fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result {
- formatter.write_str(&self.0)
- }
-}
-
-pub struct Nsec(SecretString);
-
-impl Nsec {
- /// Constructs a secret nsec display value after structural validation.
- ///
- /// # Errors
- ///
- /// Returns a safe invalid-secret-key error for a malformed nsec shape.
- pub fn from_encoded(value: String) -> Result<Self, SafeError> {
- if !is_nip19_key_shape(&value, "nsec1") {
- return Err(invalid_secret_key());
- }
- Ok(Self(SecretString::from(value)))
- }
-
- pub fn with_exposed_secret<T>(&self, operation: impl FnOnce(&str) -> T) -> T {
- operation(self.0.expose_secret())
- }
-}
-
-#[derive(Clone, Copy, Debug, Eq, PartialEq)]
-pub enum SecretKeyInputKind {
- Nsec,
- Hex,
-}
-
-pub struct SecretKeyInput {
- value: SecretString,
- kind: SecretKeyInputKind,
-}
-
-impl SecretKeyInput {
- /// Moves bounded transport bytes into the zeroizing secret boundary.
- ///
- /// The source byte allocation is cleared on every return path.
- ///
- /// # Errors
- ///
- /// Returns a safe invalid-secret-key error for oversized, non-UTF-8, or
- /// structurally invalid input.
- pub fn parse_bytes(value: Vec<u8>) -> Result<Self, SafeError> {
- let value = Zeroizing::new(value);
- if value.len() > MAX_SECRET_KEY_INPUT_BYTES {
- return Err(invalid_secret_key());
- }
- let encoded = std::str::from_utf8(&value).map_err(|_| invalid_secret_key())?;
- Self::parse(encoded.to_owned())
- }
-
- /// Moves one secret input string into a zeroizing boundary.
- ///
- /// Nsec inputs receive complete NIP-19 validation in the Nostr adapter.
- /// Hex input is structurally validated here to prevent ambiguous fallback.
- ///
- /// # Errors
- ///
- /// Returns a safe invalid-secret-key error when the input is neither an
- /// nsec-looking value nor exactly 64 lowercase hexadecimal characters.
- pub fn parse(value: String) -> Result<Self, SafeError> {
- let kind = if value.len() == PUBLIC_KEY_HEX_LENGTH
- && value
- .bytes()
- .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
- {
- SecretKeyInputKind::Hex
- } else if is_nip19_key_shape(&value, "nsec1") {
- SecretKeyInputKind::Nsec
- } else {
- return Err(invalid_secret_key());
- };
-
- Ok(Self {
- value: SecretString::from(value),
- kind,
- })
- }
-
- #[must_use]
- pub const fn kind(&self) -> SecretKeyInputKind {
- self.kind
- }
-
- pub fn with_exposed_secret<T>(&self, operation: impl FnOnce(&str) -> T) -> T {
- operation(self.value.expose_secret())
- }
-}
-
-#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
-pub struct PublicKey([u8; PUBLIC_KEY_BYTE_LENGTH]);
-
-impl PublicKey {
- #[must_use]
- pub const fn from_bytes(bytes: [u8; PUBLIC_KEY_BYTE_LENGTH]) -> Self {
- Self(bytes)
- }
-
- /// Parses a canonical lowercase hexadecimal Nostr public key.
- ///
- /// # Errors
- ///
- /// Returns a safe invalid-public-key error when the value is not exactly
- /// 64 lowercase hexadecimal characters.
- pub fn from_hex(value: &str) -> Result<Self, SafeError> {
- if value.len() != PUBLIC_KEY_HEX_LENGTH
- || !value
- .bytes()
- .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
- {
- return Err(invalid_public_key());
- }
-
- let mut bytes = [0_u8; PUBLIC_KEY_BYTE_LENGTH];
- for (index, pair) in value.as_bytes().chunks_exact(2).enumerate() {
- let high = decode_hex_digit(pair[0]).ok_or_else(invalid_public_key)?;
- let low = decode_hex_digit(pair[1]).ok_or_else(invalid_public_key)?;
- bytes[index] = (high << 4) | low;
- }
- Ok(Self(bytes))
- }
-
- #[must_use]
- pub const fn as_bytes(&self) -> &[u8; PUBLIC_KEY_BYTE_LENGTH] {
- &self.0
- }
-
- #[must_use]
- pub fn to_hex(self) -> String {
- const HEX: &[u8; 16] = b"0123456789abcdef";
- let mut output = String::with_capacity(PUBLIC_KEY_HEX_LENGTH);
- for byte in self.0 {
- output.push(char::from(HEX[usize::from(byte >> 4)]));
- output.push(char::from(HEX[usize::from(byte & 0x0f)]));
- }
- output
- }
-
- #[must_use]
- pub fn short_hex(self) -> String {
- let hex = self.to_hex();
- format!("{}…{}", &hex[..8], &hex[PUBLIC_KEY_HEX_LENGTH - 8..])
- }
-}
-
-impl Display for PublicKey {
- fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result {
- formatter.write_str(&self.to_hex())
- }
-}
-
-impl From<[u8; PUBLIC_KEY_BYTE_LENGTH]> for PublicKey {
- fn from(bytes: [u8; PUBLIC_KEY_BYTE_LENGTH]) -> Self {
- Self::from_bytes(bytes)
- }
-}
-
-impl FromStr for PublicKey {
- type Err = SafeError;
-
- fn from_str(value: &str) -> Result<Self, Self::Err> {
- Self::from_hex(value)
- }
-}
-
-const fn invalid_public_key() -> SafeError {
- SafeError::new(
- SafeErrorCode::InvalidPublicKey,
- SafeMessage::new("The Nostr public key is invalid."),
- )
-}
-
-const fn invalid_secret_key() -> SafeError {
- SafeError::new(
- SafeErrorCode::InvalidSecretKey,
- SafeMessage::new("The Nostr secret key is invalid."),
- )
-}
-
-const fn decode_hex_digit(byte: u8) -> Option<u8> {
- match byte {
- b'0'..=b'9' => Some(byte - b'0'),
- b'a'..=b'f' => Some(byte - b'a' + 10),
- _ => None,
- }
-}
-
-fn is_nip19_key_shape(value: &str, prefix: &str) -> bool {
- value.len() == NIP19_KEY_LENGTH
- && value.starts_with(prefix)
- && value[prefix.len()..]
- .bytes()
- .all(|byte| BECH32_DATA_CHARSET.contains(&byte))
-}
-
-#[cfg(test)]
-mod tests {
- use std::str::FromStr;
-
- use super::{
- MAX_SECRET_KEY_INPUT_BYTES, Npub, Nsec, PUBLIC_KEY_BYTE_LENGTH, PublicKey, SecretKeyInput,
- SecretKeyInputKind,
- };
- use crate::SafeErrorCode;
-
- const HEX: &str = "7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7";
- const NPUB: &str = "npub10elfcs4fr0l0r8af98jlmgdh9c8tcxjvz9qkw038js35mp4dma8qzvjptg";
- const NSEC: &str = "nsec1vl029mgpspedva04g90vltkh6fvh240zqtv9k0t9af8935ke9laqsnlfe5";
-
- #[test]
- fn public_key_round_trips_canonical_hex_and_bytes() {
- let key = PublicKey::from_str(HEX).expect("valid public key");
-
- assert_eq!(key.to_hex(), HEX);
- assert_eq!(key.to_string(), HEX);
- assert_eq!(key.short_hex(), "7e7e9c42…2107f6d7");
- assert_eq!(PublicKey::from_bytes(*key.as_bytes()), key);
- assert_eq!(key.as_bytes().len(), PUBLIC_KEY_BYTE_LENGTH);
- }
-
- #[test]
- fn public_key_rejects_noncanonical_or_malformed_hex() {
- for value in [
- "",
- "00",
- "7E7E9C42A91BFEF19FA7EA99D52D8AFDB67D893A8FEFBA1F5CB9793F2107F6D7",
- "ze7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7",
- " 7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7",
- ] {
- let error = PublicKey::from_hex(value).expect_err("invalid public key");
- assert_eq!(error.code(), SafeErrorCode::InvalidPublicKey);
- }
- }
-
- #[test]
- fn public_keys_are_ordered_by_canonical_bytes() {
- let low = PublicKey::from_bytes([0_u8; PUBLIC_KEY_BYTE_LENGTH]);
- let high = PublicKey::from_bytes([1_u8; PUBLIC_KEY_BYTE_LENGTH]);
-
- assert!(low < high);
- }
-
- #[test]
- fn secret_input_is_redacted_and_exposed_only_to_a_scoped_operation() {
- let secret = "11".repeat(PUBLIC_KEY_BYTE_LENGTH);
- let input = SecretKeyInput::parse(secret.clone()).expect("valid secret hex");
-
- assert_eq!(input.kind(), SecretKeyInputKind::Hex);
- assert_eq!(input.with_exposed_secret(str::len), secret.len());
- assert_eq!(input.with_exposed_secret(str::len), 64);
- }
-
- #[test]
- fn secret_input_accepts_nsec_shape_without_exposing_it() {
- let secret = NSEC.to_owned();
- let input = SecretKeyInput::parse(secret.clone()).expect("nsec-shaped input");
-
- assert_eq!(input.kind(), SecretKeyInputKind::Nsec);
- assert_eq!(input.with_exposed_secret(str::len), secret.len());
- }
-
- #[test]
- fn secret_input_rejects_invalid_hex_and_arbitrary_text() {
- for value in [
- "",
- "very-sensitive-input",
- &"GG".repeat(PUBLIC_KEY_BYTE_LENGTH),
- ] {
- let Err(error) = SecretKeyInput::parse(value.to_owned()) else {
- panic!("invalid secret accepted");
- };
- assert_eq!(error.code(), SafeErrorCode::InvalidSecretKey);
- if !value.is_empty() {
- assert!(!format!("{error:?}").contains(value));
- }
- }
- }
-
- #[test]
- fn secret_byte_transport_is_bounded_and_validated() {
- let parsed = SecretKeyInput::parse_bytes(HEX.as_bytes().to_vec()).expect("bytes");
- assert_eq!(parsed.with_exposed_secret(str::len), 64);
- assert!(SecretKeyInput::parse_bytes(vec![0xff]).is_err());
- assert!(SecretKeyInput::parse_bytes(vec![b'a'; MAX_SECRET_KEY_INPUT_BYTES + 1]).is_err());
- }
-
- #[test]
- fn npub_is_public_display_data_but_not_canonical_identity() {
- let npub = Npub::from_encoded(NPUB.to_owned()).expect("valid npub shape");
-
- assert_eq!(npub.as_str(), NPUB);
- assert_eq!(npub.to_string(), NPUB);
- }
-
- #[test]
- fn nsec_is_redacted_and_exposed_only_to_a_scoped_operation() {
- let nsec = Nsec::from_encoded(NSEC.to_owned()).expect("valid nsec shape");
-
- assert_eq!(nsec.with_exposed_secret(str::len), NSEC.len());
- assert_eq!(nsec.with_exposed_secret(str::len), NSEC.len());
- }
-
- #[test]
- fn nip19_display_types_reject_wrong_prefix_length_and_charset() {
- for invalid in [
- "",
- "npub1short",
- "nsec1short",
- "npub10elfcs4fr0l0r8af98jlmgdh9c8tcxjvz9qkw038js35mp4dma8qzvjp!g",
- ] {
- assert!(Npub::from_encoded(invalid.to_owned()).is_err());
- assert!(Nsec::from_encoded(invalid.to_owned()).is_err());
- }
- }
-}
diff --git a/core/crates/domain/src/lib.rs b/core/crates/domain/src/lib.rs
@@ -1,20 +0,0 @@
-#![doc = "Radroots Studio Nostr account domain types."]
-
-pub mod account;
-pub mod error;
-pub mod key;
-pub mod profile;
-pub mod relay;
-pub mod time;
-
-pub use account::{
- AccountCreatedAt, AccountIdentity, AccountLabel, AccountSummary, BindingAvailability,
- BindingRepairAction, LocalSignerBinding,
-};
-pub use error::{SafeError, SafeErrorCode, SafeMessage};
-pub use key::{
- MAX_SECRET_KEY_INPUT_BYTES, Npub, Nsec, PublicKey, SecretKeyInput, SecretKeyInputKind,
-};
-pub use profile::{EventId, Kind0ProfileCandidate, ProfileMetadata, select_latest_kind0};
-pub use relay::{RelayUrl, normalize_relay_urls};
-pub use time::UnixTimestamp;
diff --git a/core/crates/domain/src/profile.rs b/core/crates/domain/src/profile.rs
@@ -1,295 +0,0 @@
-//! Public Nostr profile metadata values.
-
-use crate::{PublicKey, SafeError, SafeErrorCode, SafeMessage, UnixTimestamp};
-
-const EVENT_ID_BYTES: usize = 32;
-const EVENT_ID_HEX: usize = EVENT_ID_BYTES * 2;
-const MAX_NAME_CHARS: usize = 128;
-const MAX_NIP05_CHARS: usize = 320;
-const MAX_ABOUT_CHARS: usize = 4_096;
-const MAX_PICTURE_CHARS: usize = 2_048;
-
-#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
-pub struct EventId([u8; EVENT_ID_BYTES]);
-
-impl EventId {
- /// Parses a canonical lowercase hexadecimal Nostr event ID.
- ///
- /// # Errors
- ///
- /// Returns a safe profile error for malformed input.
- pub fn from_hex(value: &str) -> Result<Self, SafeError> {
- if value.len() != EVENT_ID_HEX
- || !value
- .bytes()
- .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
- {
- return Err(invalid_profile_metadata());
- }
-
- let mut bytes = [0_u8; EVENT_ID_BYTES];
- for (index, pair) in value.as_bytes().chunks_exact(2).enumerate() {
- let high = decode_hex(pair[0]).ok_or_else(invalid_profile_metadata)?;
- let low = decode_hex(pair[1]).ok_or_else(invalid_profile_metadata)?;
- bytes[index] = (high << 4) | low;
- }
- Ok(Self(bytes))
- }
-
- #[must_use]
- pub const fn from_bytes(bytes: [u8; EVENT_ID_BYTES]) -> Self {
- Self(bytes)
- }
-
- #[must_use]
- pub const fn as_bytes(self) -> [u8; EVENT_ID_BYTES] {
- self.0
- }
-
- #[must_use]
- pub fn to_hex(self) -> String {
- const HEX: &[u8; 16] = b"0123456789abcdef";
- let mut output = String::with_capacity(EVENT_ID_HEX);
- for byte in self.0 {
- output.push(char::from(HEX[usize::from(byte >> 4)]));
- output.push(char::from(HEX[usize::from(byte & 0x0f)]));
- }
- output
- }
-}
-
-#[derive(Clone, Debug, Default, Eq, PartialEq)]
-pub struct ProfileMetadata {
- name: Option<String>,
- display_name: Option<String>,
- nip05: Option<String>,
- about: Option<String>,
- picture: Option<String>,
-}
-
-impl ProfileMetadata {
- /// Normalizes and bounds public kind-0 profile fields.
- ///
- /// # Errors
- ///
- /// Returns a safe profile error when a field exceeds its limit or contains
- /// a forbidden control character.
- pub fn new(
- name: Option<String>,
- display_name: Option<String>,
- nip05: Option<String>,
- about: Option<String>,
- picture: Option<String>,
- ) -> Result<Self, SafeError> {
- Ok(Self {
- name: normalize_field(name, MAX_NAME_CHARS, false)?,
- display_name: normalize_field(display_name, MAX_NAME_CHARS, false)?,
- nip05: normalize_field(nip05, MAX_NIP05_CHARS, false)?,
- about: normalize_field(about, MAX_ABOUT_CHARS, true)?,
- picture: normalize_field(picture, MAX_PICTURE_CHARS, false)?,
- })
- }
-
- #[must_use]
- pub fn name(&self) -> Option<&str> {
- self.name.as_deref()
- }
-
- #[must_use]
- pub fn display_name(&self) -> Option<&str> {
- self.display_name.as_deref()
- }
-
- #[must_use]
- pub fn nip05(&self) -> Option<&str> {
- self.nip05.as_deref()
- }
-
- #[must_use]
- pub fn about(&self) -> Option<&str> {
- self.about.as_deref()
- }
-
- #[must_use]
- pub fn picture(&self) -> Option<&str> {
- self.picture.as_deref()
- }
-
- #[must_use]
- pub fn preferred_name(&self) -> Option<&str> {
- self.display_name().or_else(|| self.name())
- }
-}
-
-#[derive(Clone, Debug, Eq, PartialEq)]
-pub struct Kind0ProfileCandidate {
- event_id: EventId,
- author: PublicKey,
- created_at: UnixTimestamp,
- metadata: ProfileMetadata,
-}
-
-impl Kind0ProfileCandidate {
- #[must_use]
- pub const fn new(
- event_id: EventId,
- author: PublicKey,
- created_at: UnixTimestamp,
- metadata: ProfileMetadata,
- ) -> Self {
- Self {
- event_id,
- author,
- created_at,
- metadata,
- }
- }
-
- #[must_use]
- pub const fn event_id(&self) -> EventId {
- self.event_id
- }
-
- #[must_use]
- pub const fn author(&self) -> PublicKey {
- self.author
- }
-
- #[must_use]
- pub const fn created_at(&self) -> UnixTimestamp {
- self.created_at
- }
-
- #[must_use]
- pub const fn metadata(&self) -> &ProfileMetadata {
- &self.metadata
- }
-}
-
-#[must_use]
-pub fn select_latest_kind0(
- candidates: impl IntoIterator<Item = Kind0ProfileCandidate>,
-) -> Option<Kind0ProfileCandidate> {
- candidates.into_iter().reduce(|selected, candidate| {
- if candidate.created_at > selected.created_at
- || (candidate.created_at == selected.created_at
- && candidate.event_id < selected.event_id)
- {
- candidate
- } else {
- selected
- }
- })
-}
-
-fn normalize_field(
- value: Option<String>,
- max_chars: usize,
- allow_layout_controls: bool,
-) -> Result<Option<String>, SafeError> {
- let Some(value) = value else {
- return Ok(None);
- };
- let normalized = value.trim();
- if normalized.is_empty() {
- return Ok(None);
- }
- if normalized.chars().count() > max_chars
- || normalized.chars().any(|character| {
- character.is_control()
- && !(allow_layout_controls && matches!(character, '\n' | '\r' | '\t'))
- })
- {
- return Err(invalid_profile_metadata());
- }
- Ok(Some(normalized.to_owned()))
-}
-
-const fn invalid_profile_metadata() -> SafeError {
- SafeError::new(
- SafeErrorCode::InvalidProfileMetadata,
- SafeMessage::new("The Nostr profile metadata is invalid."),
- )
-}
-
-const fn decode_hex(byte: u8) -> Option<u8> {
- match byte {
- b'0'..=b'9' => Some(byte - b'0'),
- b'a'..=b'f' => Some(byte - b'a' + 10),
- _ => None,
- }
-}
-
-#[cfg(test)]
-mod tests {
- use crate::{PublicKey, UnixTimestamp};
-
- use super::{EventId, Kind0ProfileCandidate, ProfileMetadata, select_latest_kind0};
-
- fn profile(name: &str) -> ProfileMetadata {
- ProfileMetadata::new(Some(name.to_owned()), None, None, None, None).expect("valid profile")
- }
-
- fn candidate(id_byte: u8, created_at: i64, name: &str) -> Kind0ProfileCandidate {
- Kind0ProfileCandidate::new(
- EventId::from_bytes([id_byte; 32]),
- PublicKey::from_bytes([9_u8; 32]),
- UnixTimestamp::from_seconds(created_at).expect("valid timestamp"),
- profile(name),
- )
- }
-
- #[test]
- fn profile_fields_are_trimmed_bounded_and_public() {
- let metadata = ProfileMetadata::new(
- Some(" farmer ".to_owned()),
- Some(" Farm Account ".to_owned()),
- Some("farmer@example.test".to_owned()),
- Some("First line\nSecond line".to_owned()),
- Some("https://images.example.test/profile.png".to_owned()),
- )
- .expect("valid profile");
-
- assert_eq!(metadata.name(), Some("farmer"));
- assert_eq!(metadata.display_name(), Some("Farm Account"));
- assert_eq!(metadata.preferred_name(), Some("Farm Account"));
- assert_eq!(metadata.nip05(), Some("farmer@example.test"));
- assert_eq!(metadata.about(), Some("First line\nSecond line"));
- assert_eq!(
- metadata.picture(),
- Some("https://images.example.test/profile.png")
- );
- }
-
- #[test]
- fn profile_fields_reject_forbidden_controls_and_oversize_values() {
- assert!(
- ProfileMetadata::new(Some("bad\0name".to_owned()), None, None, None, None).is_err()
- );
- assert!(ProfileMetadata::new(Some("x".repeat(129)), None, None, None, None).is_err());
- }
-
- #[test]
- fn latest_kind0_uses_timestamp_then_lowest_event_id() {
- let older = candidate(0, 10, "older");
- let equal_high_id = candidate(9, 20, "high-id");
- let equal_low_id = candidate(1, 20, "low-id");
-
- let selected =
- select_latest_kind0([older, equal_high_id, equal_low_id]).expect("selected profile");
-
- assert_eq!(selected.metadata().name(), Some("low-id"));
- assert_eq!(selected.event_id().as_bytes(), [1_u8; 32]);
- assert_eq!(selected.author(), PublicKey::from_bytes([9_u8; 32]));
- assert_eq!(selected.created_at().as_seconds(), 20);
- }
-
- #[test]
- fn event_id_rejects_noncanonical_hex_and_round_trips() {
- let hex = "12".repeat(32);
- let event_id = EventId::from_hex(&hex).expect("valid event id");
-
- assert_eq!(event_id.to_hex(), hex);
- assert!(EventId::from_hex(&"GG".repeat(32)).is_err());
- }
-}
diff --git a/core/crates/domain/src/relay.rs b/core/crates/domain/src/relay.rs
@@ -1,157 +0,0 @@
-//! Validated Nostr relay values.
-
-use std::collections::HashSet;
-use std::fmt::{self, Display, Formatter};
-use std::str::FromStr;
-
-use url::{Host, Url};
-
-use crate::{SafeError, SafeErrorCode, SafeMessage};
-
-#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
-pub struct RelayUrl(String);
-
-impl RelayUrl {
- /// Parses and normalizes an allowed WebSocket relay URL.
- ///
- /// # Errors
- ///
- /// Returns a safe configuration error for empty or malformed input,
- /// forbidden schemes, credentials, fragments, or non-loopback `ws://`.
- pub fn parse(value: &str) -> Result<Self, SafeError> {
- let trimmed = value.trim();
- if trimmed.is_empty() || trimmed.chars().any(char::is_control) {
- return Err(invalid_relay());
- }
-
- let parsed = Url::parse(trimmed).map_err(|_| invalid_relay())?;
- if !parsed.username().is_empty()
- || parsed.password().is_some()
- || parsed.fragment().is_some()
- {
- return Err(invalid_relay());
- }
-
- match parsed.scheme() {
- "wss" => {}
- "ws" if is_loopback(&parsed) => {}
- _ => return Err(invalid_relay()),
- }
-
- if parsed.host().is_none() {
- return Err(invalid_relay());
- }
-
- Ok(Self(parsed.to_string()))
- }
-
- #[must_use]
- pub fn as_str(&self) -> &str {
- &self.0
- }
-}
-
-impl Display for RelayUrl {
- fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result {
- formatter.write_str(&self.0)
- }
-}
-
-impl FromStr for RelayUrl {
- type Err = SafeError;
-
- fn from_str(value: &str) -> Result<Self, Self::Err> {
- Self::parse(value)
- }
-}
-
-/// Parses relay values and removes duplicates without changing first-seen order.
-///
-/// # Errors
-///
-/// Returns the first safe relay validation error.
-pub fn normalize_relay_urls<I, S>(values: I) -> Result<Vec<RelayUrl>, SafeError>
-where
- I: IntoIterator<Item = S>,
- S: AsRef<str>,
-{
- let mut seen = HashSet::new();
- let mut relays = Vec::new();
- for value in values {
- let relay = RelayUrl::parse(value.as_ref())?;
- if seen.insert(relay.clone()) {
- relays.push(relay);
- }
- }
- Ok(relays)
-}
-
-fn is_loopback(url: &Url) -> bool {
- match url.host() {
- Some(Host::Domain(domain)) => domain == "localhost",
- Some(Host::Ipv4(address)) => address.octets()[0] == 127,
- Some(Host::Ipv6(address)) => address.is_loopback(),
- None => false,
- }
-}
-
-const fn invalid_relay() -> SafeError {
- SafeError::new(
- SafeErrorCode::InvalidRelayConfiguration,
- SafeMessage::new("The Nostr relay URL is invalid."),
- )
-}
-
-#[cfg(test)]
-mod tests {
- use super::{RelayUrl, normalize_relay_urls};
- use crate::SafeErrorCode;
-
- #[test]
- fn relay_accepts_secure_remote_and_loopback_development_urls() {
- for (input, expected) in [
- (" wss://Relay.Example/path ", "wss://relay.example/path"),
- ("ws://localhost:8080", "ws://localhost:8080/"),
- ("ws://127.42.1.9:8080", "ws://127.42.1.9:8080/"),
- ("ws://[::1]:8080", "ws://[::1]:8080/"),
- ] {
- let relay = RelayUrl::parse(input).expect("allowed relay");
- assert_eq!(relay.as_str(), expected);
- assert_eq!(relay.to_string(), expected);
- }
- }
-
- #[test]
- fn relay_rejects_non_websocket_credentials_fragments_and_remote_plaintext() {
- for input in [
- "",
- "https://relay.example",
- "http://localhost:8080",
- "wss://user:password@relay.example",
- "wss://relay.example/#fragment",
- "ws://relay.example",
- "ws://192.168.1.2:8080",
- "ws://localhost.evil.example:8080",
- "wss://relay.example/\nunsafe",
- ] {
- let error = RelayUrl::parse(input).expect_err("forbidden relay");
- assert_eq!(error.code(), SafeErrorCode::InvalidRelayConfiguration);
- }
- }
-
- #[test]
- fn relay_deduplication_preserves_normalized_first_seen_order() {
- let relays = normalize_relay_urls([
- "wss://relay.example",
- " wss://second.example/path ",
- "wss://RELAY.example/",
- "wss://second.example/path",
- ])
- .expect("valid relays");
-
- assert_eq!(
- relays.iter().map(RelayUrl::as_str).collect::<Vec<_>>(),
- vec!["wss://relay.example/", "wss://second.example/path"]
- );
- }
-}
diff --git a/core/crates/domain/src/time.rs b/core/crates/domain/src/time.rs
@@ -1,34 +0,0 @@
-//! Time values shared by account and profile records.
-
-#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
-pub struct UnixTimestamp(i64);
-
-impl UnixTimestamp {
- #[must_use]
- pub const fn from_seconds(seconds: i64) -> Option<Self> {
- if seconds < 0 {
- None
- } else {
- Some(Self(seconds))
- }
- }
-
- #[must_use]
- pub const fn as_seconds(self) -> i64 {
- self.0
- }
-}
-
-#[cfg(test)]
-mod tests {
- use super::UnixTimestamp;
-
- #[test]
- fn timestamp_rejects_negative_seconds() {
- assert_eq!(UnixTimestamp::from_seconds(-1), None);
- assert_eq!(
- UnixTimestamp::from_seconds(0).map(UnixTimestamp::as_seconds),
- Some(0)
- );
- }
-}
diff --git a/core/crates/ffi/Cargo.toml b/core/crates/ffi/Cargo.toml
@@ -1,27 +0,0 @@
-[package]
-name = "radroots-studio-ffi"
-version.workspace = true
-edition.workspace = true
-rust-version.workspace = true
-license.workspace = true
-repository.workspace = true
-
-[lib]
-crate-type = ["cdylib", "rlib"]
-
-[dependencies]
-directories.workspace = true
-radroots-studio-application = { path = "../application" }
-radroots-studio-domain = { path = "../domain" }
-radroots-studio-storage = { path = "../storage" }
-tokio.workspace = true
-uniffi.workspace = true
-
-[dev-dependencies]
-nostr.workspace = true
-nostr-relay-builder.workspace = true
-nostr-sdk.workspace = true
-tempfile = "=3.23.0"
-
-[lints]
-workspace = true
diff --git a/core/crates/ffi/src/commands.rs b/core/crates/ffi/src/commands.rs
@@ -1,853 +0,0 @@
-use std::collections::BTreeMap;
-use std::fmt::{self, Display, Formatter};
-use std::num::NonZeroUsize;
-use std::path::{Path, PathBuf};
-use std::sync::atomic::{AtomicBool, Ordering};
-use std::sync::{Arc, Mutex, OnceLock};
-use std::time::{Duration, SystemTime, UNIX_EPOCH};
-
-use directories::ProjectDirs;
-use radroots_studio_application::{
- Clock, DurableRequestId, GeneratedKeyRecoveryHandle, RelayConfiguration, RelayRuntimeMode,
- RemovalConfirmationToken, SdkNostrClient, relay_configuration_from_environment,
-};
-use radroots_studio_domain::{PublicKey, SafeError, SecretKeyInput, UnixTimestamp};
-use radroots_studio_storage::{OsKeyringSecretStore, RuntimeActorHandle};
-
-use crate::{
- AccountDto, AppSnapshotDto, WireErrorCategory, WireErrorCode, WireRecoveryAction,
- dto::error_policy,
-};
-
-const DATABASE_QUALIFIER: &str = "org";
-const DATABASE_ORGANIZATION: &str = "radroots";
-const DATABASE_APPLICATION: &str = "studio";
-const DATABASE_FILENAME: &str = "studio.sqlite3";
-const DEVELOPMENT_DATA_DIR_ENVIRONMENT: &str = "RADROOTS_STUDIO_DEVELOPMENT_DATA_DIR";
-pub(crate) const ACTOR_MAILBOX_CAPACITY: usize = 64;
-pub const FFI_CONTRACT_MAJOR: u16 = 2;
-pub const FFI_CONTRACT_MINOR: u16 = 0;
-pub const FFI_CONTRACT_HASH: &str = "radroots-studio-native-v2-2026-08-03";
-const MAX_COMMAND_DEADLINE_MILLIS: u64 = 30_000;
-
-#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)]
-pub struct RequestContextDto {
- pub request_id: String,
- pub expected_revision: u64,
- pub deadline_millis: u64,
-}
-
-#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)]
-pub struct AccountCommandReceiptDto {
- pub request_id: String,
- pub committed_revision: u64,
- pub snapshot: AppSnapshotDto,
-}
-
-#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)]
-pub struct CompatibilityDescriptor {
- pub contract_major: u16,
- pub contract_minor: u16,
- pub contract_hash: String,
- pub minimum_schema_version: u32,
- pub current_schema_version: u32,
-}
-
-#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)]
-pub struct CompatibilityExpectation {
- pub contract_major: u16,
- pub minimum_contract_minor: u16,
- pub contract_hash: String,
- pub minimum_schema_version: u32,
- pub maximum_schema_version: u32,
-}
-
-#[uniffi::export]
-pub fn compatibility_descriptor() -> CompatibilityDescriptor {
- CompatibilityDescriptor {
- contract_major: FFI_CONTRACT_MAJOR,
- contract_minor: FFI_CONTRACT_MINOR,
- contract_hash: FFI_CONTRACT_HASH.to_owned(),
- minimum_schema_version: 5,
- current_schema_version: radroots_studio_storage::CURRENT_SCHEMA_VERSION,
- }
-}
-
-#[derive(Debug, uniffi::Error)]
-pub enum StudioError {
- Failure {
- code: WireErrorCode,
- category: WireErrorCategory,
- retryable: bool,
- recovery_action: WireRecoveryAction,
- correlation_id: Option<String>,
- safe_message: String,
- },
-}
-
-impl Display for StudioError {
- fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result {
- match self {
- Self::Failure { safe_message, .. } => formatter.write_str(safe_message),
- }
- }
-}
-
-impl std::error::Error for StudioError {}
-
-impl From<SafeError> for StudioError {
- fn from(error: SafeError) -> Self {
- let (category, retryable, recovery_action) = error_policy(error.code());
- Self::Failure {
- code: error.code().into(),
- category,
- retryable,
- recovery_action,
- correlation_id: None,
- safe_message: error.message().as_str().to_owned(),
- }
- }
-}
-
-impl StudioError {
- fn correlated(error: SafeError, correlation_id: &str) -> Self {
- let (category, retryable, recovery_action) = error_policy(error.code());
- Self::Failure {
- code: error.code().into(),
- category,
- retryable,
- recovery_action,
- correlation_id: Some(correlation_id.to_owned()),
- safe_message: error.message().as_str().to_owned(),
- }
- }
-}
-
-#[derive(uniffi::Object)]
-pub struct GeneratedRecoveryRequest {
- handle: GeneratedKeyRecoveryHandle,
- resolved: AtomicBool,
-}
-
-#[uniffi::export]
-impl GeneratedRecoveryRequest {
- pub fn account(&self) -> AccountDto {
- self.handle.view().account().into()
- }
-
- pub fn expires_at_seconds(&self) -> i64 {
- self.handle.view().expires_at().as_seconds()
- }
-
- /// Returns the recovery secret exactly once.
- ///
- /// # Errors
- ///
- /// Returns a safe unavailable error after the first read.
- pub fn take_recovery_nsec(&self) -> Result<String, StudioError> {
- self.handle
- .take_recovery_nsec()
- .map(|nsec| nsec.with_exposed_secret(str::to_owned))
- .map_err(StudioError::from)
- }
-}
-
-#[derive(uniffi::Object)]
-pub struct RemovalRequest {
- public_key_hex: String,
- deletes_local_credential: bool,
- signs_out: bool,
- expires_at_seconds: i64,
- token: Mutex<Option<RemovalConfirmationToken>>,
-}
-
-#[uniffi::export]
-impl RemovalRequest {
- pub fn public_key_hex(&self) -> String {
- self.public_key_hex.clone()
- }
-
- pub fn deletes_local_credential(&self) -> bool {
- self.deletes_local_credential
- }
-
- pub fn signs_out(&self) -> bool {
- self.signs_out
- }
-
- pub fn expires_at_seconds(&self) -> i64 {
- self.expires_at_seconds
- }
-}
-
-pub(crate) struct RuntimeCore {
- pub(crate) actor: RuntimeActorHandle,
- pub(crate) observers: Mutex<
- BTreeMap<radroots_studio_application::ChangeSubscriptionId, tokio::task::JoinHandle<()>>,
- >,
- pub(crate) closed: AtomicBool,
- pub(crate) startup_relay_problem: Option<SafeError>,
-}
-
-impl RuntimeCore {
- pub(crate) fn snapshot_dto(&self) -> AppSnapshotDto {
- AppSnapshotDto::from_runtime(&self.actor.snapshot(), self.effective_lifecycle())
- }
-
- pub(crate) fn dto_for(
- &self,
- snapshot: &radroots_studio_application::AppSnapshot,
- ) -> AppSnapshotDto {
- AppSnapshotDto::from_runtime(snapshot, self.effective_lifecycle())
- }
-
- pub(crate) fn effective_lifecycle(&self) -> radroots_studio_application::RuntimeLifecycle {
- let lifecycle = self.actor.lifecycle();
- match (lifecycle, self.startup_relay_problem) {
- (radroots_studio_application::RuntimeLifecycle::Ready, Some(problem)) => {
- radroots_studio_application::RuntimeLifecycle::Degraded(problem)
- }
- _ => lifecycle,
- }
- }
-}
-
-#[derive(uniffi::Object)]
-pub struct StudioAppCore {
- pub(crate) inner: Arc<RuntimeCore>,
-}
-
-#[uniffi::export]
-impl StudioAppCore {
- /// Verifies the static contract before touching the application data path.
- ///
- /// # Errors
- ///
- /// Returns a safe compatibility error without opening or migrating storage.
- #[uniffi::constructor]
- #[allow(clippy::needless_pass_by_value)]
- pub fn open_compatible(
- expectation: CompatibilityExpectation,
- development_mode: bool,
- ) -> Result<Arc<Self>, StudioError> {
- let path = application_database_path(development_mode)?;
- Self::open_path_compatible(&path, &expectation, development_mode)
- }
-
- /// Restores durable public application state.
- ///
- /// # Errors
- ///
- /// Returns a safe storage, recovery, or application-state error.
- pub async fn bootstrap(&self) -> Result<AppSnapshotDto, StudioError> {
- self.inner
- .actor
- .bootstrap()
- .await
- .map(|snapshot| self.inner.dto_for(&snapshot))
- .map_err(StudioError::from)
- }
-
- #[must_use]
- pub fn snapshot(&self) -> AppSnapshotDto {
- self.inner.snapshot_dto()
- }
-
- /// Begins the exclusive generated-account recovery flow without persistence.
- ///
- /// # Errors
- ///
- /// Returns a safe key-generation, conflict, timeout, or lifecycle error.
- pub async fn begin_generated_account_v2(
- &self,
- ) -> Result<Arc<GeneratedRecoveryRequest>, StudioError> {
- self.inner
- .actor
- .begin_generated_key_stage()
- .await
- .map(|handle| {
- Arc::new(GeneratedRecoveryRequest {
- handle,
- resolved: AtomicBool::new(false),
- })
- })
- .map_err(StudioError::from)
- }
-
- /// Acknowledges recovery and commits the generated account once.
- ///
- /// # Errors
- ///
- /// Returns a terminal safe recovery, credential, persistence, timeout, or lifecycle error.
- /// A failed commit must be recovered by importing the already-saved recovery key.
- pub async fn acknowledge_generated_account_v2(
- &self,
- request: Arc<GeneratedRecoveryRequest>,
- ) -> Result<AppSnapshotDto, StudioError> {
- if request.resolved.swap(true, Ordering::AcqRel) {
- return Err(generated_recovery_expired());
- }
- self.inner
- .actor
- .acknowledge_generated_key_stage(request.handle.id())
- .await
- .map(|snapshot| self.inner.dto_for(&snapshot))
- .map_err(generated_commit_failed)
- }
-
- /// Cancels the exclusive generated-account recovery flow.
- ///
- /// # Errors
- ///
- /// Returns a safe timeout or lifecycle error.
- pub async fn cancel_generated_account_v2(
- &self,
- request: Arc<GeneratedRecoveryRequest>,
- ) -> Result<bool, StudioError> {
- if request.resolved.swap(true, Ordering::AcqRel) {
- return Ok(false);
- }
- self.inner
- .actor
- .cancel_generated_key_stage()
- .await
- .map_err(StudioError::from)
- }
-
- /// Imports or repairs an account using a caller-owned idempotency key.
- ///
- /// # Errors
- ///
- /// Returns a correlated validation, conflict, timeout, credential, or storage error.
- pub async fn import_account_v2(
- &self,
- context: RequestContextDto,
- secret_key: Vec<u8>,
- ) -> Result<AccountCommandReceiptDto, StudioError> {
- let request_id = DurableRequestId::parse(context.request_id.clone())
- .map_err(|error| StudioError::correlated(error, &context.request_id))?;
- let timeout = command_timeout(context.deadline_millis, &context.request_id)?;
- let input = SecretKeyInput::parse_bytes(secret_key)
- .map_err(|error| StudioError::correlated(error, &context.request_id))?;
- self.inner
- .actor
- .import_secret_key_request(
- request_id,
- radroots_studio_application::SnapshotRevision::from_value(
- context.expected_revision,
- ),
- input,
- timeout,
- )
- .await
- .map(|_| {
- let snapshot = self.inner.snapshot_dto();
- AccountCommandReceiptDto {
- request_id: context.request_id.clone(),
- committed_revision: snapshot.revision,
- snapshot,
- }
- })
- .map_err(|error| StudioError::correlated(error, &context.request_id))
- }
-
- /// Selects one saved account without activating it.
- ///
- /// # Errors
- ///
- /// Returns a safe public-key, account, or storage error.
- pub async fn select_account(
- &self,
- public_key_hex: String,
- ) -> Result<AppSnapshotDto, StudioError> {
- let public_key = parse_public_key(&public_key_hex)?;
- self.inner
- .actor
- .select_account(public_key)
- .await
- .map(|snapshot| self.inner.dto_for(&snapshot))
- .map_err(StudioError::from)
- }
-
- /// Activates one saved account after validating its credential.
- ///
- /// # Errors
- ///
- /// Returns a safe public-key, credential, account, or storage error.
- pub async fn activate_account(
- &self,
- public_key_hex: String,
- ) -> Result<AppSnapshotDto, StudioError> {
- let public_key = parse_public_key(&public_key_hex)?;
- self.inner
- .actor
- .activate_account(public_key)
- .await
- .map(|snapshot| self.inner.dto_for(&snapshot))
- .map_err(StudioError::from)
- }
-
- /// Signs out while retaining accounts and credentials.
- ///
- /// # Errors
- ///
- /// Returns a safe application-state error.
- pub async fn sign_out(&self) -> Result<AppSnapshotDto, StudioError> {
- self.inner
- .actor
- .sign_out()
- .await
- .map(|snapshot| self.inner.dto_for(&snapshot))
- .map_err(StudioError::from)
- }
-
- /// Refreshes the active Nostr profile from configured relays.
- ///
- /// # Errors
- ///
- /// Returns a safe storage or application-state error.
- pub async fn refresh_active_profile(&self) -> Result<AppSnapshotDto, StudioError> {
- self.inner
- .actor
- .refresh_active_profile()
- .await
- .map(|snapshot| self.inner.dto_for(&snapshot))
- .map_err(StudioError::from)
- }
-
- /// Issues a revision-bound removal confirmation object.
- ///
- /// # Errors
- ///
- /// Returns a safe public-key or account error.
- pub async fn request_account_removal(
- &self,
- public_key_hex: String,
- ) -> Result<Arc<RemovalRequest>, StudioError> {
- let public_key = parse_public_key(&public_key_hex)?;
- self.inner
- .actor
- .request_account_removal(public_key)
- .await
- .map(|token| {
- let impact = token.impact();
- Arc::new(RemovalRequest {
- public_key_hex,
- deletes_local_credential: impact.deletes_local_credential(),
- signs_out: impact.signs_out(),
- expires_at_seconds: token.expires_at().as_seconds(),
- token: Mutex::new(Some(token)),
- })
- })
- .map_err(StudioError::from)
- }
-
- /// Permanently removes the account represented by a one-time request.
- ///
- /// # Errors
- ///
- /// Returns a safe confirmation, credential, recovery, or storage error.
- pub async fn confirm_account_removal(
- &self,
- request: Arc<RemovalRequest>,
- ) -> Result<AppSnapshotDto, StudioError> {
- let token = request
- .token
- .lock()
- .unwrap_or_else(std::sync::PoisonError::into_inner)
- .take()
- .ok_or_else(confirmation_expired)?;
- self.inner
- .actor
- .confirm_account_removal(token)
- .await
- .map(|snapshot| self.inner.dto_for(&snapshot))
- .map_err(StudioError::from)
- }
-}
-
-fn verify_compatibility(expectation: &CompatibilityExpectation) -> Result<(), StudioError> {
- let actual = compatibility_descriptor();
- if expectation.contract_major != actual.contract_major
- || expectation.minimum_contract_minor > actual.contract_minor
- || expectation.contract_hash != actual.contract_hash
- || expectation.minimum_schema_version > actual.current_schema_version
- || expectation.maximum_schema_version < actual.minimum_schema_version
- {
- return Err(compatibility_mismatch());
- }
- Ok(())
-}
-
-impl StudioAppCore {
- fn open_path_compatible(
- path: &Path,
- expectation: &CompatibilityExpectation,
- development_mode: bool,
- ) -> Result<Arc<Self>, StudioError> {
- verify_compatibility(expectation)?;
- std::fs::create_dir_all(path.parent().ok_or_else(path_unavailable)?)
- .map_err(|_| path_unavailable())?;
- Self::open_path(path, development_mode)
- }
-
- fn open_path(path: &Path, development_mode: bool) -> Result<Arc<Self>, StudioError> {
- let mode = if development_mode {
- RelayRuntimeMode::Development
- } else {
- RelayRuntimeMode::Packaged
- };
- let (relays, startup_relay_problem) =
- local_first_relay_configuration(relay_configuration_from_environment(mode));
- let actor = RuntimeActorHandle::open(
- path,
- relays,
- Arc::new(OsKeyringSecretStore::default()),
- Arc::new(SystemClock),
- Arc::new(SdkNostrClient::new(Duration::from_secs(5))),
- NonZeroUsize::new(ACTOR_MAILBOX_CAPACITY).expect("nonzero actor mailbox capacity"),
- runtime().handle(),
- )?;
- Ok(Arc::new(Self {
- inner: Arc::new(RuntimeCore {
- actor,
- observers: Mutex::new(BTreeMap::new()),
- closed: AtomicBool::new(false),
- startup_relay_problem,
- }),
- }))
- }
-}
-
-fn local_first_relay_configuration(
- configured: Result<RelayConfiguration, SafeError>,
-) -> (RelayConfiguration, Option<SafeError>) {
- match configured {
- Ok(relays) => (relays, None),
- Err(problem) => (RelayConfiguration::default(), Some(problem)),
- }
-}
-
-#[derive(Clone, Copy)]
-pub(crate) struct SystemClock;
-
-impl Clock for SystemClock {
- fn now(&self) -> UnixTimestamp {
- let seconds = SystemTime::now()
- .duration_since(UNIX_EPOCH)
- .map_or(0, |duration| {
- i64::try_from(duration.as_secs()).unwrap_or(i64::MAX)
- });
- UnixTimestamp::from_seconds(seconds).expect("system time is nonnegative")
- }
-}
-
-fn application_database_path(development_mode: bool) -> Result<PathBuf, StudioError> {
- if development_mode && let Some(directory) = std::env::var_os(DEVELOPMENT_DATA_DIR_ENVIRONMENT)
- {
- return Ok(PathBuf::from(directory).join(DATABASE_FILENAME));
- }
- ProjectDirs::from(
- DATABASE_QUALIFIER,
- DATABASE_ORGANIZATION,
- DATABASE_APPLICATION,
- )
- .map(|project| project.data_dir().join(DATABASE_FILENAME))
- .ok_or_else(path_unavailable)
-}
-
-fn parse_public_key(value: &str) -> Result<PublicKey, StudioError> {
- PublicKey::from_hex(value).map_err(StudioError::from)
-}
-
-fn command_timeout(millis: u64, correlation_id: &str) -> Result<Duration, StudioError> {
- if millis == 0 || millis > MAX_COMMAND_DEADLINE_MILLIS {
- return Err(StudioError::Failure {
- code: WireErrorCode::InvalidApplicationState,
- category: WireErrorCategory::Input,
- retryable: false,
- recovery_action: WireRecoveryAction::None,
- correlation_id: Some(correlation_id.to_owned()),
- safe_message: "The command deadline is invalid.".to_owned(),
- });
- }
- Ok(Duration::from_millis(millis))
-}
-
-pub(crate) fn runtime() -> &'static tokio::runtime::Runtime {
- static RUNTIME: OnceLock<tokio::runtime::Runtime> = OnceLock::new();
- RUNTIME.get_or_init(|| {
- tokio::runtime::Builder::new_multi_thread()
- .enable_all()
- .thread_name("radroots-studio-core")
- .build()
- .expect("Tokio runtime construction")
- })
-}
-
-fn path_unavailable() -> StudioError {
- StudioError::Failure {
- code: WireErrorCode::StorageUnavailable,
- category: WireErrorCategory::Storage,
- retryable: true,
- recovery_action: WireRecoveryAction::RestartApplication,
- correlation_id: None,
- safe_message: "The application data directory is unavailable.".to_owned(),
- }
-}
-
-fn confirmation_expired() -> StudioError {
- StudioError::Failure {
- code: WireErrorCode::InvalidApplicationState,
- category: WireErrorCategory::Lifecycle,
- retryable: false,
- recovery_action: WireRecoveryAction::None,
- correlation_id: None,
- safe_message: "The account removal confirmation is no longer valid.".to_owned(),
- }
-}
-
-fn generated_recovery_expired() -> StudioError {
- StudioError::Failure {
- code: WireErrorCode::InvalidApplicationState,
- category: WireErrorCategory::Lifecycle,
- retryable: false,
- recovery_action: WireRecoveryAction::None,
- correlation_id: None,
- safe_message: "The generated-key recovery step is no longer valid.".to_owned(),
- }
-}
-
-fn generated_commit_failed(error: SafeError) -> StudioError {
- let (category, _, _) = error_policy(error.code());
- StudioError::Failure {
- code: error.code().into(),
- category,
- retryable: false,
- recovery_action: WireRecoveryAction::None,
- correlation_id: None,
- safe_message:
- "The generated account could not be saved. Import the recovery key you saved to try again."
- .to_owned(),
- }
-}
-
-fn compatibility_mismatch() -> StudioError {
- StudioError::Failure {
- code: WireErrorCode::CompatibilityMismatch,
- category: WireErrorCategory::Compatibility,
- retryable: false,
- recovery_action: WireRecoveryAction::UpdateApplication,
- correlation_id: None,
- safe_message: "The application and native runtime are incompatible.".to_owned(),
- }
-}
-
-#[cfg(test)]
-mod tests {
- use std::num::NonZeroUsize;
- use std::sync::Arc;
-
- use radroots_studio_application::{InMemorySecretStore, RelayConfiguration, SdkNostrClient};
- use radroots_studio_domain::SafeError;
- use radroots_studio_storage::RuntimeActorHandle;
-
- use radroots_studio_storage::{CREDENTIAL_SERVICE, CURRENT_SCHEMA_VERSION};
-
- use super::{
- ACTOR_MAILBOX_CAPACITY, CompatibilityExpectation, DATABASE_APPLICATION, DATABASE_FILENAME,
- DATABASE_ORGANIZATION, DATABASE_QUALIFIER, FFI_CONTRACT_HASH, FFI_CONTRACT_MAJOR,
- FFI_CONTRACT_MINOR, RequestContextDto, RuntimeCore, StudioAppCore, StudioError,
- SystemClock, compatibility_descriptor, local_first_relay_configuration, runtime,
- verify_compatibility,
- };
-
- fn in_memory_core() -> Arc<StudioAppCore> {
- let actor = RuntimeActorHandle::in_memory(
- RelayConfiguration::default(),
- Arc::new(InMemorySecretStore::default()),
- Arc::new(SystemClock),
- Arc::new(SdkNostrClient::new(std::time::Duration::from_millis(10))),
- NonZeroUsize::new(ACTOR_MAILBOX_CAPACITY).expect("capacity"),
- runtime().handle(),
- )
- .expect("in-memory actor");
- Arc::new(StudioAppCore {
- inner: Arc::new(RuntimeCore {
- actor,
- observers: std::sync::Mutex::new(std::collections::BTreeMap::new()),
- closed: std::sync::atomic::AtomicBool::new(false),
- startup_relay_problem: None,
- }),
- })
- }
-
- #[tokio::test]
- async fn exported_bootstrap_and_snapshot_are_revisioned() {
- let core = in_memory_core();
- let bootstrapped = core.bootstrap().await.expect("bootstrap");
- let current = core.snapshot();
-
- assert_eq!(bootstrapped, current);
- assert_eq!(current.revision, 1);
- }
-
- #[tokio::test]
- async fn request_context_import_replays_one_committed_receipt() {
- let core = in_memory_core();
- let initial = core.snapshot();
- let context = RequestContextDto {
- request_id: "ffi-test-import-1".to_owned(),
- expected_revision: initial.revision,
- deadline_millis: 5_000,
- };
- let secret = b"7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7";
- let first = core
- .import_account_v2(context.clone(), secret.to_vec())
- .await
- .expect("first import");
- let replay = core
- .import_account_v2(context, secret.to_vec())
- .await
- .expect("replayed import");
-
- assert_eq!(first, replay);
- assert_eq!(first.snapshot.accounts.len(), 1);
- assert_eq!(first.request_id, "ffi-test-import-1");
- }
-
- #[tokio::test]
- async fn generated_recovery_handle_is_one_use_and_acknowledgement_gated() {
- let core = in_memory_core();
- let initial = core.snapshot();
- let recovery = core
- .begin_generated_account_v2()
- .await
- .expect("begin recovery");
-
- assert_eq!(core.snapshot(), initial);
- let nsec = recovery.take_recovery_nsec().expect("one-use nsec");
- assert!(nsec.starts_with("nsec1"));
- assert!(recovery.take_recovery_nsec().is_err());
- let committed = core
- .acknowledge_generated_account_v2(Arc::clone(&recovery))
- .await
- .expect("acknowledge");
- assert_eq!(committed.accounts.len(), 1);
- let repeated = core
- .acknowledge_generated_account_v2(recovery)
- .await
- .expect_err("repeated acknowledgement");
- assert!(matches!(
- repeated,
- StudioError::Failure { safe_message, .. }
- if safe_message == "The generated-key recovery step is no longer valid."
- ));
- }
-
- #[test]
- fn compatibility_matrix_rejects_before_storage_mutation() {
- let actual = compatibility_descriptor();
- let compatible = CompatibilityExpectation {
- contract_major: FFI_CONTRACT_MAJOR,
- minimum_contract_minor: FFI_CONTRACT_MINOR,
- contract_hash: FFI_CONTRACT_HASH.to_owned(),
- minimum_schema_version: 5,
- maximum_schema_version: CURRENT_SCHEMA_VERSION,
- };
- verify_compatibility(&compatible).expect("compatible");
-
- for incompatible in [
- CompatibilityExpectation {
- contract_major: FFI_CONTRACT_MAJOR + 1,
- ..compatible.clone()
- },
- CompatibilityExpectation {
- minimum_contract_minor: FFI_CONTRACT_MINOR + 1,
- ..compatible.clone()
- },
- CompatibilityExpectation {
- contract_hash: "wrong-contract".to_owned(),
- ..compatible.clone()
- },
- CompatibilityExpectation {
- minimum_schema_version: actual.current_schema_version + 1,
- ..compatible.clone()
- },
- CompatibilityExpectation {
- maximum_schema_version: actual.minimum_schema_version - 1,
- ..compatible.clone()
- },
- ] {
- assert!(verify_compatibility(&incompatible).is_err());
- }
-
- let directory = tempfile::tempdir().expect("directory");
- let rejected = directory.path().join("rejected").join("studio.sqlite3");
- let incompatible = CompatibilityExpectation {
- contract_major: FFI_CONTRACT_MAJOR + 1,
- ..compatible
- };
- assert!(StudioAppCore::open_path_compatible(&rejected, &incompatible, true).is_err());
- assert!(!rejected.parent().expect("parent").exists());
- }
-
- #[test]
- fn v5_compatibility_fixture_preserves_external_coordinates() {
- let fixture = include_str!("../../../compatibility/v5-baseline.properties");
- let property = |key: &str| {
- fixture.lines().find_map(|line| {
- line.split_once('=')
- .filter(|(candidate, _)| *candidate == key)
- .map(|(_, value)| value)
- })
- };
-
- assert_eq!(property("baseline.id"), Some("studio-runtime-v5"));
- assert_eq!(property("schema.version"), Some("5"));
- assert_eq!(CURRENT_SCHEMA_VERSION, 9);
- assert_eq!(property("ffi.contract"), Some("legacy-unversioned-v1"));
- assert_eq!(property("ffi.snapshot.schema"), Some("1"));
- assert_eq!(property("ffi.runtime.version"), Some("0.1.0-alpha"));
- assert_eq!(property("database.qualifier"), Some(DATABASE_QUALIFIER));
- assert_eq!(
- property("database.organization"),
- Some(DATABASE_ORGANIZATION)
- );
- assert_eq!(property("database.application"), Some(DATABASE_APPLICATION));
- assert_eq!(property("database.filename"), Some(DATABASE_FILENAME));
- assert_eq!(property("keyring.service"), Some(CREDENTIAL_SERVICE));
- assert_eq!(
- property("keyring.account"),
- Some("canonical-lowercase-public-key-hex")
- );
- }
-
- #[test]
- fn superseded_v1_ffi_commands_are_absent() {
- let commands = include_str!("commands.rs");
- let observer = include_str!("observer.rs");
- for forbidden in [
- format!("pub async fn {}_account(", "generate"),
- format!("pub async fn {}_secret_key(", "import"),
- format!("pub fn {}(development_mode", "open"),
- format!("pub async fn {}(", "subscribe"),
- format!("pub fn {}(&self)", "shutdown"),
- ] {
- assert!(!commands.contains(&forbidden));
- assert!(!observer.contains(&forbidden));
- }
- }
-
- #[test]
- fn invalid_relay_configuration_preserves_local_startup_as_degraded() {
- let problem = SafeError::new(
- radroots_studio_domain::SafeErrorCode::InvalidRelayConfiguration,
- radroots_studio_domain::SafeMessage::new("The Nostr relay configuration is invalid."),
- );
- let (relays, degraded) = local_first_relay_configuration(Err(problem));
-
- assert!(relays.relays().is_empty());
- assert_eq!(degraded, Some(problem));
- }
-}
diff --git a/core/crates/ffi/src/dto.rs b/core/crates/ffi/src/dto.rs
@@ -1,419 +0,0 @@
-use radroots_studio_application::{
- ActiveAccountSnapshot, AppLifecycle, AppSnapshot, ProfileLoadState, RelayConnectionState,
- RuntimeLifecycle, SessionState,
-};
-use radroots_studio_domain::{
- AccountSummary, BindingAvailability, ProfileMetadata, SafeError, SafeErrorCode,
-};
-
-#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)]
-pub enum WireErrorCode {
- InvalidPublicKey,
- InvalidSecretKey,
- InvalidAccountMetadata,
- InvalidProfileMetadata,
- InvalidApplicationState,
- AccountAlreadyExists,
- AccountNotFound,
- KeyringUnavailable,
- CredentialMissing,
- StorageUnavailable,
- StorageCorrupt,
- PendingOperationRecoveryRequired,
- InvalidRelayConfiguration,
- RelayConnectionFailed,
- ProfileRefreshFailed,
- ObserverRegistrationFailed,
- NativeLibraryLoadFailed,
- CompatibilityMismatch,
- Internal,
-}
-
-#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)]
-pub enum WireErrorCategory {
- Input,
- Conflict,
- Credential,
- Storage,
- Network,
- Lifecycle,
- Compatibility,
- Internal,
-}
-
-#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)]
-pub enum WireRecoveryAction {
- None,
- Retry,
- RepairCredential,
- CheckConfiguration,
- RestartApplication,
- UpdateApplication,
-}
-
-#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)]
-pub struct SafeErrorDto {
- pub code: WireErrorCode,
- pub category: WireErrorCategory,
- pub retryable: bool,
- pub recovery_action: WireRecoveryAction,
- pub message: String,
-}
-
-#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)]
-pub enum AppLifecycleDto {
- Opening,
- CompatibilityChecking,
- AcquiringOwnership,
- Migrating,
- Recovering,
- Ready,
- Degraded,
- Blocked,
- ShuttingDown,
- Closed,
- Fatal,
-}
-
-#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)]
-pub enum SessionStateDto {
- SignedOut,
- Activating,
- Active,
- SigningOut,
- Failed,
-}
-
-#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)]
-pub enum RelayConnectionStateDto {
- Disconnected,
- Connecting,
- Connected,
- Degraded,
- Error,
-}
-
-#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)]
-pub enum ProfileLoadStateDto {
- Empty,
- Loading,
- Cached,
- Fresh,
- Error,
-}
-
-#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)]
-pub enum SignerKindDto {
- LocalSecret,
- WatchOnly,
- RemoteNip46,
-}
-
-#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)]
-pub enum KeyAvailabilityDto {
- Available,
- CredentialMissing,
- StoreUnavailable,
- NotRequired,
-}
-
-#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)]
-pub struct ProfileDto {
- pub name: Option<String>,
- pub display_name: Option<String>,
- pub nip05: Option<String>,
- pub about: Option<String>,
- pub picture: Option<String>,
-}
-
-#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)]
-pub struct AccountDto {
- pub public_key_hex: String,
- pub npub: String,
- pub display_label: String,
- pub signer_kind: SignerKindDto,
- pub key_availability: KeyAvailabilityDto,
- pub created_at_seconds: i64,
- pub last_used_at_seconds: Option<i64>,
-}
-
-#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)]
-pub struct ActiveAccountDto {
- pub account: AccountDto,
- pub relay_state: RelayConnectionStateDto,
- pub profile_state: ProfileLoadStateDto,
- pub profile: Option<ProfileDto>,
-}
-
-#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)]
-pub struct AppSnapshotDto {
- pub revision: u64,
- pub lifecycle: AppLifecycleDto,
- pub lifecycle_error: Option<SafeErrorDto>,
- pub configured_relays: Vec<String>,
- pub accounts: Vec<AccountDto>,
- pub selected_public_key_hex: Option<String>,
- pub session: SessionStateDto,
- pub session_subject_public_key_hex: Option<String>,
- pub session_error: Option<SafeErrorDto>,
- pub active_account: Option<ActiveAccountDto>,
- pub recoverable_problem: Option<SafeErrorDto>,
-}
-
-impl From<&AppSnapshot> for AppSnapshotDto {
- fn from(snapshot: &AppSnapshot) -> Self {
- let (lifecycle, lifecycle_error) = match snapshot.lifecycle() {
- AppLifecycle::Booting => (AppLifecycleDto::Opening, None),
- AppLifecycle::Ready => (AppLifecycleDto::Ready, None),
- AppLifecycle::Fatal(error) => (AppLifecycleDto::Fatal, Some(error.into())),
- };
- let (session, session_subject_public_key_hex, session_error) = match snapshot.session() {
- SessionState::SignedOut => (SessionStateDto::SignedOut, None, None),
- SessionState::Activating(public_key) => {
- (SessionStateDto::Activating, Some(public_key.to_hex()), None)
- }
- SessionState::Active => (SessionStateDto::Active, None, None),
- SessionState::SigningOut => (SessionStateDto::SigningOut, None, None),
- SessionState::Failed(error) => (SessionStateDto::Failed, None, Some(error.into())),
- };
- Self {
- revision: snapshot.revision().value(),
- lifecycle,
- lifecycle_error,
- configured_relays: snapshot
- .relay_configuration()
- .relays()
- .iter()
- .map(|relay| relay.as_str().to_owned())
- .collect(),
- accounts: snapshot.accounts().iter().map(AccountDto::from).collect(),
- selected_public_key_hex: snapshot
- .selected_account()
- .map(radroots_studio_domain::PublicKey::to_hex),
- session,
- session_subject_public_key_hex,
- session_error,
- active_account: snapshot.active_account().map(ActiveAccountDto::from),
- recoverable_problem: snapshot.recoverable_problem().map(SafeErrorDto::from),
- }
- }
-}
-
-impl AppSnapshotDto {
- pub(crate) fn from_runtime(snapshot: &AppSnapshot, runtime: RuntimeLifecycle) -> Self {
- let mut dto = Self::from(snapshot);
- let (lifecycle, problem) = match runtime {
- RuntimeLifecycle::Opening => (AppLifecycleDto::Opening, None),
- RuntimeLifecycle::CompatibilityChecking => {
- (AppLifecycleDto::CompatibilityChecking, None)
- }
- RuntimeLifecycle::AcquiringOwnership => (AppLifecycleDto::AcquiringOwnership, None),
- RuntimeLifecycle::Migrating => (AppLifecycleDto::Migrating, None),
- RuntimeLifecycle::Recovering => (AppLifecycleDto::Recovering, None),
- RuntimeLifecycle::Ready => (AppLifecycleDto::Ready, None),
- RuntimeLifecycle::Degraded(error) => {
- (AppLifecycleDto::Degraded, Some(SafeErrorDto::from(error)))
- }
- RuntimeLifecycle::Blocked(error) => {
- (AppLifecycleDto::Blocked, Some(SafeErrorDto::from(error)))
- }
- RuntimeLifecycle::ShuttingDown => (AppLifecycleDto::ShuttingDown, None),
- RuntimeLifecycle::Closed => (AppLifecycleDto::Closed, None),
- RuntimeLifecycle::Fatal(error) => {
- (AppLifecycleDto::Fatal, Some(SafeErrorDto::from(error)))
- }
- };
- dto.lifecycle = lifecycle;
- dto.lifecycle_error = problem;
- dto
- }
-}
-
-impl From<&AccountSummary> for AccountDto {
- fn from(account: &AccountSummary) -> Self {
- Self {
- public_key_hex: account.public_key().to_hex(),
- npub: account.npub().as_str().to_owned(),
- display_label: account.display_label(),
- signer_kind: SignerKindDto::LocalSecret,
- key_availability: account.signer().availability().into(),
- created_at_seconds: account.created_at().timestamp().as_seconds(),
- last_used_at_seconds: account
- .last_used_at()
- .map(radroots_studio_domain::UnixTimestamp::as_seconds),
- }
- }
-}
-
-impl From<&ActiveAccountSnapshot> for ActiveAccountDto {
- fn from(active: &ActiveAccountSnapshot) -> Self {
- Self {
- account: active.account().into(),
- relay_state: active.relay_state().into(),
- profile_state: active.profile_state().into(),
- profile: active.profile().map(ProfileDto::from),
- }
- }
-}
-
-impl From<&ProfileMetadata> for ProfileDto {
- fn from(profile: &ProfileMetadata) -> Self {
- Self {
- name: profile.name().map(str::to_owned),
- display_name: profile.display_name().map(str::to_owned),
- nip05: profile.nip05().map(str::to_owned),
- about: profile.about().map(str::to_owned),
- picture: profile.picture().map(str::to_owned),
- }
- }
-}
-
-impl From<SafeError> for SafeErrorDto {
- fn from(error: SafeError) -> Self {
- let (category, retryable, recovery_action) = error_policy(error.code());
- Self {
- code: error.code().into(),
- category,
- retryable,
- recovery_action,
- message: error.message().as_str().to_owned(),
- }
- }
-}
-
-impl From<SafeErrorCode> for WireErrorCode {
- fn from(code: SafeErrorCode) -> Self {
- match code {
- SafeErrorCode::InvalidPublicKey => Self::InvalidPublicKey,
- SafeErrorCode::InvalidSecretKey => Self::InvalidSecretKey,
- SafeErrorCode::InvalidAccountMetadata => Self::InvalidAccountMetadata,
- SafeErrorCode::InvalidProfileMetadata => Self::InvalidProfileMetadata,
- SafeErrorCode::InvalidApplicationState => Self::InvalidApplicationState,
- SafeErrorCode::AccountAlreadyExists => Self::AccountAlreadyExists,
- SafeErrorCode::AccountNotFound => Self::AccountNotFound,
- SafeErrorCode::KeyringUnavailable => Self::KeyringUnavailable,
- SafeErrorCode::CredentialMissing => Self::CredentialMissing,
- SafeErrorCode::StorageUnavailable => Self::StorageUnavailable,
- SafeErrorCode::StorageCorrupt => Self::StorageCorrupt,
- SafeErrorCode::PendingOperationRecoveryRequired => {
- Self::PendingOperationRecoveryRequired
- }
- SafeErrorCode::InvalidRelayConfiguration => Self::InvalidRelayConfiguration,
- SafeErrorCode::RelayConnectionFailed => Self::RelayConnectionFailed,
- SafeErrorCode::ProfileRefreshFailed => Self::ProfileRefreshFailed,
- SafeErrorCode::ObserverRegistrationFailed => Self::ObserverRegistrationFailed,
- SafeErrorCode::NativeLibraryLoadFailed => Self::NativeLibraryLoadFailed,
- _ => Self::Internal,
- }
- }
-}
-
-pub(crate) const fn error_policy(
- code: SafeErrorCode,
-) -> (WireErrorCategory, bool, WireRecoveryAction) {
- match code {
- SafeErrorCode::InvalidPublicKey
- | SafeErrorCode::InvalidSecretKey
- | SafeErrorCode::InvalidAccountMetadata
- | SafeErrorCode::InvalidProfileMetadata => {
- (WireErrorCategory::Input, false, WireRecoveryAction::None)
- }
- SafeErrorCode::AccountAlreadyExists | SafeErrorCode::AccountNotFound => {
- (WireErrorCategory::Conflict, false, WireRecoveryAction::None)
- }
- SafeErrorCode::KeyringUnavailable => (
- WireErrorCategory::Credential,
- true,
- WireRecoveryAction::Retry,
- ),
- SafeErrorCode::CredentialMissing => (
- WireErrorCategory::Credential,
- false,
- WireRecoveryAction::RepairCredential,
- ),
- SafeErrorCode::StorageUnavailable => (
- WireErrorCategory::Storage,
- true,
- WireRecoveryAction::RestartApplication,
- ),
- SafeErrorCode::StorageCorrupt | SafeErrorCode::PendingOperationRecoveryRequired => (
- WireErrorCategory::Storage,
- false,
- WireRecoveryAction::RestartApplication,
- ),
- SafeErrorCode::InvalidRelayConfiguration => (
- WireErrorCategory::Network,
- false,
- WireRecoveryAction::CheckConfiguration,
- ),
- SafeErrorCode::RelayConnectionFailed | SafeErrorCode::ProfileRefreshFailed => {
- (WireErrorCategory::Network, true, WireRecoveryAction::Retry)
- }
- SafeErrorCode::InvalidApplicationState | SafeErrorCode::ObserverRegistrationFailed => (
- WireErrorCategory::Lifecycle,
- true,
- WireRecoveryAction::Retry,
- ),
- SafeErrorCode::NativeLibraryLoadFailed => (
- WireErrorCategory::Internal,
- false,
- WireRecoveryAction::RestartApplication,
- ),
- _ => (WireErrorCategory::Internal, false, WireRecoveryAction::None),
- }
-}
-
-impl From<BindingAvailability> for KeyAvailabilityDto {
- fn from(value: BindingAvailability) -> Self {
- match value {
- BindingAvailability::Available => Self::Available,
- BindingAvailability::CredentialMissing => Self::CredentialMissing,
- BindingAvailability::StoreUnavailable => Self::StoreUnavailable,
- }
- }
-}
-
-impl From<RelayConnectionState> for RelayConnectionStateDto {
- fn from(value: RelayConnectionState) -> Self {
- match value {
- RelayConnectionState::Disconnected => Self::Disconnected,
- RelayConnectionState::Connecting => Self::Connecting,
- RelayConnectionState::Connected => Self::Connected,
- RelayConnectionState::Degraded => Self::Degraded,
- RelayConnectionState::Error(_) => Self::Error,
- }
- }
-}
-
-impl From<ProfileLoadState> for ProfileLoadStateDto {
- fn from(value: ProfileLoadState) -> Self {
- match value {
- ProfileLoadState::Empty => Self::Empty,
- ProfileLoadState::Loading => Self::Loading,
- ProfileLoadState::Cached => Self::Cached,
- ProfileLoadState::Fresh => Self::Fresh,
- ProfileLoadState::Error(_) => Self::Error,
- }
- }
-}
-
-#[cfg(test)]
-mod tests {
- use radroots_studio_application::{AppCore, RelayConfiguration};
-
- use super::AppSnapshotDto;
-
- #[test]
- fn snapshot_dto_is_revisioned_public_and_secret_free() {
- let core = AppCore::in_memory(RelayConfiguration::default());
- let snapshot = core.bootstrap().expect("bootstrap");
- let dto = AppSnapshotDto::from(&snapshot);
- let debug = format!("{dto:?}");
-
- assert_eq!(dto.revision, 1);
- assert!(dto.accounts.is_empty());
- assert!(!debug.contains("nsec"));
- assert!(!debug.contains("secret_key"));
- assert!(!debug.contains("server_url"));
- }
-}
diff --git a/core/crates/ffi/src/lib.rs b/core/crates/ffi/src/lib.rs
@@ -1,34 +0,0 @@
-#![doc = "Radroots Studio `UniFFI` boundary."]
-
-mod commands;
-mod dto;
-mod observer;
-
-pub use commands::{
- AccountCommandReceiptDto, GeneratedRecoveryRequest, RemovalRequest, RequestContextDto,
- StudioAppCore, StudioError,
-};
-pub use dto::{
- AccountDto, ActiveAccountDto, AppLifecycleDto, AppSnapshotDto, KeyAvailabilityDto, ProfileDto,
- ProfileLoadStateDto, RelayConnectionStateDto, SafeErrorDto, SessionStateDto, SignerKindDto,
- WireErrorCategory, WireErrorCode, WireRecoveryAction,
-};
-pub use observer::{
- ObserverSubscription, ShutdownReceiptDto, SnapshotChangeDto, StudioChangeObserver,
-};
-
-uniffi::setup_scaffolding!();
-
-#[uniffi::export]
-#[must_use]
-pub fn native_runtime_version() -> String {
- env!("CARGO_PKG_VERSION").to_owned()
-}
-
-#[cfg(test)]
-mod tests {
- #[test]
- fn native_runtime_reports_the_crate_version() {
- assert_eq!(super::native_runtime_version(), "0.1.0-alpha");
- }
-}
diff --git a/core/crates/ffi/src/observer.rs b/core/crates/ffi/src/observer.rs
@@ -1,363 +0,0 @@
-use std::num::NonZeroUsize;
-use std::sync::atomic::Ordering;
-use std::sync::{Arc, Mutex, Weak};
-
-use radroots_studio_application::ChangeSubscriptionId;
-
-use crate::commands::RuntimeCore;
-use crate::{AppSnapshotDto, StudioAppCore, StudioError};
-
-const OBSERVER_CHANGE_CAPACITY: NonZeroUsize = match NonZeroUsize::new(64) {
- Some(capacity) => capacity,
- None => unreachable!(),
-};
-
-#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)]
-pub struct SnapshotChangeDto {
- pub snapshot: AppSnapshotDto,
- pub previous_revision: Option<u64>,
-}
-
-#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Record)]
-pub struct ShutdownReceiptDto {
- pub final_revision: u64,
- pub closed: bool,
-}
-
-#[uniffi::export(callback_interface)]
-pub trait StudioChangeObserver: Send + Sync {
- fn on_change(&self, change: SnapshotChangeDto);
-}
-
-#[derive(uniffi::Object)]
-pub struct ObserverSubscription {
- core: Weak<RuntimeCore>,
- id: Mutex<Option<ChangeSubscriptionId>>,
-}
-
-#[uniffi::export]
-impl ObserverSubscription {
- pub fn unsubscribe(&self) {
- let id = self
- .id
- .lock()
- .unwrap_or_else(std::sync::PoisonError::into_inner)
- .take();
- let (Some(core), Some(id)) = (self.core.upgrade(), id) else {
- return;
- };
- if let Some(task) = core
- .observers
- .lock()
- .unwrap_or_else(std::sync::PoisonError::into_inner)
- .remove(&id)
- {
- task.abort();
- }
- let actor = core.actor.clone();
- crate::commands::runtime().spawn(async move {
- let _ = actor.unsubscribe_changes(id).await;
- });
- }
-}
-
-impl Drop for ObserverSubscription {
- fn drop(&mut self) {
- self.unsubscribe();
- }
-}
-
-#[uniffi::export]
-impl StudioAppCore {
- /// Subscribes to ordered revision changes including predecessor metadata.
- ///
- /// # Errors
- ///
- /// Returns a safe observer or lifecycle error.
- pub async fn subscribe_changes_v2(
- &self,
- observer: Box<dyn StudioChangeObserver>,
- ) -> Result<Arc<ObserverSubscription>, StudioError> {
- if self.inner.closed.load(Ordering::Acquire) {
- return Err(closed_error());
- }
- let mut subscription = self
- .inner
- .actor
- .subscribe_changes(OBSERVER_CHANGE_CAPACITY)
- .await
- .map_err(StudioError::from)?;
- let id = subscription.id();
- let observer: Arc<dyn StudioChangeObserver> = Arc::from(observer);
- let runtime_core = Arc::clone(&self.inner);
- let task = crate::commands::runtime().spawn(async move {
- while let Some(change) = subscription.receive().await {
- observer.on_change(SnapshotChangeDto {
- snapshot: AppSnapshotDto::from_runtime(
- change.snapshot(),
- runtime_core.effective_lifecycle(),
- ),
- previous_revision: change
- .previous_revision()
- .map(radroots_studio_application::SnapshotRevision::value),
- });
- }
- });
- self.inner
- .observers
- .lock()
- .unwrap_or_else(std::sync::PoisonError::into_inner)
- .insert(id, task);
- Ok(Arc::new(ObserverSubscription {
- core: Arc::downgrade(&self.inner),
- id: Mutex::new(Some(id)),
- }))
- }
-
- /// Stops observer delivery and waits for actor-owned shutdown.
- ///
- /// # Errors
- ///
- /// Returns a safe closed or timeout error when shutdown cannot complete.
- pub async fn shutdown_v2(&self) -> Result<ShutdownReceiptDto, StudioError> {
- if self.inner.closed.swap(true, Ordering::AcqRel) {
- return Err(closed_error());
- }
- let handles = std::mem::take(
- &mut *self
- .inner
- .observers
- .lock()
- .unwrap_or_else(std::sync::PoisonError::into_inner),
- );
- for (_, task) in handles {
- task.abort();
- }
- self.inner.actor.close().await.map_err(StudioError::from)?;
- Ok(ShutdownReceiptDto {
- final_revision: self.inner.actor.snapshot().revision().value(),
- closed: true,
- })
- }
-}
-
-fn closed_error() -> StudioError {
- StudioError::Failure {
- code: crate::WireErrorCode::InvalidApplicationState,
- category: crate::WireErrorCategory::Lifecycle,
- retryable: false,
- recovery_action: crate::WireRecoveryAction::None,
- correlation_id: None,
- safe_message: "The application runtime is closed.".to_owned(),
- }
-}
-
-#[cfg(test)]
-mod tests {
- use std::num::NonZeroUsize;
- use std::sync::{Arc, Mutex};
- use std::time::Duration;
-
- use nostr::{EventBuilder, Keys, Metadata};
- use nostr_relay_builder::MockRelay;
- use nostr_sdk::Client;
- use radroots_studio_application::{InMemorySecretStore, RelayConfiguration, SdkNostrClient};
- use radroots_studio_domain::RelayUrl;
- use radroots_studio_storage::RuntimeActorHandle;
-
- use crate::commands::{ACTOR_MAILBOX_CAPACITY, RuntimeCore, SystemClock, runtime};
- use crate::{
- AppSnapshotDto, ProfileLoadStateDto, SnapshotChangeDto, StudioAppCore, StudioChangeObserver,
- };
-
- const SECRET_HEX: &str = "7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7";
-
- #[derive(Default)]
- struct RecordingObserver {
- snapshots: Mutex<Vec<AppSnapshotDto>>,
- core: Mutex<Option<Arc<StudioAppCore>>>,
- }
-
- impl StudioChangeObserver for RecordingObserver {
- fn on_change(&self, change: SnapshotChangeDto) {
- let snapshot = change.snapshot;
- if let Some(core) = self.core.lock().expect("core").as_ref() {
- assert_eq!(core.snapshot().revision, snapshot.revision);
- }
- self.snapshots.lock().expect("snapshots").push(snapshot);
- }
- }
-
- fn core() -> Arc<StudioAppCore> {
- core_with_relays(RelayConfiguration::default())
- }
-
- fn core_with_relays(relays: RelayConfiguration) -> Arc<StudioAppCore> {
- let actor = RuntimeActorHandle::in_memory(
- relays,
- Arc::new(InMemorySecretStore::default()),
- Arc::new(SystemClock),
- Arc::new(SdkNostrClient::new(std::time::Duration::from_millis(10))),
- NonZeroUsize::new(ACTOR_MAILBOX_CAPACITY).expect("capacity"),
- runtime().handle(),
- )
- .expect("actor");
- Arc::new(StudioAppCore {
- inner: Arc::new(RuntimeCore {
- actor,
- observers: Mutex::new(std::collections::BTreeMap::new()),
- closed: std::sync::atomic::AtomicBool::new(false),
- startup_relay_problem: None,
- }),
- })
- }
-
- #[test]
- fn callbacks_allow_reentry_and_stop_after_subscription_close() {
- runtime().block_on(async {
- let core = core();
- let observer = Arc::new(RecordingObserver::default());
- *observer.core.lock().expect("core") = Some(Arc::clone(&core));
- let subscription = core
- .subscribe_changes_v2(Box::new(ArcObserver(observer.clone())))
- .await
- .expect("subscribe");
-
- wait_for_snapshot_count(&observer, 1).await;
- core.inner
- .actor
- .bootstrap()
- .await
- .expect("idempotent bootstrap");
- assert_eq!(observer.snapshots.lock().expect("snapshots").len(), 1);
- subscription.unsubscribe();
- core.inner.actor.sign_out().await.expect("sign out");
- assert_eq!(observer.snapshots.lock().expect("snapshots").len(), 1);
- });
- }
-
- #[test]
- fn core_close_deregisters_all_observers_and_rejects_new_subscriptions() {
- let core = core();
- let observer = Arc::new(RecordingObserver::default());
- let _subscription = runtime()
- .block_on(core.subscribe_changes_v2(Box::new(ArcObserver(observer.clone()))))
- .expect("subscribe");
-
- runtime().block_on(core.shutdown_v2()).expect("shutdown");
-
- assert!(
- runtime()
- .block_on(core.subscribe_changes_v2(Box::new(ArcObserver(observer))))
- .is_err()
- );
- assert!(core.inner.observers.lock().expect("observers").is_empty());
- }
-
- #[tokio::test]
- async fn ffi_callback_receives_async_profile_refresh_and_stops_after_unsubscribe() {
- let local_relay = MockRelay::run().await.expect("local relay");
- let relay_url = local_relay.url().await;
- let publisher = Client::new(Keys::parse(SECRET_HEX).expect("known key"));
- publisher
- .add_relay(relay_url.clone())
- .await
- .expect("publisher relay");
- publisher.connect().await;
- publisher.wait_for_connection(Duration::from_secs(2)).await;
- publisher
- .send_event_builder(EventBuilder::metadata(
- &Metadata::new().display_name("FFI Profile"),
- ))
- .await
- .expect("publish profile");
-
- let core = core_with_relays(RelayConfiguration::new(vec![
- RelayUrl::parse(relay_url.as_str()).expect("relay URL"),
- ]));
- core.bootstrap().await.expect("bootstrap");
- let observer = Arc::new(RecordingObserver::default());
- *observer.core.lock().expect("core") = Some(Arc::clone(&core));
- let subscription = core
- .subscribe_changes_v2(Box::new(ArcObserver(observer.clone())))
- .await
- .expect("subscribe");
- let imported = core
- .import_account_v2(
- crate::RequestContextDto {
- request_id: "observer-import".to_owned(),
- expected_revision: core.snapshot().revision,
- deadline_millis: 5_000,
- },
- SECRET_HEX.as_bytes().to_vec(),
- )
- .await
- .expect("import")
- .snapshot;
- let public_key = imported.selected_public_key_hex.expect("selection");
- core.activate_account(public_key).await.expect("activate");
- core.refresh_active_profile().await.expect("refresh");
-
- wait_for_fresh_profile(&observer).await;
- let snapshots = observer.snapshots.lock().expect("snapshots").clone();
- assert!(snapshots.iter().any(|snapshot| {
- snapshot.active_account.as_ref().is_some_and(|active| {
- active.profile_state == ProfileLoadStateDto::Fresh
- && active
- .profile
- .as_ref()
- .and_then(|profile| profile.display_name.as_deref())
- == Some("FFI Profile")
- })
- }));
- subscription.unsubscribe();
- let count = observer.snapshots.lock().expect("snapshots").len();
- core.sign_out().await.expect("sign out");
- assert_eq!(observer.snapshots.lock().expect("snapshots").len(), count);
-
- core.shutdown_v2().await.expect("shutdown");
- publisher.shutdown().await;
- local_relay.shutdown();
- }
-
- struct ArcObserver(Arc<RecordingObserver>);
-
- impl StudioChangeObserver for ArcObserver {
- fn on_change(&self, change: SnapshotChangeDto) {
- self.0.on_change(change);
- }
- }
-
- async fn wait_for_snapshot_count(observer: &RecordingObserver, minimum: usize) {
- tokio::time::timeout(Duration::from_secs(1), async {
- while observer.snapshots.lock().expect("snapshots").len() < minimum {
- tokio::task::yield_now().await;
- }
- })
- .await
- .expect("snapshot delivery");
- }
-
- async fn wait_for_fresh_profile(observer: &RecordingObserver) {
- tokio::time::timeout(Duration::from_secs(1), async {
- loop {
- let fresh = observer
- .snapshots
- .lock()
- .expect("snapshots")
- .iter()
- .any(|snapshot| {
- snapshot.active_account.as_ref().is_some_and(|active| {
- active.profile_state == ProfileLoadStateDto::Fresh
- })
- });
- if fresh {
- break;
- }
- tokio::task::yield_now().await;
- }
- })
- .await
- .expect("fresh profile delivery");
- }
-}
diff --git a/core/crates/ffi/uniffi.toml b/core/crates/ffi/uniffi.toml
@@ -1,3 +0,0 @@
-[crates.radroots_studio_ffi.bindings.kotlin]
-package_name = "org.radroots.studio.ffi"
-cdylib_name = "radroots_studio_ffi"
diff --git a/core/crates/nostr/Cargo.toml b/core/crates/nostr/Cargo.toml
@@ -1,14 +0,0 @@
-[package]
-name = "radroots-studio-nostr"
-version.workspace = true
-edition.workspace = true
-rust-version.workspace = true
-license.workspace = true
-repository.workspace = true
-
-[dependencies]
-nostr.workspace = true
-radroots-studio-domain = { path = "../domain" }
-
-[lints]
-workspace = true
diff --git a/core/crates/nostr/src/keys.rs b/core/crates/nostr/src/keys.rs
@@ -1,152 +0,0 @@
-use nostr::{Keys, ToBech32};
-use radroots_studio_domain::{
- Npub, Nsec, PublicKey, SafeError, SafeErrorCode, SafeMessage, SecretKeyInput,
-};
-
-pub struct GeneratedKeyMaterial {
- public_key: PublicKey,
- npub: Npub,
- secret: SecretKeyInput,
- nsec: Nsec,
-}
-
-impl GeneratedKeyMaterial {
- #[must_use]
- pub fn into_parts(self) -> (PublicKey, Npub, SecretKeyInput, Nsec) {
- (self.public_key, self.npub, self.secret, self.nsec)
- }
-}
-
-pub struct ImportedKeyMaterial {
- public_key: PublicKey,
- npub: Npub,
- secret: SecretKeyInput,
-}
-
-impl ImportedKeyMaterial {
- #[must_use]
- pub fn into_parts(self) -> (PublicKey, Npub, SecretKeyInput) {
- (self.public_key, self.npub, self.secret)
- }
-}
-
-/// Generates one cryptographically random local Nostr keypair.
-///
-/// # Errors
-///
-/// Returns a safe key error if an upstream encoding cannot be represented by
-/// the stricter Radroots domain boundary.
-pub fn generate_local_keypair() -> Result<GeneratedKeyMaterial, SafeError> {
- let keys = Keys::generate();
- let (public_key, npub, secret, nsec) = encode_keys(&keys)?;
- Ok(GeneratedKeyMaterial {
- public_key,
- npub,
- secret,
- nsec,
- })
-}
-
-/// Parses nsec or canonical secret hex and derives public Nostr identity.
-///
-/// # Errors
-///
-/// Returns a safe invalid-secret-key error for checksum, scalar, or encoding
-/// failures without exposing the rejected input.
-pub fn import_secret(input: SecretKeyInput) -> Result<ImportedKeyMaterial, SafeError> {
- let keys = input
- .with_exposed_secret(Keys::parse)
- .map_err(|_| invalid_secret_key())?;
- drop(input);
- let public_key = PublicKey::from_bytes(keys.public_key().to_bytes());
- let npub = keys
- .public_key()
- .to_bech32()
- .map_err(|_| invalid_public_key())
- .and_then(Npub::from_encoded)?;
- let secret = SecretKeyInput::parse(keys.secret_key().to_secret_hex())?;
- Ok(ImportedKeyMaterial {
- public_key,
- npub,
- secret,
- })
-}
-
-fn encode_keys(keys: &Keys) -> Result<(PublicKey, Npub, SecretKeyInput, Nsec), SafeError> {
- let public_key = PublicKey::from_bytes(keys.public_key().to_bytes());
- let npub = keys
- .public_key()
- .to_bech32()
- .map_err(|_| invalid_public_key())
- .and_then(Npub::from_encoded)?;
- let secret = SecretKeyInput::parse(keys.secret_key().to_secret_hex())?;
- let nsec = keys
- .secret_key()
- .to_bech32()
- .map_err(|_| invalid_secret_key())
- .and_then(Nsec::from_encoded)?;
- Ok((public_key, npub, secret, nsec))
-}
-
-const fn invalid_secret_key() -> SafeError {
- SafeError::new(
- SafeErrorCode::InvalidSecretKey,
- SafeMessage::new("The Nostr secret key is invalid."),
- )
-}
-
-const fn invalid_public_key() -> SafeError {
- SafeError::new(
- SafeErrorCode::InvalidPublicKey,
- SafeMessage::new("The Nostr public key is invalid."),
- )
-}
-
-#[cfg(test)]
-mod tests {
- use radroots_studio_domain::{SafeErrorCode, SecretKeyInput};
-
- use super::{generate_local_keypair, import_secret};
-
- const SECRET_HEX: &str = "7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7";
- const NSEC: &str = "nsec1vl029mgpspedva04g90vltkh6fvh240zqtv9k0t9af8935ke9laqsnlfe5";
- const NSEC_PUBLIC_HEX: &str =
- "7e7e9c42a91bfef19fa929e5fda1b72e0ebc1a4c1141673e2794234d86addf4e";
- const HEX_PUBLIC_HEX: &str = "0cfda0afa91cc2fbbd6050c285802fe95c7a1755e0f68323999e13760501dc40";
-
- #[test]
- fn keys_generate_valid_redacted_material() {
- let generated = generate_local_keypair().expect("generated");
- let (public_key, npub, secret, nsec) = generated.into_parts();
- assert_eq!(public_key.to_hex().len(), 64);
- assert!(npub.as_str().starts_with("npub1"));
- assert_eq!(secret.with_exposed_secret(str::len), 64);
- assert_eq!(nsec.with_exposed_secret(str::len), 63);
- assert_eq!(secret.with_exposed_secret(str::len), 64);
- assert_eq!(nsec.with_exposed_secret(str::len), 63);
- }
-
- #[test]
- fn keys_import_known_nsec_and_hex_vectors() {
- let from_nsec = import_secret(SecretKeyInput::parse(NSEC.to_owned()).expect("nsec"))
- .expect("import nsec");
- let from_hex = import_secret(SecretKeyInput::parse(SECRET_HEX.to_owned()).expect("hex"))
- .expect("import hex");
- let (nsec_public, nsec_npub, _) = from_nsec.into_parts();
- let (hex_public, hex_npub, _) = from_hex.into_parts();
- assert_eq!(nsec_public.to_hex(), NSEC_PUBLIC_HEX);
- assert_eq!(hex_public.to_hex(), HEX_PUBLIC_HEX);
- assert!(nsec_npub.as_str().starts_with("npub1"));
- assert!(hex_npub.as_str().starts_with("npub1"));
- }
-
- #[test]
- fn keys_reject_structurally_plausible_nsec_with_invalid_checksum() {
- let input = SecretKeyInput::parse(
- "nsec1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq".to_owned(),
- )
- .expect("domain shape");
- let error = import_secret(input).err().expect("invalid checksum");
- assert_eq!(error.code(), SafeErrorCode::InvalidSecretKey);
- }
-}
diff --git a/core/crates/nostr/src/lib.rs b/core/crates/nostr/src/lib.rs
@@ -1,7 +0,0 @@
-#![doc = "Radroots Studio Nostr protocol adapters."]
-
-pub mod keys;
-pub mod profile;
-
-pub use keys::{GeneratedKeyMaterial, ImportedKeyMaterial, generate_local_keypair, import_secret};
-pub use profile::parse_verified_kind0;
diff --git a/core/crates/nostr/src/profile.rs b/core/crates/nostr/src/profile.rs
@@ -1,165 +0,0 @@
-use nostr::{Event, JsonUtil, Kind, Metadata};
-use radroots_studio_domain::{
- EventId, Kind0ProfileCandidate, ProfileMetadata, PublicKey, SafeError, SafeErrorCode,
- SafeMessage, UnixTimestamp,
-};
-
-const MAX_EVENT_JSON_BYTES: usize = 64 * 1_024;
-const MAX_PROFILE_CONTENT_BYTES: usize = 16 * 1_024;
-
-/// Verifies and converts one serialized Nostr kind-0 event.
-///
-/// # Errors
-///
-/// Returns a safe profile-refresh error when the event is oversized,
-/// malformed, invalidly signed, authored by another key, or not kind 0.
-pub fn parse_verified_kind0(
- event_json: &str,
- expected_author: PublicKey,
-) -> Result<Kind0ProfileCandidate, SafeError> {
- if event_json.len() > MAX_EVENT_JSON_BYTES {
- return Err(invalid_event());
- }
-
- let event = Event::from_json(event_json).map_err(|_| invalid_event())?;
- event.verify().map_err(|_| invalid_event())?;
- if event.kind != Kind::Metadata
- || event.pubkey.to_bytes() != *expected_author.as_bytes()
- || event.content.len() > MAX_PROFILE_CONTENT_BYTES
- {
- return Err(invalid_event());
- }
-
- let metadata = Metadata::from_json(&event.content).map_err(|_| invalid_metadata())?;
- let profile = ProfileMetadata::new(
- metadata.name,
- metadata.display_name,
- metadata.nip05,
- metadata.about,
- metadata.picture,
- )?;
- let created_at = i64::try_from(event.created_at.as_secs())
- .ok()
- .and_then(UnixTimestamp::from_seconds)
- .ok_or_else(invalid_event)?;
-
- Ok(Kind0ProfileCandidate::new(
- EventId::from_bytes(event.id.to_bytes()),
- expected_author,
- created_at,
- profile,
- ))
-}
-
-const fn invalid_event() -> SafeError {
- SafeError::new(
- SafeErrorCode::ProfileRefreshFailed,
- SafeMessage::new("The Nostr profile event is invalid."),
- )
-}
-
-const fn invalid_metadata() -> SafeError {
- SafeError::new(
- SafeErrorCode::InvalidProfileMetadata,
- SafeMessage::new("The Nostr profile metadata is invalid."),
- )
-}
-
-#[cfg(test)]
-mod tests {
- use nostr::{EventBuilder, JsonUtil, Keys, Metadata, Url};
- use radroots_studio_domain::{PublicKey, SafeErrorCode};
-
- use super::parse_verified_kind0;
-
- fn signed_profile() -> (Keys, String) {
- let keys = Keys::generate();
- let event = EventBuilder::metadata(
- &Metadata::new()
- .name(" farmer ")
- .display_name(" Farm Account ")
- .nip05("farmer@example.test")
- .about("Local grower")
- .picture(
- Url::parse("https://images.example.test/farmer.png")
- .expect("valid picture URL"),
- ),
- )
- .sign_with_keys(&keys)
- .expect("signed metadata event");
- (keys, event.as_json())
- }
-
- #[test]
- fn profile_event_verifies_signature_author_kind_and_metadata() {
- let (keys, json) = signed_profile();
- let expected_author = PublicKey::from_bytes(keys.public_key().to_bytes());
-
- let candidate = parse_verified_kind0(&json, expected_author).expect("verified profile");
-
- assert_eq!(candidate.author(), expected_author);
- assert_eq!(candidate.metadata().name(), Some("farmer"));
- assert_eq!(candidate.metadata().display_name(), Some("Farm Account"));
- assert_eq!(candidate.metadata().nip05(), Some("farmer@example.test"));
- assert_eq!(candidate.metadata().about(), Some("Local grower"));
- assert_eq!(
- candidate.metadata().picture(),
- Some("https://images.example.test/farmer.png")
- );
- }
-
- #[test]
- fn profile_event_rejects_tampering_wrong_author_kind_and_oversize_content() {
- let (keys, json) = signed_profile();
- let expected_author = PublicKey::from_bytes(keys.public_key().to_bytes());
- let wrong_author = PublicKey::from_bytes(Keys::generate().public_key().to_bytes());
- let tampered = json.replace("Local grower", "Remote grower");
- let note = EventBuilder::text_note("not metadata")
- .sign_with_keys(&keys)
- .expect("signed note")
- .as_json();
- let oversized = EventBuilder::metadata(&Metadata::new().about("x".repeat(16 * 1_024 + 1)))
- .sign_with_keys(&keys)
- .expect("signed oversized profile")
- .as_json();
-
- for rejected in [
- parse_verified_kind0(&tampered, expected_author),
- parse_verified_kind0(&json, wrong_author),
- parse_verified_kind0(¬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/core/crates/storage/Cargo.toml b/core/crates/storage/Cargo.toml
@@ -1,27 +0,0 @@
-[package]
-name = "radroots-studio-storage"
-version.workspace = true
-edition.workspace = true
-rust-version.workspace = true
-license.workspace = true
-repository.workspace = true
-
-[dependencies]
-radroots-studio-application = { path = "../application" }
-radroots-studio-domain = { path = "../domain" }
-fs2.workspace = true
-keyring.workspace = true
-zeroize.workspace = true
-refinery.workspace = true
-rusqlite.workspace = true
-tokio.workspace = true
-
-[dev-dependencies]
-nostr.workspace = true
-nostr-relay-builder.workspace = true
-nostr-sdk.workspace = true
-tempfile = "=3.23.0"
-tokio = { workspace = true, features = ["macros", "rt-multi-thread", "time"] }
-
-[lints]
-workspace = true
diff --git a/core/crates/storage/migrations/V1__initialize.sql b/core/crates/storage/migrations/V1__initialize.sql
@@ -1,6 +0,0 @@
-CREATE TABLE application_schema (
- singleton INTEGER PRIMARY KEY CHECK (singleton = 1),
- schema_version INTEGER NOT NULL CHECK (schema_version >= 1)
-);
-
-INSERT INTO application_schema (singleton, schema_version) VALUES (1, 1);
diff --git a/core/crates/storage/migrations/V2__accounts.sql b/core/crates/storage/migrations/V2__accounts.sql
@@ -1,28 +0,0 @@
-CREATE TABLE accounts (
- pubkey TEXT PRIMARY KEY NOT NULL CHECK (
- length(pubkey) = 64 AND pubkey = lower(pubkey)
- ),
- npub TEXT NOT NULL CHECK (length(npub) = 63),
- signer_kind TEXT NOT NULL CHECK (
- signer_kind IN ('local_secret', 'watch_only', 'remote_nip46')
- ),
- key_availability TEXT NOT NULL CHECK (
- key_availability IN (
- 'available',
- 'credential_missing',
- 'store_unavailable',
- 'not_required'
- )
- ),
- label TEXT,
- created_at INTEGER NOT NULL CHECK (created_at >= 0),
- last_used_at INTEGER CHECK (last_used_at >= 0)
-);
-
-CREATE TABLE app_state (
- singleton INTEGER PRIMARY KEY CHECK (singleton = 1),
- selected_pubkey TEXT REFERENCES accounts(pubkey) ON DELETE SET NULL
-);
-
-INSERT INTO app_state (singleton, selected_pubkey) VALUES (1, NULL);
-UPDATE application_schema SET schema_version = 2 WHERE singleton = 1;
diff --git a/core/crates/storage/migrations/V3__profile_cache.sql b/core/crates/storage/migrations/V3__profile_cache.sql
@@ -1,12 +0,0 @@
-CREATE TABLE profile_cache (
- subject_pubkey TEXT PRIMARY KEY NOT NULL REFERENCES accounts(pubkey) ON DELETE CASCADE,
- event_id TEXT NOT NULL,
- event_created_at INTEGER NOT NULL,
- name TEXT,
- display_name TEXT,
- nip05 TEXT,
- about TEXT,
- picture TEXT,
- refreshed_at INTEGER NOT NULL,
- refresh_status TEXT NOT NULL CHECK (refresh_status IN ('success', 'offline', 'invalid_data'))
-) STRICT;
diff --git a/core/crates/storage/migrations/V4__account_namespace.sql b/core/crates/storage/migrations/V4__account_namespace.sql
@@ -1,6 +0,0 @@
-CREATE TABLE account_namespace (
- owner_pubkey TEXT NOT NULL REFERENCES accounts(pubkey) ON DELETE CASCADE,
- preference_key TEXT NOT NULL CHECK (preference_key IN ('namespace_probe')),
- preference_value TEXT NOT NULL CHECK (length(preference_value) <= 4096),
- PRIMARY KEY (owner_pubkey, preference_key)
-) STRICT;
diff --git a/core/crates/storage/migrations/V5__operation_journal.sql b/core/crates/storage/migrations/V5__operation_journal.sql
@@ -1,8 +0,0 @@
-CREATE TABLE operation_journal (
- operation_id INTEGER PRIMARY KEY AUTOINCREMENT,
- operation_kind TEXT NOT NULL CHECK (operation_kind IN ('add', 'import', 'remove')),
- subject_pubkey TEXT NOT NULL,
- phase TEXT NOT NULL CHECK (phase IN ('intent_recorded', 'credential_written', 'metadata_committed', 'compensation_pending', 'credential_deleted', 'metadata_deleted')),
- updated_at INTEGER NOT NULL,
- diagnostic_code TEXT CHECK (diagnostic_code IN ('storage_unavailable', 'keyring_unavailable', 'credential_missing', 'compensation_failed'))
-) STRICT;
diff --git a/core/crates/storage/migrations/V6__normalized_runtime_schema.sql b/core/crates/storage/migrations/V6__normalized_runtime_schema.sql
@@ -1,100 +0,0 @@
-CREATE TABLE account_identities (
- public_key TEXT PRIMARY KEY NOT NULL CHECK (
- length(public_key) = 64 AND public_key = lower(public_key)
- ),
- npub TEXT NOT NULL UNIQUE CHECK (length(npub) = 63),
- label TEXT CHECK (label IS NULL OR length(label) BETWEEN 1 AND 80),
- created_at INTEGER NOT NULL CHECK (created_at >= 0),
- last_used_at INTEGER CHECK (last_used_at IS NULL OR last_used_at >= 0)
-) STRICT;
-
-CREATE TABLE local_signer_bindings (
- account_public_key TEXT NOT NULL,
- binding_public_key TEXT NOT NULL,
- binding_kind TEXT NOT NULL CHECK (binding_kind = 'local_secret'),
- availability TEXT NOT NULL CHECK (
- availability IN ('available', 'credential_missing', 'store_unavailable')
- ),
- PRIMARY KEY (account_public_key, binding_public_key),
- UNIQUE (account_public_key, binding_kind),
- FOREIGN KEY (account_public_key) REFERENCES account_identities(public_key) ON DELETE CASCADE,
- CHECK (account_public_key = binding_public_key)
-) STRICT;
-
-CREATE TABLE runtime_state (
- singleton INTEGER PRIMARY KEY CHECK (singleton = 1),
- selected_public_key TEXT REFERENCES account_identities(public_key) ON DELETE SET NULL,
- active_account_public_key TEXT,
- active_binding_public_key TEXT,
- session_generation INTEGER NOT NULL DEFAULT 0 CHECK (session_generation >= 0),
- FOREIGN KEY (active_account_public_key, active_binding_public_key)
- REFERENCES local_signer_bindings(account_public_key, binding_public_key)
- ON DELETE SET NULL,
- CHECK (
- (active_account_public_key IS NULL AND active_binding_public_key IS NULL)
- OR
- (active_account_public_key IS NOT NULL AND active_binding_public_key IS NOT NULL)
- )
-) STRICT;
-
-INSERT INTO runtime_state (singleton) VALUES (1);
-
-CREATE TABLE profile_cache_v6 (
- subject_public_key TEXT PRIMARY KEY NOT NULL
- REFERENCES account_identities(public_key) ON DELETE CASCADE,
- event_id TEXT NOT NULL CHECK (length(event_id) = 64 AND event_id = lower(event_id)),
- event_created_at INTEGER NOT NULL CHECK (event_created_at >= 0),
- name TEXT,
- display_name TEXT,
- nip05 TEXT,
- about TEXT,
- picture TEXT,
- refreshed_at INTEGER NOT NULL CHECK (refreshed_at >= 0),
- refresh_status TEXT NOT NULL CHECK (
- refresh_status IN ('success', 'offline', 'invalid_data')
- )
-) STRICT;
-
-CREATE TABLE durable_operations (
- request_id TEXT PRIMARY KEY NOT NULL CHECK (length(request_id) BETWEEN 1 AND 128),
- operation_kind TEXT NOT NULL CHECK (
- operation_kind IN ('create', 'import', 'repair', 'remove')
- ),
- account_public_key TEXT NOT NULL CHECK (
- length(account_public_key) = 64 AND account_public_key = lower(account_public_key)
- ),
- binding_public_key TEXT NOT NULL CHECK (binding_public_key = account_public_key),
- expected_revision INTEGER CHECK (expected_revision IS NULL OR expected_revision >= 0),
- phase TEXT NOT NULL CHECK (
- phase IN (
- 'intent_recorded',
- 'credential_written',
- 'metadata_committed',
- 'selection_committed',
- 'compensation_pending',
- 'credential_deleted',
- 'metadata_deleted',
- 'finalized'
- )
- ),
- terminal_outcome TEXT CHECK (
- terminal_outcome IS NULL OR terminal_outcome IN ('completed', 'cancelled', 'failed')
- ),
- prior_selected_public_key TEXT,
- updated_at INTEGER NOT NULL CHECK (updated_at >= 0),
- diagnostic_code TEXT CHECK (
- diagnostic_code IS NULL OR diagnostic_code IN (
- 'storage_unavailable',
- 'keyring_unavailable',
- 'credential_missing',
- 'compensation_failed',
- 'conflict',
- 'expired'
- )
- ),
- CHECK (
- (phase = 'finalized' AND terminal_outcome IS NOT NULL)
- OR
- (phase <> 'finalized' AND terminal_outcome IS NULL)
- )
-) STRICT;
diff --git a/core/crates/storage/migrations/V7__migrate_v5_runtime_data.sql b/core/crates/storage/migrations/V7__migrate_v5_runtime_data.sql
@@ -1,68 +0,0 @@
-INSERT INTO account_identities (
- public_key,
- npub,
- label,
- created_at,
- last_used_at
-)
-SELECT pubkey, npub, label, created_at, last_used_at
-FROM accounts;
-
-INSERT INTO local_signer_bindings (
- account_public_key,
- binding_public_key,
- binding_kind,
- availability
-)
-SELECT pubkey, pubkey, 'local_secret', key_availability
-FROM accounts;
-
-UPDATE runtime_state
-SET selected_public_key = (
- SELECT selected_pubkey FROM app_state WHERE singleton = 1
-)
-WHERE singleton = 1;
-
-INSERT INTO profile_cache_v6 (
- subject_public_key,
- event_id,
- event_created_at,
- name,
- display_name,
- nip05,
- about,
- picture,
- refreshed_at,
- refresh_status
-)
-SELECT
- subject_pubkey,
- event_id,
- event_created_at,
- name,
- display_name,
- nip05,
- about,
- picture,
- refreshed_at,
- refresh_status
-FROM profile_cache;
-
-INSERT INTO durable_operations (
- request_id,
- operation_kind,
- account_public_key,
- binding_public_key,
- phase,
- updated_at,
- diagnostic_code
-)
-SELECT
- 'legacy-v5-' || operation_id,
- CASE operation_kind WHEN 'add' THEN 'create' ELSE operation_kind END,
- subject_pubkey,
- subject_pubkey,
- phase,
- updated_at,
- diagnostic_code
-FROM operation_journal;
diff --git a/core/crates/storage/migrations/V8__normalized_account_preferences.sql b/core/crates/storage/migrations/V8__normalized_account_preferences.sql
@@ -1,10 +0,0 @@
-CREATE TABLE account_preferences (
- owner_public_key TEXT NOT NULL REFERENCES account_identities(public_key) ON DELETE CASCADE,
- preference_key TEXT NOT NULL CHECK (preference_key = 'namespace_probe'),
- preference_value TEXT NOT NULL CHECK (length(preference_value) <= 4096),
- PRIMARY KEY (owner_public_key, preference_key)
-) STRICT;
-
-INSERT INTO account_preferences (owner_public_key, preference_key, preference_value)
-SELECT owner_pubkey, preference_key, preference_value
-FROM account_namespace;
diff --git a/core/crates/storage/migrations/V9__durable_operation_receipts.sql b/core/crates/storage/migrations/V9__durable_operation_receipts.sql
@@ -1,11 +0,0 @@
-ALTER TABLE durable_operations ADD COLUMN prior_binding_availability TEXT CHECK (
- prior_binding_availability IS NULL OR prior_binding_availability IN (
- 'available',
- 'credential_missing',
- 'store_unavailable'
- )
-);
-
-ALTER TABLE durable_operations ADD COLUMN resulting_revision INTEGER CHECK (
- resulting_revision IS NULL OR resulting_revision >= 0
-);
diff --git a/core/crates/storage/src/account_namespace.rs b/core/crates/storage/src/account_namespace.rs
@@ -1,162 +0,0 @@
-use radroots_studio_application::{AccountNamespaceRepository, AccountPreferenceKey};
-use radroots_studio_domain::{PublicKey, SafeError, SafeErrorCode, SafeMessage};
-use rusqlite::{OptionalExtension, params};
-
-use crate::Database;
-
-const MAX_VALUE_CHARS: usize = 4_096;
-
-impl AccountNamespaceRepository for Database {
- fn get_value(
- &self,
- owner: PublicKey,
- key: AccountPreferenceKey,
- ) -> Result<Option<String>, SafeError> {
- self.connection()
- .query_row(
- "SELECT preference_value FROM account_preferences \
- WHERE owner_public_key = ?1 AND preference_key = ?2",
- params![owner.to_hex(), encode_key(key)],
- |row| row.get(0),
- )
- .optional()
- .map_err(|_| storage_error())
- }
-
- fn set_value(
- &self,
- owner: PublicKey,
- key: AccountPreferenceKey,
- value: &str,
- ) -> Result<(), SafeError> {
- if value.chars().count() > MAX_VALUE_CHARS || value.chars().any(char::is_control) {
- return Err(invalid_preference());
- }
- self.connection()
- .execute(
- "INSERT INTO account_preferences (owner_public_key, preference_key, preference_value) \
- VALUES (?1, ?2, ?3) ON CONFLICT(owner_public_key, preference_key) DO UPDATE SET \
- preference_value = excluded.preference_value",
- params![owner.to_hex(), encode_key(key), value],
- )
- .map(|_| ())
- .map_err(|_| storage_error())
- }
-
- fn clear_owner(&self, owner: PublicKey) -> Result<(), SafeError> {
- self.connection()
- .execute(
- "DELETE FROM account_preferences WHERE owner_public_key = ?1",
- [owner.to_hex()],
- )
- .map(|_| ())
- .map_err(|_| storage_error())
- }
-}
-
-const fn encode_key(key: AccountPreferenceKey) -> &'static str {
- match key {
- AccountPreferenceKey::NamespaceProbe => "namespace_probe",
- }
-}
-
-const fn storage_error() -> SafeError {
- SafeError::new(
- SafeErrorCode::StorageUnavailable,
- SafeMessage::new("The account preference is unavailable."),
- )
-}
-
-const fn invalid_preference() -> SafeError {
- SafeError::new(
- SafeErrorCode::InvalidAccountMetadata,
- SafeMessage::new("The account preference is invalid."),
- )
-}
-
-#[cfg(test)]
-mod tests {
- use radroots_studio_application::{
- AccountNamespaceRepository, AccountPreferenceKey, AccountRepository, AppStateRepository,
- };
- use radroots_studio_domain::{
- AccountCreatedAt, AccountIdentity, AccountSummary, BindingAvailability, LocalSignerBinding,
- PublicKey, UnixTimestamp,
- };
-
- use crate::Database;
-
- fn account(byte: u8) -> AccountSummary {
- let public_key = PublicKey::from_bytes([byte; 32]);
- AccountSummary::new(
- AccountIdentity::derive(public_key).expect("identity"),
- LocalSignerBinding::new(public_key, BindingAvailability::Available),
- None,
- AccountCreatedAt::new(UnixTimestamp::from_seconds(i64::from(byte)).expect("time")),
- None,
- )
- .expect("account")
- }
-
- #[test]
- fn namespace_partitions_same_typed_key_by_owner_and_selection() {
- let database = Database::in_memory().expect("database");
- let owner_a = PublicKey::from_bytes([1; 32]);
- let owner_b = PublicKey::from_bytes([2; 32]);
- database.insert_account(&account(1)).expect("account a");
- database.insert_account(&account(2)).expect("account b");
- database
- .set_value(owner_a, AccountPreferenceKey::NamespaceProbe, "A")
- .expect("set a");
- database
- .set_value(owner_b, AccountPreferenceKey::NamespaceProbe, "B")
- .expect("set b");
-
- database
- .save_selected_account(Some(owner_b))
- .expect("select b");
- let selected = database
- .load_selected_account()
- .expect("selection")
- .expect("selected owner");
- assert_eq!(
- database
- .get_value(selected, AccountPreferenceKey::NamespaceProbe)
- .expect("selected value"),
- Some("B".to_owned())
- );
- assert_eq!(
- database
- .get_value(owner_a, AccountPreferenceKey::NamespaceProbe)
- .expect("owner a value"),
- Some("A".to_owned())
- );
- }
-
- #[test]
- fn namespace_updates_and_cascades_with_owner_removal() {
- let database = Database::in_memory().expect("database");
- let owner = PublicKey::from_bytes([3; 32]);
- database.insert_account(&account(3)).expect("account");
- database
- .set_value(owner, AccountPreferenceKey::NamespaceProbe, "before")
- .expect("set");
- database
- .set_value(owner, AccountPreferenceKey::NamespaceProbe, "after")
- .expect("update");
- assert_eq!(
- database
- .get_value(owner, AccountPreferenceKey::NamespaceProbe)
- .expect("value"),
- Some("after".to_owned())
- );
-
- database.remove_account(owner).expect("remove");
- assert_eq!(
- database
- .get_value(owner, AccountPreferenceKey::NamespaceProbe)
- .expect("deleted value"),
- None
- );
- }
-}
diff --git a/core/crates/storage/src/accounts.rs b/core/crates/storage/src/accounts.rs
@@ -1,394 +0,0 @@
-use radroots_studio_application::{AccountRepository, AppStateRepository};
-use radroots_studio_domain::{
- AccountCreatedAt, AccountIdentity, AccountLabel, AccountSummary, BindingAvailability,
- LocalSignerBinding, PublicKey, SafeError, SafeErrorCode, SafeMessage, UnixTimestamp,
-};
-use rusqlite::{OptionalExtension, Row, params};
-
-use crate::Database;
-
-impl AccountRepository for Database {
- fn list_accounts(&self) -> Result<Vec<AccountSummary>, SafeError> {
- let connection = self.connection();
- let mut statement = connection
- .prepare(
- "SELECT identity.public_key, identity.npub, binding.binding_kind, \
- binding.availability, identity.label, identity.created_at, identity.last_used_at \
- FROM account_identities AS identity \
- JOIN local_signer_bindings AS binding \
- ON binding.account_public_key = identity.public_key \
- ORDER BY identity.created_at ASC, identity.public_key ASC",
- )
- .map_err(|_| storage_error())?;
- let rows = statement
- .query_map([], decode_account)
- .map_err(|_| storage_error())?;
- rows.map(|row| row.map_err(|_| corrupt_storage_error()))
- .collect()
- }
-
- fn find_account(&self, public_key: PublicKey) -> Result<Option<AccountSummary>, SafeError> {
- self.connection()
- .query_row(
- "SELECT identity.public_key, identity.npub, binding.binding_kind, \
- binding.availability, identity.label, identity.created_at, identity.last_used_at \
- FROM account_identities AS identity \
- JOIN local_signer_bindings AS binding \
- ON binding.account_public_key = identity.public_key \
- WHERE identity.public_key = ?1",
- [public_key.to_hex()],
- decode_account,
- )
- .optional()
- .map_err(|_| storage_error())
- }
-
- fn insert_account(&self, account: &AccountSummary) -> Result<(), SafeError> {
- let encoded = EncodedAccount::from(account);
- let mut connection = self.connection();
- let transaction = connection.transaction().map_err(|_| storage_error())?;
- let result = transaction.execute(
- "INSERT INTO account_identities (public_key, npub, label, created_at, last_used_at) \
- VALUES (?1, ?2, ?3, ?4, ?5)",
- params![
- encoded.public_key,
- encoded.npub,
- encoded.label,
- encoded.created_at,
- encoded.last_used_at
- ],
- );
- match result {
- Ok(1) => {}
- Err(error) if is_constraint_violation(&error) => return Err(account_exists()),
- Ok(_) | Err(_) => return Err(storage_error()),
- }
- if transaction
- .execute(
- "INSERT INTO local_signer_bindings (account_public_key, binding_public_key, \
- binding_kind, availability) VALUES (?1, ?1, ?2, ?3)",
- params![
- encoded.public_key,
- encoded.signer_kind,
- encoded.key_availability
- ],
- )
- .map_err(|_| storage_error())?
- != 1
- {
- return Err(storage_error());
- }
- transaction.commit().map_err(|_| storage_error())
- }
-
- fn update_account(&self, account: &AccountSummary) -> Result<(), SafeError> {
- let encoded = EncodedAccount::from(account);
- let mut connection = self.connection();
- let transaction = connection.transaction().map_err(|_| storage_error())?;
- let identity_rows = transaction
- .execute(
- "UPDATE account_identities SET npub = ?2, label = ?5, created_at = ?6, \
- last_used_at = ?7 WHERE public_key = ?1",
- params![
- encoded.public_key,
- encoded.npub,
- encoded.signer_kind,
- encoded.key_availability,
- encoded.label,
- encoded.created_at,
- encoded.last_used_at,
- ],
- )
- .map_err(|_| storage_error())?;
- if identity_rows == 0 {
- return Err(account_not_found());
- }
- if identity_rows != 1 {
- return Err(storage_error());
- }
- let binding_rows = transaction
- .execute(
- "UPDATE local_signer_bindings SET binding_kind = ?2, availability = ?3 \
- WHERE account_public_key = ?1 AND binding_public_key = ?1",
- params![
- encoded.public_key,
- encoded.signer_kind,
- encoded.key_availability
- ],
- )
- .map_err(|_| storage_error())?;
- if binding_rows != 1 {
- return Err(corrupt_storage_error());
- }
- transaction.commit().map_err(|_| storage_error())
- }
-
- fn remove_account(&self, public_key: PublicKey) -> Result<(), SafeError> {
- match self.connection().execute(
- "DELETE FROM account_identities WHERE public_key = ?1",
- [public_key.to_hex()],
- ) {
- Ok(1) => Ok(()),
- Ok(0) => Err(account_not_found()),
- Ok(_) | Err(_) => Err(storage_error()),
- }
- }
-}
-
-impl AppStateRepository for Database {
- fn load_selected_account(&self) -> Result<Option<PublicKey>, SafeError> {
- let value = self
- .connection()
- .query_row(
- "SELECT selected_public_key FROM runtime_state WHERE singleton = 1",
- [],
- |row| row.get::<_, Option<String>>(0),
- )
- .map_err(|_| corrupt_storage_error())?;
- value
- .map(|hex| PublicKey::from_hex(&hex).map_err(|_| corrupt_storage_error()))
- .transpose()
- }
-
- fn save_selected_account(&self, public_key: Option<PublicKey>) -> Result<(), SafeError> {
- let mut connection = self.connection();
- let transaction = connection.transaction().map_err(|_| storage_error())?;
- if let Some(public_key) = public_key {
- let exists = transaction
- .query_row(
- "SELECT EXISTS(SELECT 1 FROM account_identities WHERE public_key = ?1)",
- [public_key.to_hex()],
- |row| row.get::<_, bool>(0),
- )
- .map_err(|_| storage_error())?;
- if !exists {
- return Err(account_not_found());
- }
- }
- let rows = transaction
- .execute(
- "UPDATE runtime_state SET selected_public_key = ?1 WHERE singleton = 1",
- [public_key.map(PublicKey::to_hex)],
- )
- .map_err(|_| storage_error())?;
- if rows != 1 {
- return Err(corrupt_storage_error());
- }
- transaction.commit().map_err(|_| storage_error())
- }
-}
-
-struct EncodedAccount {
- public_key: String,
- npub: String,
- signer_kind: &'static str,
- key_availability: &'static str,
- label: Option<String>,
- created_at: i64,
- last_used_at: Option<i64>,
-}
-
-impl From<&AccountSummary> for EncodedAccount {
- fn from(account: &AccountSummary) -> Self {
- Self {
- public_key: account.public_key().to_hex(),
- npub: account.npub().as_str().to_owned(),
- signer_kind: "local_secret",
- key_availability: encode_key_availability(account.signer().availability()),
- label: account.label().map(|label| label.as_str().to_owned()),
- created_at: account.created_at().timestamp().as_seconds(),
- last_used_at: account.last_used_at().map(UnixTimestamp::as_seconds),
- }
- }
-}
-
-fn decode_account(row: &Row<'_>) -> rusqlite::Result<AccountSummary> {
- let public_key =
- PublicKey::from_hex(row.get::<_, String>(0)?.as_str()).map_err(|_| invalid_column(0))?;
- let npub: String = row.get(1)?;
- if row.get::<_, String>(2)?.as_str() != "local_secret" {
- return Err(invalid_column(2));
- }
- let key_availability = decode_key_availability(row.get::<_, String>(3)?.as_str())?;
- let label = row
- .get::<_, Option<String>>(4)?
- .map(|value| AccountLabel::parse(&value).map_err(|_| invalid_column(4)))
- .transpose()?;
- let created_at = UnixTimestamp::from_seconds(row.get(5)?).ok_or_else(|| invalid_column(5))?;
- let last_used_at = row
- .get::<_, Option<i64>>(6)?
- .map(|value| UnixTimestamp::from_seconds(value).ok_or_else(|| invalid_column(6)))
- .transpose()?;
-
- AccountSummary::new(
- AccountIdentity::verify(public_key, npub).map_err(|_| invalid_column(1))?,
- LocalSignerBinding::new(public_key, key_availability),
- label,
- AccountCreatedAt::new(created_at),
- last_used_at,
- )
- .map_err(|_| invalid_column(0))
-}
-
-const fn encode_key_availability(value: BindingAvailability) -> &'static str {
- match value {
- BindingAvailability::Available => "available",
- BindingAvailability::CredentialMissing => "credential_missing",
- BindingAvailability::StoreUnavailable => "store_unavailable",
- }
-}
-
-fn decode_key_availability(value: &str) -> rusqlite::Result<BindingAvailability> {
- match value {
- "available" => Ok(BindingAvailability::Available),
- "credential_missing" => Ok(BindingAvailability::CredentialMissing),
- "store_unavailable" => Ok(BindingAvailability::StoreUnavailable),
- _ => Err(invalid_column(3)),
- }
-}
-
-fn invalid_column(index: usize) -> rusqlite::Error {
- rusqlite::Error::InvalidColumnType(
- index,
- "public account metadata".to_owned(),
- rusqlite::types::Type::Text,
- )
-}
-
-fn is_constraint_violation(error: &rusqlite::Error) -> bool {
- matches!(
- error,
- rusqlite::Error::SqliteFailure(
- rusqlite::ffi::Error {
- code: rusqlite::ErrorCode::ConstraintViolation,
- ..
- },
- _
- )
- )
-}
-
-const fn storage_error() -> SafeError {
- SafeError::new(
- SafeErrorCode::StorageUnavailable,
- SafeMessage::new("The application database is unavailable."),
- )
-}
-
-const fn corrupt_storage_error() -> SafeError {
- SafeError::new(
- SafeErrorCode::StorageCorrupt,
- SafeMessage::new("The application database could not be read."),
- )
-}
-
-const fn account_exists() -> SafeError {
- SafeError::new(
- SafeErrorCode::AccountAlreadyExists,
- SafeMessage::new("The Nostr account is already saved."),
- )
-}
-
-const fn account_not_found() -> SafeError {
- SafeError::new(
- SafeErrorCode::AccountNotFound,
- SafeMessage::new("The account was not found."),
- )
-}
-
-#[cfg(test)]
-mod tests {
- use std::fs;
-
- use radroots_studio_application::{AccountRepository, AppStateRepository};
- use radroots_studio_domain::{
- AccountCreatedAt, AccountIdentity, AccountLabel, AccountSummary, BindingAvailability,
- LocalSignerBinding, PublicKey, SafeErrorCode, UnixTimestamp,
- };
- use tempfile::tempdir;
-
- use crate::Database;
-
- fn account(key_byte: u8, created_at: i64) -> AccountSummary {
- let public_key = PublicKey::from_bytes([key_byte; 32]);
- AccountSummary::new(
- AccountIdentity::derive(public_key).expect("identity"),
- LocalSignerBinding::new(public_key, BindingAvailability::Available),
- Some(AccountLabel::parse("Farm account").expect("valid label")),
- AccountCreatedAt::new(
- UnixTimestamp::from_seconds(created_at).expect("valid timestamp"),
- ),
- None,
- )
- .expect("account")
- }
-
- #[test]
- fn accounts_insert_list_update_and_reject_duplicates() {
- let database = Database::in_memory().expect("database");
- let first = account(1, 20);
- let second = account(2, 10);
-
- database.insert_account(&first).expect("insert first");
- database.insert_account(&second).expect("insert second");
- let duplicate = database.insert_account(&first).expect_err("duplicate");
-
- assert_eq!(duplicate.code(), SafeErrorCode::AccountAlreadyExists);
- assert_eq!(
- database.list_accounts().expect("list"),
- vec![second, first.clone()]
- );
- assert_eq!(
- database.find_account(first.public_key()).expect("find"),
- Some(first)
- );
- }
-
- #[test]
- fn accounts_and_selection_survive_restart_without_secret_text() {
- let directory = tempdir().expect("temporary directory");
- let path = directory.path().join("studio.sqlite3");
- let account = account(3, 30);
-
- {
- let database = Database::open(&path).expect("database");
- database.insert_account(&account).expect("insert");
- database
- .save_selected_account(Some(account.public_key()))
- .expect("select");
- }
- let reopened = Database::open(&path).expect("reopen");
-
- assert_eq!(
- reopened.list_accounts().expect("list"),
- vec![account.clone()]
- );
- assert_eq!(
- reopened.load_selected_account().expect("selection"),
- Some(account.public_key())
- );
- let bytes = fs::read(path).expect("database bytes");
- assert!(!String::from_utf8_lossy(&bytes).contains("nsec1known-test-secret"));
- }
-
- #[test]
- fn selection_requires_an_existing_account_and_clears_on_delete() {
- let database = Database::in_memory().expect("database");
- let account = account(4, 40);
-
- let missing = database
- .save_selected_account(Some(account.public_key()))
- .expect_err("missing account");
- assert_eq!(missing.code(), SafeErrorCode::AccountNotFound);
-
- database.insert_account(&account).expect("insert");
- database
- .save_selected_account(Some(account.public_key()))
- .expect("select");
- database
- .remove_account(account.public_key())
- .expect("remove");
-
- assert_eq!(database.load_selected_account().expect("selection"), None);
- }
-}
diff --git a/core/crates/storage/src/application_adapter.rs b/core/crates/storage/src/application_adapter.rs
@@ -1,718 +0,0 @@
-use std::path::Path;
-
-use radroots_studio_application::{
- AppCore, AppSnapshot, Clock, DurableRequestId, GenerateAccountReceipt, ImportAccountReceipt,
- RelayConfiguration, RemovalConfirmationToken, SecretStore, StagedGeneratedKey,
-};
-use radroots_studio_domain::{PublicKey, SafeError, SecretKeyInput};
-
-use crate::Database;
-
-pub struct PersistentAppCore {
- core: AppCore,
- database: Database,
-}
-
-impl PersistentAppCore {
- /// Commits an acknowledged generated-key stage through the durable coordinator.
- ///
- /// # Errors
- ///
- /// Returns a safe conflict, credential, storage, or recovery error.
- pub fn commit_staged_generated_key(
- &self,
- request_id: &DurableRequestId,
- staged: StagedGeneratedKey,
- secrets: &(impl SecretStore + ?Sized),
- clock: &(impl Clock + ?Sized),
- ) -> Result<ImportAccountReceipt, SafeError> {
- self.core.commit_staged_generated_key(
- request_id,
- staged,
- &self.database,
- &self.database,
- secrets,
- &self.database,
- clock,
- )
- }
-
- /// Opens the application database without accessing credentials or relays.
- ///
- /// # Errors
- ///
- /// Returns a safe storage error when the database cannot be opened or migrated.
- pub fn open(path: &Path, relay_configuration: RelayConfiguration) -> Result<Self, SafeError> {
- Ok(Self {
- core: AppCore::in_memory(relay_configuration),
- database: Database::open(path)?,
- })
- }
-
- /// Creates an isolated persistent-core adapter for tests.
- ///
- /// # Errors
- ///
- /// Returns a safe storage error when the database cannot be initialized.
- pub fn in_memory(relay_configuration: RelayConfiguration) -> Result<Self, SafeError> {
- Ok(Self {
- core: AppCore::in_memory(relay_configuration),
- database: Database::in_memory()?,
- })
- }
-
- /// Restores public accounts and selection while keeping the session signed out.
- ///
- /// # Errors
- ///
- /// Returns a safe storage or application-state error after publishing a fatal
- /// snapshot when durable state cannot be restored.
- pub fn bootstrap(
- &self,
- secrets: &(impl SecretStore + ?Sized),
- clock: &(impl Clock + ?Sized),
- ) -> Result<AppSnapshot, SafeError> {
- self.core.recover_durable_operations(
- &self.database,
- &self.database,
- secrets,
- &self.database,
- clock,
- )?;
- self.core.recover_pending_operations(
- &self.database,
- &self.database,
- secrets,
- &self.database,
- clock,
- )?;
- self.core.bootstrap_from(&self.database, &self.database)
- }
-
- /// Generates and durably persists one selected, signed-out local account.
- ///
- /// # Errors
- ///
- /// Returns a safe credential, storage, key, or application-state error.
- pub fn generate_account(
- &self,
- secrets: &(impl SecretStore + ?Sized),
- clock: &(impl Clock + ?Sized),
- ) -> Result<GenerateAccountReceipt, SafeError> {
- self.core.generate_account(
- &self.database,
- &self.database,
- secrets,
- &self.database,
- clock,
- )
- }
-
- /// Imports and durably persists one selected, signed-out local account.
- ///
- /// # Errors
- ///
- /// Returns a safe credential, storage, key, or application-state error.
- pub fn import_secret_key(
- &self,
- input: SecretKeyInput,
- secrets: &(impl SecretStore + ?Sized),
- clock: &(impl Clock + ?Sized),
- ) -> Result<ImportAccountReceipt, SafeError> {
- self.core.import_secret_key(
- input,
- &self.database,
- &self.database,
- secrets,
- &self.database,
- clock,
- )
- }
-
- /// Generates an account through the durable request coordinator.
- ///
- /// # Errors
- ///
- /// Returns a safe conflict, credential, storage, or application-state error.
- pub fn generate_account_durable(
- &self,
- request_id: &DurableRequestId,
- expected_revision: u64,
- secrets: &(impl SecretStore + ?Sized),
- clock: &(impl Clock + ?Sized),
- ) -> Result<GenerateAccountReceipt, SafeError> {
- self.core.generate_account_durable(
- request_id,
- expected_revision,
- &self.database,
- &self.database,
- secrets,
- &self.database,
- clock,
- )
- }
-
- /// Imports or repairs an account through the durable request coordinator.
- ///
- /// # Errors
- ///
- /// Returns a safe conflict, validation, credential, storage, or state error.
- pub fn import_secret_key_durable(
- &self,
- request_id: &DurableRequestId,
- expected_revision: u64,
- input: SecretKeyInput,
- secrets: &(impl SecretStore + ?Sized),
- clock: &(impl Clock + ?Sized),
- ) -> Result<ImportAccountReceipt, SafeError> {
- self.core.import_secret_key_durable(
- request_id,
- expected_revision,
- input,
- &self.database,
- &self.database,
- secrets,
- &self.database,
- clock,
- )
- }
-
- /// Persists and publishes one saved-account selection without activation.
- ///
- /// # Errors
- ///
- /// Returns a safe account, storage, or application-state error.
- pub fn select_account(&self, public_key: PublicKey) -> Result<AppSnapshot, SafeError> {
- self.core
- .select_account(public_key, &self.database, &self.database)
- }
-
- /// Activates a saved account after validating its credential and cached profile.
- ///
- /// # Errors
- ///
- /// Returns a safe account, credential, storage, or application-state error.
- pub fn activate_account(
- &self,
- public_key: PublicKey,
- secrets: &(impl SecretStore + ?Sized),
- clock: &(impl Clock + ?Sized),
- ) -> Result<AppSnapshot, SafeError> {
- self.core.activate_account(
- public_key,
- &self.database,
- &self.database,
- &self.database,
- secrets,
- clock,
- )
- }
-
- /// Signs out while retaining durable account data and credentials.
- ///
- /// # Errors
- ///
- /// Returns a safe application-state error if sign out cannot complete.
- pub fn sign_out(&self) -> Result<AppSnapshot, SafeError> {
- self.core.sign_out()
- }
-
- /// Issues a revision-bound, single-use account-removal confirmation.
- ///
- /// # Errors
- ///
- /// Returns a safe error when the target account is not saved.
- pub fn request_account_removal(
- &self,
- public_key: PublicKey,
- clock: &(impl Clock + ?Sized),
- ) -> Result<RemovalConfirmationToken, SafeError> {
- self.core.request_account_removal(public_key, clock)
- }
-
- /// Permanently removes one confirmed account and its credential.
- ///
- /// # Errors
- ///
- /// Returns a safe confirmation, credential, storage, recovery, or state error.
- pub fn confirm_account_removal(
- &self,
- token: RemovalConfirmationToken,
- secrets: &(impl SecretStore + ?Sized),
- clock: &(impl Clock + ?Sized),
- ) -> Result<AppSnapshot, SafeError> {
- self.core.confirm_account_removal(
- token,
- &self.database,
- &self.database,
- secrets,
- &self.database,
- clock,
- )
- }
-
- /// Executes a confirmed removal through the durable request coordinator.
- ///
- /// # Errors
- ///
- /// Returns a safe expiry, conflict, credential, storage, recovery, or state error.
- pub fn confirm_account_removal_durable(
- &self,
- request_id: &DurableRequestId,
- token: RemovalConfirmationToken,
- secrets: &(impl SecretStore + ?Sized),
- clock: &(impl Clock + ?Sized),
- ) -> Result<AppSnapshot, SafeError> {
- self.core.confirm_account_removal_durable(
- request_id,
- token,
- &self.database,
- &self.database,
- secrets,
- &self.database,
- clock,
- )
- }
-
- #[must_use]
- pub const fn core(&self) -> &AppCore {
- &self.core
- }
-
- #[must_use]
- pub const fn database(&self) -> &Database {
- &self.database
- }
-}
-
-#[cfg(test)]
-mod tests {
- use std::fs;
-
- use radroots_studio_application::{
- AccountOperationKind, AccountOperationPhase, AccountRepository, AppLifecycle,
- AppStateRepository, Clock, DurableOperationKind, DurableOperationPhase,
- DurableOperationRepository, DurableRequestId, DurableTerminalOutcome, FailureSecretStore,
- InMemorySecretStore, OperationJournal, OperationPriorState, RelayConfiguration,
- SecretStore, SecretStoreOperation, SessionState,
- };
- use radroots_studio_domain::{
- AccountCreatedAt, AccountIdentity, AccountSummary, BindingAvailability, LocalSignerBinding,
- PublicKey, SafeErrorCode, SecretKeyInput, UnixTimestamp,
- };
- use tempfile::tempdir;
-
- use super::PersistentAppCore;
-
- fn account() -> AccountSummary {
- let public_key = PublicKey::from_bytes([4; 32]);
- AccountSummary::new(
- AccountIdentity::derive(public_key).expect("identity"),
- LocalSignerBinding::new(public_key, BindingAvailability::Available),
- None,
- AccountCreatedAt::new(UnixTimestamp::from_seconds(1).expect("time")),
- None,
- )
- .expect("account")
- }
-
- struct FixedClock;
-
- impl Clock for FixedClock {
- fn now(&self) -> UnixTimestamp {
- UnixTimestamp::from_seconds(25).expect("time")
- }
- }
-
- #[test]
- fn persistent_bootstrap_handles_fresh_and_existing_signed_out_state() {
- let directory = tempdir().expect("directory");
- let path = directory.path().join("studio.sqlite3");
- let public_key = account().public_key();
- let secrets = InMemorySecretStore::default();
- {
- let adapter = PersistentAppCore::open(&path, RelayConfiguration::default())
- .expect("open adapter");
- let fresh = adapter
- .bootstrap(&secrets, &FixedClock)
- .expect("fresh bootstrap");
- assert!(fresh.accounts().is_empty());
- adapter
- .database()
- .insert_account(&account())
- .expect("account");
- adapter
- .database()
- .save_selected_account(Some(public_key))
- .expect("selection");
- }
-
- let adapter =
- PersistentAppCore::open(&path, RelayConfiguration::default()).expect("reopen adapter");
- let restored = adapter.bootstrap(&secrets, &FixedClock).expect("restore");
- assert_eq!(restored.lifecycle(), AppLifecycle::Ready);
- assert_eq!(restored.accounts().len(), 1);
- assert_eq!(restored.selected_account(), Some(public_key));
- assert_eq!(restored.session(), SessionState::SignedOut);
- assert!(restored.active_account().is_none());
- }
-
- #[test]
- fn corrupt_database_fails_safely_without_recreation() {
- let directory = tempdir().expect("directory");
- let path = directory.path().join("studio.sqlite3");
- fs::write(&path, b"not a sqlite database").expect("corrupt file");
-
- let error = PersistentAppCore::open(&path, RelayConfiguration::default())
- .err()
- .expect("safe failure");
- assert_eq!(error.code(), SafeErrorCode::StorageCorrupt);
- assert_eq!(
- fs::read(&path).expect("unchanged file"),
- b"not a sqlite database"
- );
- }
-
- #[test]
- fn persisted_generate_and_import_survive_restart_without_secret_bytes() {
- let directory = tempdir().expect("directory");
- let path = directory.path().join("studio.sqlite3");
- let secrets = InMemorySecretStore::default();
- let selected;
- {
- let adapter =
- PersistentAppCore::open(&path, RelayConfiguration::default()).expect("adapter");
- adapter.bootstrap(&secrets, &FixedClock).expect("bootstrap");
- let generated = adapter
- .generate_account(&secrets, &FixedClock)
- .expect("generate");
- assert!(
- secrets
- .contains(generated.account().public_key())
- .expect("generated credential")
- );
- let imported = adapter
- .import_secret_key(
- SecretKeyInput::parse(
- "7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7"
- .to_owned(),
- )
- .expect("secret"),
- &secrets,
- &FixedClock,
- )
- .expect("import");
- selected = imported.account().public_key();
- assert_eq!(adapter.core().snapshot().accounts().len(), 2);
- }
-
- let bytes = fs::read(&path).expect("database bytes");
- assert!(!bytes.windows(5).any(|value| value == b"nsec1"));
- assert!(!bytes.windows(64).any(|value| {
- value == b"7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7"
- }));
- let reopened =
- PersistentAppCore::open(&path, RelayConfiguration::default()).expect("reopen");
- let restored = reopened.bootstrap(&secrets, &FixedClock).expect("restore");
- assert_eq!(restored.accounts().len(), 2);
- assert_eq!(restored.selected_account(), Some(selected));
- assert_eq!(restored.session(), SessionState::SignedOut);
- }
-
- #[test]
- fn durable_import_commits_each_phase_and_recovers_the_terminal_receipt() {
- let adapter = PersistentAppCore::in_memory(RelayConfiguration::default()).expect("adapter");
- let secrets = InMemorySecretStore::default();
- let snapshot = adapter.bootstrap(&secrets, &FixedClock).expect("bootstrap");
- let request = DurableRequestId::parse("import:adapter:1").expect("request");
- let imported = adapter
- .import_secret_key_durable(
- &request,
- snapshot.revision().value(),
- SecretKeyInput::parse(
- "7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7".to_owned(),
- )
- .expect("secret"),
- &secrets,
- &FixedClock,
- )
- .expect("durable import");
- let operation = adapter
- .database()
- .load_durable_operation(&request)
- .expect("operation")
- .expect("durable record");
- let receipt = operation.terminal().expect("terminal receipt");
- assert_eq!(receipt.account(), imported.account().public_key());
- assert_eq!(
- receipt.resulting_revision(),
- Some(adapter.core().snapshot().revision().value())
- );
- }
-
- #[test]
- fn durable_recovery_preserves_repair_metadata_and_deletes_orphan_credentials() {
- let adapter = PersistentAppCore::in_memory(RelayConfiguration::default()).expect("adapter");
- let secrets = InMemorySecretStore::default();
- let missing = account().with_binding_availability(BindingAvailability::CredentialMissing);
- adapter
- .database()
- .insert_account(&missing)
- .expect("account");
- adapter
- .database()
- .save_selected_account(Some(missing.public_key()))
- .expect("selection");
- let request = DurableRequestId::parse("repair:recovery:1").expect("request");
- adapter
- .database()
- .begin_durable_operation(
- &request,
- DurableOperationKind::Repair,
- missing.public_key(),
- Some(0),
- OperationPriorState::new(
- Some(missing.public_key()),
- Some(BindingAvailability::CredentialMissing),
- ),
- FixedClock.now(),
- )
- .expect("intent");
- secrets
- .put(
- missing.public_key(),
- SecretKeyInput::parse(
- "7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7".to_owned(),
- )
- .expect("secret"),
- )
- .expect("credential");
- adapter
- .database()
- .advance_durable_operation(
- &request,
- DurableOperationPhase::IntentRecorded,
- DurableOperationPhase::CredentialWritten,
- FixedClock.now(),
- None,
- )
- .expect("credential phase");
-
- adapter.bootstrap(&secrets, &FixedClock).expect("recovery");
- let repaired = adapter
- .database()
- .find_account(missing.public_key())
- .expect("lookup")
- .expect("preserved account");
- assert_eq!(
- repaired.signer().availability(),
- BindingAvailability::CredentialMissing
- );
- assert!(!secrets.contains(missing.public_key()).expect("credential"));
- assert_eq!(
- adapter
- .database()
- .load_durable_operation(&request)
- .expect("operation")
- .expect("record")
- .terminal()
- .expect("receipt")
- .outcome(),
- DurableTerminalOutcome::Failed
- );
- }
-
- #[test]
- fn durable_recovery_covers_response_loss_and_irreversible_removal_windows() {
- let secrets = InMemorySecretStore::default();
- let adapter = PersistentAppCore::in_memory(RelayConfiguration::default()).expect("adapter");
- let saved = account();
- adapter.database().insert_account(&saved).expect("account");
- let import = DurableRequestId::parse("import:response-loss:1").expect("request");
- adapter
- .database()
- .begin_durable_operation(
- &import,
- DurableOperationKind::Import,
- saved.public_key(),
- Some(0),
- OperationPriorState::new(None, None),
- FixedClock.now(),
- )
- .expect("intent");
- adapter
- .database()
- .advance_durable_operation(
- &import,
- DurableOperationPhase::IntentRecorded,
- DurableOperationPhase::CredentialWritten,
- FixedClock.now(),
- None,
- )
- .expect("credential");
- adapter
- .database()
- .advance_durable_operation(
- &import,
- DurableOperationPhase::CredentialWritten,
- DurableOperationPhase::MetadataCommitted,
- FixedClock.now(),
- None,
- )
- .expect("metadata");
- let restored = adapter
- .bootstrap(&secrets, &FixedClock)
- .expect("response recovery");
- assert_eq!(restored.selected_account(), Some(saved.public_key()));
- assert_eq!(
- adapter
- .database()
- .load_durable_operation(&import)
- .expect("operation")
- .expect("record")
- .terminal()
- .expect("receipt")
- .outcome(),
- DurableTerminalOutcome::Completed
- );
-
- let removal_adapter =
- PersistentAppCore::in_memory(RelayConfiguration::default()).expect("remove adapter");
- removal_adapter
- .database()
- .insert_account(&saved)
- .expect("remove account");
- removal_adapter
- .database()
- .save_selected_account(Some(saved.public_key()))
- .expect("remove selection");
- let removal = DurableRequestId::parse("remove:response-loss:1").expect("request");
- removal_adapter
- .database()
- .begin_durable_operation(
- &removal,
- DurableOperationKind::Remove,
- saved.public_key(),
- Some(0),
- OperationPriorState::new(None, Some(BindingAvailability::Available)),
- FixedClock.now(),
- )
- .expect("remove intent");
- removal_adapter
- .database()
- .advance_durable_operation(
- &removal,
- DurableOperationPhase::IntentRecorded,
- DurableOperationPhase::CredentialDeleted,
- FixedClock.now(),
- None,
- )
- .expect("credential deleted");
- let removed = removal_adapter
- .bootstrap(&secrets, &FixedClock)
- .expect("removal recovery");
- assert!(removed.accounts().is_empty());
- assert_eq!(removed.selected_account(), None);
- }
-
- #[test]
- fn bootstrap_recovery_completes_credential_deleted_removal_and_fallback() {
- let directory = tempdir().expect("directory");
- let path = directory.path().join("studio.sqlite3");
- let secrets = InMemorySecretStore::default();
- let first;
- let removed;
- {
- let adapter =
- PersistentAppCore::open(&path, RelayConfiguration::default()).expect("adapter");
- adapter.bootstrap(&secrets, &FixedClock).expect("bootstrap");
- first = adapter
- .generate_account(&secrets, &FixedClock)
- .expect("first")
- .account()
- .public_key();
- removed = adapter
- .generate_account(&secrets, &FixedClock)
- .expect("removed")
- .account()
- .public_key();
- let operation = adapter
- .database()
- .begin_operation(AccountOperationKind::Remove, removed, FixedClock.now())
- .expect("intent");
- secrets.delete(removed).expect("credential deletion");
- adapter
- .database()
- .update_operation(
- operation,
- AccountOperationPhase::CredentialDeleted,
- FixedClock.now(),
- None,
- )
- .expect("phase");
- }
-
- let reopened =
- PersistentAppCore::open(&path, RelayConfiguration::default()).expect("reopen");
- let restored = reopened
- .bootstrap(&secrets, &FixedClock)
- .expect("recover and bootstrap");
- assert_eq!(restored.accounts().len(), 1);
- assert_eq!(restored.selected_account(), Some(first));
- assert_eq!(restored.session(), SessionState::SignedOut);
- assert!(
- reopened
- .database()
- .list_pending_operations()
- .expect("journal")
- .is_empty()
- );
- assert!(
- reopened
- .database()
- .find_account(removed)
- .expect("removed")
- .is_none()
- );
- }
-
- #[test]
- fn bootstrap_skips_keyring_when_journal_empty_and_retains_failed_intent() {
- let empty = PersistentAppCore::in_memory(RelayConfiguration::default()).expect("empty");
- let unavailable = FailureSecretStore::default();
- unavailable.fail_next(SecretStoreOperation::Delete);
- empty
- .bootstrap(&unavailable, &FixedClock)
- .expect("empty journal does not access keyring");
-
- let adapter = PersistentAppCore::in_memory(RelayConfiguration::default()).expect("adapter");
- adapter
- .database()
- .insert_account(&account())
- .expect("account");
- adapter
- .database()
- .save_selected_account(Some(account().public_key()))
- .expect("selection");
- adapter
- .database()
- .begin_operation(
- AccountOperationKind::Remove,
- account().public_key(),
- FixedClock.now(),
- )
- .expect("intent");
- let failing = FailureSecretStore::default();
- failing.fail_next(SecretStoreOperation::Delete);
- let error = adapter
- .bootstrap(&failing, &FixedClock)
- .expect_err("keyring unavailable");
- assert_eq!(error.code(), SafeErrorCode::KeyringUnavailable);
- let pending = adapter
- .database()
- .list_pending_operations()
- .expect("pending");
- assert_eq!(pending.len(), 1);
- assert_eq!(pending[0].phase(), AccountOperationPhase::IntentRecorded);
- }
-}
diff --git a/core/crates/storage/src/db.rs b/core/crates/storage/src/db.rs
@@ -1,590 +0,0 @@
-use std::fs::{self, File, OpenOptions};
-use std::ops::{Deref, DerefMut};
-use std::path::{Path, PathBuf};
-use std::sync::{Mutex, MutexGuard};
-use std::time::Duration;
-
-use fs2::FileExt;
-use radroots_studio_domain::{AccountIdentity, PublicKey, SafeError, SafeErrorCode, SafeMessage};
-use refinery::embed_migrations;
-use rusqlite::{Connection, OpenFlags};
-
-pub const CURRENT_SCHEMA_VERSION: u32 = 9;
-
-mod migrations {
- use super::embed_migrations;
-
- embed_migrations!("migrations");
-}
-
-pub struct Database {
- connection: Mutex<Connection>,
- path: Option<PathBuf>,
- _ownership: Option<WritableOwnership>,
-}
-
-pub(crate) struct DatabaseConnection<'a> {
- connection: MutexGuard<'a, Connection>,
- path: Option<&'a Path>,
-}
-
-struct WritableOwnership {
- _file: File,
-}
-
-impl Database {
- /// Opens, configures, and migrates a file-backed `SQLite` database.
- ///
- /// # Errors
- ///
- /// Returns a safe storage error when the file, connection configuration,
- /// permission update, or migration cannot complete.
- pub fn open(path: &Path) -> Result<Self, SafeError> {
- let parent = path.parent().ok_or_else(storage_error)?;
- create_secure_directory(parent)?;
- let ownership = WritableOwnership::acquire(path)?;
- let flags = OpenFlags::SQLITE_OPEN_READ_WRITE
- | OpenFlags::SQLITE_OPEN_CREATE
- | OpenFlags::SQLITE_OPEN_NO_MUTEX;
- let mut connection =
- Connection::open_with_flags(path, flags).map_err(|_| storage_error())?;
- configure(&connection).map_err(|_| corrupt_storage_error())?;
- validate_legacy_account_identities(&connection)?;
- migrations::migrations::runner()
- .run(&mut connection)
- .map_err(|_| corrupt_storage_error())?;
- restrict_file_permissions(path)?;
- restrict_sqlite_sidecars(path)?;
- Ok(Self {
- connection: Mutex::new(connection),
- path: Some(path.to_path_buf()),
- _ownership: Some(ownership),
- })
- }
-
- /// Opens and migrates an isolated in-memory `SQLite` database.
- ///
- /// # Errors
- ///
- /// Returns a safe storage error when configuration or migration fails.
- pub fn in_memory() -> Result<Self, SafeError> {
- let mut connection = Connection::open_in_memory().map_err(|_| storage_error())?;
- configure(&connection)?;
- migrations::migrations::runner()
- .run(&mut connection)
- .map_err(|_| corrupt_storage_error())?;
- Ok(Self {
- connection: Mutex::new(connection),
- path: None,
- _ownership: None,
- })
- }
-
- /// Returns the highest successfully applied migration version.
- ///
- /// # Errors
- ///
- /// Returns a safe storage error when migration history cannot be read.
- pub fn schema_version(&self) -> Result<u32, SafeError> {
- self.connection()
- .query_row(
- "SELECT COALESCE(MAX(version), 0) FROM refinery_schema_history",
- [],
- |row| row.get(0),
- )
- .map_err(|_| corrupt_storage_error())
- }
-
- pub(crate) fn connection(&self) -> DatabaseConnection<'_> {
- DatabaseConnection {
- connection: self
- .connection
- .lock()
- .unwrap_or_else(std::sync::PoisonError::into_inner),
- path: self.path.as_deref(),
- }
- }
-}
-
-impl Deref for DatabaseConnection<'_> {
- type Target = Connection;
-
- fn deref(&self) -> &Self::Target {
- &self.connection
- }
-}
-
-impl DerefMut for DatabaseConnection<'_> {
- fn deref_mut(&mut self) -> &mut Self::Target {
- &mut self.connection
- }
-}
-
-impl Drop for DatabaseConnection<'_> {
- fn drop(&mut self) {
- if let Some(path) = self.path {
- let _ = restrict_sqlite_sidecars(path);
- }
- }
-}
-
-impl WritableOwnership {
- fn acquire(database_path: &Path) -> Result<Self, SafeError> {
- let lock_path = database_path.with_extension("sqlite3.lock");
- let file = OpenOptions::new()
- .read(true)
- .write(true)
- .create(true)
- .truncate(false)
- .open(&lock_path)
- .map_err(|_| storage_error())?;
- restrict_file_permissions(&lock_path)?;
- file.try_lock_exclusive().map_err(|_| ownership_error())?;
- Ok(Self { _file: file })
- }
-}
-
-fn create_secure_directory(path: &Path) -> Result<(), SafeError> {
- fs::create_dir_all(path).map_err(|_| storage_error())?;
- restrict_directory_permissions(path)
-}
-
-fn configure(connection: &Connection) -> Result<(), SafeError> {
- connection
- .pragma_update(None, "foreign_keys", "ON")
- .and_then(|()| connection.pragma_update(None, "trusted_schema", "OFF"))
- .and_then(|()| connection.pragma_update(None, "journal_mode", "WAL"))
- .and_then(|()| connection.pragma_update(None, "synchronous", "FULL"))
- .and_then(|()| connection.pragma_update(None, "secure_delete", "ON"))
- .and_then(|()| connection.pragma_update(None, "wal_autocheckpoint", 1_000))
- .and_then(|()| connection.busy_timeout(Duration::from_secs(5)))
- .map_err(|_| storage_error())
-}
-
-fn validate_legacy_account_identities(connection: &Connection) -> Result<(), SafeError> {
- let has_accounts = connection
- .query_row(
- "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'accounts')",
- [],
- |row| row.get::<_, bool>(0),
- )
- .map_err(|_| corrupt_storage_error())?;
- if !has_accounts {
- return Ok(());
- }
-
- let mut statement = connection
- .prepare("SELECT pubkey, npub, signer_kind, key_availability FROM accounts")
- .map_err(|_| corrupt_storage_error())?;
- let rows = statement
- .query_map([], |row| {
- Ok((
- row.get::<_, String>(0)?,
- row.get::<_, String>(1)?,
- row.get::<_, String>(2)?,
- row.get::<_, String>(3)?,
- ))
- })
- .map_err(|_| corrupt_storage_error())?;
- for row in rows {
- let (public_key, npub, signer_kind, availability) =
- row.map_err(|_| corrupt_storage_error())?;
- let public_key = PublicKey::from_hex(&public_key).map_err(|_| corrupt_storage_error())?;
- AccountIdentity::verify(public_key, npub).map_err(|_| corrupt_storage_error())?;
- if signer_kind != "local_secret"
- || !matches!(
- availability.as_str(),
- "available" | "credential_missing" | "store_unavailable"
- )
- {
- return Err(corrupt_storage_error());
- }
- }
- Ok(())
-}
-
-fn restrict_sqlite_sidecars(path: &Path) -> Result<(), SafeError> {
- for suffix in ["-wal", "-shm"] {
- let sidecar = PathBuf::from(format!("{}{suffix}", path.display()));
- if sidecar.exists() {
- restrict_file_permissions(&sidecar)?;
- }
- }
- Ok(())
-}
-
-#[cfg(unix)]
-fn restrict_file_permissions(path: &Path) -> Result<(), SafeError> {
- use std::os::unix::fs::PermissionsExt;
-
- fs::set_permissions(path, fs::Permissions::from_mode(0o600)).map_err(|_| storage_error())
-}
-
-#[cfg(unix)]
-fn restrict_directory_permissions(path: &Path) -> Result<(), SafeError> {
- use std::os::unix::fs::PermissionsExt;
-
- fs::set_permissions(path, fs::Permissions::from_mode(0o700)).map_err(|_| storage_error())
-}
-
-#[cfg(not(unix))]
-fn restrict_file_permissions(_path: &Path) -> Result<(), SafeError> {
- Ok(())
-}
-
-#[cfg(not(unix))]
-fn restrict_directory_permissions(_path: &Path) -> Result<(), SafeError> {
- Ok(())
-}
-
-const fn storage_error() -> SafeError {
- SafeError::new(
- SafeErrorCode::StorageUnavailable,
- SafeMessage::new("The application database is unavailable."),
- )
-}
-
-const fn corrupt_storage_error() -> SafeError {
- SafeError::new(
- SafeErrorCode::StorageCorrupt,
- SafeMessage::new("The application database could not be read."),
- )
-}
-
-const fn ownership_error() -> SafeError {
- SafeError::new(
- SafeErrorCode::StorageUnavailable,
- SafeMessage::new("The application database is already in use."),
- )
-}
-
-#[cfg(test)]
-mod tests {
- use std::fs;
- use std::path::Path;
- use std::process::Command;
-
- use tempfile::tempdir;
-
- use radroots_studio_application::{AccountRepository, AppStateRepository};
- use radroots_studio_domain::PublicKey;
- use refinery::Target;
- use rusqlite::Connection;
-
- use super::{CURRENT_SCHEMA_VERSION, Database, configure, migrations};
-
- #[test]
- fn migration_opens_fresh_memory_database_once() {
- let database = Database::in_memory().expect("open memory database");
-
- assert_eq!(
- database.schema_version().expect("schema version"),
- CURRENT_SCHEMA_VERSION
- );
- assert_eq!(
- database.schema_version().expect("repeat schema version"),
- CURRENT_SCHEMA_VERSION
- );
- }
-
- #[test]
- fn sqlite_connection_enforces_trust_durability_and_busy_policy() {
- let database = Database::in_memory().expect("open memory database");
- let connection = database.connection();
-
- assert_eq!(
- connection
- .pragma_query_value(None, "foreign_keys", |row| row.get::<_, u8>(0))
- .expect("foreign keys"),
- 1
- );
- assert_eq!(
- connection
- .pragma_query_value(None, "trusted_schema", |row| row.get::<_, u8>(0))
- .expect("trusted schema"),
- 0
- );
- assert_eq!(
- connection
- .pragma_query_value(None, "synchronous", |row| row.get::<_, u8>(0))
- .expect("synchronous"),
- 2
- );
- assert_eq!(
- connection
- .pragma_query_value(None, "busy_timeout", |row| row.get::<_, i64>(0))
- .expect("busy timeout"),
- 5_000
- );
- }
-
- #[test]
- fn normalized_schema_is_strict_and_enforces_same_account_bindings() {
- let database = Database::in_memory().expect("open memory database");
- let connection = database.connection();
- let strict_tables: i64 = connection
- .query_row(
- "SELECT COUNT(*) FROM pragma_table_list WHERE name IN ('account_identities', 'local_signer_bindings', 'runtime_state', 'profile_cache_v6', 'durable_operations') AND strict = 1",
- [],
- |row| row.get(0),
- )
- .expect("strict table inventory");
- assert_eq!(strict_tables, 5);
-
- connection
- .execute(
- "INSERT INTO account_identities (public_key, npub, created_at) VALUES (?1, ?2, 1)",
- [
- "07".repeat(32),
- "npub1qurswpc8qurswpc8qurswpc8qurswpc8qurswpc8qurswpc8qursnvjvl7".to_owned(),
- ],
- )
- .expect("identity");
- assert!(
- connection
- .execute(
- "INSERT INTO local_signer_bindings (account_public_key, binding_public_key, binding_kind, availability) VALUES (?1, ?2, 'local_secret', 'available')",
- ["07".repeat(32), "08".repeat(32)],
- )
- .is_err()
- );
- }
-
- #[test]
- fn v5_data_migrates_append_only_with_identity_profile_and_selection() {
- let directory = tempdir().expect("temporary directory");
- let path = directory.path().join("studio.sqlite3");
- let public_key = "07".repeat(32);
- {
- let mut connection = Connection::open(&path).expect("legacy database");
- configure(&connection).expect("configuration");
- migrations::migrations::runner()
- .set_target(Target::Version(5))
- .run(&mut connection)
- .expect("V5 schema");
- connection
- .execute(
- "INSERT INTO accounts (pubkey, npub, signer_kind, key_availability, created_at) VALUES (?1, ?2, 'local_secret', 'available', 10)",
- [&public_key, "npub1qurswpc8qurswpc8qurswpc8qurswpc8qurswpc8qurswpc8qursnvjvl7"],
- )
- .expect("legacy account");
- connection
- .execute(
- "UPDATE app_state SET selected_pubkey = ?1 WHERE singleton = 1",
- [&public_key],
- )
- .expect("legacy selection");
- connection
- .execute(
- "INSERT INTO profile_cache (subject_pubkey, event_id, event_created_at, name, refreshed_at, refresh_status) VALUES (?1, ?2, 11, 'Farm', 12, 'success')",
- [&public_key, &"01".repeat(32)],
- )
- .expect("legacy profile");
- }
-
- let database = Database::open(&path).expect("migrated database");
- assert_eq!(database.schema_version().expect("version"), 9);
- assert_eq!(database.list_accounts().expect("accounts").len(), 1);
- assert_eq!(
- database.load_selected_account().expect("selection"),
- Some(PublicKey::from_bytes([7; 32]))
- );
- let connection = database.connection();
- let migrated: (i64, i64, i64) = connection
- .query_row(
- "SELECT (SELECT COUNT(*) FROM account_identities), (SELECT COUNT(*) FROM local_signer_bindings), (SELECT COUNT(*) FROM profile_cache_v6)",
- [],
- |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
- )
- .expect("migrated inventory");
- assert_eq!(migrated, (1, 1, 1));
- }
-
- #[test]
- fn corrupt_v5_identity_fails_before_migration_without_recreation() {
- let directory = tempdir().expect("temporary directory");
- let path = directory.path().join("studio.sqlite3");
- {
- let mut connection = Connection::open(&path).expect("legacy database");
- configure(&connection).expect("configuration");
- migrations::migrations::runner()
- .set_target(Target::Version(5))
- .run(&mut connection)
- .expect("V5 schema");
- connection
- .execute(
- "INSERT INTO accounts (pubkey, npub, signer_kind, key_availability, created_at) VALUES (?1, ?2, 'local_secret', 'available', 10)",
- ["07".repeat(32), "npub10elfcs4fr0l0r8af98jlmgdh9c8tcxjvz9qkw038js35mp4dma8qzvjptg".to_owned()],
- )
- .expect("mismatched legacy account");
- }
-
- assert!(Database::open(&path).is_err());
- let connection = Connection::open(&path).expect("inspect legacy database");
- let version: u32 = connection
- .query_row(
- "SELECT MAX(version) FROM refinery_schema_history",
- [],
- |row| row.get(0),
- )
- .expect("legacy version");
- let accounts: i64 = connection
- .query_row("SELECT COUNT(*) FROM accounts", [], |row| row.get(0))
- .expect("legacy accounts");
- assert_eq!((version, accounts), (5, 1));
- }
-
- #[test]
- fn failed_v5_copy_rolls_back_the_active_migration() {
- let directory = tempdir().expect("temporary directory");
- let path = directory.path().join("studio.sqlite3");
- let public_key = "07".repeat(32);
- {
- let mut connection = Connection::open(&path).expect("legacy database");
- configure(&connection).expect("configuration");
- migrations::migrations::runner()
- .set_target(Target::Version(5))
- .run(&mut connection)
- .expect("V5 schema");
- connection
- .execute(
- "INSERT INTO accounts (pubkey, npub, signer_kind, key_availability, created_at) VALUES (?1, ?2, 'local_secret', 'available', 10)",
- [&public_key, "npub1qurswpc8qurswpc8qurswpc8qurswpc8qurswpc8qurswpc8qursnvjvl7"],
- )
- .expect("legacy account");
- connection
- .execute(
- "INSERT INTO profile_cache (subject_pubkey, event_id, event_created_at, refreshed_at, refresh_status) VALUES (?1, 'invalid', 11, 12, 'success')",
- [&public_key],
- )
- .expect("legacy corrupt profile");
- }
-
- assert!(Database::open(&path).is_err());
- let connection = Connection::open(&path).expect("inspect interrupted migration");
- let version: u32 = connection
- .query_row(
- "SELECT MAX(version) FROM refinery_schema_history",
- [],
- |row| row.get(0),
- )
- .expect("migration version");
- let copied: i64 = connection
- .query_row("SELECT COUNT(*) FROM account_identities", [], |row| {
- row.get(0)
- })
- .expect("normalized accounts");
- assert_eq!((version, copied), (6, 0));
- }
-
- #[test]
- fn foreign_keys_reject_orphan_normalized_records() {
- let database = Database::in_memory().expect("database");
- let connection = database.connection();
- assert!(
- connection
- .execute(
- "INSERT INTO local_signer_bindings (account_public_key, binding_public_key, binding_kind, availability) VALUES (?1, ?1, 'local_secret', 'available')",
- ["09".repeat(32)],
- )
- .is_err()
- );
- }
-
- #[test]
- fn second_process_cannot_acquire_writable_ownership() {
- let directory = tempdir().expect("temporary directory");
- let path = directory.path().join("studio.sqlite3");
- let _owner = Database::open(&path).expect("parent owner");
- let status = Command::new(std::env::current_exe().expect("test executable"))
- .arg("--exact")
- .arg("db::tests::writable_ownership_child_probe")
- .arg("--nocapture")
- .env("RADROOTS_STUDIO_LOCK_PROBE_PATH", &path)
- .status()
- .expect("child process");
- assert!(status.success());
- }
-
- #[test]
- fn writable_ownership_child_probe() {
- let Ok(path) = std::env::var("RADROOTS_STUDIO_LOCK_PROBE_PATH") else {
- return;
- };
- assert!(Database::open(Path::new(&path)).is_err());
- }
-
- #[test]
- fn migration_persists_schema_version_across_file_reopen() {
- let directory = tempdir().expect("temporary directory");
- let path = directory.path().join("studio.sqlite3");
-
- {
- let database = Database::open(&path).expect("open file database");
- assert_eq!(
- database.schema_version().expect("schema version"),
- CURRENT_SCHEMA_VERSION
- );
- }
- let reopened = Database::open(&path).expect("reopen file database");
- assert_eq!(
- reopened.schema_version().expect("schema version"),
- CURRENT_SCHEMA_VERSION
- );
- assert!(fs::metadata(path).expect("database metadata").len() > 0);
- }
-
- #[test]
- fn writable_ownership_rejects_a_second_runtime_and_releases_on_drop() {
- let directory = tempdir().expect("temporary directory");
- let path = directory.path().join("studio.sqlite3");
- let first = Database::open(&path).expect("first owner");
- let Err(error) = Database::open(&path) else {
- panic!("second owner must fail");
- };
- assert_eq!(
- error.message().as_str(),
- "The application database is already in use."
- );
- drop(first);
- Database::open(&path).expect("ownership released");
- }
-
- #[cfg(unix)]
- #[test]
- fn migration_attempts_owner_only_database_permissions() {
- use std::os::unix::fs::PermissionsExt;
-
- let directory = tempdir().expect("temporary directory");
- let path = directory.path().join("studio.sqlite3");
- let database = Database::open(&path).expect("open file database");
- let mode = fs::metadata(&path)
- .expect("database metadata")
- .permissions()
- .mode()
- & 0o777;
-
- assert_eq!(mode, 0o600);
- let directory_mode = fs::metadata(directory.path())
- .expect("directory metadata")
- .permissions()
- .mode()
- & 0o777;
- assert_eq!(directory_mode, 0o700);
-
- let connection = database.connection();
- connection
- .execute_batch("CREATE TABLE sidecar_probe (value INTEGER) STRICT; INSERT INTO sidecar_probe VALUES (1);")
- .expect("write through WAL");
- drop(connection);
- for suffix in ["-wal", "-shm"] {
- let sidecar = std::path::PathBuf::from(format!("{}{suffix}", path.display()));
- let sidecar_mode = fs::metadata(sidecar)
- .expect("sidecar metadata")
- .permissions()
- .mode()
- & 0o777;
- assert_eq!(sidecar_mode, 0o600);
- }
- }
-}
diff --git a/core/crates/storage/src/journal.rs b/core/crates/storage/src/journal.rs
@@ -1,661 +0,0 @@
-use radroots_studio_application::{
- AccountOperationKind, AccountOperationPhase, DurableAccountOperation, DurableOperationKind,
- DurableOperationPhase, DurableOperationReceipt, DurableOperationRepository,
- DurableOperationStart, DurableRequestId, DurableTerminalOutcome, OperationDiagnostic,
- OperationId, OperationJournal, OperationPriorState, PendingAccountOperation,
-};
-use radroots_studio_domain::{
- BindingAvailability, PublicKey, SafeError, SafeErrorCode, SafeMessage, UnixTimestamp,
-};
-use rusqlite::{OptionalExtension, Row, params};
-
-use crate::Database;
-
-impl DurableOperationRepository for Database {
- fn begin_durable_operation(
- &self,
- request_id: &DurableRequestId,
- kind: DurableOperationKind,
- account: PublicKey,
- expected_revision: Option<u64>,
- prior: OperationPriorState,
- updated_at: UnixTimestamp,
- ) -> Result<DurableOperationStart, SafeError> {
- let encoded_expected_revision = expected_revision
- .map(i64::try_from)
- .transpose()
- .map_err(|_| operation_conflict())?;
- let mut connection = self.connection();
- let transaction = connection.transaction().map_err(|_| storage_error())?;
- let inserted = transaction
- .execute(
- "INSERT OR IGNORE INTO durable_operations (request_id, operation_kind, \
- account_public_key, binding_public_key, expected_revision, phase, \
- prior_selected_public_key, updated_at, prior_binding_availability) \
- VALUES (?1, ?2, ?3, ?3, ?4, 'intent_recorded', ?5, ?6, ?7)",
- params![
- request_id.as_str(),
- encode_durable_kind(kind),
- account.to_hex(),
- encoded_expected_revision,
- prior.selected_account().map(PublicKey::to_hex),
- updated_at.as_seconds(),
- prior
- .binding_availability()
- .map(encode_binding_availability),
- ],
- )
- .map_err(|_| storage_error())?;
- let operation =
- query_durable_operation(&transaction, request_id)?.ok_or_else(corrupt_storage_error)?;
- if operation.kind() != kind
- || operation.account() != account
- || operation.expected_revision() != expected_revision
- || operation.prior() != prior
- {
- return Err(operation_conflict());
- }
- transaction.commit().map_err(|_| storage_error())?;
- Ok(if inserted == 1 {
- DurableOperationStart::Started(operation)
- } else {
- DurableOperationStart::Existing(operation)
- })
- }
-
- fn load_durable_operation(
- &self,
- request_id: &DurableRequestId,
- ) -> Result<Option<DurableAccountOperation>, SafeError> {
- query_durable_operation(&self.connection(), request_id)
- }
-
- fn advance_durable_operation(
- &self,
- request_id: &DurableRequestId,
- expected_phase: DurableOperationPhase,
- next_phase: DurableOperationPhase,
- updated_at: UnixTimestamp,
- diagnostic: Option<OperationDiagnostic>,
- ) -> Result<DurableAccountOperation, SafeError> {
- let mut connection = self.connection();
- let transaction = connection.transaction().map_err(|_| storage_error())?;
- let rows = transaction
- .execute(
- "UPDATE durable_operations SET phase = ?3, updated_at = ?4, diagnostic_code = ?5 \
- WHERE request_id = ?1 AND phase = ?2 AND terminal_outcome IS NULL",
- params![
- request_id.as_str(),
- encode_durable_phase(expected_phase),
- encode_durable_phase(next_phase),
- updated_at.as_seconds(),
- diagnostic.map(encode_diagnostic),
- ],
- )
- .map_err(|_| storage_error())?;
- if rows != 1 {
- return Err(operation_conflict());
- }
- let operation =
- query_durable_operation(&transaction, request_id)?.ok_or_else(corrupt_storage_error)?;
- transaction.commit().map_err(|_| storage_error())?;
- Ok(operation)
- }
-
- fn finalize_durable_operation(
- &self,
- request_id: &DurableRequestId,
- expected_phase: DurableOperationPhase,
- outcome: DurableTerminalOutcome,
- resulting_revision: Option<u64>,
- updated_at: UnixTimestamp,
- ) -> Result<DurableOperationReceipt, SafeError> {
- if let Some(existing) = self.load_durable_operation(request_id)?
- && let Some(receipt) = existing.terminal()
- {
- return if receipt.outcome() == outcome
- && receipt.resulting_revision() == resulting_revision
- {
- Ok(receipt.clone())
- } else {
- Err(operation_conflict())
- };
- }
- let resulting_revision = resulting_revision
- .map(i64::try_from)
- .transpose()
- .map_err(|_| operation_conflict())?;
- let rows = self
- .connection()
- .execute(
- "UPDATE durable_operations SET phase = 'finalized', terminal_outcome = ?3, \
- resulting_revision = ?4, updated_at = ?5 \
- WHERE request_id = ?1 AND phase = ?2 AND terminal_outcome IS NULL",
- params![
- request_id.as_str(),
- encode_durable_phase(expected_phase),
- encode_terminal_outcome(outcome),
- resulting_revision,
- updated_at.as_seconds(),
- ],
- )
- .map_err(|_| storage_error())?;
- if rows != 1 {
- return Err(operation_conflict());
- }
- self.load_durable_operation(request_id)?
- .and_then(|operation| operation.terminal().cloned())
- .ok_or_else(corrupt_storage_error)
- }
-
- fn list_unfinished_durable_operations(
- &self,
- ) -> Result<Vec<DurableAccountOperation>, SafeError> {
- let connection = self.connection();
- let mut statement = connection
- .prepare(&format!(
- "{DURABLE_OPERATION_SELECT} WHERE terminal_outcome IS NULL ORDER BY request_id ASC"
- ))
- .map_err(|_| storage_error())?;
- let rows = statement
- .query_map([], decode_durable_operation)
- .map_err(|_| storage_error())?;
- rows.map(|row| row.map_err(|_| corrupt_storage_error()))
- .collect()
- }
-}
-
-const DURABLE_OPERATION_SELECT: &str = "SELECT request_id, operation_kind, account_public_key, \
- expected_revision, phase, prior_selected_public_key, updated_at, diagnostic_code, \
- terminal_outcome, prior_binding_availability, resulting_revision FROM durable_operations";
-
-fn query_durable_operation(
- connection: &rusqlite::Connection,
- request_id: &DurableRequestId,
-) -> Result<Option<DurableAccountOperation>, SafeError> {
- connection
- .query_row(
- &format!("{DURABLE_OPERATION_SELECT} WHERE request_id = ?1"),
- [request_id.as_str()],
- decode_durable_operation,
- )
- .optional()
- .map_err(|_| corrupt_storage_error())
-}
-
-fn decode_durable_operation(row: &Row<'_>) -> rusqlite::Result<DurableAccountOperation> {
- let request_id =
- DurableRequestId::parse(row.get::<_, String>(0)?).map_err(|_| invalid_column(0))?;
- let kind = decode_durable_kind(row.get::<_, String>(1)?.as_str())?;
- let account =
- PublicKey::from_hex(row.get::<_, String>(2)?.as_str()).map_err(|_| invalid_column(2))?;
- let expected_revision = row
- .get::<_, Option<i64>>(3)?
- .map(|value| u64::try_from(value).map_err(|_| invalid_column(3)))
- .transpose()?;
- let phase = decode_durable_phase(row.get::<_, String>(4)?.as_str())?;
- let prior_selected = row
- .get::<_, Option<String>>(5)?
- .map(|value| PublicKey::from_hex(&value).map_err(|_| invalid_column(5)))
- .transpose()?;
- let updated_at = UnixTimestamp::from_seconds(row.get(6)?).ok_or_else(|| invalid_column(6))?;
- let diagnostic = row
- .get::<_, Option<String>>(7)?
- .map(|value| decode_diagnostic(&value))
- .transpose()?;
- let outcome = row
- .get::<_, Option<String>>(8)?
- .map(|value| decode_terminal_outcome(&value))
- .transpose()?;
- let prior_availability = row
- .get::<_, Option<String>>(9)?
- .map(|value| decode_binding_availability(&value))
- .transpose()?;
- let resulting_revision = row
- .get::<_, Option<i64>>(10)?
- .map(|value| u64::try_from(value).map_err(|_| invalid_column(10)))
- .transpose()?;
- let terminal = outcome.map(|outcome| {
- DurableOperationReceipt::new(request_id.clone(), account, outcome, resulting_revision)
- });
- Ok(DurableAccountOperation::new(
- request_id,
- kind,
- account,
- expected_revision,
- phase,
- OperationPriorState::new(prior_selected, prior_availability),
- updated_at,
- diagnostic,
- terminal,
- ))
-}
-
-impl OperationJournal for Database {
- fn begin_operation(
- &self,
- kind: AccountOperationKind,
- subject: PublicKey,
- updated_at: UnixTimestamp,
- ) -> Result<OperationId, SafeError> {
- let connection = self.connection();
- connection
- .execute(
- "INSERT INTO operation_journal (operation_kind, subject_pubkey, phase, \
- updated_at) VALUES (?1, ?2, 'intent_recorded', ?3)",
- params![encode_kind(kind), subject.to_hex(), updated_at.as_seconds()],
- )
- .map_err(|_| storage_error())?;
- let id =
- u64::try_from(connection.last_insert_rowid()).map_err(|_| corrupt_storage_error())?;
- Ok(OperationId::from_raw(id))
- }
-
- fn update_operation(
- &self,
- id: OperationId,
- phase: AccountOperationPhase,
- updated_at: UnixTimestamp,
- diagnostic: Option<OperationDiagnostic>,
- ) -> Result<(), SafeError> {
- let encoded_id = i64::try_from(id.as_raw()).map_err(|_| corrupt_storage_error())?;
- match self.connection().execute(
- "UPDATE operation_journal SET phase = ?2, updated_at = ?3, diagnostic_code = ?4 \
- WHERE operation_id = ?1",
- params![
- encoded_id,
- encode_phase(phase),
- updated_at.as_seconds(),
- diagnostic.map(encode_diagnostic)
- ],
- ) {
- Ok(1) => Ok(()),
- Ok(0) => Err(operation_not_found()),
- Ok(_) | Err(_) => Err(storage_error()),
- }
- }
-
- fn list_pending_operations(&self) -> Result<Vec<PendingAccountOperation>, SafeError> {
- let connection = self.connection();
- let mut statement = connection
- .prepare(
- "SELECT operation_id, operation_kind, subject_pubkey, phase, updated_at, \
- diagnostic_code FROM operation_journal ORDER BY operation_id ASC",
- )
- .map_err(|_| storage_error())?;
- let rows = statement
- .query_map([], decode_operation)
- .map_err(|_| storage_error())?;
- rows.map(|row| row.map_err(|_| corrupt_storage_error()))
- .collect()
- }
-
- fn finalize_operation(&self, id: OperationId) -> Result<(), SafeError> {
- let encoded_id = i64::try_from(id.as_raw()).map_err(|_| corrupt_storage_error())?;
- self.connection()
- .execute(
- "DELETE FROM operation_journal WHERE operation_id = ?1",
- [encoded_id],
- )
- .map(|_| ())
- .map_err(|_| storage_error())
- }
-}
-
-fn decode_operation(row: &Row<'_>) -> rusqlite::Result<PendingAccountOperation> {
- let id = u64::try_from(row.get::<_, i64>(0)?).map_err(|_| invalid_column(0))?;
- let kind = decode_kind(row.get::<_, String>(1)?.as_str())?;
- let subject =
- PublicKey::from_hex(row.get::<_, String>(2)?.as_str()).map_err(|_| invalid_column(2))?;
- let phase = decode_phase(row.get::<_, String>(3)?.as_str())?;
- let updated_at = UnixTimestamp::from_seconds(row.get(4)?).ok_or_else(|| invalid_column(4))?;
- let diagnostic = row
- .get::<_, Option<String>>(5)?
- .map(|value| decode_diagnostic(&value))
- .transpose()?;
- Ok(PendingAccountOperation::new(
- OperationId::from_raw(id),
- kind,
- subject,
- phase,
- updated_at,
- diagnostic,
- ))
-}
-
-const fn encode_durable_kind(value: DurableOperationKind) -> &'static str {
- match value {
- DurableOperationKind::Create => "create",
- DurableOperationKind::Import => "import",
- DurableOperationKind::Repair => "repair",
- DurableOperationKind::Remove => "remove",
- }
-}
-
-fn decode_durable_kind(value: &str) -> rusqlite::Result<DurableOperationKind> {
- match value {
- "create" => Ok(DurableOperationKind::Create),
- "import" => Ok(DurableOperationKind::Import),
- "repair" => Ok(DurableOperationKind::Repair),
- "remove" => Ok(DurableOperationKind::Remove),
- _ => Err(invalid_column(1)),
- }
-}
-
-const fn encode_durable_phase(value: DurableOperationPhase) -> &'static str {
- match value {
- DurableOperationPhase::IntentRecorded => "intent_recorded",
- DurableOperationPhase::CredentialWritten => "credential_written",
- DurableOperationPhase::MetadataCommitted => "metadata_committed",
- DurableOperationPhase::SelectionCommitted => "selection_committed",
- DurableOperationPhase::CompensationPending => "compensation_pending",
- DurableOperationPhase::CredentialDeleted => "credential_deleted",
- DurableOperationPhase::MetadataDeleted => "metadata_deleted",
- DurableOperationPhase::Finalized => "finalized",
- }
-}
-
-fn decode_durable_phase(value: &str) -> rusqlite::Result<DurableOperationPhase> {
- match value {
- "intent_recorded" => Ok(DurableOperationPhase::IntentRecorded),
- "credential_written" => Ok(DurableOperationPhase::CredentialWritten),
- "metadata_committed" => Ok(DurableOperationPhase::MetadataCommitted),
- "selection_committed" => Ok(DurableOperationPhase::SelectionCommitted),
- "compensation_pending" => Ok(DurableOperationPhase::CompensationPending),
- "credential_deleted" => Ok(DurableOperationPhase::CredentialDeleted),
- "metadata_deleted" => Ok(DurableOperationPhase::MetadataDeleted),
- "finalized" => Ok(DurableOperationPhase::Finalized),
- _ => Err(invalid_column(4)),
- }
-}
-
-const fn encode_terminal_outcome(value: DurableTerminalOutcome) -> &'static str {
- match value {
- DurableTerminalOutcome::Completed => "completed",
- DurableTerminalOutcome::Cancelled => "cancelled",
- DurableTerminalOutcome::Failed => "failed",
- }
-}
-
-fn decode_terminal_outcome(value: &str) -> rusqlite::Result<DurableTerminalOutcome> {
- match value {
- "completed" => Ok(DurableTerminalOutcome::Completed),
- "cancelled" => Ok(DurableTerminalOutcome::Cancelled),
- "failed" => Ok(DurableTerminalOutcome::Failed),
- _ => Err(invalid_column(8)),
- }
-}
-
-const fn encode_binding_availability(value: BindingAvailability) -> &'static str {
- match value {
- BindingAvailability::Available => "available",
- BindingAvailability::CredentialMissing => "credential_missing",
- BindingAvailability::StoreUnavailable => "store_unavailable",
- }
-}
-
-fn decode_binding_availability(value: &str) -> rusqlite::Result<BindingAvailability> {
- match value {
- "available" => Ok(BindingAvailability::Available),
- "credential_missing" => Ok(BindingAvailability::CredentialMissing),
- "store_unavailable" => Ok(BindingAvailability::StoreUnavailable),
- _ => Err(invalid_column(9)),
- }
-}
-
-const fn encode_kind(value: AccountOperationKind) -> &'static str {
- match value {
- AccountOperationKind::Add => "add",
- AccountOperationKind::Import => "import",
- AccountOperationKind::Remove => "remove",
- }
-}
-
-fn decode_kind(value: &str) -> rusqlite::Result<AccountOperationKind> {
- match value {
- "add" => Ok(AccountOperationKind::Add),
- "import" => Ok(AccountOperationKind::Import),
- "remove" => Ok(AccountOperationKind::Remove),
- _ => Err(invalid_column(1)),
- }
-}
-
-const fn encode_phase(value: AccountOperationPhase) -> &'static str {
- match value {
- AccountOperationPhase::IntentRecorded => "intent_recorded",
- AccountOperationPhase::CredentialWritten => "credential_written",
- AccountOperationPhase::MetadataCommitted => "metadata_committed",
- AccountOperationPhase::CompensationPending => "compensation_pending",
- AccountOperationPhase::CredentialDeleted => "credential_deleted",
- AccountOperationPhase::MetadataDeleted => "metadata_deleted",
- }
-}
-
-fn decode_phase(value: &str) -> rusqlite::Result<AccountOperationPhase> {
- match value {
- "intent_recorded" => Ok(AccountOperationPhase::IntentRecorded),
- "credential_written" => Ok(AccountOperationPhase::CredentialWritten),
- "metadata_committed" => Ok(AccountOperationPhase::MetadataCommitted),
- "compensation_pending" => Ok(AccountOperationPhase::CompensationPending),
- "credential_deleted" => Ok(AccountOperationPhase::CredentialDeleted),
- "metadata_deleted" => Ok(AccountOperationPhase::MetadataDeleted),
- _ => Err(invalid_column(3)),
- }
-}
-
-const fn encode_diagnostic(value: OperationDiagnostic) -> &'static str {
- match value {
- OperationDiagnostic::StorageUnavailable => "storage_unavailable",
- OperationDiagnostic::KeyringUnavailable => "keyring_unavailable",
- OperationDiagnostic::CredentialMissing => "credential_missing",
- OperationDiagnostic::CompensationFailed => "compensation_failed",
- OperationDiagnostic::Conflict => "conflict",
- OperationDiagnostic::Expired => "expired",
- }
-}
-
-fn decode_diagnostic(value: &str) -> rusqlite::Result<OperationDiagnostic> {
- match value {
- "storage_unavailable" => Ok(OperationDiagnostic::StorageUnavailable),
- "keyring_unavailable" => Ok(OperationDiagnostic::KeyringUnavailable),
- "credential_missing" => Ok(OperationDiagnostic::CredentialMissing),
- "compensation_failed" => Ok(OperationDiagnostic::CompensationFailed),
- "conflict" => Ok(OperationDiagnostic::Conflict),
- "expired" => Ok(OperationDiagnostic::Expired),
- _ => Err(invalid_column(5)),
- }
-}
-
-fn invalid_column(index: usize) -> rusqlite::Error {
- rusqlite::Error::InvalidColumnType(
- index,
- "account operation journal".to_owned(),
- rusqlite::types::Type::Text,
- )
-}
-
-const fn storage_error() -> SafeError {
- SafeError::new(
- SafeErrorCode::StorageUnavailable,
- SafeMessage::new("The account recovery journal is unavailable."),
- )
-}
-
-const fn corrupt_storage_error() -> SafeError {
- SafeError::new(
- SafeErrorCode::StorageCorrupt,
- SafeMessage::new("The account recovery journal could not be read."),
- )
-}
-
-const fn operation_not_found() -> SafeError {
- SafeError::new(
- SafeErrorCode::PendingOperationRecoveryRequired,
- SafeMessage::new("The account recovery operation was not found."),
- )
-}
-
-const fn operation_conflict() -> SafeError {
- SafeError::new(
- SafeErrorCode::InvalidApplicationState,
- SafeMessage::new("The durable account operation conflicts with existing state."),
- )
-}
-
-#[cfg(test)]
-mod tests {
- use radroots_studio_application::{
- AccountOperationKind, AccountOperationPhase, DurableOperationKind, DurableOperationPhase,
- DurableOperationRepository, DurableOperationStart, DurableRequestId,
- DurableTerminalOutcome, OperationDiagnostic, OperationJournal, OperationPriorState,
- };
- use radroots_studio_domain::{BindingAvailability, PublicKey, UnixTimestamp};
-
- use crate::Database;
-
- #[test]
- fn journal_creates_advances_loads_and_finalizes_pending_operations() {
- let database = Database::in_memory().expect("database");
- let subject = PublicKey::from_bytes([7; 32]);
- let id = database
- .begin_operation(
- AccountOperationKind::Import,
- subject,
- UnixTimestamp::from_seconds(10).expect("time"),
- )
- .expect("begin");
- database
- .update_operation(
- id,
- AccountOperationPhase::CompensationPending,
- UnixTimestamp::from_seconds(11).expect("time"),
- Some(OperationDiagnostic::KeyringUnavailable),
- )
- .expect("advance");
-
- let pending = database.list_pending_operations().expect("pending");
- assert_eq!(pending.len(), 1);
- assert_eq!(pending[0].subject(), subject);
- assert_eq!(pending[0].kind(), AccountOperationKind::Import);
- assert_eq!(
- pending[0].phase(),
- AccountOperationPhase::CompensationPending
- );
- assert_eq!(
- pending[0].diagnostic(),
- Some(OperationDiagnostic::KeyringUnavailable)
- );
-
- database.finalize_operation(id).expect("finalize");
- assert!(
- database
- .list_pending_operations()
- .expect("pending")
- .is_empty()
- );
- }
-
- #[test]
- fn journal_schema_and_rows_exclude_secret_payload_columns() {
- let database = Database::in_memory().expect("database");
- database
- .begin_operation(
- AccountOperationKind::Remove,
- PublicKey::from_bytes([8; 32]),
- UnixTimestamp::from_seconds(12).expect("time"),
- )
- .expect("begin");
- let connection = database.connection();
- let schema: String = connection
- .query_row(
- "SELECT sql FROM sqlite_master WHERE name = 'operation_journal'",
- [],
- |row| row.get(0),
- )
- .expect("schema");
- assert!(!schema.contains("secret"));
- assert!(!schema.contains("payload"));
- }
-
- #[test]
- fn durable_repository_replays_matching_requests_and_retains_terminal_receipts() {
- let database = Database::in_memory().expect("database");
- let request = DurableRequestId::parse("import:test:1").expect("request");
- let account = PublicKey::from_bytes([9; 32]);
- let prior = OperationPriorState::new(
- Some(PublicKey::from_bytes([8; 32])),
- Some(BindingAvailability::CredentialMissing),
- );
- let started = database
- .begin_durable_operation(
- &request,
- DurableOperationKind::Repair,
- account,
- Some(4),
- prior,
- UnixTimestamp::from_seconds(10).expect("time"),
- )
- .expect("begin");
- assert!(matches!(started, DurableOperationStart::Started(_)));
- let replay = database
- .begin_durable_operation(
- &request,
- DurableOperationKind::Repair,
- account,
- Some(4),
- prior,
- UnixTimestamp::from_seconds(11).expect("time"),
- )
- .expect("replay");
- assert!(matches!(replay, DurableOperationStart::Existing(_)));
- assert!(
- database
- .begin_durable_operation(
- &request,
- DurableOperationKind::Remove,
- account,
- Some(4),
- prior,
- UnixTimestamp::from_seconds(11).expect("time"),
- )
- .is_err()
- );
- database
- .advance_durable_operation(
- &request,
- DurableOperationPhase::IntentRecorded,
- DurableOperationPhase::CredentialWritten,
- UnixTimestamp::from_seconds(12).expect("time"),
- None,
- )
- .expect("advance");
- let receipt = database
- .finalize_durable_operation(
- &request,
- DurableOperationPhase::CredentialWritten,
- DurableTerminalOutcome::Completed,
- Some(5),
- UnixTimestamp::from_seconds(13).expect("time"),
- )
- .expect("finalize");
- assert_eq!(receipt.resulting_revision(), Some(5));
- assert_eq!(
- database
- .finalize_durable_operation(
- &request,
- DurableOperationPhase::CredentialWritten,
- DurableTerminalOutcome::Completed,
- Some(5),
- UnixTimestamp::from_seconds(14).expect("time"),
- )
- .expect("receipt replay"),
- receipt
- );
- assert!(
- database
- .list_unfinished_durable_operations()
- .expect("unfinished")
- .is_empty()
- );
- }
-}
diff --git a/core/crates/storage/src/lib.rs b/core/crates/storage/src/lib.rs
@@ -1,15 +0,0 @@
-#![doc = "Radroots Studio persistence adapters."]
-
-pub mod account_namespace;
-pub mod accounts;
-pub mod application_adapter;
-pub mod db;
-pub mod journal;
-pub mod os_keyring;
-pub mod profiles;
-pub mod runtime_actor;
-
-pub use application_adapter::PersistentAppCore;
-pub use db::{CURRENT_SCHEMA_VERSION, Database};
-pub use os_keyring::{CREDENTIAL_SERVICE, OsKeyringSecretStore};
-pub use runtime_actor::RuntimeActorHandle;
diff --git a/core/crates/storage/src/os_keyring.rs b/core/crates/storage/src/os_keyring.rs
@@ -1,131 +0,0 @@
-use std::sync::{Mutex, MutexGuard};
-
-use keyring::{Entry, Error as KeyringError};
-use radroots_studio_application::SecretStore;
-use radroots_studio_domain::{PublicKey, SafeError, SafeErrorCode, SafeMessage, SecretKeyInput};
-use zeroize::Zeroizing;
-
-pub const CREDENTIAL_SERVICE: &str = "org.radroots.studio.nostr";
-
-#[derive(Default)]
-pub struct OsKeyringSecretStore {
- operation_lock: Mutex<()>,
-}
-
-impl OsKeyringSecretStore {
- fn entry(public_key: PublicKey) -> Result<Entry, SafeError> {
- Entry::new(CREDENTIAL_SERVICE, &public_key.to_hex()).map_err(|_| keyring_unavailable())
- }
-
- fn operation(&self) -> MutexGuard<'_, ()> {
- self.operation_lock
- .lock()
- .unwrap_or_else(std::sync::PoisonError::into_inner)
- }
-}
-
-impl SecretStore for OsKeyringSecretStore {
- fn put(&self, public_key: PublicKey, secret: SecretKeyInput) -> Result<(), SafeError> {
- let _operation = self.operation();
- let entry = Self::entry(public_key)?;
- match entry.get_password() {
- Ok(password) => {
- drop(Zeroizing::new(password));
- return Err(credential_exists());
- }
- Err(KeyringError::NoEntry) => {}
- Err(_) => return Err(keyring_unavailable()),
- }
- secret
- .with_exposed_secret(|value| entry.set_password(value))
- .map_err(|_| keyring_unavailable())
- }
-
- fn load(&self, public_key: PublicKey) -> Result<SecretKeyInput, SafeError> {
- let _operation = self.operation();
- let password = Self::entry(public_key)?
- .get_password()
- .map_err(|error| map_read_error(&error))?;
- SecretKeyInput::parse(password)
- }
-
- fn contains(&self, public_key: PublicKey) -> Result<bool, SafeError> {
- let _operation = self.operation();
- match Self::entry(public_key)?.get_password() {
- Ok(password) => {
- drop(Zeroizing::new(password));
- Ok(true)
- }
- Err(KeyringError::NoEntry) => Ok(false),
- Err(_) => Err(keyring_unavailable()),
- }
- }
-
- fn delete(&self, public_key: PublicKey) -> Result<(), SafeError> {
- let _operation = self.operation();
- Self::entry(public_key)?
- .delete_credential()
- .map_err(|error| map_read_error(&error))
- }
-}
-
-const fn map_read_error(error: &KeyringError) -> SafeError {
- match error {
- KeyringError::NoEntry => credential_missing(),
- _ => keyring_unavailable(),
- }
-}
-
-const fn credential_exists() -> SafeError {
- SafeError::new(
- SafeErrorCode::AccountAlreadyExists,
- SafeMessage::new("The Nostr account credential already exists."),
- )
-}
-
-const fn credential_missing() -> SafeError {
- SafeError::new(
- SafeErrorCode::CredentialMissing,
- SafeMessage::new("The Nostr account credential is missing."),
- )
-}
-
-const fn keyring_unavailable() -> SafeError {
- SafeError::new(
- SafeErrorCode::KeyringUnavailable,
- SafeMessage::new("The operating system credential store is unavailable."),
- )
-}
-
-#[cfg(test)]
-mod tests {
- use radroots_studio_application::SecretStore;
- use radroots_studio_domain::{PublicKey, SecretKeyInput};
-
- use super::{CREDENTIAL_SERVICE, OsKeyringSecretStore};
-
- #[test]
- fn keyring_coordinates_are_stable_and_public() {
- let public_key = PublicKey::from_bytes([0xab; 32]);
- assert_eq!(CREDENTIAL_SERVICE, "org.radroots.studio.nostr");
- assert_eq!(public_key.to_hex(), "ab".repeat(32));
- }
-
- #[test]
- #[ignore = "mutates the current user's operating-system credential store"]
- fn real_keyring_smoke_round_trips_and_deletes() {
- let store = OsKeyringSecretStore::default();
- let public_key = PublicKey::from_bytes([0xcd; 32]);
- let _ = store.delete(public_key);
- store
- .put(
- public_key,
- SecretKeyInput::parse("11".repeat(32)).expect("secret"),
- )
- .expect("keyring put");
- assert!(store.contains(public_key).expect("keyring contains"));
- let loaded = store.load(public_key).expect("keyring load");
- assert_eq!(loaded.with_exposed_secret(str::len), 64);
- store.delete(public_key).expect("keyring delete");
- }
-}
diff --git a/core/crates/storage/src/profiles.rs b/core/crates/storage/src/profiles.rs
@@ -1,250 +0,0 @@
-use radroots_studio_application::{CachedProfile, ProfileRefreshStatus, ProfileRepository};
-use radroots_studio_domain::{
- EventId, Kind0ProfileCandidate, ProfileMetadata, PublicKey, SafeError, SafeErrorCode,
- SafeMessage, UnixTimestamp,
-};
-use rusqlite::{OptionalExtension, Row, params};
-
-use crate::Database;
-
-impl ProfileRepository for Database {
- fn load_profile(&self, public_key: PublicKey) -> Result<Option<CachedProfile>, SafeError> {
- self.connection()
- .query_row(
- "SELECT event_id, event_created_at, name, display_name, nip05, about, picture, \
- refreshed_at, refresh_status FROM profile_cache_v6 WHERE subject_public_key = ?1",
- [public_key.to_hex()],
- |row| decode_profile(row, public_key),
- )
- .optional()
- .map_err(|_| corrupt_storage_error())
- }
-
- fn save_profile(&self, profile: &CachedProfile) -> Result<(), SafeError> {
- let candidate = profile.candidate();
- let metadata = candidate.metadata();
- self.connection()
- .execute(
- "INSERT INTO profile_cache_v6 (subject_public_key, event_id, event_created_at, name, \
- display_name, nip05, about, picture, refreshed_at, refresh_status) \
- VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10) \
- ON CONFLICT(subject_public_key) DO UPDATE SET \
- event_id = excluded.event_id, event_created_at = excluded.event_created_at, \
- name = excluded.name, display_name = excluded.display_name, nip05 = excluded.nip05, \
- about = excluded.about, picture = excluded.picture, \
- refreshed_at = excluded.refreshed_at, refresh_status = excluded.refresh_status \
- WHERE excluded.event_created_at > profile_cache_v6.event_created_at \
- OR (excluded.event_created_at = profile_cache_v6.event_created_at \
- AND excluded.event_id < profile_cache_v6.event_id)",
- params![
- candidate.author().to_hex(),
- candidate.event_id().to_hex(),
- candidate.created_at().as_seconds(),
- metadata.name(),
- metadata.display_name(),
- metadata.nip05(),
- metadata.about(),
- metadata.picture(),
- profile.refreshed_at().as_seconds(),
- encode_refresh_status(profile.refresh_status()),
- ],
- )
- .map(|_| ())
- .map_err(|_| storage_error())
- }
-
- fn record_refresh_status(
- &self,
- public_key: PublicKey,
- refreshed_at: UnixTimestamp,
- status: ProfileRefreshStatus,
- ) -> Result<(), SafeError> {
- self.connection()
- .execute(
- "UPDATE profile_cache_v6 SET refreshed_at = ?2, refresh_status = ?3 \
- WHERE subject_public_key = ?1",
- params![
- public_key.to_hex(),
- refreshed_at.as_seconds(),
- encode_refresh_status(status)
- ],
- )
- .map(|_| ())
- .map_err(|_| storage_error())
- }
-
- fn remove_profile(&self, public_key: PublicKey) -> Result<(), SafeError> {
- self.connection()
- .execute(
- "DELETE FROM profile_cache_v6 WHERE subject_public_key = ?1",
- [public_key.to_hex()],
- )
- .map(|_| ())
- .map_err(|_| storage_error())
- }
-}
-
-fn decode_profile(row: &Row<'_>, author: PublicKey) -> rusqlite::Result<CachedProfile> {
- let event_id =
- EventId::from_hex(row.get::<_, String>(0)?.as_str()).map_err(|_| invalid_column(0))?;
- let created_at = UnixTimestamp::from_seconds(row.get(1)?).ok_or_else(|| invalid_column(1))?;
- let metadata = ProfileMetadata::new(
- row.get(2)?,
- row.get(3)?,
- row.get(4)?,
- row.get(5)?,
- row.get(6)?,
- )
- .map_err(|_| invalid_column(2))?;
- let refreshed_at = UnixTimestamp::from_seconds(row.get(7)?).ok_or_else(|| invalid_column(7))?;
- let refresh_status = decode_refresh_status(row.get::<_, String>(8)?.as_str())?;
- Ok(CachedProfile::new(
- Kind0ProfileCandidate::new(event_id, author, created_at, metadata),
- refreshed_at,
- refresh_status,
- ))
-}
-
-const fn encode_refresh_status(status: ProfileRefreshStatus) -> &'static str {
- match status {
- ProfileRefreshStatus::Success => "success",
- ProfileRefreshStatus::Offline => "offline",
- ProfileRefreshStatus::InvalidData => "invalid_data",
- }
-}
-
-fn decode_refresh_status(value: &str) -> rusqlite::Result<ProfileRefreshStatus> {
- match value {
- "success" => Ok(ProfileRefreshStatus::Success),
- "offline" => Ok(ProfileRefreshStatus::Offline),
- "invalid_data" => Ok(ProfileRefreshStatus::InvalidData),
- _ => Err(invalid_column(8)),
- }
-}
-
-fn invalid_column(index: usize) -> rusqlite::Error {
- rusqlite::Error::InvalidColumnType(
- index,
- "cached Nostr profile".to_owned(),
- rusqlite::types::Type::Text,
- )
-}
-
-const fn storage_error() -> SafeError {
- SafeError::new(
- SafeErrorCode::StorageUnavailable,
- SafeMessage::new("The profile cache is unavailable."),
- )
-}
-
-const fn corrupt_storage_error() -> SafeError {
- SafeError::new(
- SafeErrorCode::StorageCorrupt,
- SafeMessage::new("The profile cache could not be read."),
- )
-}
-
-#[cfg(test)]
-mod tests {
- use radroots_studio_application::{
- AccountRepository, CachedProfile, ProfileRefreshStatus, ProfileRepository,
- };
- use radroots_studio_domain::{
- AccountCreatedAt, AccountIdentity, AccountSummary, BindingAvailability, EventId,
- Kind0ProfileCandidate, LocalSignerBinding, ProfileMetadata, PublicKey, UnixTimestamp,
- };
-
- use crate::Database;
-
- fn account(public_key: PublicKey) -> AccountSummary {
- AccountSummary::new(
- AccountIdentity::derive(public_key).expect("identity"),
- LocalSignerBinding::new(public_key, BindingAvailability::Available),
- None,
- AccountCreatedAt::new(UnixTimestamp::from_seconds(1).expect("time")),
- None,
- )
- .expect("account")
- }
-
- fn profile(public_key: PublicKey, id: u8, created_at: i64, name: &str) -> CachedProfile {
- CachedProfile::new(
- Kind0ProfileCandidate::new(
- EventId::from_bytes([id; 32]),
- public_key,
- UnixTimestamp::from_seconds(created_at).expect("time"),
- ProfileMetadata::new(Some(name.to_owned()), None, None, None, None)
- .expect("metadata"),
- ),
- UnixTimestamp::from_seconds(created_at + 1).expect("refresh time"),
- ProfileRefreshStatus::Success,
- )
- }
-
- #[test]
- fn profile_cache_round_trips_and_records_refresh_status() {
- let database = Database::in_memory().expect("database");
- let public_key = PublicKey::from_bytes([1; 32]);
- database
- .insert_account(&account(public_key))
- .expect("account");
- database
- .save_profile(&profile(public_key, 1, 10, "Farm"))
- .expect("save profile");
- database
- .record_refresh_status(
- public_key,
- UnixTimestamp::from_seconds(20).expect("time"),
- ProfileRefreshStatus::Offline,
- )
- .expect("record status");
-
- let loaded = database
- .load_profile(public_key)
- .expect("load profile")
- .expect("cached profile");
- assert_eq!(loaded.candidate().metadata().name(), Some("Farm"));
- assert_eq!(loaded.refreshed_at().as_seconds(), 20);
- assert_eq!(loaded.refresh_status(), ProfileRefreshStatus::Offline);
- }
-
- #[test]
- fn profile_cache_keeps_newest_then_lowest_event_id() {
- let database = Database::in_memory().expect("database");
- let public_key = PublicKey::from_bytes([2; 32]);
- database
- .insert_account(&account(public_key))
- .expect("account");
- database
- .save_profile(&profile(public_key, 9, 20, "High ID"))
- .expect("initial");
- database
- .save_profile(&profile(public_key, 1, 20, "Low ID"))
- .expect("equal newer candidate");
- database
- .save_profile(&profile(public_key, 0, 10, "Older"))
- .expect("older candidate");
-
- let loaded = database
- .load_profile(public_key)
- .expect("load")
- .expect("profile");
- assert_eq!(loaded.candidate().metadata().name(), Some("Low ID"));
- assert_eq!(loaded.candidate().event_id(), EventId::from_bytes([1; 32]));
- }
-
- #[test]
- fn profile_cache_cascades_with_account_removal() {
- let database = Database::in_memory().expect("database");
- let public_key = PublicKey::from_bytes([3; 32]);
- database
- .insert_account(&account(public_key))
- .expect("account");
- database
- .save_profile(&profile(public_key, 1, 10, "Farm"))
- .expect("profile");
- database.remove_account(public_key).expect("remove account");
-
- assert_eq!(database.load_profile(public_key).expect("load"), None);
- }
-}
diff --git a/core/crates/storage/src/runtime_actor.rs b/core/crates/storage/src/runtime_actor.rs
@@ -1,1693 +0,0 @@
-use std::collections::BTreeMap;
-use std::num::{NonZeroU64, NonZeroUsize};
-use std::path::Path;
-use std::sync::atomic::{AtomicU64, Ordering};
-use std::sync::{Arc, Mutex};
-use std::time::{Duration, Instant};
-
-use radroots_studio_application::{
- ActorMailbox, AppSnapshot, ChangeSubscriptionId, Clock, CommandContext, CommandEnvelope,
- CommandReceipt, CommandResult, CommandSubmission, ForegroundSessionBinding,
- GenerateAccountReceipt, GeneratedKeyRecoveryHandle, GeneratedKeyStage, ImportAccountReceipt,
- LifecycleGate, NostrClient, OrderedSnapshotChanges, ProfileRefreshPlan, RecoveryStageId,
- RelayConfiguration, RemovalConfirmationToken, RequestId, RuntimeCommandClass, RuntimeLifecycle,
- SecretStore, SessionGeneration, SnapshotChange, SnapshotChangeReceiver, SnapshotRevision,
- TaskCorrelation,
-};
-use radroots_studio_domain::{
- AccountIdentity, BindingAvailability, Kind0ProfileCandidate, LocalSignerBinding, PublicKey,
- SafeError, SafeErrorCode, SafeMessage, SecretKeyInput,
-};
-use tokio::runtime::Handle;
-use tokio::sync::{mpsc, oneshot};
-
-use crate::PersistentAppCore;
-
-const DEFAULT_COMMAND_TIMEOUT: Duration = Duration::from_secs(30);
-const DEFAULT_TASK_CAPACITY: usize = 64;
-
-enum RuntimeCommand {
- Snapshot,
- GenerateAccount,
- BeginGeneratedKeyStage,
- AcknowledgeGeneratedKeyStage(RecoveryStageId),
- CancelGeneratedKeyStage,
- ImportSecretKey {
- input: SecretKeyInput,
- durable_request: Option<radroots_studio_application::DurableRequestId>,
- durable_expected_revision: Option<u64>,
- },
- SelectAccount(PublicKey),
- ActivateAccount(PublicKey),
- SignOut,
- RefreshActiveProfile,
- RequestAccountRemoval(PublicKey),
- ConfirmAccountRemoval(RemovalConfirmationToken),
- SubscribeChanges(NonZeroUsize),
- UnsubscribeChanges(ChangeSubscriptionId),
- Close,
-}
-
-enum RuntimeCommandValue {
- Snapshot(Box<AppSnapshot>),
- Generated(GenerateAccountReceipt),
- GeneratedKeyStage(GeneratedKeyRecoveryHandle),
- GeneratedKeyStageCancelled(bool),
- Imported(ImportAccountReceipt),
- RemovalRequest(RemovalConfirmationToken),
- Subscription(RuntimeChangeSubscription),
- Unsubscribed(bool),
- Closed,
-}
-
-impl RuntimeCommand {
- const fn class(&self) -> RuntimeCommandClass {
- match self {
- Self::Snapshot | Self::SubscribeChanges(_) | Self::UnsubscribeChanges(_) => {
- RuntimeCommandClass::Observe
- }
- Self::GenerateAccount
- | Self::BeginGeneratedKeyStage
- | Self::AcknowledgeGeneratedKeyStage(_)
- | Self::ImportSecretKey { .. }
- | Self::ActivateAccount(_)
- | Self::ConfirmAccountRemoval(_) => RuntimeCommandClass::UseCredential,
- Self::SelectAccount(_)
- | Self::SignOut
- | Self::RequestAccountRemoval(_)
- | Self::CancelGeneratedKeyStage => RuntimeCommandClass::MutateLocalState,
- Self::RefreshActiveProfile => RuntimeCommandClass::UseRelay,
- Self::Close => RuntimeCommandClass::Shutdown,
- }
- }
-}
-
-struct RuntimeActor {
- adapter: Arc<PersistentAppCore>,
- secrets: Arc<dyn SecretStore>,
- clock: Arc<dyn Clock>,
- nostr: Arc<dyn NostrClient>,
- lifecycle: Arc<Mutex<LifecycleGate>>,
- runtime: Handle,
- session_generation: SessionGeneration,
- published_session_generation: Arc<AtomicU64>,
- profile_tasks: BTreeMap<RequestId, PendingProfileTask>,
- changes: OrderedSnapshotChanges,
- published_foreground_session: Arc<Mutex<Option<ForegroundSessionBinding>>>,
- durable_request_namespace: String,
- generated_key_stage: GeneratedKeyStage,
-}
-
-struct PendingProfileTask {
- correlation: TaskCorrelation,
- plan: ProfileRefreshPlan,
- reply: oneshot::Sender<CommandReceipt<RuntimeCommandValue>>,
- handle: tokio::task::JoinHandle<()>,
-}
-
-struct ProfileCompletion {
- request_id: RequestId,
- result: Result<Option<Kind0ProfileCandidate>, SafeError>,
-}
-
-#[derive(Clone)]
-pub struct RuntimeActorHandle {
- mailbox: ActorMailbox<RuntimeCommand, RuntimeCommandValue>,
- adapter: Arc<PersistentAppCore>,
- lifecycle: Arc<Mutex<LifecycleGate>>,
- runtime: Handle,
- next_request: Arc<AtomicU64>,
- session_generation: Arc<AtomicU64>,
- foreground_session: Arc<Mutex<Option<ForegroundSessionBinding>>>,
-}
-
-pub struct RuntimeChangeSubscription {
- id: ChangeSubscriptionId,
- receiver: SnapshotChangeReceiver,
-}
-
-impl RuntimeChangeSubscription {
- #[must_use]
- pub const fn id(&self) -> ChangeSubscriptionId {
- self.id
- }
-
- pub async fn receive(&mut self) -> Option<SnapshotChange> {
- self.receiver.receive().await
- }
-}
-
-impl RuntimeActorHandle {
- /// Opens, migrates, recovers, and starts one actor-owned file-backed runtime.
- ///
- /// # Errors
- ///
- /// Returns a safe storage, recovery, or lifecycle error before the actor is
- /// published when opening cannot reach ready state.
- pub fn open(
- path: &Path,
- relay_configuration: RelayConfiguration,
- secrets: Arc<dyn SecretStore>,
- clock: Arc<dyn Clock>,
- nostr: Arc<dyn NostrClient>,
- capacity: NonZeroUsize,
- runtime: &Handle,
- ) -> Result<Self, SafeError> {
- Self::start(
- PersistentAppCore::open(path, relay_configuration)?,
- secrets,
- clock,
- nostr,
- capacity,
- runtime,
- )
- }
-
- /// Starts one isolated actor-owned in-memory runtime for tests.
- ///
- /// # Errors
- ///
- /// Returns a safe storage, recovery, or lifecycle error before publication.
- pub fn in_memory(
- relay_configuration: RelayConfiguration,
- secrets: Arc<dyn SecretStore>,
- clock: Arc<dyn Clock>,
- nostr: Arc<dyn NostrClient>,
- capacity: NonZeroUsize,
- runtime: &Handle,
- ) -> Result<Self, SafeError> {
- Self::start(
- PersistentAppCore::in_memory(relay_configuration)?,
- secrets,
- clock,
- nostr,
- capacity,
- runtime,
- )
- }
-
- fn start(
- adapter: PersistentAppCore,
- secrets: Arc<dyn SecretStore>,
- clock: Arc<dyn Clock>,
- nostr: Arc<dyn NostrClient>,
- capacity: NonZeroUsize,
- runtime: &Handle,
- ) -> Result<Self, SafeError> {
- let mut gate = LifecycleGate::opening();
- gate.begin_compatibility_check()?;
- gate.compatibility_accepted()?;
- gate.ownership_acquired()?;
- gate.migration_complete()?;
- adapter.bootstrap(secrets.as_ref(), clock.as_ref())?;
- gate.recovery_complete()?;
-
- let adapter = Arc::new(adapter);
- let lifecycle = Arc::new(Mutex::new(gate));
- let (mailbox, receiver) = ActorMailbox::bounded(capacity);
- let session_generation = Arc::new(AtomicU64::new(SessionGeneration::initial().value()));
- let foreground_session = Arc::new(Mutex::new(None));
- let changes = OrderedSnapshotChanges::new(adapter.core().snapshot());
- let durable_request_namespace = format!(
- "runtime:{}:{}",
- std::process::id(),
- std::time::SystemTime::now()
- .duration_since(std::time::UNIX_EPOCH)
- .map_or(0, |duration| duration.as_nanos())
- );
- let actor = RuntimeActor {
- adapter: Arc::clone(&adapter),
- secrets,
- clock,
- nostr,
- lifecycle: Arc::clone(&lifecycle),
- runtime: runtime.clone(),
- session_generation: SessionGeneration::initial(),
- published_session_generation: Arc::clone(&session_generation),
- profile_tasks: BTreeMap::new(),
- changes,
- published_foreground_session: Arc::clone(&foreground_session),
- durable_request_namespace,
- generated_key_stage: GeneratedKeyStage::default(),
- };
- drop(runtime.spawn(actor.run(receiver)));
- Ok(Self {
- mailbox,
- adapter,
- lifecycle,
- runtime: runtime.clone(),
- next_request: Arc::new(AtomicU64::new(1)),
- session_generation,
- foreground_session,
- })
- }
-
- #[must_use]
- pub fn lifecycle(&self) -> RuntimeLifecycle {
- self.lifecycle
- .lock()
- .unwrap_or_else(std::sync::PoisonError::into_inner)
- .lifecycle()
- }
-
- #[must_use]
- pub fn session_generation(&self) -> SessionGeneration {
- SessionGeneration::from_value(self.session_generation.load(Ordering::Acquire))
- }
-
- #[must_use]
- pub fn foreground_session(&self) -> Option<ForegroundSessionBinding> {
- self.foreground_session
- .lock()
- .unwrap_or_else(std::sync::PoisonError::into_inner)
- .clone()
- }
-
- #[must_use]
- pub fn snapshot(&self) -> AppSnapshot {
- self.adapter.core().snapshot()
- }
-
- /// Returns the ready snapshot through the actor command boundary.
- ///
- /// # Errors
- ///
- /// Returns a typed safe actor error.
- pub async fn bootstrap(&self) -> Result<AppSnapshot, SafeError> {
- Self::expect_snapshot(self.dispatch(RuntimeCommand::Snapshot, None).await?)
- }
-
- /// Generates one account through the serialized actor boundary.
- ///
- /// # Errors
- ///
- /// Returns a safe account, storage, keyring, timeout, or actor error.
- pub async fn generate_account(&self) -> Result<GenerateAccountReceipt, SafeError> {
- match self.dispatch(RuntimeCommand::GenerateAccount, None).await? {
- RuntimeCommandValue::Generated(receipt) => Ok(receipt),
- _ => Err(invalid_actor_response()),
- }
- }
-
- /// Begins the only actor-owned generated-key recovery stage.
- ///
- /// # Errors
- ///
- /// Returns a safe conflict, timeout, key-generation, or actor error.
- pub async fn begin_generated_key_stage(&self) -> Result<GeneratedKeyRecoveryHandle, SafeError> {
- match self
- .dispatch(RuntimeCommand::BeginGeneratedKeyStage, None)
- .await?
- {
- RuntimeCommandValue::GeneratedKeyStage(view) => Ok(view),
- _ => Err(invalid_actor_response()),
- }
- }
-
- /// Acknowledges recovery and commits the staged account and credential once.
- ///
- /// # Errors
- ///
- /// Returns a safe unavailable, conflict, keyring, storage, timeout, or actor error.
- pub async fn acknowledge_generated_key_stage(
- &self,
- id: RecoveryStageId,
- ) -> Result<AppSnapshot, SafeError> {
- let value = self
- .dispatch(RuntimeCommand::AcknowledgeGeneratedKeyStage(id), None)
- .await?;
- Self::expect_snapshot(value)
- }
-
- /// Cancels and zeroizes the active generated-key stage, if present.
- ///
- /// # Errors
- ///
- /// Returns a safe timeout or actor error.
- pub async fn cancel_generated_key_stage(&self) -> Result<bool, SafeError> {
- match self
- .dispatch(RuntimeCommand::CancelGeneratedKeyStage, None)
- .await?
- {
- RuntimeCommandValue::GeneratedKeyStageCancelled(cancelled) => Ok(cancelled),
- _ => Err(invalid_actor_response()),
- }
- }
-
- /// Imports one account through the serialized actor boundary.
- ///
- /// # Errors
- ///
- /// Returns a safe account, storage, keyring, timeout, or actor error.
- pub async fn import_secret_key(
- &self,
- input: SecretKeyInput,
- ) -> Result<ImportAccountReceipt, SafeError> {
- match self
- .dispatch(
- RuntimeCommand::ImportSecretKey {
- input,
- durable_request: None,
- durable_expected_revision: None,
- },
- None,
- )
- .await?
- {
- RuntimeCommandValue::Imported(receipt) => Ok(receipt),
- _ => Err(invalid_actor_response()),
- }
- }
-
- /// Imports or repairs with a caller-owned durable request and deadline.
- ///
- /// # Errors
- ///
- /// Returns a safe validation, conflict, timeout, persistence, or actor error.
- pub async fn import_secret_key_request(
- &self,
- request: radroots_studio_application::DurableRequestId,
- expected_revision: SnapshotRevision,
- input: SecretKeyInput,
- timeout: Duration,
- ) -> Result<ImportAccountReceipt, SafeError> {
- let raw_request = self.next_request.fetch_add(1, Ordering::Relaxed);
- let request_id = RequestId::new(raw_request).ok_or_else(request_space_exhausted)?;
- match self
- .dispatch_with_deadline(
- RuntimeCommand::ImportSecretKey {
- input,
- durable_request: Some(request),
- durable_expected_revision: Some(expected_revision.value()),
- },
- None,
- request_id,
- Instant::now() + timeout,
- )
- .await?
- {
- RuntimeCommandValue::Imported(receipt) => Ok(receipt),
- _ => Err(invalid_actor_response()),
- }
- }
-
- /// Selects one account through the serialized actor boundary.
- ///
- /// # Errors
- ///
- /// Returns a safe account, storage, timeout, or actor error.
- pub async fn select_account(&self, public_key: PublicKey) -> Result<AppSnapshot, SafeError> {
- let value = self
- .dispatch(RuntimeCommand::SelectAccount(public_key), None)
- .await?;
- Self::expect_snapshot(value)
- }
-
- /// Activates one account through the serialized actor boundary.
- ///
- /// # Errors
- ///
- /// Returns a safe account, credential, storage, timeout, or actor error.
- pub async fn activate_account(&self, public_key: PublicKey) -> Result<AppSnapshot, SafeError> {
- let value = self
- .dispatch(RuntimeCommand::ActivateAccount(public_key), None)
- .await?;
- Self::expect_snapshot(value)
- }
-
- /// Signs out through the serialized actor boundary.
- ///
- /// # Errors
- ///
- /// Returns a safe timeout or actor error.
- pub async fn sign_out(&self) -> Result<AppSnapshot, SafeError> {
- let value = self.dispatch(RuntimeCommand::SignOut, None).await?;
- Self::expect_snapshot(value)
- }
-
- /// Refreshes the active profile through the serialized actor boundary.
- ///
- /// # Errors
- ///
- /// Returns a safe relay, storage, timeout, or actor error.
- pub async fn refresh_active_profile(&self) -> Result<AppSnapshot, SafeError> {
- let value = self
- .dispatch(RuntimeCommand::RefreshActiveProfile, None)
- .await?;
- Self::expect_snapshot(value)
- }
-
- /// Creates one removal request through the serialized actor boundary.
- ///
- /// # Errors
- ///
- /// Returns a safe account, timeout, or actor error.
- pub async fn request_account_removal(
- &self,
- public_key: PublicKey,
- ) -> Result<RemovalConfirmationToken, SafeError> {
- match self
- .dispatch(RuntimeCommand::RequestAccountRemoval(public_key), None)
- .await?
- {
- RuntimeCommandValue::RemovalRequest(token) => Ok(token),
- _ => Err(invalid_actor_response()),
- }
- }
-
- /// Confirms one removal through the serialized actor boundary.
- ///
- /// # Errors
- ///
- /// Returns a safe account, credential, storage, timeout, or actor error.
- pub async fn confirm_account_removal(
- &self,
- token: RemovalConfirmationToken,
- ) -> Result<AppSnapshot, SafeError> {
- let value = self
- .dispatch(RuntimeCommand::ConfirmAccountRemoval(token), None)
- .await?;
- Self::expect_snapshot(value)
- }
-
- /// Closes command admission and cancels supervised work.
- ///
- /// # Errors
- ///
- /// Returns a safe timeout or actor error. Repeated calls return closed.
- pub async fn close(&self) -> Result<(), SafeError> {
- self.close_with_timeout(DEFAULT_COMMAND_TIMEOUT).await
- }
-
- /// Closes the runtime within the supplied command deadline.
- ///
- /// # Errors
- ///
- /// Returns a safe timeout or actor error. An expired queued close cannot
- /// later change runtime state.
- pub async fn close_with_timeout(&self, timeout: Duration) -> Result<(), SafeError> {
- let raw_request = self.next_request.fetch_add(1, Ordering::Relaxed);
- let request_id = RequestId::new(raw_request).ok_or_else(request_space_exhausted)?;
- match self
- .dispatch_with_deadline(
- RuntimeCommand::Close,
- None,
- request_id,
- Instant::now() + timeout,
- )
- .await?
- {
- RuntimeCommandValue::Closed => Ok(()),
- _ => Err(invalid_actor_response()),
- }
- }
-
- /// Atomically registers a bounded ordered change consumer with its initial snapshot.
- ///
- /// # Errors
- ///
- /// Returns a safe actor or subscription error.
- pub async fn subscribe_changes(
- &self,
- capacity: NonZeroUsize,
- ) -> Result<RuntimeChangeSubscription, SafeError> {
- match self
- .dispatch(RuntimeCommand::SubscribeChanges(capacity), None)
- .await?
- {
- RuntimeCommandValue::Subscription(subscription) => Ok(subscription),
- _ => Err(invalid_actor_response()),
- }
- }
-
- /// Removes a change consumer through the serialized actor boundary.
- ///
- /// # Errors
- ///
- /// Returns a safe actor error.
- pub async fn unsubscribe_changes(&self, id: ChangeSubscriptionId) -> Result<bool, SafeError> {
- match self
- .dispatch(RuntimeCommand::UnsubscribeChanges(id), None)
- .await?
- {
- RuntimeCommandValue::Unsubscribed(removed) => Ok(removed),
- _ => Err(invalid_actor_response()),
- }
- }
-
- async fn dispatch(
- &self,
- command: RuntimeCommand,
- expected_revision: Option<SnapshotRevision>,
- ) -> Result<RuntimeCommandValue, SafeError> {
- let raw_request = self.next_request.fetch_add(1, Ordering::Relaxed);
- let request_id = RequestId::new(raw_request).ok_or_else(request_space_exhausted)?;
- self.dispatch_with_deadline(
- command,
- expected_revision,
- request_id,
- Instant::now() + DEFAULT_COMMAND_TIMEOUT,
- )
- .await
- }
-
- async fn dispatch_with_deadline(
- &self,
- command: RuntimeCommand,
- expected_revision: Option<SnapshotRevision>,
- request_id: RequestId,
- deadline: Instant,
- ) -> Result<RuntimeCommandValue, SafeError> {
- let context = CommandContext::new(request_id, expected_revision, deadline);
- let receipt = match self.mailbox.submit(context, command) {
- CommandSubmission::Accepted(ticket) => {
- let remaining = deadline.saturating_duration_since(Instant::now());
- let waiting = self
- .runtime
- .spawn(async move { tokio::time::timeout(remaining, ticket.receipt()).await });
- match waiting.await {
- Ok(Ok(receipt)) => receipt,
- Ok(Err(_)) => CommandReceipt::new(request_id, CommandResult::TimedOut),
- Err(_) => CommandReceipt::new(request_id, CommandResult::Closed),
- }
- }
- CommandSubmission::Rejected(receipt) => receipt,
- };
- match receipt.into_result() {
- CommandResult::Completed(value) => Ok(value),
- CommandResult::Conflicted { .. } => Err(command_conflicted()),
- CommandResult::Rejected(_) => Err(command_rejected()),
- CommandResult::TimedOut => Err(command_timed_out()),
- CommandResult::Closed => Err(runtime_closed()),
- CommandResult::Failed(error) => Err(error),
- }
- }
-
- #[cfg(test)]
- async fn import_secret_key_with_timeout(
- &self,
- input: SecretKeyInput,
- timeout: Duration,
- ) -> Result<ImportAccountReceipt, SafeError> {
- let raw_request = self.next_request.fetch_add(1, Ordering::Relaxed);
- let request_id = RequestId::new(raw_request).ok_or_else(request_space_exhausted)?;
- match self
- .dispatch_with_deadline(
- RuntimeCommand::ImportSecretKey {
- input,
- durable_request: None,
- durable_expected_revision: None,
- },
- None,
- request_id,
- Instant::now() + timeout,
- )
- .await?
- {
- RuntimeCommandValue::Imported(receipt) => Ok(receipt),
- _ => Err(invalid_actor_response()),
- }
- }
-
- fn expect_snapshot(value: RuntimeCommandValue) -> Result<AppSnapshot, SafeError> {
- match value {
- RuntimeCommandValue::Snapshot(snapshot) => Ok(*snapshot),
- _ => Err(invalid_actor_response()),
- }
- }
-}
-
-impl RuntimeActor {
- async fn run(
- mut self,
- mut receiver: mpsc::Receiver<CommandEnvelope<RuntimeCommand, RuntimeCommandValue>>,
- ) {
- let (completion_sender, mut completions) = mpsc::channel(DEFAULT_TASK_CAPACITY);
- loop {
- tokio::select! {
- envelope = receiver.recv() => {
- let Some(envelope) = envelope else {
- break;
- };
- if !self.handle_command(envelope, &completion_sender) {
- break;
- }
- }
- completion = completions.recv(), if !self.profile_tasks.is_empty() => {
- if let Some(completion) = completion {
- self.complete_profile_task(completion);
- }
- }
- }
- }
- self.cancel_profile_tasks(None);
- }
-
- fn handle_command(
- &mut self,
- envelope: CommandEnvelope<RuntimeCommand, RuntimeCommandValue>,
- completion_sender: &mpsc::Sender<ProfileCompletion>,
- ) -> bool {
- let (context, command, reply) = envelope.into_parts();
- if let Some(result) = self.preflight(context, &command) {
- let _ = reply.send(CommandReceipt::new(context.request_id(), result));
- return true;
- }
- if matches!(command, RuntimeCommand::RefreshActiveProfile) {
- self.start_profile_task(context, reply, completion_sender.clone());
- return true;
- }
- if matches!(command, RuntimeCommand::Close) {
- let result = self.close_actor();
- let closed = matches!(result, CommandResult::Completed(_));
- let _ = reply.send(CommandReceipt::new(context.request_id(), result));
- return !closed;
- }
- let changes_session = matches!(
- command,
- RuntimeCommand::ActivateAccount(_)
- | RuntimeCommand::SignOut
- | RuntimeCommand::ConfirmAccountRemoval(_)
- );
- let begins_generated_recovery = matches!(&command, RuntimeCommand::BeginGeneratedKeyStage);
- let result = self.execute_sync(context, command);
- if begins_generated_recovery && matches!(&result, CommandResult::Completed(_)) {
- let snapshot = self.adapter.core().snapshot();
- self.cancel_profile_tasks(Some(&snapshot));
- }
- if changes_session && matches!(result, CommandResult::Completed(_)) {
- self.advance_session_generation();
- self.synchronize_foreground_session();
- }
- if matches!(result, CommandResult::Completed(_)) {
- self.changes.publish(self.adapter.core().snapshot());
- }
- let _ = reply.send(CommandReceipt::new(context.request_id(), result));
- true
- }
-
- fn preflight(
- &self,
- context: CommandContext,
- command: &RuntimeCommand,
- ) -> Option<CommandResult<RuntimeCommandValue>> {
- if context.is_expired(Instant::now()) {
- return Some(CommandResult::TimedOut);
- }
- let lifecycle = self
- .lifecycle
- .lock()
- .unwrap_or_else(std::sync::PoisonError::into_inner)
- .to_owned();
- if matches!(lifecycle.lifecycle(), RuntimeLifecycle::Closed) {
- return Some(CommandResult::Closed);
- }
- if !lifecycle.allows(command.class()) {
- return Some(CommandResult::Failed(command_unavailable()));
- }
- if self.generated_key_stage.pending().is_some()
- && !matches!(
- command,
- RuntimeCommand::Snapshot
- | RuntimeCommand::AcknowledgeGeneratedKeyStage(_)
- | RuntimeCommand::CancelGeneratedKeyStage
- | RuntimeCommand::SubscribeChanges(_)
- | RuntimeCommand::UnsubscribeChanges(_)
- | RuntimeCommand::Close
- )
- {
- return Some(CommandResult::Failed(generated_recovery_route_active()));
- }
- let current_revision = self.adapter.core().snapshot().revision();
- if context
- .expected_revision()
- .is_some_and(|expected| expected != current_revision)
- {
- return Some(CommandResult::Conflicted { current_revision });
- }
- None
- }
-
- fn execute_sync(
- &mut self,
- context: CommandContext,
- command: RuntimeCommand,
- ) -> CommandResult<RuntimeCommandValue> {
- let durable_request = radroots_studio_application::DurableRequestId::parse(format!(
- "{}:{}",
- self.durable_request_namespace,
- context.request_id().get()
- ));
- let expected_revision = context
- .expected_revision()
- .unwrap_or_else(|| self.adapter.core().snapshot().revision())
- .value();
- let result = match command {
- RuntimeCommand::Snapshot => Ok(RuntimeCommandValue::Snapshot(Box::new(
- self.adapter.core().snapshot(),
- ))),
- RuntimeCommand::GenerateAccount => durable_request.and_then(|request| {
- self.adapter
- .generate_account_durable(
- &request,
- expected_revision,
- self.secrets.as_ref(),
- self.clock.as_ref(),
- )
- .map(RuntimeCommandValue::Generated)
- }),
- RuntimeCommand::BeginGeneratedKeyStage => self
- .generated_key_stage
- .begin(
- RecoveryStageId::new(
- NonZeroU64::new(context.request_id().get())
- .expect("request IDs are always non-zero"),
- ),
- expected_revision,
- self.clock.now(),
- )
- .map(RuntimeCommandValue::GeneratedKeyStage),
- RuntimeCommand::AcknowledgeGeneratedKeyStage(id) => {
- durable_request.and_then(|request| self.commit_generated_key_stage(&request, id))
- }
- RuntimeCommand::CancelGeneratedKeyStage => Ok(
- RuntimeCommandValue::GeneratedKeyStageCancelled(self.generated_key_stage.cancel()),
- ),
- RuntimeCommand::ImportSecretKey {
- input,
- durable_request: caller_request,
- durable_expected_revision,
- } => self.import_secret_key_command(
- input,
- caller_request,
- durable_request,
- durable_expected_revision.unwrap_or(expected_revision),
- ),
- RuntimeCommand::SelectAccount(public_key) => self
- .adapter
- .select_account(public_key)
- .map(Box::new)
- .map(RuntimeCommandValue::Snapshot),
- RuntimeCommand::ActivateAccount(public_key) => self
- .adapter
- .activate_account(public_key, self.secrets.as_ref(), self.clock.as_ref())
- .map(Box::new)
- .map(RuntimeCommandValue::Snapshot),
- RuntimeCommand::SignOut => self
- .adapter
- .sign_out()
- .map(Box::new)
- .map(RuntimeCommandValue::Snapshot),
- RuntimeCommand::RequestAccountRemoval(public_key) => self
- .adapter
- .request_account_removal(public_key, self.clock.as_ref())
- .map(RuntimeCommandValue::RemovalRequest),
- RuntimeCommand::ConfirmAccountRemoval(token) => durable_request.and_then(|request| {
- self.adapter
- .confirm_account_removal_durable(
- &request,
- token,
- self.secrets.as_ref(),
- self.clock.as_ref(),
- )
- .map(Box::new)
- .map(RuntimeCommandValue::Snapshot)
- }),
- RuntimeCommand::SubscribeChanges(capacity) => self
- .changes
- .subscribe(capacity)
- .map(|(id, receiver)| {
- RuntimeCommandValue::Subscription(RuntimeChangeSubscription { id, receiver })
- })
- .ok_or_else(observer_registration_failed),
- RuntimeCommand::UnsubscribeChanges(id) => Ok(RuntimeCommandValue::Unsubscribed(
- self.changes.unsubscribe(id),
- )),
- RuntimeCommand::Close | RuntimeCommand::RefreshActiveProfile => {
- Err(invalid_actor_response())
- }
- };
- result.map_or_else(CommandResult::Failed, CommandResult::Completed)
- }
-
- fn commit_generated_key_stage(
- &mut self,
- request: &radroots_studio_application::DurableRequestId,
- id: RecoveryStageId,
- ) -> Result<RuntimeCommandValue, SafeError> {
- let staged = self.generated_key_stage.take(id, self.clock.now())?;
- self.adapter.commit_staged_generated_key(
- request,
- staged,
- self.secrets.as_ref(),
- self.clock.as_ref(),
- )?;
- Ok(RuntimeCommandValue::Snapshot(Box::new(
- self.adapter.core().snapshot(),
- )))
- }
-
- fn import_secret_key_command(
- &self,
- input: SecretKeyInput,
- caller_request: Option<radroots_studio_application::DurableRequestId>,
- fallback_request: Result<radroots_studio_application::DurableRequestId, SafeError>,
- expected_revision: u64,
- ) -> Result<RuntimeCommandValue, SafeError> {
- let request = caller_request.map_or(fallback_request, Ok)?;
- self.adapter
- .import_secret_key_durable(
- &request,
- expected_revision,
- input,
- self.secrets.as_ref(),
- self.clock.as_ref(),
- )
- .map(RuntimeCommandValue::Imported)
- }
-
- fn start_profile_task(
- &mut self,
- context: CommandContext,
- reply: oneshot::Sender<CommandReceipt<RuntimeCommandValue>>,
- completion_sender: mpsc::Sender<ProfileCompletion>,
- ) {
- let foreground = self
- .published_foreground_session
- .lock()
- .unwrap_or_else(std::sync::PoisonError::into_inner)
- .clone();
- let plan = match self.adapter.core().begin_profile_refresh() {
- Ok(Some(plan)) => plan,
- Ok(None) => {
- let _ = reply.send(CommandReceipt::new(
- context.request_id(),
- CommandResult::Completed(RuntimeCommandValue::Snapshot(Box::new(
- self.adapter.core().snapshot(),
- ))),
- ));
- return;
- }
- Err(error) => {
- let _ = reply.send(CommandReceipt::new(
- context.request_id(),
- CommandResult::Failed(error),
- ));
- return;
- }
- };
- let Some(foreground) = foreground.filter(|binding| {
- binding.identity().public_key() == plan.public_key()
- && binding.generation() == self.session_generation
- }) else {
- let _ = reply.send(CommandReceipt::new(
- context.request_id(),
- CommandResult::Failed(stale_profile_binding()),
- ));
- return;
- };
- let correlation = TaskCorrelation::new(
- context.request_id(),
- plan.public_key(),
- foreground.signer(),
- plan.expected_revision(),
- self.session_generation,
- );
- let client = Arc::clone(&self.nostr);
- let relays = plan.relays().to_vec();
- let request_id = context.request_id();
- let handle = self.runtime.spawn(async move {
- let result = client.fetch_profile(correlation.account(), &relays).await;
- let _ = completion_sender
- .send(ProfileCompletion { request_id, result })
- .await;
- });
- let previous = self.profile_tasks.insert(
- request_id,
- PendingProfileTask {
- correlation,
- plan,
- reply,
- handle,
- },
- );
- debug_assert!(previous.is_none(), "request identifiers are unique");
- }
-
- fn close_actor(&mut self) -> CommandResult<RuntimeCommandValue> {
- let transition = (|| {
- let mut lifecycle = self
- .lifecycle
- .lock()
- .unwrap_or_else(std::sync::PoisonError::into_inner);
- lifecycle.begin_shutdown()?;
- lifecycle.finish_shutdown()
- })();
- match transition {
- Ok(()) => {
- self.generated_key_stage.cancel();
- self.cancel_profile_tasks(None);
- self.changes.close();
- *self
- .published_foreground_session
- .lock()
- .unwrap_or_else(std::sync::PoisonError::into_inner) = None;
- CommandResult::Completed(RuntimeCommandValue::Closed)
- }
- Err(error) => CommandResult::Failed(error),
- }
- }
-
- fn complete_profile_task(&mut self, completion: ProfileCompletion) {
- let Some(task) = self.profile_tasks.remove(&completion.request_id) else {
- return;
- };
- let current = self.adapter.core().snapshot();
- let foreground = self
- .published_foreground_session
- .lock()
- .unwrap_or_else(std::sync::PoisonError::into_inner)
- .clone();
- let correlated = task.correlation.session_generation() == self.session_generation
- && foreground.is_some_and(|binding| {
- binding.generation() == task.correlation.session_generation()
- && binding.identity().public_key() == task.correlation.account()
- && binding.signer() == task.correlation.binding()
- })
- && current
- .active_account()
- .is_some_and(|active| active.account().public_key() == task.correlation.account());
- let result = if correlated {
- self.adapter
- .core()
- .complete_profile_refresh(
- &task.plan,
- completion.result,
- self.adapter.database(),
- self.clock.as_ref(),
- )
- .map(Box::new)
- .map(RuntimeCommandValue::Snapshot)
- .map_or_else(CommandResult::Failed, CommandResult::Completed)
- } else {
- CommandResult::Completed(RuntimeCommandValue::Snapshot(Box::new(current)))
- };
- if matches!(result, CommandResult::Completed(_)) {
- self.changes.publish(self.adapter.core().snapshot());
- }
- let _ = task
- .reply
- .send(CommandReceipt::new(task.correlation.request_id(), result));
- }
-
- fn advance_session_generation(&mut self) {
- let Some(next) = self.session_generation.next() else {
- self.lifecycle
- .lock()
- .unwrap_or_else(std::sync::PoisonError::into_inner)
- .fail(request_space_exhausted());
- self.cancel_profile_tasks(None);
- return;
- };
- self.session_generation = next;
- self.published_session_generation
- .store(next.value(), Ordering::Release);
- let snapshot = self.adapter.core().snapshot();
- self.cancel_profile_tasks(Some(&snapshot));
- }
-
- fn synchronize_foreground_session(&mut self) {
- let session = self
- .adapter
- .core()
- .snapshot()
- .active_account()
- .map(|active| {
- let public_key = active.account().public_key();
- ForegroundSessionBinding::new(
- AccountIdentity::derive(public_key)?,
- LocalSignerBinding::new(public_key, BindingAvailability::Available),
- self.session_generation,
- )
- });
- let session = match session.transpose() {
- Ok(session) => session,
- Err(error) => {
- self.lifecycle
- .lock()
- .unwrap_or_else(std::sync::PoisonError::into_inner)
- .fail(error);
- None
- }
- };
- *self
- .published_foreground_session
- .lock()
- .unwrap_or_else(std::sync::PoisonError::into_inner) = session;
- }
-
- fn cancel_profile_tasks(&mut self, snapshot: Option<&AppSnapshot>) {
- let tasks = std::mem::take(&mut self.profile_tasks);
- for (_, task) in tasks {
- task.handle.abort();
- let receipt_result = snapshot.map_or(CommandResult::Closed, |snapshot| {
- CommandResult::Completed(RuntimeCommandValue::Snapshot(Box::new(snapshot.clone())))
- });
- let _ = task.reply.send(CommandReceipt::new(
- task.correlation.request_id(),
- receipt_result,
- ));
- }
- }
-}
-
-const fn request_space_exhausted() -> SafeError {
- SafeError::new(
- SafeErrorCode::InvalidApplicationState,
- SafeMessage::new("The runtime request identifier space is exhausted."),
- )
-}
-
-const fn stale_profile_binding() -> SafeError {
- SafeError::new(
- SafeErrorCode::InvalidApplicationState,
- SafeMessage::new("The active account binding changed before profile refresh."),
- )
-}
-
-const fn command_conflicted() -> SafeError {
- SafeError::new(
- SafeErrorCode::InvalidApplicationState,
- SafeMessage::new("The command conflicts with newer application state."),
- )
-}
-
-const fn command_rejected() -> SafeError {
- SafeError::new(
- SafeErrorCode::InvalidApplicationState,
- SafeMessage::new("The runtime is busy. Try again."),
- )
-}
-
-const fn command_timed_out() -> SafeError {
- SafeError::new(
- SafeErrorCode::InvalidApplicationState,
- SafeMessage::new("The runtime command timed out."),
- )
-}
-
-const fn runtime_closed() -> SafeError {
- SafeError::new(
- SafeErrorCode::InvalidApplicationState,
- SafeMessage::new("The application runtime is closed."),
- )
-}
-
-const fn command_unavailable() -> SafeError {
- SafeError::new(
- SafeErrorCode::InvalidApplicationState,
- SafeMessage::new("The command is unavailable in the current runtime state."),
- )
-}
-
-const fn generated_recovery_route_active() -> SafeError {
- SafeError::new(
- SafeErrorCode::InvalidApplicationState,
- SafeMessage::new("Complete or cancel generated-key recovery before another action."),
- )
-}
-
-const fn invalid_actor_response() -> SafeError {
- SafeError::new(
- SafeErrorCode::InvalidApplicationState,
- SafeMessage::new("The runtime returned an invalid command response."),
- )
-}
-
-const fn observer_registration_failed() -> SafeError {
- SafeError::new(
- SafeErrorCode::ObserverRegistrationFailed,
- SafeMessage::new("The application change subscription could not be registered."),
- )
-}
-
-#[cfg(test)]
-mod tests {
- use std::num::NonZeroUsize;
- use std::sync::atomic::{AtomicBool, Ordering};
- use std::sync::{Arc, Condvar, Mutex};
- use std::time::Duration;
-
- use radroots_studio_application::{
- BoxFuture, Clock, FailureSecretStore, InMemorySecretStore, NostrClient, RelayConfiguration,
- RuntimeLifecycle, SecretStore, SecretStoreOperation, SessionState,
- };
- use radroots_studio_domain::{
- Kind0ProfileCandidate, PublicKey, RelayUrl, SafeError, SafeErrorCode, SecretKeyInput,
- UnixTimestamp,
- };
-
- use super::RuntimeActorHandle;
-
- struct FixedClock;
-
- impl Clock for FixedClock {
- fn now(&self) -> UnixTimestamp {
- UnixTimestamp::from_seconds(50).expect("time")
- }
- }
-
- struct OfflineNostr;
-
- impl NostrClient for OfflineNostr {
- fn fetch_profile<'a>(
- &'a self,
- _public_key: PublicKey,
- _relays: &'a [RelayUrl],
- ) -> BoxFuture<'a, Result<Option<Kind0ProfileCandidate>, SafeError>> {
- Box::pin(async { Ok(None) })
- }
- }
-
- struct BlockingNostr {
- started: tokio::sync::Semaphore,
- release: tokio::sync::Semaphore,
- }
-
- impl BlockingNostr {
- fn new() -> Self {
- Self {
- started: tokio::sync::Semaphore::new(0),
- release: tokio::sync::Semaphore::new(0),
- }
- }
- }
-
- impl NostrClient for BlockingNostr {
- fn fetch_profile<'a>(
- &'a self,
- _public_key: PublicKey,
- _relays: &'a [RelayUrl],
- ) -> BoxFuture<'a, Result<Option<Kind0ProfileCandidate>, SafeError>> {
- Box::pin(async move {
- self.started.add_permits(1);
- let permit = self.release.acquire().await.expect("release");
- permit.forget();
- Ok(None)
- })
- }
- }
-
- struct BlockingSecretStore {
- inner: InMemorySecretStore,
- block_next_put: AtomicBool,
- put_started: AtomicBool,
- released: Mutex<bool>,
- release_signal: Condvar,
- }
-
- impl BlockingSecretStore {
- fn new() -> Self {
- Self {
- inner: InMemorySecretStore::default(),
- block_next_put: AtomicBool::new(true),
- put_started: AtomicBool::new(false),
- released: Mutex::new(false),
- release_signal: Condvar::new(),
- }
- }
-
- async fn wait_until_put_started(&self) {
- while !self.put_started.load(Ordering::Acquire) {
- tokio::task::yield_now().await;
- }
- }
-
- fn release(&self) {
- *self
- .released
- .lock()
- .unwrap_or_else(std::sync::PoisonError::into_inner) = true;
- self.release_signal.notify_all();
- }
- }
-
- impl SecretStore for BlockingSecretStore {
- fn put(&self, public_key: PublicKey, secret: SecretKeyInput) -> Result<(), SafeError> {
- if self.block_next_put.swap(false, Ordering::AcqRel) {
- self.put_started.store(true, Ordering::Release);
- let released = self
- .released
- .lock()
- .unwrap_or_else(std::sync::PoisonError::into_inner);
- drop(
- self.release_signal
- .wait_while(released, |released| !*released)
- .unwrap_or_else(std::sync::PoisonError::into_inner),
- );
- }
- self.inner.put(public_key, secret)
- }
-
- fn load(&self, public_key: PublicKey) -> Result<SecretKeyInput, SafeError> {
- self.inner.load(public_key)
- }
-
- fn contains(&self, public_key: PublicKey) -> Result<bool, SafeError> {
- self.inner.contains(public_key)
- }
-
- fn delete(&self, public_key: PublicKey) -> Result<(), SafeError> {
- self.inner.delete(public_key)
- }
- }
-
- fn actor() -> (RuntimeActorHandle, Arc<InMemorySecretStore>) {
- let secrets = Arc::new(InMemorySecretStore::default());
- let secret_port: Arc<dyn SecretStore> = secrets.clone();
- let actor = RuntimeActorHandle::in_memory(
- RelayConfiguration::default(),
- secret_port,
- Arc::new(FixedClock),
- Arc::new(OfflineNostr),
- NonZeroUsize::new(8).expect("capacity"),
- &tokio::runtime::Handle::current(),
- )
- .expect("actor");
- (actor, secrets)
- }
-
- #[tokio::test(flavor = "multi_thread")]
- async fn account_mutations_run_serially_through_one_ready_actor() {
- let (actor, secrets) = actor();
- assert_eq!(actor.lifecycle(), RuntimeLifecycle::Ready);
-
- let imported = actor
- .import_secret_key(
- SecretKeyInput::parse(
- "7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7".to_owned(),
- )
- .expect("input"),
- )
- .await
- .expect("import");
- let public_key = imported.account().public_key();
- let activated = actor.activate_account(public_key).await.expect("activate");
- assert_eq!(activated.session(), SessionState::Active);
- let foreground = actor.foreground_session().expect("foreground session");
- assert_eq!(foreground.identity().public_key(), public_key);
- assert_eq!(foreground.signer().account(), public_key);
- assert_eq!(foreground.generation(), actor.session_generation());
- assert!(secrets.contains(public_key).expect("credential"));
-
- let signed_out = actor.sign_out().await.expect("sign out");
- assert_eq!(signed_out.session(), SessionState::SignedOut);
- assert!(actor.foreground_session().is_none());
- let removal = actor
- .request_account_removal(public_key)
- .await
- .expect("removal request");
- let removed = actor
- .confirm_account_removal(removal)
- .await
- .expect("remove");
- assert!(removed.accounts().is_empty());
- assert!(!secrets.contains(public_key).expect("credential removed"));
- }
-
- #[tokio::test(flavor = "multi_thread")]
- async fn generated_key_stage_is_exclusive_cancelable_and_snapshot_free() {
- let (actor, secrets) = actor();
- let initial = actor.snapshot();
- let stage = actor
- .begin_generated_key_stage()
- .await
- .expect("generated key stage");
-
- assert!(actor.begin_generated_key_stage().await.is_err());
- assert_eq!(actor.snapshot(), initial);
- assert!(
- !secrets
- .contains(stage.view().account().public_key())
- .expect("keyring")
- );
- assert!(actor.sign_out().await.is_err());
- assert_eq!(actor.snapshot(), initial);
- assert!(actor.cancel_generated_key_stage().await.expect("cancel"));
- assert!(
- !actor
- .cancel_generated_key_stage()
- .await
- .expect("cancel empty")
- );
- assert_eq!(actor.snapshot(), initial);
-
- actor
- .begin_generated_key_stage()
- .await
- .expect("replacement stage");
- actor.close().await.expect("close clears stage");
- assert_eq!(actor.lifecycle(), RuntimeLifecycle::Closed);
- }
-
- #[tokio::test(flavor = "multi_thread")]
- async fn recovery_handle_is_one_use_and_acknowledgement_commits_once() {
- let (actor, secrets) = actor();
- let initial = actor.snapshot();
- let handle = actor
- .begin_generated_key_stage()
- .await
- .expect("generated key stage");
- let public_key = handle.view().account().public_key();
- let recovery = handle.take_recovery_nsec().expect("recovery material");
- assert_eq!(recovery.with_exposed_secret(str::len), 63);
- assert!(handle.take_recovery_nsec().is_err());
- assert_eq!(actor.snapshot(), initial);
- assert!(!secrets.contains(public_key).expect("not committed"));
-
- let committed = actor
- .acknowledge_generated_key_stage(handle.id())
- .await
- .expect("acknowledge");
- assert_eq!(committed.accounts().len(), 1);
- assert_eq!(committed.selected_account(), Some(public_key));
- assert!(secrets.contains(public_key).expect("credential committed"));
- assert!(
- actor
- .acknowledge_generated_key_stage(handle.id())
- .await
- .is_err()
- );
- }
-
- #[tokio::test(flavor = "multi_thread")]
- async fn failed_generated_commit_consumes_the_stage_without_poisoning_the_actor() {
- let secrets = Arc::new(FailureSecretStore::default());
- secrets.fail_next(SecretStoreOperation::Put);
- let secret_port: Arc<dyn SecretStore> = secrets.clone();
- let actor = RuntimeActorHandle::in_memory(
- RelayConfiguration::default(),
- secret_port,
- Arc::new(FixedClock),
- Arc::new(OfflineNostr),
- NonZeroUsize::new(8).expect("capacity"),
- &tokio::runtime::Handle::current(),
- )
- .expect("actor");
- let handle = actor
- .begin_generated_key_stage()
- .await
- .expect("generated key stage");
-
- let error = actor
- .acknowledge_generated_key_stage(handle.id())
- .await
- .expect_err("injected keyring failure");
-
- assert_eq!(error.code(), SafeErrorCode::KeyringUnavailable);
- assert!(actor.snapshot().accounts().is_empty());
- actor
- .begin_generated_key_stage()
- .await
- .expect("fresh recovery after terminal failure");
- assert!(actor.cancel_generated_key_stage().await.expect("cancel"));
- }
-
- #[tokio::test(flavor = "multi_thread")]
- async fn session_generation_cancels_correlated_profile_work_on_sign_out() {
- let client = Arc::new(BlockingNostr::new());
- let actor = RuntimeActorHandle::in_memory(
- RelayConfiguration::new(vec![RelayUrl::parse("ws://localhost:8080").expect("relay")]),
- Arc::new(InMemorySecretStore::default()),
- Arc::new(FixedClock),
- client.clone(),
- NonZeroUsize::new(8).expect("capacity"),
- &tokio::runtime::Handle::current(),
- )
- .expect("actor");
- let imported = actor
- .import_secret_key(
- SecretKeyInput::parse(
- "7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7".to_owned(),
- )
- .expect("input"),
- )
- .await
- .expect("import");
- actor
- .activate_account(imported.account().public_key())
- .await
- .expect("activate");
- assert_eq!(actor.session_generation().value(), 1);
-
- let refresh_actor = actor.clone();
- let refresh = tokio::spawn(async move { refresh_actor.refresh_active_profile().await });
- let started = client.started.acquire().await.expect("refresh started");
- started.forget();
- let signed_out = actor.sign_out().await.expect("sign out");
- let cancelled = refresh
- .await
- .expect("refresh task")
- .expect("safe cancellation");
-
- assert_eq!(actor.session_generation().value(), 2);
- assert_eq!(signed_out.session(), SessionState::SignedOut);
- assert_eq!(cancelled.session(), SessionState::SignedOut);
- assert!(cancelled.active_account().is_none());
- }
-
- #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
- async fn bounded_runtime_rejects_saturation_without_dropping_accepted_commands() {
- let secrets = Arc::new(BlockingSecretStore::new());
- let actor = RuntimeActorHandle::in_memory(
- RelayConfiguration::default(),
- secrets.clone(),
- Arc::new(FixedClock),
- Arc::new(OfflineNostr),
- NonZeroUsize::new(1).expect("capacity"),
- &tokio::runtime::Handle::current(),
- )
- .expect("actor");
-
- let first_actor = actor.clone();
- let first = tokio::spawn(async move {
- first_actor
- .import_secret_key(secret(
- "7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7",
- ))
- .await
- });
- secrets.wait_until_put_started().await;
-
- let second_actor = actor.clone();
- let second = tokio::spawn(async move {
- second_actor
- .import_secret_key(secret(
- "0000000000000000000000000000000000000000000000000000000000000001",
- ))
- .await
- });
- while actor.mailbox.available_capacity() != 0 {
- assert!(
- !second.is_finished(),
- "second command must enter the mailbox"
- );
- tokio::task::yield_now().await;
- }
- let rejected = actor
- .import_secret_key(secret(
- "0000000000000000000000000000000000000000000000000000000000000002",
- ))
- .await
- .expect_err("full mailbox must reject");
- assert_eq!(
- rejected.message().as_str(),
- "The runtime is busy. Try again."
- );
-
- secrets.release();
- first.await.expect("first task").expect("first command");
- second.await.expect("second task").expect("second command");
- assert_eq!(
- actor.bootstrap().await.expect("snapshot").accounts().len(),
- 2
- );
- }
-
- #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
- async fn queued_command_expiry_returns_timeout_and_prevents_late_mutation() {
- let secrets = Arc::new(BlockingSecretStore::new());
- let actor = RuntimeActorHandle::in_memory(
- RelayConfiguration::default(),
- secrets.clone(),
- Arc::new(FixedClock),
- Arc::new(OfflineNostr),
- NonZeroUsize::new(1).expect("capacity"),
- &tokio::runtime::Handle::current(),
- )
- .expect("actor");
-
- let first_actor = actor.clone();
- let first = tokio::spawn(async move {
- first_actor
- .import_secret_key(secret(
- "7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7",
- ))
- .await
- });
- secrets.wait_until_put_started().await;
-
- let expired = actor
- .import_secret_key_with_timeout(
- secret("0000000000000000000000000000000000000000000000000000000000000001"),
- Duration::from_millis(10),
- )
- .await
- .expect_err("queued command must time out");
- assert_eq!(expired.message().as_str(), "The runtime command timed out.");
-
- secrets.release();
- first.await.expect("first task").expect("first command");
- assert_eq!(
- actor.bootstrap().await.expect("snapshot").accounts().len(),
- 1
- );
- }
-
- #[tokio::test(flavor = "multi_thread")]
- async fn close_is_terminal_and_every_later_command_is_rejected_as_closed() {
- let (actor, _) = actor();
- actor.close().await.expect("close");
- assert_eq!(actor.lifecycle(), RuntimeLifecycle::Closed);
-
- for error in [
- actor.bootstrap().await.expect_err("bootstrap after close"),
- actor.close().await.expect_err("repeated close"),
- ] {
- assert_eq!(
- error.message().as_str(),
- "The application runtime is closed."
- );
- }
- }
-
- #[tokio::test(flavor = "multi_thread")]
- async fn actor_subscription_atomically_delivers_initial_then_ordered_changes() {
- let (actor, _) = actor();
- let mut subscription = actor
- .subscribe_changes(NonZeroUsize::new(4).expect("capacity"))
- .await
- .expect("subscribe");
- let initial = subscription.receive().await.expect("initial snapshot");
- assert_eq!(initial.revision(), actor.snapshot().revision());
- assert!(initial.previous_revision().is_none());
-
- actor
- .import_secret_key(secret(
- "7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7",
- ))
- .await
- .expect("import");
- let changed = subscription.receive().await.expect("change");
- assert!(changed.revision() > initial.revision());
- assert_eq!(changed.previous_revision(), Some(initial.revision()));
- assert!(
- actor
- .unsubscribe_changes(subscription.id())
- .await
- .expect("unsubscribe")
- );
- }
-
- #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
- async fn expired_queued_shutdown_does_not_close_runtime_later() {
- let secrets = Arc::new(BlockingSecretStore::new());
- let actor = RuntimeActorHandle::in_memory(
- RelayConfiguration::default(),
- secrets.clone(),
- Arc::new(FixedClock),
- Arc::new(OfflineNostr),
- NonZeroUsize::new(1).expect("capacity"),
- &tokio::runtime::Handle::current(),
- )
- .expect("actor");
- let import_actor = actor.clone();
- let import = tokio::spawn(async move {
- import_actor
- .import_secret_key(secret(
- "7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7",
- ))
- .await
- });
- secrets.wait_until_put_started().await;
-
- let timeout = actor
- .close_with_timeout(Duration::from_millis(10))
- .await
- .expect_err("queued shutdown must expire");
- assert_eq!(timeout.message().as_str(), "The runtime command timed out.");
- secrets.release();
- import.await.expect("import task").expect("import");
- assert_eq!(actor.lifecycle(), RuntimeLifecycle::Ready);
- assert_eq!(
- actor
- .bootstrap()
- .await
- .expect("still open")
- .accounts()
- .len(),
- 1
- );
- actor.close().await.expect("later close");
- }
-
- #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
- async fn shutdown_cancels_in_flight_work_and_terminates_publication() {
- let client = Arc::new(BlockingNostr::new());
- let actor = RuntimeActorHandle::in_memory(
- RelayConfiguration::new(vec![RelayUrl::parse("ws://localhost:8080").expect("relay")]),
- Arc::new(InMemorySecretStore::default()),
- Arc::new(FixedClock),
- client.clone(),
- NonZeroUsize::new(8).expect("capacity"),
- &tokio::runtime::Handle::current(),
- )
- .expect("actor");
- let imported = actor
- .import_secret_key(secret(
- "7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7",
- ))
- .await
- .expect("import");
- actor
- .activate_account(imported.account().public_key())
- .await
- .expect("activate");
- let mut changes = actor
- .subscribe_changes(NonZeroUsize::new(4).expect("capacity"))
- .await
- .expect("subscribe");
- changes.receive().await.expect("initial");
-
- let refresh_actor = actor.clone();
- let refresh = tokio::spawn(async move { refresh_actor.refresh_active_profile().await });
- let started = client.started.acquire().await.expect("refresh started");
- started.forget();
- actor.close().await.expect("close");
-
- let cancelled = refresh
- .await
- .expect("refresh task")
- .expect_err("refresh closes");
- assert_eq!(
- cancelled.message().as_str(),
- "The application runtime is closed."
- );
- assert!(changes.receive().await.is_none());
- assert_eq!(actor.lifecycle(), RuntimeLifecycle::Closed);
- }
-
- fn secret(value: &str) -> SecretKeyInput {
- SecretKeyInput::parse(value.to_owned()).expect("valid test secret")
- }
-}
diff --git a/core/crates/storage/tests/local_relay_e2e.rs b/core/crates/storage/tests/local_relay_e2e.rs
@@ -1,95 +0,0 @@
-use std::time::Duration;
-
-use nostr::{EventBuilder, Keys, Metadata};
-use nostr_relay_builder::MockRelay;
-use nostr_sdk::Client;
-use radroots_studio_application::{
- Clock, InMemorySecretStore, ProfileLoadState, ProfileRepository, RelayConfiguration,
- RelayConnectionState, SdkNostrClient, SecretStore, SessionState,
-};
-use radroots_studio_domain::{RelayUrl, SecretKeyInput, UnixTimestamp};
-use radroots_studio_storage::PersistentAppCore;
-
-const SECRET_HEX: &str = "7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7";
-
-struct FixedClock;
-
-impl Clock for FixedClock {
- fn now(&self) -> UnixTimestamp {
- UnixTimestamp::from_seconds(100).expect("fixed timestamp")
- }
-}
-
-#[tokio::test]
-async fn local_relay_e2e_imports_activates_refreshes_and_caches_profile() {
- let local_relay = MockRelay::run().await.expect("local relay");
- let relay_url = local_relay.url().await;
- let keys = Keys::parse(SECRET_HEX).expect("known secret key");
- let publisher = Client::new(keys);
- publisher
- .add_relay(relay_url.clone())
- .await
- .expect("publisher relay");
- publisher.connect().await;
- publisher.wait_for_connection(Duration::from_secs(2)).await;
- publisher
- .send_event_builder(EventBuilder::metadata(
- &Metadata::new()
- .name("farmer")
- .display_name("Farm Account")
- .about("Local food profile"),
- ))
- .await
- .expect("publish profile");
-
- let relay = RelayUrl::parse(relay_url.as_str()).expect("relay URL");
- let adapter = PersistentAppCore::in_memory(RelayConfiguration::new(vec![relay]))
- .expect("persistent adapter");
- let secrets = InMemorySecretStore::default();
- adapter.bootstrap(&secrets, &FixedClock).expect("bootstrap");
- let imported = adapter
- .import_secret_key(
- SecretKeyInput::parse(SECRET_HEX.to_owned()).expect("secret input"),
- &secrets,
- &FixedClock,
- )
- .expect("import account");
- let public_key = imported.account().public_key();
- assert!(secrets.contains(public_key).expect("credential exists"));
- adapter
- .activate_account(public_key, &secrets, &FixedClock)
- .expect("activate account");
-
- let refreshed = adapter
- .core()
- .refresh_active_profile(
- adapter.database(),
- &SdkNostrClient::new(Duration::from_secs(2)),
- &FixedClock,
- )
- .await
- .expect("refresh profile");
-
- assert_eq!(refreshed.session(), SessionState::Active);
- let active = refreshed.active_account().expect("active account");
- assert_eq!(active.relay_state(), RelayConnectionState::Connected);
- assert_eq!(active.profile_state(), ProfileLoadState::Fresh);
- assert_eq!(
- active.profile().and_then(|profile| profile.display_name()),
- Some("Farm Account")
- );
- let cached = adapter
- .database()
- .load_profile(public_key)
- .expect("load cache")
- .expect("cached profile");
- assert_eq!(
- cached.candidate().metadata().preferred_name(),
- Some("Farm Account")
- );
- let public_debug = format!("{refreshed:?}");
- assert!(!public_debug.contains(SECRET_HEX));
- assert!(!public_debug.contains("nsec1"));
- publisher.shutdown().await;
- local_relay.shutdown();
-}
diff --git a/core/crates/storage/tests/redaction.rs b/core/crates/storage/tests/redaction.rs
@@ -1,52 +0,0 @@
-use std::fs;
-
-use radroots_studio_application::{AccountOperationKind, AccountRepository, OperationJournal};
-use radroots_studio_domain::{
- AccountCreatedAt, AccountIdentity, AccountSummary, BindingAvailability, LocalSignerBinding,
- PublicKey, UnixTimestamp,
-};
-use radroots_studio_storage::Database;
-use tempfile::tempdir;
-
-const SECRET_HEX: &str = "1111111111111111111111111111111111111111111111111111111111111111";
-const SECRET_NSEC: &str = "nsec1vl029mgpspedva04g90vltkh6fvh240zqtv9k0t9af8935ke9laqsnlfe5";
-fn assert_redacted(bytes: &[u8]) {
- assert!(
- !bytes
- .windows(SECRET_HEX.len())
- .any(|value| value == SECRET_HEX.as_bytes())
- );
- assert!(
- !bytes
- .windows(SECRET_NSEC.len())
- .any(|value| value == SECRET_NSEC.as_bytes())
- );
- assert!(!bytes.windows(5).any(|value| value == b"nsec1"));
-}
-
-#[test]
-fn redaction_guards_sqlite_schema_and_non_secret_records() {
- let directory = tempdir().expect("directory");
- let path = directory.path().join("studio.sqlite3");
- {
- let database = Database::open(&path).expect("database");
- let public_key = PublicKey::from_bytes([2; 32]);
- let account = AccountSummary::new(
- AccountIdentity::derive(public_key).expect("identity"),
- LocalSignerBinding::new(public_key, BindingAvailability::Available),
- None,
- AccountCreatedAt::new(UnixTimestamp::from_seconds(1).expect("time")),
- None,
- )
- .expect("account");
- database.insert_account(&account).expect("account");
- database
- .begin_operation(
- AccountOperationKind::Add,
- account.public_key(),
- UnixTimestamp::from_seconds(2).expect("time"),
- )
- .expect("journal");
- }
- assert_redacted(&fs::read(path).expect("database bytes"));
-}
diff --git a/core/crates/storage/tests/restart_isolation.rs b/core/crates/storage/tests/restart_isolation.rs
@@ -1,95 +0,0 @@
-use std::fs;
-
-use radroots_studio_application::{
- AccountNamespaceRepository, AccountPreferenceKey, Clock, InMemorySecretStore,
- RelayConfiguration, SessionState,
-};
-use radroots_studio_domain::{SecretKeyInput, UnixTimestamp};
-use radroots_studio_storage::PersistentAppCore;
-use tempfile::tempdir;
-
-const SECRET_A: &str = "7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7";
-const SECRET_B: &str = "0101010101010101010101010101010101010101010101010101010101010101";
-
-struct FixedClock;
-
-impl Clock for FixedClock {
- fn now(&self) -> UnixTimestamp {
- UnixTimestamp::from_seconds(200).expect("fixed timestamp")
- }
-}
-
-#[test]
-fn restart_restores_selection_and_keeps_account_namespaces_isolated() {
- let directory = tempdir().expect("temporary directory");
- let path = directory.path().join("studio.sqlite3");
- let secrets = InMemorySecretStore::default();
- let (owner_a, owner_b);
-
- {
- let adapter = PersistentAppCore::open(&path, RelayConfiguration::default())
- .expect("persistent adapter");
- adapter.bootstrap(&secrets, &FixedClock).expect("bootstrap");
- owner_a = adapter
- .import_secret_key(
- SecretKeyInput::parse(SECRET_A.to_owned()).expect("secret A"),
- &secrets,
- &FixedClock,
- )
- .expect("account A")
- .account()
- .public_key();
- owner_b = adapter
- .import_secret_key(
- SecretKeyInput::parse(SECRET_B.to_owned()).expect("secret B"),
- &secrets,
- &FixedClock,
- )
- .expect("account B")
- .account()
- .public_key();
- adapter
- .database()
- .set_value(owner_a, AccountPreferenceKey::NamespaceProbe, "account-a")
- .expect("namespace A");
- adapter
- .database()
- .set_value(owner_b, AccountPreferenceKey::NamespaceProbe, "account-b")
- .expect("namespace B");
- adapter.select_account(owner_b).expect("select B");
- }
-
- let reopened =
- PersistentAppCore::open(&path, RelayConfiguration::default()).expect("reopen adapter");
- let restored = reopened.bootstrap(&secrets, &FixedClock).expect("restore");
- assert_eq!(restored.accounts().len(), 2);
- assert_eq!(restored.selected_account(), Some(owner_b));
- assert_eq!(restored.session(), SessionState::SignedOut);
- assert_eq!(
- reopened
- .database()
- .get_value(owner_a, AccountPreferenceKey::NamespaceProbe)
- .expect("read A"),
- Some("account-a".to_owned())
- );
- assert_eq!(
- reopened
- .database()
- .get_value(owner_b, AccountPreferenceKey::NamespaceProbe)
- .expect("read B"),
- Some("account-b".to_owned())
- );
-
- let database = fs::read(path).expect("database bytes");
- assert!(
- !database
- .windows(SECRET_A.len())
- .any(|bytes| bytes == SECRET_A.as_bytes())
- );
- assert!(
- !database
- .windows(SECRET_B.len())
- .any(|bytes| bytes == SECRET_B.as_bytes())
- );
- assert!(!database.windows(5).any(|bytes| bytes == b"nsec1"));
-}
diff --git a/core/tools/uniffi-bindgen/Cargo.toml b/core/tools/uniffi-bindgen/Cargo.toml
@@ -1,14 +0,0 @@
-[package]
-name = "radroots-studio-uniffi-bindgen"
-version.workspace = true
-edition.workspace = true
-rust-version.workspace = true
-license.workspace = true
-repository.workspace = true
-publish = false
-
-[dependencies]
-uniffi = { workspace = true, features = ["cli"] }
-
-[lints]
-workspace = true
diff --git a/core/tools/uniffi-bindgen/src/main.rs b/core/tools/uniffi-bindgen/src/main.rs
@@ -1,13 +0,0 @@
-#![doc = "Pinned `UniFFI` binding generator entry point."]
-
-fn main() {
- uniffi::uniffi_bindgen_main();
-}
-
-#[cfg(test)]
-mod tests {
- #[test]
- fn tool_is_available_to_the_workspace() {
- assert_eq!(env!("CARGO_PKG_NAME"), "radroots-studio-uniffi-bindgen");
- }
-}