commit 2a77aee91492d052e1b22aa009266cf223dbe7b8
parent 84e4f67d326c8c5d1153f1c709fa52d2cf6dfaa5
Author: triesap <tyson@radroots.org>
Date: Wed, 17 Jun 2026 13:47:39 -0700
sdk: add runtime contract DTOs
- expose stable SDK error codes, classes, retryability, details, and typed recovery actions
- serialize CLI-facing runtime DTOs with redacted idempotency and signer-sensitive fields
- reject boundary whitespace in SDK idempotency keys and split relay caller order from canonical digest order
- validate with SDK fmt, no-default check, cli-runtime tests, all-features tests, workspace check, and xtask check
Diffstat:
12 files changed, 1034 insertions(+), 59 deletions(-)
diff --git a/crates/sdk/src/error.rs b/crates/sdk/src/error.rs
@@ -2,22 +2,49 @@
use std::{fmt, path::PathBuf};
#[cfg(feature = "runtime")]
-#[derive(Clone, Debug, PartialEq, Eq)]
+use serde_json::{Value, json};
+
+#[cfg(feature = "runtime")]
+#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize)]
+#[serde(rename_all = "snake_case")]
+#[non_exhaustive]
+pub enum RadrootsSdkErrorClass {
+ Authorization,
+ Clock,
+ Configuration,
+ LocalMutation,
+ Request,
+ Storage,
+ Transport,
+ Unsupported,
+}
+
+#[cfg(feature = "runtime")]
+#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize)]
+#[serde(rename_all = "snake_case")]
+#[non_exhaustive]
pub enum RadrootsSdkRecoveryAction {
RetryOutboxEnqueue,
InspectLocalStores,
RetryOperationWithSameIdempotencyKey,
+ ConfigureRelayTargets,
+ FixRequest,
+ SelectAuthorizedActor,
+ RetryAfterTransportFailure,
+ EnableRequiredFeature,
}
#[cfg(feature = "runtime")]
-#[derive(Clone, Debug, PartialEq, Eq)]
+#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize)]
+#[serde(rename_all = "snake_case")]
+#[non_exhaustive]
pub enum RadrootsSdkPartialLocalMutationFailure {
OutboxEnqueue,
OutboxIdempotencyConflict,
}
#[cfg(feature = "runtime")]
-#[derive(Clone, Debug, PartialEq, Eq)]
+#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)]
pub struct RadrootsSdkPartialLocalMutationError {
pub event_id: Option<String>,
pub operation_kind: String,
@@ -110,6 +137,175 @@ pub enum RadrootsSdkError {
#[cfg(feature = "runtime")]
impl RadrootsSdkError {
+ pub fn code(&self) -> &'static str {
+ match self {
+ Self::Io { .. } => "io",
+ Self::ClockBeforeUnixEpoch => "clock_before_unix_epoch",
+ Self::TimestampOutOfRange { .. } => "timestamp_out_of_range",
+ Self::UnauthorizedActor { .. } => "unauthorized_actor",
+ Self::SignerPubkeyMismatch { .. } => "signer_pubkey_mismatch",
+ Self::EmptyTargetRelays { .. } => "empty_target_relays",
+ Self::RelayTargetLimitExceeded { .. } => "relay_target_limit_exceeded",
+ Self::InvalidRelayUrl { .. } => "invalid_relay_url",
+ Self::IdempotencyConflict { .. } => "idempotency_conflict",
+ Self::OrderStatusLimitInvalid { .. } => "order_status_limit_invalid",
+ Self::InvalidOrderId { .. } => "invalid_order_id",
+ Self::ProductSyncUnsupported { .. } => "product_sync_unsupported",
+ Self::ProductSyncRelaySetupFailure { .. } => "product_sync_relay_setup_failure",
+ Self::Authority { .. } => "authority",
+ Self::EventStore { .. } => "event_store",
+ Self::InvalidRequest { .. } => "invalid_request",
+ Self::ListingDraft { .. } => "listing_draft",
+ Self::ListingMutation { .. } => "listing_mutation",
+ Self::Outbox { .. } => "outbox",
+ Self::RelayTransport { .. } => "relay_transport",
+ Self::Projection { .. } => "projection",
+ Self::PartialLocalMutation(_) => "partial_local_mutation",
+ }
+ }
+
+ pub fn class(&self) -> RadrootsSdkErrorClass {
+ match self {
+ Self::Io { .. }
+ | Self::EventStore { .. }
+ | Self::Outbox { .. }
+ | Self::Projection { .. } => RadrootsSdkErrorClass::Storage,
+ Self::ClockBeforeUnixEpoch | Self::TimestampOutOfRange { .. } => {
+ RadrootsSdkErrorClass::Clock
+ }
+ Self::UnauthorizedActor { .. }
+ | Self::SignerPubkeyMismatch { .. }
+ | Self::Authority { .. } => RadrootsSdkErrorClass::Authorization,
+ Self::EmptyTargetRelays { .. }
+ | Self::RelayTargetLimitExceeded { .. }
+ | Self::InvalidRelayUrl { .. } => RadrootsSdkErrorClass::Configuration,
+ Self::IdempotencyConflict { .. }
+ | Self::OrderStatusLimitInvalid { .. }
+ | Self::InvalidOrderId { .. }
+ | Self::InvalidRequest { .. }
+ | Self::ListingDraft { .. }
+ | Self::ListingMutation { .. } => RadrootsSdkErrorClass::Request,
+ Self::ProductSyncUnsupported { .. } => RadrootsSdkErrorClass::Unsupported,
+ Self::ProductSyncRelaySetupFailure { .. } | Self::RelayTransport { .. } => {
+ RadrootsSdkErrorClass::Transport
+ }
+ Self::PartialLocalMutation(_) => RadrootsSdkErrorClass::LocalMutation,
+ }
+ }
+
+ pub fn retryable(&self) -> bool {
+ matches!(
+ self,
+ Self::Io { .. }
+ | Self::ProductSyncRelaySetupFailure { .. }
+ | Self::EventStore { .. }
+ | Self::Outbox { .. }
+ | Self::RelayTransport { .. }
+ | Self::Projection { .. }
+ | Self::PartialLocalMutation(_)
+ )
+ }
+
+ pub fn recovery_actions(&self) -> Vec<RadrootsSdkRecoveryAction> {
+ match self {
+ Self::Io { .. }
+ | Self::EventStore { .. }
+ | Self::Outbox { .. }
+ | Self::Projection { .. } => vec![RadrootsSdkRecoveryAction::InspectLocalStores],
+ Self::UnauthorizedActor { .. }
+ | Self::SignerPubkeyMismatch { .. }
+ | Self::Authority { .. } => vec![RadrootsSdkRecoveryAction::SelectAuthorizedActor],
+ Self::EmptyTargetRelays { .. }
+ | Self::RelayTargetLimitExceeded { .. }
+ | Self::InvalidRelayUrl { .. } => {
+ vec![RadrootsSdkRecoveryAction::ConfigureRelayTargets]
+ }
+ Self::IdempotencyConflict { .. } => {
+ vec![RadrootsSdkRecoveryAction::RetryOperationWithSameIdempotencyKey]
+ }
+ Self::ProductSyncUnsupported { .. } => {
+ vec![RadrootsSdkRecoveryAction::EnableRequiredFeature]
+ }
+ Self::ProductSyncRelaySetupFailure { .. } | Self::RelayTransport { .. } => {
+ vec![RadrootsSdkRecoveryAction::RetryAfterTransportFailure]
+ }
+ Self::PartialLocalMutation(error) => vec![error.recovery],
+ Self::ClockBeforeUnixEpoch
+ | Self::TimestampOutOfRange { .. }
+ | Self::OrderStatusLimitInvalid { .. }
+ | Self::InvalidOrderId { .. }
+ | Self::InvalidRequest { .. }
+ | Self::ListingDraft { .. }
+ | Self::ListingMutation { .. } => vec![RadrootsSdkRecoveryAction::FixRequest],
+ }
+ }
+
+ pub fn detail_json(&self) -> Value {
+ let detail = match self {
+ Self::Io { path, message } => {
+ json!({ "path": path.display().to_string(), "message": message })
+ }
+ Self::ClockBeforeUnixEpoch => json!({}),
+ Self::TimestampOutOfRange { value } => json!({ "value": value }),
+ Self::UnauthorizedActor { operation, reason } => {
+ json!({ "operation": operation, "reason": reason })
+ }
+ Self::SignerPubkeyMismatch {
+ operation,
+ expected_pubkey_prefix,
+ signer_pubkey_prefix,
+ } => json!({
+ "operation": operation,
+ "expected_pubkey_prefix": expected_pubkey_prefix,
+ "signer_pubkey_prefix": signer_pubkey_prefix
+ }),
+ Self::EmptyTargetRelays { operation } => json!({ "operation": operation }),
+ Self::RelayTargetLimitExceeded { max, actual } => {
+ json!({ "max": max, "actual": actual })
+ }
+ Self::InvalidRelayUrl { url, reason } => json!({ "url": url, "reason": reason }),
+ Self::IdempotencyConflict {
+ operation_kind,
+ expected_pubkey_prefix,
+ existing_digest_prefix,
+ new_digest_prefix,
+ } => json!({
+ "operation_kind": operation_kind,
+ "expected_pubkey_prefix": expected_pubkey_prefix,
+ "existing_digest_prefix": existing_digest_prefix,
+ "new_digest_prefix": new_digest_prefix
+ }),
+ Self::OrderStatusLimitInvalid { limit, min, max } => {
+ json!({ "limit": limit, "min": min, "max": max })
+ }
+ Self::InvalidOrderId { value, message } => {
+ json!({ "value": value, "message": message })
+ }
+ Self::ProductSyncUnsupported {
+ operation,
+ required_feature,
+ } => json!({ "operation": operation, "required_feature": required_feature }),
+ Self::ProductSyncRelaySetupFailure { message }
+ | Self::Authority { message }
+ | Self::EventStore { message }
+ | Self::InvalidRequest { message }
+ | Self::ListingDraft { message }
+ | Self::ListingMutation { message }
+ | Self::Outbox { message }
+ | Self::RelayTransport { message }
+ | Self::Projection { message } => json!({ "message": message }),
+ Self::PartialLocalMutation(error) => json!(error),
+ };
+ json!({
+ "code": self.code(),
+ "class": self.class(),
+ "retryable": self.retryable(),
+ "message": self.to_string(),
+ "recovery_actions": self.recovery_actions(),
+ "detail": detail
+ })
+ }
+
pub fn partial_local_mutation(error: RadrootsSdkPartialLocalMutationError) -> Self {
Self::PartialLocalMutation(error)
}
@@ -412,13 +608,24 @@ fn redacted_prefix(value: &str) -> String {
#[cfg(feature = "runtime")]
fn redacted_relay_url(value: String) -> String {
- let Some((scheme, rest)) = value.split_once("://") else {
- return value;
+ let redacted = redact_query_or_fragment(value.as_str());
+ let Some((scheme, rest)) = redacted.split_once("://") else {
+ return redacted;
};
let authority = rest.split('/').next().unwrap_or(rest);
let Some((_, after_userinfo)) = authority.rsplit_once('@') else {
- return value;
+ return redacted;
};
let path = rest.strip_prefix(authority).unwrap_or_default();
format!("{scheme}://<redacted>@{after_userinfo}{path}")
}
+
+#[cfg(feature = "runtime")]
+fn redact_query_or_fragment(value: &str) -> String {
+ let Some((index, marker)) = value.char_indices().find_map(|(index, character)| {
+ matches!(character, '?' | '#').then_some((index, character))
+ }) else {
+ return value.to_owned();
+ };
+ format!("{}{}<redacted>", &value[..index], marker)
+}
diff --git a/crates/sdk/src/idempotency.rs b/crates/sdk/src/idempotency.rs
@@ -1,5 +1,6 @@
use crate::RadrootsSdkError;
use core::fmt;
+use serde::ser::SerializeStruct;
use sha2::{Digest, Sha256};
pub const SDK_IDEMPOTENCY_KEY_MAX_LEN: usize = 256;
@@ -9,10 +10,15 @@ pub struct SdkIdempotencyKey(String);
impl SdkIdempotencyKey {
pub fn new(value: impl AsRef<str>) -> Result<Self, RadrootsSdkError> {
- let value = value.as_ref().trim();
+ let value = value.as_ref();
if value.is_empty() {
return Err(invalid_request("idempotency key must not be empty"));
}
+ if value.trim() != value {
+ return Err(invalid_request(
+ "idempotency key must not include boundary whitespace",
+ ));
+ }
if value.len() > SDK_IDEMPOTENCY_KEY_MAX_LEN {
return Err(invalid_request(format!(
"idempotency key must be at most {SDK_IDEMPOTENCY_KEY_MAX_LEN} bytes"
@@ -64,6 +70,18 @@ impl fmt::Debug for SdkIdempotencyKey {
}
}
+impl serde::Serialize for SdkIdempotencyKey {
+ fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
+ where
+ S: serde::Serializer,
+ {
+ let mut state = serializer.serialize_struct("SdkIdempotencyKey", 2)?;
+ state.serialize_field("value", "<redacted>")?;
+ state.serialize_field("len", &self.0.len())?;
+ state.end()
+ }
+}
+
#[derive(serde::Serialize)]
struct SdkIdempotencyDerivationInput<'a> {
operation_kind: &'static str,
diff --git a/crates/sdk/src/lib.rs b/crates/sdk/src/lib.rs
@@ -39,8 +39,8 @@ mod sync_runtime;
#[cfg(feature = "runtime")]
pub use crate::error::{
- RadrootsSdkError, RadrootsSdkPartialLocalMutationError, RadrootsSdkPartialLocalMutationFailure,
- RadrootsSdkRecoveryAction,
+ RadrootsSdkError, RadrootsSdkErrorClass, RadrootsSdkPartialLocalMutationError,
+ RadrootsSdkPartialLocalMutationFailure, RadrootsSdkRecoveryAction,
};
#[cfg(feature = "runtime")]
pub use crate::idempotency::{SDK_IDEMPOTENCY_KEY_MAX_LEN, SdkIdempotencyKey};
@@ -63,8 +63,10 @@ pub use crate::relay_targets::{
};
#[cfg(feature = "runtime")]
pub use crate::runtime::{
- RadrootsSdk, RadrootsSdkBuilder, RadrootsSdkClock, RadrootsSdkStorageConfig,
- RadrootsSdkStoragePaths, RadrootsSdkTimestamp,
+ BackupReceipt, BackupRequest, IntegrityReceipt, IntegrityRequest, RadrootsSdk,
+ RadrootsSdkBuilder, RadrootsSdkClock, RadrootsSdkStorageConfig, RadrootsSdkStoragePaths,
+ RadrootsSdkTimestamp, SdkBackupState, SdkStorageKind, StorageStatusReceipt,
+ StorageStatusRequest,
};
#[cfg(feature = "runtime")]
pub use crate::sync_runtime::{
diff --git a/crates/sdk/src/listings_runtime.rs b/crates/sdk/src/listings_runtime.rs
@@ -4,12 +4,15 @@ use crate::{
SdkRelayTargetPolicy, SdkRelayTargetSet, SdkRelayUrlPolicy, runtime::sdk_now_ms,
};
#[cfg(feature = "runtime")]
-use radroots_authority::{RadrootsActorContext, RadrootsEventSigner, sign_authorized_draft};
+use radroots_authority::{
+ RadrootsActorContext, RadrootsActorSource, RadrootsEventSigner, sign_authorized_draft,
+};
#[cfg(feature = "runtime")]
use radroots_event_store::RadrootsEventIngest;
#[cfg(feature = "runtime")]
use radroots_events::{
RadrootsNostrEvent,
+ contract::RadrootsActorRole,
draft::{RadrootsFrozenEventDraft, RadrootsSignedNostrEvent},
ids::{RadrootsEventId, RadrootsListingAddress},
listing::RadrootsListing,
@@ -22,6 +25,8 @@ use radroots_trade::listing::{
build_listing_mutation_draft, canonicalize_listing_draft,
};
#[cfg(feature = "runtime")]
+use serde::ser::SerializeStruct;
+#[cfg(feature = "runtime")]
use sha2::{Digest, Sha256};
#[cfg(feature = "runtime")]
@@ -36,6 +41,20 @@ pub struct ListingPreparePublishRequest {
}
#[cfg(feature = "runtime")]
+impl serde::Serialize for ListingPreparePublishRequest {
+ fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
+ where
+ S: serde::Serializer,
+ {
+ let mut state = serializer.serialize_struct("ListingPreparePublishRequest", 3)?;
+ state.serialize_field("actor", &SdkActorContextJson(&self.actor))?;
+ state.serialize_field("document", &self.document)?;
+ state.serialize_field("created_at", &self.created_at)?;
+ state.end()
+ }
+}
+
+#[cfg(feature = "runtime")]
impl ListingPreparePublishRequest {
pub fn new(actor: RadrootsActorContext, listing: RadrootsListing) -> Self {
Self {
@@ -73,6 +92,22 @@ pub struct ListingEnqueuePublishRequest {
}
#[cfg(feature = "runtime")]
+impl serde::Serialize for ListingEnqueuePublishRequest {
+ fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
+ where
+ S: serde::Serializer,
+ {
+ let mut state = serializer.serialize_struct("ListingEnqueuePublishRequest", 5)?;
+ state.serialize_field("actor", &SdkActorContextJson(&self.actor))?;
+ state.serialize_field("document", &self.document)?;
+ state.serialize_field("target_relays", &self.target_relays)?;
+ state.serialize_field("idempotency_key", &self.idempotency_key)?;
+ state.serialize_field("created_at", &self.created_at)?;
+ state.end()
+ }
+}
+
+#[cfg(feature = "runtime")]
impl ListingEnqueuePublishRequest {
pub fn new(
actor: RadrootsActorContext,
@@ -133,7 +168,7 @@ impl ListingEnqueuePublishRequest {
}
#[cfg(feature = "runtime")]
-#[derive(Clone, Debug, PartialEq, Eq)]
+#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)]
pub struct ListingPublishPlan {
pub public_listing_addr: RadrootsListingAddress,
pub draft_listing_addr: RadrootsListingAddress,
@@ -143,7 +178,9 @@ pub struct ListingPublishPlan {
}
#[cfg(feature = "runtime")]
-#[derive(Clone, Debug, PartialEq, Eq)]
+#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)]
+#[serde(rename_all = "snake_case")]
+#[non_exhaustive]
pub enum SdkMutationState {
StoredAndQueued,
AlreadyQueued,
@@ -160,7 +197,7 @@ impl From<RadrootsOutboxEnqueueStatus> for SdkMutationState {
}
#[cfg(feature = "runtime")]
-#[derive(Clone, Debug, PartialEq, Eq)]
+#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)]
pub struct ListingEnqueueReceipt {
pub public_listing_addr: RadrootsListingAddress,
pub draft_listing_addr: RadrootsListingAddress,
@@ -227,7 +264,7 @@ impl<'sdk> ListingsClient<'sdk> {
LISTING_PUBLISH_OPERATION_KIND,
plan.frozen_draft.expected_event_id.as_str(),
plan.frozen_draft.expected_pubkey.as_str(),
- target_relays.relays(),
+ target_relays.canonical_relays(),
)?,
};
let observed_at_ms = sdk_now_ms(self.sdk)?;
@@ -236,9 +273,10 @@ impl<'sdk> ListingsClient<'sdk> {
let ingest = RadrootsEventIngest::new(event, observed_at_ms)
.with_raw_json(signed_event.raw_json.clone());
let ingest_receipt = self.sdk._event_store.ingest_event(ingest).await?;
+ let canonical_target_relays = target_relays.canonical_relays().to_vec();
let target_relay_values = target_relays.into_vec();
let partial_failure_digest_prefix =
- outbox_idempotency_digest_prefix(&plan, target_relay_values.as_slice())?;
+ outbox_idempotency_digest_prefix(&plan, canonical_target_relays.as_slice())?;
let outbox_input = signed_outbox_input(
&plan,
signed_event.clone(),
@@ -411,3 +449,54 @@ fn event_from_signed(signed_event: &RadrootsSignedNostrEvent) -> RadrootsNostrEv
sig: signed_event.sig.clone(),
}
}
+
+#[cfg(feature = "runtime")]
+struct SdkActorContextJson<'a>(&'a RadrootsActorContext);
+
+#[cfg(feature = "runtime")]
+impl serde::Serialize for SdkActorContextJson<'_> {
+ fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
+ where
+ S: serde::Serializer,
+ {
+ let roles = self
+ .0
+ .roles()
+ .iter()
+ .map(actor_role_code)
+ .collect::<Vec<_>>();
+ let account_id = self.0.account_id().map(|account_id| account_id.as_str());
+ let mut state = serializer.serialize_struct("SdkActorContext", 4)?;
+ state.serialize_field("pubkey", self.0.pubkey().as_str())?;
+ state.serialize_field("roles", &roles)?;
+ state.serialize_field("account_id", &account_id)?;
+ state.serialize_field("source", actor_source_code(self.0.source()))?;
+ state.end()
+ }
+}
+
+#[cfg(feature = "runtime")]
+fn actor_role_code(role: &RadrootsActorRole) -> &'static str {
+ match role {
+ RadrootsActorRole::Any => "any",
+ RadrootsActorRole::Application => "application",
+ RadrootsActorRole::Buyer => "buyer",
+ RadrootsActorRole::Farmer => "farmer",
+ RadrootsActorRole::Member => "member",
+ RadrootsActorRole::Moderator => "moderator",
+ RadrootsActorRole::Relay => "relay",
+ RadrootsActorRole::Seller => "seller",
+ RadrootsActorRole::Service => "service",
+ }
+}
+
+#[cfg(feature = "runtime")]
+fn actor_source_code(source: RadrootsActorSource) -> &'static str {
+ match source {
+ RadrootsActorSource::LocalAccount => "local_account",
+ RadrootsActorSource::ExplicitPubkey => "explicit_pubkey",
+ RadrootsActorSource::RemoteSigner => "remote_signer",
+ RadrootsActorSource::Service => "service",
+ RadrootsActorSource::Test => "test",
+ }
+}
diff --git a/crates/sdk/src/orders_runtime.rs b/crates/sdk/src/orders_runtime.rs
@@ -11,6 +11,8 @@ use radroots_trade::order::{
RadrootsOrderSettlementState, RadrootsOrderStatus, RadrootsOrderStoreQueryError,
order_projection_query_for_order_id,
};
+#[cfg(feature = "runtime")]
+use serde::ser::SerializeStruct;
#[cfg(feature = "runtime")]
pub const ORDER_STATUS_DEFAULT_LIMIT: u32 = 500;
@@ -18,7 +20,7 @@ pub const ORDER_STATUS_DEFAULT_LIMIT: u32 = 500;
pub const ORDER_STATUS_MAX_LIMIT: u32 = 1_000;
#[cfg(feature = "runtime")]
-#[derive(Clone, Debug, PartialEq, Eq)]
+#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)]
pub struct OrderStatusRequest {
pub order_id: RadrootsOrderId,
pub limit: u32,
@@ -57,7 +59,7 @@ impl OrderStatusRequest {
}
#[cfg(feature = "runtime")]
-#[derive(Clone, Debug, PartialEq, Eq)]
+#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)]
pub struct OrderStatusReceipt {
pub order_id: RadrootsOrderId,
pub source: SdkOrderStatusSource,
@@ -80,13 +82,17 @@ pub struct OrderStatusReceipt {
}
#[cfg(feature = "runtime")]
-#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize)]
+#[serde(rename_all = "snake_case")]
+#[non_exhaustive]
pub enum SdkOrderStatusSource {
LocalEventStore,
}
#[cfg(feature = "runtime")]
-#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize)]
+#[serde(rename_all = "snake_case")]
+#[non_exhaustive]
pub enum OrderStatusKind {
Missing,
Requested,
@@ -99,7 +105,9 @@ pub enum OrderStatusKind {
}
#[cfg(feature = "runtime")]
-#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize)]
+#[serde(rename_all = "snake_case")]
+#[non_exhaustive]
pub enum OrderFulfillmentStatusKind {
AcceptedNotFulfilled,
Preparing,
@@ -110,7 +118,9 @@ pub enum OrderFulfillmentStatusKind {
}
#[cfg(feature = "runtime")]
-#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize)]
+#[serde(rename_all = "snake_case")]
+#[non_exhaustive]
pub enum OrderPaymentStateKind {
NotRecorded,
Recorded,
@@ -120,7 +130,9 @@ pub enum OrderPaymentStateKind {
}
#[cfg(feature = "runtime")]
-#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize)]
+#[serde(rename_all = "snake_case")]
+#[non_exhaustive]
pub enum OrderSettlementStateKind {
NotRequired,
Pending,
@@ -145,10 +157,30 @@ impl SdkOrderStatusIssue {
fn single(kind: SdkOrderStatusIssueKind, event_id: RadrootsEventId) -> Self {
Self::new(kind, vec![event_id])
}
+
+ pub fn code(&self) -> String {
+ self.kind.code()
+ }
+}
+
+#[cfg(feature = "runtime")]
+impl serde::Serialize for SdkOrderStatusIssue {
+ fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
+ where
+ S: serde::Serializer,
+ {
+ let mut state = serializer.serialize_struct("SdkOrderStatusIssue", 3)?;
+ state.serialize_field("code", &self.code())?;
+ state.serialize_field("kind", &self.kind)?;
+ state.serialize_field("event_ids", &self.event_ids)?;
+ state.end()
+ }
}
#[cfg(feature = "runtime")]
-#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize)]
+#[serde(rename_all = "snake_case")]
+#[non_exhaustive]
pub enum SdkOrderStatusIssueKind {
MissingRequest,
MultipleRequests,
@@ -274,6 +306,13 @@ pub enum SdkOrderStatusIssueKind {
}
#[cfg(feature = "runtime")]
+impl SdkOrderStatusIssueKind {
+ pub fn code(self) -> String {
+ camel_to_snake(format!("{self:?}").as_str())
+ }
+}
+
+#[cfg(feature = "runtime")]
impl<'sdk> OrdersClient<'sdk> {
pub async fn status(
&self,
@@ -827,3 +866,19 @@ fn projection_error(error: RadrootsOrderStoreQueryError) -> RadrootsSdkError {
message: message.to_owned(),
}
}
+
+#[cfg(feature = "runtime")]
+fn camel_to_snake(value: &str) -> String {
+ let mut output = String::new();
+ for (index, character) in value.chars().enumerate() {
+ if character.is_ascii_uppercase() {
+ if index > 0 {
+ output.push('_');
+ }
+ output.push(character.to_ascii_lowercase());
+ } else {
+ output.push(character);
+ }
+ }
+ output
+}
diff --git a/crates/sdk/src/relay_targets.rs b/crates/sdk/src/relay_targets.rs
@@ -1,10 +1,13 @@
use crate::RadrootsSdkError;
use radroots_relay_transport::{RadrootsRelayUrl, RadrootsRelayUrlPolicy};
+use serde::ser::SerializeStruct;
use std::collections::BTreeSet;
pub const SDK_RELAY_TARGET_MAX_COUNT: usize = 20;
-#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize)]
+#[serde(rename_all = "snake_case")]
+#[non_exhaustive]
pub enum SdkRelayUrlPolicy {
Public,
Localhost,
@@ -20,6 +23,7 @@ impl SdkRelayUrlPolicy {
}
#[derive(Clone, Debug, PartialEq, Eq)]
+#[non_exhaustive]
pub enum SdkRelayTargetPolicy {
Explicit(SdkRelayTargetSet),
UseConfiguredRelays,
@@ -42,9 +46,32 @@ impl SdkRelayTargetPolicy {
}
}
+impl serde::Serialize for SdkRelayTargetPolicy {
+ fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
+ where
+ S: serde::Serializer,
+ {
+ match self {
+ Self::Explicit(targets) => {
+ let mut state = serializer.serialize_struct("SdkRelayTargetPolicy", 3)?;
+ state.serialize_field("kind", "explicit")?;
+ state.serialize_field("relays", targets.relays())?;
+ state.serialize_field("canonical_relays", targets.canonical_relays())?;
+ state.end()
+ }
+ Self::UseConfiguredRelays => {
+ let mut state = serializer.serialize_struct("SdkRelayTargetPolicy", 1)?;
+ state.serialize_field("kind", "use_configured_relays")?;
+ state.end()
+ }
+ }
+ }
+}
+
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct SdkRelayTargetSet {
relays: Vec<String>,
+ canonical_relays: Vec<String>,
}
impl SdkRelayTargetSet {
@@ -53,17 +80,25 @@ impl SdkRelayTargetSet {
I: IntoIterator<Item = S>,
S: AsRef<str>,
{
- let mut normalized = BTreeSet::new();
+ let mut ordered_relays = Vec::new();
+ let mut seen = BTreeSet::new();
for relay in relays {
- normalized.insert(normalized_relay_url(relay.as_ref(), policy)?);
+ let normalized = normalized_relay_url(relay.as_ref(), policy)?;
+ if seen.insert(normalized.clone()) {
+ ordered_relays.push(normalized);
+ }
}
- Self::from_normalized_set(normalized)
+ Self::from_normalized_ordered(ordered_relays)
}
pub fn relays(&self) -> &[String] {
self.relays.as_slice()
}
+ pub fn canonical_relays(&self) -> &[String] {
+ self.canonical_relays.as_slice()
+ }
+
pub fn into_vec(self) -> Vec<String> {
self.relays
}
@@ -92,28 +127,48 @@ impl SdkRelayTargetSet {
}
pub(crate) fn from_normalized_relays(relays: Vec<String>) -> Result<Self, RadrootsSdkError> {
- let normalized = relays.into_iter().collect::<BTreeSet<_>>();
- Self::from_normalized_set(normalized)
+ let mut ordered = Vec::new();
+ let mut seen = BTreeSet::new();
+ for relay in relays {
+ if seen.insert(relay.clone()) {
+ ordered.push(relay);
+ }
+ }
+ Self::from_normalized_ordered(ordered)
}
- fn from_normalized_set(normalized: BTreeSet<String>) -> Result<Self, RadrootsSdkError> {
- if normalized.is_empty() {
+ fn from_normalized_ordered(relays: Vec<String>) -> Result<Self, RadrootsSdkError> {
+ if relays.is_empty() {
return Err(RadrootsSdkError::empty_target_relays(
"sdk relay target set",
));
}
- if normalized.len() > SDK_RELAY_TARGET_MAX_COUNT {
+ if relays.len() > SDK_RELAY_TARGET_MAX_COUNT {
return Err(RadrootsSdkError::relay_target_limit_exceeded(
SDK_RELAY_TARGET_MAX_COUNT,
- normalized.len(),
+ relays.len(),
));
}
+ let canonical_relays = relays.iter().cloned().collect::<BTreeSet<_>>();
Ok(Self {
- relays: normalized.into_iter().collect(),
+ relays,
+ canonical_relays: canonical_relays.into_iter().collect(),
})
}
}
+impl serde::Serialize for SdkRelayTargetSet {
+ fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
+ where
+ S: serde::Serializer,
+ {
+ let mut state = serializer.serialize_struct("SdkRelayTargetSet", 2)?;
+ state.serialize_field("relays", self.relays())?;
+ state.serialize_field("canonical_relays", self.canonical_relays())?;
+ state.end()
+ }
+}
+
fn normalized_relay_url(
value: &str,
policy: SdkRelayUrlPolicy,
diff --git a/crates/sdk/src/runtime.rs b/crates/sdk/src/runtime.rs
@@ -15,7 +15,8 @@ use std::{
};
#[cfg(feature = "runtime")]
-#[derive(Clone, Debug, PartialEq, Eq)]
+#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)]
+#[non_exhaustive]
pub enum RadrootsSdkStorageConfig {
Memory,
Directory(PathBuf),
@@ -29,7 +30,7 @@ impl Default for RadrootsSdkStorageConfig {
}
#[cfg(feature = "runtime")]
-#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
+#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, serde::Serialize)]
pub struct RadrootsSdkTimestamp(u64);
#[cfg(feature = "runtime")]
@@ -48,7 +49,9 @@ impl RadrootsSdkTimestamp {
}
#[cfg(feature = "runtime")]
-#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize)]
+#[serde(rename_all = "snake_case")]
+#[non_exhaustive]
pub enum RadrootsSdkClock {
System,
Fixed(RadrootsSdkTimestamp),
@@ -77,13 +80,70 @@ impl RadrootsSdkClock {
}
#[cfg(feature = "runtime")]
-#[derive(Clone, Debug, PartialEq, Eq)]
+#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)]
pub struct RadrootsSdkStoragePaths {
pub event_store_path: PathBuf,
pub outbox_path: PathBuf,
}
#[cfg(feature = "runtime")]
+#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize)]
+pub struct StorageStatusRequest {}
+
+#[cfg(feature = "runtime")]
+#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)]
+pub struct StorageStatusReceipt {
+ pub storage: SdkStorageKind,
+ pub paths: Option<RadrootsSdkStoragePaths>,
+}
+
+#[cfg(feature = "runtime")]
+#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize)]
+#[serde(rename_all = "snake_case")]
+#[non_exhaustive]
+pub enum SdkStorageKind {
+ Memory,
+ Directory,
+}
+
+#[cfg(feature = "runtime")]
+#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)]
+pub struct BackupRequest {
+ pub destination: PathBuf,
+ pub overwrite: bool,
+}
+
+#[cfg(feature = "runtime")]
+#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)]
+pub struct BackupReceipt {
+ pub destination: PathBuf,
+ pub state: SdkBackupState,
+ pub event_store_path: Option<PathBuf>,
+ pub outbox_path: Option<PathBuf>,
+}
+
+#[cfg(feature = "runtime")]
+#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize)]
+#[serde(rename_all = "snake_case")]
+#[non_exhaustive]
+pub enum SdkBackupState {
+ Planned,
+ Completed,
+}
+
+#[cfg(feature = "runtime")]
+#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize)]
+pub struct IntegrityRequest {}
+
+#[cfg(feature = "runtime")]
+#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)]
+pub struct IntegrityReceipt {
+ pub checked_paths: Vec<PathBuf>,
+ pub event_store_ok: bool,
+ pub outbox_ok: bool,
+}
+
+#[cfg(feature = "runtime")]
#[derive(Clone, Debug)]
pub struct RadrootsSdkBuilder {
storage: RadrootsSdkStorageConfig,
diff --git a/crates/sdk/src/sync_runtime.rs b/crates/sdk/src/sync_runtime.rs
@@ -28,7 +28,7 @@ const CLAIM_TTL_MS: i64 = 30_000;
const NEXT_ATTEMPT_DELAY_MS: i64 = 60_000;
#[cfg(feature = "runtime")]
-#[derive(Clone, Debug, PartialEq, Eq)]
+#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)]
pub struct PushOutboxRequest {
pub limit: usize,
pub republish_accepted_relays: bool,
@@ -71,7 +71,7 @@ impl PushOutboxRequest {
}
#[cfg(feature = "runtime")]
-#[derive(Clone, Debug, Default, PartialEq, Eq)]
+#[derive(Clone, Debug, Default, PartialEq, Eq, serde::Serialize)]
pub struct PushOutboxReceipt {
pub attempted_events: usize,
pub published_events: usize,
@@ -95,7 +95,7 @@ impl PushOutboxReceipt {
}
#[cfg(feature = "runtime")]
-#[derive(Clone, Debug, PartialEq, Eq)]
+#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)]
pub struct PushOutboxEventReceipt {
pub event_id: RadrootsEventId,
pub outbox_event_id: i64,
@@ -110,7 +110,7 @@ pub struct PushOutboxEventReceipt {
}
#[cfg(feature = "runtime")]
-#[derive(Clone, Debug, PartialEq, Eq)]
+#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)]
pub struct PushOutboxRelayReceipt {
pub relay_url: String,
pub outcome_kind: PushOutboxRelayOutcomeKind,
@@ -119,7 +119,9 @@ pub struct PushOutboxRelayReceipt {
}
#[cfg(feature = "runtime")]
-#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize)]
+#[serde(rename_all = "snake_case")]
+#[non_exhaustive]
pub enum PushOutboxEventState {
DraftQueued,
Signing,
@@ -150,7 +152,9 @@ impl From<RadrootsOutboxEventState> for PushOutboxEventState {
}
#[cfg(feature = "runtime")]
-#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize)]
+#[serde(rename_all = "snake_case")]
+#[non_exhaustive]
pub enum PushOutboxRelayOutcomeKind {
Accepted,
DuplicateAccepted,
diff --git a/crates/sdk/tests/listings_runtime.rs b/crates/sdk/tests/listings_runtime.rs
@@ -392,6 +392,66 @@ fn mutation_state_debug_uses_product_state_names() {
}
#[tokio::test]
+async fn listing_runtime_dtos_serialize_deterministically() {
+ let (_tempdir, sdk) = directory_sdk().await;
+ let created_at = RadrootsSdkTimestamp::from_unix_seconds(1_700_000_123);
+ let prepare_request =
+ ListingPreparePublishRequest::new(actor(), listing(LISTING_A_D_TAG, "Serialized Coffee"))
+ .with_created_at(created_at);
+ let prepare_json = serde_json::to_value(&prepare_request).expect("prepare request json");
+
+ assert_eq!(prepare_json["actor"]["pubkey"], SELLER);
+ assert_eq!(
+ prepare_json["actor"]["roles"],
+ serde_json::json!(["seller"])
+ );
+ assert_eq!(prepare_json["actor"]["source"], "test");
+ assert_eq!(prepare_json["created_at"], 1_700_000_123);
+ assert_eq!(
+ prepare_json["document"]["listing"]["product"]["title"],
+ "Serialized Coffee"
+ );
+
+ let enqueue_request = ListingEnqueuePublishRequest::new(
+ actor(),
+ listing(LISTING_B_D_TAG, "Queued Coffee"),
+ SdkRelayTargetPolicy::UseConfiguredRelays,
+ )
+ .try_with_target_relays([RELAY, RELAY_B], SdkRelayUrlPolicy::Public)
+ .expect("relay targets")
+ .try_with_idempotency_key("serialized-idempotency")
+ .expect("idempotency")
+ .with_created_at(created_at);
+ let enqueue_json = serde_json::to_value(&enqueue_request).expect("enqueue request json");
+
+ assert_eq!(enqueue_json["target_relays"]["kind"], "explicit");
+ assert_eq!(
+ enqueue_json["target_relays"]["relays"],
+ serde_json::json!([RELAY, RELAY_B])
+ );
+ assert_eq!(
+ enqueue_json["target_relays"]["canonical_relays"],
+ serde_json::json!([RELAY_B, RELAY])
+ );
+ assert_eq!(
+ enqueue_json["idempotency_key"],
+ serde_json::json!({ "value": "<redacted>", "len": 22 })
+ );
+ assert!(!enqueue_json.to_string().contains("serialized-idempotency"));
+
+ let receipt = sdk
+ .listings()
+ .enqueue_publish(enqueue_request, &FixtureSigner::new(SELLER))
+ .await
+ .expect("enqueue");
+ let receipt_json = serde_json::to_value(&receipt).expect("receipt json");
+
+ assert_eq!(receipt_json["state"], "stored_and_queued");
+ assert_eq!(receipt_json["local_event_seq"], 1);
+ assert!(receipt_json["idempotency_digest_prefix"].is_string());
+}
+
+#[tokio::test]
async fn enqueue_publish_convenience_matches_prepare_plus_enqueue_prepared() {
let (_prepared_tempdir, prepared_sdk) = directory_sdk().await;
let prepared_actor = actor();
@@ -638,4 +698,17 @@ async fn enqueue_publish_derives_order_independent_idempotency_key() {
second_receipt.idempotency_digest_prefix
);
assert_eq!(second_receipt.state, SdkMutationState::AlreadyQueued);
+
+ let paths = sdk.storage_paths().expect("paths");
+ let outbox = RadrootsOutbox::open_file(&paths.outbox_path)
+ .await
+ .expect("outbox");
+ let relay_urls = outbox
+ .relay_statuses(first_receipt.outbox_event_id)
+ .await
+ .expect("relay statuses")
+ .into_iter()
+ .map(|status| status.relay_url)
+ .collect::<Vec<_>>();
+ assert_eq!(relay_urls, vec![RELAY_B.to_owned(), RELAY.to_owned()]);
}
diff --git a/crates/sdk/tests/orders_runtime.rs b/crates/sdk/tests/orders_runtime.rs
@@ -24,7 +24,7 @@ use radroots_sdk::protocol::wire::WireEventParts;
use radroots_sdk::{
ORDER_STATUS_DEFAULT_LIMIT, ORDER_STATUS_MAX_LIMIT, OrderPaymentStateKind,
OrderSettlementStateKind, OrderStatusKind, OrderStatusRequest, RadrootsSdk, RadrootsSdkError,
- RadrootsSdkTimestamp, SdkOrderStatusIssueKind, SdkOrderStatusSource,
+ RadrootsSdkTimestamp, SdkOrderStatusIssue, SdkOrderStatusIssueKind, SdkOrderStatusSource,
};
const BUYER_SECRET_KEY_HEX: &str =
@@ -255,6 +255,43 @@ fn order_status_parse_rejects_invalid_order_ids() {
}
#[tokio::test]
+async fn order_status_contract_dtos_serialize_deterministically() {
+ let (_tempdir, sdk, _store) = directory_sdk_and_store().await;
+ let request = status_request("order-1").with_limit(25);
+ let request_json = serde_json::to_value(&request).expect("request json");
+
+ assert_eq!(
+ request_json,
+ serde_json::json!({
+ "order_id": "order-1",
+ "limit": 25
+ })
+ );
+
+ let receipt = sdk.orders().status(request).await.expect("status");
+ let receipt_json = serde_json::to_value(&receipt).expect("receipt json");
+
+ assert_eq!(receipt_json["source"], "local_event_store");
+ assert_eq!(receipt_json["status"], "missing");
+ assert_eq!(receipt_json["payment_state"], "not_recorded");
+ assert_eq!(receipt_json["settlement_state"], "not_required");
+
+ let issue = SdkOrderStatusIssue {
+ kind: SdkOrderStatusIssueKind::DecisionPayloadInvalid,
+ event_ids: vec![deterministic_event_id("issue-event")],
+ };
+ assert_eq!(issue.code(), "decision_payload_invalid");
+ assert_eq!(
+ serde_json::to_value(issue).expect("issue json"),
+ serde_json::json!({
+ "code": "decision_payload_invalid",
+ "kind": "decision_payload_invalid",
+ "event_ids": [deterministic_event_id("issue-event")]
+ })
+ );
+}
+
+#[tokio::test]
async fn order_status_projects_local_request_and_decision_events() {
let (_tempdir, sdk, store) = directory_sdk_and_store().await;
let request_event = signed_order_request_event("order-1", 20);
diff --git a/crates/sdk/tests/runtime_foundation.rs b/crates/sdk/tests/runtime_foundation.rs
@@ -1,10 +1,13 @@
#![cfg(feature = "runtime")]
use radroots_sdk::{
- RadrootsSdk, RadrootsSdkClock, RadrootsSdkError, RadrootsSdkStorageConfig,
- RadrootsSdkTimestamp, SDK_IDEMPOTENCY_KEY_MAX_LEN, SDK_RELAY_TARGET_MAX_COUNT,
- SdkIdempotencyKey, SdkRelayTargetSet, SdkRelayUrlPolicy,
+ BackupRequest, IntegrityRequest, RadrootsSdk, RadrootsSdkClock, RadrootsSdkError,
+ RadrootsSdkErrorClass, RadrootsSdkRecoveryAction, RadrootsSdkStorageConfig,
+ RadrootsSdkTimestamp, SDK_IDEMPOTENCY_KEY_MAX_LEN, SDK_RELAY_TARGET_MAX_COUNT, SdkBackupState,
+ SdkIdempotencyKey, SdkRelayTargetPolicy, SdkRelayTargetSet, SdkRelayUrlPolicy, SdkStorageKind,
+ StorageStatusReceipt, StorageStatusRequest,
};
+use std::path::PathBuf;
#[tokio::test]
async fn sdk_builder_defaults_to_memory_storage_and_no_relays() {
@@ -30,8 +33,8 @@ async fn sdk_builder_validates_configured_relay_targets() {
assert_eq!(
sdk.relay_urls(),
&[
- "wss://relay-a.example.com".to_owned(),
- "wss://relay-b.example.com".to_owned()
+ "wss://relay-b.example.com".to_owned(),
+ "wss://relay-a.example.com".to_owned()
]
);
}
@@ -52,15 +55,32 @@ async fn sdk_builder_rejects_ws_relay_without_localhost_policy() {
#[test]
fn invalid_relay_url_errors_redact_userinfo() {
let error = SdkRelayTargetSet::new(
- ["wss://user:password@relay.example.com"],
+ ["wss://user:password@relay.example.com/path?token=secret#frag"],
SdkRelayUrlPolicy::Public,
)
.expect_err("invalid relay");
let message = error.to_string();
+ let detail = error.detail_json();
assert!(matches!(error, RadrootsSdkError::InvalidRelayUrl { .. }));
- assert!(message.contains("<redacted>@relay.example.com"));
+ assert_eq!(error.code(), "invalid_relay_url");
+ assert_eq!(error.class(), RadrootsSdkErrorClass::Configuration);
+ assert!(!error.retryable());
+ assert_eq!(
+ error.recovery_actions(),
+ vec![RadrootsSdkRecoveryAction::ConfigureRelayTargets]
+ );
+ assert!(message.contains("<redacted>@relay.example.com/path?<redacted>"));
assert!(!message.contains("password"));
+ assert!(!message.contains("token=secret"));
+ assert!(!message.contains("frag"));
+ assert_eq!(detail["code"], "invalid_relay_url");
+ assert_eq!(detail["class"], "configuration");
+ assert_eq!(detail["retryable"], false);
+ assert_eq!(detail["recovery_actions"][0], "configure_relay_targets");
+ assert!(!detail.to_string().contains("password"));
+ assert!(!detail.to_string().contains("token=secret"));
+ assert!(!detail.to_string().contains("frag"));
}
#[tokio::test]
@@ -170,7 +190,232 @@ fn sdk_partial_local_mutation_error_is_sanitized() {
}
#[test]
-fn relay_target_set_validates_normalizes_dedupes_sorts_and_caps() {
+fn sdk_error_contract_methods_cover_all_variants() {
+ let cases = vec![
+ (
+ RadrootsSdkError::Io {
+ path: PathBuf::from("store.sqlite"),
+ message: "permission denied".to_owned(),
+ },
+ "io",
+ RadrootsSdkErrorClass::Storage,
+ true,
+ vec![RadrootsSdkRecoveryAction::InspectLocalStores],
+ ),
+ (
+ RadrootsSdkError::ClockBeforeUnixEpoch,
+ "clock_before_unix_epoch",
+ RadrootsSdkErrorClass::Clock,
+ false,
+ vec![RadrootsSdkRecoveryAction::FixRequest],
+ ),
+ (
+ RadrootsSdkError::TimestampOutOfRange { value: u64::MAX },
+ "timestamp_out_of_range",
+ RadrootsSdkErrorClass::Clock,
+ false,
+ vec![RadrootsSdkRecoveryAction::FixRequest],
+ ),
+ (
+ RadrootsSdkError::UnauthorizedActor {
+ operation: "listing.prepare_publish".to_owned(),
+ reason: "missing role".to_owned(),
+ },
+ "unauthorized_actor",
+ RadrootsSdkErrorClass::Authorization,
+ false,
+ vec![RadrootsSdkRecoveryAction::SelectAuthorizedActor],
+ ),
+ (
+ RadrootsSdkError::SignerPubkeyMismatch {
+ operation: "event signing".to_owned(),
+ expected_pubkey_prefix: "aaaaaaaaaaaa".to_owned(),
+ signer_pubkey_prefix: "bbbbbbbbbbbb".to_owned(),
+ },
+ "signer_pubkey_mismatch",
+ RadrootsSdkErrorClass::Authorization,
+ false,
+ vec![RadrootsSdkRecoveryAction::SelectAuthorizedActor],
+ ),
+ (
+ RadrootsSdkError::EmptyTargetRelays {
+ operation: "listing.publish".to_owned(),
+ },
+ "empty_target_relays",
+ RadrootsSdkErrorClass::Configuration,
+ false,
+ vec![RadrootsSdkRecoveryAction::ConfigureRelayTargets],
+ ),
+ (
+ RadrootsSdkError::RelayTargetLimitExceeded {
+ max: 20,
+ actual: 21,
+ },
+ "relay_target_limit_exceeded",
+ RadrootsSdkErrorClass::Configuration,
+ false,
+ vec![RadrootsSdkRecoveryAction::ConfigureRelayTargets],
+ ),
+ (
+ SdkRelayTargetSet::new(["wss://u:p@relay.example.com"], SdkRelayUrlPolicy::Public)
+ .expect_err("invalid relay"),
+ "invalid_relay_url",
+ RadrootsSdkErrorClass::Configuration,
+ false,
+ vec![RadrootsSdkRecoveryAction::ConfigureRelayTargets],
+ ),
+ (
+ RadrootsSdkError::IdempotencyConflict {
+ operation_kind: "listing.publish.v1".to_owned(),
+ expected_pubkey_prefix: "aaaaaaaaaaaa".to_owned(),
+ existing_digest_prefix: "bbbbbbbbbbbb".to_owned(),
+ new_digest_prefix: "cccccccccccc".to_owned(),
+ },
+ "idempotency_conflict",
+ RadrootsSdkErrorClass::Request,
+ false,
+ vec![RadrootsSdkRecoveryAction::RetryOperationWithSameIdempotencyKey],
+ ),
+ (
+ RadrootsSdkError::OrderStatusLimitInvalid {
+ limit: 0,
+ min: 1,
+ max: 1000,
+ },
+ "order_status_limit_invalid",
+ RadrootsSdkErrorClass::Request,
+ false,
+ vec![RadrootsSdkRecoveryAction::FixRequest],
+ ),
+ (
+ RadrootsSdkError::InvalidOrderId {
+ value: "bad".to_owned(),
+ message: "invalid".to_owned(),
+ },
+ "invalid_order_id",
+ RadrootsSdkErrorClass::Request,
+ false,
+ vec![RadrootsSdkRecoveryAction::FixRequest],
+ ),
+ (
+ RadrootsSdkError::ProductSyncUnsupported {
+ operation: "sync.push_outbox",
+ required_feature: "relay-runtime",
+ },
+ "product_sync_unsupported",
+ RadrootsSdkErrorClass::Unsupported,
+ false,
+ vec![RadrootsSdkRecoveryAction::EnableRequiredFeature],
+ ),
+ (
+ RadrootsSdkError::ProductSyncRelaySetupFailure {
+ message: "relay setup".to_owned(),
+ },
+ "product_sync_relay_setup_failure",
+ RadrootsSdkErrorClass::Transport,
+ true,
+ vec![RadrootsSdkRecoveryAction::RetryAfterTransportFailure],
+ ),
+ (
+ RadrootsSdkError::Authority {
+ message: "authority".to_owned(),
+ },
+ "authority",
+ RadrootsSdkErrorClass::Authorization,
+ false,
+ vec![RadrootsSdkRecoveryAction::SelectAuthorizedActor],
+ ),
+ (
+ RadrootsSdkError::EventStore {
+ message: "store".to_owned(),
+ },
+ "event_store",
+ RadrootsSdkErrorClass::Storage,
+ true,
+ vec![RadrootsSdkRecoveryAction::InspectLocalStores],
+ ),
+ (
+ RadrootsSdkError::InvalidRequest {
+ message: "bad input".to_owned(),
+ },
+ "invalid_request",
+ RadrootsSdkErrorClass::Request,
+ false,
+ vec![RadrootsSdkRecoveryAction::FixRequest],
+ ),
+ (
+ RadrootsSdkError::ListingDraft {
+ message: "draft".to_owned(),
+ },
+ "listing_draft",
+ RadrootsSdkErrorClass::Request,
+ false,
+ vec![RadrootsSdkRecoveryAction::FixRequest],
+ ),
+ (
+ RadrootsSdkError::ListingMutation {
+ message: "mutation".to_owned(),
+ },
+ "listing_mutation",
+ RadrootsSdkErrorClass::Request,
+ false,
+ vec![RadrootsSdkRecoveryAction::FixRequest],
+ ),
+ (
+ RadrootsSdkError::Outbox {
+ message: "outbox".to_owned(),
+ },
+ "outbox",
+ RadrootsSdkErrorClass::Storage,
+ true,
+ vec![RadrootsSdkRecoveryAction::InspectLocalStores],
+ ),
+ (
+ RadrootsSdkError::RelayTransport {
+ message: "relay".to_owned(),
+ },
+ "relay_transport",
+ RadrootsSdkErrorClass::Transport,
+ true,
+ vec![RadrootsSdkRecoveryAction::RetryAfterTransportFailure],
+ ),
+ (
+ RadrootsSdkError::Projection {
+ message: "projection".to_owned(),
+ },
+ "projection",
+ RadrootsSdkErrorClass::Storage,
+ true,
+ vec![RadrootsSdkRecoveryAction::InspectLocalStores],
+ ),
+ (
+ RadrootsSdkError::partial_outbox_enqueue_mutation(
+ "a".repeat(64),
+ "listing.publish.v1",
+ "abcdef123456",
+ ),
+ "partial_local_mutation",
+ RadrootsSdkErrorClass::LocalMutation,
+ true,
+ vec![RadrootsSdkRecoveryAction::RetryOperationWithSameIdempotencyKey],
+ ),
+ ];
+
+ for (error, code, class, retryable, recovery_actions) in cases {
+ assert_eq!(error.code(), code);
+ assert_eq!(error.class(), class);
+ assert_eq!(error.retryable(), retryable);
+ assert_eq!(error.recovery_actions(), recovery_actions);
+ let detail = error.detail_json();
+ assert_eq!(detail["code"], code);
+ assert_eq!(detail["retryable"], retryable);
+ assert!(detail["message"].is_string());
+ assert!(detail["detail"].is_object());
+ }
+}
+
+#[test]
+fn relay_target_set_validates_normalizes_dedupes_preserves_order_and_caps() {
let targets = SdkRelayTargetSet::new(
[
" wss://relay-b.example.com/ ",
@@ -184,10 +429,26 @@ fn relay_target_set_validates_normalizes_dedupes_sorts_and_caps() {
assert_eq!(
targets.relays(),
&[
+ "wss://relay-b.example.com".to_owned(),
+ "wss://relay-a.example.com".to_owned()
+ ]
+ );
+ assert_eq!(
+ targets.canonical_relays(),
+ &[
"wss://relay-a.example.com".to_owned(),
"wss://relay-b.example.com".to_owned()
]
);
+ assert_eq!(
+ serde_json::to_value(SdkRelayTargetPolicy::explicit(targets.clone()))
+ .expect("relay target policy json"),
+ serde_json::json!({
+ "kind": "explicit",
+ "relays": ["wss://relay-b.example.com", "wss://relay-a.example.com"],
+ "canonical_relays": ["wss://relay-a.example.com", "wss://relay-b.example.com"]
+ })
+ );
assert!(matches!(
SdkRelayTargetSet::new(Vec::<String>::new(), SdkRelayUrlPolicy::Public),
@@ -208,16 +469,27 @@ fn relay_target_set_validates_normalizes_dedupes_sorts_and_caps() {
#[test]
fn idempotency_key_validation_is_bounded_and_debug_redacted() {
- let key = SdkIdempotencyKey::new(" idem-a ").expect("key");
+ let key = SdkIdempotencyKey::new("idem-a").expect("key");
assert_eq!(key.as_str(), "idem-a");
let debug = format!("{key:?}");
assert!(debug.contains("<redacted>"));
assert!(!debug.contains("idem-a"));
+ assert_eq!(
+ serde_json::to_value(&key).expect("key json"),
+ serde_json::json!({ "value": "<redacted>", "len": 6 })
+ );
assert!(matches!(
SdkIdempotencyKey::new(" "),
Err(RadrootsSdkError::InvalidRequest { .. })
));
+ let untrimmed = SdkIdempotencyKey::new(" idem-a ").expect_err("untrimmed");
+ assert!(matches!(
+ untrimmed,
+ RadrootsSdkError::InvalidRequest { ref message }
+ if message == "idempotency key must not include boundary whitespace"
+ ));
+ assert!(!untrimmed.to_string().contains("idem-a"));
assert!(matches!(
SdkIdempotencyKey::new("idem\nbad"),
Err(RadrootsSdkError::InvalidRequest { .. })
@@ -229,6 +501,44 @@ fn idempotency_key_validation_is_bounded_and_debug_redacted() {
}
#[test]
+fn storage_backup_and_integrity_contract_dtos_serialize() {
+ assert_eq!(
+ serde_json::to_value(StorageStatusRequest::default()).expect("status request"),
+ serde_json::json!({})
+ );
+ assert_eq!(
+ serde_json::to_value(StorageStatusReceipt {
+ storage: SdkStorageKind::Directory,
+ paths: None,
+ })
+ .expect("status receipt"),
+ serde_json::json!({
+ "storage": "directory",
+ "paths": null
+ })
+ );
+ assert_eq!(
+ serde_json::to_value(BackupRequest {
+ destination: PathBuf::from("backup"),
+ overwrite: false,
+ })
+ .expect("backup request"),
+ serde_json::json!({
+ "destination": "backup",
+ "overwrite": false
+ })
+ );
+ assert_eq!(
+ serde_json::to_value(SdkBackupState::Completed).expect("backup state"),
+ serde_json::json!("completed")
+ );
+ assert_eq!(
+ serde_json::to_value(IntegrityRequest::default()).expect("integrity request"),
+ serde_json::json!({})
+ );
+}
+
+#[test]
fn outbox_idempotency_conflict_maps_to_structured_sdk_error() {
let error = RadrootsSdkError::from(radroots_outbox::RadrootsOutboxError::IdempotencyConflict {
operation_kind: "listing.publish.v1".to_owned(),
diff --git a/crates/sdk/tests/sync_runtime.rs b/crates/sdk/tests/sync_runtime.rs
@@ -12,7 +12,7 @@ use radroots_events::{
contract::RadrootsActorRole,
draft::{RadrootsFrozenEventDraft, RadrootsSignedNostrEvent, RadrootsSignedNostrEventParts},
farm::RadrootsFarmRef,
- ids::{RadrootsDTag, RadrootsInventoryBinId},
+ ids::{RadrootsDTag, RadrootsEventId, RadrootsInventoryBinId},
listing::{RadrootsListing, RadrootsListingBin, RadrootsListingProduct},
};
use radroots_outbox::{RadrootsOutbox, RadrootsOutboxEventState, RadrootsOutboxOperationInput};
@@ -22,8 +22,9 @@ use radroots_relay_transport::{
};
use radroots_sdk::{
ListingEnqueuePublishRequest, ListingPreparePublishRequest, PUSH_OUTBOX_DEFAULT_LIMIT,
- PUSH_OUTBOX_MAX_LIMIT, PushOutboxEventState, PushOutboxRelayOutcomeKind, PushOutboxRequest,
- RadrootsSdk, RadrootsSdkError, RadrootsSdkTimestamp, SdkRelayTargetPolicy, SdkRelayUrlPolicy,
+ PUSH_OUTBOX_MAX_LIMIT, PushOutboxEventReceipt, PushOutboxEventState, PushOutboxReceipt,
+ PushOutboxRelayOutcomeKind, PushOutboxRelayReceipt, PushOutboxRequest, RadrootsSdk,
+ RadrootsSdkError, RadrootsSdkTimestamp, SdkRelayTargetPolicy, SdkRelayUrlPolicy,
};
use std::collections::BTreeSet;
@@ -226,6 +227,70 @@ async fn push_outbox_empty_queue_returns_zero_counts() {
assert!(adapter.captured_raw_events().is_empty());
}
+#[test]
+fn push_outbox_contract_dtos_serialize_deterministically() {
+ let request = PushOutboxRequest::new()
+ .with_limit(2)
+ .republish_accepted_relays(true);
+ assert_eq!(
+ serde_json::to_value(&request).expect("request json"),
+ serde_json::json!({
+ "limit": 2,
+ "republish_accepted_relays": true
+ })
+ );
+
+ let receipt = PushOutboxReceipt {
+ attempted_events: 1,
+ published_events: 1,
+ retryable_events: 0,
+ terminal_events: 0,
+ events: vec![PushOutboxEventReceipt {
+ event_id: RadrootsEventId::parse(&"a".repeat(64)).expect("event id"),
+ outbox_event_id: 7,
+ final_state: PushOutboxEventState::Published,
+ attempted_count: 2,
+ accepted_count: 1,
+ retryable_count: 1,
+ terminal_count: 0,
+ quorum: 1,
+ quorum_met: true,
+ relays: vec![PushOutboxRelayReceipt {
+ relay_url: RELAY_A.to_owned(),
+ outcome_kind: PushOutboxRelayOutcomeKind::DuplicateAccepted,
+ attempted: true,
+ message: Some("duplicate".to_owned()),
+ }],
+ }],
+ };
+ assert_eq!(
+ serde_json::to_value(receipt).expect("receipt json"),
+ serde_json::json!({
+ "attempted_events": 1,
+ "published_events": 1,
+ "retryable_events": 0,
+ "terminal_events": 0,
+ "events": [{
+ "event_id": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
+ "outbox_event_id": 7,
+ "final_state": "published",
+ "attempted_count": 2,
+ "accepted_count": 1,
+ "retryable_count": 1,
+ "terminal_count": 0,
+ "quorum": 1,
+ "quorum_met": true,
+ "relays": [{
+ "relay_url": RELAY_A,
+ "outcome_kind": "duplicate_accepted",
+ "attempted": true,
+ "message": "duplicate"
+ }]
+ }]
+ })
+ );
+}
+
#[cfg(not(feature = "relay-runtime"))]
#[tokio::test]
async fn product_push_outbox_without_relay_runtime_returns_structured_error() {