commit 2295b883a64a4ac9fa8229bebcbfccb7bc009324
parent 79a2c9a053ffa022686cf0c934181244474deffc
Author: triesap <tyson@radroots.org>
Date: Fri, 7 Aug 2026 19:11:00 +0000
feat(sdk): add verified Blossom transport
- enforce bounded BUD-11 authorization and exact-byte BUD-02 uploads
- validate BUD-01 retrievals, redirects, descriptors, media, and network policy
- persist verified media, retry evidence, cancellation, and possible orphans
- prove simulator I/O, redaction, package boundaries, and release coverage
Diffstat:
5 files changed, 481 insertions(+), 62 deletions(-)
diff --git a/core/crates/tera_core/src/runtime/builder.rs b/core/crates/tera_core/src/runtime/builder.rs
@@ -8,6 +8,8 @@ pub struct RuntimeBuilder {
signer: Option<std::sync::Arc<dyn radroots_signing::Signer>>,
#[cfg(feature = "mobile-social")]
relay_profile: radroots_sdk::transport::RelayProfile,
+ #[cfg(feature = "mobile-social")]
+ blossom_config: Option<radroots_sdk::transport::BlossomConfig>,
}
impl RuntimeBuilder {
@@ -20,6 +22,8 @@ impl RuntimeBuilder {
#[cfg(feature = "mobile-social")]
relay_profile: radroots_sdk::transport::RelayProfile::public(Vec::<String>::new())
.expect("bundled public relay profile is valid"),
+ #[cfg(feature = "mobile-social")]
+ blossom_config: None,
}
}
@@ -40,6 +44,17 @@ impl RuntimeBuilder {
self
}
+ /// Installs one validated inert Blossom environment profile.
+ #[cfg(feature = "mobile-social")]
+ #[must_use]
+ pub fn blossom_config(
+ mut self,
+ blossom_config: radroots_sdk::transport::BlossomConfig,
+ ) -> Self {
+ self.blossom_config = Some(blossom_config);
+ self
+ }
+
/// Opens the exact authenticated user's durable SQLite store.
pub async fn build(self) -> Result<RadrootsRuntime, RadrootsAppError> {
if self.store.protected_data() == ProtectedDataAvailability::Unavailable {
@@ -57,6 +72,8 @@ impl RuntimeBuilder {
self.signer,
#[cfg(feature = "mobile-social")]
Some(self.relay_profile),
+ #[cfg(feature = "mobile-social")]
+ self.blossom_config,
)
}
}
@@ -146,6 +163,24 @@ mod tests {
.expect("configured profile"),
report
);
+ assert!(
+ runtime
+ .configure_simulator_blossom(vec!["http://127.0.0.1:3000".to_owned()])
+ .is_ok()
+ );
+ assert_eq!(
+ runtime.sdk_blossom_profile().expect("Blossom profile"),
+ Some("simulator_local".to_owned())
+ );
+ assert!(
+ runtime
+ .configure_public_blossom(vec!["http://127.0.0.1:3000".to_owned()])
+ .is_err()
+ );
+ assert_eq!(
+ runtime.sdk_blossom_profile().expect("unchanged profile"),
+ Some("simulator_local".to_owned())
+ );
runtime.shutdown().await.expect("shutdown");
}
diff --git a/core/crates/tera_core/src/runtime/mod.rs b/core/crates/tera_core/src/runtime/mod.rs
@@ -37,6 +37,9 @@ impl RadrootsRuntime {
#[cfg(feature = "mobile-social")] relay_profile: Option<
radroots_sdk::transport::RelayProfile,
>,
+ #[cfg(feature = "mobile-social")] blossom_config: Option<
+ radroots_sdk::transport::BlossomConfig,
+ >,
) -> Result<Self, RadrootsAppError> {
#[cfg(feature = "mobile-social")]
let builder = {
@@ -48,6 +51,14 @@ impl RadrootsRuntime {
}
let builder = builder
.nostr(nostr_slot)
+ .blossom({
+ let slot = radroots_sdk::transport::BlossomSlot::new();
+ if let Some(config) = blossom_config {
+ slot.configure(config)
+ .map_err(|error| RadrootsAppError::runtime(error.code().to_owned()))?;
+ }
+ slot
+ })
.host_sync(radroots_sdk::sync::HostPolicy::standard());
match signer {
Some(signer) => builder.signing(radroots_sdk::signing::Provider::host(signer)),
@@ -74,6 +85,8 @@ impl RadrootsRuntime {
None,
#[cfg(feature = "mobile-social")]
None,
+ #[cfg(feature = "mobile-social")]
+ None,
)
}
diff --git a/core/crates/tera_core/src/runtime/product_surface.rs b/core/crates/tera_core/src/runtime/product_surface.rs
@@ -34,8 +34,9 @@ pub use model::{
};
#[cfg(feature = "mobile-social")]
pub use outbox::{
- Phase1CancellationPolicy, Phase1DraftError, Phase1DraftStatus, Phase1MediaPrerequisite,
- Phase1MediaStage, Phase1OutboxState, Phase1QueuePolicy, Phase1RelaySatisfaction,
+ Phase1CancellationPolicy, Phase1DraftError, Phase1DraftStatus, Phase1MediaOrphanRecord,
+ Phase1MediaPrerequisite, Phase1MediaStage, Phase1OutboxState, Phase1QueuePolicy,
+ Phase1RelaySatisfaction,
};
pub use projection::{ProductEventClassification, ProductEventExclusion, classify_admitted_event};
pub use ranking::{RankError, TODAY_RANK_SCHEMA_VERSION, TimeRelevance, TodayRank, TodayRankInput};
diff --git a/core/crates/tera_core/src/runtime/product_surface/outbox.rs b/core/crates/tera_core/src/runtime/product_surface/outbox.rs
@@ -1,6 +1,8 @@
use std::collections::BTreeSet;
-use radroots_blossom::{BlobUrl, MediaType, authorization::AuthoredUploadClaim};
+use radroots_blossom::{
+ BlobUrl, ByteVerifiedDescriptor, MediaType, authorization::AuthoredUploadClaim,
+};
use radroots_event::contract::AuthorRole;
use radroots_event_codec::authoring::PlanWireV1;
use radroots_identity::PublicKey;
@@ -64,37 +66,50 @@ pub struct Phase1MediaPrerequisite {
byte_size: u64,
stage: Phase1MediaStage,
failure_code: Option<String>,
+ upload_attempts: u8,
+ verified_at_unix_ms: Option<u64>,
+ orphan: Option<Phase1MediaOrphanRecord>,
+}
+
+/// Durable, secret-safe evidence that a remote blob may be unreferenced.
+#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
+#[serde(deny_unknown_fields)]
+pub struct Phase1MediaOrphanRecord {
+ reason_code: String,
+ recorded_at_unix_ms: u64,
+}
+
+impl Phase1MediaOrphanRecord {
+ pub fn reason_code(&self) -> &str {
+ self.reason_code.as_str()
+ }
+
+ pub const fn recorded_at_unix_ms(&self) -> u64 {
+ self.recorded_at_unix_ms
+ }
}
impl Phase1MediaPrerequisite {
pub fn new(
local_reference: impl Into<String>,
- url: impl Into<String>,
- sha256: impl Into<String>,
- media_type: impl Into<String>,
- byte_size: u64,
- stage: Phase1MediaStage,
+ descriptor: &ByteVerifiedDescriptor,
) -> Result<Self, Phase1DraftError> {
let value = Self {
local_reference: local_reference.into(),
- url: url.into(),
- sha256: sha256.into(),
- media_type: media_type.into(),
- byte_size,
- stage,
+ url: descriptor.url().as_blob_url().as_str().to_owned(),
+ sha256: descriptor.sha256().to_hex(),
+ media_type: descriptor.media_type().as_str().to_owned(),
+ byte_size: descriptor.size(),
+ stage: Phase1MediaStage::Pending,
failure_code: None,
+ upload_attempts: 0,
+ verified_at_unix_ms: None,
+ orphan: None,
};
value.validate()?;
Ok(value)
}
- pub fn with_failure_code(mut self, code: impl Into<String>) -> Result<Self, Phase1DraftError> {
- self.stage = Phase1MediaStage::Failed;
- self.failure_code = Some(code.into());
- self.validate()?;
- Ok(self)
- }
-
fn validate(&self) -> Result<(), Phase1DraftError> {
let blob = BlobUrl::parse(self.url.as_str()).map_err(|_| Phase1DraftError::InvalidMedia)?;
let hash = blob.hash_path().hash().to_string();
@@ -111,7 +126,36 @@ impl Phase1MediaPrerequisite {
|| code != code.trim()
|| code.chars().any(char::is_control)
})
- || (self.stage == Phase1MediaStage::Failed) != self.failure_code.is_some()
+ || self.orphan.as_ref().is_some_and(|record| {
+ record.reason_code.is_empty()
+ || record.reason_code.len() > DRAFT_FAILURE_CODE_MAX_BYTES
+ || record.reason_code != record.reason_code.trim()
+ || record.reason_code.chars().any(char::is_control)
+ || record.recorded_at_unix_ms == 0
+ })
+ || match self.stage {
+ Phase1MediaStage::Pending | Phase1MediaStage::Preparing => {
+ self.failure_code.is_some()
+ || self.upload_attempts != 0
+ || self.verified_at_unix_ms.is_some()
+ || self.orphan.is_some()
+ }
+ Phase1MediaStage::Uploading => {
+ self.failure_code.is_some()
+ || self.verified_at_unix_ms.is_some()
+ || self.orphan.is_some()
+ }
+ Phase1MediaStage::Verified => {
+ self.failure_code.is_some()
+ || self.upload_attempts == 0
+ || self.verified_at_unix_ms.is_none()
+ || self.orphan.is_some()
+ }
+ Phase1MediaStage::Failed => {
+ self.failure_code.is_none() || self.verified_at_unix_ms.is_some()
+ }
+ Phase1MediaStage::Orphaned => self.failure_code.is_some() || self.orphan.is_none(),
+ }
{
return Err(Phase1DraftError::InvalidMedia);
}
@@ -124,6 +168,34 @@ impl Phase1MediaPrerequisite {
pub const fn stage(&self) -> Phase1MediaStage {
self.stage
}
+
+ pub const fn upload_attempts(&self) -> u8 {
+ self.upload_attempts
+ }
+
+ pub const fn verified_at_unix_ms(&self) -> Option<u64> {
+ self.verified_at_unix_ms
+ }
+
+ pub const fn orphan(&self) -> Option<&Phase1MediaOrphanRecord> {
+ self.orphan.as_ref()
+ }
+
+ fn matches_receipt(&self, receipt: &radroots_sdk::transport::BlossomUploadReceipt) -> bool {
+ let descriptor = receipt.descriptor();
+ self.url == descriptor.url().as_blob_url().as_str()
+ && self.sha256 == descriptor.sha256().to_hex()
+ && self.media_type == descriptor.media_type().as_str()
+ && self.byte_size == descriptor.size()
+ && receipt.attempts() > 0
+ && receipt.verified_at_unix_ms() > 0
+ }
+
+ fn is_remote_verified(&self) -> bool {
+ self.stage == Phase1MediaStage::Verified
+ && self.upload_attempts > 0
+ && self.verified_at_unix_ms.is_some()
+ }
}
/// Closed delivery-satisfaction profiles available to Phase 1 Add.
@@ -505,11 +577,19 @@ impl RadrootsRuntime {
.iter_mut()
.find(|media| media.url == url)
.ok_or(Phase1DraftError::InvalidMedia)?;
- if !valid_media_transition(media.stage, stage) {
+ if !valid_media_transition(media.stage, stage)
+ || matches!(
+ stage,
+ Phase1MediaStage::Verified | Phase1MediaStage::Orphaned
+ )
+ {
return Err(Phase1DraftError::InvalidMedia);
}
media.stage = stage;
media.failure_code = failure_code;
+ if stage != Phase1MediaStage::Failed {
+ media.failure_code = None;
+ }
media.validate()?;
let next_stage = match stage {
Phase1MediaStage::Pending | Phase1MediaStage::Preparing => {
@@ -530,6 +610,134 @@ impl RadrootsRuntime {
self.draft_status_from(receipt.draft().clone()).await
}
+ /// Records the only proof that can advance media to remote-byte-verified.
+ pub async fn phase1_complete_draft_media(
+ &self,
+ draft_id: [u8; 16],
+ expected_revision: u64,
+ url: &str,
+ receipt: radroots_sdk::transport::BlossomUploadReceipt,
+ updated_at_unix_ms: u64,
+ ) -> Result<Phase1DraftStatus, Phase1DraftError> {
+ let draft_id =
+ AuthoredDraftId::new(draft_id).map_err(|_| Phase1DraftError::InvalidDraft)?;
+ let expected = AuthoredDraftRevision::new(expected_revision)
+ .map_err(|_| Phase1DraftError::RevisionConflict)?;
+ let storage = self.storage()?;
+ let head = storage
+ .authored_draft_head(draft_id)
+ .await
+ .map_err(|_| Phase1DraftError::Storage)?
+ .ok_or(Phase1DraftError::NotFound)?;
+ if head.revision() != expected
+ || head.stage().is_terminal()
+ || matches!(
+ head.stage(),
+ AuthoredDraftStage::ReadyToSign | AuthoredDraftStage::Queued
+ )
+ {
+ return Err(Phase1DraftError::RevisionConflict);
+ }
+ let mut payload = Phase1DraftPayload::decode(&head)?;
+ let media = payload
+ .media
+ .iter_mut()
+ .find(|media| media.url == url)
+ .ok_or(Phase1DraftError::InvalidMedia)?;
+ if media.stage != Phase1MediaStage::Uploading || !media.matches_receipt(&receipt) {
+ return Err(Phase1DraftError::InvalidMedia);
+ }
+ media.stage = Phase1MediaStage::Verified;
+ media.failure_code = None;
+ media.upload_attempts = receipt.attempts();
+ media.verified_at_unix_ms = Some(receipt.verified_at_unix_ms());
+ media.orphan = None;
+ media.validate()?;
+ let next = head
+ .successor(
+ payload.encode()?,
+ AuthoredDraftStage::MediaUploading,
+ None,
+ updated_at_unix_ms,
+ )
+ .map_err(|_| Phase1DraftError::RevisionConflict)?;
+ let receipt = storage
+ .append_authored_draft(next, Some(expected))
+ .await
+ .map_err(map_draft_storage_error)?;
+ self.draft_status_from(receipt.draft().clone()).await
+ }
+
+ /// Persists a redacted recoverable Blossom failure and possible-orphan evidence.
+ pub async fn phase1_fail_draft_media(
+ &self,
+ draft_id: [u8; 16],
+ expected_revision: u64,
+ url: &str,
+ error: &radroots_sdk::transport::BlossomError,
+ updated_at_unix_ms: u64,
+ ) -> Result<Phase1DraftStatus, Phase1DraftError> {
+ if updated_at_unix_ms == 0 {
+ return Err(Phase1DraftError::InvalidMedia);
+ }
+ let draft_id =
+ AuthoredDraftId::new(draft_id).map_err(|_| Phase1DraftError::InvalidDraft)?;
+ let expected = AuthoredDraftRevision::new(expected_revision)
+ .map_err(|_| Phase1DraftError::RevisionConflict)?;
+ let storage = self.storage()?;
+ let head = storage
+ .authored_draft_head(draft_id)
+ .await
+ .map_err(|_| Phase1DraftError::Storage)?
+ .ok_or(Phase1DraftError::NotFound)?;
+ if head.revision() != expected
+ || head.stage().is_terminal()
+ || matches!(
+ head.stage(),
+ AuthoredDraftStage::ReadyToSign | AuthoredDraftStage::Queued
+ )
+ {
+ return Err(Phase1DraftError::RevisionConflict);
+ }
+ let mut payload = Phase1DraftPayload::decode(&head)?;
+ let media = payload
+ .media
+ .iter_mut()
+ .find(|media| media.url == url)
+ .ok_or(Phase1DraftError::InvalidMedia)?;
+ if !matches!(
+ media.stage,
+ Phase1MediaStage::Pending
+ | Phase1MediaStage::Preparing
+ | Phase1MediaStage::Uploading
+ | Phase1MediaStage::Failed
+ ) {
+ return Err(Phase1DraftError::InvalidMedia);
+ }
+ media.stage = Phase1MediaStage::Failed;
+ media.failure_code = Some(error.code().to_owned());
+ media.upload_attempts = error.attempts();
+ media.verified_at_unix_ms = None;
+ media.orphan = error.possible_orphan().then(|| Phase1MediaOrphanRecord {
+ reason_code: error.code().to_owned(),
+ recorded_at_unix_ms: updated_at_unix_ms,
+ });
+ media.validate()?;
+ let next = head
+ .successor(
+ payload.encode()?,
+ AuthoredDraftStage::MediaUploading,
+ None,
+ updated_at_unix_ms,
+ )
+ .map_err(|_| Phase1DraftError::RevisionConflict)?;
+ let receipt = storage
+ .append_authored_draft(next, Some(expected))
+ .await
+ .map_err(map_draft_storage_error)?;
+ self.draft_status_from(receipt.draft().clone()).await
+ }
+
/// Freezes queue intent and atomically prepares the canonical outbox before
/// returning `queued`. Network connectivity is neither read nor required.
pub async fn phase1_queue_draft(
@@ -573,7 +781,7 @@ impl RadrootsRuntime {
if payload
.media
.iter()
- .any(|media| media.stage != Phase1MediaStage::Verified)
+ .any(|media| !media.is_remote_verified())
{
return Err(Phase1DraftError::MediaNotReady);
}
@@ -700,6 +908,115 @@ impl RadrootsRuntime {
.map_err(|_| Phase1DraftError::Operation)
}
+ /// Runs the complete durable BUD-11/BUD-02/BUD-01 media transaction.
+ ///
+ /// Final image bytes are bound before authorization. The draft becomes
+ /// verified only after the upload descriptor and a full retrieval agree.
+ #[allow(clippy::too_many_arguments)]
+ pub async fn phase1_upload_draft_media(
+ &self,
+ draft_id: [u8; 16],
+ expected_revision: u64,
+ request: radroots_sdk::transport::BlossomUploadRequest,
+ authorization_content: radroots_blossom::authorization::AuthorizationContent,
+ authorization_created_at_unix_s: u64,
+ authorization_lifetime_seconds: u64,
+ operation_id: [u8; 16],
+ artifact_id: [u8; 16],
+ signing_deadline_unix_ms: u64,
+ signing_cancellation: Phase1CancellationPolicy,
+ transfer_cancellation: radroots_sdk::transport::BlossomCancellation,
+ updated_at_unix_ms: u64,
+ ) -> Result<Phase1DraftStatus, Phase1DraftError> {
+ let url = request.expected_url().as_str().to_owned();
+ let uploading = self
+ .phase1_update_draft_media(
+ draft_id,
+ expected_revision,
+ url.as_str(),
+ Phase1MediaStage::Uploading,
+ None,
+ updated_at_unix_ms,
+ )
+ .await?;
+ let revision = uploading.draft().revision().get();
+ let blossom = self
+ .client
+ .blossom()
+ .map_err(|_| Phase1DraftError::OperationUnavailable)?
+ .ok_or(Phase1DraftError::OperationUnavailable)?;
+ let claim = match blossom.authored_upload_claim(
+ &request,
+ authorization_content,
+ authorization_created_at_unix_s,
+ authorization_lifetime_seconds,
+ ) {
+ Ok(claim) => claim,
+ Err(_) => {
+ self.phase1_update_draft_media(
+ draft_id,
+ revision,
+ url.as_str(),
+ Phase1MediaStage::Failed,
+ Some("blossom_authorization_failed".to_owned()),
+ updated_at_unix_ms,
+ )
+ .await?;
+ return Err(Phase1DraftError::Operation);
+ }
+ };
+ let authorization = match self
+ .phase1_authorize_blossom_upload(
+ operation_id,
+ artifact_id,
+ claim,
+ signing_deadline_unix_ms,
+ signing_cancellation,
+ )
+ .await
+ {
+ Ok(authorization) => authorization,
+ Err(error) => {
+ self.phase1_update_draft_media(
+ draft_id,
+ revision,
+ url.as_str(),
+ Phase1MediaStage::Failed,
+ Some("blossom_authorization_failed".to_owned()),
+ updated_at_unix_ms,
+ )
+ .await?;
+ return Err(error);
+ }
+ };
+ match blossom
+ .upload(request, authorization, transfer_cancellation)
+ .await
+ {
+ Ok(receipt) => {
+ self.phase1_complete_draft_media(
+ draft_id,
+ revision,
+ url.as_str(),
+ receipt,
+ updated_at_unix_ms,
+ )
+ .await
+ }
+ Err(error) => {
+ self.phase1_fail_draft_media(
+ draft_id,
+ revision,
+ url.as_str(),
+ &error,
+ updated_at_unix_ms,
+ )
+ .await?;
+ Err(Phase1DraftError::Operation)
+ }
+ }
+ }
+
/// Returns durable draft state composed with canonical authored-operation state.
pub async fn phase1_draft_status(
&self,
@@ -765,10 +1082,10 @@ impl RadrootsRuntime {
.await
.map_err(|_| Phase1DraftError::Operation)?;
if status.artifact().signed().is_none() {
- mark_possible_orphans(&mut payload.media);
+ mark_possible_orphans(&mut payload.media, cancelled_at_unix_ms);
}
} else {
- mark_possible_orphans(&mut payload.media);
+ mark_possible_orphans(&mut payload.media, cancelled_at_unix_ms);
}
let next = head
.successor(
@@ -990,10 +1307,15 @@ fn draft_stage_for_media(media: &[Phase1MediaPrerequisite]) -> AuthoredDraftStag
}
}
-fn mark_possible_orphans(media: &mut [Phase1MediaPrerequisite]) {
+fn mark_possible_orphans(media: &mut [Phase1MediaPrerequisite], recorded_at_unix_ms: u64) {
for media in media {
- if media.stage == Phase1MediaStage::Verified {
+ if media.stage == Phase1MediaStage::Verified || media.orphan.is_some() {
media.stage = Phase1MediaStage::Orphaned;
+ media.failure_code = None;
+ media.orphan = Some(Phase1MediaOrphanRecord {
+ reason_code: "draft_cancelled_after_upload".to_owned(),
+ recorded_at_unix_ms,
+ });
}
}
}
@@ -1180,6 +1502,7 @@ mod tests {
Some(PublicKey::from_hex(AUTHOR).unwrap()),
None,
None,
+ None,
)
.unwrap()
}
@@ -1194,6 +1517,7 @@ mod tests {
Some(PublicKey::from_hex(AUTHOR).unwrap()),
Some(std::sync::Arc::new(signer)),
None,
+ None,
)
.unwrap()
}
@@ -1437,11 +1761,13 @@ mod tests {
}
#[tokio::test]
- async fn media_phase_revisions_gate_queue_and_record_possible_orphans() {
+ async fn media_phase_revisions_gate_queue_and_reject_forged_verification() {
let runtime = runtime();
let id = [9; 16];
let (command, mut media) = photo_command();
media.stage = Phase1MediaStage::Pending;
+ media.upload_attempts = 0;
+ media.verified_at_unix_ms = None;
media.validate().unwrap();
let saved = runtime
.phase1_save_draft(id, command, 1_784_347_200, vec![media], None, 30)
@@ -1477,41 +1803,27 @@ mod tests {
)
.await
.unwrap();
- let verified = runtime
- .phase1_update_draft_media(
- id,
- 3,
- uploading.media()[0].url(),
- Phase1MediaStage::Verified,
- None,
- 33,
- )
- .await
- .unwrap();
- assert_eq!(verified.media()[0].stage(), Phase1MediaStage::Verified);
assert_eq!(
runtime
.phase1_update_draft_media(
id,
- 4,
- verified.media()[0].url(),
- Phase1MediaStage::Uploading,
+ 3,
+ uploading.media()[0].url(),
+ Phase1MediaStage::Verified,
None,
- 34,
+ 33,
)
.await
.unwrap_err(),
Phase1DraftError::InvalidMedia
);
- let queued = runtime
- .phase1_queue_draft(id, 4, policy(), 34)
- .await
- .unwrap();
- let cancelled = runtime
- .phase1_cancel_draft(id, queued.draft().revision().get(), 35)
- .await
- .unwrap();
- assert_eq!(cancelled.media()[0].stage(), Phase1MediaStage::Orphaned);
+ assert_eq!(
+ runtime
+ .phase1_queue_draft(id, 3, policy(), 34)
+ .await
+ .unwrap_err(),
+ Phase1DraftError::MediaNotReady
+ );
assert_eq!(runtime.phase1_draft_heads(10).await.unwrap().len(), 1);
}
@@ -1554,6 +1866,8 @@ mod tests {
.unwrap()
.verify_bytes(bytes, &media_type)
.unwrap();
+ let mut prerequisite =
+ Phase1MediaPrerequisite::new("protected://draft/photo-1", &descriptor).unwrap();
let image = AuthoredPostImage::new(
AuthoredImage::try_from(descriptor).unwrap(),
PostImageDimensions::new(1200, 900).unwrap(),
@@ -1563,15 +1877,10 @@ mod tests {
let command = Phase1AddCommand::CreatePhotoUpdate(
CreatePhotoUpdate::new(format!("Harvest photo {url}"), vec![image]).unwrap(),
);
- let prerequisite = Phase1MediaPrerequisite::new(
- "protected://draft/photo-1",
- url,
- hash.to_hex(),
- "image/webp",
- bytes.len() as u64,
- Phase1MediaStage::Verified,
- )
- .unwrap();
+ prerequisite.stage = Phase1MediaStage::Verified;
+ prerequisite.upload_attempts = 2;
+ prerequisite.verified_at_unix_ms = Some(1_784_347_100_000);
+ prerequisite.validate().unwrap();
(command, prerequisite)
}
diff --git a/core/crates/tera_core/src/runtime/sdk.rs b/core/crates/tera_core/src/runtime/sdk.rs
@@ -122,6 +122,67 @@ impl RadrootsRuntime {
.map_err(RadrootsAppError::from_sdk)
}
+ /// Installs explicit public TLS Blossom origins without probing them.
+ #[cfg(feature = "mobile-social")]
+ pub fn configure_public_blossom(&self, origins: Vec<String>) -> Result<(), RadrootsAppError> {
+ self.configure_blossom_profile(
+ radroots_sdk::transport::BlossomProfile::public(origins)
+ .map_err(|error| RadrootsAppError::runtime(error.code().to_owned()))?,
+ )
+ }
+
+ /// Installs exact-loopback simulator Blossom origins without probing them.
+ #[cfg(feature = "mobile-social")]
+ pub fn configure_simulator_blossom(
+ &self,
+ origins: Vec<String>,
+ ) -> Result<(), RadrootsAppError> {
+ self.configure_blossom_profile(
+ radroots_sdk::transport::BlossomProfile::simulator(origins)
+ .map_err(|error| RadrootsAppError::runtime(error.code().to_owned()))?,
+ )
+ }
+
+ /// Installs explicit physical-device TLS Blossom origins without probing them.
+ #[cfg(feature = "mobile-social")]
+ pub fn configure_device_blossom(&self, origins: Vec<String>) -> Result<(), RadrootsAppError> {
+ self.configure_blossom_profile(
+ radroots_sdk::transport::BlossomProfile::device(origins)
+ .map_err(|error| RadrootsAppError::runtime(error.code().to_owned()))?,
+ )
+ }
+
+ #[cfg(feature = "mobile-social")]
+ fn configure_blossom_profile(
+ &self,
+ profile: radroots_sdk::transport::BlossomProfile,
+ ) -> Result<(), RadrootsAppError> {
+ self.client
+ .configure_blossom(radroots_sdk::transport::BlossomConfig::from_profile(
+ profile,
+ ))
+ .map_err(RadrootsAppError::from_sdk)
+ }
+
+ /// Returns the inert Blossom environment profile, when configured.
+ #[cfg(feature = "mobile-social")]
+ pub fn sdk_blossom_profile(&self) -> Result<Option<String>, RadrootsAppError> {
+ let profile = self
+ .client
+ .blossom()
+ .map_err(RadrootsAppError::from_sdk)?
+ .and_then(radroots_sdk::transport::BlossomSlot::profile_kind);
+ Ok(profile.map(|profile| {
+ match profile {
+ radroots_sdk::transport::BlossomProfileKind::Public => "public",
+ radroots_sdk::transport::BlossomProfileKind::Simulator => "simulator_local",
+ radroots_sdk::transport::BlossomProfileKind::Device => "device_development",
+ _ => "unknown",
+ }
+ .to_owned()
+ }))
+ }
+
/// Returns passive relay evidence without DNS, socket, or probe work.
#[cfg(feature = "mobile-social")]
pub fn sdk_relay_status(&self) -> Result<Option<SdkRelayStatusReportRecord>, RadrootsAppError> {