sdk

Radroots SDK and bindings
git clone https://radroots.dev/git/sdk.git
Log | Files | Refs | README

commit 99e68e3d54ebd748f8f3fdae220bee63458a55b3
parent e743afc0154003897dd1e318e543f1a80ccf9dc4
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:
MCargo.lock | 1+
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+++++++++++++++++++++++++++++++++-
Mdocs/api/radroots_sdk-0.1.0-alpha.txt | 91+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mdocs/implementation/DEVIATIONS.md | 1+
Mdocs/implementation/deviations.toml | 26++++++++++++++++++++++++++
Mdocs/specs/radroots_crates_release_v1.md | 2+-
Mtools/xtask/src/check.rs | 2+-
14 files changed, 1361 insertions(+), 13 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -2075,6 +2075,7 @@ dependencies = [ "serde_json", "tempfile", "tokio", + "uuid", ] [[package]] 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/docs/api/radroots_sdk-0.1.0-alpha.txt b/docs/api/radroots_sdk-0.1.0-alpha.txt @@ -54,6 +54,7 @@ pub fn radroots_sdk::client::Client::is_closed(&self) -> bool pub fn radroots_sdk::client::Client::listing(&self) -> radroots_sdk::error::Result<core::option::Option<radroots_sdk::listing::Operations<'_>>> pub fn radroots_sdk::client::Client::signer(&self) -> radroots_sdk::error::Result<core::option::Option<&dyn radroots_signing::signer::Signer>> pub fn radroots_sdk::client::Client::sink(&self) -> radroots_sdk::error::Result<core::option::Option<&dyn radroots_transport::sink::EventSink>> +pub fn radroots_sdk::client::Client::social(&self) -> radroots_sdk::error::Result<radroots_sdk::client::SocialOperations<'_>> pub fn radroots_sdk::client::Client::source(&self) -> radroots_sdk::error::Result<core::option::Option<&dyn radroots_transport::source::EventSource>> pub fn radroots_sdk::client::Client::storage(&self) -> radroots_sdk::error::Result<&dyn radroots_storage::Storage> pub async fn radroots_sdk::client::Client::storage_integrity(&self) -> radroots_sdk::error::Result<radroots_sdk::storage::IntegrityStatus> @@ -67,9 +68,11 @@ pub struct radroots_sdk::client::ClientBuilder impl radroots_sdk::client::ClientBuilder pub fn radroots_sdk::client::ClientBuilder::build(self) -> radroots_sdk::error::Result<radroots_sdk::client::Client> pub fn radroots_sdk::client::ClientBuilder::capability_availability(self, radroots_sdk::capability::CapabilityId, radroots_sdk::capability::Availability) -> Self +pub fn radroots_sdk::client::ClientBuilder::host_sync(self, radroots_sdk::sync::HostPolicy) -> Self pub fn radroots_sdk::client::ClientBuilder::memory(radroots_storage::event::SourceGeneration) -> Self pub fn radroots_sdk::client::ClientBuilder::memory_default() -> Self pub fn radroots_sdk::client::ClientBuilder::new() -> Self +pub fn radroots_sdk::client::ClientBuilder::nostr(self, radroots_sdk::transport::NostrSlot) -> Self pub fn radroots_sdk::client::ClientBuilder::signer(self, alloc::sync::Arc<dyn radroots_signing::signer::Signer>) -> Self pub fn radroots_sdk::client::ClientBuilder::signing(self, radroots_sdk::signing::Provider) -> Self pub fn radroots_sdk::client::ClientBuilder::sink(self, alloc::sync::Arc<dyn radroots_transport::sink::EventSink>) -> Self @@ -79,6 +82,50 @@ pub fn radroots_sdk::client::ClientBuilder::storage(self, alloc::sync::Arc<dyn r pub fn radroots_sdk::client::ClientBuilder::sync_engine(self, radroots_sync::engine::Engine) -> Self impl core::fmt::Debug for radroots_sdk::client::ClientBuilder pub fn radroots_sdk::client::ClientBuilder::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +pub struct radroots_sdk::client::PostEvent +impl radroots_sdk::client::PostEvent +pub fn radroots_sdk::client::PostEvent::author(&self) -> &str +pub fn radroots_sdk::client::PostEvent::content(&self) -> &str +pub const fn radroots_sdk::client::PostEvent::created_at(&self) -> u64 +pub fn radroots_sdk::client::PostEvent::event_id(&self) -> &str +pub struct radroots_sdk::client::ProfileDraft +impl radroots_sdk::client::ProfileDraft +pub fn radroots_sdk::client::ProfileDraft::new(impl core::convert::Into<alloc::string::String>) -> Self +pub fn radroots_sdk::client::ProfileDraft::with_about(self, impl core::convert::Into<alloc::string::String>) -> Self +pub const fn radroots_sdk::client::ProfileDraft::with_bot(self, bool) -> Self +pub fn radroots_sdk::client::ProfileDraft::with_display_name(self, impl core::convert::Into<alloc::string::String>) -> Self +pub fn radroots_sdk::client::ProfileDraft::with_nip05(self, impl core::convert::Into<alloc::string::String>) -> Self +pub struct radroots_sdk::client::ProfileEvent +impl radroots_sdk::client::ProfileEvent +pub fn radroots_sdk::client::ProfileEvent::about(&self) -> core::option::Option<&str> +pub fn radroots_sdk::client::ProfileEvent::author(&self) -> &str +pub fn radroots_sdk::client::ProfileEvent::banner(&self) -> core::option::Option<&str> +pub const fn radroots_sdk::client::ProfileEvent::bot(&self) -> core::option::Option<bool> +pub const fn radroots_sdk::client::ProfileEvent::created_at(&self) -> u64 +pub fn radroots_sdk::client::ProfileEvent::display_name(&self) -> core::option::Option<&str> +pub fn radroots_sdk::client::ProfileEvent::event_id(&self) -> &str +pub fn radroots_sdk::client::ProfileEvent::name(&self) -> core::option::Option<&str> +pub fn radroots_sdk::client::ProfileEvent::nip05(&self) -> core::option::Option<&str> +pub fn radroots_sdk::client::ProfileEvent::picture(&self) -> core::option::Option<&str> +pub struct radroots_sdk::client::PublishReceipt +impl radroots_sdk::client::PublishReceipt +pub fn radroots_sdk::client::PublishReceipt::event_id(&self) -> &str +pub const fn radroots_sdk::client::PublishReceipt::is_delivered(&self) -> bool +pub const fn radroots_sdk::client::PublishReceipt::is_delivery_pending(&self) -> bool +pub const fn radroots_sdk::client::PublishReceipt::is_replay(&self) -> bool +pub struct radroots_sdk::client::SocialOperations<'a> +impl radroots_sdk::client::SocialOperations<'_> +pub async fn radroots_sdk::client::SocialOperations<'_>::fetch_posts(&self, u16, core::option::Option<u64>) -> radroots_sdk::error::Result<alloc::vec::Vec<radroots_sdk::client::PostEvent>> +pub async fn radroots_sdk::client::SocialOperations<'_>::fetch_profile_for_signer(&self) -> radroots_sdk::error::Result<core::option::Option<radroots_sdk::client::ProfileEvent>> +pub async fn radroots_sdk::client::SocialOperations<'_>::publish_profile(&self, radroots_sdk::client::ProfileDraft) -> radroots_sdk::error::Result<radroots_sdk::client::PublishReceipt> +pub async fn radroots_sdk::client::SocialOperations<'_>::publish_reply(&self, impl core::convert::Into<alloc::string::String>, &str, &str, core::option::Option<&str>) -> radroots_sdk::error::Result<radroots_sdk::client::PublishReceipt> +pub async fn radroots_sdk::client::SocialOperations<'_>::publish_text(&self, impl core::convert::Into<alloc::string::String>) -> radroots_sdk::error::Result<radroots_sdk::client::PublishReceipt> +pub async fn radroots_sdk::client::SocialOperations<'_>::transport_health(&self) -> radroots_sdk::error::Result<radroots_sdk::client::TransportHealth> +pub struct radroots_sdk::client::TransportHealth +impl radroots_sdk::client::TransportHealth +pub const fn radroots_sdk::client::TransportHealth::is_configured(&self) -> bool +pub const fn radroots_sdk::client::TransportHealth::is_sink_available(&self) -> bool +pub const fn radroots_sdk::client::TransportHealth::is_source_available(&self) -> bool pub mod radroots_sdk::diagnostics pub struct radroots_sdk::diagnostics::Report impl radroots_sdk::diagnostics::Report @@ -90,7 +137,10 @@ pub mod radroots_sdk::error pub radroots_sdk::error::ErrorKind::ClientClosed pub radroots_sdk::error::ErrorKind::ClientClosing pub radroots_sdk::error::ErrorKind::CloseInProgress +pub radroots_sdk::error::ErrorKind::InvalidHostConfiguration pub radroots_sdk::error::ErrorKind::MissingStorage +pub radroots_sdk::error::ErrorKind::SharedOperationFailed +pub radroots_sdk::error::ErrorKind::SharedOperationUnavailable pub radroots_sdk::error::ErrorKind::SignerWithoutSink pub radroots_sdk::error::ErrorKind::StorageCloseFailed pub radroots_sdk::error::ErrorKind::StorageInspectionFailed @@ -192,6 +242,10 @@ pub mod radroots_sdk::signing pub radroots_sdk::signing::Mode::Host pub radroots_sdk::signing::Mode::Local pub radroots_sdk::signing::Mode::Nip46 +pub struct radroots_sdk::signing::LocalIdentity +impl radroots_sdk::signing::LocalIdentity +pub fn radroots_sdk::signing::LocalIdentity::npub(&self) -> &str +pub fn radroots_sdk::signing::LocalIdentity::public_key_hex(&self) -> alloc::string::String pub struct radroots_sdk::signing::Provider impl radroots_sdk::signing::Provider pub fn radroots_sdk::signing::Provider::as_signer(&self) -> &dyn radroots_signing::signer::Signer @@ -200,9 +254,22 @@ pub fn radroots_sdk::signing::Provider::local(radroots_nostr::signing::LocalSign pub const fn radroots_sdk::signing::Provider::mode(&self) -> radroots_sdk::signing::Mode pub fn radroots_sdk::signing::Provider::nip46(alloc::sync::Arc<dyn radroots_signing::signer::Signer>) -> Self pub async fn radroots_sdk::signing::Provider::sign(&self, radroots_signing::request::SignRequest) -> core::result::Result<radroots_signing::receipt::SignReceipt, radroots_signing::error::Error> +pub fn radroots_sdk::signing::Provider::slot(radroots_sdk::signing::Slot) -> Self pub async fn radroots_sdk::signing::Provider::status(&self) -> core::result::Result<radroots_signing::status::SignerStatus, radroots_signing::error::Error> impl core::fmt::Debug for radroots_sdk::signing::Provider pub fn radroots_sdk::signing::Provider::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +pub struct radroots_sdk::signing::Slot +impl radroots_sdk::signing::Slot +pub fn radroots_sdk::signing::Slot::clear(&self) +pub fn radroots_sdk::signing::Slot::generate(&self) -> core::result::Result<(alloc::string::String, radroots_sdk::signing::LocalIdentity), radroots_nostr::error::Error> +pub fn radroots_sdk::signing::Slot::identity(&self) -> core::option::Option<radroots_sdk::signing::LocalIdentity> +pub fn radroots_sdk::signing::Slot::install(&self, &str) -> core::result::Result<radroots_sdk::signing::LocalIdentity, radroots_nostr::error::Error> +pub fn radroots_sdk::signing::Slot::new() -> Self +impl core::fmt::Debug for radroots_sdk::signing::Slot +pub fn radroots_sdk::signing::Slot::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +impl radroots_signing::signer::Signer for radroots_sdk::signing::Slot +pub fn radroots_sdk::signing::Slot::sign(&self, radroots_signing::request::SignRequest) -> radroots_signing::signer::BoxFuture<'_, core::result::Result<radroots_signing::receipt::SignReceipt, radroots_signing::error::Error>> +pub fn radroots_sdk::signing::Slot::status(&self) -> radroots_signing::signer::BoxFuture<'_, core::result::Result<radroots_signing::status::SignerStatus, radroots_signing::error::Error>> pub mod radroots_sdk::storage pub struct radroots_sdk::storage::Operations<'a> impl<'a> radroots_sdk::storage::Operations<'a> @@ -220,6 +287,12 @@ pub type radroots_sdk::storage::SqliteOptions = radroots_storage_sqlite::config: pub type radroots_sdk::storage::SqlitePaths = radroots_storage_sqlite::open::Paths pub type radroots_sdk::storage::Status = radroots_storage::status::StorageStatus pub mod radroots_sdk::sync +pub struct radroots_sdk::sync::HostPolicy +impl radroots_sdk::sync::HostPolicy +pub fn radroots_sdk::sync::HostPolicy::new(u64, u64, u64) -> core::result::Result<Self, radroots_sync::policy::Error> +pub fn radroots_sdk::sync::HostPolicy::standard() -> Self +impl core::default::Default for radroots_sdk::sync::HostPolicy +pub fn radroots_sdk::sync::HostPolicy::default() -> Self pub struct radroots_sdk::sync::Operations<'a> impl<'a> radroots_sdk::sync::Operations<'a> pub async fn radroots_sdk::sync::Operations<'a>::deliver_pending(&self, radroots_sync::push::DeliveryRunRequest) -> core::result::Result<radroots_sync::push::DeliveryRunReceipt, radroots_sync::policy::Error> @@ -274,6 +347,7 @@ pub const fn radroots_sdk::trade::PrepareRequest::new(radroots_signing::actor::A pub fn radroots_sdk::trade::prepare(radroots_sdk::trade::PrepareRequest) -> core::result::Result<radroots_sdk::trade::Plan, radroots_sdk::trade::PrepareError> pub fn radroots_sdk::trade::project(radroots_trade::trade_contract_v1::RadrootsTradeReductionInputV1) -> radroots_trade::trade_contract_v1::RadrootsTradeProjectionV1 pub mod radroots_sdk::transport +pub use radroots_sdk::transport::RelayUrlPolicy #[non_exhaustive] pub enum radroots_sdk::transport::DaemonAuth pub radroots_sdk::transport::DaemonAuth::BearerToken(alloc::string::String) pub radroots_sdk::transport::DaemonAuth::None @@ -303,6 +377,20 @@ impl core::fmt::Debug for radroots_sdk::transport::DaemonError pub fn radroots_sdk::transport::DaemonError::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result impl core::fmt::Display for radroots_sdk::transport::DaemonError pub fn radroots_sdk::transport::DaemonError::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +pub struct radroots_sdk::transport::NostrSlot +impl radroots_sdk::transport::NostrSlot +pub fn radroots_sdk::transport::NostrSlot::clear(&self) +pub fn radroots_sdk::transport::NostrSlot::configure<I, S>(&self, I) -> radroots_sdk::error::Result<()> where I: core::iter::traits::collect::IntoIterator<Item = S>, S: core::convert::AsRef<str> +pub fn radroots_sdk::transport::NostrSlot::new(radroots_transport_nostr::relay::RelayUrlPolicy) -> Self +pub fn radroots_sdk::transport::NostrSlot::targets(&self) -> core::option::Option<radroots_transport::target::TargetSet> +impl core::fmt::Debug for radroots_sdk::transport::NostrSlot +pub fn radroots_sdk::transport::NostrSlot::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +impl radroots_transport::sink::EventSink for radroots_sdk::transport::NostrSlot +pub fn radroots_sdk::transport::NostrSlot::deliver(&self, radroots_transport::sink::DeliveryRequest) -> radroots_transport::source::BoxFuture<'_, core::result::Result<radroots_transport::sink::DeliveryReceipt, radroots_transport::error::Error>> +pub fn radroots_sdk::transport::NostrSlot::status(&self) -> radroots_transport::source::BoxFuture<'_, core::result::Result<radroots_transport::status::SinkStatus, radroots_transport::error::Error>> +impl radroots_transport::source::EventSource for radroots_sdk::transport::NostrSlot +pub fn radroots_sdk::transport::NostrSlot::fetch(&self, radroots_transport::source::FetchRequest) -> radroots_transport::source::BoxFuture<'_, core::result::Result<radroots_transport::source::FetchPage, radroots_transport::error::Error>> +pub fn radroots_sdk::transport::NostrSlot::status(&self) -> radroots_transport::source::BoxFuture<'_, core::result::Result<radroots_transport::status::SourceStatus, radroots_transport::error::Error>> pub struct radroots_sdk::transport::Profile impl radroots_sdk::transport::Profile pub fn radroots_sdk::transport::Profile::delivery(radroots_transport::target::TargetSet, radroots_transport::policy::SatisfactionPolicy) -> core::result::Result<Self, radroots_transport::error::Error> @@ -324,6 +412,7 @@ pub fn radroots_sdk::client::Client::is_closed(&self) -> bool pub fn radroots_sdk::client::Client::listing(&self) -> radroots_sdk::error::Result<core::option::Option<radroots_sdk::listing::Operations<'_>>> pub fn radroots_sdk::client::Client::signer(&self) -> radroots_sdk::error::Result<core::option::Option<&dyn radroots_signing::signer::Signer>> pub fn radroots_sdk::client::Client::sink(&self) -> radroots_sdk::error::Result<core::option::Option<&dyn radroots_transport::sink::EventSink>> +pub fn radroots_sdk::client::Client::social(&self) -> radroots_sdk::error::Result<radroots_sdk::client::SocialOperations<'_>> pub fn radroots_sdk::client::Client::source(&self) -> radroots_sdk::error::Result<core::option::Option<&dyn radroots_transport::source::EventSource>> pub fn radroots_sdk::client::Client::storage(&self) -> radroots_sdk::error::Result<&dyn radroots_storage::Storage> pub async fn radroots_sdk::client::Client::storage_integrity(&self) -> radroots_sdk::error::Result<radroots_sdk::storage::IntegrityStatus> @@ -337,9 +426,11 @@ pub struct radroots_sdk::ClientBuilder impl radroots_sdk::client::ClientBuilder pub fn radroots_sdk::client::ClientBuilder::build(self) -> radroots_sdk::error::Result<radroots_sdk::client::Client> pub fn radroots_sdk::client::ClientBuilder::capability_availability(self, radroots_sdk::capability::CapabilityId, radroots_sdk::capability::Availability) -> Self +pub fn radroots_sdk::client::ClientBuilder::host_sync(self, radroots_sdk::sync::HostPolicy) -> Self pub fn radroots_sdk::client::ClientBuilder::memory(radroots_storage::event::SourceGeneration) -> Self pub fn radroots_sdk::client::ClientBuilder::memory_default() -> Self pub fn radroots_sdk::client::ClientBuilder::new() -> Self +pub fn radroots_sdk::client::ClientBuilder::nostr(self, radroots_sdk::transport::NostrSlot) -> Self pub fn radroots_sdk::client::ClientBuilder::signer(self, alloc::sync::Arc<dyn radroots_signing::signer::Signer>) -> Self pub fn radroots_sdk::client::ClientBuilder::signing(self, radroots_sdk::signing::Provider) -> Self pub fn radroots_sdk::client::ClientBuilder::sink(self, alloc::sync::Arc<dyn radroots_transport::sink::EventSink>) -> Self diff --git a/docs/implementation/DEVIATIONS.md b/docs/implementation/DEVIATIONS.md @@ -15,6 +15,7 @@ silently change `radroots.crates.release.v1`. | `RCRV1-DEV-006` | 073, 261-268 | Retire public event/trade codegen edges now and authenticate the predecessor TypeScript snapshots until their scheduled protocol/codec replacement. | | `RCRV1-DEV-007` | 122, 170, 235, 305 | Remove the predecessor monolithic transport SPI now; quarantine publish-frozen runtime, SDK, CLI, and daemon consumer shims until their explicit removal gates. | | `RCRV1-DEV-008` | 153, 155, 171, 179, 226, 288, 293, 313 | Activate final secrets dependency edges now; quarantine legacy vault/store consumers until their ordered storage, SDK, downstream, and final-removal gates. | +| `RCRV1-DEV-012` | 279, 282-283 | Complete the shared SDK engine with host-controlled identity/relay slots and bounded social operations before migrating the iOS host. | ## Record template diff --git a/docs/implementation/deviations.toml b/docs/implementation/deviations.toml @@ -2,6 +2,32 @@ schema_version = 1 architecture_id = "radroots.crates.release.v1" [[deviation]] +id = "RCRV1-DEV-012" +date = "2026-08-03" +status = "active" +approval = "All code-review recommendations and the full multi-RCLD implementation sequence were explicitly approved by the user." +affected_steps = ["279", "282", "283"] +spec_anchors = [ + "docs/specs/radroots_crates_release_v1.md#18-radroots_sdk", + "docs/specs/radroots_crates_release_v1.md#161-radroots_sdk", +] +source_evidence = [ + "The initial Step 279 app_rt bridge removed mobile identity, relay, profile, and post behavior before an equivalent shared-engine SDK operation surface existed.", + "The Step 282 simulator compile exposed those removed operations as downstream iOS failures rather than presentation-only drift.", + "The canonical transport selector correction in oss/lib now permits bounded kind, author, and time-filtered Nostr retrieval without reintroducing legacy runtime ownership.", +] +replacement_action = "Before completing Step 282, add SDK-owned host-controlled local-signer and Nostr slots, explicit native host sync policy, and bounded verified social fetch/publish operations; then keep keychain persistence, lifecycle polling, and retry scheduling in the iOS host." +verification = [ + "The SDK mobile feature composition builds and tests with one shared client and no hidden executor or worker.", + "Local signer generation hands the only persistence representation to the host; clear and restore retain the same public identity.", + "Nostr relay replacement validates the full set before atomic installation and preserves the prior set on failure.", + "Fetch verifies and durably ingests accepted events; publish durably enqueues and performs exactly one explicit delivery pass.", +] +unresolved_risk = "Step 282 and Step 283 must still regenerate app_rt bindings and migrate the iOS host to polling and keychain-owned lifecycle behavior before the corrective record can close." +normative_architecture_change = false +adr_required = false + +[[deviation]] id = "RCRV1-DEV-008" date = "2026-08-01" status = "active" diff --git a/docs/specs/radroots_crates_release_v1.md b/docs/specs/radroots_crates_release_v1.md @@ -928,7 +928,7 @@ memory = ["radroots_storage/memory"] # Explicit native capabilities. sqlite = ["dep:radroots_storage_sqlite"] -sync = ["dep:radroots_sync"] +sync = ["dep:radroots_sync", "dep:uuid"] nostr = [ "sync", "dep:radroots_nostr", diff --git a/tools/xtask/src/check.rs b/tools/xtask/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"],