lib

Core libraries for Radroots
git clone https://radroots.dev/git/lib.git
Log | Files | Refs | README

commit 1b5c210a0d9189c5a632448bd3374e4ff871095f
parent b9a1f1e38c5c9ea0154eaa1a4741bb0b49bd37d4
Author: triesap <tyson@radroots.org>
Date:   Mon,  3 Aug 2026 17:05:07 +0000

sdk: restore shared mobile social engine

- add host-controlled local signer and Nostr transport slots
- compose bounded native sync policy with SDK-owned storage
- expose verified profile and post fetch and publish operations
- lock the public API, tests, documentation, and architecture policy

Diffstat:
Mcrates/sdk/Cargo.toml | 3++-
Mcrates/sdk/README.md | 22++++++++++++++++++++--
Mcrates/sdk/src/client.rs | 647++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mcrates/sdk/src/error.rs | 56++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/sdk/src/signing.rs | 225+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/sdk/src/sync.rs | 82+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/sdk/src/transport.rs | 182+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/sdk/tests/public_api.rs | 34+++++++++++++++++++++++++++++++++-
Mtools/sdk_xtask_import/src/check.rs | 2+-
9 files changed, 1241 insertions(+), 12 deletions(-)

diff --git a/crates/sdk/Cargo.toml b/crates/sdk/Cargo.toml @@ -20,7 +20,7 @@ name = "radroots_sdk" default = ["memory"] memory = ["radroots_storage/memory"] sqlite = ["dep:radroots_storage_sqlite"] -sync = ["dep:radroots_sync"] +sync = ["dep:radroots_sync", "dep:uuid"] nostr = [ "sync", "dep:radroots_nostr", @@ -84,6 +84,7 @@ reqwest = { workspace = true, optional = true, default-features = false, feature ] } serde = { workspace = true, optional = true, features = ["derive"] } serde_json = { workspace = true, optional = true, features = ["std"] } +uuid = { workspace = true, optional = true, features = ["v4"] } [dev-dependencies] tempfile = { workspace = true } diff --git a/crates/sdk/README.md b/crates/sdk/README.md @@ -91,6 +91,22 @@ and `ClientBuilder::sqlite(...)` are explicit constructors; merely enabling a feature or constructing an empty builder creates no resource. Signers, event sources, event sinks, and the sync engine are injected separately. +Native mobile hosts can retain one client while changing host-owned identity +and relay selection. `signing::Slot` accepts a key restored from secure host +storage or generates a one-time `nsec` handoff; it never persists that secret. +`transport::NostrSlot` validates a complete relay set before atomically +installing it. `ClientBuilder::host_sync(sync::HostPolicy)` explicitly opts +SDK-created memory or SQLite storage into system-clock and random operation-ID +policy without creating a runtime, timer, retry loop, or worker. + +With `sync`, `nostr`, and `local-signing`, `Client::social()` exposes bounded +profile/post fetch and publish operations. Fetch verifies and durably ingests +each accepted event. Publish durably commits a signed event, then performs one +explicit delivery pass and reports whether delivery remains pending. Host UI +lifecycle code owns polling, background policy, keychain access, and any later +retry. Profile authoring is deliberately media-free until the host can supply +the canonical byte-verified media descriptor required by the event contract. + Transport profiles are explicit. `Profile::local_only()` contains no target. `Profile::delivery(...)` retains the exact canonical target set and satisfaction policy. Preview transports report unavailable and never @@ -130,10 +146,12 @@ errors and daemon failures use stable, redacted classifications while retaining private source chains for local diagnostics. Signer material and bearer credentials are caller-owned capabilities. The SDK -does not generate keys, read a keyring, persist secrets, or include credentials +does not read a keyring, persist secrets, or include credentials in `Debug`, `Display`, diagnostics, receipts, or public error text. Hosts remain responsible for protecting source chains and any lower-level logs they choose -to expose. +to expose. When explicitly requested, `signing::Slot::generate` creates one +ephemeral key and immediately hands its only persistence representation to the +host for secure custody. ## Daemon execution diff --git a/crates/sdk/src/client.rs b/crates/sdk/src/client.rs @@ -29,11 +29,19 @@ pub struct Client { #[derive(Default)] pub struct ClientBuilder { storage: Option<Arc<dyn Storage>>, + #[cfg(feature = "sync")] + sync_storage: Option<Arc<dyn radroots_sync::policy::SyncStorage>>, signer: Option<Arc<dyn Signer>>, source: Option<Arc<dyn EventSource>>, sink: Option<Arc<dyn EventSink>>, #[cfg(feature = "sync")] sync: Option<radroots_sync::Engine>, + #[cfg(feature = "sync")] + host_sync: Option<crate::sync::HostPolicy>, + #[cfg(feature = "local-signing")] + signing_slot: Option<crate::signing::Slot>, + #[cfg(feature = "nostr")] + nostr_slot: Option<crate::transport::NostrSlot>, capability_availability: BTreeMap<CapabilityId, Availability>, explicitly_configured_capabilities: BTreeSet<CapabilityId>, } @@ -45,6 +53,10 @@ struct ClientInner { sink: Option<Arc<dyn EventSink>>, #[cfg(feature = "sync")] sync: Option<radroots_sync::Engine>, + #[cfg(feature = "local-signing")] + signing_slot: Option<crate::signing::Slot>, + #[cfg(feature = "nostr")] + nostr_slot: Option<crate::transport::NostrSlot>, capability_availability: BTreeMap<CapabilityId, Availability>, explicitly_configured_capabilities: BTreeSet<CapabilityId>, lifecycle: AtomicU8, @@ -55,6 +67,211 @@ const CLOSING: u8 = 1; const CLOSE_RETRY_REQUIRED: u8 = 2; const CLOSED: u8 = 3; +/// Host-authored, media-free profile replacement. +#[cfg(all(feature = "sync", feature = "nostr", feature = "local-signing"))] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ProfileDraft { + name: String, + display_name: Option<String>, + about: Option<String>, + nip05: Option<String>, + bot: Option<bool>, +} + +#[cfg(all(feature = "sync", feature = "nostr", feature = "local-signing"))] +impl ProfileDraft { + /// Creates a complete replacement with the required canonical name. + #[must_use] + pub fn new(name: impl Into<String>) -> Self { + Self { + name: name.into(), + display_name: None, + about: None, + nip05: None, + bot: None, + } + } + + /// Sets the optional display name. + #[must_use] + pub fn with_display_name(mut self, value: impl Into<String>) -> Self { + self.display_name = Some(value.into()); + self + } + + /// Sets the optional profile description. + #[must_use] + pub fn with_about(mut self, value: impl Into<String>) -> Self { + self.about = Some(value.into()); + self + } + + /// Sets a syntax-checked NIP-05 identifier at publish time. + #[must_use] + pub fn with_nip05(mut self, value: impl Into<String>) -> Self { + self.nip05 = Some(value.into()); + self + } + + /// Sets the optional NIP-05 bot marker. + #[must_use] + pub const fn with_bot(mut self, value: bool) -> Self { + self.bot = Some(value); + self + } +} + +/// One verified, durably ingested profile observation. +#[cfg(all(feature = "sync", feature = "nostr", feature = "local-signing"))] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ProfileEvent { + event_id: String, + author: String, + created_at: u64, + name: Option<String>, + display_name: Option<String>, + about: Option<String>, + picture: Option<String>, + banner: Option<String>, + nip05: Option<String>, + bot: Option<bool>, +} + +#[cfg(all(feature = "sync", feature = "nostr", feature = "local-signing"))] +impl ProfileEvent { + /// Returns the canonical event identifier. + pub fn event_id(&self) -> &str { + self.event_id.as_str() + } + /// Returns the canonical author public key. + pub fn author(&self) -> &str { + self.author.as_str() + } + /// Returns the event timestamp in Unix seconds. + pub const fn created_at(&self) -> u64 { + self.created_at + } + /// Returns the projected profile name. + pub fn name(&self) -> Option<&str> { + self.name.as_deref() + } + /// Returns the projected display name. + pub fn display_name(&self) -> Option<&str> { + self.display_name.as_deref() + } + /// Returns the projected description. + pub fn about(&self) -> Option<&str> { + self.about.as_deref() + } + /// Returns the unverified inbound picture reference. + pub fn picture(&self) -> Option<&str> { + self.picture.as_deref() + } + /// Returns the unverified inbound banner reference. + pub fn banner(&self) -> Option<&str> { + self.banner.as_deref() + } + /// Returns the syntax-checked, unresolved NIP-05 identifier. + pub fn nip05(&self) -> Option<&str> { + self.nip05.as_deref() + } + /// Returns the optional bot marker. + pub const fn bot(&self) -> Option<bool> { + self.bot + } +} + +/// One verified, durably ingested kind-1 social event. +#[cfg(all(feature = "sync", feature = "nostr", feature = "local-signing"))] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct PostEvent { + event_id: String, + author: String, + created_at: u64, + content: String, +} + +#[cfg(all(feature = "sync", feature = "nostr", feature = "local-signing"))] +impl PostEvent { + /// Returns the canonical event identifier. + pub fn event_id(&self) -> &str { + self.event_id.as_str() + } + /// Returns the canonical author public key. + pub fn author(&self) -> &str { + self.author.as_str() + } + /// Returns the event timestamp in Unix seconds. + pub const fn created_at(&self) -> u64 { + self.created_at + } + /// Returns the canonical event content. + pub fn content(&self) -> &str { + self.content.as_str() + } +} + +/// Result of one explicit local commit followed by one delivery pass. +#[cfg(all(feature = "sync", feature = "nostr", feature = "local-signing"))] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct PublishReceipt { + event_id: String, + replay: bool, + delivered: bool, +} + +#[cfg(all(feature = "sync", feature = "nostr", feature = "local-signing"))] +impl PublishReceipt { + /// Returns the canonical signed event identifier committed locally. + pub fn event_id(&self) -> &str { + self.event_id.as_str() + } + /// Returns whether the durable enqueue replayed an identical operation. + pub const fn is_replay(&self) -> bool { + self.replay + } + /// Returns whether this explicit pass recorded at least one success. + pub const fn is_delivered(&self) -> bool { + self.delivered + } + /// Returns whether durable local intent remains pending delivery. + pub const fn is_delivery_pending(&self) -> bool { + !self.delivered + } +} + +/// Passive status of the configured shared Nostr source and sink. +#[cfg(all(feature = "sync", feature = "nostr", feature = "local-signing"))] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct TransportHealth { + configured: bool, + source_available: bool, + sink_available: bool, +} + +#[cfg(all(feature = "sync", feature = "nostr", feature = "local-signing"))] +impl TransportHealth { + /// Returns whether a validated relay set is installed. + pub const fn is_configured(&self) -> bool { + self.configured + } + /// Returns whether passive source status is fully available. + pub const fn is_source_available(&self) -> bool { + self.source_available + } + /// Returns whether passive sink status is fully available. + pub const fn is_sink_available(&self) -> bool { + self.sink_available + } +} + +/// Borrowed high-level social operations over one shared SDK engine. +#[cfg(all(feature = "sync", feature = "nostr", feature = "local-signing"))] +#[derive(Clone, Copy)] +pub struct SocialOperations<'a> { + client: &'a Client, +} + impl ClientBuilder { /// Creates an empty builder with no hidden storage, network, signing, or /// runtime side effects. @@ -67,7 +284,13 @@ impl ClientBuilder { #[cfg(feature = "memory")] #[must_use] pub fn memory(generation: SourceGeneration) -> Self { - Self::new().storage(Arc::new(MemoryStorage::new(generation))) + let storage = Arc::new(MemoryStorage::new(generation)); + let mut builder = Self::new().storage(storage.clone()); + #[cfg(feature = "sync")] + { + builder.sync_storage = Some(storage); + } + builder } /// Creates the ordinary deterministic in-process memory configuration. @@ -79,7 +302,13 @@ impl ClientBuilder { #[cfg(feature = "memory")] #[must_use] pub fn memory_default() -> Self { - Self::new().storage(Arc::new(MemoryStorage::default())) + let storage = Arc::new(MemoryStorage::default()); + let mut builder = Self::new().storage(storage.clone()); + #[cfg(feature = "sync")] + { + builder.sync_storage = Some(storage); + } + builder } /// Explicitly opens canonical SQLite storage from validated host-owned @@ -89,9 +318,15 @@ impl ClientBuilder { let storage = radroots_storage_sqlite::SqliteStorage::open(options) .await .map_err(Error::storage_open_failed)?; - Ok(Self::new() - .storage(Arc::new(storage)) - .capability_availability(CapabilityId::PERSISTENT_STORAGE, Availability::Available)) + let storage = Arc::new(storage); + let mut builder = Self::new() + .storage(storage.clone()) + .capability_availability(CapabilityId::PERSISTENT_STORAGE, Availability::Available); + #[cfg(feature = "sync")] + { + builder.sync_storage = Some(storage); + } + Ok(builder) } /// Injects the canonical storage capability. @@ -117,7 +352,16 @@ impl ClientBuilder { crate::signing::Mode::Nip46 => Some(CapabilityId::NIP46_SIGNING), crate::signing::Mode::Host => None, }; - self.signer = Some(provider.into_signer()); + #[cfg(feature = "local-signing")] + { + let (signer, slot) = provider.into_parts(); + self.signer = Some(signer); + self.signing_slot = slot; + } + #[cfg(not(feature = "local-signing"))] + { + self.signer = Some(provider.into_signer()); + } if let Some(capability) = capability { self.explicitly_configured_capabilities.insert(capability); self.capability_availability @@ -140,6 +384,16 @@ impl ClientBuilder { self } + /// Installs one host-reconfigurable Nostr source and sink. + #[cfg(feature = "nostr")] + #[must_use] + pub fn nostr(mut self, slot: crate::transport::NostrSlot) -> Self { + self.source = Some(Arc::new(slot.clone())); + self.sink = Some(Arc::new(slot.clone())); + self.nostr_slot = Some(slot); + self + } + /// Injects an explicitly composed synchronization engine. #[cfg(feature = "sync")] #[must_use] @@ -148,6 +402,17 @@ impl ClientBuilder { self } + /// Requests one SDK-composed engine using explicit native host policy. + /// + /// This is available only for SDK-created memory or SQLite storage, whose + /// complete synchronization capability is known without downcasting. + #[cfg(feature = "sync")] + #[must_use] + pub fn host_sync(mut self, policy: crate::sync::HostPolicy) -> Self { + self.host_sync = Some(policy); + self + } + /// Marks a specialized capability as configured and records its /// host-observed initial availability without probing resources. /// @@ -161,11 +426,31 @@ impl ClientBuilder { } /// Validates the selected capabilities and creates a client handle. - pub fn build(self) -> Result<Client> { + #[allow(unused_mut)] + pub fn build(mut self) -> Result<Client> { let storage = self.storage.ok_or_else(Error::missing_storage)?; if self.signer.is_some() && self.sink.is_none() { return Err(Error::signer_without_sink()); } + #[cfg(feature = "sync")] + if let Some(policy) = self.host_sync { + let sync_storage = self + .sync_storage + .take() + .ok_or_else(Error::shared_operation_unavailable)?; + let (clock, ids, deadlines) = policy.composition(); + let mut builder = radroots_sync::Engine::builder(sync_storage, clock, ids, deadlines); + if let Some(source) = self.source.as_ref() { + builder = builder.source(Arc::clone(source)); + } + if let Some(sink) = self.sink.as_ref() { + builder = builder.sink(Arc::clone(sink)); + } + if let Some(signer) = self.signer.as_ref() { + builder = builder.signer(Arc::clone(signer)); + } + self.sync = Some(builder.build().map_err(Error::invalid_host_configuration)?); + } Ok(Client { inner: Arc::new(ClientInner { storage, @@ -174,6 +459,10 @@ impl ClientBuilder { sink: self.sink, #[cfg(feature = "sync")] sync: self.sync, + #[cfg(feature = "local-signing")] + signing_slot: self.signing_slot, + #[cfg(feature = "nostr")] + nostr_slot: self.nostr_slot, capability_availability: self.capability_availability, explicitly_configured_capabilities: self.explicitly_configured_capabilities, lifecycle: AtomicU8::new(OPEN), @@ -277,6 +566,20 @@ impl Client { .map(|sync| crate::trade::Operations::new(storage, sync))) } + /// Returns high-level shared social operations when the required explicit + /// signer, transport, and synchronization composition is present. + #[cfg(all(feature = "sync", feature = "nostr", feature = "local-signing"))] + pub fn social(&self) -> Result<SocialOperations<'_>> { + self.require_open()?; + if self.inner.sync.is_none() + || self.inner.signing_slot.is_none() + || self.inner.nostr_slot.is_none() + { + return Err(Error::shared_operation_unavailable()); + } + Ok(SocialOperations { client: self }) + } + /// Returns whether explicit close completed successfully or reached the /// lower storage commit point. #[must_use] @@ -336,6 +639,298 @@ impl Client { } } +#[cfg(all(feature = "sync", feature = "nostr", feature = "local-signing"))] +impl SocialOperations<'_> { + /// Observes both transport directions without initiating relay work. + pub async fn transport_health(&self) -> Result<TransportHealth> { + use radroots_transport::capability::Availability as TransportAvailability; + let slot = self.nostr()?; + let configured = slot.targets().is_some(); + let source = radroots_transport::EventSource::status(slot) + .await + .map_err(|_| Error::shared_operation_failed_without_source())?; + let sink = radroots_transport::EventSink::status(slot) + .await + .map_err(|_| Error::shared_operation_failed_without_source())?; + Ok(TransportHealth { + configured, + source_available: source.availability() == TransportAvailability::Available, + sink_available: sink.availability() == TransportAvailability::Available, + }) + } + + /// Fetches, verifies, and durably ingests the latest profile for the active signer. + pub async fn fetch_profile_for_signer(&self) -> Result<Option<ProfileEvent>> { + let identity = self.identity()?; + let selector = radroots_transport::source::FetchSelector::all() + .with_kinds(vec![radroots_event::envelope::kind::KIND_PROFILE]) + .and_then(|selector| selector.with_authors(vec![identity.public_key()])) + .map_err(|_| Error::invalid_host_configuration_without_source())?; + let events = self.fetch(32, selector).await?; + let mut profiles = events + .into_iter() + .filter_map(|event| profile_event(&event).ok()) + .collect::<Vec<_>>(); + profiles.sort_by_key(|event| std::cmp::Reverse(event.created_at)); + Ok(profiles.into_iter().next()) + } + + /// Fetches, verifies, and durably ingests a bounded kind-1 page. + pub async fn fetch_posts( + &self, + limit: u16, + since_unix_seconds: Option<u64>, + ) -> Result<Vec<PostEvent>> { + let mut selector = radroots_transport::source::FetchSelector::all() + .with_kinds(vec![radroots_event::envelope::kind::KIND_POST]) + .map_err(|_| Error::invalid_host_configuration_without_source())?; + if let Some(since) = since_unix_seconds { + selector = selector + .with_since_unix_seconds(since) + .map_err(|_| Error::invalid_host_configuration_without_source())?; + } + let mut posts = self + .fetch(limit, selector) + .await? + .into_iter() + .map(|event| PostEvent { + event_id: event.id_hex(), + author: event.pubkey().to_hex(), + created_at: event.created_at(), + content: event.content().to_owned(), + }) + .collect::<Vec<_>>(); + posts.sort_by_key(|event| std::cmp::Reverse(event.created_at)); + Ok(posts) + } + + /// Publishes a complete media-free profile replacement. + pub async fn publish_profile(&self, draft: ProfileDraft) -> Result<PublishReceipt> { + let mut profile = radroots_event::profile::AuthoredProfile::new(draft.name) + .map_err(Error::invalid_host_configuration)?; + if let Some(value) = draft.display_name { + profile = profile.with_display_name(value); + } + if let Some(value) = draft.about { + profile = profile.with_about(value); + } + if let Some(value) = draft.nip05 { + profile = profile.with_nip05( + radroots_event::profile::Nip05Identifier::parse(value.as_str()) + .map_err(Error::invalid_host_configuration)?, + ); + } + if let Some(value) = draft.bot { + profile = profile.with_bot(value); + } + let parts = + radroots_event_codec::profile::authored::authored_profile_to_wire_parts(&profile) + .map_err(Error::invalid_host_configuration)?; + self.publish("radroots.profile.metadata.v1", parts).await + } + + /// Publishes one strict root kind-1 update. + pub async fn publish_text(&self, content: impl Into<String>) -> Result<PublishReceipt> { + let update = radroots_event::post::AuthoredUpdate::new(content) + .map_err(Error::invalid_host_configuration)?; + let parts = radroots_event_codec::post::authored::authored_update_to_wire_parts(&update); + self.publish("radroots.social.update.v1", parts).await + } + + /// Publishes one strict direct NIP-10 reply. + pub async fn publish_reply( + &self, + content: impl Into<String>, + root_event_id: &str, + root_author: &str, + relay_hint: Option<&str>, + ) -> Result<PublishReceipt> { + let reference = radroots_event::post::reply::Nip10ReplyReference::parse( + root_event_id, + root_author, + relay_hint, + ) + .map_err(Error::invalid_host_configuration)?; + let reply = radroots_event::post::reply::AuthoredNip10Reply::direct(content, reference) + .map_err(Error::invalid_host_configuration)?; + let parts = + radroots_event_codec::reply::authored::authored_nip10_reply_to_wire_parts(&reply); + self.publish("radroots.social.reply.v1", parts).await + } + + async fn fetch( + &self, + limit: u16, + selector: radroots_transport::source::FetchSelector, + ) -> Result<Vec<radroots_event::SignedEvent>> { + let slot = self.nostr()?; + let targets = slot + .targets() + .ok_or_else(Error::shared_operation_unavailable)?; + let request_id = format!("sdk-fetch-{}", uuid::Uuid::new_v4()); + let deadline = now_unix_ms()?.saturating_add(30_000); + let request = radroots_transport::FetchRequest::new( + request_id, + targets, + radroots_transport::source::FetchBounds::new(limit, deadline) + .map_err(|_| Error::invalid_host_configuration_without_source())?, + ) + .map_err(|_| Error::invalid_host_configuration_without_source())? + .with_selector(selector); + let page = radroots_transport::EventSource::fetch(slot, request) + .await + .map_err(|_| Error::shared_operation_failed_without_source())?; + let observed = page.events().to_vec(); + let receipt = self + .client + .sync()? + .ok_or_else(Error::shared_operation_unavailable)? + .ingest_batch( + observed.clone(), + &radroots_sync::ingest::RegistryPolicy::verified(), + ) + .await; + Ok(observed + .into_iter() + .zip(receipt.outcomes()) + .filter_map(|(observed, outcome)| { + outcome.as_ref().ok().map(|_| observed.event().clone()) + }) + .collect()) + } + + async fn publish( + &self, + contract_id: &'static str, + parts: radroots_event::wire::Nip01EventWireParts, + ) -> Result<PublishReceipt> { + use radroots_event::contract::AuthorRole; + use radroots_signing::{Actor, actor::ActorSource, request::CancellationPolicy}; + use radroots_storage::{journal::IdempotencyKey, outbox::LeaseOwner}; + use radroots_sync::{PushRequest, policy::SyncId, push::DeliveryRunRequest}; + use radroots_transport::policy::{SatisfactionClass, SatisfactionPolicy, TargetPolicy}; + + let identity = self.identity()?; + let targets = self + .nostr()? + .targets() + .ok_or_else(Error::shared_operation_unavailable)?; + let operation_uuid = uuid::Uuid::new_v4(); + let operation_id = + SyncId::new(*operation_uuid.as_bytes()).map_err(Error::invalid_host_configuration)?; + let draft = radroots_event::EventDraft::new( + contract_id, + parts.kind, + now_unix_ms()? / 1_000, + parts.tags, + parts.content, + identity.public_key_hex(), + ) + .map_err(Error::invalid_host_configuration)?; + let actor = Actor::new( + identity.public_key(), + ActorSource::ExplicitPublicKey, + [AuthorRole::Any], + ) + .map_err(Error::invalid_host_configuration)?; + let request = PushRequest::new( + operation_id, + IdempotencyKey::parse(format!("sdk-{operation_uuid}")) + .map_err(Error::invalid_host_configuration)?, + actor, + draft, + targets, + SatisfactionPolicy::new(SatisfactionClass::Accepted, TargetPolicy::any()), + CancellationPolicy::PreservePublishedRequest, + ) + .map_err(Error::invalid_host_configuration)?; + let sync = self + .client + .sync()? + .ok_or_else(Error::shared_operation_unavailable)?; + let push = sync + .sign_and_enqueue(request) + .await + .map_err(Error::shared_operation_failed)?; + let event_id = push.outbox().request().payload().event().id_hex(); + let delivery = sync + .deliver_pending( + DeliveryRunRequest::new( + LeaseOwner::parse("radroots-sdk-host") + .map_err(Error::invalid_host_configuration)?, + SyncId::new(*uuid::Uuid::new_v4().as_bytes()) + .map_err(Error::invalid_host_configuration)?, + 30_000, + radroots_storage::outbox::OUTBOX_CLAIM_LIMIT_MAX, + ) + .map_err(Error::invalid_host_configuration)?, + ) + .await + .map_err(Error::shared_operation_failed)?; + Ok(PublishReceipt { + event_id, + replay: push.is_replay(), + delivered: delivery.outcomes().iter().any(|outcome| { + outcome.as_ref().is_ok_and(|record| { + record.item_id() == push.outbox().item_id() + && record.satisfaction() + != radroots_storage::outbox::SatisfactionResult::Pending + }) + }), + }) + } + + fn identity(&self) -> Result<crate::signing::LocalIdentity> { + self.client + .inner + .signing_slot + .as_ref() + .and_then(crate::signing::Slot::identity) + .ok_or_else(Error::shared_operation_unavailable) + } + + fn nostr(&self) -> Result<&crate::transport::NostrSlot> { + self.client + .inner + .nostr_slot + .as_ref() + .ok_or_else(Error::shared_operation_unavailable) + } +} + +#[cfg(all(feature = "sync", feature = "nostr", feature = "local-signing"))] +fn now_unix_ms() -> Result<u64> { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .ok() + .and_then(|duration| u64::try_from(duration.as_millis()).ok()) + .filter(|value| *value != 0) + .ok_or_else(Error::shared_operation_unavailable) +} + +#[cfg(all(feature = "sync", feature = "nostr", feature = "local-signing"))] +fn profile_event( + event: &radroots_event::SignedEvent, +) -> std::result::Result< + ProfileEvent, + radroots_event_codec::profile::inbound::RadrootsProfileMetadataParseError, +> { + let profile = + radroots_event_codec::profile::inbound::parse_inbound_profile_metadata(event.content())?; + Ok(ProfileEvent { + event_id: event.id_hex(), + author: event.pubkey().to_hex(), + created_at: event.created_at(), + name: profile.name().map(str::to_owned), + display_name: profile.display_name().map(str::to_owned), + about: profile.about().map(str::to_owned), + picture: profile.picture().map(|value| value.as_str().to_owned()), + banner: profile.banner().map(|value| value.as_str().to_owned()), + nip05: profile.nip05().map(|value| value.as_str().to_owned()), + bot: profile.bot(), + }) +} + struct CloseAttempt { inner: Arc<ClientInner>, completed: bool, @@ -521,6 +1116,44 @@ mod tests { assert_eq!(status.availability(), Availability::Available); } + #[cfg(all(feature = "sync", feature = "nostr", feature = "local-signing"))] + #[tokio::test] + async fn shared_mobile_composition_is_single_client_explicit_and_fail_closed() { + let signing = crate::signing::Slot::new(); + let nostr = crate::transport::NostrSlot::new(crate::transport::RelayUrlPolicy::Local); + let client = ClientBuilder::memory(generation()) + .signing(crate::signing::Provider::slot(signing.clone())) + .nostr(nostr.clone()) + .host_sync(crate::sync::HostPolicy::standard()) + .build() + .expect("shared client"); + + let health = client + .social() + .expect("social composition") + .transport_health() + .await + .expect("passive health"); + assert!(!health.is_configured()); + assert!(!health.is_source_available()); + assert!(!health.is_sink_available()); + assert!(matches!( + client + .social() + .expect("social composition") + .fetch_posts(1, None) + .await, + Err(error) if error.kind() == crate::error::ErrorKind::SharedOperationUnavailable + )); + + let (_secret, identity) = signing.generate().expect("host key handoff"); + assert_eq!(signing.identity(), Some(identity)); + nostr + .configure(["ws://127.0.0.1:7447"]) + .expect("relay selection"); + assert!(nostr.targets().is_some()); + } + #[test] fn close_is_clone_shared_idempotent_and_rejects_later_capability_access() { let client = ClientBuilder::memory(generation()).build().expect("client"); diff --git a/crates/sdk/src/error.rs b/crates/sdk/src/error.rs @@ -120,6 +120,27 @@ error_catalog! { message: "SDK storage inspection failed", safe_detail_keys: [] }, + InvalidHostConfiguration => { + code: InvalidArgument, + operation: None, + capability: None, + message: "SDK host configuration is invalid", + safe_detail_keys: [] + }, + SharedOperationUnavailable => { + code: TransportOperationUnavailable, + operation: None, + capability: None, + message: "SDK shared network operation is unavailable", + safe_detail_keys: [] + }, + SharedOperationFailed => { + code: SyncPartial, + operation: None, + capability: None, + message: "SDK shared network operation failed", + safe_detail_keys: [] + }, } /// Stable metadata for one native SDK failure. @@ -238,6 +259,41 @@ impl Error { } } + #[cfg(any(feature = "sync", feature = "nostr"))] + pub(crate) fn invalid_host_configuration( + source: impl error::Error + Send + Sync + 'static, + ) -> Self { + Self { + kind: ErrorKind::InvalidHostConfiguration, + source: Some(Box::new(source)), + } + } + + #[cfg(feature = "nostr")] + pub(crate) fn invalid_host_configuration_without_source() -> Self { + Self::without_source(ErrorKind::InvalidHostConfiguration) + } + + #[cfg(any(feature = "sync", feature = "nostr"))] + pub(crate) fn shared_operation_unavailable() -> Self { + Self::without_source(ErrorKind::SharedOperationUnavailable) + } + + #[cfg(all(feature = "sync", feature = "nostr", feature = "local-signing"))] + pub(crate) fn shared_operation_failed( + source: impl error::Error + Send + Sync + 'static, + ) -> Self { + Self { + kind: ErrorKind::SharedOperationFailed, + source: Some(Box::new(source)), + } + } + + #[cfg(all(feature = "sync", feature = "nostr", feature = "local-signing"))] + pub(crate) fn shared_operation_failed_without_source() -> Self { + Self::without_source(ErrorKind::SharedOperationFailed) + } + fn without_source(kind: ErrorKind) -> Self { Self { kind, source: None } } diff --git a/crates/sdk/src/signing.rs b/crates/sdk/src/signing.rs @@ -1,6 +1,8 @@ //! Generic signer composition without protocol or relay ownership. use std::sync::Arc; +#[cfg(feature = "local-signing")] +use std::sync::RwLock; use radroots_signing::{SignReceipt, SignRequest, Signer, SignerStatus}; @@ -21,6 +23,8 @@ pub enum Mode { pub struct Provider { mode: Mode, signer: Arc<dyn Signer>, + #[cfg(feature = "local-signing")] + slot: Option<Slot>, } impl Provider { @@ -30,6 +34,8 @@ impl Provider { Self { mode: Mode::Host, signer, + #[cfg(feature = "local-signing")] + slot: None, } } @@ -40,6 +46,21 @@ impl Provider { Self { mode: Mode::Local, signer: Arc::new(signer), + slot: None, + } + } + + /// Wraps a host-controlled local signer slot. + /// + /// The slot starts inert and can be populated or cleared without rebuilding + /// the client. Secret persistence remains entirely host-owned. + #[cfg(feature = "local-signing")] + #[must_use] + pub fn slot(slot: Slot) -> Self { + Self { + mode: Mode::Local, + signer: Arc::new(slot.clone()), + slot: Some(slot), } } @@ -54,6 +75,8 @@ impl Provider { Self { mode: Mode::Nip46, signer, + #[cfg(feature = "local-signing")] + slot: None, } } @@ -79,11 +102,192 @@ impl Provider { self.signer.sign(request).await } + #[cfg(feature = "local-signing")] + pub(crate) fn into_parts(self) -> (Arc<dyn Signer>, Option<Slot>) { + (self.signer, self.slot) + } + + #[cfg(not(feature = "local-signing"))] pub(crate) fn into_signer(self) -> Arc<dyn Signer> { self.signer } } +/// Public identity controlled by an installed local signer. +#[cfg(feature = "local-signing")] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct LocalIdentity { + public_key: radroots_identity::PublicKey, + npub: String, +} + +#[cfg(feature = "local-signing")] +impl LocalIdentity { + fn from_public_key( + public_key: radroots_identity::PublicKey, + ) -> Result<Self, radroots_nostr::Error> { + Ok(Self { + public_key, + npub: radroots_nostr::key::public_key_to_npub(public_key)?, + }) + } + + /// Returns the canonical lowercase public-key hexadecimal form. + #[must_use] + pub fn public_key_hex(&self) -> String { + self.public_key.to_hex() + } + + /// Returns the canonical NIP-19 public identity. + #[must_use] + pub fn npub(&self) -> &str { + self.npub.as_str() + } + + pub(crate) const fn public_key(&self) -> radroots_identity::PublicKey { + self.public_key + } +} + +/// Mutable, client-shareable local signer selected by the host. +/// +/// The slot never persists key material and its debug output never observes +/// the installed signer. Installing and clearing are explicit host actions. +#[cfg(feature = "local-signing")] +#[derive(Clone, Default)] +pub struct Slot { + state: Arc<RwLock<Option<SlotState>>>, +} + +#[cfg(feature = "local-signing")] +struct SlotState { + signer: Arc<dyn Signer>, + identity: LocalIdentity, +} + +#[cfg(feature = "local-signing")] +impl Slot { + /// Creates an empty signer slot. + #[must_use] + pub fn new() -> Self { + Self::default() + } + + /// Validates and installs one host-supplied hexadecimal or `nsec` secret. + pub fn install(&self, encoded: &str) -> Result<LocalIdentity, radroots_nostr::Error> { + let secret = radroots_nostr::key::SecretKey::parse(encoded)?; + self.install_secret(secret) + } + + /// Generates, installs, and returns one secret for immediate host custody. + /// + /// The SDK retains only the opaque signer. The returned `nsec` is the sole + /// persistence handoff and must be moved into host secure storage. + pub fn generate(&self) -> Result<(String, LocalIdentity), radroots_nostr::Error> { + let secret = radroots_nostr::key::SecretKey::generate(); + let encoded = radroots_nostr::key::secret_key_to_nsec(&secret); + let identity = self.install_secret(secret)?; + Ok((encoded, identity)) + } + + /// Removes the active signer from this process. + pub fn clear(&self) { + if let Ok(mut state) = self.state.write() { + *state = None; + } + } + + /// Returns the currently installed public identity. + #[must_use] + pub fn identity(&self) -> Option<LocalIdentity> { + self.state + .read() + .ok() + .and_then(|state| state.as_ref().map(|state| state.identity.clone())) + } + + fn install_secret( + &self, + secret: radroots_nostr::key::SecretKey, + ) -> Result<LocalIdentity, radroots_nostr::Error> { + let public_key = secret.public_key()?; + let identity = LocalIdentity::from_public_key(public_key)?; + let signer: Arc<dyn Signer> = Arc::new(radroots_nostr::signing::LocalSigner::new(secret)?); + if let Ok(mut state) = self.state.write() { + *state = Some(SlotState { + signer, + identity: identity.clone(), + }); + } + Ok(identity) + } + + fn signer(&self) -> Result<Arc<dyn Signer>, radroots_signing::Error> { + self.state + .read() + .map_err(|source| { + radroots_signing::Error::with_source( + radroots_signing::error::Kind::InternalError, + LockFailure(source.to_string()), + ) + })? + .as_ref() + .map(|state| Arc::clone(&state.signer)) + .ok_or_else(|| { + radroots_signing::Error::new(radroots_signing::error::Kind::SignerUnavailable) + }) + } +} + +#[cfg(feature = "local-signing")] +impl Signer for Slot { + fn status( + &self, + ) -> radroots_signing::signer::BoxFuture<'_, Result<SignerStatus, radroots_signing::Error>> + { + Box::pin(async move { + match self.signer() { + Ok(signer) => signer.status().await, + Err(error) if error.kind() == radroots_signing::error::Kind::SignerUnavailable => { + Ok(SignerStatus::unavailable()) + } + Err(error) => Err(error), + } + }) + } + + fn sign( + &self, + request: SignRequest, + ) -> radroots_signing::signer::BoxFuture<'_, Result<SignReceipt, radroots_signing::Error>> { + Box::pin(async move { self.signer()?.sign(request).await }) + } +} + +#[cfg(feature = "local-signing")] +impl std::fmt::Debug for Slot { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("Slot") + .field("installed", &self.identity().is_some()) + .finish() + } +} + +#[cfg(feature = "local-signing")] +#[derive(Debug)] +struct LockFailure(String); + +#[cfg(feature = "local-signing")] +impl std::fmt::Display for LockFailure { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str(self.0.as_str()) + } +} + +#[cfg(feature = "local-signing")] +impl std::error::Error for LockFailure {} + impl std::fmt::Debug for Provider { fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { formatter @@ -196,6 +400,27 @@ mod tests { assert_eq!(status.capabilities()[0].kind(), SignerKind::Local); } + #[cfg(feature = "local-signing")] + #[tokio::test] + async fn local_slot_hands_secret_to_host_and_supports_lock_restore() { + let slot = Slot::new(); + assert!(slot.identity().is_none()); + assert_eq!( + slot.status().await.expect("empty status").availability(), + SignerAvailability::Unavailable + ); + + let (secret, generated) = slot.generate().expect("generated identity"); + assert!(secret.starts_with("nsec1")); + assert_eq!(slot.identity().expect("installed"), generated); + assert!(!format!("{slot:?}").contains(secret.as_str())); + + slot.clear(); + assert!(slot.identity().is_none()); + let restored = slot.install(secret.as_str()).expect("restored identity"); + assert_eq!(restored, generated); + } + #[cfg(feature = "nip46")] #[tokio::test] async fn nip46_provider_preserves_auth_challenge_and_capabilities() { diff --git a/crates/sdk/src/sync.rs b/crates/sdk/src/sync.rs @@ -1,6 +1,9 @@ //! Client-scoped access to the canonical synchronization engine. #[cfg(feature = "sync")] +use std::sync::Arc; + +#[cfg(feature = "sync")] use radroots_storage::{outbox::OutboxRecord, projection::ProjectionId}; #[cfg(feature = "sync")] use radroots_sync::{ @@ -13,6 +16,85 @@ use radroots_sync::{ #[cfg(feature = "sync")] use radroots_transport::source::ObservedEvent; +/// Explicit host policy for SDK-composed synchronization. +/// +/// Selecting this policy opts into the system clock and operating-system +/// randomness for operation IDs. It creates no executor, timer, or worker. +#[cfg(feature = "sync")] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct HostPolicy { + deadlines: radroots_sync::policy::DeadlinePolicy, +} + +#[cfg(feature = "sync")] +impl HostPolicy { + /// Creates a bounded policy for pull, signing, and delivery calls. + pub fn new( + pull_timeout_ms: u64, + sign_timeout_ms: u64, + delivery_timeout_ms: u64, + ) -> Result<Self, radroots_sync::policy::Error> { + Ok(Self { + deadlines: radroots_sync::policy::DeadlinePolicy::new( + pull_timeout_ms, + sign_timeout_ms, + delivery_timeout_ms, + )?, + }) + } + + /// Returns the ordinary bounded native-host policy. + #[must_use] + pub fn standard() -> Self { + Self::new(30_000, 30_000, 30_000).expect("static host deadlines are valid") + } + + pub(crate) fn composition( + self, + ) -> ( + Arc<dyn radroots_sync::policy::Clock>, + Arc<dyn radroots_sync::policy::IdSource>, + radroots_sync::policy::DeadlinePolicy, + ) { + (Arc::new(SystemClock), Arc::new(RandomIds), self.deadlines) + } +} + +#[cfg(feature = "sync")] +impl Default for HostPolicy { + fn default() -> Self { + Self::standard() + } +} + +#[cfg(feature = "sync")] +struct SystemClock; + +#[cfg(feature = "sync")] +impl radroots_sync::policy::Clock for SystemClock { + fn now_unix_ms(&self) -> Result<u64, radroots_sync::policy::Error> { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .ok() + .and_then(|duration| u64::try_from(duration.as_millis()).ok()) + .filter(|value| *value != 0) + .ok_or(radroots_sync::policy::Error::ClockUnavailable) + } +} + +#[cfg(feature = "sync")] +struct RandomIds; + +#[cfg(feature = "sync")] +impl radroots_sync::policy::IdSource for RandomIds { + fn next_id( + &self, + _operation: radroots_sync::policy::OperationKind, + ) -> Result<radroots_sync::policy::SyncId, radroots_sync::policy::Error> { + radroots_sync::policy::SyncId::new(*uuid::Uuid::new_v4().as_bytes()) + } +} + /// Borrowed client operations over one explicitly composed sync engine. /// /// This type owns no scheduling, retries, status strings, outbox state, or diff --git a/crates/sdk/src/transport.rs b/crates/sdk/src/transport.rs @@ -9,6 +9,11 @@ use radroots_transport::{ capability::{Availability, Maturity, SinkCapabilities, SourceCapabilities}, policy::SatisfactionPolicy, }; +#[cfg(feature = "nostr")] +use std::sync::{Arc, RwLock}; + +#[cfg(feature = "nostr")] +pub use radroots_transport_nostr::RelayUrlPolicy; const PREVIEW_UNAVAILABLE_MESSAGE: &str = "preview transport is unavailable in this SDK release"; @@ -132,6 +137,170 @@ impl Default for Profile { } } +/// Host-configured, client-shareable Nostr transport slot. +/// +/// Reconfiguration validates the complete relay set before atomically +/// replacing the active adapter. Construction, clearing, and target +/// inspection perform no network I/O. +#[cfg(feature = "nostr")] +#[derive(Clone)] +pub struct NostrSlot { + policy: RelayUrlPolicy, + state: Arc<RwLock<Option<NostrState>>>, +} + +#[cfg(feature = "nostr")] +#[derive(Clone)] +struct NostrState { + transport: Arc<radroots_transport_nostr::NostrTransport>, + targets: TargetSet, +} + +#[cfg(feature = "nostr")] +impl NostrSlot { + /// Creates an inert slot with an explicit destination policy. + #[must_use] + pub fn new(policy: RelayUrlPolicy) -> Self { + Self { + policy, + state: Arc::new(RwLock::new(None)), + } + } + + /// Validates and atomically installs the complete relay selection. + pub fn configure<I, S>(&self, relays: I) -> crate::Result<()> + where + I: IntoIterator<Item = S>, + S: AsRef<str>, + { + let config = radroots_transport_nostr::Config::new(self.policy, relays) + .map_err(crate::Error::invalid_host_configuration)?; + let targets = TargetSet::new( + config + .relays() + .iter() + .map(radroots_transport_nostr::RelayUrl::to_target) + .collect::<Result<Vec<_>, _>>() + .map_err(crate::Error::invalid_host_configuration)?, + ) + .map_err(|_| crate::Error::invalid_host_configuration_without_source())?; + let state = NostrState { + transport: Arc::new(radroots_transport_nostr::NostrTransport::new(config)), + targets, + }; + let mut current = self + .state + .write() + .map_err(|_| crate::Error::shared_operation_unavailable())?; + *current = Some(state); + Ok(()) + } + + /// Removes the active adapter without starting or stopping background work. + pub fn clear(&self) { + if let Ok(mut state) = self.state.write() { + *state = None; + } + } + + /// Returns the currently selected canonical targets. + #[must_use] + pub fn targets(&self) -> Option<TargetSet> { + self.snapshot().map(|state| state.targets) + } + + fn snapshot(&self) -> Option<NostrState> { + self.state.read().ok().and_then(|state| state.clone()) + } +} + +#[cfg(feature = "nostr")] +impl radroots_transport::EventSource for NostrSlot { + fn status( + &self, + ) -> radroots_transport::BoxFuture<'_, Result<SourceStatus, radroots_transport::Error>> { + Box::pin(async move { + match self.snapshot() { + Some(state) => { + radroots_transport::EventSource::status(state.transport.as_ref()).await + } + None => Ok(SourceStatus::new( + TransportId::NOSTR, + false, + Maturity::Stable, + Availability::Unavailable, + SourceCapabilities::FETCH, + "Nostr transport is not configured", + )), + } + }) + } + + fn fetch( + &self, + request: radroots_transport::FetchRequest, + ) -> radroots_transport::BoxFuture< + '_, + Result<radroots_transport::FetchPage, radroots_transport::Error>, + > { + Box::pin(async move { + let state = self + .snapshot() + .ok_or(radroots_transport::Error::UnsupportedOperation)?; + radroots_transport::EventSource::fetch(state.transport.as_ref(), request).await + }) + } +} + +#[cfg(feature = "nostr")] +impl radroots_transport::EventSink for NostrSlot { + fn status( + &self, + ) -> radroots_transport::BoxFuture<'_, Result<SinkStatus, radroots_transport::Error>> { + Box::pin(async move { + match self.snapshot() { + Some(state) => { + radroots_transport::EventSink::status(state.transport.as_ref()).await + } + None => Ok(SinkStatus::new( + TransportId::NOSTR, + false, + Maturity::Stable, + Availability::Unavailable, + SinkCapabilities::DELIVER, + "Nostr transport is not configured", + )), + } + }) + } + + fn deliver( + &self, + request: radroots_transport::DeliveryRequest, + ) -> radroots_transport::BoxFuture< + '_, + Result<radroots_transport::DeliveryReceipt, radroots_transport::Error>, + > { + Box::pin(async move { + let state = self + .snapshot() + .ok_or(radroots_transport::Error::UnsupportedOperation)?; + radroots_transport::EventSink::deliver(state.transport.as_ref(), request).await + }) + } +} + +#[cfg(feature = "nostr")] +impl std::fmt::Debug for NostrSlot { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("NostrSlot") + .field("policy", &self.policy) + .field("configured", &self.targets().is_some()) + .finish() + } +} + /// Explicit daemon adapter authentication configuration. #[cfg(feature = "radrootsd")] #[derive(Clone, Eq, PartialEq)] @@ -431,4 +600,17 @@ mod tests { assert!(!debug.contains("secret-token")); assert!(!debug.contains("reqwest")); } + + #[cfg(feature = "nostr")] + #[test] + fn nostr_slot_reconfiguration_is_validated_atomic_and_inert() { + let slot = NostrSlot::new(RelayUrlPolicy::Local); + assert!(slot.targets().is_none()); + assert!(slot.configure(["ws://127.0.0.1:7447"]).is_ok()); + let original = slot.targets().expect("configured targets"); + assert!(slot.configure(Vec::<String>::new()).is_err()); + assert_eq!(slot.targets(), Some(original)); + slot.clear(); + assert!(slot.targets().is_none()); + } } diff --git a/crates/sdk/tests/public_api.rs b/crates/sdk/tests/public_api.rs @@ -55,11 +55,37 @@ fn public_native_type_snapshot_uses_contextual_names() { type_name::<radroots_sdk::listing::EnqueueRequest>(), type_name::<radroots_sdk::listing::Operations<'static>>(), type_name::<radroots_sdk::sync::Operations<'static>>(), + type_name::<radroots_sdk::sync::HostPolicy>(), type_name::<radroots_sdk::trade::EnqueueRequest>(), type_name::<radroots_sdk::trade::Operations<'static>>(), type_name::<radroots_sdk::trade::PrivateTermsError>(), ]) .collect::<BTreeSet<_>>(); + #[cfg(feature = "local-signing")] + let actual = actual + .into_iter() + .chain([ + type_name::<radroots_sdk::signing::LocalIdentity>(), + type_name::<radroots_sdk::signing::Slot>(), + ]) + .collect::<BTreeSet<_>>(); + #[cfg(feature = "nostr")] + let actual = actual + .into_iter() + .chain([type_name::<radroots_sdk::transport::NostrSlot>()]) + .collect::<BTreeSet<_>>(); + #[cfg(all(feature = "sync", feature = "nostr", feature = "local-signing"))] + let actual = actual + .into_iter() + .chain([ + type_name::<radroots_sdk::client::PostEvent>(), + type_name::<radroots_sdk::client::ProfileDraft>(), + type_name::<radroots_sdk::client::ProfileEvent>(), + type_name::<radroots_sdk::client::PublishReceipt>(), + type_name::<radroots_sdk::client::SocialOperations<'static>>(), + type_name::<radroots_sdk::client::TransportHealth>(), + ]) + .collect::<BTreeSet<_>>(); #[cfg(feature = "radrootsd")] let actual = actual .into_iter() @@ -133,7 +159,13 @@ fn active_native_api_has_no_sdk_owned_traits_or_public_field_layout() { const fn expected_public_type_count() -> usize { let count = 28; #[cfg(feature = "sync")] - let count = count + 8; + let count = count + 9; + #[cfg(feature = "local-signing")] + let count = count + 2; + #[cfg(feature = "nostr")] + let count = count + 1; + #[cfg(all(feature = "sync", feature = "nostr", feature = "local-signing"))] + let count = count + 6; #[cfg(feature = "radrootsd")] let count = count + 5; count diff --git a/tools/sdk_xtask_import/src/check.rs b/tools/sdk_xtask_import/src/check.rs @@ -676,7 +676,7 @@ fn check_sdk_feature_matrix(root: &Path) -> Result<(), String> { ("default", &["memory"][..]), ("memory", &["radroots_storage/memory"]), ("sqlite", &["dep:radroots_storage_sqlite"]), - ("sync", &["dep:radroots_sync"]), + ("sync", &["dep:radroots_sync", "dep:uuid"]), ( "nostr", &["sync", "dep:radroots_nostr", "dep:radroots_transport_nostr"],