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 77e41918cd230faea81fa5dda7fa33080f4fde9f
parent c203901d935bcf5c7259f71d6dbf145fd7dda77d
Author: triesap <tyson@radroots.org>
Date:   Mon, 10 Aug 2026 20:35:58 +0000

feat: authorize native mobile uploads

Diffstat:
Mcore/crates/tera_core/src/runtime/builder.rs | 5+++--
Mcore/crates/tera_core/src/runtime/product_surface.rs | 9+++++----
Mcore/crates/tera_core/src/runtime/product_surface/outbox.rs | 196++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mcore/crates/tera_core/src/runtime/sdk.rs | 15++++++++++-----
4 files changed, 212 insertions(+), 13 deletions(-)

diff --git a/core/crates/tera_core/src/runtime/builder.rs b/core/crates/tera_core/src/runtime/builder.rs @@ -85,6 +85,7 @@ impl RuntimeBuilder { #[cfg(test)] mod tests { use super::RuntimeBuilder; + use crate::runtime::sdk::SdkRelayAccessRecord; use crate::runtime::store::{MobileUserStoreConfig, ProtectedDataAvailability}; const PUBLIC_KEY: &str = "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798"; @@ -128,7 +129,7 @@ mod tests { assert_eq!(report.state, "configured"); assert_eq!(report.relays.len(), 1); assert_eq!(report.relays[0].relay_url, "wss://radroots.org"); - assert_eq!(report.relays[0].access, "read_write"); + assert_eq!(report.relays[0].access, SdkRelayAccessRecord::ReadWrite); assert_eq!(report.relays[0].read_state, "unobserved"); assert_eq!(report.relays[0].write_state, "unobserved"); } @@ -154,7 +155,7 @@ mod tests { .expect("configured profile"); assert_eq!(report.profile, "simulator_local"); assert_eq!(report.relays.len(), 1); - assert_eq!(report.relays[0].access, "read_write"); + assert_eq!(report.relays[0].access, SdkRelayAccessRecord::ReadWrite); assert!( runtime .configure_public_relays(vec!["ws://127.0.0.1:8080".to_owned()]) diff --git a/core/crates/tera_core/src/runtime/product_surface.rs b/core/crates/tera_core/src/runtime/product_surface.rs @@ -47,10 +47,11 @@ pub use outbox::{ Phase1AddIntent, Phase1CancellationPolicy, Phase1DraftError, Phase1DraftEventTiming, Phase1DraftFormSnapshot, Phase1DraftKind, Phase1DraftMediaSnapshot, Phase1DraftStatus, Phase1ExistingDraft, Phase1MediaOrphanRecord, Phase1MediaPrerequisite, Phase1MediaStage, - Phase1OutboxState, Phase1ProfileStatus, Phase1QueueIntent, Phase1QueuePolicy, - Phase1RelaySatisfaction, Phase1ReviseIntent, Phase1RevisionPhase, Phase1RevisionPolicy, - Phase1RevisionStatus, Phase1RevisionTarget, Phase1UploadIntent, Phase1UploadPlan, - phase1_new_addressable_identifier, phase1_new_operation_id, phase1_operation_now_unix_ms, + Phase1NativeUploadJob, Phase1OutboxState, Phase1ProfileStatus, Phase1QueueIntent, + Phase1QueuePolicy, Phase1RelaySatisfaction, Phase1ReviseIntent, Phase1RevisionPhase, + Phase1RevisionPolicy, Phase1RevisionStatus, Phase1RevisionTarget, Phase1UploadIntent, + Phase1UploadPlan, phase1_new_addressable_identifier, phase1_new_operation_id, + phase1_operation_now_unix_ms, }; 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 @@ -557,6 +557,39 @@ pub struct Phase1UploadPlan { pub updated_at_unix_ms: u64, } +/// Immutable Rust-authorized upload job handed to a native background +/// transfer implementation after the durable draft enters `media_uploading`. +#[derive(Clone)] +pub struct Phase1NativeUploadJob { + operation_id: [u8; 16], + remote_url: String, + authorization_header: String, + expected_sha256: String, + media_type: String, + byte_size: u64, +} + +impl Phase1NativeUploadJob { + pub const fn operation_id(&self) -> [u8; 16] { + self.operation_id + } + pub fn remote_url(&self) -> &str { + self.remote_url.as_str() + } + pub fn authorization_header(&self) -> &str { + self.authorization_header.as_str() + } + pub fn expected_sha256(&self) -> &str { + self.expected_sha256.as_str() + } + pub fn media_type(&self) -> &str { + self.media_type.as_str() + } + pub const fn byte_size(&self) -> u64 { + self.byte_size + } +} + /// Existing published card selected for one lossless revision operation. #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] #[serde(deny_unknown_fields)] @@ -590,6 +623,29 @@ impl Phase1RevisionTarget { Ok(value) } + /// Builds a revision target from its canonical source identity while + /// keeping Nostr kind parsing inside the Rust protocol boundary. + pub fn from_source( + command_type: AddCommandType, + card_id: CardId, + source_event_id: impl Into<String>, + source_address: Option<String>, + author_public_key: impl Into<String>, + ) -> Result<Self, Phase1DraftError> { + let source_kind = match source_address.as_deref() { + Some(address) => parse_address(address)?.0, + None => 1, + }; + Self::new( + command_type, + card_id, + source_event_id, + source_kind, + source_address, + author_public_key, + ) + } + fn validate(&self) -> Result<(), Phase1DraftError> { let author = PublicKey::from_hex(&self.author_public_key) .map_err(|_| Phase1DraftError::InvalidRevision)?; @@ -867,6 +923,7 @@ pub struct Phase1DraftStatus { state: Phase1OutboxState, card_id: CardId, push: Option<PushStatus>, + revision_policy: Option<Phase1RevisionPolicy>, } impl Phase1DraftStatus { @@ -894,6 +951,9 @@ impl Phase1DraftStatus { pub const fn push(&self) -> Option<&PushStatus> { self.push.as_ref() } + pub const fn revision_policy(&self) -> Option<Phase1RevisionPolicy> { + self.revision_policy + } } #[derive(Clone, Debug, Error, Eq, PartialEq)] @@ -1432,6 +1492,137 @@ impl RadrootsRuntime { .await } + /// Persists the upload transition and returns one immutable native job. + /// The authorization header is deliberately absent from durable draft + /// state and must never be persisted by the host. + pub async fn phase1_prepare_native_upload( + &self, + intent: Phase1UploadIntent, + ) -> Result<(Phase1DraftStatus, Phase1NativeUploadJob), Phase1DraftError> { + let now_unix_ms = phase1_operation_now_unix_ms()?; + let plan = Phase1UploadPlan::derive(now_unix_ms, phase1_random_id()?, phase1_random_id()?)?; + let request = radroots_sdk::transport::BlossomUploadRequest::new( + intent.bytes, + intent.media_type, + intent.dimensions, + now_unix_ms, + ) + .map_err(|_| Phase1DraftError::InvalidMedia)?; + let blossom = self + .client + .blossom() + .map_err(|_| Phase1DraftError::OperationUnavailable)? + .ok_or(Phase1DraftError::OperationUnavailable)?; + let transaction = blossom + .prepare_upload(request) + .map_err(|_| Phase1DraftError::Operation)?; + let remote_url = transaction.expected_url().as_str().to_owned(); + let content = radroots_blossom::authorization::AuthorizationContent::parse( + &plan.authorization_content, + ) + .map_err(|_| Phase1DraftError::InvalidMedia)?; + let claim = blossom + .authored_upload_claim( + &transaction, + content, + plan.authorization_created_at_unix_s, + plan.authorization_lifetime_seconds, + ) + .map_err(|_| Phase1DraftError::Operation)?; + let authorization = self + .phase1_authorize_blossom_upload( + plan.operation_id, + plan.artifact_id, + claim, + plan.signing_deadline_unix_ms, + plan.cancellation, + ) + .await?; + let uploading = self + .phase1_update_draft_media( + intent.draft_id, + intent.expected_revision, + remote_url.as_str(), + Phase1MediaStage::Uploading, + None, + now_unix_ms, + ) + .await?; + Ok(( + uploading, + Phase1NativeUploadJob { + operation_id: plan.operation_id, + remote_url, + authorization_header: authorization.into_string(), + expected_sha256: transaction.request().sha256().to_string(), + media_type: transaction.request().media_type().as_str().to_owned(), + byte_size: transaction.request().byte_size(), + }, + )) + } + + /// Verifies a native BUD-02 response and canonical BUD-01 retrieval before + /// advancing the durable media prerequisite. + pub async fn phase1_complete_native_upload( + &self, + intent: Phase1UploadIntent, + status_code: u16, + response_media_type: Option<&str>, + response_content_encoding: Option<&str>, + response_body: &[u8], + ) -> Result<Phase1DraftStatus, Phase1DraftError> { + let now_unix_ms = phase1_operation_now_unix_ms()?; + let request = radroots_sdk::transport::BlossomUploadRequest::new( + intent.bytes, + intent.media_type, + intent.dimensions, + now_unix_ms, + ) + .map_err(|_| Phase1DraftError::InvalidMedia)?; + let blossom = self + .client + .blossom() + .map_err(|_| Phase1DraftError::OperationUnavailable)? + .ok_or(Phase1DraftError::OperationUnavailable)?; + let transaction = blossom + .prepare_upload(request) + .map_err(|_| Phase1DraftError::Operation)?; + let url = transaction.expected_url().as_str().to_owned(); + match blossom + .complete_native_upload( + transaction, + status_code, + response_media_type, + response_content_encoding, + response_body, + radroots_sdk::transport::BlossomCancellation::default(), + ) + .await + { + Ok(receipt) => { + self.phase1_complete_draft_media( + intent.draft_id, + intent.expected_revision, + url.as_str(), + receipt, + now_unix_ms, + ) + .await + } + Err(error) => { + self.phase1_fail_draft_media( + intent.draft_id, + intent.expected_revision, + url.as_str(), + &error, + now_unix_ms, + ) + .await?; + Err(Phase1DraftError::Operation) + } + } + } + /// Cancels local work with a Rust-owned transition timestamp. pub async fn phase1_cancel_add_intent( &self, @@ -2653,6 +2844,7 @@ impl RadrootsRuntime { .target_card_id .map(Ok) .unwrap_or_else(|| card_id(payload.command_type, integrity.plan()))?; + let revision_policy = payload.revision.as_ref().map(|value| value.policy); let push = self.push_status_for(&draft).await?; if draft.stage() == AuthoredDraftStage::Queued && push.is_none() { return Err(Phase1DraftError::Corrupt); @@ -2667,6 +2859,7 @@ impl RadrootsRuntime { state, card_id, push, + revision_policy, }) } @@ -3377,11 +3570,10 @@ mod tests { let identifier = "market:summer:2026"; let source = CardSourceIdentity::address(31_923, AUTHOR, identifier).unwrap(); let target_card = CardId::derive(TodayCardType::Event, &source); - let target = Phase1RevisionTarget::new( + let target = Phase1RevisionTarget::from_source( AddCommandType::CreateEvent, target_card, "b".repeat(64), - 31_923, Some(format!("31923:{AUTHOR}:{identifier}")), AUTHOR, ) diff --git a/core/crates/tera_core/src/runtime/sdk.rs b/core/crates/tera_core/src/runtime/sdk.rs @@ -21,9 +21,15 @@ pub struct SdkStorageStatusRecord { } #[derive(Clone, Debug, Eq, PartialEq)] +pub enum SdkRelayAccessRecord { + ReadOnly, + ReadWrite, +} + +#[derive(Clone, Debug, Eq, PartialEq)] pub struct SdkRelayStatusRecord { pub relay_url: String, - pub access: String, + pub access: SdkRelayAccessRecord, pub read_state: String, pub write_state: String, pub read_last_attempt_unix_ms: Option<u64>, @@ -264,11 +270,10 @@ impl RadrootsRuntime { .map(|relay| SdkRelayStatusRecord { relay_url: relay.endpoint().url().to_string(), access: if relay.endpoint().access().can_write() { - "read_write" + SdkRelayAccessRecord::ReadWrite } else { - "read_only" - } - .to_owned(), + SdkRelayAccessRecord::ReadOnly + }, read_state: relay_evidence_label(relay.read().state()).to_owned(), write_state: relay_evidence_label(relay.write().state()).to_owned(), read_last_attempt_unix_ms: relay.read().last_attempt_unix_ms(),