app

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

commit 04a101be3ab5c0443b006dfeb4c6bf8a169352a4
parent 78ee69c6d0d909245473981b208bd103514dd2a5
Author: triesap <tyson@radroots.org>
Date:   Sun,  9 Aug 2026 18:54:10 +0000

runtime: rename HarvestCircle adapter crates

- move the Nostr adapter and runtime under HarvestCircle crate names
- update all workspace dependencies and imports
- align product-owned crate documentation and test naming
- refresh and verify the locked dependency graph

Diffstat:
Mcore/Cargo.lock | 66+++++++++++++++++++++++++++++++++---------------------------------
Mcore/Cargo.toml | 8++++----
Mcore/crates/harvestcircle_application/Cargo.toml | 2+-
Mcore/crates/harvestcircle_application/src/lib.rs | 2+-
Mcore/crates/harvestcircle_domain/Cargo.toml | 2+-
Mcore/crates/harvestcircle_domain/src/lib.rs | 2+-
Acore/crates/harvestcircle_nostr/Cargo.toml | 29+++++++++++++++++++++++++++++
Acore/crates/harvestcircle_nostr/src/client.rs | 339+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Rcore/crates/studio_nostr/src/keys.rs -> core/crates/harvestcircle_nostr/src/keys.rs | 0
Acore/crates/harvestcircle_nostr/src/lib.rs | 9+++++++++
Rcore/crates/studio_nostr/src/profile.rs -> core/crates/harvestcircle_nostr/src/profile.rs | 0
Mcore/crates/harvestcircle_preferences/Cargo.toml | 2+-
Acore/crates/harvestcircle_runtime/Cargo.toml | 29+++++++++++++++++++++++++++++
Rcore/crates/studio_runtime/src/blocking.rs -> core/crates/harvestcircle_runtime/src/blocking.rs | 0
Rcore/crates/studio_runtime/src/installation.rs -> core/crates/harvestcircle_runtime/src/installation.rs | 0
Acore/crates/harvestcircle_runtime/src/lib.rs | 12++++++++++++
Acore/crates/harvestcircle_runtime/src/persistence.rs | 746+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Rcore/crates/studio_runtime/src/runtime_actor.rs -> core/crates/harvestcircle_runtime/src/runtime_actor.rs | 0
Acore/crates/harvestcircle_runtime/tests/local_relay_e2e.rs | 100+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acore/crates/harvestcircle_runtime/tests/restart_isolation.rs | 95+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcore/crates/harvestcircle_storage/Cargo.toml | 2+-
Mcore/crates/harvestcircle_storage/src/lib.rs | 2+-
Mcore/crates/studio_ffi/Cargo.toml | 6+++---
Mcore/crates/studio_ffi/src/commands.rs | 10+++++-----
Mcore/crates/studio_ffi/src/dto.rs | 2+-
Mcore/crates/studio_ffi/src/lib.rs | 2+-
Mcore/crates/studio_ffi/src/observer.rs | 8++++----
Dcore/crates/studio_nostr/Cargo.toml | 29-----------------------------
Dcore/crates/studio_nostr/src/client.rs | 339-------------------------------------------------------------------------------
Dcore/crates/studio_nostr/src/lib.rs | 9---------
Dcore/crates/studio_runtime/Cargo.toml | 29-----------------------------
Dcore/crates/studio_runtime/src/lib.rs | 12------------
Dcore/crates/studio_runtime/src/persistence.rs | 746-------------------------------------------------------------------------------
Dcore/crates/studio_runtime/tests/local_relay_e2e.rs | 100-------------------------------------------------------------------------------
Dcore/crates/studio_runtime/tests/restart_isolation.rs | 95-------------------------------------------------------------------------------
Mcore/crates/studio_uniffi_bindgen/Cargo.toml | 2+-
36 files changed, 1418 insertions(+), 1418 deletions(-)

diff --git a/core/Cargo.lock b/core/Cargo.lock @@ -1083,6 +1083,21 @@ dependencies = [ ] [[package]] +name = "harvestcircle_nostr" +version = "0.1.0-alpha" +dependencies = [ + "harvestcircle_application", + "harvestcircle_domain", + "nostr 0.44.1", + "nostr-relay-builder", + "nostr-sdk 0.44.0", + "radroots_identity", + "radroots_transport", + "radroots_transport_nostr", + "tokio", +] + +[[package]] name = "harvestcircle_preferences" version = "0.1.0-alpha" dependencies = [ @@ -1090,6 +1105,22 @@ dependencies = [ ] [[package]] +name = "harvestcircle_runtime" +version = "0.1.0-alpha" +dependencies = [ + "harvestcircle_application", + "harvestcircle_domain", + "harvestcircle_nostr", + "harvestcircle_storage", + "nostr 0.44.1", + "nostr-relay-builder", + "nostr-sdk 0.44.0", + "tempfile", + "tokio", + "uuid", +] + +[[package]] name = "harvestcircle_storage" version = "0.1.0-alpha" dependencies = [ @@ -2053,13 +2084,13 @@ dependencies = [ "directories", "harvestcircle_application", "harvestcircle_domain", + "harvestcircle_nostr", + "harvestcircle_runtime", "harvestcircle_storage", "nostr 0.44.1", "nostr-relay-builder", "nostr-sdk 0.44.0", "quote", - "radroots_studio_nostr", - "radroots_studio_runtime", "sha2", "syn 2.0.119", "tempfile", @@ -2068,37 +2099,6 @@ dependencies = [ ] [[package]] -name = "radroots_studio_nostr" -version = "0.1.0-alpha" -dependencies = [ - "harvestcircle_application", - "harvestcircle_domain", - "nostr 0.44.1", - "nostr-relay-builder", - "nostr-sdk 0.44.0", - "radroots_identity", - "radroots_transport", - "radroots_transport_nostr", - "tokio", -] - -[[package]] -name = "radroots_studio_runtime" -version = "0.1.0-alpha" -dependencies = [ - "harvestcircle_application", - "harvestcircle_domain", - "harvestcircle_storage", - "nostr 0.44.1", - "nostr-relay-builder", - "nostr-sdk 0.44.0", - "radroots_studio_nostr", - "tempfile", - "tokio", - "uuid", -] - -[[package]] name = "radroots_studio_uniffi_bindgen" version = "0.1.0-alpha" dependencies = [ diff --git a/core/Cargo.toml b/core/Cargo.toml @@ -3,9 +3,9 @@ members = [ "crates/harvestcircle_application", "crates/harvestcircle_domain", "crates/studio_ffi", - "crates/studio_nostr", + "crates/harvestcircle_nostr", "crates/harvestcircle_preferences", - "crates/studio_runtime", + "crates/harvestcircle_runtime", "crates/harvestcircle_storage", "crates/studio_uniffi_bindgen", ] @@ -36,9 +36,9 @@ unimplemented = "deny" harvestcircle_application = { path = "crates/harvestcircle_application", version = "=0.1.0-alpha" } harvestcircle_domain = { path = "crates/harvestcircle_domain", version = "=0.1.0-alpha" } radroots_studio_ffi = { path = "crates/studio_ffi", version = "=0.1.0-alpha" } -radroots_studio_nostr = { path = "crates/studio_nostr", version = "=0.1.0-alpha" } +harvestcircle_nostr = { path = "crates/harvestcircle_nostr", version = "=0.1.0-alpha" } harvestcircle_preferences = { path = "crates/harvestcircle_preferences", version = "=0.1.0-alpha" } -radroots_studio_runtime = { path = "crates/studio_runtime", version = "=0.1.0-alpha" } +harvestcircle_runtime = { path = "crates/harvestcircle_runtime", version = "=0.1.0-alpha" } harvestcircle_storage = { path = "crates/harvestcircle_storage", version = "=0.1.0-alpha" } radroots_studio_uniffi_bindgen = { path = "crates/studio_uniffi_bindgen", version = "=0.1.0-alpha" } radroots_identity = { git = "https://github.com/radrootslabs/lib", rev = "09065a610d95e57acdc895a14c07580fa099e7c3", version = "=0.1.0-alpha", default-features = false } diff --git a/core/crates/harvestcircle_application/Cargo.toml b/core/crates/harvestcircle_application/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "harvestcircle_application" -description = "Private application policy and ports for Radroots Studio" +description = "Private application policy and ports for HarvestCircle" version = "0.1.0-alpha" edition.workspace = true authors.workspace = true diff --git a/core/crates/harvestcircle_application/src/lib.rs b/core/crates/harvestcircle_application/src/lib.rs @@ -1,4 +1,4 @@ -#![doc = "Radroots Studio application runtime."] +#![doc = "HarvestCircle application runtime."] pub mod accounts; pub mod actor; diff --git a/core/crates/harvestcircle_domain/Cargo.toml b/core/crates/harvestcircle_domain/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "harvestcircle_domain" -description = "Private domain model for Radroots Studio" +description = "Private domain model for HarvestCircle" version = "0.1.0-alpha" edition.workspace = true authors.workspace = true diff --git a/core/crates/harvestcircle_domain/src/lib.rs b/core/crates/harvestcircle_domain/src/lib.rs @@ -1,4 +1,4 @@ -#![doc = "Radroots Studio Nostr account domain types."] +#![doc = "HarvestCircle Nostr account domain types."] pub mod account; pub mod error; diff --git a/core/crates/harvestcircle_nostr/Cargo.toml b/core/crates/harvestcircle_nostr/Cargo.toml @@ -0,0 +1,29 @@ +[package] +name = "harvestcircle_nostr" +description = "Private Nostr adapter for HarvestCircle" +version = "0.1.0-alpha" +edition.workspace = true +authors.workspace = true +rust-version.workspace = true +license = "GPL-3.0-only" +repository.workspace = true +homepage.workspace = true +publish = false +include = ["src/**", "Cargo.toml"] + +[dependencies] +nostr = { git = "https://github.com/rust-nostr/nostr.git", rev = "5bba5163eb77107f82c4a8262cf29d7f33a73219", package = "nostr" } +nostr-sdk = { git = "https://github.com/rust-nostr/nostr.git", rev = "5bba5163eb77107f82c4a8262cf29d7f33a73219", package = "nostr-sdk" } +harvestcircle_application.workspace = true +harvestcircle_domain.workspace = true +radroots_identity.workspace = true +radroots_transport.workspace = true +radroots_transport_nostr.workspace = true +tokio = { version = "=1.47.1", features = ["sync", "time"] } + +[dev-dependencies] +nostr-relay-builder = { git = "https://github.com/rust-nostr/nostr.git", rev = "5bba5163eb77107f82c4a8262cf29d7f33a73219", package = "nostr-relay-builder" } +tokio = { version = "=1.47.1", features = ["macros", "rt-multi-thread", "sync", "time"] } + +[lints] +workspace = true diff --git a/core/crates/harvestcircle_nostr/src/client.rs b/core/crates/harvestcircle_nostr/src/client.rs @@ -0,0 +1,339 @@ +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; + +use harvestcircle_domain::{ + PublicKey, RelayDestinationPolicy, RelayUrl, SafeError, SafeErrorCode, SafeMessage, + select_latest_kind0, +}; +use radroots_transport::{ + EventSource, FetchRequest, Target, TargetSet, + outcome::FetchTargetState, + source::{FetchBounds, FetchSelector}, +}; +use radroots_transport_nostr::{Config, NostrTransport, RelayUrlPolicy}; + +use harvestcircle_application::{ + BoxFuture, MAX_CONFIGURED_RELAYS, NostrClient, ProfileFetchResult, +}; + +pub struct SdkNostrClient { + timeout: Duration, +} + +const MAX_PROFILE_EVENTS_PER_RELAY: usize = 64; + +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], + deadline: Instant, + ) -> BoxFuture<'a, Result<ProfileFetchResult, SafeError>> { + Box::pin(async move { + if relays.is_empty() { + return Err(invalid_relay_configuration()); + } + if relays.len() > MAX_CONFIGURED_RELAYS { + return Err(invalid_relay_configuration()); + } + + let author = public_key.canonical(); + let deadline = deadline.min(Instant::now() + self.timeout); + let mut candidates = Vec::new(); + let mut successful_relays = 0usize; + for policy in [ + RelayDestinationPolicy::Public, + RelayDestinationPolicy::Local, + RelayDestinationPolicy::PrivateNetwork, + ] { + let policy_relays = relays + .iter() + .filter(|relay| relay.policy() == policy) + .collect::<Vec<_>>(); + if policy_relays.is_empty() { + continue; + } + let config = Config::new( + canonical_policy(policy), + policy_relays.iter().map(|relay| relay.as_str()), + ) + .and_then(|config| { + let timeout_ms = + timeout_millis(deadline.saturating_duration_since(Instant::now())); + config.with_timeouts(timeout_ms, timeout_ms, timeout_ms) + }) + .map_err(|_| invalid_relay_configuration())?; + let targets = policy_relays + .iter() + .map(|relay| Target::nostr_relay(relay.as_str())) + .collect::<Result<Vec<_>, _>>() + .map_err(|_| invalid_relay_configuration())?; + let request = FetchRequest::new( + format!("studio-profile-{policy:?}"), + TargetSet::new(targets).map_err(|_| invalid_relay_configuration())?, + FetchBounds::new( + MAX_PROFILE_EVENTS_PER_RELAY as u16, + unix_deadline(deadline.saturating_duration_since(Instant::now()))?, + ) + .map_err(|_| invalid_relay_configuration())?, + ) + .map_err(|_| invalid_relay_configuration())? + .with_selector( + FetchSelector::all() + .with_kinds(vec![0]) + .and_then(|selector| selector.with_authors(vec![author])) + .map_err(|_| invalid_relay_configuration())?, + ); + let page = tokio::time::timeout_at( + deadline.into(), + NostrTransport::new(config).fetch(request), + ) + .await + .map_err(|_| relay_connection_failed())? + .map_err(|_| relay_connection_failed())?; + successful_relays += page + .target_outcomes() + .iter() + .filter(|outcome| { + matches!( + outcome.state(), + FetchTargetState::Complete | FetchTargetState::Partial + ) + }) + .count(); + for observed in page.events() { + candidates.push(crate::parse_verified_kind0( + observed.event().raw_json(), + public_key, + )?); + } + } + if successful_relays == 0 { + return Err(relay_connection_failed()); + } + let candidate = select_latest_kind0(candidates); + if successful_relays == relays.len() { + Ok(ProfileFetchResult::complete(candidate)) + } else { + Ok(ProfileFetchResult::partial(candidate)) + } + }) + } +} + +fn unix_deadline(timeout: Duration) -> Result<u64, SafeError> { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_err(|_| relay_connection_failed())?; + let deadline = now + .checked_add(timeout) + .ok_or_else(relay_connection_failed)?; + u64::try_from(deadline.as_millis()) + .ok() + .filter(|deadline| *deadline > 0) + .ok_or_else(relay_connection_failed) +} + +fn timeout_millis(timeout: Duration) -> u64 { + u64::try_from(timeout.as_millis()) + .unwrap_or(u64::MAX) + .clamp(1, 120_000) +} + +const fn canonical_policy(policy: RelayDestinationPolicy) -> RelayUrlPolicy { + match policy { + RelayDestinationPolicy::Public => RelayUrlPolicy::Public, + RelayDestinationPolicy::Local => RelayUrlPolicy::Local, + RelayDestinationPolicy::PrivateNetwork => RelayUrlPolicy::PrivateNetwork, + } +} + +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."), + ) +} + +#[cfg(test)] +mod tests { + use std::time::Duration; + + use harvestcircle_domain::{PublicKey, RelayDestinationPolicy, RelayUrl, SafeErrorCode}; + use nostr::{EventBuilder, Keys, Metadata}; + use nostr_relay_builder::MockRelay; + use nostr_sdk::Client; + + use harvestcircle_application::NostrClient; + + use crate::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(), RelayDestinationPolicy::Local) + .expect("domain relay URL"); + let public_key = + PublicKey::from_bytes(keys.public_key().to_bytes()).expect("valid public key"); + let fetched = adapter + .fetch_profile( + public_key, + &[domain_relay], + std::time::Instant::now() + Duration::from_secs(2), + ) + .await + .expect("fetch profile"); + let (profile, completeness) = fetched.into_parts(); + let profile = profile.expect("published profile"); + + assert_eq!(profile.author(), public_key); + assert_eq!(profile.metadata().preferred_name(), Some("Farm Account")); + assert_eq!( + completeness, + harvestcircle_application::RelayFetchCompleteness::Complete + ); + 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([7; 32]).expect("valid public key"), + &[], + std::time::Instant::now() + Duration::from_millis(10), + ) + .await + .expect_err("empty relay list"); + + assert_eq!(error.code(), SafeErrorCode::InvalidRelayConfiguration); + + let relay = RelayUrl::parse("wss://relay.example.test", RelayDestinationPolicy::Public) + .expect("relay URL"); + let too_many = vec![relay; harvestcircle_application::MAX_CONFIGURED_RELAYS + 1]; + let error = SdkNostrClient::new(Duration::from_millis(10)) + .fetch_profile( + PublicKey::from_bytes([7; 32]).expect("valid public key"), + &too_many, + std::time::Instant::now() + Duration::from_millis(10), + ) + .await + .expect_err("oversized relay list"); + assert_eq!(error.code(), SafeErrorCode::InvalidRelayConfiguration); + } + + #[tokio::test] + async fn sdk_client_fails_when_no_configured_relay_completes() { + let relay = RelayUrl::parse("ws://127.0.0.1:1", RelayDestinationPolicy::Local) + .expect("unavailable relay"); + let error = SdkNostrClient::new(Duration::from_millis(25)) + .fetch_profile( + PublicKey::from_bytes([7; 32]).expect("valid public key"), + &[relay], + std::time::Instant::now() + Duration::from_millis(50), + ) + .await + .expect_err("all relays unavailable"); + assert_eq!(error.code(), SafeErrorCode::RelayConnectionFailed); + } + + #[tokio::test] + async fn sdk_client_reports_partial_when_one_configured_relay_is_unavailable() { + 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 + .send_event_builder(EventBuilder::metadata(&Metadata::new().name("Partial"))) + .await + .expect("publish metadata"); + + let configured = [ + RelayUrl::parse(relay_url.as_str(), RelayDestinationPolicy::Local).expect("live relay"), + RelayUrl::parse("ws://127.0.0.1:1", RelayDestinationPolicy::Local) + .expect("unavailable relay"), + ]; + let fetched = SdkNostrClient::new(Duration::from_millis(250)) + .fetch_profile( + PublicKey::from_bytes(keys.public_key().to_bytes()).expect("valid public key"), + &configured, + std::time::Instant::now() + Duration::from_secs(1), + ) + .await + .expect("partial fetch"); + let (candidate, completeness) = fetched.into_parts(); + assert!(candidate.is_some()); + assert_eq!( + completeness, + harvestcircle_application::RelayFetchCompleteness::Partial + ); + publisher.shutdown().await; + relay.shutdown(); + } + + #[test] + fn harvestcircle_policy_maps_exactly_to_the_canonical_transport_policy() { + assert_eq!( + super::canonical_policy(RelayDestinationPolicy::Public), + radroots_transport_nostr::RelayUrlPolicy::Public + ); + assert_eq!( + super::canonical_policy(RelayDestinationPolicy::Local), + radroots_transport_nostr::RelayUrlPolicy::Local + ); + assert_eq!( + super::canonical_policy(RelayDestinationPolicy::PrivateNetwork), + radroots_transport_nostr::RelayUrlPolicy::PrivateNetwork + ); + assert_eq!(super::timeout_millis(Duration::ZERO), 1); + assert_eq!( + super::timeout_millis(Duration::from_secs(1_000_000)), + 120_000 + ); + assert!(super::unix_deadline(Duration::from_secs(1)).is_ok()); + assert_eq!( + super::invalid_relay_configuration().code(), + SafeErrorCode::InvalidRelayConfiguration + ); + assert_eq!( + super::relay_connection_failed().code(), + SafeErrorCode::RelayConnectionFailed + ); + } +} diff --git a/core/crates/studio_nostr/src/keys.rs b/core/crates/harvestcircle_nostr/src/keys.rs diff --git a/core/crates/harvestcircle_nostr/src/lib.rs b/core/crates/harvestcircle_nostr/src/lib.rs @@ -0,0 +1,9 @@ +#![doc = "HarvestCircle Nostr protocol adapters."] + +pub mod client; +pub mod keys; +pub mod profile; + +pub use client::SdkNostrClient; +pub use keys::NostrKeyMaterialProvider; +pub use profile::parse_verified_kind0; diff --git a/core/crates/studio_nostr/src/profile.rs b/core/crates/harvestcircle_nostr/src/profile.rs diff --git a/core/crates/harvestcircle_preferences/Cargo.toml b/core/crates/harvestcircle_preferences/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "harvestcircle_preferences" -description = "Private preference model for Radroots Studio" +description = "Private preference model for HarvestCircle" version = "0.1.0-alpha" edition.workspace = true authors.workspace = true diff --git a/core/crates/harvestcircle_runtime/Cargo.toml b/core/crates/harvestcircle_runtime/Cargo.toml @@ -0,0 +1,29 @@ +[package] +name = "harvestcircle_runtime" +description = "Private supervised composition runtime for HarvestCircle" +version = "0.1.0-alpha" +edition.workspace = true +authors.workspace = true +rust-version.workspace = true +license = "GPL-3.0-only" +repository.workspace = true +homepage.workspace = true +publish = false +include = ["src/**", "tests/**", "Cargo.toml"] + +[dependencies] +harvestcircle_application.workspace = true +harvestcircle_domain.workspace = true +harvestcircle_nostr.workspace = true +harvestcircle_storage.workspace = true +tokio = { version = "=1.47.1", features = ["macros", "rt-multi-thread", "sync", "time"] } +uuid.workspace = true + +[dev-dependencies] +nostr = { git = "https://github.com/rust-nostr/nostr.git", rev = "5bba5163eb77107f82c4a8262cf29d7f33a73219", package = "nostr" } +nostr-relay-builder = { git = "https://github.com/rust-nostr/nostr.git", rev = "5bba5163eb77107f82c4a8262cf29d7f33a73219", package = "nostr-relay-builder" } +nostr-sdk = { git = "https://github.com/rust-nostr/nostr.git", rev = "5bba5163eb77107f82c4a8262cf29d7f33a73219", package = "nostr-sdk" } +tempfile = "=3.23.0" + +[lints] +workspace = true diff --git a/core/crates/studio_runtime/src/blocking.rs b/core/crates/harvestcircle_runtime/src/blocking.rs diff --git a/core/crates/studio_runtime/src/installation.rs b/core/crates/harvestcircle_runtime/src/installation.rs diff --git a/core/crates/harvestcircle_runtime/src/lib.rs b/core/crates/harvestcircle_runtime/src/lib.rs @@ -0,0 +1,12 @@ +#![doc = "HarvestCircle supervised runtime composition."] + +mod blocking; +mod installation; +mod persistence; +mod runtime_actor; + +pub use installation::{ + InstallationIdentity, InstallationIdentitySource, UuidInstallationIdentitySource, +}; +pub use persistence::PersistentAppCore; +pub use runtime_actor::{RuntimeActorHandle, RuntimeChangeSubscription, RuntimeDependencies}; diff --git a/core/crates/harvestcircle_runtime/src/persistence.rs b/core/crates/harvestcircle_runtime/src/persistence.rs @@ -0,0 +1,746 @@ +use std::path::Path; +use std::sync::Arc; + +use harvestcircle_application::{ + AppCore, AppSnapshot, Clock, DurableRequestId, GenerateAccountReceipt, ImportAccountReceipt, + KeyMaterialProvider, RelayConfiguration, RemovalConfirmationToken, SecretStore, + StagedGeneratedKey, +}; +use harvestcircle_domain::{PublicKey, SafeError, SecretKeyInput}; +use harvestcircle_nostr::NostrKeyMaterialProvider; + +use harvestcircle_storage::Database; + +use crate::{InstallationIdentity, InstallationIdentitySource}; + +pub struct PersistentAppCore { + core: AppCore, + database: Database, + key_material: Arc<dyn KeyMaterialProvider>, +} + +impl PersistentAppCore { + pub(crate) fn initialize_installation_identity( + &self, + source: &dyn InstallationIdentitySource, + ) -> Result<InstallationIdentity, SafeError> { + if let Some(existing) = self.database.load_installation_id()? { + return InstallationIdentity::parse(existing); + } + let candidate = source.generate()?; + InstallationIdentity::parse( + self.database + .initialize_installation_id(candidate.as_str())?, + ) + } + + /// Commits an acknowledged generated-key stage through the durable coordinator. + /// + /// # Errors + /// + /// Returns a safe conflict, credential, storage, or recovery error. + pub fn commit_staged_generated_key( + &self, + request_id: &DurableRequestId, + staged: StagedGeneratedKey, + secrets: &(impl SecretStore + ?Sized), + clock: &(impl Clock + ?Sized), + ) -> Result<ImportAccountReceipt, SafeError> { + self.core.commit_staged_generated_key( + request_id, + staged, + &self.database, + &self.database, + secrets, + &self.database, + clock, + ) + } + + /// Opens the application database without accessing credentials or relays. + /// + /// # Errors + /// + /// Returns a safe storage error when the database cannot be opened or migrated. + pub fn open(path: &Path, relay_configuration: RelayConfiguration) -> Result<Self, SafeError> { + let key_material: Arc<dyn KeyMaterialProvider> = Arc::new(NostrKeyMaterialProvider); + Ok(Self { + core: AppCore::new(relay_configuration, Arc::clone(&key_material)), + database: Database::open(path)?, + key_material, + }) + } + + /// Creates an isolated persistent-core adapter for tests. + /// + /// # Errors + /// + /// Returns a safe storage error when the database cannot be initialized. + pub fn in_memory(relay_configuration: RelayConfiguration) -> Result<Self, SafeError> { + let key_material: Arc<dyn KeyMaterialProvider> = Arc::new(NostrKeyMaterialProvider); + Ok(Self { + core: AppCore::new(relay_configuration, Arc::clone(&key_material)), + database: Database::in_memory()?, + key_material, + }) + } + + pub(crate) fn key_material(&self) -> &dyn KeyMaterialProvider { + self.key_material.as_ref() + } + + /// Restores public accounts and selection while keeping the session signed out. + /// + /// # Errors + /// + /// Returns a safe storage or application-state error after publishing a fatal + /// snapshot when durable state cannot be restored. + pub fn bootstrap( + &self, + secrets: &(impl SecretStore + ?Sized), + clock: &(impl Clock + ?Sized), + ) -> Result<AppSnapshot, SafeError> { + self.core.recover_durable_operations( + &self.database, + &self.database, + secrets, + &self.database, + clock, + )?; + self.core.recover_pending_operations( + &self.database, + &self.database, + secrets, + &self.database, + clock, + )?; + self.core.bootstrap_from(&self.database, &self.database) + } + + /// Generates and durably persists one selected, signed-out local account. + /// + /// # Errors + /// + /// Returns a safe credential, storage, key, or application-state error. + pub fn generate_account( + &self, + secrets: &(impl SecretStore + ?Sized), + clock: &(impl Clock + ?Sized), + ) -> Result<GenerateAccountReceipt, SafeError> { + self.core.generate_account( + &self.database, + &self.database, + secrets, + &self.database, + clock, + ) + } + + /// Imports and durably persists one selected, signed-out local account. + /// + /// # Errors + /// + /// Returns a safe credential, storage, key, or application-state error. + pub fn import_secret_key( + &self, + input: SecretKeyInput, + secrets: &(impl SecretStore + ?Sized), + clock: &(impl Clock + ?Sized), + ) -> Result<ImportAccountReceipt, SafeError> { + self.core.import_secret_key( + input, + &self.database, + &self.database, + secrets, + &self.database, + clock, + ) + } + + /// Generates an account through the durable request coordinator. + /// + /// # Errors + /// + /// Returns a safe conflict, credential, storage, or application-state error. + pub fn generate_account_durable( + &self, + request_id: &DurableRequestId, + expected_revision: u64, + secrets: &(impl SecretStore + ?Sized), + clock: &(impl Clock + ?Sized), + ) -> Result<GenerateAccountReceipt, SafeError> { + self.core.generate_account_durable( + request_id, + expected_revision, + &self.database, + &self.database, + secrets, + &self.database, + clock, + ) + } + + /// Imports or repairs an account through the durable request coordinator. + /// + /// # Errors + /// + /// Returns a safe conflict, validation, credential, storage, or state error. + pub fn import_secret_key_durable( + &self, + request_id: &DurableRequestId, + expected_revision: u64, + input: SecretKeyInput, + secrets: &(impl SecretStore + ?Sized), + clock: &(impl Clock + ?Sized), + ) -> Result<ImportAccountReceipt, SafeError> { + self.core.import_secret_key_durable( + request_id, + expected_revision, + input, + &self.database, + &self.database, + secrets, + &self.database, + clock, + ) + } + + /// Persists and publishes one saved-account selection without activation. + /// + /// # Errors + /// + /// Returns a safe account, storage, or application-state error. + pub fn select_account(&self, public_key: PublicKey) -> Result<AppSnapshot, SafeError> { + self.core + .select_account(public_key, &self.database, &self.database) + } + + /// Activates a saved account after validating its credential and cached profile. + /// + /// # Errors + /// + /// Returns a safe account, credential, storage, or application-state error. + pub fn activate_account( + &self, + public_key: PublicKey, + secrets: &(impl SecretStore + ?Sized), + clock: &(impl Clock + ?Sized), + ) -> Result<AppSnapshot, SafeError> { + self.core.activate_account( + public_key, + &self.database, + &self.database, + &self.database, + secrets, + clock, + ) + } + + /// Signs out while retaining durable account data and credentials. + /// + /// # Errors + /// + /// Returns a safe application-state error if sign out cannot complete. + pub fn sign_out(&self) -> Result<AppSnapshot, SafeError> { + self.core.sign_out() + } + + /// Issues a revision-bound, single-use account-removal confirmation. + /// + /// # Errors + /// + /// Returns a safe error when the target account is not saved. + pub fn request_account_removal( + &self, + public_key: PublicKey, + clock: &(impl Clock + ?Sized), + ) -> Result<RemovalConfirmationToken, SafeError> { + self.core.request_account_removal(public_key, clock) + } + + /// Permanently removes one confirmed account and its credential. + /// + /// # Errors + /// + /// Returns a safe confirmation, credential, storage, recovery, or state error. + pub fn confirm_account_removal( + &self, + token: RemovalConfirmationToken, + secrets: &(impl SecretStore + ?Sized), + clock: &(impl Clock + ?Sized), + ) -> Result<AppSnapshot, SafeError> { + self.core.confirm_account_removal( + token, + &self.database, + &self.database, + secrets, + &self.database, + clock, + ) + } + + /// Executes a confirmed removal through the durable request coordinator. + /// + /// # Errors + /// + /// Returns a safe expiry, conflict, credential, storage, recovery, or state error. + pub fn confirm_account_removal_durable( + &self, + request_id: &DurableRequestId, + token: RemovalConfirmationToken, + secrets: &(impl SecretStore + ?Sized), + clock: &(impl Clock + ?Sized), + ) -> Result<AppSnapshot, SafeError> { + self.core.confirm_account_removal_durable( + request_id, + token, + &self.database, + &self.database, + secrets, + &self.database, + clock, + ) + } + + #[must_use] + pub const fn core(&self) -> &AppCore { + &self.core + } + + #[must_use] + pub const fn database(&self) -> &Database { + &self.database + } +} + +#[cfg(test)] +mod tests { + use std::fs; + + use harvestcircle_application::{ + AccountOperationKind, AccountOperationPhase, AccountRepository, AppLifecycle, + AppStateRepository, Clock, DurableOperationKind, DurableOperationPhase, + DurableOperationRepository, DurableRequestId, DurableTerminalOutcome, FailureSecretStore, + InMemorySecretStore, OperationJournal, OperationPriorState, RelayConfiguration, + SecretStore, SecretStoreOperation, SessionState, + }; + use harvestcircle_domain::{ + AccountCreatedAt, AccountIdentity, AccountSummary, BindingAvailability, LocalSignerBinding, + PublicKey, SafeErrorCode, SecretKeyInput, UnixTimestamp, + }; + use tempfile::tempdir; + + use super::PersistentAppCore; + + fn account() -> AccountSummary { + let public_key = PublicKey::from_bytes([7; 32]).expect("valid public key"); + AccountSummary::new( + AccountIdentity::derive(public_key).expect("identity"), + LocalSignerBinding::new(public_key, BindingAvailability::Available), + None, + AccountCreatedAt::new(UnixTimestamp::from_seconds(1).expect("time")), + None, + ) + .expect("account") + } + + struct FixedClock; + + impl Clock for FixedClock { + fn now(&self) -> UnixTimestamp { + UnixTimestamp::from_seconds(25).expect("time") + } + } + + #[test] + fn persistent_bootstrap_handles_fresh_and_existing_signed_out_state() { + let directory = tempdir().expect("directory"); + let path = directory.path().join("studio.sqlite3"); + let public_key = account().public_key(); + let secrets = InMemorySecretStore::default(); + { + let adapter = PersistentAppCore::open(&path, RelayConfiguration::default()) + .expect("open adapter"); + let fresh = adapter + .bootstrap(&secrets, &FixedClock) + .expect("fresh bootstrap"); + assert!(fresh.accounts().is_empty()); + adapter + .database() + .insert_account(&account()) + .expect("account"); + adapter + .database() + .save_selected_account(Some(public_key)) + .expect("selection"); + } + + let adapter = + PersistentAppCore::open(&path, RelayConfiguration::default()).expect("reopen adapter"); + let restored = adapter.bootstrap(&secrets, &FixedClock).expect("restore"); + assert_eq!(restored.lifecycle(), AppLifecycle::Ready); + assert_eq!(restored.accounts().len(), 1); + assert_eq!(restored.selected_account(), Some(public_key)); + assert_eq!(restored.session(), SessionState::SignedOut); + assert!(restored.active_account().is_none()); + } + + #[test] + fn corrupt_database_fails_safely_without_recreation() { + let directory = tempdir().expect("directory"); + let path = directory.path().join("studio.sqlite3"); + fs::write(&path, b"not a sqlite database").expect("corrupt file"); + + let error = PersistentAppCore::open(&path, RelayConfiguration::default()) + .err() + .expect("safe failure"); + assert_eq!(error.code(), SafeErrorCode::StorageCorrupt); + assert_eq!( + fs::read(&path).expect("unchanged file"), + b"not a sqlite database" + ); + } + + #[test] + fn persisted_generate_and_import_survive_restart_without_secret_bytes() { + let directory = tempdir().expect("directory"); + let path = directory.path().join("studio.sqlite3"); + let secrets = InMemorySecretStore::default(); + let selected; + { + let adapter = + PersistentAppCore::open(&path, RelayConfiguration::default()).expect("adapter"); + adapter.bootstrap(&secrets, &FixedClock).expect("bootstrap"); + let generated = adapter + .generate_account(&secrets, &FixedClock) + .expect("generate"); + assert!( + secrets + .contains(generated.account().public_key()) + .expect("generated credential") + ); + let imported = adapter + .import_secret_key( + SecretKeyInput::parse( + "7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7" + .to_owned(), + ) + .expect("secret"), + &secrets, + &FixedClock, + ) + .expect("import"); + selected = imported.account().public_key(); + assert_eq!(adapter.core().snapshot().accounts().len(), 2); + } + + let bytes = fs::read(&path).expect("database bytes"); + assert!(!bytes.windows(5).any(|value| value == b"nsec1")); + assert!(!bytes.windows(64).any(|value| { + value == b"7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7" + })); + let reopened = + PersistentAppCore::open(&path, RelayConfiguration::default()).expect("reopen"); + let restored = reopened.bootstrap(&secrets, &FixedClock).expect("restore"); + assert_eq!(restored.accounts().len(), 2); + assert_eq!(restored.selected_account(), Some(selected)); + assert_eq!(restored.session(), SessionState::SignedOut); + } + + #[test] + fn durable_import_commits_each_phase_and_recovers_the_terminal_receipt() { + let adapter = PersistentAppCore::in_memory(RelayConfiguration::default()).expect("adapter"); + let secrets = InMemorySecretStore::default(); + let snapshot = adapter.bootstrap(&secrets, &FixedClock).expect("bootstrap"); + let request = DurableRequestId::parse("import:adapter:1").expect("request"); + let imported = adapter + .import_secret_key_durable( + &request, + snapshot.revision().value(), + SecretKeyInput::parse( + "7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7".to_owned(), + ) + .expect("secret"), + &secrets, + &FixedClock, + ) + .expect("durable import"); + let operation = adapter + .database() + .load_durable_operation(&request) + .expect("operation") + .expect("durable record"); + let receipt = operation.terminal().expect("terminal receipt"); + assert_eq!(receipt.account(), imported.account().public_key()); + assert_eq!( + receipt.resulting_revision(), + Some(adapter.core().snapshot().revision().value()) + ); + } + + #[test] + fn durable_recovery_preserves_repair_metadata_and_deletes_orphan_credentials() { + let adapter = PersistentAppCore::in_memory(RelayConfiguration::default()).expect("adapter"); + let secrets = InMemorySecretStore::default(); + let missing = account().with_binding_availability(BindingAvailability::CredentialMissing); + adapter + .database() + .insert_account(&missing) + .expect("account"); + adapter + .database() + .save_selected_account(Some(missing.public_key())) + .expect("selection"); + let request = DurableRequestId::parse("repair:recovery:1").expect("request"); + adapter + .database() + .begin_durable_operation( + &request, + DurableOperationKind::Repair, + missing.public_key(), + Some(0), + OperationPriorState::new( + Some(missing.public_key()), + Some(BindingAvailability::CredentialMissing), + ), + FixedClock.now(), + ) + .expect("intent"); + secrets + .put( + missing.public_key(), + SecretKeyInput::parse( + "7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7".to_owned(), + ) + .expect("secret"), + ) + .expect("credential"); + adapter + .database() + .advance_durable_operation( + &request, + DurableOperationPhase::IntentRecorded, + DurableOperationPhase::CredentialWritten, + FixedClock.now(), + None, + ) + .expect("credential phase"); + + adapter.bootstrap(&secrets, &FixedClock).expect("recovery"); + let repaired = adapter + .database() + .find_account(missing.public_key()) + .expect("lookup") + .expect("preserved account"); + assert_eq!( + repaired.signer().availability(), + BindingAvailability::CredentialMissing + ); + assert!(!secrets.contains(missing.public_key()).expect("credential")); + assert_eq!( + adapter + .database() + .load_durable_operation(&request) + .expect("operation") + .expect("record") + .terminal() + .expect("receipt") + .outcome(), + DurableTerminalOutcome::Failed + ); + } + + #[test] + fn durable_recovery_covers_response_loss_and_irreversible_removal_windows() { + let secrets = InMemorySecretStore::default(); + let adapter = PersistentAppCore::in_memory(RelayConfiguration::default()).expect("adapter"); + let saved = account(); + adapter.database().insert_account(&saved).expect("account"); + let import = DurableRequestId::parse("import:response-loss:1").expect("request"); + adapter + .database() + .begin_durable_operation( + &import, + DurableOperationKind::Import, + saved.public_key(), + Some(0), + OperationPriorState::new(None, None), + FixedClock.now(), + ) + .expect("intent"); + adapter + .database() + .advance_durable_operation( + &import, + DurableOperationPhase::IntentRecorded, + DurableOperationPhase::CredentialWritten, + FixedClock.now(), + None, + ) + .expect("credential"); + adapter + .database() + .advance_durable_operation( + &import, + DurableOperationPhase::CredentialWritten, + DurableOperationPhase::MetadataCommitted, + FixedClock.now(), + None, + ) + .expect("metadata"); + let restored = adapter + .bootstrap(&secrets, &FixedClock) + .expect("response recovery"); + assert_eq!(restored.selected_account(), Some(saved.public_key())); + assert_eq!( + adapter + .database() + .load_durable_operation(&import) + .expect("operation") + .expect("record") + .terminal() + .expect("receipt") + .outcome(), + DurableTerminalOutcome::Completed + ); + + let removal_adapter = + PersistentAppCore::in_memory(RelayConfiguration::default()).expect("remove adapter"); + removal_adapter + .database() + .insert_account(&saved) + .expect("remove account"); + removal_adapter + .database() + .save_selected_account(Some(saved.public_key())) + .expect("remove selection"); + let removal = DurableRequestId::parse("remove:response-loss:1").expect("request"); + removal_adapter + .database() + .begin_durable_operation( + &removal, + DurableOperationKind::Remove, + saved.public_key(), + Some(0), + OperationPriorState::new(None, Some(BindingAvailability::Available)), + FixedClock.now(), + ) + .expect("remove intent"); + removal_adapter + .database() + .advance_durable_operation( + &removal, + DurableOperationPhase::IntentRecorded, + DurableOperationPhase::CredentialDeleted, + FixedClock.now(), + None, + ) + .expect("credential deleted"); + let removed = removal_adapter + .bootstrap(&secrets, &FixedClock) + .expect("removal recovery"); + assert!(removed.accounts().is_empty()); + assert_eq!(removed.selected_account(), None); + } + + #[test] + fn bootstrap_recovery_completes_credential_deleted_removal_and_fallback() { + let directory = tempdir().expect("directory"); + let path = directory.path().join("studio.sqlite3"); + let secrets = InMemorySecretStore::default(); + let first; + let removed; + { + let adapter = + PersistentAppCore::open(&path, RelayConfiguration::default()).expect("adapter"); + adapter.bootstrap(&secrets, &FixedClock).expect("bootstrap"); + first = adapter + .generate_account(&secrets, &FixedClock) + .expect("first") + .account() + .public_key(); + removed = adapter + .generate_account(&secrets, &FixedClock) + .expect("removed") + .account() + .public_key(); + let operation = adapter + .database() + .begin_operation(AccountOperationKind::Remove, removed, FixedClock.now()) + .expect("intent"); + secrets.delete(removed).expect("credential deletion"); + adapter + .database() + .update_operation( + operation, + AccountOperationPhase::CredentialDeleted, + FixedClock.now(), + None, + ) + .expect("phase"); + } + + let reopened = + PersistentAppCore::open(&path, RelayConfiguration::default()).expect("reopen"); + let restored = reopened + .bootstrap(&secrets, &FixedClock) + .expect("recover and bootstrap"); + assert_eq!(restored.accounts().len(), 1); + assert_eq!(restored.selected_account(), Some(first)); + assert_eq!(restored.session(), SessionState::SignedOut); + assert!( + reopened + .database() + .list_pending_operations() + .expect("journal") + .is_empty() + ); + assert!( + reopened + .database() + .find_account(removed) + .expect("removed") + .is_none() + ); + } + + #[test] + fn bootstrap_skips_keyring_when_journal_empty_and_retains_failed_intent() { + let empty = PersistentAppCore::in_memory(RelayConfiguration::default()).expect("empty"); + let unavailable = FailureSecretStore::default(); + unavailable.fail_next(SecretStoreOperation::Delete); + empty + .bootstrap(&unavailable, &FixedClock) + .expect("empty journal does not access keyring"); + + let adapter = PersistentAppCore::in_memory(RelayConfiguration::default()).expect("adapter"); + adapter + .database() + .insert_account(&account()) + .expect("account"); + adapter + .database() + .save_selected_account(Some(account().public_key())) + .expect("selection"); + adapter + .database() + .begin_operation( + AccountOperationKind::Remove, + account().public_key(), + FixedClock.now(), + ) + .expect("intent"); + let failing = FailureSecretStore::default(); + failing.fail_next(SecretStoreOperation::Delete); + let error = adapter + .bootstrap(&failing, &FixedClock) + .expect_err("keyring unavailable"); + assert_eq!(error.code(), SafeErrorCode::KeyringUnavailable); + let pending = adapter + .database() + .list_pending_operations() + .expect("pending"); + assert_eq!(pending.len(), 1); + assert_eq!(pending[0].phase(), AccountOperationPhase::IntentRecorded); + } +} diff --git a/core/crates/studio_runtime/src/runtime_actor.rs b/core/crates/harvestcircle_runtime/src/runtime_actor.rs diff --git a/core/crates/harvestcircle_runtime/tests/local_relay_e2e.rs b/core/crates/harvestcircle_runtime/tests/local_relay_e2e.rs @@ -0,0 +1,100 @@ +use std::time::Duration; + +use harvestcircle_application::{ + Clock, InMemorySecretStore, ProfileLoadState, ProfileRepository, RelayConfiguration, + RelayConnectionState, SecretStore, SessionState, +}; +use harvestcircle_domain::{RelayDestinationPolicy, RelayUrl, SecretKeyInput, UnixTimestamp}; +use harvestcircle_nostr::SdkNostrClient; +use harvestcircle_runtime::PersistentAppCore; +use nostr::{EventBuilder, Keys, Metadata}; +use nostr_relay_builder::MockRelay; +use nostr_sdk::Client; + +const SECRET_HEX: &str = "7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7"; + +struct FixedClock; + +impl Clock for FixedClock { + fn now(&self) -> UnixTimestamp { + UnixTimestamp::from_seconds(100).expect("fixed timestamp") + } +} + +#[tokio::test] +async fn local_relay_e2e_imports_activates_refreshes_and_caches_profile() { + let local_relay = MockRelay::run().await.expect("local relay"); + let relay_url = local_relay.url().await; + let keys = Keys::parse(SECRET_HEX).expect("known secret key"); + let publisher = Client::new(keys); + publisher + .add_relay(relay_url.clone()) + .await + .expect("publisher relay"); + publisher.connect().await; + publisher.wait_for_connection(Duration::from_secs(2)).await; + publisher + .send_event_builder(EventBuilder::metadata( + &Metadata::new() + .name("farmer") + .display_name("Farm Account") + .about("Local food profile"), + )) + .await + .expect("publish profile"); + + let relay = + RelayUrl::parse(relay_url.as_str(), RelayDestinationPolicy::Local).expect("relay URL"); + let adapter = PersistentAppCore::in_memory( + RelayConfiguration::new(vec![relay]).expect("relay configuration"), + ) + .expect("persistent adapter"); + let secrets = InMemorySecretStore::default(); + adapter.bootstrap(&secrets, &FixedClock).expect("bootstrap"); + let imported = adapter + .import_secret_key( + SecretKeyInput::parse(SECRET_HEX.to_owned()).expect("secret input"), + &secrets, + &FixedClock, + ) + .expect("import account"); + let public_key = imported.account().public_key(); + assert!(secrets.contains(public_key).expect("credential exists")); + adapter + .activate_account(public_key, &secrets, &FixedClock) + .expect("activate account"); + + let refreshed = adapter + .core() + .refresh_active_profile( + adapter.database(), + &SdkNostrClient::new(Duration::from_secs(2)), + &FixedClock, + std::time::Instant::now() + Duration::from_secs(2), + ) + .await + .expect("refresh profile"); + + assert_eq!(refreshed.session(), SessionState::Active); + let active = refreshed.active_account().expect("active account"); + assert_eq!(active.relay_state(), RelayConnectionState::Connected); + assert_eq!(active.profile_state(), ProfileLoadState::Fresh); + assert_eq!( + active.profile().and_then(|profile| profile.display_name()), + Some("Farm Account") + ); + let cached = adapter + .database() + .load_profile(public_key) + .expect("load cache") + .expect("cached profile"); + assert_eq!( + cached.candidate().metadata().preferred_name(), + Some("Farm Account") + ); + let public_debug = format!("{refreshed:?}"); + assert!(!public_debug.contains(SECRET_HEX)); + assert!(!public_debug.contains("nsec1")); + publisher.shutdown().await; + local_relay.shutdown(); +} diff --git a/core/crates/harvestcircle_runtime/tests/restart_isolation.rs b/core/crates/harvestcircle_runtime/tests/restart_isolation.rs @@ -0,0 +1,95 @@ +use std::fs; + +use harvestcircle_application::{ + AccountNamespaceRepository, AccountPreferenceKey, Clock, InMemorySecretStore, + RelayConfiguration, SessionState, +}; +use harvestcircle_domain::{SecretKeyInput, UnixTimestamp}; +use harvestcircle_runtime::PersistentAppCore; +use tempfile::tempdir; + +const SECRET_A: &str = "7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7"; +const SECRET_B: &str = "0101010101010101010101010101010101010101010101010101010101010101"; + +struct FixedClock; + +impl Clock for FixedClock { + fn now(&self) -> UnixTimestamp { + UnixTimestamp::from_seconds(200).expect("fixed timestamp") + } +} + +#[test] +fn restart_restores_selection_and_keeps_account_namespaces_isolated() { + let directory = tempdir().expect("temporary directory"); + let path = directory.path().join("studio.sqlite3"); + let secrets = InMemorySecretStore::default(); + let (owner_a, owner_b); + + { + let adapter = PersistentAppCore::open(&path, RelayConfiguration::default()) + .expect("persistent adapter"); + adapter.bootstrap(&secrets, &FixedClock).expect("bootstrap"); + owner_a = adapter + .import_secret_key( + SecretKeyInput::parse(SECRET_A.to_owned()).expect("secret A"), + &secrets, + &FixedClock, + ) + .expect("account A") + .account() + .public_key(); + owner_b = adapter + .import_secret_key( + SecretKeyInput::parse(SECRET_B.to_owned()).expect("secret B"), + &secrets, + &FixedClock, + ) + .expect("account B") + .account() + .public_key(); + adapter + .database() + .set_value(owner_a, AccountPreferenceKey::NamespaceProbe, "account-a") + .expect("namespace A"); + adapter + .database() + .set_value(owner_b, AccountPreferenceKey::NamespaceProbe, "account-b") + .expect("namespace B"); + adapter.select_account(owner_b).expect("select B"); + } + + let reopened = + PersistentAppCore::open(&path, RelayConfiguration::default()).expect("reopen adapter"); + let restored = reopened.bootstrap(&secrets, &FixedClock).expect("restore"); + assert_eq!(restored.accounts().len(), 2); + assert_eq!(restored.selected_account(), Some(owner_b)); + assert_eq!(restored.session(), SessionState::SignedOut); + assert_eq!( + reopened + .database() + .get_value(owner_a, AccountPreferenceKey::NamespaceProbe) + .expect("read A"), + Some("account-a".to_owned()) + ); + assert_eq!( + reopened + .database() + .get_value(owner_b, AccountPreferenceKey::NamespaceProbe) + .expect("read B"), + Some("account-b".to_owned()) + ); + + let database = fs::read(path).expect("database bytes"); + assert!( + !database + .windows(SECRET_A.len()) + .any(|bytes| bytes == SECRET_A.as_bytes()) + ); + assert!( + !database + .windows(SECRET_B.len()) + .any(|bytes| bytes == SECRET_B.as_bytes()) + ); + assert!(!database.windows(5).any(|bytes| bytes == b"nsec1")); +} diff --git a/core/crates/harvestcircle_storage/Cargo.toml b/core/crates/harvestcircle_storage/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "harvestcircle_storage" -description = "Private persistence and keyring adapters for Radroots Studio" +description = "Private persistence and keyring adapters for HarvestCircle" version = "0.1.0-alpha" edition.workspace = true authors.workspace = true diff --git a/core/crates/harvestcircle_storage/src/lib.rs b/core/crates/harvestcircle_storage/src/lib.rs @@ -1,4 +1,4 @@ -#![doc = "Radroots Studio persistence adapters."] +#![doc = "HarvestCircle persistence adapters."] #![cfg_attr(coverage_nightly, feature(coverage_attribute))] pub mod account_namespace; diff --git a/core/crates/studio_ffi/Cargo.toml b/core/crates/studio_ffi/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "radroots_studio_ffi" -description = "Private native FFI boundary for Radroots Studio" +description = "Private native FFI boundary for HarvestCircle" version = "0.1.0-alpha" edition.workspace = true authors.workspace = true @@ -19,8 +19,8 @@ crate-type = ["cdylib", "rlib"] directories = "=6.0.0" harvestcircle_application.workspace = true harvestcircle_domain.workspace = true -radroots_studio_nostr.workspace = true -radroots_studio_runtime.workspace = true +harvestcircle_nostr.workspace = true +harvestcircle_runtime.workspace = true harvestcircle_storage.workspace = true tokio = { version = "=1.47.1", features = ["macros", "rt-multi-thread", "sync", "time"] } uniffi = "=0.32.0" diff --git a/core/crates/studio_ffi/src/commands.rs b/core/crates/studio_ffi/src/commands.rs @@ -12,11 +12,11 @@ use harvestcircle_application::{ RemovalConfirmationToken, relay_configuration_from_environment, }; use harvestcircle_domain::{PublicKey, SafeError, SecretKeyInput, UnixTimestamp}; -use harvestcircle_storage::OsKeyringSecretStore; -use radroots_studio_nostr::SdkNostrClient; -use radroots_studio_runtime::{ +use harvestcircle_nostr::SdkNostrClient; +use harvestcircle_runtime::{ RuntimeActorHandle, RuntimeDependencies, UuidInstallationIdentitySource, }; +use harvestcircle_storage::OsKeyringSecretStore; use crate::{ AccountDto, AppSnapshotDto, WireErrorCategory, WireErrorCode, WireRecoveryAction, @@ -712,8 +712,8 @@ mod tests { use harvestcircle_application::{InMemorySecretStore, RelayConfiguration}; use harvestcircle_domain::SafeError; - use radroots_studio_nostr::SdkNostrClient; - use radroots_studio_runtime::{ + use harvestcircle_nostr::SdkNostrClient; + use harvestcircle_runtime::{ RuntimeActorHandle, RuntimeDependencies, UuidInstallationIdentitySource, }; diff --git a/core/crates/studio_ffi/src/dto.rs b/core/crates/studio_ffi/src/dto.rs @@ -448,7 +448,7 @@ mod tests { use harvestcircle_application::{ AppCore, ProfileLoadState, RelayConfiguration, RelayConnectionState, RuntimeLifecycle, }; - use radroots_studio_nostr::NostrKeyMaterialProvider; + use harvestcircle_nostr::NostrKeyMaterialProvider; use harvestcircle_domain::{BindingAvailability, SafeError, SafeErrorCode, SafeMessage}; diff --git a/core/crates/studio_ffi/src/lib.rs b/core/crates/studio_ffi/src/lib.rs @@ -1,4 +1,4 @@ -#![doc = "Radroots Studio `UniFFI` boundary."] +#![doc = "HarvestCircle `UniFFI` boundary."] #![cfg_attr(coverage_nightly, feature(coverage_attribute))] mod commands; diff --git a/core/crates/studio_ffi/src/observer.rs b/core/crates/studio_ffi/src/observer.rs @@ -217,13 +217,13 @@ mod tests { use harvestcircle_application::{InMemorySecretStore, RelayConfiguration}; use harvestcircle_domain::{RelayDestinationPolicy, RelayUrl}; + use harvestcircle_nostr::SdkNostrClient; + use harvestcircle_runtime::{ + RuntimeActorHandle, RuntimeDependencies, UuidInstallationIdentitySource, + }; use nostr::{EventBuilder, Keys, Metadata}; use nostr_relay_builder::MockRelay; use nostr_sdk::Client; - use radroots_studio_nostr::SdkNostrClient; - use radroots_studio_runtime::{ - RuntimeActorHandle, RuntimeDependencies, UuidInstallationIdentitySource, - }; use crate::commands::{ACTOR_MAILBOX_CAPACITY, RuntimeCore, SystemClock, runtime}; use crate::{ diff --git a/core/crates/studio_nostr/Cargo.toml b/core/crates/studio_nostr/Cargo.toml @@ -1,29 +0,0 @@ -[package] -name = "radroots_studio_nostr" -description = "Private Nostr adapter for Radroots Studio" -version = "0.1.0-alpha" -edition.workspace = true -authors.workspace = true -rust-version.workspace = true -license = "GPL-3.0-only" -repository.workspace = true -homepage.workspace = true -publish = false -include = ["src/**", "Cargo.toml"] - -[dependencies] -nostr = { git = "https://github.com/rust-nostr/nostr.git", rev = "5bba5163eb77107f82c4a8262cf29d7f33a73219", package = "nostr" } -nostr-sdk = { git = "https://github.com/rust-nostr/nostr.git", rev = "5bba5163eb77107f82c4a8262cf29d7f33a73219", package = "nostr-sdk" } -harvestcircle_application.workspace = true -harvestcircle_domain.workspace = true -radroots_identity.workspace = true -radroots_transport.workspace = true -radroots_transport_nostr.workspace = true -tokio = { version = "=1.47.1", features = ["sync", "time"] } - -[dev-dependencies] -nostr-relay-builder = { git = "https://github.com/rust-nostr/nostr.git", rev = "5bba5163eb77107f82c4a8262cf29d7f33a73219", package = "nostr-relay-builder" } -tokio = { version = "=1.47.1", features = ["macros", "rt-multi-thread", "sync", "time"] } - -[lints] -workspace = true diff --git a/core/crates/studio_nostr/src/client.rs b/core/crates/studio_nostr/src/client.rs @@ -1,339 +0,0 @@ -use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; - -use harvestcircle_domain::{ - PublicKey, RelayDestinationPolicy, RelayUrl, SafeError, SafeErrorCode, SafeMessage, - select_latest_kind0, -}; -use radroots_transport::{ - EventSource, FetchRequest, Target, TargetSet, - outcome::FetchTargetState, - source::{FetchBounds, FetchSelector}, -}; -use radroots_transport_nostr::{Config, NostrTransport, RelayUrlPolicy}; - -use harvestcircle_application::{ - BoxFuture, MAX_CONFIGURED_RELAYS, NostrClient, ProfileFetchResult, -}; - -pub struct SdkNostrClient { - timeout: Duration, -} - -const MAX_PROFILE_EVENTS_PER_RELAY: usize = 64; - -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], - deadline: Instant, - ) -> BoxFuture<'a, Result<ProfileFetchResult, SafeError>> { - Box::pin(async move { - if relays.is_empty() { - return Err(invalid_relay_configuration()); - } - if relays.len() > MAX_CONFIGURED_RELAYS { - return Err(invalid_relay_configuration()); - } - - let author = public_key.canonical(); - let deadline = deadline.min(Instant::now() + self.timeout); - let mut candidates = Vec::new(); - let mut successful_relays = 0usize; - for policy in [ - RelayDestinationPolicy::Public, - RelayDestinationPolicy::Local, - RelayDestinationPolicy::PrivateNetwork, - ] { - let policy_relays = relays - .iter() - .filter(|relay| relay.policy() == policy) - .collect::<Vec<_>>(); - if policy_relays.is_empty() { - continue; - } - let config = Config::new( - canonical_policy(policy), - policy_relays.iter().map(|relay| relay.as_str()), - ) - .and_then(|config| { - let timeout_ms = - timeout_millis(deadline.saturating_duration_since(Instant::now())); - config.with_timeouts(timeout_ms, timeout_ms, timeout_ms) - }) - .map_err(|_| invalid_relay_configuration())?; - let targets = policy_relays - .iter() - .map(|relay| Target::nostr_relay(relay.as_str())) - .collect::<Result<Vec<_>, _>>() - .map_err(|_| invalid_relay_configuration())?; - let request = FetchRequest::new( - format!("studio-profile-{policy:?}"), - TargetSet::new(targets).map_err(|_| invalid_relay_configuration())?, - FetchBounds::new( - MAX_PROFILE_EVENTS_PER_RELAY as u16, - unix_deadline(deadline.saturating_duration_since(Instant::now()))?, - ) - .map_err(|_| invalid_relay_configuration())?, - ) - .map_err(|_| invalid_relay_configuration())? - .with_selector( - FetchSelector::all() - .with_kinds(vec![0]) - .and_then(|selector| selector.with_authors(vec![author])) - .map_err(|_| invalid_relay_configuration())?, - ); - let page = tokio::time::timeout_at( - deadline.into(), - NostrTransport::new(config).fetch(request), - ) - .await - .map_err(|_| relay_connection_failed())? - .map_err(|_| relay_connection_failed())?; - successful_relays += page - .target_outcomes() - .iter() - .filter(|outcome| { - matches!( - outcome.state(), - FetchTargetState::Complete | FetchTargetState::Partial - ) - }) - .count(); - for observed in page.events() { - candidates.push(crate::parse_verified_kind0( - observed.event().raw_json(), - public_key, - )?); - } - } - if successful_relays == 0 { - return Err(relay_connection_failed()); - } - let candidate = select_latest_kind0(candidates); - if successful_relays == relays.len() { - Ok(ProfileFetchResult::complete(candidate)) - } else { - Ok(ProfileFetchResult::partial(candidate)) - } - }) - } -} - -fn unix_deadline(timeout: Duration) -> Result<u64, SafeError> { - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .map_err(|_| relay_connection_failed())?; - let deadline = now - .checked_add(timeout) - .ok_or_else(relay_connection_failed)?; - u64::try_from(deadline.as_millis()) - .ok() - .filter(|deadline| *deadline > 0) - .ok_or_else(relay_connection_failed) -} - -fn timeout_millis(timeout: Duration) -> u64 { - u64::try_from(timeout.as_millis()) - .unwrap_or(u64::MAX) - .clamp(1, 120_000) -} - -const fn canonical_policy(policy: RelayDestinationPolicy) -> RelayUrlPolicy { - match policy { - RelayDestinationPolicy::Public => RelayUrlPolicy::Public, - RelayDestinationPolicy::Local => RelayUrlPolicy::Local, - RelayDestinationPolicy::PrivateNetwork => RelayUrlPolicy::PrivateNetwork, - } -} - -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."), - ) -} - -#[cfg(test)] -mod tests { - use std::time::Duration; - - use harvestcircle_domain::{PublicKey, RelayDestinationPolicy, RelayUrl, SafeErrorCode}; - use nostr::{EventBuilder, Keys, Metadata}; - use nostr_relay_builder::MockRelay; - use nostr_sdk::Client; - - use harvestcircle_application::NostrClient; - - use crate::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(), RelayDestinationPolicy::Local) - .expect("domain relay URL"); - let public_key = - PublicKey::from_bytes(keys.public_key().to_bytes()).expect("valid public key"); - let fetched = adapter - .fetch_profile( - public_key, - &[domain_relay], - std::time::Instant::now() + Duration::from_secs(2), - ) - .await - .expect("fetch profile"); - let (profile, completeness) = fetched.into_parts(); - let profile = profile.expect("published profile"); - - assert_eq!(profile.author(), public_key); - assert_eq!(profile.metadata().preferred_name(), Some("Farm Account")); - assert_eq!( - completeness, - harvestcircle_application::RelayFetchCompleteness::Complete - ); - 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([7; 32]).expect("valid public key"), - &[], - std::time::Instant::now() + Duration::from_millis(10), - ) - .await - .expect_err("empty relay list"); - - assert_eq!(error.code(), SafeErrorCode::InvalidRelayConfiguration); - - let relay = RelayUrl::parse("wss://relay.example.test", RelayDestinationPolicy::Public) - .expect("relay URL"); - let too_many = vec![relay; harvestcircle_application::MAX_CONFIGURED_RELAYS + 1]; - let error = SdkNostrClient::new(Duration::from_millis(10)) - .fetch_profile( - PublicKey::from_bytes([7; 32]).expect("valid public key"), - &too_many, - std::time::Instant::now() + Duration::from_millis(10), - ) - .await - .expect_err("oversized relay list"); - assert_eq!(error.code(), SafeErrorCode::InvalidRelayConfiguration); - } - - #[tokio::test] - async fn sdk_client_fails_when_no_configured_relay_completes() { - let relay = RelayUrl::parse("ws://127.0.0.1:1", RelayDestinationPolicy::Local) - .expect("unavailable relay"); - let error = SdkNostrClient::new(Duration::from_millis(25)) - .fetch_profile( - PublicKey::from_bytes([7; 32]).expect("valid public key"), - &[relay], - std::time::Instant::now() + Duration::from_millis(50), - ) - .await - .expect_err("all relays unavailable"); - assert_eq!(error.code(), SafeErrorCode::RelayConnectionFailed); - } - - #[tokio::test] - async fn sdk_client_reports_partial_when_one_configured_relay_is_unavailable() { - 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 - .send_event_builder(EventBuilder::metadata(&Metadata::new().name("Partial"))) - .await - .expect("publish metadata"); - - let configured = [ - RelayUrl::parse(relay_url.as_str(), RelayDestinationPolicy::Local).expect("live relay"), - RelayUrl::parse("ws://127.0.0.1:1", RelayDestinationPolicy::Local) - .expect("unavailable relay"), - ]; - let fetched = SdkNostrClient::new(Duration::from_millis(250)) - .fetch_profile( - PublicKey::from_bytes(keys.public_key().to_bytes()).expect("valid public key"), - &configured, - std::time::Instant::now() + Duration::from_secs(1), - ) - .await - .expect("partial fetch"); - let (candidate, completeness) = fetched.into_parts(); - assert!(candidate.is_some()); - assert_eq!( - completeness, - harvestcircle_application::RelayFetchCompleteness::Partial - ); - publisher.shutdown().await; - relay.shutdown(); - } - - #[test] - fn studio_policy_maps_exactly_to_the_canonical_transport_policy() { - assert_eq!( - super::canonical_policy(RelayDestinationPolicy::Public), - radroots_transport_nostr::RelayUrlPolicy::Public - ); - assert_eq!( - super::canonical_policy(RelayDestinationPolicy::Local), - radroots_transport_nostr::RelayUrlPolicy::Local - ); - assert_eq!( - super::canonical_policy(RelayDestinationPolicy::PrivateNetwork), - radroots_transport_nostr::RelayUrlPolicy::PrivateNetwork - ); - assert_eq!(super::timeout_millis(Duration::ZERO), 1); - assert_eq!( - super::timeout_millis(Duration::from_secs(1_000_000)), - 120_000 - ); - assert!(super::unix_deadline(Duration::from_secs(1)).is_ok()); - assert_eq!( - super::invalid_relay_configuration().code(), - SafeErrorCode::InvalidRelayConfiguration - ); - assert_eq!( - super::relay_connection_failed().code(), - SafeErrorCode::RelayConnectionFailed - ); - } -} diff --git a/core/crates/studio_nostr/src/lib.rs b/core/crates/studio_nostr/src/lib.rs @@ -1,9 +0,0 @@ -#![doc = "Radroots Studio Nostr protocol adapters."] - -pub mod client; -pub mod keys; -pub mod profile; - -pub use client::SdkNostrClient; -pub use keys::NostrKeyMaterialProvider; -pub use profile::parse_verified_kind0; diff --git a/core/crates/studio_runtime/Cargo.toml b/core/crates/studio_runtime/Cargo.toml @@ -1,29 +0,0 @@ -[package] -name = "radroots_studio_runtime" -description = "Private supervised composition runtime for Radroots Studio" -version = "0.1.0-alpha" -edition.workspace = true -authors.workspace = true -rust-version.workspace = true -license = "GPL-3.0-only" -repository.workspace = true -homepage.workspace = true -publish = false -include = ["src/**", "tests/**", "Cargo.toml"] - -[dependencies] -harvestcircle_application.workspace = true -harvestcircle_domain.workspace = true -radroots_studio_nostr.workspace = true -harvestcircle_storage.workspace = true -tokio = { version = "=1.47.1", features = ["macros", "rt-multi-thread", "sync", "time"] } -uuid.workspace = true - -[dev-dependencies] -nostr = { git = "https://github.com/rust-nostr/nostr.git", rev = "5bba5163eb77107f82c4a8262cf29d7f33a73219", package = "nostr" } -nostr-relay-builder = { git = "https://github.com/rust-nostr/nostr.git", rev = "5bba5163eb77107f82c4a8262cf29d7f33a73219", package = "nostr-relay-builder" } -nostr-sdk = { git = "https://github.com/rust-nostr/nostr.git", rev = "5bba5163eb77107f82c4a8262cf29d7f33a73219", package = "nostr-sdk" } -tempfile = "=3.23.0" - -[lints] -workspace = true diff --git a/core/crates/studio_runtime/src/lib.rs b/core/crates/studio_runtime/src/lib.rs @@ -1,12 +0,0 @@ -#![doc = "Radroots Studio supervised runtime composition."] - -mod blocking; -mod installation; -mod persistence; -mod runtime_actor; - -pub use installation::{ - InstallationIdentity, InstallationIdentitySource, UuidInstallationIdentitySource, -}; -pub use persistence::PersistentAppCore; -pub use runtime_actor::{RuntimeActorHandle, RuntimeChangeSubscription, RuntimeDependencies}; diff --git a/core/crates/studio_runtime/src/persistence.rs b/core/crates/studio_runtime/src/persistence.rs @@ -1,746 +0,0 @@ -use std::path::Path; -use std::sync::Arc; - -use harvestcircle_application::{ - AppCore, AppSnapshot, Clock, DurableRequestId, GenerateAccountReceipt, ImportAccountReceipt, - KeyMaterialProvider, RelayConfiguration, RemovalConfirmationToken, SecretStore, - StagedGeneratedKey, -}; -use harvestcircle_domain::{PublicKey, SafeError, SecretKeyInput}; -use radroots_studio_nostr::NostrKeyMaterialProvider; - -use harvestcircle_storage::Database; - -use crate::{InstallationIdentity, InstallationIdentitySource}; - -pub struct PersistentAppCore { - core: AppCore, - database: Database, - key_material: Arc<dyn KeyMaterialProvider>, -} - -impl PersistentAppCore { - pub(crate) fn initialize_installation_identity( - &self, - source: &dyn InstallationIdentitySource, - ) -> Result<InstallationIdentity, SafeError> { - if let Some(existing) = self.database.load_installation_id()? { - return InstallationIdentity::parse(existing); - } - let candidate = source.generate()?; - InstallationIdentity::parse( - self.database - .initialize_installation_id(candidate.as_str())?, - ) - } - - /// Commits an acknowledged generated-key stage through the durable coordinator. - /// - /// # Errors - /// - /// Returns a safe conflict, credential, storage, or recovery error. - pub fn commit_staged_generated_key( - &self, - request_id: &DurableRequestId, - staged: StagedGeneratedKey, - secrets: &(impl SecretStore + ?Sized), - clock: &(impl Clock + ?Sized), - ) -> Result<ImportAccountReceipt, SafeError> { - self.core.commit_staged_generated_key( - request_id, - staged, - &self.database, - &self.database, - secrets, - &self.database, - clock, - ) - } - - /// Opens the application database without accessing credentials or relays. - /// - /// # Errors - /// - /// Returns a safe storage error when the database cannot be opened or migrated. - pub fn open(path: &Path, relay_configuration: RelayConfiguration) -> Result<Self, SafeError> { - let key_material: Arc<dyn KeyMaterialProvider> = Arc::new(NostrKeyMaterialProvider); - Ok(Self { - core: AppCore::new(relay_configuration, Arc::clone(&key_material)), - database: Database::open(path)?, - key_material, - }) - } - - /// Creates an isolated persistent-core adapter for tests. - /// - /// # Errors - /// - /// Returns a safe storage error when the database cannot be initialized. - pub fn in_memory(relay_configuration: RelayConfiguration) -> Result<Self, SafeError> { - let key_material: Arc<dyn KeyMaterialProvider> = Arc::new(NostrKeyMaterialProvider); - Ok(Self { - core: AppCore::new(relay_configuration, Arc::clone(&key_material)), - database: Database::in_memory()?, - key_material, - }) - } - - pub(crate) fn key_material(&self) -> &dyn KeyMaterialProvider { - self.key_material.as_ref() - } - - /// Restores public accounts and selection while keeping the session signed out. - /// - /// # Errors - /// - /// Returns a safe storage or application-state error after publishing a fatal - /// snapshot when durable state cannot be restored. - pub fn bootstrap( - &self, - secrets: &(impl SecretStore + ?Sized), - clock: &(impl Clock + ?Sized), - ) -> Result<AppSnapshot, SafeError> { - self.core.recover_durable_operations( - &self.database, - &self.database, - secrets, - &self.database, - clock, - )?; - self.core.recover_pending_operations( - &self.database, - &self.database, - secrets, - &self.database, - clock, - )?; - self.core.bootstrap_from(&self.database, &self.database) - } - - /// Generates and durably persists one selected, signed-out local account. - /// - /// # Errors - /// - /// Returns a safe credential, storage, key, or application-state error. - pub fn generate_account( - &self, - secrets: &(impl SecretStore + ?Sized), - clock: &(impl Clock + ?Sized), - ) -> Result<GenerateAccountReceipt, SafeError> { - self.core.generate_account( - &self.database, - &self.database, - secrets, - &self.database, - clock, - ) - } - - /// Imports and durably persists one selected, signed-out local account. - /// - /// # Errors - /// - /// Returns a safe credential, storage, key, or application-state error. - pub fn import_secret_key( - &self, - input: SecretKeyInput, - secrets: &(impl SecretStore + ?Sized), - clock: &(impl Clock + ?Sized), - ) -> Result<ImportAccountReceipt, SafeError> { - self.core.import_secret_key( - input, - &self.database, - &self.database, - secrets, - &self.database, - clock, - ) - } - - /// Generates an account through the durable request coordinator. - /// - /// # Errors - /// - /// Returns a safe conflict, credential, storage, or application-state error. - pub fn generate_account_durable( - &self, - request_id: &DurableRequestId, - expected_revision: u64, - secrets: &(impl SecretStore + ?Sized), - clock: &(impl Clock + ?Sized), - ) -> Result<GenerateAccountReceipt, SafeError> { - self.core.generate_account_durable( - request_id, - expected_revision, - &self.database, - &self.database, - secrets, - &self.database, - clock, - ) - } - - /// Imports or repairs an account through the durable request coordinator. - /// - /// # Errors - /// - /// Returns a safe conflict, validation, credential, storage, or state error. - pub fn import_secret_key_durable( - &self, - request_id: &DurableRequestId, - expected_revision: u64, - input: SecretKeyInput, - secrets: &(impl SecretStore + ?Sized), - clock: &(impl Clock + ?Sized), - ) -> Result<ImportAccountReceipt, SafeError> { - self.core.import_secret_key_durable( - request_id, - expected_revision, - input, - &self.database, - &self.database, - secrets, - &self.database, - clock, - ) - } - - /// Persists and publishes one saved-account selection without activation. - /// - /// # Errors - /// - /// Returns a safe account, storage, or application-state error. - pub fn select_account(&self, public_key: PublicKey) -> Result<AppSnapshot, SafeError> { - self.core - .select_account(public_key, &self.database, &self.database) - } - - /// Activates a saved account after validating its credential and cached profile. - /// - /// # Errors - /// - /// Returns a safe account, credential, storage, or application-state error. - pub fn activate_account( - &self, - public_key: PublicKey, - secrets: &(impl SecretStore + ?Sized), - clock: &(impl Clock + ?Sized), - ) -> Result<AppSnapshot, SafeError> { - self.core.activate_account( - public_key, - &self.database, - &self.database, - &self.database, - secrets, - clock, - ) - } - - /// Signs out while retaining durable account data and credentials. - /// - /// # Errors - /// - /// Returns a safe application-state error if sign out cannot complete. - pub fn sign_out(&self) -> Result<AppSnapshot, SafeError> { - self.core.sign_out() - } - - /// Issues a revision-bound, single-use account-removal confirmation. - /// - /// # Errors - /// - /// Returns a safe error when the target account is not saved. - pub fn request_account_removal( - &self, - public_key: PublicKey, - clock: &(impl Clock + ?Sized), - ) -> Result<RemovalConfirmationToken, SafeError> { - self.core.request_account_removal(public_key, clock) - } - - /// Permanently removes one confirmed account and its credential. - /// - /// # Errors - /// - /// Returns a safe confirmation, credential, storage, recovery, or state error. - pub fn confirm_account_removal( - &self, - token: RemovalConfirmationToken, - secrets: &(impl SecretStore + ?Sized), - clock: &(impl Clock + ?Sized), - ) -> Result<AppSnapshot, SafeError> { - self.core.confirm_account_removal( - token, - &self.database, - &self.database, - secrets, - &self.database, - clock, - ) - } - - /// Executes a confirmed removal through the durable request coordinator. - /// - /// # Errors - /// - /// Returns a safe expiry, conflict, credential, storage, recovery, or state error. - pub fn confirm_account_removal_durable( - &self, - request_id: &DurableRequestId, - token: RemovalConfirmationToken, - secrets: &(impl SecretStore + ?Sized), - clock: &(impl Clock + ?Sized), - ) -> Result<AppSnapshot, SafeError> { - self.core.confirm_account_removal_durable( - request_id, - token, - &self.database, - &self.database, - secrets, - &self.database, - clock, - ) - } - - #[must_use] - pub const fn core(&self) -> &AppCore { - &self.core - } - - #[must_use] - pub const fn database(&self) -> &Database { - &self.database - } -} - -#[cfg(test)] -mod tests { - use std::fs; - - use harvestcircle_application::{ - AccountOperationKind, AccountOperationPhase, AccountRepository, AppLifecycle, - AppStateRepository, Clock, DurableOperationKind, DurableOperationPhase, - DurableOperationRepository, DurableRequestId, DurableTerminalOutcome, FailureSecretStore, - InMemorySecretStore, OperationJournal, OperationPriorState, RelayConfiguration, - SecretStore, SecretStoreOperation, SessionState, - }; - use harvestcircle_domain::{ - AccountCreatedAt, AccountIdentity, AccountSummary, BindingAvailability, LocalSignerBinding, - PublicKey, SafeErrorCode, SecretKeyInput, UnixTimestamp, - }; - use tempfile::tempdir; - - use super::PersistentAppCore; - - fn account() -> AccountSummary { - let public_key = PublicKey::from_bytes([7; 32]).expect("valid public key"); - AccountSummary::new( - AccountIdentity::derive(public_key).expect("identity"), - LocalSignerBinding::new(public_key, BindingAvailability::Available), - None, - AccountCreatedAt::new(UnixTimestamp::from_seconds(1).expect("time")), - None, - ) - .expect("account") - } - - struct FixedClock; - - impl Clock for FixedClock { - fn now(&self) -> UnixTimestamp { - UnixTimestamp::from_seconds(25).expect("time") - } - } - - #[test] - fn persistent_bootstrap_handles_fresh_and_existing_signed_out_state() { - let directory = tempdir().expect("directory"); - let path = directory.path().join("studio.sqlite3"); - let public_key = account().public_key(); - let secrets = InMemorySecretStore::default(); - { - let adapter = PersistentAppCore::open(&path, RelayConfiguration::default()) - .expect("open adapter"); - let fresh = adapter - .bootstrap(&secrets, &FixedClock) - .expect("fresh bootstrap"); - assert!(fresh.accounts().is_empty()); - adapter - .database() - .insert_account(&account()) - .expect("account"); - adapter - .database() - .save_selected_account(Some(public_key)) - .expect("selection"); - } - - let adapter = - PersistentAppCore::open(&path, RelayConfiguration::default()).expect("reopen adapter"); - let restored = adapter.bootstrap(&secrets, &FixedClock).expect("restore"); - assert_eq!(restored.lifecycle(), AppLifecycle::Ready); - assert_eq!(restored.accounts().len(), 1); - assert_eq!(restored.selected_account(), Some(public_key)); - assert_eq!(restored.session(), SessionState::SignedOut); - assert!(restored.active_account().is_none()); - } - - #[test] - fn corrupt_database_fails_safely_without_recreation() { - let directory = tempdir().expect("directory"); - let path = directory.path().join("studio.sqlite3"); - fs::write(&path, b"not a sqlite database").expect("corrupt file"); - - let error = PersistentAppCore::open(&path, RelayConfiguration::default()) - .err() - .expect("safe failure"); - assert_eq!(error.code(), SafeErrorCode::StorageCorrupt); - assert_eq!( - fs::read(&path).expect("unchanged file"), - b"not a sqlite database" - ); - } - - #[test] - fn persisted_generate_and_import_survive_restart_without_secret_bytes() { - let directory = tempdir().expect("directory"); - let path = directory.path().join("studio.sqlite3"); - let secrets = InMemorySecretStore::default(); - let selected; - { - let adapter = - PersistentAppCore::open(&path, RelayConfiguration::default()).expect("adapter"); - adapter.bootstrap(&secrets, &FixedClock).expect("bootstrap"); - let generated = adapter - .generate_account(&secrets, &FixedClock) - .expect("generate"); - assert!( - secrets - .contains(generated.account().public_key()) - .expect("generated credential") - ); - let imported = adapter - .import_secret_key( - SecretKeyInput::parse( - "7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7" - .to_owned(), - ) - .expect("secret"), - &secrets, - &FixedClock, - ) - .expect("import"); - selected = imported.account().public_key(); - assert_eq!(adapter.core().snapshot().accounts().len(), 2); - } - - let bytes = fs::read(&path).expect("database bytes"); - assert!(!bytes.windows(5).any(|value| value == b"nsec1")); - assert!(!bytes.windows(64).any(|value| { - value == b"7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7" - })); - let reopened = - PersistentAppCore::open(&path, RelayConfiguration::default()).expect("reopen"); - let restored = reopened.bootstrap(&secrets, &FixedClock).expect("restore"); - assert_eq!(restored.accounts().len(), 2); - assert_eq!(restored.selected_account(), Some(selected)); - assert_eq!(restored.session(), SessionState::SignedOut); - } - - #[test] - fn durable_import_commits_each_phase_and_recovers_the_terminal_receipt() { - let adapter = PersistentAppCore::in_memory(RelayConfiguration::default()).expect("adapter"); - let secrets = InMemorySecretStore::default(); - let snapshot = adapter.bootstrap(&secrets, &FixedClock).expect("bootstrap"); - let request = DurableRequestId::parse("import:adapter:1").expect("request"); - let imported = adapter - .import_secret_key_durable( - &request, - snapshot.revision().value(), - SecretKeyInput::parse( - "7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7".to_owned(), - ) - .expect("secret"), - &secrets, - &FixedClock, - ) - .expect("durable import"); - let operation = adapter - .database() - .load_durable_operation(&request) - .expect("operation") - .expect("durable record"); - let receipt = operation.terminal().expect("terminal receipt"); - assert_eq!(receipt.account(), imported.account().public_key()); - assert_eq!( - receipt.resulting_revision(), - Some(adapter.core().snapshot().revision().value()) - ); - } - - #[test] - fn durable_recovery_preserves_repair_metadata_and_deletes_orphan_credentials() { - let adapter = PersistentAppCore::in_memory(RelayConfiguration::default()).expect("adapter"); - let secrets = InMemorySecretStore::default(); - let missing = account().with_binding_availability(BindingAvailability::CredentialMissing); - adapter - .database() - .insert_account(&missing) - .expect("account"); - adapter - .database() - .save_selected_account(Some(missing.public_key())) - .expect("selection"); - let request = DurableRequestId::parse("repair:recovery:1").expect("request"); - adapter - .database() - .begin_durable_operation( - &request, - DurableOperationKind::Repair, - missing.public_key(), - Some(0), - OperationPriorState::new( - Some(missing.public_key()), - Some(BindingAvailability::CredentialMissing), - ), - FixedClock.now(), - ) - .expect("intent"); - secrets - .put( - missing.public_key(), - SecretKeyInput::parse( - "7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7".to_owned(), - ) - .expect("secret"), - ) - .expect("credential"); - adapter - .database() - .advance_durable_operation( - &request, - DurableOperationPhase::IntentRecorded, - DurableOperationPhase::CredentialWritten, - FixedClock.now(), - None, - ) - .expect("credential phase"); - - adapter.bootstrap(&secrets, &FixedClock).expect("recovery"); - let repaired = adapter - .database() - .find_account(missing.public_key()) - .expect("lookup") - .expect("preserved account"); - assert_eq!( - repaired.signer().availability(), - BindingAvailability::CredentialMissing - ); - assert!(!secrets.contains(missing.public_key()).expect("credential")); - assert_eq!( - adapter - .database() - .load_durable_operation(&request) - .expect("operation") - .expect("record") - .terminal() - .expect("receipt") - .outcome(), - DurableTerminalOutcome::Failed - ); - } - - #[test] - fn durable_recovery_covers_response_loss_and_irreversible_removal_windows() { - let secrets = InMemorySecretStore::default(); - let adapter = PersistentAppCore::in_memory(RelayConfiguration::default()).expect("adapter"); - let saved = account(); - adapter.database().insert_account(&saved).expect("account"); - let import = DurableRequestId::parse("import:response-loss:1").expect("request"); - adapter - .database() - .begin_durable_operation( - &import, - DurableOperationKind::Import, - saved.public_key(), - Some(0), - OperationPriorState::new(None, None), - FixedClock.now(), - ) - .expect("intent"); - adapter - .database() - .advance_durable_operation( - &import, - DurableOperationPhase::IntentRecorded, - DurableOperationPhase::CredentialWritten, - FixedClock.now(), - None, - ) - .expect("credential"); - adapter - .database() - .advance_durable_operation( - &import, - DurableOperationPhase::CredentialWritten, - DurableOperationPhase::MetadataCommitted, - FixedClock.now(), - None, - ) - .expect("metadata"); - let restored = adapter - .bootstrap(&secrets, &FixedClock) - .expect("response recovery"); - assert_eq!(restored.selected_account(), Some(saved.public_key())); - assert_eq!( - adapter - .database() - .load_durable_operation(&import) - .expect("operation") - .expect("record") - .terminal() - .expect("receipt") - .outcome(), - DurableTerminalOutcome::Completed - ); - - let removal_adapter = - PersistentAppCore::in_memory(RelayConfiguration::default()).expect("remove adapter"); - removal_adapter - .database() - .insert_account(&saved) - .expect("remove account"); - removal_adapter - .database() - .save_selected_account(Some(saved.public_key())) - .expect("remove selection"); - let removal = DurableRequestId::parse("remove:response-loss:1").expect("request"); - removal_adapter - .database() - .begin_durable_operation( - &removal, - DurableOperationKind::Remove, - saved.public_key(), - Some(0), - OperationPriorState::new(None, Some(BindingAvailability::Available)), - FixedClock.now(), - ) - .expect("remove intent"); - removal_adapter - .database() - .advance_durable_operation( - &removal, - DurableOperationPhase::IntentRecorded, - DurableOperationPhase::CredentialDeleted, - FixedClock.now(), - None, - ) - .expect("credential deleted"); - let removed = removal_adapter - .bootstrap(&secrets, &FixedClock) - .expect("removal recovery"); - assert!(removed.accounts().is_empty()); - assert_eq!(removed.selected_account(), None); - } - - #[test] - fn bootstrap_recovery_completes_credential_deleted_removal_and_fallback() { - let directory = tempdir().expect("directory"); - let path = directory.path().join("studio.sqlite3"); - let secrets = InMemorySecretStore::default(); - let first; - let removed; - { - let adapter = - PersistentAppCore::open(&path, RelayConfiguration::default()).expect("adapter"); - adapter.bootstrap(&secrets, &FixedClock).expect("bootstrap"); - first = adapter - .generate_account(&secrets, &FixedClock) - .expect("first") - .account() - .public_key(); - removed = adapter - .generate_account(&secrets, &FixedClock) - .expect("removed") - .account() - .public_key(); - let operation = adapter - .database() - .begin_operation(AccountOperationKind::Remove, removed, FixedClock.now()) - .expect("intent"); - secrets.delete(removed).expect("credential deletion"); - adapter - .database() - .update_operation( - operation, - AccountOperationPhase::CredentialDeleted, - FixedClock.now(), - None, - ) - .expect("phase"); - } - - let reopened = - PersistentAppCore::open(&path, RelayConfiguration::default()).expect("reopen"); - let restored = reopened - .bootstrap(&secrets, &FixedClock) - .expect("recover and bootstrap"); - assert_eq!(restored.accounts().len(), 1); - assert_eq!(restored.selected_account(), Some(first)); - assert_eq!(restored.session(), SessionState::SignedOut); - assert!( - reopened - .database() - .list_pending_operations() - .expect("journal") - .is_empty() - ); - assert!( - reopened - .database() - .find_account(removed) - .expect("removed") - .is_none() - ); - } - - #[test] - fn bootstrap_skips_keyring_when_journal_empty_and_retains_failed_intent() { - let empty = PersistentAppCore::in_memory(RelayConfiguration::default()).expect("empty"); - let unavailable = FailureSecretStore::default(); - unavailable.fail_next(SecretStoreOperation::Delete); - empty - .bootstrap(&unavailable, &FixedClock) - .expect("empty journal does not access keyring"); - - let adapter = PersistentAppCore::in_memory(RelayConfiguration::default()).expect("adapter"); - adapter - .database() - .insert_account(&account()) - .expect("account"); - adapter - .database() - .save_selected_account(Some(account().public_key())) - .expect("selection"); - adapter - .database() - .begin_operation( - AccountOperationKind::Remove, - account().public_key(), - FixedClock.now(), - ) - .expect("intent"); - let failing = FailureSecretStore::default(); - failing.fail_next(SecretStoreOperation::Delete); - let error = adapter - .bootstrap(&failing, &FixedClock) - .expect_err("keyring unavailable"); - assert_eq!(error.code(), SafeErrorCode::KeyringUnavailable); - let pending = adapter - .database() - .list_pending_operations() - .expect("pending"); - assert_eq!(pending.len(), 1); - assert_eq!(pending[0].phase(), AccountOperationPhase::IntentRecorded); - } -} diff --git a/core/crates/studio_runtime/tests/local_relay_e2e.rs b/core/crates/studio_runtime/tests/local_relay_e2e.rs @@ -1,100 +0,0 @@ -use std::time::Duration; - -use harvestcircle_application::{ - Clock, InMemorySecretStore, ProfileLoadState, ProfileRepository, RelayConfiguration, - RelayConnectionState, SecretStore, SessionState, -}; -use harvestcircle_domain::{RelayDestinationPolicy, RelayUrl, SecretKeyInput, UnixTimestamp}; -use nostr::{EventBuilder, Keys, Metadata}; -use nostr_relay_builder::MockRelay; -use nostr_sdk::Client; -use radroots_studio_nostr::SdkNostrClient; -use radroots_studio_runtime::PersistentAppCore; - -const SECRET_HEX: &str = "7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7"; - -struct FixedClock; - -impl Clock for FixedClock { - fn now(&self) -> UnixTimestamp { - UnixTimestamp::from_seconds(100).expect("fixed timestamp") - } -} - -#[tokio::test] -async fn local_relay_e2e_imports_activates_refreshes_and_caches_profile() { - let local_relay = MockRelay::run().await.expect("local relay"); - let relay_url = local_relay.url().await; - let keys = Keys::parse(SECRET_HEX).expect("known secret key"); - let publisher = Client::new(keys); - publisher - .add_relay(relay_url.clone()) - .await - .expect("publisher relay"); - publisher.connect().await; - publisher.wait_for_connection(Duration::from_secs(2)).await; - publisher - .send_event_builder(EventBuilder::metadata( - &Metadata::new() - .name("farmer") - .display_name("Farm Account") - .about("Local food profile"), - )) - .await - .expect("publish profile"); - - let relay = - RelayUrl::parse(relay_url.as_str(), RelayDestinationPolicy::Local).expect("relay URL"); - let adapter = PersistentAppCore::in_memory( - RelayConfiguration::new(vec![relay]).expect("relay configuration"), - ) - .expect("persistent adapter"); - let secrets = InMemorySecretStore::default(); - adapter.bootstrap(&secrets, &FixedClock).expect("bootstrap"); - let imported = adapter - .import_secret_key( - SecretKeyInput::parse(SECRET_HEX.to_owned()).expect("secret input"), - &secrets, - &FixedClock, - ) - .expect("import account"); - let public_key = imported.account().public_key(); - assert!(secrets.contains(public_key).expect("credential exists")); - adapter - .activate_account(public_key, &secrets, &FixedClock) - .expect("activate account"); - - let refreshed = adapter - .core() - .refresh_active_profile( - adapter.database(), - &SdkNostrClient::new(Duration::from_secs(2)), - &FixedClock, - std::time::Instant::now() + Duration::from_secs(2), - ) - .await - .expect("refresh profile"); - - assert_eq!(refreshed.session(), SessionState::Active); - let active = refreshed.active_account().expect("active account"); - assert_eq!(active.relay_state(), RelayConnectionState::Connected); - assert_eq!(active.profile_state(), ProfileLoadState::Fresh); - assert_eq!( - active.profile().and_then(|profile| profile.display_name()), - Some("Farm Account") - ); - let cached = adapter - .database() - .load_profile(public_key) - .expect("load cache") - .expect("cached profile"); - assert_eq!( - cached.candidate().metadata().preferred_name(), - Some("Farm Account") - ); - let public_debug = format!("{refreshed:?}"); - assert!(!public_debug.contains(SECRET_HEX)); - assert!(!public_debug.contains("nsec1")); - publisher.shutdown().await; - local_relay.shutdown(); -} diff --git a/core/crates/studio_runtime/tests/restart_isolation.rs b/core/crates/studio_runtime/tests/restart_isolation.rs @@ -1,95 +0,0 @@ -use std::fs; - -use harvestcircle_application::{ - AccountNamespaceRepository, AccountPreferenceKey, Clock, InMemorySecretStore, - RelayConfiguration, SessionState, -}; -use harvestcircle_domain::{SecretKeyInput, UnixTimestamp}; -use radroots_studio_runtime::PersistentAppCore; -use tempfile::tempdir; - -const SECRET_A: &str = "7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7"; -const SECRET_B: &str = "0101010101010101010101010101010101010101010101010101010101010101"; - -struct FixedClock; - -impl Clock for FixedClock { - fn now(&self) -> UnixTimestamp { - UnixTimestamp::from_seconds(200).expect("fixed timestamp") - } -} - -#[test] -fn restart_restores_selection_and_keeps_account_namespaces_isolated() { - let directory = tempdir().expect("temporary directory"); - let path = directory.path().join("studio.sqlite3"); - let secrets = InMemorySecretStore::default(); - let (owner_a, owner_b); - - { - let adapter = PersistentAppCore::open(&path, RelayConfiguration::default()) - .expect("persistent adapter"); - adapter.bootstrap(&secrets, &FixedClock).expect("bootstrap"); - owner_a = adapter - .import_secret_key( - SecretKeyInput::parse(SECRET_A.to_owned()).expect("secret A"), - &secrets, - &FixedClock, - ) - .expect("account A") - .account() - .public_key(); - owner_b = adapter - .import_secret_key( - SecretKeyInput::parse(SECRET_B.to_owned()).expect("secret B"), - &secrets, - &FixedClock, - ) - .expect("account B") - .account() - .public_key(); - adapter - .database() - .set_value(owner_a, AccountPreferenceKey::NamespaceProbe, "account-a") - .expect("namespace A"); - adapter - .database() - .set_value(owner_b, AccountPreferenceKey::NamespaceProbe, "account-b") - .expect("namespace B"); - adapter.select_account(owner_b).expect("select B"); - } - - let reopened = - PersistentAppCore::open(&path, RelayConfiguration::default()).expect("reopen adapter"); - let restored = reopened.bootstrap(&secrets, &FixedClock).expect("restore"); - assert_eq!(restored.accounts().len(), 2); - assert_eq!(restored.selected_account(), Some(owner_b)); - assert_eq!(restored.session(), SessionState::SignedOut); - assert_eq!( - reopened - .database() - .get_value(owner_a, AccountPreferenceKey::NamespaceProbe) - .expect("read A"), - Some("account-a".to_owned()) - ); - assert_eq!( - reopened - .database() - .get_value(owner_b, AccountPreferenceKey::NamespaceProbe) - .expect("read B"), - Some("account-b".to_owned()) - ); - - let database = fs::read(path).expect("database bytes"); - assert!( - !database - .windows(SECRET_A.len()) - .any(|bytes| bytes == SECRET_A.as_bytes()) - ); - assert!( - !database - .windows(SECRET_B.len()) - .any(|bytes| bytes == SECRET_B.as_bytes()) - ); - assert!(!database.windows(5).any(|bytes| bytes == b"nsec1")); -} diff --git a/core/crates/studio_uniffi_bindgen/Cargo.toml b/core/crates/studio_uniffi_bindgen/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "radroots_studio_uniffi_bindgen" -description = "Private UniFFI binding generator for Radroots Studio" +description = "Private UniFFI binding generator for HarvestCircle" version = "0.1.0-alpha" edition.workspace = true authors.workspace = true