field_ios

In-the-field app for Radroots on iOS
git clone https://radroots.dev/git/field_ios.git
Log | Files | Refs | README | LICENSE

commit 0f74c7ebd91668425b5a56cb737d290640758cbe
parent 6c02cd26a5fc4eed0693e603f744354c80d71ba7
Author: triesap <tyson@radroots.org>
Date:   Sat,  8 Aug 2026 20:47:20 +0000

blossom: bind uploads to canonical endpoint authority

Separate host execution context from endpoint trust authority. Derive upload destinations in Rust, bind authorization and transfer to complete configuration fingerprints, and remove caller-owned remote URLs from prepared media inputs.

Diffstat:
Mcore/crates/tera_ffi/src/dto.rs | 355++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------------------
Mcore/crates/tera_ffi/src/runtime.rs | 57+++++++++++++++++++++++++++++++++------------------------
Mcore/crates/tera_ffi/tests/local_mvp_real_io.rs | 30++++++++++++++++++++----------
Mcore/crates/tera_ffi/tests/runtime_delegation.rs | 44++++++++++++++++++++++++++++++++------------
4 files changed, 339 insertions(+), 147 deletions(-)

diff --git a/core/crates/tera_ffi/src/dto.rs b/core/crates/tera_ffi/src/dto.rs @@ -3,7 +3,7 @@ #[cfg(unix)] use std::os::unix::fs::FileExt; -use radroots_blossom::{BlobDescriptor, BlobUrl, MediaType, Sha256}; +use radroots_blossom::{BlobDescriptor, MediaType, Sha256}; use radroots_event::{ calendar::{AuthoredCalendarDateEvent, AuthoredCalendarTimeEvent, CalendarDate}, food::availability::{ @@ -28,8 +28,8 @@ use radroots_mobile_core::runtime::{ TodayRefreshReceipt, TodayRelaySyncState, TodaySyncReceipt, }, sdk::{ - SdkCapabilityRecord, SdkRelayStatusRecord, SdkRelayStatusReportRecord, SdkShutdownRecord, - SdkStorageStatusRecord, + SdkBlossomConfigurationRecord, SdkCapabilityRecord, SdkRelayStatusRecord, + SdkRelayStatusReportRecord, SdkShutdownRecord, SdkStorageStatusRecord, }, }; @@ -838,7 +838,6 @@ pub struct FfiPreparedMediaInput { pub schema_version: u16, pub opaque_reference: String, pub file_descriptor: u64, - pub url: String, pub sha256: String, pub media_type: String, pub byte_size: u64, @@ -904,14 +903,16 @@ impl FfiAddDraftInput { pub(crate) fn command_and_media( self, authored_at_unix_s: u64, + blossom: Option<&radroots_sdk::transport::BlossomSlot>, ) -> Result<(Phase1AddCommand, Vec<Phase1MediaPrerequisite>), RadrootsAppError> { - self.command_media_and_form(authored_at_unix_s) + self.command_media_and_form(authored_at_unix_s, blossom) .map(|(command, media, _)| (command, media)) } pub(crate) fn command_media_and_form( self, authored_at_unix_s: u64, + blossom: Option<&radroots_sdk::transport::BlossomSlot>, ) -> Result< ( Phase1AddCommand, @@ -929,19 +930,30 @@ impl FfiAddDraftInput { .iter() .cloned() .map(PreparedMedia::try_from) - .collect::<Result<Vec<_>, _>>()?; + .map(|media| { + media.and_then(|media| { + let blossom = blossom.ok_or_else(|| { + RadrootsAppError::invalid_argument("blossom_not_configured") + })?; + media.bind(blossom) + }) + }) + .collect::<Result<Vec<BoundPreparedMedia>, _>>()?; let prerequisites = prepared .iter() .map(|value| { - Phase1MediaPrerequisite::new(value.opaque_reference.clone(), &value.descriptor) + Phase1MediaPrerequisite::new( + value.media.opaque_reference.clone(), + &value.descriptor, + ) }) .collect::<Result<Vec<_>, _>>() .map_err(|_| RadrootsAppError::invalid_argument("invalid_media_reference"))?; let post_images = prepared .iter() - .map(PreparedMedia::post_image) + .map(BoundPreparedMedia::post_image) .collect::<Result<Vec<_>, _>>()?; - let form = self.form_snapshot(); + let form = self.form_snapshot(&prepared); let command = match self.command_type { FfiAddCommandType::CreateUpdate => { reject_media(&prepared)?; @@ -974,7 +986,7 @@ impl FfiAddDraftInput { Ok((command, prerequisites, form)) } - fn form_snapshot(&self) -> Phase1DraftFormSnapshot { + fn form_snapshot(&self, prepared: &[BoundPreparedMedia]) -> Phase1DraftFormSnapshot { Phase1DraftFormSnapshot { command_type: match self.command_type { FfiAddCommandType::CreateUpdate => AddCommandType::CreateUpdate, @@ -1003,19 +1015,18 @@ impl FfiAddDraftInput { quantity: self.quantity.clone(), food_published_at_unix_s: self.food_published_at_unix_s, food_status: self.food_status.clone(), - media: self - .media + media: prepared .iter() .map(|value| Phase1DraftMediaSnapshot { - opaque_reference: value.opaque_reference.clone(), - url: value.url.clone(), - sha256: value.sha256.clone(), - media_type: value.media_type.clone(), - byte_size: value.byte_size, - width: value.width, - height: value.height, - alt: value.alt.clone(), - prepared_at_unix_s: value.prepared_at_unix_s, + opaque_reference: value.media.opaque_reference.clone(), + url: value.descriptor.url().as_str().to_owned(), + sha256: value.media.sha256.to_hex(), + media_type: value.media.media_type.as_str().to_owned(), + byte_size: value.media.byte_size, + width: value.media.width, + height: value.media.height, + alt: value.media.alt.clone(), + prepared_at_unix_s: value.media.prepared_at_unix_s, }) .collect(), } @@ -1024,7 +1035,9 @@ impl FfiAddDraftInput { pub(crate) struct PreparedMedia { opaque_reference: String, - descriptor: radroots_blossom::ByteVerifiedDescriptor, + sha256: Sha256, + byte_size: u64, + prepared_at_unix_s: u64, bytes: std::sync::Arc<[u8]>, media_type: MediaType, width: u32, @@ -1032,6 +1045,11 @@ pub(crate) struct PreparedMedia { alt: String, } +struct BoundPreparedMedia { + media: PreparedMedia, + descriptor: radroots_blossom::ByteVerifiedDescriptor, +} + impl TryFrom<FfiPreparedMediaInput> for PreparedMedia { type Error = RadrootsAppError; @@ -1055,21 +1073,32 @@ impl TryFrom<FfiPreparedMediaInput> for PreparedMedia { let bytes = read_media_file_descriptor(value.file_descriptor, value.byte_size, byte_size)?; let media_type = MediaType::parse(&value.media_type) .map_err(|_| RadrootsAppError::invalid_argument("invalid_media_type"))?; - let descriptor = BlobDescriptor::new( - BlobUrl::parse(&value.url) - .map_err(|_| RadrootsAppError::invalid_argument("invalid_media_url"))?, - Sha256::from_hex(&value.sha256) - .map_err(|_| RadrootsAppError::invalid_argument("invalid_media_digest"))?, - value.byte_size, + let sha256 = Sha256::from_hex(&value.sha256) + .map_err(|_| RadrootsAppError::invalid_argument("invalid_media_digest"))?; + if Sha256::digest(&bytes) != sha256 { + return Err(RadrootsAppError::invalid_argument( + "media_verification_failed", + )); + } + let dimensions = + radroots_sdk::transport::BlossomImageDimensions::new(value.width, value.height) + .map_err(|_| RadrootsAppError::invalid_argument("invalid_image_dimensions"))?; + let verified_at_unix_ms = value + .prepared_at_unix_s + .checked_mul(1_000) + .ok_or_else(|| RadrootsAppError::invalid_argument("invalid_media_reference"))?; + radroots_sdk::transport::BlossomUploadRequest::new( + bytes.clone().into(), media_type.clone(), - value.prepared_at_unix_s, + dimensions, + verified_at_unix_ms, ) - .and_then(BlobDescriptor::approve_reference) - .and_then(|descriptor| descriptor.verify_bytes(&bytes, &media_type)) .map_err(|_| RadrootsAppError::invalid_argument("media_verification_failed"))?; Ok(Self { opaque_reference: value.opaque_reference, - descriptor, + sha256, + byte_size: value.byte_size, + prepared_at_unix_s: value.prepared_at_unix_s, bytes: bytes.into(), media_type, width: value.width, @@ -1115,21 +1144,6 @@ fn read_media_file_descriptor( } impl PreparedMedia { - fn authored_image(&self) -> Result<AuthoredImage, RadrootsAppError> { - AuthoredImage::try_from_verified_descriptor(self.descriptor.clone()) - .map_err(|_| RadrootsAppError::invalid_argument("invalid_image_media")) - } - - fn post_image(&self) -> Result<AuthoredPostImage, RadrootsAppError> { - AuthoredPostImage::new( - self.authored_image()?, - PostImageDimensions::new(self.width, self.height) - .map_err(|_| RadrootsAppError::invalid_argument("invalid_image_dimensions"))?, - self.alt.clone(), - ) - .map_err(|_| RadrootsAppError::invalid_argument("invalid_image")) - } - pub(crate) fn upload_request( &self, verified_at_unix_ms: u64, @@ -1138,7 +1152,6 @@ impl PreparedMedia { radroots_sdk::transport::BlossomImageDimensions::new(self.width, self.height) .map_err(|_| RadrootsAppError::invalid_argument("invalid_image_dimensions"))?; radroots_sdk::transport::BlossomUploadRequest::new( - self.descriptor.url().as_blob_url().clone(), std::sync::Arc::clone(&self.bytes), self.media_type.clone(), dimensions, @@ -1146,11 +1159,55 @@ impl PreparedMedia { ) .map_err(|_| RadrootsAppError::invalid_argument("invalid_blossom_upload")) } + + fn bind( + self, + blossom: &radroots_sdk::transport::BlossomSlot, + ) -> Result<BoundPreparedMedia, RadrootsAppError> { + let verified_at_unix_ms = self + .prepared_at_unix_s + .checked_mul(1_000) + .ok_or_else(|| RadrootsAppError::invalid_argument("invalid_media_reference"))?; + let transaction = blossom + .prepare_upload(self.upload_request(verified_at_unix_ms)?) + .map_err(|error| RadrootsAppError::invalid_argument(error.code()))?; + let descriptor = BlobDescriptor::new( + transaction.expected_url().clone(), + self.sha256, + self.byte_size, + self.media_type.clone(), + self.prepared_at_unix_s, + ) + .and_then(BlobDescriptor::approve_reference) + .and_then(|descriptor| descriptor.verify_bytes(&self.bytes, &self.media_type)) + .map_err(|_| RadrootsAppError::invalid_argument("media_verification_failed"))?; + Ok(BoundPreparedMedia { + media: self, + descriptor, + }) + } +} + +impl BoundPreparedMedia { + fn authored_image(&self) -> Result<AuthoredImage, RadrootsAppError> { + AuthoredImage::try_from_verified_descriptor(self.descriptor.clone()) + .map_err(|_| RadrootsAppError::invalid_argument("invalid_image_media")) + } + + fn post_image(&self) -> Result<AuthoredPostImage, RadrootsAppError> { + AuthoredPostImage::new( + self.authored_image()?, + PostImageDimensions::new(self.media.width, self.media.height) + .map_err(|_| RadrootsAppError::invalid_argument("invalid_image_dimensions"))?, + self.media.alt.clone(), + ) + .map_err(|_| RadrootsAppError::invalid_argument("invalid_image")) + } } fn event_command( input: &FfiAddDraftInput, - image: Option<&PreparedMedia>, + image: Option<&BoundPreparedMedia>, ) -> Result<CreateEvent, RadrootsAppError> { if input.media.len() > 1 { return Err(RadrootsAppError::invalid_argument("event_image_limit")); @@ -1233,7 +1290,7 @@ fn event_command( fn food_command( input: FfiAddDraftInput, authored_at_unix_s: u64, - media: &[PreparedMedia], + media: &[BoundPreparedMedia], ) -> Result<CreateFoodAvailability, RadrootsAppError> { let unit = FoodUnit::parse(required(input.unit.as_deref(), "food_unit_required")?) .map_err(|_| RadrootsAppError::invalid_argument("invalid_food_unit"))?; @@ -1242,7 +1299,7 @@ fn food_command( .map(|image| { Ok(FoodAvailabilityImage::new( image.authored_image()?, - FoodImageDimensions::new(image.width, image.height) + FoodImageDimensions::new(image.media.width, image.media.height) .map_err(|_| RadrootsAppError::invalid_argument("invalid_image_dimensions"))?, )) }) @@ -1286,7 +1343,7 @@ fn food_command( Ok(CreateFoodAvailability::new(details)) } -fn reject_media(media: &[PreparedMedia]) -> Result<(), RadrootsAppError> { +fn reject_media(media: &[BoundPreparedMedia]) -> Result<(), RadrootsAppError> { if media.is_empty() { Ok(()) } else { @@ -1296,7 +1353,7 @@ fn reject_media(media: &[PreparedMedia]) -> Result<(), RadrootsAppError> { fn content_with_media_references( mut content: String, - media: &[PreparedMedia], + media: &[BoundPreparedMedia], ) -> Result<String, RadrootsAppError> { if content.trim().is_empty() { return Err(RadrootsAppError::invalid_argument("content_required")); @@ -1759,6 +1816,66 @@ pub struct FfiRelayStatusReportRecord { pub relays: Vec<FfiRelayStatusRecord>, } +#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)] +pub enum FfiBlossomHostKind { + Native, + Simulator, + PhysicalDevice, +} + +impl From<FfiBlossomHostKind> for radroots_sdk::transport::BlossomHostKind { + fn from(value: FfiBlossomHostKind) -> Self { + match value { + FfiBlossomHostKind::Native => Self::Native, + FfiBlossomHostKind::Simulator => Self::Simulator, + FfiBlossomHostKind::PhysicalDevice => Self::PhysicalDevice, + } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)] +pub enum FfiBlossomEndpointAuthority { + PublicWebPki, + LoopbackDevelopment, + PrivateNetworkDevelopment, +} + +impl From<FfiBlossomEndpointAuthority> for radroots_sdk::transport::BlossomEndpointAuthority { + fn from(value: FfiBlossomEndpointAuthority) -> Self { + match value { + FfiBlossomEndpointAuthority::PublicWebPki => Self::PublicWebPki, + FfiBlossomEndpointAuthority::LoopbackDevelopment => Self::LoopbackDevelopment, + FfiBlossomEndpointAuthority::PrivateNetworkDevelopment => { + Self::PrivateNetworkDevelopment + } + } + } +} + +#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)] +pub struct FfiBlossomConfigurationRecord { + pub schema_version: u16, + pub host_kind: String, + pub endpoint_authority: String, + pub primary_origin: String, + pub fallback_origins: Vec<String>, + pub config_fingerprint: String, +} + +#[cfg_attr(coverage_nightly, coverage(off))] +impl From<SdkBlossomConfigurationRecord> for FfiBlossomConfigurationRecord { + fn from(value: SdkBlossomConfigurationRecord) -> Self { + Self { + schema_version: MOBILE_FFI_SCHEMA_VERSION, + host_kind: value.host_kind, + endpoint_authority: value.endpoint_authority, + primary_origin: value.primary_origin, + fallback_origins: value.fallback_origins, + config_fingerprint: value.config_fingerprint, + } + } +} + #[cfg_attr(coverage_nightly, coverage(off))] impl From<SdkRelayStatusReportRecord> for FfiRelayStatusReportRecord { fn from(value: SdkRelayStatusReportRecord) -> Self { @@ -1818,6 +1935,29 @@ mod tests { use super::*; + fn png(width: u32, height: u32) -> Vec<u8> { + let mut bytes = b"\x89PNG\r\n\x1a\n\0\0\0\rIHDR".to_vec(); + bytes.extend_from_slice(&width.to_be_bytes()); + bytes.extend_from_slice(&height.to_be_bytes()); + bytes + } + + fn blossom_slot() -> radroots_sdk::transport::BlossomSlot { + let profile = radroots_sdk::transport::BlossomProfile::new( + radroots_sdk::transport::BlossomHostKind::Simulator, + radroots_sdk::transport::BlossomEndpointAuthority::LoopbackDevelopment, + "http://127.0.0.1:3000", + std::iter::empty::<&str>(), + ) + .unwrap(); + let slot = radroots_sdk::transport::BlossomSlot::new(); + slot.configure(radroots_sdk::transport::BlossomConfig::from_profile( + profile, + )) + .unwrap(); + slot + } + fn photo_input(file_descriptor: u64, bytes: &[u8], digest: String) -> FfiAddDraftInput { FfiAddDraftInput { schema_version: MOBILE_FFI_SCHEMA_VERSION, @@ -1843,9 +1983,8 @@ mod tests { schema_version: MOBILE_FFI_SCHEMA_VERSION, opaque_reference: "media:carrots-01".to_owned(), file_descriptor, - url: format!("https://blossom.example/{digest}.jpg"), sha256: digest, - media_type: "image/jpeg".to_owned(), + media_type: "image/png".to_owned(), byte_size: bytes.len() as u64, width: 2, height: 2, @@ -1883,13 +2022,13 @@ mod tests { #[test] fn exact_five_add_inputs_build_their_typed_core_commands() { let (update, media) = text_input(FfiAddCommandType::CreateUpdate) - .command_and_media(1_800_000_000) + .command_and_media(1_800_000_000, None) .expect("update"); assert!(matches!(update, Phase1AddCommand::CreateUpdate(_))); assert!(media.is_empty()); let (ask, media) = text_input(FfiAddCommandType::CreateAsk) - .command_and_media(1_800_000_000) + .command_and_media(1_800_000_000, None) .expect("ask"); assert!(matches!(ask, Phase1AddCommand::CreateAsk(_))); assert!(media.is_empty()); @@ -1902,7 +2041,7 @@ mod tests { all_day.event_start_date = Some("2026-08-08".to_owned()); all_day.event_end_date = Some("2026-08-09".to_owned()); let (event, media) = all_day - .command_and_media(1_800_000_000) + .command_and_media(1_800_000_000, None) .expect("all-day event"); assert!(matches!(event, Phase1AddCommand::CreateEvent(_))); assert!(media.is_empty()); @@ -1914,16 +2053,18 @@ mod tests { timed.event_start_unix_s = Some(1_800_000_000); timed.event_end_unix_s = Some(1_800_003_600); timed.event_timezone = Some("America/Vancouver".to_owned()); - let (event, media) = timed.command_and_media(1_800_000_000).expect("timed event"); + let (event, media) = timed + .command_and_media(1_800_000_000, None) + .expect("timed event"); assert!(matches!(event, Phase1AddCommand::CreateEvent(_))); assert!(media.is_empty()); - let bytes = b"verified-food-image"; + let bytes = png(2, 2); let mut file = tempfile::NamedTempFile::new().expect("media file"); - file.write_all(bytes).expect("write media"); + file.write_all(&bytes).expect("write media"); file.flush().expect("flush media"); - let digest = Sha256::digest(bytes).to_hex(); - let mut food = photo_input(file.as_file().as_raw_fd() as u64, bytes, digest); + let digest = Sha256::digest(&bytes).to_hex(); + let mut food = photo_input(file.as_file().as_raw_fd() as u64, &bytes, digest); food.command_type = FfiAddCommandType::CreateFoodAvailability; food.identifier = Some("carrots-2026-08".to_owned()); food.title = Some("Carrots".to_owned()); @@ -1934,8 +2075,9 @@ mod tests { food.unit = Some("bunch".to_owned()); food.quantity = Some("12".to_owned()); food.food_status = Some("active".to_owned()); + let blossom = blossom_slot(); let (food, media) = food - .command_and_media(1_800_000_000) + .command_and_media(1_800_000_000, Some(&blossom)) .expect("food availability"); assert!(matches!(food, Phase1AddCommand::CreateFoodAvailability(_))); assert_eq!(media.len(), 1); @@ -1947,7 +2089,7 @@ mod tests { wrong_schema.schema_version = MOBILE_FFI_SCHEMA_VERSION + 1; assert_eq!( wrong_schema - .command_and_media(1_800_000_000) + .command_and_media(1_800_000_000, None) .expect_err("schema") .report() .code, @@ -1955,7 +2097,7 @@ mod tests { ); assert_eq!( text_input(FfiAddCommandType::CreateUpdate) - .command_and_media(0) + .command_and_media(0, None) .expect_err("authored time") .report() .code, @@ -1963,7 +2105,7 @@ mod tests { ); assert_eq!( text_input(FfiAddCommandType::CreateEvent) - .command_and_media(1_800_000_000) + .command_and_media(1_800_000_000, None) .expect_err("event identity") .report() .code, @@ -1972,7 +2114,7 @@ mod tests { let mut food = text_input(FfiAddCommandType::CreateFoodAvailability); food.unit = Some("crate".to_owned()); assert_eq!( - food.command_and_media(1_800_000_000) + food.command_and_media(1_800_000_000, None) .expect_err("food unit") .report() .code, @@ -1982,39 +2124,41 @@ mod tests { #[test] fn prepared_media_accepts_only_the_exact_bounded_file_descriptor_bytes() { - let bytes = b"normalized-image-bytes"; + let bytes = png(2, 2); let mut file = tempfile::NamedTempFile::new().expect("media file"); - file.write_all(bytes).expect("write media"); + file.write_all(&bytes).expect("write media"); file.flush().expect("flush media"); - let digest = Sha256::digest(bytes).to_hex(); - let input = photo_input(file.as_file().as_raw_fd() as u64, bytes, digest.clone()); + let digest = Sha256::digest(&bytes).to_hex(); + let input = photo_input(file.as_file().as_raw_fd() as u64, &bytes, digest.clone()); + let blossom = blossom_slot(); let (command, media) = input - .command_and_media(1_800_000_000) + .command_and_media(1_800_000_000, Some(&blossom)) .expect("verified media input"); assert!(matches!(command, Phase1AddCommand::CreatePhotoUpdate(_))); assert_eq!(media.len(), 1); assert_eq!( media[0].url(), - format!("https://blossom.example/{digest}.jpg") + format!("http://127.0.0.1:3000/{digest}.png") ); } #[test] fn prepared_media_rejects_digest_tamper_and_path_like_references() { - let bytes = b"normalized-image-bytes"; + let bytes = png(2, 2); let mut file = tempfile::NamedTempFile::new().expect("media file"); - file.write_all(bytes).expect("write media"); + file.write_all(&bytes).expect("write media"); file.flush().expect("flush media"); let tampered = photo_input( file.as_file().as_raw_fd() as u64, - bytes, + &bytes, Sha256::digest(b"other").to_hex(), ); + let blossom = blossom_slot(); assert_eq!( tampered - .command_and_media(1_800_000_000) + .command_and_media(1_800_000_000, Some(&blossom)) .expect_err("digest mismatch") .report() .code, @@ -2023,13 +2167,13 @@ mod tests { let mut path_like = photo_input( file.as_file().as_raw_fd() as u64, - bytes, - Sha256::digest(bytes).to_hex(), + &bytes, + Sha256::digest(&bytes).to_hex(), ); path_like.media[0].opaque_reference = "file:/private/media.jpg".to_owned(); assert_eq!( path_like - .command_and_media(1_800_000_000) + .command_and_media(1_800_000_000, Some(&blossom)) .expect_err("path-like reference") .report() .code, @@ -2045,7 +2189,6 @@ mod tests { file.flush().expect("flush media"); let digest = Sha256::digest(bytes).to_hex(); let mut input = photo_input(file.as_file().as_raw_fd() as u64, bytes, digest.clone()); - input.media[0].url = format!("https://blossom.example/{digest}.png"); input.media[0].media_type = "image/png".to_owned(); let prepared = PreparedMedia::try_from(input.media.remove(0)).expect("prepared media"); @@ -2061,12 +2204,12 @@ mod tests { #[test] fn media_validation_executes_every_bounded_shape_guard() { - let bytes = b"bounded-media"; + let bytes = png(2, 2); let mut file = tempfile::NamedTempFile::new().expect("media file"); - file.write_all(bytes).expect("write media"); + file.write_all(&bytes).expect("write media"); file.flush().expect("flush media"); - let digest = Sha256::digest(bytes).to_hex(); - let valid = photo_input(file.as_file().as_raw_fd() as u64, bytes, digest).media[0].clone(); + let digest = Sha256::digest(&bytes).to_hex(); + let valid = photo_input(file.as_file().as_raw_fd() as u64, &bytes, digest).media[0].clone(); let mut invalid_values = Vec::new(); let mut value = valid.clone(); @@ -2130,7 +2273,7 @@ mod tests { minimal_date.title = Some("Minimal date".to_owned()); minimal_date.event_timing = Some(FfiEventTimingKind::AllDay); minimal_date.event_start_date = Some("2026-08-08".to_owned()); - assert!(minimal_date.command_and_media(1_800_000_000).is_ok()); + assert!(minimal_date.command_and_media(1_800_000_000, None).is_ok()); let mut minimal_time = text_input(FfiAddCommandType::CreateEvent); minimal_time.content.clear(); @@ -2138,27 +2281,33 @@ mod tests { minimal_time.title = Some("Minimal time".to_owned()); minimal_time.event_timing = Some(FfiEventTimingKind::Timed); minimal_time.event_start_unix_s = Some(1_800_000_000); - assert!(minimal_time.command_and_media(1_800_000_000).is_ok()); + assert!(minimal_time.command_and_media(1_800_000_000, None).is_ok()); - let bytes = b"event-image"; + let bytes = png(2, 2); let mut file = tempfile::NamedTempFile::new().expect("media file"); - file.write_all(bytes).expect("write media"); + file.write_all(&bytes).expect("write media"); file.flush().expect("flush media"); - let digest = Sha256::digest(bytes).to_hex(); - let mut event = photo_input(file.as_file().as_raw_fd() as u64, bytes, digest); + let digest = Sha256::digest(&bytes).to_hex(); + let mut event = photo_input(file.as_file().as_raw_fd() as u64, &bytes, digest); event.command_type = FfiAddCommandType::CreateEvent; event.identifier = Some("event-image".to_owned()); event.title = Some("Event image".to_owned()); event.event_timing = Some(FfiEventTimingKind::Timed); event.event_start_unix_s = Some(1_800_000_000); - assert!(event.clone().command_and_media(1_800_000_000).is_ok()); + let blossom = blossom_slot(); + assert!( + event + .clone() + .command_and_media(1_800_000_000, Some(&blossom)) + .is_ok() + ); let mut second = event.media[0].clone(); second.opaque_reference = "media:event-image-two".to_owned(); event.media.push(second); assert_eq!( event - .command_and_media(1_800_000_000) + .command_and_media(1_800_000_000, Some(&blossom)) .expect_err("event media limit") .report() .code, @@ -2168,13 +2317,17 @@ mod tests { #[test] fn content_references_and_identifier_decoding_cover_all_outcomes() { - let bytes = b"post-image"; + let bytes = png(2, 2); let mut file = tempfile::NamedTempFile::new().expect("media file"); - file.write_all(bytes).expect("write media"); + file.write_all(&bytes).expect("write media"); file.flush().expect("flush media"); - let digest = Sha256::digest(bytes).to_hex(); - let input = photo_input(file.as_file().as_raw_fd() as u64, bytes, digest); - let prepared = PreparedMedia::try_from(input.media[0].clone()).expect("prepared media"); + let digest = Sha256::digest(&bytes).to_hex(); + let input = photo_input(file.as_file().as_raw_fd() as u64, &bytes, digest); + let blossom = blossom_slot(); + let prepared = PreparedMedia::try_from(input.media[0].clone()) + .expect("prepared media") + .bind(&blossom) + .expect("bound media"); let url = prepared.descriptor.url().as_str().to_owned(); assert_eq!( @@ -2214,7 +2367,7 @@ mod tests { update.media.push(input.media[0].clone()); assert_eq!( update - .command_and_media(1_800_000_000) + .command_and_media(1_800_000_000, Some(&blossom)) .expect_err("update media") .report() .code, @@ -2224,7 +2377,7 @@ mod tests { over_limit.media = vec![input.media[0].clone(); 21]; assert_eq!( over_limit - .command_and_media(1_800_000_000) + .command_and_media(1_800_000_000, None) .expect_err("media count") .report() .code, diff --git a/core/crates/tera_ffi/src/runtime.rs b/core/crates/tera_ffi/src/runtime.rs @@ -7,7 +7,8 @@ use crate::dto::PreparedMedia; use crate::signer::HostSignerAdapter; use crate::subscription::SubscriptionHub; use crate::{ - FfiAddDraftInput, FfiAddSchemaRecord, FfiBlossomUploadInput, FfiCapabilityRecord, + FfiAddDraftInput, FfiAddSchemaRecord, FfiBlossomConfigurationRecord, + FfiBlossomEndpointAuthority, FfiBlossomHostKind, FfiBlossomUploadInput, FfiCapabilityRecord, FfiCardAddParityRecord, FfiDraftStatusRecord, FfiIdentityStatusRecord, FfiLocalNetworkRecord, FfiMeRecord, FfiQueuePolicyRecord, FfiRelayStatusReportRecord, FfiRetractionDraftInput, FfiRuntimeChangeKind, FfiRuntimeInfoRecord, FfiSearchResultRecord, FfiShutdownRecord, @@ -164,8 +165,13 @@ impl RadrootsRuntime { .map_err(Into::into) } - pub fn sdk_blossom_profile(&self) -> Result<Option<String>, RadrootsAppError> { - self.inner.sdk_blossom_profile().map_err(Into::into) + pub fn sdk_blossom_configuration( + &self, + ) -> Result<Option<FfiBlossomConfigurationRecord>, RadrootsAppError> { + self.inner + .sdk_blossom_configuration() + .map(|value| value.map(Into::into)) + .map_err(Into::into) } pub fn subscribe_changes( @@ -208,28 +214,20 @@ impl RadrootsRuntime { Ok(()) } - pub fn configure_public_blossom(&self, origins: Vec<String>) -> Result<(), RadrootsAppError> { - self.inner - .configure_public_blossom(origins) - .map_err(RadrootsAppError::from)?; - self.subscriptions.notify(FfiRuntimeChangeKind::Media, None); - Ok(()) - } - - pub fn configure_simulator_blossom( + pub fn configure_blossom( &self, - origins: Vec<String>, + host_kind: FfiBlossomHostKind, + endpoint_authority: FfiBlossomEndpointAuthority, + primary_origin: String, + fallback_origins: Vec<String>, ) -> Result<(), RadrootsAppError> { self.inner - .configure_simulator_blossom(origins) - .map_err(RadrootsAppError::from)?; - self.subscriptions.notify(FfiRuntimeChangeKind::Media, None); - Ok(()) - } - - pub fn configure_device_blossom(&self, origins: Vec<String>) -> Result<(), RadrootsAppError> { - self.inner - .configure_device_blossom(origins) + .configure_blossom( + host_kind.into(), + endpoint_authority.into(), + primary_origin, + fallback_origins, + ) .map_err(RadrootsAppError::from)?; self.subscriptions.notify(FfiRuntimeChangeKind::Media, None); Ok(()) @@ -363,7 +361,13 @@ impl RadrootsRuntime { input: FfiAddDraftInput, authored_at_unix_s: u64, ) -> Result<(), RadrootsAppError> { - input.command_and_media(authored_at_unix_s).map(|_| ()) + let blossom = self + .inner + .sdk_blossom_slot() + .map_err(RadrootsAppError::from)?; + input + .command_and_media(authored_at_unix_s, blossom.as_ref()) + .map(|_| ()) } #[allow(clippy::too_many_arguments)] @@ -375,7 +379,12 @@ impl RadrootsRuntime { expected_revision: Option<u64>, persisted_at_unix_ms: u64, ) -> Result<FfiDraftStatusRecord, RadrootsAppError> { - let (command, media, form) = input.command_media_and_form(authored_at_unix_s)?; + let blossom = self + .inner + .sdk_blossom_slot() + .map_err(RadrootsAppError::from)?; + let (command, media, form) = + input.command_media_and_form(authored_at_unix_s, blossom.as_ref())?; let decoded_id = decode_id(&draft_id, "invalid_draft_id")?; let status = self .inner diff --git a/core/crates/tera_ffi/tests/local_mvp_real_io.rs b/core/crates/tera_ffi/tests/local_mvp_real_io.rs @@ -7,12 +7,13 @@ use nostr_relay_builder::MockRelay; use nostr_sdk::Client; use radroots_blossom::Sha256; use radroots_mobile_ffi::{ - FfiAddCommandType, FfiAddDraftInput, FfiBlossomUploadInput, FfiCancellationPolicy, - FfiEventTimingKind, FfiLocalNetworkRecord, FfiMediaStage, FfiOutboxState, - FfiPreparedMediaInput, FfiQueuePolicyRecord, FfiRelaySatisfaction, FfiRetractionDraftInput, - FfiTodayCardType, FfiTodayProjectionUpdate, FfiTodayRelaySyncState, HostSigningOutcome, - HostSigningRequest, HostSigningResult, MOBILE_FFI_SCHEMA_VERSION, ProtectedDataAvailability, - RadrootsHostSigner, RadrootsRuntime, SignerAvailabilityRecord, SignerStatusRecord, + FfiAddCommandType, FfiAddDraftInput, FfiBlossomEndpointAuthority, FfiBlossomHostKind, + FfiBlossomUploadInput, FfiCancellationPolicy, FfiEventTimingKind, FfiLocalNetworkRecord, + FfiMediaStage, FfiOutboxState, FfiPreparedMediaInput, FfiQueuePolicyRecord, + FfiRelaySatisfaction, FfiRetractionDraftInput, FfiTodayCardType, FfiTodayProjectionUpdate, + FfiTodayRelaySyncState, HostSigningOutcome, HostSigningRequest, HostSigningResult, + MOBILE_FFI_SCHEMA_VERSION, ProtectedDataAvailability, RadrootsHostSigner, RadrootsRuntime, + SignerAvailabilityRecord, SignerStatusRecord, }; use secp256k1::{Keypair, Message, Secp256k1, SecretKey}; use tokio::io::{AsyncReadExt, AsyncWriteExt}; @@ -507,7 +508,12 @@ fn configure_simulator(runtime: &RadrootsRuntime, relay_url: &str, blossom_origi .configure_simulator_relays(vec![relay_url.to_owned()]) .expect("simulator relay profile"); runtime - .configure_simulator_blossom(vec![blossom_origin.to_owned()]) + .configure_blossom( + FfiBlossomHostKind::Simulator, + FfiBlossomEndpointAuthority::LoopbackDevelopment, + blossom_origin.to_owned(), + vec![], + ) .expect("simulator Blossom profile"); } @@ -579,7 +585,7 @@ fn food_input(content: &str, identifier: &str) -> FfiAddDraftInput { } fn prepared_media( - origin: &str, + _origin: &str, bytes: &[u8], file: &std::fs::File, alt: &str, @@ -589,7 +595,6 @@ fn prepared_media( schema_version: MOBILE_FFI_SCHEMA_VERSION, opaque_reference: format!("media:{hash}"), file_descriptor: u64::try_from(file.as_raw_fd()).expect("nonnegative media descriptor"), - url: format!("{origin}/{hash}.png"), sha256: hash, media_type: "image/png".to_owned(), byte_size: u64::try_from(bytes.len()).expect("media size"), @@ -754,7 +759,12 @@ async fn prove_corrupted_media_fails( ) { let corrupt = BlossomServer::spawn(bytes.to_vec(), true).await; runtime - .configure_simulator_blossom(vec![corrupt.origin.clone()]) + .configure_blossom( + FfiBlossomHostKind::Simulator, + FfiBlossomEndpointAuthority::LoopbackDevelopment, + corrupt.origin.clone(), + vec![], + ) .expect("corrupt test Blossom profile"); let media = prepared_media( &corrupt.origin, diff --git a/core/crates/tera_ffi/tests/runtime_delegation.rs b/core/crates/tera_ffi/tests/runtime_delegation.rs @@ -1,8 +1,9 @@ use radroots_mobile_ffi::{ - FfiAddCommandType, FfiAddDraftInput, FfiBlossomUploadInput, FfiCancellationPolicy, - FfiDraftKind, FfiLocalNetworkRecord, FfiOutboxState, FfiPreparedMediaInput, - FfiQueuePolicyRecord, FfiRelaySatisfaction, FfiRetractionDraftInput, FfiTodayCardType, - FfiTodayProjectionUpdate, MOBILE_FFI_SCHEMA_VERSION, RadrootsAppError, + FfiAddCommandType, FfiAddDraftInput, FfiBlossomEndpointAuthority, FfiBlossomHostKind, + FfiBlossomUploadInput, FfiCancellationPolicy, FfiDraftKind, FfiLocalNetworkRecord, + FfiOutboxState, FfiPreparedMediaInput, FfiQueuePolicyRecord, FfiRelaySatisfaction, + FfiRetractionDraftInput, FfiTodayCardType, FfiTodayProjectionUpdate, MOBILE_FFI_SCHEMA_VERSION, + RadrootsAppError, }; mod support; @@ -102,17 +103,37 @@ async fn native_boundary_delegates_the_complete_core_surface() { ); runtime - .configure_public_blossom(vec!["https://media.example".to_owned()]) + .configure_blossom( + FfiBlossomHostKind::PhysicalDevice, + FfiBlossomEndpointAuthority::PublicWebPki, + "https://media.example".to_owned(), + vec!["https://fallback.example".to_owned()], + ) .expect("public Blossom"); - assert_eq!( - runtime.sdk_blossom_profile().expect("Blossom profile"), - Some("public".to_owned()) - ); + let blossom = runtime + .sdk_blossom_configuration() + .expect("Blossom configuration") + .expect("configured Blossom"); + assert_eq!(blossom.host_kind, "physical_device"); + assert_eq!(blossom.endpoint_authority, "public_webpki"); + assert_eq!(blossom.primary_origin, "https://media.example"); + assert_eq!(blossom.fallback_origins, ["https://fallback.example"]); + assert_eq!(blossom.config_fingerprint.len(), 64); runtime - .configure_simulator_blossom(vec!["http://127.0.0.1:3100".to_owned()]) + .configure_blossom( + FfiBlossomHostKind::Simulator, + FfiBlossomEndpointAuthority::LoopbackDevelopment, + "http://127.0.0.1:3100".to_owned(), + vec![], + ) .expect("simulator Blossom"); runtime - .configure_device_blossom(vec!["https://10.0.0.5:3100".to_owned()]) + .configure_blossom( + FfiBlossomHostKind::PhysicalDevice, + FfiBlossomEndpointAuthority::PrivateNetworkDevelopment, + "https://10.0.0.5:3100".to_owned(), + vec![], + ) .expect("device Blossom"); assert_eq!( @@ -368,7 +389,6 @@ async fn native_boundary_delegates_the_complete_core_surface() { schema_version: MOBILE_FFI_SCHEMA_VERSION, opaque_reference: "media:unused".to_owned(), file_descriptor: 0, - url: "https://media.example/unused.png".to_owned(), sha256: "00".repeat(32), media_type: "image/png".to_owned(), byte_size: 1,