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:
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"],