commit 9e68fb26ba41bb74b1bcb6f6f1aebc1ea6361ee3
parent 249e12d4c6eaaa71e6fd46b0dc330af684af6c40
Author: triesap <tyson@radroots.org>
Date: Sun, 9 Aug 2026 17:48:11 +0000
nostr: own the Studio Nostr adapter
- copy the exact locked Studio Nostr adapter into HarvestCircle core
- register the adapter as a local workspace member and dependency
- retain canonical identity and transport packages at one immutable revision
- refresh the lockfile after the source-boundary change
Diffstat:
7 files changed, 722 insertions(+), 4 deletions(-)
diff --git a/core/Cargo.lock b/core/Cargo.lock
@@ -1524,6 +1524,22 @@ dependencies = [
]
[[package]]
+name = "nostr-relay-builder"
+version = "0.44.0"
+source = "git+https://github.com/rust-nostr/nostr.git?rev=5bba5163eb77107f82c4a8262cf29d7f33a73219#5bba5163eb77107f82c4a8262cf29d7f33a73219"
+dependencies = [
+ "async-utility",
+ "async-wsocket",
+ "atomic-destructor",
+ "hex",
+ "negentropy",
+ "nostr 0.44.1",
+ "nostr-database 0.44.0 (git+https://github.com/rust-nostr/nostr.git?rev=5bba5163eb77107f82c4a8262cf29d7f33a73219)",
+ "tokio",
+ "tracing",
+]
+
+[[package]]
name = "nostr-relay-pool"
version = "0.44.0"
source = "git+https://github.com/rust-nostr/nostr.git?rev=5bba5163eb77107f82c4a8262cf29d7f33a73219#5bba5163eb77107f82c4a8262cf29d7f33a73219"
@@ -1991,7 +2007,7 @@ dependencies = [
"quote",
"radroots_studio_application 0.1.0-alpha (git+https://github.com/radrootslabs/lib?rev=09065a610d95e57acdc895a14c07580fa099e7c3)",
"radroots_studio_domain 0.1.0-alpha (git+https://github.com/radrootslabs/lib?rev=09065a610d95e57acdc895a14c07580fa099e7c3)",
- "radroots_studio_nostr",
+ "radroots_studio_nostr 0.1.0-alpha (git+https://github.com/radrootslabs/lib?rev=09065a610d95e57acdc895a14c07580fa099e7c3)",
"radroots_studio_runtime",
"radroots_studio_storage 0.1.0-alpha (git+https://github.com/radrootslabs/lib?rev=09065a610d95e57acdc895a14c07580fa099e7c3)",
"sha2",
@@ -2003,6 +2019,21 @@ dependencies = [
[[package]]
name = "radroots_studio_nostr"
version = "0.1.0-alpha"
+dependencies = [
+ "nostr 0.44.1",
+ "nostr-relay-builder",
+ "nostr-sdk 0.44.0",
+ "radroots_identity",
+ "radroots_studio_application 0.1.0-alpha",
+ "radroots_studio_domain 0.1.0-alpha",
+ "radroots_transport",
+ "radroots_transport_nostr",
+ "tokio",
+]
+
+[[package]]
+name = "radroots_studio_nostr"
+version = "0.1.0-alpha"
source = "git+https://github.com/radrootslabs/lib?rev=09065a610d95e57acdc895a14c07580fa099e7c3#09065a610d95e57acdc895a14c07580fa099e7c3"
dependencies = [
"nostr 0.44.1",
@@ -2029,7 +2060,7 @@ source = "git+https://github.com/radrootslabs/lib?rev=09065a610d95e57acdc895a14c
dependencies = [
"radroots_studio_application 0.1.0-alpha (git+https://github.com/radrootslabs/lib?rev=09065a610d95e57acdc895a14c07580fa099e7c3)",
"radroots_studio_domain 0.1.0-alpha (git+https://github.com/radrootslabs/lib?rev=09065a610d95e57acdc895a14c07580fa099e7c3)",
- "radroots_studio_nostr",
+ "radroots_studio_nostr 0.1.0-alpha (git+https://github.com/radrootslabs/lib?rev=09065a610d95e57acdc895a14c07580fa099e7c3)",
"radroots_studio_storage 0.1.0-alpha (git+https://github.com/radrootslabs/lib?rev=09065a610d95e57acdc895a14c07580fa099e7c3)",
"tokio",
"uuid",
@@ -2042,7 +2073,7 @@ dependencies = [
"radroots_studio_application 0.1.0-alpha",
"radroots_studio_domain 0.1.0-alpha",
"radroots_studio_ffi",
- "radroots_studio_nostr",
+ "radroots_studio_nostr 0.1.0-alpha",
"radroots_studio_preferences",
"radroots_studio_runtime",
"radroots_studio_storage 0.1.0-alpha",
diff --git a/core/Cargo.toml b/core/Cargo.toml
@@ -3,6 +3,7 @@ members = [
"crates/source_lock",
"crates/studio_application",
"crates/studio_domain",
+ "crates/studio_nostr",
"crates/studio_preferences",
"crates/studio_storage",
]
@@ -29,11 +30,13 @@ pedantic = "deny"
radroots_studio_application = { path = "crates/studio_application", version = "=0.1.0-alpha" }
radroots_studio_domain = { path = "crates/studio_domain", version = "=0.1.0-alpha" }
radroots_studio_ffi = { git = "https://github.com/radrootslabs/lib", rev = "09065a610d95e57acdc895a14c07580fa099e7c3", version = "=0.1.0-alpha" }
-radroots_studio_nostr = { git = "https://github.com/radrootslabs/lib", rev = "09065a610d95e57acdc895a14c07580fa099e7c3", version = "=0.1.0-alpha" }
+radroots_studio_nostr = { path = "crates/studio_nostr", version = "=0.1.0-alpha" }
radroots_studio_preferences = { path = "crates/studio_preferences", version = "=0.1.0-alpha" }
radroots_studio_runtime = { git = "https://github.com/radrootslabs/lib", rev = "09065a610d95e57acdc895a14c07580fa099e7c3", version = "=0.1.0-alpha" }
radroots_studio_storage = { path = "crates/studio_storage", version = "=0.1.0-alpha" }
radroots_identity = { git = "https://github.com/radrootslabs/lib", rev = "09065a610d95e57acdc895a14c07580fa099e7c3", version = "=0.1.0-alpha", default-features = false }
+radroots_transport = { git = "https://github.com/radrootslabs/lib", rev = "09065a610d95e57acdc895a14c07580fa099e7c3", version = "=0.1.0-alpha", default-features = false }
+radroots_transport_nostr = { git = "https://github.com/radrootslabs/lib", rev = "09065a610d95e57acdc895a14c07580fa099e7c3", version = "=0.1.0-alpha", default-features = false }
getrandom = { version = "0.2", default-features = false }
hmac = { version = "0.12", default-features = false }
rustix = { version = "1", features = ["fs", "process", "std"] }
diff --git a/core/crates/studio_nostr/Cargo.toml b/core/crates/studio_nostr/Cargo.toml
@@ -0,0 +1,29 @@
+[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" }
+radroots_studio_application.workspace = true
+radroots_studio_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
@@ -0,0 +1,339 @@
+use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
+
+use radroots_studio_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 radroots_studio_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 nostr::{EventBuilder, Keys, Metadata};
+ use nostr_relay_builder::MockRelay;
+ use nostr_sdk::Client;
+ use radroots_studio_domain::{PublicKey, RelayDestinationPolicy, RelayUrl, SafeErrorCode};
+
+ use radroots_studio_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,
+ radroots_studio_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; radroots_studio_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,
+ radroots_studio_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/keys.rs b/core/crates/studio_nostr/src/keys.rs
@@ -0,0 +1,125 @@
+use nostr::{Keys, ToBech32};
+use radroots_studio_application::{GeneratedKeyMaterial, ImportedKeyMaterial, KeyMaterialProvider};
+use radroots_studio_domain::{
+ Npub, Nsec, PublicKey, SafeError, SafeErrorCode, SafeMessage, SecretKeyInput,
+};
+
+#[derive(Clone, Copy, Debug, Default)]
+pub struct NostrKeyMaterialProvider;
+
+/// Generates one cryptographically random local Nostr keypair.
+///
+/// # Errors
+///
+/// Returns a safe key error if an upstream encoding cannot be represented by
+/// the stricter Radroots domain boundary.
+impl KeyMaterialProvider for NostrKeyMaterialProvider {
+ fn generate(&self) -> Result<GeneratedKeyMaterial, SafeError> {
+ let keys = Keys::generate();
+ let (public_key, npub, secret, nsec) = encode_keys(&keys)?;
+ Ok(GeneratedKeyMaterial::new(public_key, npub, secret, nsec))
+ }
+
+ fn import(&self, input: SecretKeyInput) -> Result<ImportedKeyMaterial, SafeError> {
+ let keys = input
+ .with_exposed_secret(Keys::parse)
+ .map_err(|_| invalid_secret_key())?;
+ drop(input);
+ let public_key = PublicKey::from_bytes(keys.public_key().to_bytes())?;
+ let npub = keys
+ .public_key()
+ .to_bech32()
+ .map_err(|_| invalid_public_key())
+ .and_then(Npub::from_encoded)?;
+ let secret = SecretKeyInput::parse(keys.secret_key().to_secret_hex())?;
+ Ok(ImportedKeyMaterial::new(public_key, npub, secret))
+ }
+}
+
+fn encode_keys(keys: &Keys) -> Result<(PublicKey, Npub, SecretKeyInput, Nsec), SafeError> {
+ let public_key = PublicKey::from_bytes(keys.public_key().to_bytes())?;
+ let npub = keys
+ .public_key()
+ .to_bech32()
+ .map_err(|_| invalid_public_key())
+ .and_then(Npub::from_encoded)?;
+ let secret = SecretKeyInput::parse(keys.secret_key().to_secret_hex())?;
+ let nsec = keys
+ .secret_key()
+ .to_bech32()
+ .map_err(|_| invalid_secret_key())
+ .and_then(Nsec::from_encoded)?;
+ Ok((public_key, npub, secret, nsec))
+}
+
+const fn invalid_secret_key() -> SafeError {
+ SafeError::new(
+ SafeErrorCode::InvalidSecretKey,
+ SafeMessage::new("The Nostr secret key is invalid."),
+ )
+}
+
+const fn invalid_public_key() -> SafeError {
+ SafeError::new(
+ SafeErrorCode::InvalidPublicKey,
+ SafeMessage::new("The Nostr public key is invalid."),
+ )
+}
+
+#[cfg(test)]
+mod tests {
+ use radroots_studio_domain::{SafeErrorCode, SecretKeyInput};
+
+ use radroots_studio_application::KeyMaterialProvider;
+
+ use super::{NostrKeyMaterialProvider, invalid_public_key, invalid_secret_key};
+
+ const SECRET_HEX: &str = "7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7";
+ const NSEC: &str = "nsec1vl029mgpspedva04g90vltkh6fvh240zqtv9k0t9af8935ke9laqsnlfe5";
+ const NSEC_PUBLIC_HEX: &str =
+ "7e7e9c42a91bfef19fa929e5fda1b72e0ebc1a4c1141673e2794234d86addf4e";
+ const HEX_PUBLIC_HEX: &str = "0cfda0afa91cc2fbbd6050c285802fe95c7a1755e0f68323999e13760501dc40";
+
+ #[test]
+ fn keys_generate_valid_redacted_material() {
+ let generated = NostrKeyMaterialProvider.generate().expect("generated");
+ let (public_key, npub, secret, nsec) = generated.into_parts();
+ assert_eq!(public_key.to_hex().len(), 64);
+ assert!(npub.as_str().starts_with("npub1"));
+ assert_eq!(secret.with_exposed_secret(str::len), 64);
+ assert_eq!(nsec.with_exposed_secret(str::len), 63);
+ assert_eq!(secret.with_exposed_secret(str::len), 64);
+ assert_eq!(nsec.with_exposed_secret(str::len), 63);
+ }
+
+ #[test]
+ fn keys_import_known_nsec_and_hex_vectors() {
+ let from_nsec = NostrKeyMaterialProvider
+ .import(SecretKeyInput::parse(NSEC.to_owned()).expect("nsec"))
+ .expect("import nsec");
+ let from_hex = NostrKeyMaterialProvider
+ .import(SecretKeyInput::parse(SECRET_HEX.to_owned()).expect("hex"))
+ .expect("import hex");
+ let (nsec_public, nsec_npub, _) = from_nsec.into_parts();
+ let (hex_public, hex_npub, _) = from_hex.into_parts();
+ assert_eq!(nsec_public.to_hex(), NSEC_PUBLIC_HEX);
+ assert_eq!(hex_public.to_hex(), HEX_PUBLIC_HEX);
+ assert!(nsec_npub.as_str().starts_with("npub1"));
+ assert!(hex_npub.as_str().starts_with("npub1"));
+ }
+
+ #[test]
+ fn keys_reject_structurally_plausible_nsec_with_invalid_checksum() {
+ let input = SecretKeyInput::parse(
+ "nsec1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq".to_owned(),
+ )
+ .expect("domain shape");
+ let error = NostrKeyMaterialProvider
+ .import(input)
+ .err()
+ .expect("invalid checksum");
+ assert_eq!(error.code(), SafeErrorCode::InvalidSecretKey);
+ assert_eq!(invalid_secret_key().code(), SafeErrorCode::InvalidSecretKey);
+ assert_eq!(invalid_public_key().code(), SafeErrorCode::InvalidPublicKey);
+ }
+}
diff --git a/core/crates/studio_nostr/src/lib.rs b/core/crates/studio_nostr/src/lib.rs
@@ -0,0 +1,9 @@
+#![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_nostr/src/profile.rs b/core/crates/studio_nostr/src/profile.rs
@@ -0,0 +1,182 @@
+use nostr::{Event, JsonUtil, Kind, Metadata};
+use radroots_studio_domain::{
+ EventId, Kind0ProfileCandidate, ProfileMetadata, PublicKey, SafeError, SafeErrorCode,
+ SafeMessage, UnixTimestamp,
+};
+
+const MAX_EVENT_JSON_BYTES: usize = 64 * 1_024;
+const MAX_PROFILE_CONTENT_BYTES: usize = 16 * 1_024;
+
+/// Verifies and converts one serialized Nostr kind-0 event.
+///
+/// # Errors
+///
+/// Returns a safe profile-refresh error when the event is oversized,
+/// malformed, invalidly signed, authored by another key, or not kind 0.
+pub fn parse_verified_kind0(
+ event_json: &str,
+ expected_author: PublicKey,
+) -> Result<Kind0ProfileCandidate, SafeError> {
+ if event_json.len() > MAX_EVENT_JSON_BYTES {
+ return Err(invalid_event());
+ }
+
+ let event = Event::from_json(event_json).map_err(|_| invalid_event())?;
+ event.verify().map_err(|_| invalid_event())?;
+ if event.kind != Kind::Metadata
+ || event.pubkey.to_bytes() != *expected_author.as_bytes()
+ || event.content.len() > MAX_PROFILE_CONTENT_BYTES
+ {
+ return Err(invalid_event());
+ }
+
+ let metadata = Metadata::from_json(&event.content).map_err(|_| invalid_metadata())?;
+ let profile = ProfileMetadata::new(
+ metadata.name,
+ metadata.display_name,
+ metadata.nip05,
+ metadata.about,
+ metadata.picture,
+ )?;
+ let created_at = i64::try_from(event.created_at.as_secs())
+ .ok()
+ .and_then(UnixTimestamp::from_seconds)
+ .ok_or_else(invalid_event)?;
+
+ Ok(Kind0ProfileCandidate::new(
+ EventId::from_bytes(event.id.to_bytes()),
+ expected_author,
+ created_at,
+ profile,
+ ))
+}
+
+const fn invalid_event() -> SafeError {
+ SafeError::new(
+ SafeErrorCode::ProfileRefreshFailed,
+ SafeMessage::new("The Nostr profile event is invalid."),
+ )
+}
+
+const fn invalid_metadata() -> SafeError {
+ SafeError::new(
+ SafeErrorCode::InvalidProfileMetadata,
+ SafeMessage::new("The Nostr profile metadata is invalid."),
+ )
+}
+
+#[cfg(test)]
+mod tests {
+ use nostr::{EventBuilder, JsonUtil, Keys, Metadata, Url};
+ use radroots_studio_domain::{PublicKey, SafeErrorCode};
+
+ use super::parse_verified_kind0;
+
+ fn signed_profile() -> (Keys, String) {
+ let keys = Keys::generate();
+ let event = EventBuilder::metadata(
+ &Metadata::new()
+ .name(" farmer ")
+ .display_name(" Farm Account ")
+ .nip05("farmer@example.test")
+ .about("Local grower")
+ .picture(
+ Url::parse("https://images.example.test/farmer.png")
+ .expect("valid picture URL"),
+ ),
+ )
+ .sign_with_keys(&keys)
+ .expect("signed metadata event");
+ (keys, event.as_json())
+ }
+
+ #[test]
+ fn profile_event_verifies_signature_author_kind_and_metadata() {
+ let (keys, json) = signed_profile();
+ let expected_author =
+ PublicKey::from_bytes(keys.public_key().to_bytes()).expect("valid public key");
+
+ let candidate = parse_verified_kind0(&json, expected_author).expect("verified profile");
+
+ assert_eq!(candidate.author(), expected_author);
+ assert_eq!(candidate.metadata().name(), Some("farmer"));
+ assert_eq!(candidate.metadata().display_name(), Some("Farm Account"));
+ assert_eq!(candidate.metadata().nip05(), Some("farmer@example.test"));
+ assert_eq!(candidate.metadata().about(), Some("Local grower"));
+ assert_eq!(
+ candidate.metadata().picture(),
+ Some("https://images.example.test/farmer.png")
+ );
+ }
+
+ #[test]
+ fn profile_event_rejects_tampering_wrong_author_kind_and_oversize_content() {
+ let (keys, json) = signed_profile();
+ let expected_author =
+ PublicKey::from_bytes(keys.public_key().to_bytes()).expect("valid public key");
+ let wrong_author = PublicKey::from_bytes(Keys::generate().public_key().to_bytes())
+ .expect("valid public key");
+ let tampered = json.replace("Local grower", "Remote grower");
+ let note = EventBuilder::text_note("not metadata")
+ .sign_with_keys(&keys)
+ .expect("signed note")
+ .as_json();
+ let oversized = EventBuilder::metadata(&Metadata::new().about("x".repeat(16 * 1_024 + 1)))
+ .sign_with_keys(&keys)
+ .expect("signed oversized profile")
+ .as_json();
+
+ for rejected in [
+ parse_verified_kind0(&tampered, expected_author),
+ parse_verified_kind0(&json, wrong_author),
+ parse_verified_kind0(¬e, expected_author),
+ parse_verified_kind0(&oversized, expected_author),
+ ] {
+ assert_eq!(
+ rejected.expect_err("invalid event").code(),
+ SafeErrorCode::ProfileRefreshFailed
+ );
+ }
+ }
+
+ #[test]
+ fn profile_event_rejects_malformed_and_bounded_invalid_metadata() {
+ let keys = Keys::generate();
+ let malformed = EventBuilder::new(nostr::Kind::Metadata, "not json")
+ .sign_with_keys(&keys)
+ .expect("signed malformed metadata")
+ .as_json();
+ let invalid = EventBuilder::metadata(&Metadata::new().name("x".repeat(129)))
+ .sign_with_keys(&keys)
+ .expect("signed invalid metadata")
+ .as_json();
+ let author = PublicKey::from_bytes(keys.public_key().to_bytes()).expect("valid public key");
+
+ assert_eq!(
+ parse_verified_kind0(&malformed, author)
+ .expect_err("malformed metadata")
+ .code(),
+ SafeErrorCode::InvalidProfileMetadata
+ );
+ assert_eq!(
+ parse_verified_kind0(&invalid, author)
+ .expect_err("bounded metadata")
+ .code(),
+ SafeErrorCode::InvalidProfileMetadata
+ );
+ assert_eq!(
+ parse_verified_kind0(&"x".repeat(64 * 1_024 + 1), author)
+ .expect_err("oversized event")
+ .code(),
+ SafeErrorCode::ProfileRefreshFailed
+ );
+ assert_eq!(
+ super::invalid_event().code(),
+ SafeErrorCode::ProfileRefreshFailed
+ );
+ assert_eq!(
+ super::invalid_metadata().code(),
+ SafeErrorCode::InvalidProfileMetadata
+ );
+ }
+}