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 ead4c3f7b0a2069a49f9eaaa5fbbddbd7aefb686
parent 7f679fa8e0d4c43ed6828f718cd1cd01a2821e68
Author: triesap <tyson@radroots.org>
Date:   Tue,  8 Sep 2026 18:42:56 +0000

tera: own the shared application core locally

- preserve the verified core history and original license provenance
- wire tera_core into the sole workspace at the exact shared source pin
- adapt package identity and test imports without runtime behavior changes
- verify default and social core profiles plus frozen ffi compatibility

Diffstat:
MCargo.lock | 31+++++++++++++++++++++++++++++++
MCargo.toml | 26+++++++++++++++++++++++++-
Acore/crates/tera_core/Cargo.toml | 78++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acore/crates/tera_core/build.rs | 59+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acore/crates/tera_core/src/error.rs | 187+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acore/crates/tera_core/src/lib.rs | 12++++++++++++
Acore/crates/tera_core/src/provenance.rs | 26++++++++++++++++++++++++++
Acore/crates/tera_core/src/runtime/app_info.rs | 26++++++++++++++++++++++++++
Acore/crates/tera_core/src/runtime/builder.rs | 250+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acore/crates/tera_core/src/runtime/info.rs | 79+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acore/crates/tera_core/src/runtime/mod.rs | 223+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acore/crates/tera_core/src/runtime/product_surface.rs | 154+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acore/crates/tera_core/src/runtime/product_surface/authoring.rs | 310+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acore/crates/tera_core/src/runtime/product_surface/context.rs | 466+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acore/crates/tera_core/src/runtime/product_surface/cursor.rs | 457+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acore/crates/tera_core/src/runtime/product_surface/identity.rs | 185+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acore/crates/tera_core/src/runtime/product_surface/media.rs | 2172+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acore/crates/tera_core/src/runtime/product_surface/model.rs | 251+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acore/crates/tera_core/src/runtime/product_surface/outbox.rs | 4402+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acore/crates/tera_core/src/runtime/product_surface/projection.rs | 646+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acore/crates/tera_core/src/runtime/product_surface/ranking.rs | 259+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acore/crates/tera_core/src/runtime/product_surface/settings.rs | 1669+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acore/crates/tera_core/src/runtime/product_surface/today.rs | 3776+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acore/crates/tera_core/src/runtime/sdk.rs | 532+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acore/crates/tera_core/src/runtime/store.rs | 257+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acore/crates/tera_core/tests/durable_runtime.rs | 111+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acore/crates/tera_core/tests/package_boundary.rs | 101+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acore/crates/tera_core/tests/sdk_runtime.rs | 55+++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acore/crates/tera_core/tests/support/mod.rs | 33+++++++++++++++++++++++++++++++++
Acore/provenance/tera_core/LICENSE-APACHE | 201+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acore/provenance/tera_core/LICENSE-GPL-3.0-only | 674+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acore/provenance/tera_core/LICENSE-GPL-3.0-or-later | 674+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acore/provenance/tera_core/LICENSE-MIT | 21+++++++++++++++++++++
33 files changed, 18402 insertions(+), 1 deletion(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -1365,6 +1365,7 @@ dependencies = [ "unicode-normalization", "url", "url-fork", + "zeroize", ] [[package]] @@ -1790,6 +1791,7 @@ dependencies = [ "radroots_event", "radroots_event_codec", "radroots_identity", + "radroots_signing", "serde", "serde_json", "thiserror 2.0.19", @@ -2581,6 +2583,35 @@ dependencies = [ ] [[package]] +name = "tera_core" +version = "0.1.0-alpha" +dependencies = [ + "chrono", + "hex", + "nostr", + "radroots_blossom", + "radroots_event", + "radroots_event_codec", + "radroots_identity", + "radroots_nostr", + "radroots_protocol", + "radroots_sdk", + "radroots_signing", + "radroots_storage", + "radroots_sync", + "radroots_transport", + "radroots_transport_nostr", + "serde", + "serde_json", + "sha2", + "tempfile", + "thiserror 1.0.69", + "tokio", + "url", + "uuid", +] + +[[package]] name = "textwrap" version = "0.16.2" source = "registry+https://github.com/rust-lang/crates.io-index" diff --git a/Cargo.toml b/Cargo.toml @@ -1,5 +1,5 @@ [workspace] -members = ["crates/source_lock"] +members = ["crates/source_lock", "core/crates/tera_core"] resolver = "3" [workspace.package] @@ -9,6 +9,7 @@ rust-version = "1.97.1" license = "GPL-3.0-or-later" repository = "https://github.com/radrootslabs/tera" homepage = "https://radroots.org" +readme = "README.md" authors = ["Tyson Lupul <tyson@radroots.org>"] [workspace.lints.rust] @@ -24,3 +25,26 @@ unimplemented = "deny" [workspace.dependencies] radroots_mobile_ffi = { git = "https://github.com/radrootslabs/lib", rev = "ad17b7d3455a7147cfa303d976fc5c70c3a4c0cb", version = "=0.1.0-alpha" } +chrono = { version = "0.4" } +hex = { version = "0.4" } +nostr = { version = "0.44.7", default-features = false } +radroots_blossom = { package = "radroots_blossom", version = "=0.1.0-alpha", default-features = false, git = "https://github.com/radrootslabs/lib", rev = "ad17b7d3455a7147cfa303d976fc5c70c3a4c0cb" } +radroots_event = { package = "radroots_event", version = "=0.1.0-alpha", default-features = false, git = "https://github.com/radrootslabs/lib", rev = "ad17b7d3455a7147cfa303d976fc5c70c3a4c0cb" } +radroots_event_codec = { package = "radroots_event_codec", version = "=0.1.0-alpha", default-features = false, git = "https://github.com/radrootslabs/lib", rev = "ad17b7d3455a7147cfa303d976fc5c70c3a4c0cb" } +radroots_identity = { package = "radroots_identity", version = "=0.1.0-alpha", default-features = false, git = "https://github.com/radrootslabs/lib", rev = "ad17b7d3455a7147cfa303d976fc5c70c3a4c0cb" } +radroots_nostr = { package = "radroots_nostr", version = "=0.1.0-alpha", default-features = false, git = "https://github.com/radrootslabs/lib", rev = "ad17b7d3455a7147cfa303d976fc5c70c3a4c0cb" } +radroots_protocol = { package = "radroots_protocol", version = "=0.1.0-alpha", default-features = false, git = "https://github.com/radrootslabs/lib", rev = "ad17b7d3455a7147cfa303d976fc5c70c3a4c0cb" } +radroots_sdk = { version = "=0.1.0-alpha", default-features = false, git = "https://github.com/radrootslabs/lib", rev = "ad17b7d3455a7147cfa303d976fc5c70c3a4c0cb" } +radroots_signing = { package = "radroots_signing", version = "=0.1.0-alpha", default-features = false, git = "https://github.com/radrootslabs/lib", rev = "ad17b7d3455a7147cfa303d976fc5c70c3a4c0cb" } +radroots_storage = { package = "radroots_storage", version = "=0.1.0-alpha", default-features = false, git = "https://github.com/radrootslabs/lib", rev = "ad17b7d3455a7147cfa303d976fc5c70c3a4c0cb" } +radroots_sync = { package = "radroots_sync", version = "=0.1.0-alpha", default-features = false, git = "https://github.com/radrootslabs/lib", rev = "ad17b7d3455a7147cfa303d976fc5c70c3a4c0cb" } +radroots_transport = { package = "radroots_transport", version = "=0.1.0-alpha", default-features = false, git = "https://github.com/radrootslabs/lib", rev = "ad17b7d3455a7147cfa303d976fc5c70c3a4c0cb" } +radroots_transport_nostr = { package = "radroots_transport_nostr", version = "=0.1.0-alpha", default-features = false, git = "https://github.com/radrootslabs/lib", rev = "ad17b7d3455a7147cfa303d976fc5c70c3a4c0cb" } +serde = { version = "1", default-features = false, features = ["derive", "alloc"] } +serde_json = { version = "1", default-features = false, features = ["alloc"] } +sha2 = { version = "0.10", default-features = false } +tempfile = { version = "3" } +thiserror = { version = "1" } +tokio = { version = "1" } +url = { version = "2" } +uuid = { version = "1.22.0", features = ["v4", "v7"] } diff --git a/core/crates/tera_core/Cargo.toml b/core/crates/tera_core/Cargo.toml @@ -0,0 +1,78 @@ +[package] +name = "tera_core" +version = "0.1.0-alpha" +edition.workspace = true +authors = ["Radroots Authors"] +rust-version.workspace = true +license = "GPL-3.0-or-later" +description = "Application core runtime for Radroots apps" +repository.workspace = true +homepage.workspace = true +readme.workspace = true +publish = false +include = ["src/**", "tests/**", "build.rs", "Cargo.toml"] + +[lib] +crate-type = ["rlib"] + +[lints.rust] +unexpected_cfgs = { level = "warn", check-cfg = ['cfg(coverage_nightly)'] } + +[features] +default = [] +mobile-social = [ + "radroots_sdk/blossom", + "radroots_sdk/nostr", + "radroots_sdk/sync", + "dep:tokio", +] + +[dependencies] +radroots_blossom = { workspace = true, default-features = false, features = [ + "serde", + "std", +] } +radroots_sdk = { workspace = true, features = ["sqlite"] } +radroots_event = { workspace = true, default-features = false, features = [ + "std", +] } +radroots_event_codec = { workspace = true, default-features = false, features = [ + "json", + "std", +] } +radroots_identity = { workspace = true, default-features = false, features = [ + "std", +] } +radroots_protocol = { workspace = true, default-features = false, features = [ + "std", +] } +radroots_signing = { workspace = true, default-features = false, features = [ + "std", +] } +radroots_storage = { workspace = true, default-features = false } +radroots_sync = { workspace = true, default-features = false } +radroots_transport = { workspace = true, default-features = false, features = [ + "std", +] } +radroots_transport_nostr = { workspace = true } +chrono = { workspace = true } +hex = { workspace = true } +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } +sha2 = { workspace = true } +thiserror = { workspace = true } +tokio = { workspace = true, optional = true, features = [ + "fs", + "io-util", + "rt", + "sync", +] } +uuid = { workspace = true, features = ["v4"] } +url = { workspace = true } + +[dev-dependencies] +nostr = { workspace = true, features = ["std"] } +radroots_nostr = { workspace = true, features = ["blossom", "signing"] } +radroots_sdk = { workspace = true, features = ["memory", "sqlite"] } +tempfile = { workspace = true } +tokio = { workspace = true, features = ["macros", "rt"] } diff --git a/core/crates/tera_core/build.rs b/core/crates/tera_core/build.rs @@ -0,0 +1,59 @@ +use std::{env, process::Command}; + +#[path = "src/provenance.rs"] +mod provenance; + +fn main() { + println!("cargo:rerun-if-changed=build.rs"); + println!("cargo:rerun-if-env-changed=RUSTC"); + println!("cargo:rerun-if-env-changed=PROFILE"); + println!("cargo:rerun-if-env-changed=RADROOTS_LIB_REVISION"); + println!("cargo:rerun-if-env-changed=RADROOTS_CONSUMER_REVISION"); + println!("cargo:rerun-if-env-changed=SOURCE_DATE_EPOCH"); + + let rustc = env::var("RUSTC").expect("missing required env var RUSTC"); + if let Ok(output) = Command::new(rustc).arg("--version").output() + && output.status.success() + && let Ok(version) = String::from_utf8(output.stdout) + { + println!("cargo:rustc-env=RUSTC_VERSION={}", version.trim()); + } + + let lib_revision = optional_full_revision("RADROOTS_LIB_REVISION"); + let consumer_revision = optional_full_revision("RADROOTS_CONSUMER_REVISION"); + assert!( + consumer_revision.is_none() || lib_revision.is_some(), + "RADROOTS_CONSUMER_REVISION requires RADROOTS_LIB_REVISION" + ); + if let Some(revision) = lib_revision { + println!("cargo:rustc-env=RADROOTS_LIB_REVISION={revision}"); + } + if let Some(revision) = consumer_revision { + println!("cargo:rustc-env=RADROOTS_CONSUMER_REVISION={revision}"); + } + + let profile = env::var("PROFILE").expect("missing required env var PROFILE"); + println!("cargo:rustc-env=PROFILE={profile}"); + + if let Some(epoch) = optional_source_date_epoch() { + println!("cargo:rustc-env=BUILD_TIME_UNIX={epoch}"); + } +} + +fn optional_full_revision(name: &str) -> Option<String> { + let value = env::var(name).ok()?; + assert!( + provenance::is_full_revision(&value), + "{name} must contain exactly 40 lowercase hexadecimal characters" + ); + Some(value) +} + +fn optional_source_date_epoch() -> Option<u64> { + let value = env::var("SOURCE_DATE_EPOCH").ok()?; + Some( + value + .parse() + .expect("SOURCE_DATE_EPOCH must be an unsigned Unix timestamp"), + ) +} diff --git a/core/crates/tera_core/src/error.rs b/core/crates/tera_core/src/error.rs @@ -0,0 +1,187 @@ +use thiserror::Error; + +/// Versioned, secret-safe SDK failure exposed to mobile hosts. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct SdkErrorRecord { + pub schema_version: u16, + pub code: String, + pub class: String, + pub retryable: bool, + pub recovery_actions: Vec<String>, + pub operation_id: Option<String>, + pub capability_id: Option<String>, + pub message: String, +} + +/// Versioned, path-redacted mobile store failure exposed to native hosts. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct StoreErrorRecord { + pub schema_version: u16, + pub code: String, + pub class: String, + pub retryable: bool, + pub recovery_actions: Vec<String>, + pub message: String, +} + +#[derive(Debug, Error)] +pub enum RadrootsAppError { + #[error("initialization: {0}")] + Initialization(String), + #[error("sdk: {report:?}")] + Sdk { report: SdkErrorRecord }, + #[error("store: {report:?}")] + Store { report: StoreErrorRecord }, + #[error("runtime: {0}")] + Runtime(String), + #[error("unsupported: {0}")] + Unsupported(String), + #[error("internal: {0}")] + Internal(String), +} + +impl RadrootsAppError { + /// Returns the stable store report when this is a mobile storage failure. + pub const fn store_report(&self) -> Option<&StoreErrorRecord> { + match self { + Self::Store { report } => Some(report), + _ => None, + } + } + + pub(crate) fn from_sdk(error: radroots_sdk::Error) -> Self { + let report = error.to_report(); + Self::Sdk { + report: SdkErrorRecord { + schema_version: report.schema_version(), + code: report.code().as_str().to_owned(), + class: report.class().as_str().to_owned(), + retryable: report.retryable(), + recovery_actions: report + .recovery_actions() + .iter() + .map(|action| action.as_str().to_owned()) + .collect(), + operation_id: report.operation_id().map(|id| id.as_str().to_owned()), + capability_id: report.capability_id().map(|id| id.as_str().to_owned()), + message: report.message().as_str().to_owned(), + }, + } + } + + pub fn initialization(message: impl Into<String>) -> Self { + Self::Initialization(message.into()) + } + + pub fn runtime(message: impl Into<String>) -> Self { + Self::Runtime(message.into()) + } + + pub fn unsupported(message: impl Into<String>) -> Self { + Self::Unsupported(message.into()) + } + + pub fn internal(message: impl Into<String>) -> Self { + Self::Internal(message.into()) + } + + pub(crate) fn store_invalid_configuration() -> Self { + Self::Store { + report: StoreErrorRecord { + schema_version: 1, + code: "invalid_store_configuration".to_owned(), + class: "validation".to_owned(), + retryable: false, + recovery_actions: vec!["configure_user_store".to_owned()], + message: "mobile user store configuration is invalid".to_owned(), + }, + } + } + + pub(crate) fn protected_data_unavailable() -> Self { + Self::Store { + report: StoreErrorRecord { + schema_version: 1, + code: "protected_data_unavailable".to_owned(), + class: "storage".to_owned(), + retryable: true, + recovery_actions: vec!["retry_after_protected_data_available".to_owned()], + message: "Apple protected data is unavailable".to_owned(), + }, + } + } + + pub(crate) fn store_path_unavailable() -> Self { + Self::Store { + report: StoreErrorRecord { + schema_version: 1, + code: "store_path_unavailable".to_owned(), + class: "storage".to_owned(), + retryable: true, + recovery_actions: vec!["prepare_application_support_directory".to_owned()], + message: "mobile user store directory is unavailable".to_owned(), + }, + } + } +} + +#[cfg(test)] +#[cfg_attr(coverage_nightly, coverage(off))] +mod tests { + use super::{RadrootsAppError, SdkErrorRecord, StoreErrorRecord}; + + #[test] + fn sdk_error_records_are_versioned_stable_and_secret_safe() { + let error = radroots_sdk::ClientBuilder::new() + .build() + .expect_err("storage is required"); + let RadrootsAppError::Sdk { report } = RadrootsAppError::from_sdk(error) else { + panic!("expected SDK report"); + }; + assert_eq!( + report, + SdkErrorRecord { + schema_version: 1, + code: "missing_storage".to_owned(), + class: "capability".to_owned(), + retryable: false, + recovery_actions: vec!["configure_storage".to_owned()], + operation_id: None, + capability_id: Some("storage.canonical".to_owned()), + message: "SDK storage capability is not configured".to_owned(), + } + ); + assert!(!format!("{report:?}").contains("source")); + } + + #[test] + fn public_error_constructors_preserve_typed_variants() { + assert!(matches!( + RadrootsAppError::initialization("init"), + RadrootsAppError::Initialization(message) if message == "init" + )); + assert!(matches!( + RadrootsAppError::runtime("runtime"), + RadrootsAppError::Runtime(message) if message == "runtime" + )); + assert!(matches!( + RadrootsAppError::unsupported("unsupported"), + RadrootsAppError::Unsupported(message) if message == "unsupported" + )); + assert!(matches!( + RadrootsAppError::internal("internal"), + RadrootsAppError::Internal(message) if message == "internal" + )); + assert_eq!( + RadrootsAppError::protected_data_unavailable().store_report(), + Some(&StoreErrorRecord { + schema_version: 1, + code: "protected_data_unavailable".to_owned(), + class: "storage".to_owned(), + retryable: true, + recovery_actions: vec!["retry_after_protected_data_available".to_owned()], + message: "Apple protected data is unavailable".to_owned(), + }) + ); + } +} diff --git a/core/crates/tera_core/src/lib.rs b/core/crates/tera_core/src/lib.rs @@ -0,0 +1,12 @@ +// Mobile errors retain the complete stable SDK report. Preserving that typed +// value is more important than optimizing the Rust enum's in-process size. +#![allow(clippy::result_large_err)] +#![cfg_attr(coverage_nightly, feature(coverage_attribute))] + +pub mod error; +#[cfg(test)] +mod provenance; +pub mod runtime; + +pub use error::{RadrootsAppError, SdkErrorRecord, StoreErrorRecord}; +pub use runtime::RadrootsRuntime; diff --git a/core/crates/tera_core/src/provenance.rs b/core/crates/tera_core/src/provenance.rs @@ -0,0 +1,26 @@ +pub(crate) fn is_full_revision(value: &str) -> bool { + value.len() == 40 + && value + .bytes() + .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase()) +} + +#[cfg(test)] +mod tests { + use super::is_full_revision; + + #[test] + fn only_full_lowercase_git_revisions_are_accepted() { + assert!(is_full_revision("0123456789abcdef0123456789abcdef01234567")); + assert!(!is_full_revision("0123456")); + assert!(!is_full_revision( + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" + )); + assert!(!is_full_revision( + "0123456789ABCDEF0123456789abcdef01234567" + )); + assert!(!is_full_revision( + "g123456789abcdef0123456789abcdef01234567" + )); + } +} diff --git a/core/crates/tera_core/src/runtime/app_info.rs b/core/crates/tera_core/src/runtime/app_info.rs @@ -0,0 +1,26 @@ +#[derive(Debug, Clone, Default, serde::Serialize)] +pub struct AppInfoPlatform { + pub platform: Option<String>, + pub bundle_id: Option<String>, + pub version: Option<String>, + pub build_number: Option<String>, + pub build_sha: Option<String>, +} + +impl AppInfoPlatform { + pub fn new( + platform: Option<String>, + bundle_id: Option<String>, + version: Option<String>, + build_number: Option<String>, + build_sha: Option<String>, + ) -> Self { + Self { + platform, + bundle_id, + version, + build_number, + build_sha, + } + } +} diff --git a/core/crates/tera_core/src/runtime/builder.rs b/core/crates/tera_core/src/runtime/builder.rs @@ -0,0 +1,250 @@ +use crate::runtime::store::{MobileUserStoreConfig, ProtectedDataAvailability}; +use crate::{RadrootsAppError, RadrootsRuntime}; + +/// Host-owned construction boundary for the shared SDK-backed runtime. +pub struct RuntimeBuilder { + store: MobileUserStoreConfig, + #[cfg(feature = "mobile-social")] + 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 { + #[must_use] + pub fn new(store: MobileUserStoreConfig) -> Self { + Self { + store, + #[cfg(feature = "mobile-social")] + signer: None, + #[cfg(feature = "mobile-social")] + relay_profile: radroots_sdk::transport::RelayProfile::explicit( + radroots_sdk::transport::RelayProfileKind::Public, + [radroots_sdk::transport::RelayEndpoint::new( + "wss://radroots.org", + radroots_sdk::transport::RelayUrlPolicy::Public, + radroots_sdk::transport::RelayAccess::ReadWrite, + ) + .expect("bundled public relay endpoint is valid")], + ) + .expect("bundled public relay profile is valid"), + #[cfg(feature = "mobile-social")] + blossom_config: None, + } + } + + /// Installs one opaque host signer without transferring secret material. + #[cfg(feature = "mobile-social")] + #[must_use] + pub fn signer(mut self, signer: std::sync::Arc<dyn radroots_signing::Signer>) -> Self { + self.signer = Some(signer); + self + } + + /// Replaces the bundled read-only public profile with one validated host + /// environment profile. Construction remains inert. + #[cfg(feature = "mobile-social")] + #[must_use] + pub fn relay_profile(mut self, relay_profile: radroots_sdk::transport::RelayProfile) -> Self { + self.relay_profile = relay_profile; + 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 { + return Err(RadrootsAppError::protected_data_unavailable()); + } + self.store.validate_host_filesystem()?; + let options = self.store.sqlite_options()?; + #[cfg(feature = "mobile-social")] + let inbound_media_directory = self.store.owner_directory().join("inbound_media.v1"); + let builder = radroots_sdk::ClientBuilder::sqlite(options) + .await + .map_err(RadrootsAppError::from_sdk)?; + RadrootsRuntime::from_client_builder( + builder, + Some(self.store.public_key()), + #[cfg(feature = "mobile-social")] + Some(inbound_media_directory), + #[cfg(feature = "mobile-social")] + self.signer, + #[cfg(feature = "mobile-social")] + Some(self.relay_profile), + #[cfg(feature = "mobile-social")] + self.blossom_config, + ) + } +} + +#[cfg(test)] +mod tests { + use super::RuntimeBuilder; + #[cfg(feature = "mobile-social")] + use crate::runtime::sdk::SdkRelayAccessRecord; + use crate::runtime::store::{MobileUserStoreConfig, ProtectedDataAvailability}; + + const PUBLIC_KEY: &str = "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798"; + const GENERATION: &str = "0202020202020202020202020202020202020202020202020202020202020202"; + + fn store( + root: &std::path::Path, + protected_data: ProtectedDataAvailability, + ) -> MobileUserStoreConfig { + let store = MobileUserStoreConfig::from_encoded( + root, + PUBLIC_KEY, + GENERATION, + 1_800_000_000_000, + protected_data, + ) + .expect("store config"); + std::fs::create_dir_all(store.owner_directory()).expect("owner directory"); + store + } + + #[tokio::test] + async fn builder_constructs_a_durable_sdk_backed_runtime() { + let root = tempfile::tempdir().expect("tempdir"); + let runtime = RuntimeBuilder::new(store(root.path(), ProtectedDataAvailability::Available)) + .build() + .await + .expect("runtime"); + assert!(!runtime.info().sdk_closed); + assert_eq!( + runtime.sdk_storage_status().await.expect("status").backend, + "sqlite" + ); + #[cfg(feature = "mobile-social")] + { + let report = runtime + .sdk_relay_status() + .expect("relay status") + .expect("configured profile"); + assert_eq!(report.profile, "public"); + 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, SdkRelayAccessRecord::ReadWrite); + assert_eq!(report.relays[0].read_state, "unobserved"); + assert_eq!(report.relays[0].write_state, "unobserved"); + } + runtime.shutdown().await.expect("shutdown"); + } + + #[cfg(feature = "mobile-social")] + #[tokio::test] + async fn runtime_reconfiguration_preserves_profile_network_boundaries() { + let root = tempfile::tempdir().expect("tempdir"); + let runtime = RuntimeBuilder::new(store(root.path(), ProtectedDataAvailability::Available)) + .build() + .await + .expect("runtime"); + assert!( + runtime + .configure_simulator_relays(vec!["ws://127.0.0.1:8080".to_owned()]) + .is_ok() + ); + let report = runtime + .sdk_relay_status() + .expect("simulator status") + .expect("configured profile"); + assert_eq!(report.profile, "simulator_local"); + assert_eq!(report.relays.len(), 1); + assert_eq!(report.relays[0].access, SdkRelayAccessRecord::ReadWrite); + assert!( + runtime + .configure_public_relays(vec!["ws://127.0.0.1:8080".to_owned()]) + .is_err() + ); + assert_eq!( + runtime + .sdk_relay_status() + .expect("unchanged status") + .expect("configured profile"), + report + ); + assert!( + runtime + .configure_blossom( + radroots_sdk::transport::BlossomHostKind::Simulator, + radroots_sdk::transport::BlossomEndpointAuthority::LoopbackDevelopment, + "http://127.0.0.1:3000".to_owned(), + vec![], + ) + .is_ok() + ); + assert_eq!( + runtime + .sdk_blossom_configuration() + .expect("Blossom profile") + .expect("configured") + .host_kind, + "simulator" + ); + let evidence = runtime + .sdk_blossom_evidence() + .expect("Blossom evidence") + .expect("configured evidence"); + assert_eq!(evidence.schema_version, 2); + assert_eq!(evidence.state, "configured_unobserved"); + assert_eq!(evidence.last_successful_state, "configured_unobserved"); + assert_eq!(evidence.transport_security, "development_cleartext"); + assert!(evidence.observed_at_unix_ms.is_none()); + assert!(evidence.error_code.is_none()); + assert!(evidence.server_error_code.is_none()); + assert!( + runtime + .configure_blossom( + radroots_sdk::transport::BlossomHostKind::PhysicalDevice, + radroots_sdk::transport::BlossomEndpointAuthority::PublicWebPki, + "http://127.0.0.1:3000".to_owned(), + vec![], + ) + .is_err() + ); + assert_eq!( + runtime + .sdk_blossom_configuration() + .expect("unchanged profile") + .expect("configured") + .host_kind, + "simulator" + ); + runtime.shutdown().await.expect("shutdown"); + } + + #[tokio::test] + async fn protected_data_unavailability_is_retryable_and_reopen_recovers() { + let root = tempfile::tempdir().expect("tempdir"); + let unavailable = + RuntimeBuilder::new(store(root.path(), ProtectedDataAvailability::Unavailable)) + .build() + .await; + let Err(unavailable) = unavailable else { + panic!("protected data unavailability must fail"); + }; + let report = unavailable.store_report().expect("store report"); + assert_eq!(report.code, "protected_data_unavailable"); + assert!(report.retryable); + + let runtime = RuntimeBuilder::new(store(root.path(), ProtectedDataAvailability::Available)) + .build() + .await + .expect("recovered runtime"); + runtime.shutdown().await.expect("shutdown"); + } +} diff --git a/core/crates/tera_core/src/runtime/info.rs b/core/crates/tera_core/src/runtime/info.rs @@ -0,0 +1,79 @@ +use super::RadrootsRuntime; +use chrono::Utc; +use serde::Serialize; + +#[derive(Debug, Clone, Serialize, Default)] +pub struct RuntimeBuildInfo { + pub crate_name: String, + pub crate_version: String, + pub rustc: Option<String>, + pub profile: Option<String>, + pub lib_revision: Option<String>, + pub consumer_revision: Option<String>, + pub build_time_unix: Option<u64>, +} + +#[derive(Debug, Clone, Serialize)] +pub struct AppInfo { + pub build: RuntimeBuildInfo, + pub started_unix_ms: i64, + pub uptime_millis: i64, + pub shutting_down: bool, + pub platform: Option<super::app_info::AppInfoPlatform>, +} + +#[derive(Debug, Clone, Serialize)] +pub struct RuntimeInfo { + pub app: AppInfo, + pub sdk: RuntimeBuildInfo, + pub sdk_closed: bool, +} + +pub fn gather_runtime_info(runtime: &RadrootsRuntime) -> RuntimeInfo { + let now_ms = Utc::now().timestamp_millis(); + RuntimeInfo { + app: AppInfo { + build: app_build_info(), + started_unix_ms: runtime.started_unix_ms, + uptime_millis: now_ms - runtime.started_unix_ms, + shutting_down: runtime + .shutting_down + .load(std::sync::atomic::Ordering::SeqCst), + platform: runtime + .platform_app + .read() + .ok() + .and_then(|value| (*value).clone()), + }, + sdk: RuntimeBuildInfo { + crate_name: "radroots_sdk".to_owned(), + crate_version: "0.1.0-alpha".to_owned(), + ..RuntimeBuildInfo::default() + }, + sdk_closed: runtime.client.is_closed(), + } +} + +pub fn app_build_info() -> RuntimeBuildInfo { + RuntimeBuildInfo { + crate_name: env!("CARGO_PKG_NAME").to_owned(), + crate_version: env!("CARGO_PKG_VERSION").to_owned(), + rustc: option_env!("RUSTC_VERSION").map(str::to_owned), + profile: option_env!("PROFILE").map(str::to_owned), + lib_revision: option_env!("RADROOTS_LIB_REVISION").map(str::to_owned), + consumer_revision: option_env!("RADROOTS_CONSUMER_REVISION").map(str::to_owned), + build_time_unix: option_env!("BUILD_TIME_UNIX").and_then(|value| value.parse().ok()), + } +} + +#[cfg(test)] +mod tests { + #[test] + fn build_info_uses_sdk_identity_without_lower_runtime_metadata() { + let runtime = super::RadrootsRuntime::test_memory().expect("runtime"); + let info = runtime.info(); + assert_eq!(info.sdk.crate_name, "radroots_sdk"); + assert_eq!(info.sdk.crate_version, "0.1.0-alpha"); + assert!(!info.sdk_closed); + } +} diff --git a/core/crates/tera_core/src/runtime/mod.rs b/core/crates/tera_core/src/runtime/mod.rs @@ -0,0 +1,223 @@ +pub mod app_info; +pub mod builder; +pub mod info; +pub mod product_surface; +pub mod sdk; +pub mod store; + +use chrono::Utc; +use radroots_identity::PublicKey; +use radroots_sdk::{Client, ClientBuilder}; +#[cfg(feature = "mobile-social")] +use std::path::PathBuf; +use std::sync::{ + RwLock, + atomic::{AtomicBool, Ordering}, +}; + +use self::{ + app_info::AppInfoPlatform, + info::{RuntimeInfo, gather_runtime_info}, +}; +use crate::RadrootsAppError; + +pub struct RadrootsRuntime { + pub(crate) client: Client, + pub(crate) started_unix_ms: i64, + pub(crate) shutting_down: AtomicBool, + pub(crate) platform_app: RwLock<Option<AppInfoPlatform>>, + pub(crate) store_public_key: Option<PublicKey>, + #[cfg(feature = "mobile-social")] + pub(crate) settings_lock: tokio::sync::Mutex<()>, + #[cfg(feature = "mobile-social")] + pub(crate) identity_session: tokio::sync::RwLock<Option<(u64, product_surface::IdentityState)>>, + #[cfg(feature = "mobile-social")] + pub(crate) inbound_media_directory: Option<PathBuf>, + #[cfg(feature = "mobile-social")] + pub(crate) inbound_media_lock: tokio::sync::Mutex<()>, +} + +impl RadrootsRuntime { + pub(crate) fn from_client_builder( + builder: ClientBuilder, + store_public_key: Option<PublicKey>, + #[cfg(feature = "mobile-social")] inbound_media_directory: Option<PathBuf>, + #[cfg(feature = "mobile-social")] signer: Option< + std::sync::Arc<dyn radroots_signing::Signer>, + >, + #[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 = { + let nostr_slot = radroots_sdk::transport::NostrSlot::new(); + if let Some(profile) = relay_profile { + nostr_slot + .configure(profile) + .map_err(RadrootsAppError::from_sdk)?; + } + 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)), + None => builder, + } + }; + let client = builder.build().map_err(RadrootsAppError::from_sdk)?; + + Ok(Self { + client, + started_unix_ms: Utc::now().timestamp_millis(), + shutting_down: AtomicBool::new(false), + platform_app: RwLock::new(None), + store_public_key, + #[cfg(feature = "mobile-social")] + settings_lock: tokio::sync::Mutex::new(()), + #[cfg(feature = "mobile-social")] + identity_session: tokio::sync::RwLock::new(None), + #[cfg(feature = "mobile-social")] + inbound_media_directory, + #[cfg(feature = "mobile-social")] + inbound_media_lock: tokio::sync::Mutex::new(()), + }) + } + + #[cfg(test)] + pub(crate) fn test_memory() -> Result<Self, RadrootsAppError> { + Self::from_client_builder( + ClientBuilder::memory_default(), + None, + #[cfg(feature = "mobile-social")] + None, + #[cfg(feature = "mobile-social")] + None, + #[cfg(feature = "mobile-social")] + None, + #[cfg(feature = "mobile-social")] + None, + ) + } + + /// Closes SDK resources asynchronously across every runtime reference. + /// + /// Dropping the returned future before its first poll has no effect. If a + /// host cancels after close begins, it must call `shutdown` again; the SDK + /// remains unavailable and resumes the explicit close attempt. Completed + /// calls are idempotent and no blocking destructor is installed. + pub async fn shutdown(&self) -> Result<sdk::SdkShutdownRecord, RadrootsAppError> { + let already_closed = self.client.is_closed(); + self.shutting_down.store(true, Ordering::Release); + self.client + .close() + .await + .map_err(RadrootsAppError::from_sdk)?; + Ok(sdk::SdkShutdownRecord { + state: "closed".to_owned(), + already_closed, + }) + } + + pub fn uptime_millis(&self) -> i64 { + Utc::now().timestamp_millis() - self.started_unix_ms + } + + /// Returns the canonical public identity that scopes durable storage. + /// Explicit unit-test memory runtimes are the only runtimes without one. + pub fn authenticated_store_public_key_hex(&self) -> Option<String> { + self.store_public_key.map(|key| key.to_hex()) + } + + pub fn info(&self) -> RuntimeInfo { + gather_runtime_info(self) + } + + pub fn info_json(&self) -> String { + serde_json::to_string_pretty(&self.info()) + .unwrap_or_else(|error| format!(r#"{{"error":"serialize RuntimeInfo: {error}"}}"#)) + } + + pub fn set_app_info_platform( + &self, + platform: Option<String>, + bundle_id: Option<String>, + version: Option<String>, + build_number: Option<String>, + build_sha: Option<String>, + ) { + let platform_info = + AppInfoPlatform::new(platform, bundle_id, version, build_number, build_sha); + if let Ok(mut guard) = self.platform_app.write() { + *guard = Some(platform_info); + } + } +} + +#[cfg(test)] +mod tests { + use super::RadrootsRuntime; + use radroots_sdk::capability::{Availability, CapabilityId}; + use std::panic::{AssertUnwindSafe, catch_unwind}; + + fn poison_platform_lock(runtime: &RadrootsRuntime) { + let _ = catch_unwind(AssertUnwindSafe(|| { + let _guard = runtime.platform_app.write().expect("lock platform"); + panic!("poison platform lock"); + })); + } + + #[test] + fn runtime_owns_one_sdk_client() { + let runtime = RadrootsRuntime::test_memory().expect("runtime"); + let storage = runtime + .client + .capabilities() + .get(CapabilityId::CANONICAL_STORAGE) + .expect("storage capability"); + assert_eq!(storage.availability(), Availability::Available); + assert!(!runtime.client.is_closed()); + } + + #[test] + fn set_platform_info_handles_poisoned_lock() { + let runtime = RadrootsRuntime::test_memory().expect("runtime"); + runtime.set_app_info_platform( + Some("ios".to_owned()), + Some("org.radroots.app".to_owned()), + Some("1.0.0".to_owned()), + Some("100".to_owned()), + Some("abc123".to_owned()), + ); + assert_eq!( + runtime + .info() + .app + .platform + .as_ref() + .and_then(|value| value.platform.clone()), + Some("ios".to_owned()) + ); + poison_platform_lock(&runtime); + runtime.set_app_info_platform(None, None, None, None, None); + } + + #[test] + fn runtime_metadata_helpers_are_host_safe() { + let runtime = RadrootsRuntime::test_memory().expect("runtime"); + assert!(runtime.uptime_millis() >= 0); + let json = runtime.info_json(); + assert!(json.contains("sdk")); + } +} diff --git a/core/crates/tera_core/src/runtime/product_surface.rs b/core/crates/tera_core/src/runtime/product_surface.rs @@ -0,0 +1,154 @@ +//! Focused Phase 1 social-product domain for native Radroots clients. +//! +//! This module owns presentation-neutral product semantics. Protocol parsing +//! and admission remain in lower event crates; persistence and live query +//! composition remain in the runtime slices that consume these types. + +mod authoring; +mod context; +mod cursor; +mod identity; +mod media; +mod model; +#[cfg(feature = "mobile-social")] +mod outbox; +mod projection; +mod ranking; +#[cfg(feature = "mobile-social")] +mod settings; +mod today; + +pub use authoring::{ + CreateAsk, CreateEvent, CreateFoodAvailability, CreatePhotoUpdate, CreateUpdate, + Phase1AddCommand, Phase1ReplacementPolicy, phase1_retraction_plan, +}; +pub use context::{ + ContextAdmission, ContextRank, LocalNetwork, LocalNetworkAdmission, LocalNetworkError, + LocalNetworkRelayPolicy, LocalityEvidence, +}; +pub use cursor::{CursorError, CursorScope, TodayCursor, TodayCursorPosition}; +pub use identity::{CARD_ID_SCHEMA_VERSION, CardId, CardIdError, CardSourceIdentity}; +#[cfg(feature = "mobile-social")] +pub use media::Phase1LocalMediaArtifact; +pub use media::{ + MediaReference, Phase1InboundMediaError, Phase1InboundMediaFailure, Phase1InboundMediaPending, + Phase1InboundMediaState, Phase1MediaArtifactId, Phase1MediaCacheIndex, Phase1MediaCachePolicy, + Phase1MediaCacheStatus, Phase1MediaConfigurationFingerprint, Phase1StructuralMediaReference, + Phase1VerifiedMediaReceipt, +}; +pub use model::{ + AddCommandType, CANONICAL_ADD_COMMAND_TYPES, CANONICAL_CARD_ADD_PARITY, + CANONICAL_TODAY_CARD_TYPES, CardAddParity, CardLifecycleState, ClassifiedCard, + LocalAuthorOverlay, MeSnapshot, ProfileSummary, SearchResult, SearchResultType, + SupportingProfile, ThreadEntry, ThreadReference, TodayCard, TodayCardType, TodayPage, +}; +#[cfg(feature = "mobile-social")] +pub use outbox::{ + Phase1AddIntent, Phase1CancellationPolicy, Phase1DraftError, Phase1DraftEventTiming, + Phase1DraftFormSnapshot, Phase1DraftKind, Phase1DraftMediaSnapshot, Phase1DraftStatus, + Phase1ExistingDraft, Phase1MediaOrphanRecord, Phase1MediaPrerequisite, Phase1MediaStage, + 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}; +#[cfg(feature = "mobile-social")] +pub use settings::{ + BlossomEndpointAuthorityPreference, BlossomPreferences, DEFAULT_PUBLIC_BLOSSOM_ORIGIN, + DEFAULT_PUBLIC_RELAY, DEFAULT_SIMULATOR_BLOSSOM_ORIGIN, DEFAULT_SIMULATOR_RELAY, + IdentityCommand, IdentityLockState, IdentityRecord, IdentitySettingsError, IdentityState, + LocalStoragePolicy, MOBILE_SETTINGS_SCHEMA_VERSION, MediaNetworkPolicy, + MobileNetworkEnvironment, MobileSettings, ProfileMetadataCommand, ProfileMetadataError, + RelayAccessPreference, RelayEndpointPreference, RelayPreferences, ReplaceMobileSettings, + SettingsError, SettingsTransition, +}; +pub use today::{ + TodayError, TodayIngestReceipt, TodayPageRequest, TodayProjectionUpdate, TodayRefreshReceipt, +}; +#[cfg(feature = "mobile-social")] +pub use today::{TodayRelaySyncState, TodaySyncReceipt}; + +use super::RadrootsRuntime; + +impl RadrootsRuntime { + /// Returns the exact five Phase 1 Today card types in contract order. + pub fn phase1_card_types(&self) -> Vec<TodayCardType> { + CANONICAL_TODAY_CARD_TYPES.to_vec() + } + + /// Returns the exact five Phase 1 Add commands in card-parity order. + pub fn phase1_add_command_types(&self) -> Vec<AddCommandType> { + CANONICAL_ADD_COMMAND_TYPES.to_vec() + } + + /// Returns the closed one-to-one Today/Add mapping. + pub fn phase1_card_add_parity(&self) -> Vec<CardAddParity> { + CANONICAL_CARD_ADD_PARITY.to_vec() + } + + /// Constructs a validated local query/composer context. + pub fn phase1_local_network( + &self, + id: String, + label: String, + relay_urls: Vec<String>, + locality: Option<String>, + followed_authors: Vec<String>, + generation: u64, + ) -> Result<LocalNetwork, crate::RadrootsAppError> { + LocalNetwork::new( + id, + label, + relay_urls, + locality, + followed_authors, + generation, + ) + .map_err(|error| crate::RadrootsAppError::runtime(error.to_string())) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn runtime_exposes_only_the_locked_card_and_add_catalogs() { + let runtime = RadrootsRuntime::test_memory().expect("runtime"); + assert_eq!(runtime.phase1_card_types(), CANONICAL_TODAY_CARD_TYPES); + assert_eq!( + runtime.phase1_add_command_types(), + CANONICAL_ADD_COMMAND_TYPES + ); + assert_eq!(runtime.phase1_card_add_parity(), CANONICAL_CARD_ADD_PARITY); + assert_eq!( + runtime + .phase1_local_network( + "nearby".into(), + "Near me".into(), + vec!["wss://relay.example".into()], + Some("u10h".into()), + vec!["a".repeat(64)], + 1, + ) + .expect("network") + .id, + "nearby" + ); + assert!( + runtime + .phase1_local_network( + "nearby".into(), + "Near me".into(), + Vec::new(), + None, + Vec::new(), + 1, + ) + .is_err() + ); + } +} diff --git a/core/crates/tera_core/src/runtime/product_surface/authoring.rs b/core/crates/tera_core/src/runtime/product_surface/authoring.rs @@ -0,0 +1,310 @@ +use radroots_event::{ + calendar::{AuthoredCalendarDateEvent, AuthoredCalendarTimeEvent}, + food::availability::FoodAvailabilityDetails, + post::{ + AuthoredAsk, AuthoredPhotoUpdate, AuthoredPostError, AuthoredPostImage, AuthoredUpdate, + deletion::AuthoredNip09DeletionRequest, + }, +}; +use radroots_event_codec::authoring::{AuthoredEventPlan, AuthoredPlanError}; + +use super::AddCommandType; + +/// A strict `CreateUpdate` command. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct CreateUpdate(AuthoredUpdate); + +impl CreateUpdate { + pub fn new(content: impl Into<String>) -> Result<Self, AuthoredPostError> { + AuthoredUpdate::new(content).map(Self) + } + + pub const fn authored(&self) -> &AuthoredUpdate { + &self.0 + } +} + +/// A strict `CreatePhotoUpdate` command. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct CreatePhotoUpdate(AuthoredPhotoUpdate); + +impl CreatePhotoUpdate { + pub fn new( + content: impl Into<String>, + images: Vec<AuthoredPostImage>, + ) -> Result<Self, AuthoredPostError> { + AuthoredPhotoUpdate::new(content, images).map(Self) + } + + pub const fn authored(&self) -> &AuthoredPhotoUpdate { + &self.0 + } +} + +/// A strict `CreateAsk` command. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct CreateAsk(AuthoredAsk); + +impl CreateAsk { + pub fn new( + question: impl Into<String>, + images: Vec<AuthoredPostImage>, + ) -> Result<Self, AuthoredPostError> { + AuthoredAsk::new(question, images).map(Self) + } + + pub const fn authored(&self) -> &AuthoredAsk { + &self.0 + } +} + +/// A strict `CreateEvent` command with an explicit all-day or timed profile. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct CreateEvent(Box<CreateEventProfile>); + +#[derive(Clone, Debug, PartialEq, Eq)] +enum CreateEventProfile { + Date(AuthoredCalendarDateEvent), + Time(AuthoredCalendarTimeEvent), +} + +impl CreateEvent { + pub fn date(event: AuthoredCalendarDateEvent) -> Self { + Self(Box::new(CreateEventProfile::Date(event))) + } + + pub fn time(event: AuthoredCalendarTimeEvent) -> Self { + Self(Box::new(CreateEventProfile::Time(event))) + } +} + +/// A strict `CreateFoodAvailability` command. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct CreateFoodAvailability(FoodAvailabilityDetails); + +impl CreateFoodAvailability { + pub const fn new(details: FoodAvailabilityDetails) -> Self { + Self(details) + } + + pub const fn authored(&self) -> &FoodAvailabilityDetails { + &self.0 + } +} + +/// The only five Phase 1 Add commands accepted by focused mobile authoring. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Phase1AddCommand { + CreateUpdate(CreateUpdate), + CreatePhotoUpdate(CreatePhotoUpdate), + CreateAsk(CreateAsk), + CreateEvent(CreateEvent), + CreateFoodAvailability(CreateFoodAvailability), +} + +/// Standard revision behavior for a Phase 1 authored profile. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Phase1ReplacementPolicy { + /// Regular kind-1 events have no edit convention. The corrected event must + /// settle before an independent retraction is allowed to begin. + CreateThenRetract, + /// Addressable events replace the current head by reusing their stable `d`. + AddressableReplacement, +} + +impl Phase1AddCommand { + pub const fn command_type(&self) -> AddCommandType { + match self { + Self::CreateUpdate(_) => AddCommandType::CreateUpdate, + Self::CreatePhotoUpdate(_) => AddCommandType::CreatePhotoUpdate, + Self::CreateAsk(_) => AddCommandType::CreateAsk, + Self::CreateEvent(_) => AddCommandType::CreateEvent, + Self::CreateFoodAvailability(_) => AddCommandType::CreateFoodAvailability, + } + } + + pub const fn replacement_policy(&self) -> Phase1ReplacementPolicy { + match self { + Self::CreateUpdate(_) | Self::CreatePhotoUpdate(_) | Self::CreateAsk(_) => { + Phase1ReplacementPolicy::CreateThenRetract + } + Self::CreateEvent(_) | Self::CreateFoodAvailability(_) => { + Phase1ReplacementPolicy::AddressableReplacement + } + } + } + + /// Binds the validated command to one exact timestamp and expected author. + pub fn authored_plan( + &self, + created_at: u64, + expected_author: impl AsRef<str>, + ) -> Result<AuthoredEventPlan, AuthoredPlanError> { + let expected_author = expected_author.as_ref(); + match self { + Self::CreateUpdate(command) => { + AuthoredEventPlan::from_update(command.authored(), created_at, expected_author) + } + Self::CreatePhotoUpdate(command) => AuthoredEventPlan::from_photo_update( + command.authored(), + created_at, + expected_author, + ), + Self::CreateAsk(command) => { + AuthoredEventPlan::from_ask(command.authored(), created_at, expected_author) + } + Self::CreateEvent(command) => match command.0.as_ref() { + CreateEventProfile::Date(event) => { + AuthoredEventPlan::from_calendar_date_event(event, created_at, expected_author) + } + CreateEventProfile::Time(event) => { + AuthoredEventPlan::from_calendar_time_event(event, created_at, expected_author) + } + }, + Self::CreateFoodAvailability(command) => AuthoredEventPlan::from_food_availability( + command.authored(), + created_at, + expected_author, + ), + } + } +} + +/// Builds the independent strict NIP-09 plan used for retraction or withdrawal. +/// +/// This function intentionally does not combine retraction and replacement +/// into one purportedly atomic operation. +pub fn phase1_retraction_plan( + request: &AuthoredNip09DeletionRequest, + created_at: u64, + expected_author: impl AsRef<str>, +) -> Result<AuthoredEventPlan, AuthoredPlanError> { + AuthoredEventPlan::from_nip09_deletion_request(request, created_at, expected_author) +} + +#[cfg(test)] +mod tests { + use super::*; + use radroots_blossom::{BlobDescriptor, BlobUrl, MediaType, Sha256}; + use radroots_event::{ + calendar::CalendarDate, + envelope::kind::{ + KIND_CALENDAR_DATE_EVENT, KIND_CALENDAR_TIME_EVENT, KIND_CLASSIFIED_LISTING, KIND_POST, + }, + food::availability::{ + FoodAvailabilityDetailsParts, FoodAvailabilityStatus, FoodContent, FoodCurrency, + FoodIdentifier, FoodPrice, FoodPublishedAt, FoodText, FoodUnit, + }, + media::AuthoredImage, + post::PostImageDimensions, + post::deletion::{AuthoredNip09DeletionRequest, Nip09DeletionEventTarget}, + }; + + const AUTHOR: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + + #[test] + fn focused_commands_bind_only_locked_wire_profiles() { + let date = AuthoredCalendarDateEvent::new( + "market-day", + "Saturday Market", + CalendarDate::parse("2026-08-08").unwrap(), + ) + .unwrap(); + let time = AuthoredCalendarTimeEvent::new("farm-tour", "Farm Tour", 1_784_380_800).unwrap(); + let image = post_image(); + let commands = [ + Phase1AddCommand::CreateUpdate(CreateUpdate::new("Harvest update").unwrap()), + Phase1AddCommand::CreatePhotoUpdate( + CreatePhotoUpdate::new(format!("Harvest photo {}", image.url()), vec![image]) + .unwrap(), + ), + Phase1AddCommand::CreateAsk(CreateAsk::new("Who has basil?", Vec::new()).unwrap()), + Phase1AddCommand::CreateEvent(CreateEvent::date(date)), + Phase1AddCommand::CreateEvent(CreateEvent::time(time)), + Phase1AddCommand::CreateFoodAvailability(CreateFoodAvailability::new(food())), + ]; + let expected = [ + (AddCommandType::CreateUpdate, KIND_POST), + (AddCommandType::CreatePhotoUpdate, KIND_POST), + (AddCommandType::CreateAsk, KIND_POST), + (AddCommandType::CreateEvent, KIND_CALENDAR_DATE_EVENT), + (AddCommandType::CreateEvent, KIND_CALENDAR_TIME_EVENT), + ( + AddCommandType::CreateFoodAvailability, + KIND_CLASSIFIED_LISTING, + ), + ]; + for (command, (command_type, kind)) in commands.iter().zip(expected) { + let plan = command.authored_plan(1_784_347_200, AUTHOR).unwrap(); + assert_eq!(command.command_type(), command_type); + assert_eq!(plan.body().kind(), kind); + assert_ne!(plan.body().kind(), 20); + } + } + + #[test] + fn revision_and_retraction_semantics_are_explicit() { + let update = Phase1AddCommand::CreateUpdate(CreateUpdate::new("new post").unwrap()); + assert_eq!( + update.replacement_policy(), + Phase1ReplacementPolicy::CreateThenRetract + ); + let event = Phase1AddCommand::CreateEvent(CreateEvent::time( + AuthoredCalendarTimeEvent::new("farm-tour", "Farm Tour", 1_784_380_800).unwrap(), + )); + assert_eq!( + event.replacement_policy(), + Phase1ReplacementPolicy::AddressableReplacement + ); + + let request = AuthoredNip09DeletionRequest::new( + "retracted", + vec![Nip09DeletionEventTarget::parse("b".repeat(64), KIND_POST).unwrap()], + Vec::new(), + ) + .unwrap(); + let plan = phase1_retraction_plan(&request, 1_784_347_201, AUTHOR).unwrap(); + assert_eq!(plan.body().kind(), 5); + } + + fn food() -> FoodAvailabilityDetails { + FoodAvailabilityDetails::new(FoodAvailabilityDetailsParts { + content: FoodContent::new("Carrots available this week.").unwrap(), + identifier: FoodIdentifier::parse("nantes-carrots").unwrap(), + title: FoodText::new("Nantes Carrots").unwrap(), + summary: FoodText::new("Fresh bunches").unwrap(), + published_at: FoodPublishedAt::new(1_784_347_100).unwrap(), + location: FoodText::new("Central Saanich, BC").unwrap(), + price: FoodPrice::new("3", FoodCurrency::parse("CAD").unwrap(), FoodUnit::Pound) + .unwrap(), + quantity: None, + status: FoodAvailabilityStatus::Active, + images: Vec::new(), + }) + .unwrap() + } + + fn post_image() -> AuthoredPostImage { + let bytes = b"harvest-photo"; + let hash = Sha256::digest(bytes); + let media_type = MediaType::parse("image/webp").unwrap(); + let descriptor = BlobDescriptor::new( + BlobUrl::parse(&format!("https://media.example/{hash}.webp")).unwrap(), + hash, + bytes.len() as u64, + media_type.clone(), + 1_784_347_100, + ) + .unwrap() + .approve_reference() + .unwrap() + .verify_bytes(bytes, &media_type) + .unwrap(); + AuthoredPostImage::new( + AuthoredImage::try_from(descriptor).unwrap(), + PostImageDimensions::new(1200, 900).unwrap(), + "Harvest", + ) + .unwrap() + } +} diff --git a/core/crates/tera_core/src/runtime/product_surface/context.rs b/core/crates/tera_core/src/runtime/product_surface/context.rs @@ -0,0 +1,466 @@ +use std::collections::BTreeSet; + +use radroots_transport_nostr::{RelayUrl, RelayUrlPolicy}; +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +const CONTEXT_TEXT_MAX_BYTES: usize = 256; +const RELAY_URL_MAX_BYTES: usize = 2_048; + +/// A validated local query/composer context. It has no Nostr event identity. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct LocalNetwork { + pub id: String, + pub label: String, + pub relay_urls: Vec<String>, + pub locality: Option<String>, + pub followed_authors: Vec<String>, + pub generation: u64, +} + +#[derive(Clone, Debug, Error, Eq, PartialEq)] +pub enum LocalNetworkError { + #[error("local network {field} is invalid")] + InvalidText { field: &'static str }, + #[error("local network requires at least one relay")] + MissingRelay, + #[error("local network relay URL is invalid")] + InvalidRelay, + #[error("local network relay URLs must be unique")] + DuplicateRelay, + #[error("local network followed author is invalid")] + InvalidAuthor, + #[error("local network followed authors must be unique")] + DuplicateAuthor, +} + +/// Host environment whose destination policy governs LocalNetwork relays. +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "PascalCase")] +pub enum LocalNetworkRelayPolicy { + Public, + Simulator, + Device, +} + +impl LocalNetwork { + pub fn new( + id: String, + label: String, + relay_urls: Vec<String>, + locality: Option<String>, + followed_authors: Vec<String>, + generation: u64, + ) -> Result<Self, LocalNetworkError> { + Self::new_for_relay_policy( + id, + label, + relay_urls, + locality, + followed_authors, + generation, + LocalNetworkRelayPolicy::Public, + ) + } + + /// Constructs a context under the exact host relay destination policy. + #[allow(clippy::too_many_arguments)] + pub fn new_for_relay_policy( + id: String, + label: String, + relay_urls: Vec<String>, + locality: Option<String>, + followed_authors: Vec<String>, + generation: u64, + relay_policy: LocalNetworkRelayPolicy, + ) -> Result<Self, LocalNetworkError> { + validate_text(&id, "id")?; + validate_text(&label, "label")?; + if let Some(locality) = locality.as_deref() { + validate_text(locality, "locality")?; + } + if relay_urls.is_empty() { + return Err(LocalNetworkError::MissingRelay); + } + let mut relays = BTreeSet::new(); + let mut canonical_relay_urls = Vec::with_capacity(relay_urls.len()); + for relay in relay_urls { + if relay.is_empty() || relay.len() > RELAY_URL_MAX_BYTES { + return Err(LocalNetworkError::InvalidRelay); + } + let relay = RelayUrl::parse( + relay, + match relay_policy { + LocalNetworkRelayPolicy::Public => RelayUrlPolicy::Public, + LocalNetworkRelayPolicy::Simulator => RelayUrlPolicy::Local, + LocalNetworkRelayPolicy::Device => RelayUrlPolicy::PrivateNetwork, + }, + ) + .map_err(|_| LocalNetworkError::InvalidRelay)?; + if !relays.insert(relay.clone()) { + return Err(LocalNetworkError::DuplicateRelay); + } + canonical_relay_urls.push(relay.to_string()); + } + let mut authors = BTreeSet::new(); + for author in &followed_authors { + if author.len() != 64 + || !author + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) + { + return Err(LocalNetworkError::InvalidAuthor); + } + if !authors.insert(author) { + return Err(LocalNetworkError::DuplicateAuthor); + } + } + Ok(Self { + id, + label, + relay_urls: canonical_relay_urls, + locality, + followed_authors, + generation, + }) + } + + /// Applies the locked locality policy to the selected local context. + pub const fn admit(&self, evidence: LocalityEvidence) -> LocalNetworkAdmission { + match evidence { + LocalityEvidence::Match => LocalNetworkAdmission::Included(ContextAdmission { + rank: ContextRank::LocalityMatch, + reason: "locality_match", + }), + LocalityEvidence::Missing => LocalNetworkAdmission::Included(ContextAdmission { + rank: ContextRank::MissingLocalityFallback, + reason: "locality_missing_fallback", + }), + LocalityEvidence::Nonmatch => LocalNetworkAdmission::Excluded { + reason: "locality_nonmatch", + }, + } + } +} + +fn validate_text(value: &str, field: &'static str) -> Result<(), LocalNetworkError> { + if value.is_empty() + || value.trim() != value + || value.len() > CONTEXT_TEXT_MAX_BYTES + || value.chars().any(char::is_control) + { + return Err(LocalNetworkError::InvalidText { field }); + } + Ok(()) +} + +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "PascalCase")] +pub enum LocalityEvidence { + Match, + Missing, + Nonmatch, +} + +/// The only admitted context-rank values. +#[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)] +#[serde(rename_all = "PascalCase")] +pub enum ContextRank { + MissingLocalityFallback = 1, + LocalityMatch = 2, +} + +impl ContextRank { + pub const fn value(self) -> u8 { + self as u8 + } + + pub const fn from_value(value: u8) -> Option<Self> { + match value { + 1 => Some(Self::MissingLocalityFallback), + 2 => Some(Self::LocalityMatch), + _ => None, + } + } +} + +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ContextAdmission { + pub rank: ContextRank, + pub reason: &'static str, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum LocalNetworkAdmission { + Included(ContextAdmission), + Excluded { reason: &'static str }, +} + +#[cfg(test)] +mod tests { + use super::*; + + fn network() -> LocalNetwork { + LocalNetwork::new( + "local-network".into(), + "Near me".into(), + vec!["wss://relay.example".into()], + Some("u10h".into()), + vec!["a".repeat(64)], + 7, + ) + .expect("network") + } + + #[test] + fn locality_policy_has_exact_rank_and_exclusion_outcomes() { + assert_eq!( + network().admit(LocalityEvidence::Match), + LocalNetworkAdmission::Included(ContextAdmission { + rank: ContextRank::LocalityMatch, + reason: "locality_match", + }) + ); + assert_eq!( + network().admit(LocalityEvidence::Missing), + LocalNetworkAdmission::Included(ContextAdmission { + rank: ContextRank::MissingLocalityFallback, + reason: "locality_missing_fallback", + }) + ); + assert!(matches!( + network().admit(LocalityEvidence::Nonmatch), + LocalNetworkAdmission::Excluded { .. } + )); + } + + #[test] + fn local_network_fields_are_bounded_and_unique() { + assert_eq!(network().generation, 7); + for invalid in [ + LocalNetwork::new( + "".into(), + "label".into(), + vec!["wss://r".into()], + None, + vec![], + 0, + ), + LocalNetwork::new("id".into(), "label".into(), vec![], None, vec![], 0), + LocalNetwork::new( + "id".into(), + "label".into(), + vec!["".into()], + None, + vec![], + 0, + ), + LocalNetwork::new( + "id".into(), + "label".into(), + vec![format!("wss://{}", "r".repeat(RELAY_URL_MAX_BYTES))], + None, + vec![], + 0, + ), + LocalNetwork::new( + "id".into(), + "label".into(), + vec!["wss://relay example".into()], + None, + vec![], + 0, + ), + LocalNetwork::new( + "id".into(), + "label".into(), + vec!["wss://relay\u{7f}".into()], + None, + vec![], + 0, + ), + LocalNetwork::new( + "id".into(), + "label".into(), + vec!["https://r".into()], + None, + vec![], + 0, + ), + LocalNetwork::new( + "id".into(), + "label".into(), + vec!["wss://".into()], + None, + vec![], + 0, + ), + LocalNetwork::new( + "id".into(), + "label".into(), + vec!["wss://user@relay.example".into()], + None, + vec![], + 0, + ), + LocalNetwork::new( + "id".into(), + "label".into(), + vec!["wss://relay.example#fragment".into()], + None, + vec![], + 0, + ), + LocalNetwork::new( + "id".into(), + "label".into(), + vec!["wss://r".into(), "wss://r".into()], + None, + vec![], + 0, + ), + LocalNetwork::new( + "id".into(), + "label".into(), + vec!["wss://r".into()], + None, + vec!["A".repeat(64)], + 0, + ), + LocalNetwork::new( + "id".into(), + "label".into(), + vec!["wss://r".into()], + None, + vec!["a".repeat(63)], + 0, + ), + LocalNetwork::new( + "id".into(), + "label".into(), + vec!["wss://r".into()], + None, + vec!["a".repeat(64), "a".repeat(64)], + 0, + ), + LocalNetwork::new( + " id ".into(), + "label".into(), + vec!["wss://r".into()], + None, + vec![], + 0, + ), + LocalNetwork::new( + "i".repeat(CONTEXT_TEXT_MAX_BYTES + 1), + "label".into(), + vec!["wss://r".into()], + None, + vec![], + 0, + ), + LocalNetwork::new( + "id".into(), + "la\u{7f}bel".into(), + vec!["wss://r".into()], + None, + vec![], + 0, + ), + ] { + assert!(invalid.is_err()); + } + let canonical = LocalNetwork::new( + "id".into(), + "label".into(), + vec!["WSS://RELAY.EXAMPLE:443/".into()], + None, + vec![], + 0, + ) + .expect("canonical relay"); + assert_eq!(canonical.relay_urls, vec!["wss://relay.example"]); + assert!(matches!( + LocalNetwork::new( + "id".into(), + "label".into(), + vec![ + "wss://relay.example".into(), + "WSS://RELAY.EXAMPLE:443/".into(), + ], + None, + vec![], + 0, + ), + Err(LocalNetworkError::DuplicateRelay) + )); + assert!(matches!( + LocalNetwork::new( + "id".into(), + "label".into(), + vec!["wss://127.0.0.1:7447".into()], + None, + vec![], + 0, + ), + Err(LocalNetworkError::InvalidRelay) + )); + assert!( + LocalNetwork::new_for_relay_policy( + "id".into(), + "label".into(), + vec!["ws://127.0.0.1:7447".into()], + None, + vec![], + 0, + LocalNetworkRelayPolicy::Simulator, + ) + .is_ok() + ); + assert!( + LocalNetwork::new_for_relay_policy( + "id".into(), + "label".into(), + vec!["wss://192.168.1.7:7447".into()], + None, + vec![], + 0, + LocalNetworkRelayPolicy::Device, + ) + .is_ok() + ); + assert!( + LocalNetwork::new_for_relay_policy( + "id".into(), + "label".into(), + vec!["ws://192.168.1.7:7447".into()], + None, + vec![], + 0, + LocalNetworkRelayPolicy::Device, + ) + .is_ok() + ); + for denied in [ + "wss://relay.example", + "ws://127.0.0.1:7447", + "ws://169.254.1.7:7447", + "ws://8.8.8.8:7447", + ] { + assert!(matches!( + LocalNetwork::new_for_relay_policy( + "id".into(), + "label".into(), + vec![denied.into()], + None, + vec![], + 0, + LocalNetworkRelayPolicy::Device, + ), + Err(LocalNetworkError::InvalidRelay) + )); + } + } +} diff --git a/core/crates/tera_core/src/runtime/product_surface/cursor.rs b/core/crates/tera_core/src/runtime/product_surface/cursor.rs @@ -0,0 +1,457 @@ +use sha2::{Digest, Sha256}; +use thiserror::Error; + +use super::{CardId, ContextRank, TODAY_RANK_SCHEMA_VERSION, TodayRank}; +use crate::runtime::product_surface::ranking::TODAY_RANK_ALGORITHM_VERSION; + +const CURSOR_PREFIX: &str = "rrtc1:"; +const CURSOR_DOMAIN: &[u8] = b"radroots.today-cursor.v1\0"; +const CURSOR_SCHEMA_VERSION: u16 = 1; +const MAX_CONTEXT_ID_BYTES: usize = 256; +const FIXED_PAYLOAD_BYTES: usize = 2 + 2 + 2 + 2 + 8 + 8 + 32 + 8 + 1 + 1 + 8 + 32; +const DIGEST_BYTES: usize = 32; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct CursorScope { + pub context_id: String, + pub context_generation: u64, + pub as_of: u64, + pub store_generation: [u8; 32], + pub projection_generation: u64, +} + +impl CursorScope { + pub fn new( + context_id: String, + context_generation: u64, + as_of: u64, + store_generation: [u8; 32], + projection_generation: u64, + ) -> Result<Self, CursorError> { + validate_context_id(&context_id)?; + Ok(Self { + context_id, + context_generation, + as_of, + store_generation, + projection_generation, + }) + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct TodayCursorPosition { + pub rank: TodayRank, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct TodayCursor(String); + +#[derive(Clone, Copy, Debug, Error, Eq, PartialEq)] +pub enum CursorError { + #[error("today cursor context id is invalid")] + InvalidContext, + #[error("today cursor encoding is malformed")] + Malformed, + #[error("today cursor integrity check failed")] + Integrity, + #[error("today cursor version is unsupported")] + Version, + #[error("today cursor belongs to another context")] + ContextMismatch, + #[error("today cursor belongs to another frozen snapshot")] + SnapshotMismatch, + #[error("today cursor belongs to a retired store or projection generation")] + Stale, + #[error("today cursor position is invalid")] + InvalidPosition, +} + +impl TodayCursor { + pub fn encode(scope: &CursorScope, position: TodayCursorPosition) -> Result<Self, CursorError> { + if position.rank.schema_version != TODAY_RANK_SCHEMA_VERSION + || position.rank.algorithm_version != TODAY_RANK_ALGORITHM_VERSION + { + return Err(CursorError::Version); + } + if position.rank.time_relevance_rank > 4 { + return Err(CursorError::InvalidPosition); + } + let context_bytes = scope.context_id.as_bytes(); + let mut payload = Vec::with_capacity(FIXED_PAYLOAD_BYTES + context_bytes.len()); + payload.extend_from_slice(&CURSOR_SCHEMA_VERSION.to_be_bytes()); + payload.extend_from_slice(&TODAY_RANK_SCHEMA_VERSION.to_be_bytes()); + payload.extend_from_slice(&TODAY_RANK_ALGORITHM_VERSION.to_be_bytes()); + payload.extend_from_slice( + &u16::try_from(context_bytes.len()) + .expect("validated context length fits u16") + .to_be_bytes(), + ); + payload.extend_from_slice(context_bytes); + payload.extend_from_slice(&scope.context_generation.to_be_bytes()); + payload.extend_from_slice(&scope.as_of.to_be_bytes()); + payload.extend_from_slice(&scope.store_generation); + payload.extend_from_slice(&scope.projection_generation.to_be_bytes()); + payload.push(position.rank.context_rank.value()); + payload.push(position.rank.time_relevance_rank); + payload.extend_from_slice(&position.rank.effective_at.to_be_bytes()); + payload.extend_from_slice(position.rank.card_id.as_bytes()); + let digest = cursor_digest(&payload); + payload.extend_from_slice(&digest); + Ok(Self(format!("{CURSOR_PREFIX}{}", hex::encode(payload)))) + } + + pub fn decode(value: &str, expected: &CursorScope) -> Result<TodayCursorPosition, CursorError> { + let (scope, position) = decode_unbound(value)?; + if scope.context_id != expected.context_id + || scope.context_generation != expected.context_generation + { + return Err(CursorError::ContextMismatch); + } + if scope.as_of != expected.as_of { + return Err(CursorError::SnapshotMismatch); + } + if scope.store_generation != expected.store_generation + || scope.projection_generation != expected.projection_generation + { + return Err(CursorError::Stale); + } + Ok(position) + } + + /// Recovers the integrity-checked frozen scope carried by an opaque cursor. + pub fn scope(value: &str) -> Result<CursorScope, CursorError> { + decode_unbound(value).map(|(scope, _)| scope) + } + + pub fn as_str(&self) -> &str { + &self.0 + } +} + +fn decode_unbound(value: &str) -> Result<(CursorScope, TodayCursorPosition), CursorError> { + let encoded = value + .strip_prefix(CURSOR_PREFIX) + .ok_or(CursorError::Malformed)?; + if encoded.len() % 2 != 0 + || !encoded + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) + { + return Err(CursorError::Malformed); + } + let bytes = hex::decode(encoded).map_err(|_| CursorError::Malformed)?; + if bytes.len() < FIXED_PAYLOAD_BYTES + DIGEST_BYTES { + return Err(CursorError::Malformed); + } + let (payload, observed_digest) = bytes.split_at(bytes.len() - DIGEST_BYTES); + if cursor_digest(payload).as_slice() != observed_digest { + return Err(CursorError::Integrity); + } + decode_payload(payload) +} + +fn decode_payload(payload: &[u8]) -> Result<(CursorScope, TodayCursorPosition), CursorError> { + let mut decoder = Decoder::new(payload); + let cursor_version = decoder.u16()?; + let rank_schema_version = decoder.u16()?; + let rank_algorithm_version = decoder.u16()?; + if cursor_version != CURSOR_SCHEMA_VERSION + || rank_schema_version != TODAY_RANK_SCHEMA_VERSION + || rank_algorithm_version != TODAY_RANK_ALGORITHM_VERSION + { + return Err(CursorError::Version); + } + let context_len = usize::from(decoder.u16()?); + let context_id = + core::str::from_utf8(decoder.bytes(context_len)?).map_err(|_| CursorError::Malformed)?; + validate_context_id(context_id)?; + let context_generation = decoder.u64()?; + let as_of = decoder.u64()?; + let store_generation = decoder.array_32()?; + let projection_generation = decoder.u64()?; + let context_rank = ContextRank::from_value(decoder.u8()?).ok_or(CursorError::Malformed)?; + let time_relevance_rank = decoder.u8()?; + if time_relevance_rank > 4 { + return Err(CursorError::Malformed); + } + let effective_at = decoder.u64()?; + let card_id = + CardId::parse(&hex::encode(decoder.array_32()?)).map_err(|_| CursorError::Malformed)?; + if !decoder.is_finished() { + return Err(CursorError::Malformed); + } + Ok(( + CursorScope { + context_id: context_id.to_owned(), + context_generation, + as_of, + store_generation, + projection_generation, + }, + TodayCursorPosition { + rank: TodayRank { + schema_version: rank_schema_version, + algorithm_version: rank_algorithm_version, + context_rank, + time_relevance_rank, + effective_at, + card_id, + }, + }, + )) +} + +fn validate_context_id(value: &str) -> Result<(), CursorError> { + if value.is_empty() + || value.len() > MAX_CONTEXT_ID_BYTES + || value.trim() != value + || value.chars().any(char::is_control) + { + return Err(CursorError::InvalidContext); + } + Ok(()) +} + +fn cursor_digest(payload: &[u8]) -> [u8; 32] { + let mut digest = Sha256::new(); + digest.update(CURSOR_DOMAIN); + digest.update(payload); + digest.finalize().into() +} + +struct Decoder<'a> { + remaining: &'a [u8], +} + +impl<'a> Decoder<'a> { + const fn new(value: &'a [u8]) -> Self { + Self { remaining: value } + } + + fn bytes(&mut self, length: usize) -> Result<&'a [u8], CursorError> { + if self.remaining.len() < length { + return Err(CursorError::Malformed); + } + let (value, remaining) = self.remaining.split_at(length); + self.remaining = remaining; + Ok(value) + } + + fn u8(&mut self) -> Result<u8, CursorError> { + Ok(self.bytes(1)?[0]) + } + + fn u16(&mut self) -> Result<u16, CursorError> { + Ok(u16::from_be_bytes( + self.bytes(2)?.try_into().expect("exact length"), + )) + } + + fn u64(&mut self) -> Result<u64, CursorError> { + Ok(u64::from_be_bytes( + self.bytes(8)?.try_into().expect("exact length"), + )) + } + + fn array_32(&mut self) -> Result<[u8; 32], CursorError> { + Ok(self.bytes(32)?.try_into().expect("exact length")) + } + + const fn is_finished(&self) -> bool { + self.remaining.is_empty() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn scope() -> CursorScope { + CursorScope::new("nearby".into(), 4, 2_000_000_000, [7; 32], 9).expect("scope") + } + + fn position() -> TodayCursorPosition { + TodayCursorPosition { + rank: TodayRank { + schema_version: TODAY_RANK_SCHEMA_VERSION, + algorithm_version: TODAY_RANK_ALGORITHM_VERSION, + context_rank: ContextRank::LocalityMatch, + time_relevance_rank: 3, + effective_at: 1_999_999_000, + card_id: CardId::parse(&"a".repeat(64)).expect("card"), + }, + } + } + + fn payload(cursor: &TodayCursor) -> Vec<u8> { + let bytes = + hex::decode(cursor.as_str().strip_prefix(CURSOR_PREFIX).expect("prefix")).expect("hex"); + bytes[..bytes.len() - DIGEST_BYTES].to_vec() + } + + fn signed_payload(mut payload: Vec<u8>) -> String { + payload.extend_from_slice(&cursor_digest(&payload)); + format!("{CURSOR_PREFIX}{}", hex::encode(payload)) + } + + #[test] + fn cursor_vector_round_trips_and_is_fixed() { + let cursor = TodayCursor::encode(&scope(), position()).expect("cursor"); + assert_eq!( + cursor.as_str(), + "rrtc1:00010001000100066e6561726279000000000000000400000000773594000707070707070707070707070707070707070707070707070707070707070707000000000000000902030000000077359018aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaedf305be41633dfc2f7d621e067c3d33a71c3548c6a1fcf68a6707a1d8664b11" + ); + assert_eq!( + TodayCursor::decode(cursor.as_str(), &scope()).expect("decode"), + position() + ); + assert_eq!(TodayCursor::scope(cursor.as_str()).expect("scope"), scope()); + } + + #[test] + fn cursor_rejects_tamper_context_snapshot_and_stale_generations() { + let cursor = TodayCursor::encode(&scope(), position()).expect("cursor"); + let mut tampered = cursor.as_str().as_bytes().to_vec(); + *tampered.last_mut().expect("byte") = b'0'; + assert_eq!( + TodayCursor::decode(core::str::from_utf8(&tampered).expect("utf8"), &scope()), + Err(CursorError::Integrity) + ); + let other_context = + CursorScope::new("other".into(), 4, 2_000_000_000, [7; 32], 9).expect("scope"); + assert_eq!( + TodayCursor::decode(cursor.as_str(), &other_context), + Err(CursorError::ContextMismatch) + ); + let other_context_generation = + CursorScope::new("nearby".into(), 5, 2_000_000_000, [7; 32], 9).expect("scope"); + assert_eq!( + TodayCursor::decode(cursor.as_str(), &other_context_generation), + Err(CursorError::ContextMismatch) + ); + let other_snapshot = + CursorScope::new("nearby".into(), 4, 2_000_000_001, [7; 32], 9).expect("scope"); + assert_eq!( + TodayCursor::decode(cursor.as_str(), &other_snapshot), + Err(CursorError::SnapshotMismatch) + ); + let stale = CursorScope::new("nearby".into(), 4, 2_000_000_000, [8; 32], 9).expect("scope"); + assert_eq!( + TodayCursor::decode(cursor.as_str(), &stale), + Err(CursorError::Stale) + ); + let stale_projection = + CursorScope::new("nearby".into(), 4, 2_000_000_000, [7; 32], 10).expect("scope"); + assert_eq!( + TodayCursor::decode(cursor.as_str(), &stale_projection), + Err(CursorError::Stale) + ); + } + + #[test] + fn malformed_and_versioned_cursor_inputs_fail_closed() { + assert_eq!( + TodayCursor::decode("nope", &scope()), + Err(CursorError::Malformed) + ); + for malformed in ["rrtc1:0", "rrtc1:GG", "rrtc1:00"] { + assert_eq!( + TodayCursor::decode(malformed, &scope()), + Err(CursorError::Malformed) + ); + } + assert_eq!( + TodayCursor::decode( + &TodayCursor::encode(&scope(), position()) + .expect("cursor") + .as_str() + .to_uppercase(), + &scope() + ), + Err(CursorError::Malformed) + ); + assert!(CursorScope::new("".into(), 0, 0, [0; 32], 0).is_err()); + assert!(CursorScope::new("x".repeat(257), 0, 0, [0; 32], 0).is_err()); + assert!(CursorScope::new(" nearby ".into(), 0, 0, [0; 32], 0).is_err()); + assert!(CursorScope::new("near\u{7f}by".into(), 0, 0, [0; 32], 0).is_err()); + let cursor = TodayCursor::encode(&scope(), position()).expect("cursor"); + for version_offset in [1, 3, 5] { + let mut unsupported = payload(&cursor); + unsupported[version_offset] = 2; + assert_eq!( + TodayCursor::decode(&signed_payload(unsupported), &scope()), + Err(CursorError::Version) + ); + } + let mut invalid_utf8 = payload(&cursor); + invalid_utf8[8] = 0xff; + assert_eq!( + TodayCursor::decode(&signed_payload(invalid_utf8), &scope()), + Err(CursorError::Malformed) + ); + let mut invalid_context = payload(&cursor); + invalid_context[8] = b' '; + assert_eq!( + TodayCursor::decode(&signed_payload(invalid_context), &scope()), + Err(CursorError::InvalidContext) + ); + let mut trailing = payload(&cursor); + trailing.push(0); + assert_eq!( + TodayCursor::decode(&signed_payload(trailing), &scope()), + Err(CursorError::Malformed) + ); + let mut invalid_context_rank = payload(&cursor); + invalid_context_rank[70] = 3; + assert_eq!( + TodayCursor::decode(&signed_payload(invalid_context_rank), &scope()), + Err(CursorError::Malformed) + ); + let mut invalid_time_rank = payload(&cursor); + invalid_time_rank[71] = 5; + assert_eq!( + TodayCursor::decode(&signed_payload(invalid_time_rank), &scope()), + Err(CursorError::Malformed) + ); + let mut truncated_field = vec![0; FIXED_PAYLOAD_BYTES]; + truncated_field[1] = 1; + truncated_field[3] = 1; + truncated_field[5] = 1; + truncated_field[6] = 1; + assert_eq!( + TodayCursor::decode(&signed_payload(truncated_field), &scope()), + Err(CursorError::Malformed) + ); + let invalid_version = TodayCursorPosition { + rank: TodayRank { + schema_version: 2, + ..position().rank + }, + }; + assert_eq!( + TodayCursor::encode(&scope(), invalid_version), + Err(CursorError::Version) + ); + let invalid_algorithm = TodayCursorPosition { + rank: TodayRank { + algorithm_version: 2, + ..position().rank + }, + }; + assert_eq!( + TodayCursor::encode(&scope(), invalid_algorithm), + Err(CursorError::Version) + ); + let invalid_rank = TodayCursorPosition { + rank: TodayRank { + time_relevance_rank: 5, + ..position().rank + }, + }; + assert_eq!( + TodayCursor::encode(&scope(), invalid_rank), + Err(CursorError::InvalidPosition) + ); + } +} diff --git a/core/crates/tera_core/src/runtime/product_surface/identity.rs b/core/crates/tera_core/src/runtime/product_surface/identity.rs @@ -0,0 +1,185 @@ +use core::fmt; + +use radroots_event::EventId; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use thiserror::Error; + +use super::TodayCardType; + +pub const CARD_ID_SCHEMA_VERSION: u16 = 1; +const CARD_ID_DOMAIN: &[u8] = b"radroots.today-card.v1\0"; + +/// Canonical source identity used to derive a stable card identifier. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum CardSourceIdentity { + Event(EventId), + Address { + kind: u32, + author_pubkey: String, + identifier: String, + }, +} + +#[derive(Clone, Debug, Error, Eq, PartialEq)] +pub enum CardIdError { + #[error("card address kind must be parameterized replaceable")] + InvalidAddressKind, + #[error("card address author must be canonical lowercase hexadecimal")] + InvalidAuthor, + #[error("card address identifier is invalid")] + InvalidIdentifier, + #[error("card identifier must be 64 lowercase hexadecimal characters")] + InvalidCardId, +} + +impl CardSourceIdentity { + pub fn address( + kind: u32, + author_pubkey: impl Into<String>, + identifier: impl Into<String>, + ) -> Result<Self, CardIdError> { + if !(30_000..40_000).contains(&kind) { + return Err(CardIdError::InvalidAddressKind); + } + let author_pubkey = author_pubkey.into(); + if author_pubkey.len() != 64 + || !author_pubkey + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) + { + return Err(CardIdError::InvalidAuthor); + } + let identifier = identifier.into(); + if identifier.is_empty() + || identifier.len() > 512 + || identifier.chars().any(char::is_control) + { + return Err(CardIdError::InvalidIdentifier); + } + Ok(Self::Address { + kind, + author_pubkey, + identifier, + }) + } + + pub fn canonical_string(&self) -> String { + match self { + Self::Event(event_id) => format!("event:{}", event_id.to_hex()), + Self::Address { + kind, + author_pubkey, + identifier, + } => format!("address:{kind}:{author_pubkey}:{identifier}"), + } + } +} + +/// Lowercase SHA-256 stable identity for one top-level Today card. +#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] +pub struct CardId([u8; 32]); + +impl CardId { + pub fn derive(card_type: TodayCardType, source: &CardSourceIdentity) -> Self { + let mut digest = Sha256::new(); + digest.update(CARD_ID_DOMAIN); + digest.update(card_type.label().as_bytes()); + digest.update(b"\0"); + digest.update(source.canonical_string().as_bytes()); + Self(digest.finalize().into()) + } + + pub fn parse(value: &str) -> Result<Self, CardIdError> { + if value.len() != 64 + || !value + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) + { + return Err(CardIdError::InvalidCardId); + } + let mut bytes = [0; 32]; + hex::decode_to_slice(value, &mut bytes).map_err(|_| CardIdError::InvalidCardId)?; + Ok(Self(bytes)) + } + + pub const fn as_bytes(&self) -> &[u8; 32] { + &self.0 + } + + pub fn to_hex(self) -> String { + hex::encode(self.0) + } +} + +impl fmt::Display for CardId { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(&hex::encode(self.0)) + } +} + +impl Serialize for CardId { + fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error> + where + S: serde::Serializer, + { + serializer.serialize_str(&self.to_hex()) + } +} + +impl<'de> Deserialize<'de> for CardId { + fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> + where + D: serde::Deserializer<'de>, + { + let value = String::deserialize(deserializer)?; + Self::parse(&value).map_err(serde::de::Error::custom) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn stable_card_id_vectors_cover_regular_and_addressable_sources() { + let event = EventId::parse("a".repeat(64)).expect("event"); + assert_eq!( + CardId::derive(TodayCardType::Update, &CardSourceIdentity::Event(event)).to_hex(), + "36bf89dc7a6759143986b1f339870ec792f7bee865c9738b0adb50ac9c5197be" + ); + let address = CardSourceIdentity::address(31_923, "b".repeat(64), "farmers-market-2026") + .expect("address"); + assert_eq!( + CardId::derive(TodayCardType::Event, &address).to_hex(), + "75f6127161783c583368b6c76ed779ae5e02cd7bc9f71ef55ff39e32a59274fc" + ); + let replacement = CardId::derive(TodayCardType::Event, &address); + assert_eq!(replacement, CardId::derive(TodayCardType::Event, &address)); + } + + #[test] + fn card_identity_rejects_noncanonical_addresses_and_ids() { + assert!(CardSourceIdentity::address(1, "a".repeat(64), "id").is_err()); + assert!(CardSourceIdentity::address(40_000, "a".repeat(64), "id").is_err()); + assert!(CardSourceIdentity::address(30_402, "a".repeat(63), "id").is_err()); + assert!(CardSourceIdentity::address(30_402, "A".repeat(64), "id").is_err()); + assert!(CardSourceIdentity::address(30_402, "a".repeat(64), "").is_err()); + assert!(CardSourceIdentity::address(30_402, "a".repeat(64), "i".repeat(513)).is_err()); + assert!(CardSourceIdentity::address(30_402, "a".repeat(64), "bad\nid").is_err()); + let opaque = CardSourceIdentity::address(31_923, "a".repeat(64), " market day ") + .expect("opaque d value"); + assert!(opaque.canonical_string().ends_with(": market day ")); + assert!(CardId::parse(&"a".repeat(63)).is_err()); + assert!(CardId::parse(&"A".repeat(64)).is_err()); + + let card = CardId::parse(&"c".repeat(64)).expect("card"); + assert_eq!(card.to_string(), "c".repeat(64)); + let encoded = serde_json::to_string(&card).expect("serialize"); + assert_eq!( + serde_json::from_str::<CardId>(&encoded).expect("deserialize"), + card + ); + assert!(serde_json::from_str::<CardId>(&format!("\"{}\"", "G".repeat(64))).is_err()); + } +} diff --git a/core/crates/tera_core/src/runtime/product_surface/media.rs b/core/crates/tera_core/src/runtime/product_surface/media.rs @@ -0,0 +1,2172 @@ +//! Typed inbound-media trust, receipt, and bounded cache metadata. +//! +//! Structural Nostr references are intentionally distinct from locally +//! verified artifacts. A caller cannot represent renderable media with a URL +//! and a boolean: the `Verified` state always contains a receipt derived from +//! an actual byte commitment and bound to the active retrieval configuration. + +use std::collections::BTreeMap; +#[cfg(feature = "mobile-social")] +use std::path::{Path, PathBuf}; + +use radroots_blossom::{BlobUrl, MediaType, Sha256, descriptor::ByteCommitment}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256 as Sha256Hasher}; +use thiserror::Error; +#[cfg(feature = "mobile-social")] +use tokio::io::AsyncWriteExt; + +const MEDIA_REFERENCE_SCHEMA_VERSION: u16 = 1; +const MEDIA_RECEIPT_SCHEMA_VERSION: u16 = 1; +const MEDIA_CACHE_SCHEMA_VERSION: u16 = 1; +const MEDIA_URL_MAX_BYTES: usize = 8_192; +const MEDIA_ALT_MAX_BYTES: usize = 2_048; +const MEDIA_FAILURE_CODE_MAX_BYTES: usize = 96; +const MEDIA_DIMENSION_MAX_EDGE: u32 = 16_384; +const MEDIA_DIMENSION_MAX_PIXELS: u64 = 100_000_000; +const MEDIA_REFERENCE_FINGERPRINT_DOMAIN: &[u8] = b"radroots.inbound-media-reference.v1\0"; +#[cfg(feature = "mobile-social")] +const MEDIA_CACHE_EXTENSIONS: &[&str] = &["gif", "jpg", "png", "webp"]; + +#[derive(Clone, Copy, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)] +#[serde(transparent)] +pub struct Phase1MediaConfigurationFingerprint([u8; 32]); + +impl Phase1MediaConfigurationFingerprint { + pub fn new(value: [u8; 32]) -> Result<Self, Phase1InboundMediaError> { + (value != [0; 32]) + .then_some(Self(value)) + .ok_or(Phase1InboundMediaError::InvalidConfiguration) + } + + pub fn parse(value: &str) -> Result<Self, Phase1InboundMediaError> { + let decoded = + hex::decode(value).map_err(|_| Phase1InboundMediaError::InvalidConfiguration)?; + let bytes: [u8; 32] = decoded + .try_into() + .map_err(|_| Phase1InboundMediaError::InvalidConfiguration)?; + Self::new(bytes) + } + + pub const fn as_bytes(&self) -> &[u8; 32] { + &self.0 + } + + pub fn to_hex(self) -> String { + hex::encode(self.0) + } + + fn validate(self) -> Result<(), Phase1InboundMediaError> { + Self::new(self.0).map(|_| ()) + } +} + +#[derive(Clone, Copy, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)] +#[serde(transparent)] +pub struct Phase1MediaArtifactId([u8; 32]); + +impl Phase1MediaArtifactId { + pub fn parse(value: &str) -> Result<Self, Phase1InboundMediaError> { + let hash = Sha256::from_hex(value).map_err(|_| Phase1InboundMediaError::InvalidDigest)?; + Ok(Self(*hash.as_bytes())) + } + + pub const fn from_sha256(value: Sha256) -> Self { + Self(*value.as_bytes()) + } + + pub const fn as_bytes(&self) -> &[u8; 32] { + &self.0 + } + + pub fn to_hex(self) -> String { + hex::encode(self.0) + } +} + +/// Signed-event media facts. These facts do not imply that any bytes were +/// fetched, trusted, stored, or rendered. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct Phase1StructuralMediaReference { + schema_version: u16, + source_url: String, + expected_sha256: Option<String>, + expected_media_type: Option<String>, + expected_width: Option<u32>, + expected_height: Option<u32>, + expected_byte_size: Option<u64>, + alt: Option<String>, + fingerprint: [u8; 32], +} + +impl Phase1StructuralMediaReference { + #[allow(clippy::too_many_arguments)] + pub fn new( + source_url: impl Into<String>, + expected_sha256: Option<String>, + expected_media_type: Option<String>, + expected_width: Option<u32>, + expected_height: Option<u32>, + expected_byte_size: Option<u64>, + alt: Option<String>, + ) -> Result<Self, Phase1InboundMediaError> { + let source_url = source_url.into(); + validate_url_text(&source_url)?; + let parsed = + url::Url::parse(&source_url).map_err(|_| Phase1InboundMediaError::InvalidReference)?; + if !matches!(parsed.scheme(), "http" | "https") + || parsed.host_str().is_none() + || !parsed.username().is_empty() + || parsed.password().is_some() + || parsed.as_str() != source_url + { + return Err(Phase1InboundMediaError::InvalidReference); + } + let path_digest = BlobUrl::parse(&source_url) + .ok() + .map(|value| value.hash_path().hash().to_hex()); + let expected_sha256 = match expected_sha256 { + Some(value) => { + let digest = + Sha256::from_hex(&value).map_err(|_| Phase1InboundMediaError::InvalidDigest)?; + if digest.to_hex() != value + || path_digest + .as_deref() + .is_some_and(|path| digest.to_hex() != path) + { + return Err(Phase1InboundMediaError::MetadataMismatch); + } + Some(value) + } + None => path_digest, + }; + let expected_media_type = expected_media_type + .map(|value| { + MediaType::parse(&value) + .map(|parsed| parsed.to_string()) + .map_err(|_| Phase1InboundMediaError::InvalidMediaType) + }) + .transpose()?; + validate_dimensions(expected_width, expected_height)?; + if expected_byte_size == Some(0) { + return Err(Phase1InboundMediaError::InvalidByteSize); + } + if alt.as_deref().is_some_and(|value| { + value.len() > MEDIA_ALT_MAX_BYTES || value.chars().any(char::is_control) + }) { + return Err(Phase1InboundMediaError::InvalidAlt); + } + let mut value = Self { + schema_version: MEDIA_REFERENCE_SCHEMA_VERSION, + source_url: parsed.to_string(), + expected_sha256, + expected_media_type, + expected_width, + expected_height, + expected_byte_size, + alt, + fingerprint: [0; 32], + }; + value.fingerprint = value.derive_fingerprint(); + Ok(value) + } + + fn validate(&self) -> Result<(), Phase1InboundMediaError> { + if self.schema_version != MEDIA_REFERENCE_SCHEMA_VERSION { + return Err(Phase1InboundMediaError::UnsupportedSchema); + } + let canonical = Self::new( + self.source_url.clone(), + self.expected_sha256.clone(), + self.expected_media_type.clone(), + self.expected_width, + self.expected_height, + self.expected_byte_size, + self.alt.clone(), + )?; + (canonical == *self) + .then_some(()) + .ok_or(Phase1InboundMediaError::CorruptState) + } + + fn derive_fingerprint(&self) -> [u8; 32] { + let mut digest = Sha256Hasher::new(); + digest.update(MEDIA_REFERENCE_FINGERPRINT_DOMAIN); + digest.update(self.source_url.as_bytes()); + update_optional(&mut digest, self.expected_sha256.as_deref()); + update_optional(&mut digest, self.expected_media_type.as_deref()); + update_optional_u64(&mut digest, self.expected_width.map(u64::from)); + update_optional_u64(&mut digest, self.expected_height.map(u64::from)); + update_optional_u64(&mut digest, self.expected_byte_size); + update_optional(&mut digest, self.alt.as_deref()); + digest.finalize().into() + } + + pub fn source_url(&self) -> &str { + self.source_url.as_str() + } + + pub fn expected_sha256(&self) -> Option<&str> { + self.expected_sha256.as_deref() + } + + pub fn expected_media_type(&self) -> Option<&str> { + self.expected_media_type.as_deref() + } + + pub const fn expected_width(&self) -> Option<u32> { + self.expected_width + } + + pub const fn expected_height(&self) -> Option<u32> { + self.expected_height + } + + pub const fn expected_byte_size(&self) -> Option<u64> { + self.expected_byte_size + } + + pub fn alt(&self) -> Option<&str> { + self.alt.as_deref() + } + + pub const fn fingerprint(&self) -> &[u8; 32] { + &self.fingerprint + } +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct Phase1InboundMediaPending { + operation_id: [u8; 16], + configuration: Phase1MediaConfigurationFingerprint, + started_at_unix_ms: u64, +} + +impl Phase1InboundMediaPending { + pub fn new( + operation_id: [u8; 16], + configuration: Phase1MediaConfigurationFingerprint, + started_at_unix_ms: u64, + ) -> Result<Self, Phase1InboundMediaError> { + configuration.validate()?; + if operation_id == [0; 16] || started_at_unix_ms == 0 { + return Err(Phase1InboundMediaError::InvalidOperation); + } + Ok(Self { + operation_id, + configuration, + started_at_unix_ms, + }) + } + + pub const fn operation_id(&self) -> &[u8; 16] { + &self.operation_id + } + + pub const fn configuration(&self) -> Phase1MediaConfigurationFingerprint { + self.configuration + } + + pub const fn started_at_unix_ms(&self) -> u64 { + self.started_at_unix_ms + } + + fn validate(&self) -> Result<(), Phase1InboundMediaError> { + Self::new( + self.operation_id, + self.configuration, + self.started_at_unix_ms, + ) + .map(|_| ()) + } +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct Phase1InboundMediaFailure { + operation_id: [u8; 16], + safe_code: String, + retryable: bool, + failed_at_unix_ms: u64, +} + +impl Phase1InboundMediaFailure { + pub fn new( + operation_id: [u8; 16], + safe_code: impl Into<String>, + retryable: bool, + failed_at_unix_ms: u64, + ) -> Result<Self, Phase1InboundMediaError> { + let safe_code = safe_code.into(); + if operation_id == [0; 16] + || failed_at_unix_ms == 0 + || safe_code.is_empty() + || safe_code.len() > MEDIA_FAILURE_CODE_MAX_BYTES + || !safe_code + .bytes() + .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'_') + { + return Err(Phase1InboundMediaError::InvalidFailure); + } + Ok(Self { + operation_id, + safe_code, + retryable, + failed_at_unix_ms, + }) + } + + pub fn safe_code(&self) -> &str { + self.safe_code.as_str() + } + + pub const fn retryable(&self) -> bool { + self.retryable + } + + fn validate(&self) -> Result<(), Phase1InboundMediaError> { + Self::new( + self.operation_id, + self.safe_code.clone(), + self.retryable, + self.failed_at_unix_ms, + ) + .map(|_| ()) + } +} + +/// Exact-byte verification evidence. Construction requires a byte commitment, +/// binds every signed expected field, and derives the artifact identity from +/// the observed digest rather than caller input. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct Phase1VerifiedMediaReceipt { + schema_version: u16, + reference_fingerprint: [u8; 32], + source_url: String, + canonical_final_url: String, + expected_sha256: String, + observed_sha256: String, + byte_size: u64, + media_type: String, + extension: String, + width: u32, + height: u32, + artifact_id: Phase1MediaArtifactId, + configuration: Phase1MediaConfigurationFingerprint, + verified_at_unix_ms: u64, +} + +impl Phase1VerifiedMediaReceipt { + pub fn from_commitment( + reference: &Phase1StructuralMediaReference, + canonical_final_url: BlobUrl, + commitment: &ByteCommitment, + width: u32, + height: u32, + configuration: Phase1MediaConfigurationFingerprint, + verified_at_unix_ms: u64, + ) -> Result<Self, Phase1InboundMediaError> { + reference.validate()?; + configuration.validate()?; + if verified_at_unix_ms == 0 { + return Err(Phase1InboundMediaError::InvalidVerificationTime); + } + BlobUrl::parse(reference.source_url()) + .and_then(BlobUrl::approve) + .map_err(|_| Phase1InboundMediaError::InvalidReference)?; + canonical_final_url + .clone() + .approve() + .map_err(|_| Phase1InboundMediaError::InvalidReference)?; + validate_dimensions(Some(width), Some(height))?; + let observed_sha256 = commitment.sha256().to_hex(); + let expected_sha256 = reference + .expected_sha256() + .ok_or(Phase1InboundMediaError::MissingDigest)?; + if observed_sha256 != expected_sha256 + || reference + .expected_byte_size() + .is_some_and(|value| value != commitment.size()) + || reference + .expected_media_type() + .is_some_and(|value| value != commitment.media_type().to_string()) + || reference + .expected_width() + .is_some_and(|value| value != width) + || reference + .expected_height() + .is_some_and(|value| value != height) + { + return Err(Phase1InboundMediaError::MetadataMismatch); + } + let extension = canonical_final_url + .hash_path() + .extension() + .ok_or(Phase1InboundMediaError::InvalidReference)? + .as_str() + .to_owned(); + if canonical_extension(commitment.media_type()) != Some(extension.as_str()) { + return Err(Phase1InboundMediaError::MetadataMismatch); + } + if canonical_final_url.hash_path().hash() != commitment.sha256() { + return Err(Phase1InboundMediaError::MetadataMismatch); + } + let receipt = Self { + schema_version: MEDIA_RECEIPT_SCHEMA_VERSION, + reference_fingerprint: *reference.fingerprint(), + source_url: reference.source_url().to_owned(), + canonical_final_url: canonical_final_url.to_string(), + expected_sha256: expected_sha256.to_owned(), + observed_sha256, + byte_size: commitment.size(), + media_type: commitment.media_type().to_string(), + extension, + width, + height, + artifact_id: Phase1MediaArtifactId::from_sha256(commitment.sha256()), + configuration, + verified_at_unix_ms, + }; + receipt.validate(reference)?; + Ok(receipt) + } + + fn validate( + &self, + reference: &Phase1StructuralMediaReference, + ) -> Result<(), Phase1InboundMediaError> { + self.validate_intrinsic()?; + if self.reference_fingerprint != *reference.fingerprint() + || self.source_url != reference.source_url() + || reference.expected_sha256() != Some(self.expected_sha256.as_str()) + { + return Err(Phase1InboundMediaError::CorruptReceipt); + } + BlobUrl::parse(reference.source_url()) + .and_then(BlobUrl::approve) + .map_err(|_| Phase1InboundMediaError::CorruptReceipt)?; + if reference + .expected_byte_size() + .is_some_and(|value| value != self.byte_size) + || reference + .expected_media_type() + .is_some_and(|value| value != self.media_type) + || reference + .expected_width() + .is_some_and(|value| value != self.width) + || reference + .expected_height() + .is_some_and(|value| value != self.height) + { + return Err(Phase1InboundMediaError::CorruptReceipt); + } + Ok(()) + } + + fn validate_intrinsic(&self) -> Result<(), Phase1InboundMediaError> { + if self.schema_version != MEDIA_RECEIPT_SCHEMA_VERSION + || self.expected_sha256 != self.observed_sha256 + || self.expected_sha256 != self.artifact_id.to_hex() + || self.byte_size == 0 + || self.verified_at_unix_ms == 0 + { + return Err(Phase1InboundMediaError::CorruptReceipt); + } + self.configuration + .validate() + .map_err(|_| Phase1InboundMediaError::CorruptReceipt)?; + let final_url = BlobUrl::parse(&self.canonical_final_url) + .map_err(|_| Phase1InboundMediaError::CorruptReceipt)?; + final_url + .clone() + .approve() + .map_err(|_| Phase1InboundMediaError::CorruptReceipt)?; + if final_url.to_string() != self.canonical_final_url + || final_url.hash_path().hash().to_hex() != self.observed_sha256 + || final_url + .hash_path() + .extension() + .is_none_or(|value| value.as_str() != self.extension) + || !canonical_media_type(&self.media_type) + || MediaType::parse(&self.media_type) + .ok() + .and_then(|value| canonical_extension(&value)) + != Some(self.extension.as_str()) + || validate_dimensions(Some(self.width), Some(self.height)).is_err() + { + return Err(Phase1InboundMediaError::CorruptReceipt); + } + Ok(()) + } + + pub const fn artifact_id(&self) -> Phase1MediaArtifactId { + self.artifact_id + } + + pub const fn configuration(&self) -> Phase1MediaConfigurationFingerprint { + self.configuration + } + + pub fn canonical_final_url(&self) -> &str { + self.canonical_final_url.as_str() + } + + pub fn observed_sha256(&self) -> &str { + self.observed_sha256.as_str() + } + + pub const fn byte_size(&self) -> u64 { + self.byte_size + } + + pub fn media_type(&self) -> &str { + self.media_type.as_str() + } + + pub fn extension(&self) -> &str { + self.extension.as_str() + } + + pub const fn width(&self) -> u32 { + self.width + } + + pub const fn height(&self) -> u32 { + self.height + } + + pub const fn verified_at_unix_ms(&self) -> u64 { + self.verified_at_unix_ms + } +} + +/// One immutable exact-byte artifact in the authenticated user's local cache. +#[cfg(feature = "mobile-social")] +#[derive(Clone, Eq, PartialEq)] +pub struct Phase1LocalMediaArtifact { + artifact_id: Phase1MediaArtifactId, + local_path: PathBuf, + bytes: Vec<u8>, + byte_size: u64, + media_type: String, + width: u32, + height: u32, +} + +#[cfg(feature = "mobile-social")] +impl std::fmt::Debug for Phase1LocalMediaArtifact { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("Phase1LocalMediaArtifact") + .field("artifact_id", &self.artifact_id) + .field("local_path", &"<redacted>") + .field("byte_size", &self.byte_size) + .field("media_type", &self.media_type) + .field("width", &self.width) + .field("height", &self.height) + .finish() + } +} + +#[cfg(feature = "mobile-social")] +impl Phase1LocalMediaArtifact { + pub const fn artifact_id(&self) -> Phase1MediaArtifactId { + self.artifact_id + } + + pub fn local_path(&self) -> &Path { + self.local_path.as_path() + } + + pub fn bytes(&self) -> &[u8] { + self.bytes.as_slice() + } + + pub const fn byte_size(&self) -> u64 { + self.byte_size + } + + pub fn media_type(&self) -> &str { + self.media_type.as_str() + } + + pub const fn width(&self) -> u32 { + self.width + } + + pub const fn height(&self) -> u32 { + self.height + } +} + +#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase", tag = "state", content = "evidence")] +pub enum Phase1InboundMediaState { + #[default] + Unavailable, + Pending(Phase1InboundMediaPending), + Failed(Phase1InboundMediaFailure), + Verified(Box<Phase1VerifiedMediaReceipt>), +} + +/// Public media model: signed structure plus local retrieval evidence. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct MediaReference { + structural: Phase1StructuralMediaReference, + retrieval: Phase1InboundMediaState, +} + +impl MediaReference { + pub fn new( + structural: Phase1StructuralMediaReference, + ) -> Result<Self, Phase1InboundMediaError> { + structural.validate()?; + Ok(Self { + structural, + retrieval: Phase1InboundMediaState::Unavailable, + }) + } + + pub(crate) fn legacy_unavailable( + source_url: String, + expected_sha256: Option<String>, + expected_media_type: Option<String>, + expected_width: Option<u32>, + expected_height: Option<u32>, + expected_byte_size: Option<u64>, + alt: Option<String>, + ) -> Result<Self, Phase1InboundMediaError> { + Self::new(Phase1StructuralMediaReference::new( + source_url, + expected_sha256, + expected_media_type, + expected_width, + expected_height, + expected_byte_size, + alt, + )?) + } + + pub fn structural(&self) -> &Phase1StructuralMediaReference { + &self.structural + } + + pub const fn retrieval(&self) -> &Phase1InboundMediaState { + &self.retrieval + } + + pub(crate) fn validate(&self) -> Result<(), Phase1InboundMediaError> { + self.structural.validate()?; + match &self.retrieval { + Phase1InboundMediaState::Unavailable => Ok(()), + Phase1InboundMediaState::Pending(value) => value.validate(), + Phase1InboundMediaState::Failed(value) => value.validate(), + Phase1InboundMediaState::Verified(value) => value.validate(&self.structural), + } + } + + pub(crate) fn restore( + &mut self, + retrieval: Phase1InboundMediaState, + cache: &Phase1MediaCacheIndex, + ) -> Result<(), Phase1InboundMediaError> { + let mut candidate = self.clone(); + candidate.retrieval = retrieval; + candidate.validate()?; + if let Phase1InboundMediaState::Verified(receipt) = &candidate.retrieval + && !cache.contains(receipt) + { + candidate.retrieval = Phase1InboundMediaState::Unavailable; + } + *self = candidate; + Ok(()) + } + + pub fn begin( + &mut self, + pending: Phase1InboundMediaPending, + ) -> Result<(), Phase1InboundMediaError> { + self.structural.validate()?; + pending.validate()?; + self.retrieval = Phase1InboundMediaState::Pending(pending); + Ok(()) + } + + pub fn fail( + &mut self, + failure: Phase1InboundMediaFailure, + ) -> Result<(), Phase1InboundMediaError> { + failure.validate()?; + match &self.retrieval { + Phase1InboundMediaState::Pending(pending) + if pending.operation_id == failure.operation_id => + { + self.retrieval = Phase1InboundMediaState::Failed(failure); + Ok(()) + } + _ => Err(Phase1InboundMediaError::OperationMismatch), + } + } + + pub fn verify( + &mut self, + operation_id: [u8; 16], + receipt: Phase1VerifiedMediaReceipt, + ) -> Result<(), Phase1InboundMediaError> { + let Phase1InboundMediaState::Pending(pending) = &self.retrieval else { + return Err(Phase1InboundMediaError::OperationMismatch); + }; + if pending.operation_id != operation_id || pending.configuration != receipt.configuration { + return Err(Phase1InboundMediaError::OperationMismatch); + } + receipt.validate(&self.structural)?; + self.retrieval = Phase1InboundMediaState::Verified(Box::new(receipt)); + Ok(()) + } + + pub fn invalidate(&mut self) -> Option<Phase1MediaArtifactId> { + let artifact = match &self.retrieval { + Phase1InboundMediaState::Verified(receipt) => Some(receipt.artifact_id), + _ => None, + }; + self.retrieval = Phase1InboundMediaState::Unavailable; + artifact + } + + pub fn is_renderable_with( + &self, + cache: &Phase1MediaCacheIndex, + configuration: Phase1MediaConfigurationFingerprint, + ) -> bool { + match &self.retrieval { + Phase1InboundMediaState::Verified(receipt) + if receipt.configuration == configuration => + { + cache.contains(receipt) + } + _ => false, + } + } +} + +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct Phase1MediaCachePolicy { + max_bytes: u64, + max_artifacts: u32, +} + +impl Phase1MediaCachePolicy { + pub fn new(max_bytes: u64, max_artifacts: u32) -> Result<Self, Phase1InboundMediaError> { + if max_bytes == 0 || max_artifacts == 0 { + return Err(Phase1InboundMediaError::InvalidCachePolicy); + } + Ok(Self { + max_bytes, + max_artifacts, + }) + } + + pub const fn max_bytes(&self) -> u64 { + self.max_bytes + } + + pub const fn max_artifacts(&self) -> u32 { + self.max_artifacts + } +} + +impl Default for Phase1MediaCachePolicy { + fn default() -> Self { + Self { + max_bytes: 256 * 1024 * 1024, + max_artifacts: 2_000, + } + } +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +struct Phase1MediaCacheEntry { + artifact_id: Phase1MediaArtifactId, + byte_size: u64, + media_type: String, + extension: String, + width: u32, + height: u32, + cached_at_unix_ms: u64, + last_accessed_at_unix_ms: u64, +} + +impl Phase1MediaCacheEntry { + fn from_receipt( + receipt: &Phase1VerifiedMediaReceipt, + cached_at_unix_ms: u64, + ) -> Result<Self, Phase1InboundMediaError> { + if cached_at_unix_ms < receipt.verified_at_unix_ms { + return Err(Phase1InboundMediaError::InvalidCacheObservation); + } + Ok(Self { + artifact_id: receipt.artifact_id, + byte_size: receipt.byte_size, + media_type: receipt.media_type.clone(), + extension: receipt.extension.clone(), + width: receipt.width, + height: receipt.height, + cached_at_unix_ms, + last_accessed_at_unix_ms: cached_at_unix_ms, + }) + } + + fn matches(&self, receipt: &Phase1VerifiedMediaReceipt) -> bool { + self.artifact_id == receipt.artifact_id + && self.byte_size == receipt.byte_size + && self.media_type == receipt.media_type + && self.extension == receipt.extension + && self.width == receipt.width + && self.height == receipt.height + } +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct Phase1MediaCacheIndex { + schema_version: u16, + configuration: Option<Phase1MediaConfigurationFingerprint>, + entries: BTreeMap<String, Phase1MediaCacheEntry>, +} + +impl Default for Phase1MediaCacheIndex { + fn default() -> Self { + Self { + schema_version: MEDIA_CACHE_SCHEMA_VERSION, + configuration: None, + entries: BTreeMap::new(), + } + } +} + +impl Phase1MediaCacheIndex { + pub fn admit( + &mut self, + receipt: &Phase1VerifiedMediaReceipt, + policy: Phase1MediaCachePolicy, + cached_at_unix_ms: u64, + ) -> Result<Vec<Phase1MediaArtifactId>, Phase1InboundMediaError> { + self.validate()?; + receipt.validate_intrinsic()?; + if receipt.byte_size > policy.max_bytes { + return Err(Phase1InboundMediaError::CacheQuotaExceeded); + } + if self + .configuration + .is_some_and(|value| value != receipt.configuration) + { + return Err(Phase1InboundMediaError::ConfigurationMismatch); + } + self.configuration = Some(receipt.configuration); + let key = receipt.artifact_id.to_hex(); + let entry = Phase1MediaCacheEntry::from_receipt(receipt, cached_at_unix_ms)?; + if self + .entries + .get(&key) + .is_some_and(|existing| !existing.matches(receipt)) + { + return Err(Phase1InboundMediaError::ArtifactCollision); + } + self.entries.insert(key.clone(), entry); + let mut evicted = Vec::new(); + while self.entries.len() > policy.max_artifacts as usize + || self.total_bytes()? > policy.max_bytes + { + let oldest = self + .entries + .iter() + .filter(|(candidate, _)| candidate.as_str() != key) + .min_by_key(|(key, entry)| (entry.last_accessed_at_unix_ms, key.as_str())) + .map(|(key, _)| key.clone()) + .ok_or(Phase1InboundMediaError::CorruptState)?; + let removed = self + .entries + .remove(&oldest) + .ok_or(Phase1InboundMediaError::CorruptState)?; + evicted.push(removed.artifact_id); + } + Ok(evicted) + } + + pub fn contains(&self, receipt: &Phase1VerifiedMediaReceipt) -> bool { + self.schema_version == MEDIA_CACHE_SCHEMA_VERSION + && self.configuration == Some(receipt.configuration) + && self + .entries + .get(&receipt.artifact_id.to_hex()) + .is_some_and(|entry| entry.matches(receipt)) + } + + pub fn touch( + &mut self, + artifact_id: Phase1MediaArtifactId, + observed_at_unix_ms: u64, + ) -> Result<bool, Phase1InboundMediaError> { + if observed_at_unix_ms == 0 { + return Err(Phase1InboundMediaError::InvalidCacheObservation); + } + let Some(entry) = self.entries.get_mut(&artifact_id.to_hex()) else { + return Ok(false); + }; + entry.last_accessed_at_unix_ms = entry.last_accessed_at_unix_ms.max(observed_at_unix_ms); + Ok(true) + } + + pub fn invalidate_artifact(&mut self, artifact_id: Phase1MediaArtifactId) -> bool { + self.entries.remove(&artifact_id.to_hex()).is_some() + } + + pub fn invalidate_configuration( + &mut self, + configuration: Phase1MediaConfigurationFingerprint, + ) -> Vec<Phase1MediaArtifactId> { + if self + .configuration + .is_none_or(|current| current == configuration) + { + self.configuration = Some(configuration); + return Vec::new(); + } + let removed = self + .entries + .values() + .map(|entry| entry.artifact_id) + .collect(); + self.entries.clear(); + self.configuration = Some(configuration); + removed + } + + pub fn artifact_count(&self) -> u32 { + self.entries.len().try_into().unwrap_or(u32::MAX) + } + + pub fn total_bytes(&self) -> Result<u64, Phase1InboundMediaError> { + self.entries.values().try_fold(0_u64, |total, entry| { + total + .checked_add(entry.byte_size) + .ok_or(Phase1InboundMediaError::CorruptState) + }) + } + + fn validate(&self) -> Result<(), Phase1InboundMediaError> { + if self.schema_version != MEDIA_CACHE_SCHEMA_VERSION + || (self.configuration.is_none() && !self.entries.is_empty()) + || self.entries.iter().any(|(key, entry)| { + key != &entry.artifact_id.to_hex() + || key != &hex::encode(entry.artifact_id.as_bytes()) + || entry.byte_size == 0 + || entry.cached_at_unix_ms == 0 + || entry.last_accessed_at_unix_ms < entry.cached_at_unix_ms + || !canonical_media_type(&entry.media_type) + || entry.extension.is_empty() + || validate_dimensions(Some(entry.width), Some(entry.height)).is_err() + }) + { + return Err(Phase1InboundMediaError::CorruptState); + } + if self + .configuration + .is_some_and(|value| value.validate().is_err()) + { + return Err(Phase1InboundMediaError::CorruptState); + } + self.total_bytes().map(|_| ()) + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct Phase1MediaCacheStatus { + pub artifacts: u32, + pub bytes: u64, + pub configuration: Option<Phase1MediaConfigurationFingerprint>, +} + +impl Phase1MediaCacheIndex { + pub fn status(&self) -> Result<Phase1MediaCacheStatus, Phase1InboundMediaError> { + self.validate()?; + Ok(Phase1MediaCacheStatus { + artifacts: self.artifact_count(), + bytes: self.total_bytes()?, + configuration: self.configuration, + }) + } +} + +#[derive(Clone, Debug, Error, Eq, PartialEq)] +pub enum Phase1InboundMediaError { + #[error("inbound media reference is invalid")] + InvalidReference, + #[error("inbound media digest is invalid")] + InvalidDigest, + #[error("inbound media reference requires a digest")] + MissingDigest, + #[error("inbound media type is invalid")] + InvalidMediaType, + #[error("inbound media dimensions are invalid")] + InvalidDimensions, + #[error("inbound media byte size is invalid")] + InvalidByteSize, + #[error("inbound media alternative text is invalid")] + InvalidAlt, + #[error("inbound media metadata does not match verified bytes")] + MetadataMismatch, + #[error("inbound media operation is invalid")] + InvalidOperation, + #[error("inbound media operation identity does not match")] + OperationMismatch, + #[error("inbound media failure evidence is invalid")] + InvalidFailure, + #[error("inbound media configuration is invalid")] + InvalidConfiguration, + #[error("inbound media configuration changed")] + ConfigurationMismatch, + #[error("inbound media verification time is invalid")] + InvalidVerificationTime, + #[error("inbound media cache policy is invalid")] + InvalidCachePolicy, + #[error("inbound media cache observation is invalid")] + InvalidCacheObservation, + #[error("inbound media artifact exceeds cache quota")] + CacheQuotaExceeded, + #[error("inbound media artifact identity collides with different metadata")] + ArtifactCollision, + #[error("inbound media receipt is corrupt")] + CorruptReceipt, + #[error("inbound media state is corrupt")] + CorruptState, + #[error("inbound media schema version is unsupported")] + UnsupportedSchema, + #[error("inbound media cache directory is unavailable")] + CacheUnavailable, + #[error("inbound media cache filesystem operation failed")] + CacheIo, + #[error("inbound media cache artifact is corrupt")] + CorruptArtifact, +} + +#[cfg(feature = "mobile-social")] +pub(crate) async fn write_verified_artifact( + directory: &Path, + receipt: &Phase1VerifiedMediaReceipt, + bytes: &[u8], +) -> Result<Phase1LocalMediaArtifact, Phase1InboundMediaError> { + receipt.validate_intrinsic()?; + if bytes.len() as u64 != receipt.byte_size + || Sha256::digest(bytes).to_hex() != receipt.observed_sha256 + { + return Err(Phase1InboundMediaError::CorruptArtifact); + } + ensure_cache_directory(directory).await?; + let final_path = artifact_path(directory, receipt.artifact_id, receipt.extension.as_str())?; + match tokio::fs::symlink_metadata(&final_path).await { + Ok(_) => { + let verified_bytes = verify_artifact_file(&final_path, receipt).await?; + return Ok(local_artifact(final_path, receipt, verified_bytes)); + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(_) => return Err(Phase1InboundMediaError::CacheIo), + } + + let temporary_path = directory.join(format!( + ".{}.{}.tmp", + receipt.artifact_id.to_hex(), + uuid::Uuid::new_v4().simple() + )); + let mut temporary = tokio::fs::OpenOptions::new() + .create_new(true) + .write(true) + .open(&temporary_path) + .await + .map_err(|_| Phase1InboundMediaError::CacheIo)?; + let write_result = async { + temporary + .write_all(bytes) + .await + .map_err(|_| Phase1InboundMediaError::CacheIo)?; + temporary + .flush() + .await + .map_err(|_| Phase1InboundMediaError::CacheIo)?; + temporary + .sync_all() + .await + .map_err(|_| Phase1InboundMediaError::CacheIo)?; + drop(temporary); + match tokio::fs::hard_link(&temporary_path, &final_path).await { + Ok(()) => {} + Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => { + verify_artifact_file(&final_path, receipt).await?; + } + Err(_) => return Err(Phase1InboundMediaError::CacheIo), + } + tokio::fs::remove_file(&temporary_path) + .await + .map_err(|_| Phase1InboundMediaError::CacheIo)?; + sync_cache_directory(directory).await?; + verify_artifact_file(&final_path, receipt).await + } + .await; + if write_result.is_err() { + let _ = tokio::fs::remove_file(&temporary_path).await; + } + let verified_bytes = write_result?; + Ok(local_artifact(final_path, receipt, verified_bytes)) +} + +#[cfg(feature = "mobile-social")] +pub(crate) async fn remove_artifact_files( + directory: &Path, + artifact_id: Phase1MediaArtifactId, +) -> Result<(), Phase1InboundMediaError> { + ensure_cache_directory(directory).await?; + for extension in MEDIA_CACHE_EXTENSIONS { + let path = artifact_path(directory, artifact_id, extension)?; + match tokio::fs::symlink_metadata(&path).await { + Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_file() => { + return Err(Phase1InboundMediaError::CorruptArtifact); + } + Ok(_) => tokio::fs::remove_file(path) + .await + .map_err(|_| Phase1InboundMediaError::CacheIo)?, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(_) => return Err(Phase1InboundMediaError::CacheIo), + } + } + sync_cache_directory(directory).await +} + +#[cfg(feature = "mobile-social")] +pub(crate) async fn verified_artifact( + directory: &Path, + receipt: &Phase1VerifiedMediaReceipt, +) -> Result<Phase1LocalMediaArtifact, Phase1InboundMediaError> { + receipt.validate_intrinsic()?; + ensure_cache_directory(directory).await?; + let path = artifact_path(directory, receipt.artifact_id, receipt.extension.as_str())?; + let verified_bytes = verify_artifact_file(&path, receipt).await?; + Ok(local_artifact(path, receipt, verified_bytes)) +} + +#[cfg(feature = "mobile-social")] +async fn ensure_cache_directory(directory: &Path) -> Result<(), Phase1InboundMediaError> { + match tokio::fs::create_dir(directory).await { + Ok(()) => {} + Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {} + Err(_) => return Err(Phase1InboundMediaError::CacheIo), + } + let metadata = tokio::fs::symlink_metadata(directory) + .await + .map_err(|_| Phase1InboundMediaError::CacheIo)?; + if metadata.file_type().is_symlink() || !metadata.is_dir() { + return Err(Phase1InboundMediaError::CorruptArtifact); + } + Ok(()) +} + +#[cfg(feature = "mobile-social")] +fn artifact_path( + directory: &Path, + artifact_id: Phase1MediaArtifactId, + extension: &str, +) -> Result<PathBuf, Phase1InboundMediaError> { + if !MEDIA_CACHE_EXTENSIONS.contains(&extension) { + return Err(Phase1InboundMediaError::CorruptArtifact); + } + Ok(directory.join(format!("{}.{}", artifact_id.to_hex(), extension))) +} + +#[cfg(feature = "mobile-social")] +async fn verify_artifact_file( + path: &Path, + receipt: &Phase1VerifiedMediaReceipt, +) -> Result<Vec<u8>, Phase1InboundMediaError> { + let metadata = tokio::fs::symlink_metadata(path) + .await + .map_err(|_| Phase1InboundMediaError::CorruptArtifact)?; + if metadata.file_type().is_symlink() + || !metadata.is_file() + || metadata.len() != receipt.byte_size + { + return Err(Phase1InboundMediaError::CorruptArtifact); + } + let bytes = tokio::fs::read(path) + .await + .map_err(|_| Phase1InboundMediaError::CacheIo)?; + if Sha256::digest(bytes.as_slice()).to_hex() != receipt.observed_sha256 { + return Err(Phase1InboundMediaError::CorruptArtifact); + } + Ok(bytes) +} + +#[cfg(feature = "mobile-social")] +async fn sync_cache_directory(directory: &Path) -> Result<(), Phase1InboundMediaError> { + let directory = directory.to_path_buf(); + tokio::task::spawn_blocking(move || std::fs::File::open(directory)?.sync_all()) + .await + .map_err(|_| Phase1InboundMediaError::CacheIo)? + .map_err(|_| Phase1InboundMediaError::CacheIo) +} + +#[cfg(feature = "mobile-social")] +fn local_artifact( + local_path: PathBuf, + receipt: &Phase1VerifiedMediaReceipt, + bytes: Vec<u8>, +) -> Phase1LocalMediaArtifact { + Phase1LocalMediaArtifact { + artifact_id: receipt.artifact_id, + local_path, + bytes, + byte_size: receipt.byte_size, + media_type: receipt.media_type.clone(), + width: receipt.width, + height: receipt.height, + } +} + +fn validate_url_text(value: &str) -> Result<(), Phase1InboundMediaError> { + if value.is_empty() + || value.len() > MEDIA_URL_MAX_BYTES + || value.chars().any(|character| character.is_control()) + { + return Err(Phase1InboundMediaError::InvalidReference); + } + Ok(()) +} + +fn canonical_media_type(value: &str) -> bool { + MediaType::parse(value).is_ok_and(|parsed| parsed.to_string() == value) +} + +fn canonical_extension(media_type: &MediaType) -> Option<&'static str> { + match media_type.as_str() { + "image/gif" => Some("gif"), + "image/jpeg" => Some("jpg"), + "image/png" => Some("png"), + "image/webp" => Some("webp"), + _ => None, + } +} + +fn validate_dimensions( + width: Option<u32>, + height: Option<u32>, +) -> Result<(), Phase1InboundMediaError> { + match (width, height) { + (None, None) => Ok(()), + (Some(width), Some(height)) + if width != 0 + && height != 0 + && width <= MEDIA_DIMENSION_MAX_EDGE + && height <= MEDIA_DIMENSION_MAX_EDGE + && u64::from(width) * u64::from(height) <= MEDIA_DIMENSION_MAX_PIXELS => + { + Ok(()) + } + _ => Err(Phase1InboundMediaError::InvalidDimensions), + } +} + +fn update_optional(digest: &mut Sha256Hasher, value: Option<&str>) { + match value { + Some(value) => { + digest.update([1]); + digest.update((value.len() as u64).to_be_bytes()); + digest.update(value.as_bytes()); + } + None => digest.update([0]), + } +} + +fn update_optional_u64(digest: &mut Sha256Hasher, value: Option<u64>) { + match value { + Some(value) => { + digest.update([1]); + digest.update(value.to_be_bytes()); + } + None => digest.update([0]), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + type ReceiptMutation = Box<dyn Fn(&mut Phase1VerifiedMediaReceipt)>; + type CacheMutation = Box<dyn Fn(&mut Phase1MediaCacheIndex)>; + + const HASH: &str = "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"; + + fn reference(alt: Option<&str>) -> Phase1StructuralMediaReference { + Phase1StructuralMediaReference::new( + format!("https://media.example/{HASH}.jpg"), + Some(HASH.to_owned()), + Some("image/jpeg".to_owned()), + Some(2), + Some(3), + Some(5), + alt.map(str::to_owned), + ) + .expect("reference") + } + + fn configuration(value: u8) -> Phase1MediaConfigurationFingerprint { + Phase1MediaConfigurationFingerprint::new([value; 32]).expect("configuration") + } + + fn receipt( + reference: &Phase1StructuralMediaReference, + configuration: Phase1MediaConfigurationFingerprint, + ) -> Phase1VerifiedMediaReceipt { + let commitment = + ByteCommitment::from_bytes(b"hello", MediaType::parse("image/jpeg").unwrap()); + Phase1VerifiedMediaReceipt::from_commitment( + reference, + BlobUrl::parse(&format!("https://cdn.example/{HASH}.jpg")).unwrap(), + &commitment, + 2, + 3, + configuration, + 10, + ) + .expect("receipt") + } + + #[test] + fn structural_reference_is_canonical_and_metadata_sensitive() { + let first = reference(Some("Harvest")); + let second = reference(Some("Harvest detail")); + assert_ne!(first.fingerprint(), second.fingerprint()); + assert_eq!(first.expected_sha256(), Some(HASH)); + assert!( + Phase1StructuralMediaReference::new( + format!("https://media.example/{}.jpg", "a".repeat(64)), + Some(HASH.to_owned()), + Some("image/jpeg".to_owned()), + Some(2), + Some(3), + Some(5), + None, + ) + .is_err() + ); + let interoperable = Phase1StructuralMediaReference::new( + "https://cdn.example/harvest.jpg", + Some(HASH.to_owned()), + Some("image/jpeg".to_owned()), + Some(2), + Some(3), + Some(5), + None, + ) + .expect("non-Blossom NIP-92 reference remains structural"); + assert!( + Phase1VerifiedMediaReceipt::from_commitment( + &interoperable, + BlobUrl::parse(&format!("https://cdn.example/{HASH}.jpg")).unwrap(), + &ByteCommitment::from_bytes(b"hello", MediaType::parse("image/jpeg").unwrap()), + 2, + 3, + configuration(1), + 1, + ) + .is_err() + ); + } + + #[test] + fn structural_reference_rejects_each_noncanonical_field_independently() { + for source_url in [ + "", + "ftp://media.example/file.jpg", + "https:///file.jpg", + "https://user@media.example/file.jpg", + "https://user:password@media.example/file.jpg", + "https://media.example/file.jpg\n", + ] { + assert!( + Phase1StructuralMediaReference::new( + source_url, + None, + None, + None, + None, + None, + None, + ) + .is_err(), + "source URL should fail: {source_url:?}" + ); + } + assert!( + Phase1StructuralMediaReference::new( + format!("https://media.example/{}", "x".repeat(MEDIA_URL_MAX_BYTES)), + None, + None, + None, + None, + None, + None, + ) + .is_err() + ); + for digest in ["not-hex".to_owned(), HASH.to_ascii_uppercase()] { + assert!( + Phase1StructuralMediaReference::new( + "https://media.example/file.jpg", + Some(digest), + None, + None, + None, + None, + None, + ) + .is_err() + ); + } + assert!( + Phase1StructuralMediaReference::new( + "https://media.example/file.jpg", + Some(HASH.to_owned()), + Some("not a media type".to_owned()), + None, + None, + None, + None, + ) + .is_err() + ); + for (width, height) in [ + (Some(1), None), + (None, Some(1)), + (Some(0), Some(1)), + (Some(1), Some(0)), + (Some(MEDIA_DIMENSION_MAX_EDGE + 1), Some(1)), + (Some(1), Some(MEDIA_DIMENSION_MAX_EDGE + 1)), + (Some(10_001), Some(10_000)), + ] { + assert!( + Phase1StructuralMediaReference::new( + "https://media.example/file.jpg", + Some(HASH.to_owned()), + Some("image/jpeg".to_owned()), + width, + height, + Some(5), + None, + ) + .is_err() + ); + } + for alt in [ + "x".repeat(MEDIA_ALT_MAX_BYTES + 1), + "line\nbreak".to_owned(), + ] { + assert!( + Phase1StructuralMediaReference::new( + "https://media.example/file.jpg", + Some(HASH.to_owned()), + Some("image/jpeg".to_owned()), + None, + None, + Some(5), + Some(alt), + ) + .is_err() + ); + } + assert!( + Phase1StructuralMediaReference::new( + "https://media.example/file.jpg", + Some(HASH.to_owned()), + Some("image/jpeg".to_owned()), + None, + None, + Some(0), + None, + ) + .is_err() + ); + + let mut unsupported = reference(None); + unsupported.schema_version = 2; + assert_eq!( + unsupported.validate(), + Err(Phase1InboundMediaError::UnsupportedSchema) + ); + let mut corrupt = reference(None); + corrupt.fingerprint = [9; 32]; + assert_eq!( + corrupt.validate(), + Err(Phase1InboundMediaError::CorruptState) + ); + } + + #[test] + fn operation_and_failure_evidence_reject_each_invalid_field() { + assert!(Phase1MediaConfigurationFingerprint::new([0; 32]).is_err()); + assert!(Phase1MediaConfigurationFingerprint::parse("not-hex").is_err()); + assert!(Phase1MediaConfigurationFingerprint::parse("00").is_err()); + assert!(Phase1MediaArtifactId::parse("not-hex").is_err()); + + assert!(Phase1InboundMediaPending::new([0; 16], configuration(1), 1).is_err()); + assert!(Phase1InboundMediaPending::new([1; 16], configuration(1), 0).is_err()); + assert!( + Phase1InboundMediaPending::new( + [1; 16], + Phase1MediaConfigurationFingerprint([0; 32]), + 1, + ) + .is_err() + ); + + for (operation_id, code, failed_at) in [ + ([0; 16], "failed".to_owned(), 1), + ([1; 16], "failed".to_owned(), 0), + ([1; 16], String::new(), 1), + ([1; 16], "x".repeat(MEDIA_FAILURE_CODE_MAX_BYTES + 1), 1), + ([1; 16], "Not_Safe".to_owned(), 1), + ] { + assert!(Phase1InboundMediaFailure::new(operation_id, code, true, failed_at).is_err()); + } + let evidence = Phase1InboundMediaFailure::new([2; 16], "retry_2", true, 9).unwrap(); + assert_eq!(evidence.safe_code(), "retry_2"); + assert!(evidence.retryable()); + } + + #[test] + fn media_helper_vocabularies_cover_every_closed_outcome() { + assert!(validate_url_text("https://example.test/media").is_ok()); + assert_eq!( + validate_url_text(""), + Err(Phase1InboundMediaError::InvalidReference) + ); + assert_eq!( + validate_url_text(&"x".repeat(MEDIA_URL_MAX_BYTES + 1)), + Err(Phase1InboundMediaError::InvalidReference) + ); + assert_eq!( + validate_url_text("bad\nurl"), + Err(Phase1InboundMediaError::InvalidReference) + ); + assert!(canonical_media_type("image/jpeg")); + assert!(!canonical_media_type("IMAGE/JPEG")); + assert_eq!( + canonical_extension(&MediaType::parse("image/gif").unwrap()), + Some("gif") + ); + assert_eq!( + canonical_extension(&MediaType::parse("image/jpeg").unwrap()), + Some("jpg") + ); + assert_eq!( + canonical_extension(&MediaType::parse("image/png").unwrap()), + Some("png") + ); + assert_eq!( + canonical_extension(&MediaType::parse("image/webp").unwrap()), + Some("webp") + ); + assert_eq!( + canonical_extension(&MediaType::parse("image/svg+xml").unwrap()), + None + ); + assert!(validate_dimensions(None, None).is_ok()); + assert!(validate_dimensions(Some(10_000), Some(10_000)).is_ok()); + } + + #[test] + fn verified_state_requires_matching_operation_bytes_and_configuration() { + let structural = reference(None); + let mut media = MediaReference::new(structural.clone()).unwrap(); + let pending = Phase1InboundMediaPending::new([7; 16], configuration(3), 9).unwrap(); + media.begin(pending).unwrap(); + assert_eq!( + media.verify([8; 16], receipt(&structural, configuration(3))), + Err(Phase1InboundMediaError::OperationMismatch) + ); + media + .verify([7; 16], receipt(&structural, configuration(3))) + .unwrap(); + assert!(matches!( + media.retrieval(), + Phase1InboundMediaState::Verified(_) + )); + media + .restore( + Phase1InboundMediaState::Unavailable, + &Phase1MediaCacheIndex::default(), + ) + .unwrap(); + assert!(matches!( + media.retrieval(), + Phase1InboundMediaState::Unavailable + )); + } + + #[test] + fn media_state_transitions_and_renderability_cover_every_state() { + let structural = reference(None); + let config = configuration(3); + let verified = receipt(&structural, config); + let pending = Phase1InboundMediaPending::new([7; 16], config, 9).unwrap(); + let failure = Phase1InboundMediaFailure::new([7; 16], "network", true, 10).unwrap(); + let wrong_failure = Phase1InboundMediaFailure::new([8; 16], "network", true, 10).unwrap(); + + let mut media = MediaReference::new(structural.clone()).unwrap(); + assert_eq!(media.invalidate(), None); + assert!(!media.is_renderable_with(&Phase1MediaCacheIndex::default(), config)); + assert_eq!( + media.fail(failure.clone()), + Err(Phase1InboundMediaError::OperationMismatch) + ); + assert_eq!( + media.verify([7; 16], verified.clone()), + Err(Phase1InboundMediaError::OperationMismatch) + ); + + media.begin(pending.clone()).unwrap(); + assert_eq!( + media.fail(wrong_failure), + Err(Phase1InboundMediaError::OperationMismatch) + ); + assert_eq!( + media.verify([7; 16], receipt(&structural, configuration(4))), + Err(Phase1InboundMediaError::OperationMismatch) + ); + media.fail(failure).unwrap(); + assert!(matches!( + media.retrieval(), + Phase1InboundMediaState::Failed(_) + )); + assert!(media.validate().is_ok()); + + media.begin(pending).unwrap(); + media.verify([7; 16], verified.clone()).unwrap(); + assert!(!media.is_renderable_with(&Phase1MediaCacheIndex::default(), config)); + let mut cache = Phase1MediaCacheIndex::default(); + cache + .admit(&verified, Phase1MediaCachePolicy::new(5, 1).unwrap(), 10) + .unwrap(); + assert!(media.is_renderable_with(&cache, config)); + assert!(!media.is_renderable_with(&cache, configuration(4))); + assert_eq!(media.invalidate(), Some(verified.artifact_id())); + + media + .restore( + Phase1InboundMediaState::Verified(Box::new(verified.clone())), + &Phase1MediaCacheIndex::default(), + ) + .unwrap(); + assert!(matches!( + media.retrieval(), + Phase1InboundMediaState::Unavailable + )); + media + .restore( + Phase1InboundMediaState::Verified(Box::new(verified)), + &cache, + ) + .unwrap(); + assert!(matches!( + media.retrieval(), + Phase1InboundMediaState::Verified(_) + )); + } + + #[test] + fn receipt_rejects_hash_size_type_and_dimension_mismatch() { + let expected = reference(None); + let wrong_bytes = + ByteCommitment::from_bytes(b"other", MediaType::parse("image/jpeg").unwrap()); + assert!( + Phase1VerifiedMediaReceipt::from_commitment( + &expected, + BlobUrl::parse(&format!("https://cdn.example/{}.jpg", wrong_bytes.sha256())) + .unwrap(), + &wrong_bytes, + 2, + 3, + configuration(1), + 1, + ) + .is_err() + ); + let commitment = + ByteCommitment::from_bytes(b"hello", MediaType::parse("image/png").unwrap()); + assert!( + Phase1VerifiedMediaReceipt::from_commitment( + &expected, + BlobUrl::parse(&format!("https://cdn.example/{HASH}.jpg")).unwrap(), + &commitment, + 2, + 3, + configuration(1), + 1, + ) + .is_err() + ); + } + + #[test] + fn receipt_validation_rejects_each_bound_field_independently() { + let expected = reference(None); + let commitment = + ByteCommitment::from_bytes(b"hello", MediaType::parse("image/jpeg").unwrap()); + let final_url = BlobUrl::parse(&format!("https://cdn.example/{HASH}.jpg")).unwrap(); + assert!( + Phase1VerifiedMediaReceipt::from_commitment( + &expected, + final_url.clone(), + &commitment, + 2, + 3, + configuration(1), + 0, + ) + .is_err() + ); + + let mutations: [fn(&mut Phase1StructuralMediaReference); 4] = [ + |value: &mut Phase1StructuralMediaReference| value.expected_byte_size = Some(6), + |value: &mut Phase1StructuralMediaReference| { + value.expected_media_type = Some("image/png".to_owned()) + }, + |value: &mut Phase1StructuralMediaReference| value.expected_width = Some(3), + |value: &mut Phase1StructuralMediaReference| value.expected_height = Some(4), + ]; + for mutate in mutations { + let mut changed = expected.clone(); + mutate(&mut changed); + changed.fingerprint = changed.derive_fingerprint(); + assert!( + Phase1VerifiedMediaReceipt::from_commitment( + &changed, + final_url.clone(), + &commitment, + 2, + 3, + configuration(1), + 1, + ) + .is_err() + ); + } + + let mut without_digest = Phase1StructuralMediaReference::new( + "https://media.example/file.jpg", + None, + Some("image/jpeg".to_owned()), + Some(2), + Some(3), + Some(5), + None, + ) + .unwrap(); + without_digest.expected_sha256 = None; + without_digest.fingerprint = without_digest.derive_fingerprint(); + assert!( + Phase1VerifiedMediaReceipt::from_commitment( + &without_digest, + final_url, + &commitment, + 2, + 3, + configuration(1), + 1, + ) + .is_err() + ); + + let valid = receipt(&expected, configuration(1)); + let mut corruptions: Vec<ReceiptMutation> = vec![ + Box::new(|value| value.schema_version = 2), + Box::new(|value| value.expected_sha256 = "a".repeat(64)), + Box::new(|value| value.artifact_id = Phase1MediaArtifactId([8; 32])), + Box::new(|value| value.byte_size = 0), + Box::new(|value| value.verified_at_unix_ms = 0), + Box::new(|value| value.configuration = Phase1MediaConfigurationFingerprint([0; 32])), + Box::new(|value| value.canonical_final_url = "not a url".to_owned()), + Box::new(|value| value.canonical_final_url.push_str("?changed=1")), + Box::new(|value| value.extension = "png".to_owned()), + Box::new(|value| value.media_type = "not a media type".to_owned()), + Box::new(|value| value.media_type = "image/svg+xml".to_owned()), + Box::new(|value| value.width = 0), + ]; + for mutate in corruptions.drain(..) { + let mut changed = valid.clone(); + mutate(&mut changed); + assert_eq!( + changed.validate_intrinsic(), + Err(Phase1InboundMediaError::CorruptReceipt) + ); + } + assert_eq!(valid.artifact_id().to_hex(), HASH); + assert_eq!(valid.configuration(), configuration(1)); + assert_eq!( + valid.canonical_final_url(), + format!("https://cdn.example/{HASH}.jpg") + ); + assert_eq!(valid.observed_sha256(), HASH); + assert_eq!(valid.byte_size(), 5); + assert_eq!(valid.media_type(), "image/jpeg"); + assert_eq!(valid.extension(), "jpg"); + assert_eq!( + (valid.width(), valid.height(), valid.verified_at_unix_ms()), + (2, 3, 10) + ); + + let wrong_extension = BlobUrl::parse(&format!("https://cdn.example/{HASH}.png")).unwrap(); + assert_eq!( + Phase1VerifiedMediaReceipt::from_commitment( + &expected, + wrong_extension, + &commitment, + 2, + 3, + configuration(1), + 1, + ), + Err(Phase1InboundMediaError::MetadataMismatch) + ); + let wrong_path_hash = "a".repeat(64); + assert_eq!( + Phase1VerifiedMediaReceipt::from_commitment( + &expected, + BlobUrl::parse(&format!("https://cdn.example/{wrong_path_hash}.jpg")).unwrap(), + &commitment, + 2, + 3, + configuration(1), + 1, + ), + Err(Phase1InboundMediaError::MetadataMismatch) + ); + + let reference_mutations: [fn(&mut Phase1StructuralMediaReference); 7] = [ + |value| value.fingerprint = [8; 32], + |value| value.source_url = "https://other.example/file.jpg".to_owned(), + |value| value.expected_sha256 = Some("a".repeat(64)), + |value| value.expected_byte_size = Some(6), + |value| value.expected_media_type = Some("image/png".to_owned()), + |value| value.expected_width = Some(3), + |value| value.expected_height = Some(4), + ]; + for mutate in reference_mutations { + let mut changed = expected.clone(); + mutate(&mut changed); + assert_eq!( + valid.validate(&changed), + Err(Phase1InboundMediaError::CorruptReceipt) + ); + } + } + + #[test] + fn cache_validation_rejects_each_invalid_field_independently() { + assert!(Phase1MediaCachePolicy::new(0, 1).is_err()); + assert!(Phase1MediaCachePolicy::new(1, 0).is_err()); + let policy = Phase1MediaCachePolicy::default(); + assert_eq!(policy.max_bytes(), 256 * 1024 * 1024); + assert_eq!(policy.max_artifacts(), 2_000); + + let structural = reference(None); + let verified = receipt(&structural, configuration(4)); + assert!(Phase1MediaCacheEntry::from_receipt(&verified, 9).is_err()); + let mut cache = Phase1MediaCacheIndex::default(); + assert!(!cache.touch(verified.artifact_id(), 1).unwrap()); + assert!(cache.touch(verified.artifact_id(), 0).is_err()); + assert!(!cache.invalidate_artifact(verified.artifact_id())); + assert!(cache.invalidate_configuration(configuration(4)).is_empty()); + cache + .admit(&verified, Phase1MediaCachePolicy::new(5, 1).unwrap(), 10) + .unwrap(); + assert!(cache.touch(verified.artifact_id(), 11).unwrap()); + assert!(cache.invalidate_artifact(verified.artifact_id())); + + let mut baseline = Phase1MediaCacheIndex::default(); + baseline + .admit(&verified, Phase1MediaCachePolicy::new(5, 1).unwrap(), 10) + .unwrap(); + let key = verified.artifact_id().to_hex(); + let mutations: Vec<CacheMutation> = vec![ + Box::new(|value| value.schema_version = 2), + Box::new(|value| value.configuration = None), + Box::new({ + let key = key.clone(); + move |value| value.entries.get_mut(&key).unwrap().byte_size = 0 + }), + Box::new({ + let key = key.clone(); + move |value| value.entries.get_mut(&key).unwrap().cached_at_unix_ms = 0 + }), + Box::new({ + let key = key.clone(); + move |value| { + value + .entries + .get_mut(&key) + .unwrap() + .last_accessed_at_unix_ms = 9 + } + }), + Box::new({ + let key = key.clone(); + move |value| value.entries.get_mut(&key).unwrap().media_type = "bad".to_owned() + }), + Box::new({ + let key = key.clone(); + move |value| value.entries.get_mut(&key).unwrap().extension.clear() + }), + Box::new({ + let key = key.clone(); + move |value| value.entries.get_mut(&key).unwrap().width = 0 + }), + Box::new(|value| { + let entry = value.entries.pop_first().unwrap().1; + value.entries.insert("wrong-key".to_owned(), entry); + }), + Box::new(|value| { + value.configuration = Some(Phase1MediaConfigurationFingerprint([0; 32])); + }), + ]; + for mutate in mutations { + let mut changed = baseline.clone(); + mutate(&mut changed); + assert_eq!(changed.status(), Err(Phase1InboundMediaError::CorruptState)); + } + assert_eq!( + baseline.admit(&verified, Phase1MediaCachePolicy::new(4, 1).unwrap(), 10), + Err(Phase1InboundMediaError::CacheQuotaExceeded) + ); + + let mut wrong_schema = baseline.clone(); + wrong_schema.schema_version = 2; + assert!(!wrong_schema.contains(&verified)); + let mut wrong_configuration = baseline.clone(); + wrong_configuration.configuration = Some(configuration(5)); + assert!(!wrong_configuration.contains(&verified)); + let mut missing = baseline.clone(); + missing.entries.clear(); + assert!(!missing.contains(&verified)); + let mut mismatched = baseline.clone(); + mismatched.entries.get_mut(&key).unwrap().height = 4; + assert!(!mismatched.contains(&verified)); + + let receipt_mutations: [fn(&mut Phase1VerifiedMediaReceipt); 6] = [ + |value| value.artifact_id = Phase1MediaArtifactId([8; 32]), + |value| value.byte_size = 6, + |value| value.media_type = "image/png".to_owned(), + |value| value.extension = "png".to_owned(), + |value| value.width = 3, + |value| value.height = 4, + ]; + let entry = baseline.entries.get(&key).unwrap(); + for mutate in receipt_mutations { + let mut changed = verified.clone(); + mutate(&mut changed); + assert!(!entry.matches(&changed)); + } + + let mut collision = baseline.clone(); + collision.entries.get_mut(&key).unwrap().byte_size = 4; + assert_eq!( + collision.admit(&verified, Phase1MediaCachePolicy::new(10, 2).unwrap(), 10), + Err(Phase1InboundMediaError::ArtifactCollision) + ); + + let second_hash = Sha256::digest(b"world").to_hex(); + let second_reference = Phase1StructuralMediaReference::new( + format!("https://media.example/{second_hash}.jpg"), + Some(second_hash.clone()), + Some("image/jpeg".to_owned()), + Some(2), + Some(3), + Some(5), + None, + ) + .unwrap(); + let second = Phase1VerifiedMediaReceipt::from_commitment( + &second_reference, + BlobUrl::parse(&format!("https://cdn.example/{second_hash}.jpg")).unwrap(), + &ByteCommitment::from_bytes(b"world", MediaType::parse("image/jpeg").unwrap()), + 2, + 3, + configuration(4), + 11, + ) + .unwrap(); + let mut byte_limited = baseline.clone(); + assert_eq!( + byte_limited + .admit(&second, Phase1MediaCachePolicy::new(8, 2).unwrap(), 11) + .unwrap(), + vec![verified.artifact_id()] + ); + + let mut overflow = Phase1MediaCacheIndex { + configuration: Some(configuration(4)), + ..Phase1MediaCacheIndex::default() + }; + let mut first_entry = Phase1MediaCacheEntry::from_receipt(&verified, 10).unwrap(); + first_entry.byte_size = u64::MAX; + overflow.entries.insert(key, first_entry); + let second_key = second.artifact_id().to_hex(); + overflow.entries.insert( + second_key, + Phase1MediaCacheEntry::from_receipt(&second, 11).unwrap(), + ); + assert_eq!( + overflow.total_bytes(), + Err(Phase1InboundMediaError::CorruptState) + ); + } + + #[test] + fn cache_is_content_addressed_bounded_lru_and_configuration_scoped() { + let config = configuration(4); + let first_reference = reference(None); + let first = receipt(&first_reference, config); + let second_hash = Sha256::digest(b"world").to_hex(); + let second_reference = Phase1StructuralMediaReference::new( + format!("https://media.example/{second_hash}.jpg"), + Some(second_hash.clone()), + Some("image/jpeg".to_owned()), + Some(2), + Some(3), + Some(5), + None, + ) + .unwrap(); + let second_commitment = + ByteCommitment::from_bytes(b"world", MediaType::parse("image/jpeg").unwrap()); + let second = Phase1VerifiedMediaReceipt::from_commitment( + &second_reference, + BlobUrl::parse(&format!("https://cdn.example/{second_hash}.jpg")).unwrap(), + &second_commitment, + 2, + 3, + config, + 11, + ) + .unwrap(); + let mut cache = Phase1MediaCacheIndex::default(); + let policy = Phase1MediaCachePolicy::new(5, 1).unwrap(); + assert!(cache.admit(&first, policy, 10).unwrap().is_empty()); + let evicted = cache.admit(&second, policy, 11).unwrap(); + assert_eq!(evicted, vec![first.artifact_id()]); + assert!(!cache.contains(&first)); + assert!(cache.contains(&second)); + assert_eq!(cache.status().unwrap().artifacts, 1); + assert_eq!( + cache.admit(&second, policy, 12), + Ok(Vec::new()), + "idempotent cache admission remains bounded" + ); + assert_eq!( + cache.admit(&receipt(&first_reference, configuration(5)), policy, 13,), + Err(Phase1InboundMediaError::ConfigurationMismatch) + ); + assert_eq!( + cache.invalidate_configuration(configuration(5)), + vec![second.artifact_id()] + ); + assert_eq!(cache.status().unwrap().artifacts, 0); + } + + #[test] + fn persisted_receipt_and_cache_tamper_fail_closed() { + let structural = reference(None); + let config = configuration(7); + let mut media = MediaReference::new(structural.clone()).unwrap(); + media + .begin(Phase1InboundMediaPending::new([8; 16], config, 1).unwrap()) + .unwrap(); + let verified = receipt(&structural, config); + media.verify([8; 16], verified.clone()).unwrap(); + let mut media_value = serde_json::to_value(&media).unwrap(); + media_value["retrieval"]["evidence"]["observedSha256"] = serde_json::json!("a".repeat(64)); + let corrupt: MediaReference = serde_json::from_value(media_value).unwrap(); + assert_eq!( + corrupt.validate(), + Err(Phase1InboundMediaError::CorruptReceipt) + ); + + let mut cache = Phase1MediaCacheIndex::default(); + cache + .admit(&verified, Phase1MediaCachePolicy::new(10, 1).unwrap(), 12) + .unwrap(); + let mut cache_value = serde_json::to_value(cache).unwrap(); + let entry = cache_value["entries"] + .as_object_mut() + .unwrap() + .values_mut() + .next() + .unwrap(); + entry["byteSize"] = serde_json::json!(0); + let corrupt: Phase1MediaCacheIndex = serde_json::from_value(cache_value).unwrap(); + assert_eq!(corrupt.status(), Err(Phase1InboundMediaError::CorruptState)); + } + + #[cfg(feature = "mobile-social")] + #[tokio::test] + async fn atomic_artifact_writes_converge_and_corruption_fails_closed() { + let bytes = b"GIF89a\x02\0\x03\0"; + let hash = Sha256::digest(bytes).to_hex(); + let structural = Phase1StructuralMediaReference::new( + format!("https://media.example/{hash}.gif"), + Some(hash.clone()), + Some("image/gif".to_owned()), + Some(2), + Some(3), + Some(bytes.len() as u64), + None, + ) + .unwrap(); + let receipt = Phase1VerifiedMediaReceipt::from_commitment( + &structural, + BlobUrl::parse(&format!("https://media.example/{hash}.gif")).unwrap(), + &ByteCommitment::from_bytes(bytes, MediaType::parse("image/gif").unwrap()), + 2, + 3, + configuration(4), + 1, + ) + .unwrap(); + let root = tempfile::tempdir().unwrap(); + let directory = root.path().join("cache"); + let (left, right) = tokio::join!( + write_verified_artifact(&directory, &receipt, bytes), + write_verified_artifact(&directory, &receipt, bytes), + ); + let left = left.unwrap(); + let right = right.unwrap(); + assert_eq!(left, right); + assert_eq!(left.bytes(), bytes); + assert_eq!(tokio::fs::read(left.local_path()).await.unwrap(), bytes); + assert_eq!( + std::fs::read_dir(&directory).unwrap().count(), + 1, + "no temporary file survives a converged write" + ); + tokio::fs::write(left.local_path(), b"GIF89a\x03\0\x03\0") + .await + .unwrap(); + assert_eq!( + verified_artifact(&directory, &receipt).await, + Err(Phase1InboundMediaError::CorruptArtifact) + ); + } +} diff --git a/core/crates/tera_core/src/runtime/product_surface/model.rs b/core/crates/tera_core/src/runtime/product_surface/model.rs @@ -0,0 +1,251 @@ +use serde::{Deserialize, Serialize}; + +use super::{CardId, ContextRank, MediaReference, TodayRank}; + +/// The closed Phase 1 top-level Today taxonomy. +#[derive(Clone, Copy, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)] +#[serde(rename_all = "PascalCase")] +pub enum TodayCardType { + Update, + PhotoUpdate, + Ask, + Event, + FoodAvailability, +} + +impl TodayCardType { + pub const fn label(self) -> &'static str { + match self { + Self::Update => "Update", + Self::PhotoUpdate => "PhotoUpdate", + Self::Ask => "Ask", + Self::Event => "Event", + Self::FoodAvailability => "FoodAvailability", + } + } +} + +/// The closed Phase 1 Add command taxonomy. +#[derive(Clone, Copy, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)] +#[serde(rename_all = "PascalCase")] +pub enum AddCommandType { + CreateUpdate, + CreatePhotoUpdate, + CreateAsk, + CreateEvent, + CreateFoodAvailability, +} + +/// One exact top-level card to Add-command mapping. +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CardAddParity { + pub card_type: TodayCardType, + pub add_command_type: AddCommandType, +} + +pub const CANONICAL_TODAY_CARD_TYPES: [TodayCardType; 5] = [ + TodayCardType::Update, + TodayCardType::PhotoUpdate, + TodayCardType::Ask, + TodayCardType::Event, + TodayCardType::FoodAvailability, +]; + +pub const CANONICAL_ADD_COMMAND_TYPES: [AddCommandType; 5] = [ + AddCommandType::CreateUpdate, + AddCommandType::CreatePhotoUpdate, + AddCommandType::CreateAsk, + AddCommandType::CreateEvent, + AddCommandType::CreateFoodAvailability, +]; + +pub const CANONICAL_CARD_ADD_PARITY: [CardAddParity; 5] = [ + CardAddParity { + card_type: TodayCardType::Update, + add_command_type: AddCommandType::CreateUpdate, + }, + CardAddParity { + card_type: TodayCardType::PhotoUpdate, + add_command_type: AddCommandType::CreatePhotoUpdate, + }, + CardAddParity { + card_type: TodayCardType::Ask, + add_command_type: AddCommandType::CreateAsk, + }, + CardAddParity { + card_type: TodayCardType::Event, + add_command_type: AddCommandType::CreateEvent, + }, + CardAddParity { + card_type: TodayCardType::FoodAvailability, + add_command_type: AddCommandType::CreateFoodAvailability, + }, +]; + +/// Supporting standard profiles that enrich the product without creating cards. +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "PascalCase")] +pub enum SupportingProfile { + Profile, + Reply, + Comment, + Deletion, +} + +/// Tolerant profile attribution attached to cards and Me results. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ProfileSummary { + pub author_pubkey: String, + pub name: Option<String>, + pub display_name: Option<String>, + pub about: Option<String>, + pub picture: Option<MediaReference>, + pub banner: Option<MediaReference>, + pub nip05: Option<String>, + pub website: Option<String>, + pub lightning_address: Option<String>, +} + +/// Thread enrichment identity; replies and comments never become top-level cards. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ThreadReference { + pub profile: SupportingProfile, + pub root: String, + pub parent_event_id: String, +} + +/// One admitted reply or comment attached to its canonical root. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ThreadEntry { + pub event_id: String, + pub author_pubkey: String, + pub content: String, + pub authored_at: u64, + pub reference: ThreadReference, + pub author_profile: Option<ProfileSummary>, +} + +/// Durable local-only authored state overlaid without changing event truth. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct LocalAuthorOverlay { + pub operation_id: String, + pub state: String, +} + +/// Current rendering state derived from standard event semantics. +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "PascalCase")] +pub enum CardLifecycleState { + Active, + Sold, + Past, +} + +/// Verified, visible, context-admitted source facts for one top-level card. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ClassifiedCard { + pub schema_version: u16, + pub card_id: CardId, + pub card_type: TodayCardType, + pub source_event_id: String, + pub source_address: Option<String>, + pub author_pubkey: String, + pub contract_id: String, + pub title: Option<String>, + pub content: String, + pub authored_at: u64, + pub effective_at: u64, + pub event_start: Option<u64>, + pub event_end: Option<u64>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub location: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub price_amount: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub price_currency: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub price_unit: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub quantity: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub food_summary: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub food_published_at: Option<u64>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub food_status: Option<String>, + pub context_rank: ContextRank, + pub inclusion_reason: String, + pub media: Vec<MediaReference>, + pub lifecycle: CardLifecycleState, + pub rank: Option<TodayRank>, +} + +/// One fully enriched Today card returned to a host. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct TodayCard { + pub card: ClassifiedCard, + pub author_profile: Option<ProfileSummary>, + pub thread: Vec<ThreadEntry>, + pub local_overlay: Option<LocalAuthorOverlay>, +} + +/// One frozen, cursor-addressable Today page. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct TodayPage { + pub as_of: u64, + pub items: Vec<TodayCard>, + pub next_cursor: Option<String>, +} + +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "PascalCase")] +pub enum SearchResultType { + Card, + Profile, +} + +/// One local search result governed by the same current projection as Today. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SearchResult { + pub result_type: SearchResultType, + pub stable_id: String, + pub card: Option<TodayCard>, + pub profile: Option<ProfileSummary>, +} + +/// Active identity attribution and its current visible Phase 1 cards. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct MeSnapshot { + pub public_key: String, + pub profile: Option<ProfileSummary>, + pub cards: Vec<TodayCard>, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn card_and_add_taxonomies_are_exact_and_serialized_stably() { + assert_eq!(CANONICAL_TODAY_CARD_TYPES.len(), 5); + assert_eq!(CANONICAL_ADD_COMMAND_TYPES.len(), 5); + for (index, parity) in CANONICAL_CARD_ADD_PARITY.iter().enumerate() { + assert_eq!(parity.card_type, CANONICAL_TODAY_CARD_TYPES[index]); + assert_eq!(parity.add_command_type, CANONICAL_ADD_COMMAND_TYPES[index]); + } + assert_eq!( + serde_json::to_string(&CANONICAL_TODAY_CARD_TYPES).expect("cards"), + r#"["Update","PhotoUpdate","Ask","Event","FoodAvailability"]"# + ); + } +} diff --git a/core/crates/tera_core/src/runtime/product_surface/outbox.rs b/core/crates/tera_core/src/runtime/product_surface/outbox.rs @@ -0,0 +1,4402 @@ +use std::{ + collections::BTreeSet, + sync::Arc, + time::{SystemTime, UNIX_EPOCH}, +}; + +use radroots_blossom::{ + BlobUrl, ByteVerifiedDescriptor, MediaType, authorization::AuthoredUploadClaim, +}; +use radroots_event::{ + contract::AuthorRole, + post::deletion::{ + AuthoredNip09DeletionRequest, Nip09DeletionAddressTarget, Nip09DeletionEventTarget, + }, +}; +use radroots_event_codec::authoring::{AuthoredEventPlan, PlanWireV1}; +use radroots_identity::PublicKey; +use radroots_signing::{ + Actor, AuthoredArtifactId, SigningIntentId, SigningOperationId, + actor::ActorSource, + request::{CancellationPolicy, SignPolicy}, +}; +use radroots_storage::{ + authored::{AdmissionState, SigningState}, + authored_delivery::{AuthoredDeliveryState, DeliveryAttemptOutcome}, + authored_draft::{ + AuthoredDraft, AuthoredDraftId, AuthoredDraftRevision, AuthoredDraftStage, + AuthoredDraftStore, + }, + journal::{IdempotencyKey, OperationInstanceId}, +}; +use radroots_sync::{PushRequest, PushStatus, policy::SyncId}; +use radroots_transport::{ + Target, TargetSet, + outcome::DeliveryOutcomeKind, + policy::{SatisfactionClass, SatisfactionPolicy, TargetPolicy}, +}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use thiserror::Error; + +use super::{ + AddCommandType, CardId, CardSourceIdentity, LocalAuthorOverlay, LocalNetwork, Phase1AddCommand, + ProfileMetadataCommand, TodayCardType, TodayError, phase1_retraction_plan, +}; +use crate::runtime::RadrootsRuntime; + +const DRAFT_PAYLOAD_SCHEMA: &str = "radroots.mobile.phase1-draft.v1"; +const PROFILE_PAYLOAD_SCHEMA: &str = "radroots.mobile.phase1-profile.v1"; +const DRAFT_SCHEMA_VERSION: u16 = 1; +const DRAFT_MEDIA_MAX: usize = 20; +const DRAFT_LOCAL_REFERENCE_MAX_BYTES: usize = 4_096; +const DRAFT_FAILURE_CODE_MAX_BYTES: usize = 96; +const DRAFT_OPERATION_DOMAIN: &[u8] = b"radroots.mobile.phase1-draft-operation.v1\0"; +const ADD_DELIVERY_TIMEOUT_MS: u64 = 24 * 60 * 60 * 1_000; +const BLOSSOM_AUTHORIZATION_BACKDATE_SECONDS: u64 = 5; +const BLOSSOM_AUTHORIZATION_LIFETIME_SECONDS: u64 = 5 * 60; +const BLOSSOM_SIGNING_TIMEOUT_MS: u64 = 60 * 1_000; +const BLOSSOM_AUTHORIZATION_CONTENT: &str = "Upload exact Radroots image"; +const REVISION_RETRACTION_REASON: &str = "Replaced by a corrected Radroots event"; + +/// Product intent represented by one durable draft/outbox item. +#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum Phase1DraftKind { + #[default] + Add, + Retraction, +} + +/// Event timing profile retained for a reopenable Add form. +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum Phase1DraftEventTiming { + AllDay, + Timed, +} + +/// Secret-free, restart-safe media fields retained with an Add form. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct Phase1DraftMediaSnapshot { + pub opaque_reference: String, + pub url: String, + pub sha256: String, + pub media_type: String, + pub byte_size: u64, + pub width: u32, + pub height: u32, + pub alt: String, + pub prepared_at_unix_s: u64, +} + +/// Immutable, validated presentation input used to reopen a durable draft. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct Phase1DraftFormSnapshot { + pub command_type: AddCommandType, + pub content: String, + pub identifier: Option<String>, + pub title: Option<String>, + pub summary: Option<String>, + pub location: Option<String>, + pub event_timing: Option<Phase1DraftEventTiming>, + pub event_start_date: Option<String>, + pub event_end_date: Option<String>, + pub event_start_unix_s: Option<u64>, + pub event_end_unix_s: Option<u64>, + pub event_timezone: Option<String>, + pub price_amount: Option<String>, + pub currency: Option<String>, + pub unit: Option<String>, + pub quantity: Option<String>, + #[serde(default)] + pub food_published_at_unix_s: Option<u64>, + pub food_status: Option<String>, + pub media: Vec<Phase1DraftMediaSnapshot>, +} + +impl Phase1DraftFormSnapshot { + fn validate( + &self, + command_type: AddCommandType, + media: &[Phase1MediaPrerequisite], + ) -> Result<(), Phase1DraftError> { + let bounded = |value: &str, maximum: usize| { + value.len() <= maximum && !value.chars().any(char::is_control) + }; + if self.command_type != command_type + || self.content.len() > 65_535 + || self.media.len() != media.len() + || [ + self.identifier.as_deref(), + self.title.as_deref(), + self.summary.as_deref(), + self.location.as_deref(), + self.event_start_date.as_deref(), + self.event_end_date.as_deref(), + self.event_timezone.as_deref(), + self.price_amount.as_deref(), + self.currency.as_deref(), + self.unit.as_deref(), + self.quantity.as_deref(), + self.food_status.as_deref(), + ] + .into_iter() + .flatten() + .any(|value| !bounded(value, 1_024)) + { + return Err(Phase1DraftError::InvalidDraft); + } + for (snapshot, prerequisite) in self.media.iter().zip(media) { + if snapshot.opaque_reference.is_empty() + || snapshot.opaque_reference != prerequisite.local_reference() + || snapshot.url != prerequisite.url + || snapshot.sha256 != prerequisite.sha256() + || snapshot.media_type != prerequisite.media_type() + || snapshot.byte_size != prerequisite.byte_size() + || snapshot.width == 0 + || snapshot.height == 0 + || snapshot.alt.trim().is_empty() + || !bounded(&snapshot.alt, 1_024) + || snapshot.prepared_at_unix_s == 0 + { + return Err(Phase1DraftError::InvalidMedia); + } + } + Ok(()) + } +} + +/// Durable state of one media prerequisite referenced by an Add command. +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum Phase1MediaStage { + Pending, + Preparing, + Uploading, + Verified, + Failed, + Orphaned, +} + +/// Exact local and remote identity of one Add media prerequisite. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct Phase1MediaPrerequisite { + local_reference: String, + url: String, + sha256: String, + media_type: String, + 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>, + descriptor: &ByteVerifiedDescriptor, + ) -> Result<Self, Phase1DraftError> { + let value = Self { + local_reference: local_reference.into(), + 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) + } + + 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(); + if self.local_reference.is_empty() + || self.local_reference.len() > DRAFT_LOCAL_REFERENCE_MAX_BYTES + || self.local_reference != self.local_reference.trim() + || self.local_reference.chars().any(char::is_control) + || self.sha256 != hash + || MediaType::parse(self.media_type.as_str()).is_err() + || self.byte_size == 0 + || self.failure_code.as_ref().is_some_and(|code| { + code.is_empty() + || code.len() > DRAFT_FAILURE_CODE_MAX_BYTES + || code != code.trim() + || code.chars().any(char::is_control) + }) + || 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); + } + Ok(()) + } + + pub fn url(&self) -> &str { + self.url.as_str() + } + pub fn local_reference(&self) -> &str { + self.local_reference.as_str() + } + pub fn sha256(&self) -> &str { + self.sha256.as_str() + } + pub fn media_type(&self) -> &str { + self.media_type.as_str() + } + pub const fn byte_size(&self) -> u64 { + self.byte_size + } + 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. +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum Phase1RelaySatisfaction { + AnyAccepted, + AllAccepted, + AnyDelivered, + AllDelivered, +} + +/// Stable product-level cancellation policy persisted with queue intent. +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum Phase1CancellationPolicy { + PreservePublishedRequest, + LocalCooperative, +} + +impl Phase1CancellationPolicy { + const fn signing(self) -> CancellationPolicy { + match self { + Self::PreservePublishedRequest => CancellationPolicy::PreservePublishedRequest, + Self::LocalCooperative => CancellationPolicy::LocalCooperative, + } + } +} + +/// Exact relay and deadline intent frozen before an operation is prepared. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct Phase1QueuePolicy { + relay_urls: Vec<String>, + satisfaction: Phase1RelaySatisfaction, + delivery_deadline_unix_ms: u64, + cancellation: Phase1CancellationPolicy, +} + +impl Phase1QueuePolicy { + pub fn new( + relay_urls: Vec<String>, + satisfaction: Phase1RelaySatisfaction, + delivery_deadline_unix_ms: u64, + cancellation: Phase1CancellationPolicy, + ) -> Result<Self, Phase1DraftError> { + let value = Self { + relay_urls, + satisfaction, + delivery_deadline_unix_ms, + cancellation, + }; + value.materialize()?; + Ok(value) + } + + fn materialize( + &self, + ) -> Result<(TargetSet, SatisfactionPolicy, CancellationPolicy), Phase1DraftError> { + if self.delivery_deadline_unix_ms == 0 || self.relay_urls.is_empty() { + return Err(Phase1DraftError::InvalidQueuePolicy); + } + let mut canonical = BTreeSet::new(); + let targets = self + .relay_urls + .iter() + .map(|url| { + let target = + Target::nostr_relay(url).map_err(|_| Phase1DraftError::InvalidQueuePolicy)?; + if target.uri().as_str() != url || !canonical.insert(url.as_str()) { + return Err(Phase1DraftError::InvalidQueuePolicy); + } + Ok(target) + }) + .collect::<Result<Vec<_>, _>>()?; + let targets = TargetSet::new(targets).map_err(|_| Phase1DraftError::InvalidQueuePolicy)?; + let (class, target_policy) = match self.satisfaction { + Phase1RelaySatisfaction::AnyAccepted => { + (SatisfactionClass::Accepted, TargetPolicy::any()) + } + Phase1RelaySatisfaction::AllAccepted => { + (SatisfactionClass::Accepted, TargetPolicy::all()) + } + Phase1RelaySatisfaction::AnyDelivered => { + (SatisfactionClass::Delivered, TargetPolicy::any()) + } + Phase1RelaySatisfaction::AllDelivered => { + (SatisfactionClass::Delivered, TargetPolicy::all()) + } + }; + let cancellation = match self.cancellation { + Phase1CancellationPolicy::PreservePublishedRequest => { + CancellationPolicy::PreservePublishedRequest + } + Phase1CancellationPolicy::LocalCooperative => CancellationPolicy::LocalCooperative, + }; + Ok(( + targets, + SatisfactionPolicy::new(class, target_policy), + cancellation, + )) + } +} + +/// Existing durable draft selected for an optimistic replacement. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct Phase1ExistingDraft { + draft_id: [u8; 16], + expected_revision: u64, +} + +impl Phase1ExistingDraft { + pub fn new(draft_id: [u8; 16], expected_revision: u64) -> Result<Self, Phase1DraftError> { + AuthoredDraftId::new(draft_id).map_err(|_| Phase1DraftError::InvalidDraft)?; + AuthoredDraftRevision::new(expected_revision) + .map_err(|_| Phase1DraftError::RevisionConflict)?; + Ok(Self { + draft_id, + expected_revision, + }) + } +} + +/// One typed Add save intent. Rust supplies the creation identifier and all +/// policy timestamps; a caller can only name an existing revision to replace. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Phase1AddIntent { + command: Phase1AddCommand, + media: Vec<Phase1MediaPrerequisite>, + form: Phase1DraftFormSnapshot, + existing: Option<Phase1ExistingDraft>, +} + +impl Phase1AddIntent { + pub fn new( + command: Phase1AddCommand, + media: Vec<Phase1MediaPrerequisite>, + form: Phase1DraftFormSnapshot, + existing: Option<Phase1ExistingDraft>, + ) -> Result<Self, Phase1DraftError> { + if media.len() > DRAFT_MEDIA_MAX { + return Err(Phase1DraftError::InvalidMedia); + } + form.validate(command.command_type(), &media)?; + Ok(Self { + command, + media, + form, + existing, + }) + } +} + +/// Minimal queue intent. Relay selection, settlement, deadline, and +/// cancellation are derived from the active typed Rust transport profile. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct Phase1QueueIntent { + draft_id: [u8; 16], + expected_revision: u64, +} + +impl Phase1QueueIntent { + pub fn new(draft_id: [u8; 16], expected_revision: u64) -> Result<Self, Phase1DraftError> { + AuthoredDraftId::new(draft_id).map_err(|_| Phase1DraftError::InvalidDraft)?; + AuthoredDraftRevision::new(expected_revision) + .map_err(|_| Phase1DraftError::RevisionConflict)?; + Ok(Self { + draft_id, + expected_revision, + }) + } +} + +/// Bounded exact-byte input for one Rust-planned Blossom upload attempt. +#[derive(Clone)] +pub struct Phase1UploadIntent { + draft_id: [u8; 16], + expected_revision: u64, + bytes: Arc<[u8]>, + media_type: MediaType, + dimensions: radroots_sdk::transport::BlossomImageDimensions, +} + +impl Phase1UploadIntent { + pub fn new( + draft_id: [u8; 16], + expected_revision: u64, + bytes: Arc<[u8]>, + media_type: MediaType, + width: u32, + height: u32, + ) -> Result<Self, Phase1DraftError> { + AuthoredDraftId::new(draft_id).map_err(|_| Phase1DraftError::InvalidDraft)?; + AuthoredDraftRevision::new(expected_revision) + .map_err(|_| Phase1DraftError::RevisionConflict)?; + if bytes.is_empty() { + return Err(Phase1DraftError::InvalidMedia); + } + let dimensions = radroots_sdk::transport::BlossomImageDimensions::new(width, height) + .map_err(|_| Phase1DraftError::InvalidMedia)?; + Ok(Self { + draft_id, + expected_revision, + bytes, + media_type, + dimensions, + }) + } +} + +/// Immutable Rust-derived policy for one upload attempt. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Phase1UploadPlan { + pub authorization_content: String, + pub authorization_created_at_unix_s: u64, + pub authorization_lifetime_seconds: u64, + pub operation_id: [u8; 16], + pub artifact_id: [u8; 16], + pub signing_deadline_unix_ms: u64, + pub cancellation: Phase1CancellationPolicy, + 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)] +pub struct Phase1RevisionTarget { + command_type: AddCommandType, + card_id: CardId, + source_event_id: String, + source_kind: u32, + source_address: Option<String>, + author_public_key: String, +} + +impl Phase1RevisionTarget { + pub fn new( + command_type: AddCommandType, + card_id: CardId, + source_event_id: impl Into<String>, + source_kind: u32, + source_address: Option<String>, + author_public_key: impl Into<String>, + ) -> Result<Self, Phase1DraftError> { + let value = Self { + command_type, + card_id, + source_event_id: source_event_id.into(), + source_kind, + source_address, + author_public_key: author_public_key.into(), + }; + value.validate()?; + 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)?; + if author.to_hex() != self.author_public_key { + return Err(Phase1DraftError::InvalidRevision); + } + let event_id = radroots_event::EventId::parse(&self.source_event_id) + .map_err(|_| Phase1DraftError::InvalidRevision)?; + if event_id.to_hex() != self.source_event_id { + return Err(Phase1DraftError::InvalidRevision); + } + Nip09DeletionEventTarget::parse(&self.source_event_id, self.source_kind) + .map_err(|_| Phase1DraftError::InvalidRevision)?; + let addressable = matches!(self.source_kind, 30_402 | 31_922 | 31_923); + let source = if addressable { + let address = self + .source_address + .as_deref() + .ok_or(Phase1DraftError::InvalidRevision)?; + Nip09DeletionAddressTarget::parse(address) + .map_err(|_| Phase1DraftError::InvalidRevision)?; + let (kind, author, _) = parse_address(address)?; + if kind != self.source_kind || author != self.author_public_key { + return Err(Phase1DraftError::InvalidRevision); + } + let (_, _, identifier) = parse_address(address)?; + if format!("{kind}:{author}:{identifier}") != address { + return Err(Phase1DraftError::InvalidRevision); + } + CardSourceIdentity::address(kind, author, identifier) + .map_err(|_| Phase1DraftError::InvalidRevision)? + } else if self.source_kind != 1 || self.source_address.is_some() { + return Err(Phase1DraftError::InvalidRevision); + } else { + CardSourceIdentity::Event(event_id) + }; + let command_matches = match self.command_type { + AddCommandType::CreateUpdate + | AddCommandType::CreatePhotoUpdate + | AddCommandType::CreateAsk => self.source_kind == 1, + AddCommandType::CreateEvent => matches!(self.source_kind, 31_922 | 31_923), + AddCommandType::CreateFoodAvailability => self.source_kind == 30_402, + }; + let card_type = match self.command_type { + AddCommandType::CreateUpdate => TodayCardType::Update, + AddCommandType::CreatePhotoUpdate => TodayCardType::PhotoUpdate, + AddCommandType::CreateAsk => TodayCardType::Ask, + AddCommandType::CreateEvent => TodayCardType::Event, + AddCommandType::CreateFoodAvailability => TodayCardType::FoodAvailability, + }; + (command_matches && CardId::derive(card_type, &source) == self.card_id) + .then_some(()) + .ok_or(Phase1DraftError::InvalidRevision) + } + + pub const fn command_type(&self) -> AddCommandType { + self.command_type + } + + pub const fn card_id(&self) -> CardId { + self.card_id + } + + pub fn source_event_id(&self) -> &str { + self.source_event_id.as_str() + } + + pub const fn source_kind(&self) -> u32 { + self.source_kind + } + + pub fn source_address(&self) -> Option<&str> { + self.source_address.as_deref() + } +} + +/// Protocol-correct ordering selected from the source event kind. +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum Phase1RevisionPolicy { + ReplaceThenRetract, + AddressableReplacement, +} + +/// One complete replacement intent. The form is the canonical lossless reopen +/// snapshot; the command is its validated authored representation. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Phase1ReviseIntent { + target: Phase1RevisionTarget, + command: Phase1AddCommand, + media: Vec<Phase1MediaPrerequisite>, + form: Phase1DraftFormSnapshot, +} + +impl Phase1ReviseIntent { + pub fn new( + target: Phase1RevisionTarget, + command: Phase1AddCommand, + media: Vec<Phase1MediaPrerequisite>, + form: Phase1DraftFormSnapshot, + ) -> Result<Self, Phase1DraftError> { + target.validate()?; + if media.len() > DRAFT_MEDIA_MAX { + return Err(Phase1DraftError::InvalidMedia); + } + form.validate(command.command_type(), &media)?; + let same_family = match (target.command_type(), command.command_type()) { + ( + AddCommandType::CreateUpdate + | AddCommandType::CreatePhotoUpdate + | AddCommandType::CreateAsk, + AddCommandType::CreateUpdate + | AddCommandType::CreatePhotoUpdate + | AddCommandType::CreateAsk, + ) => true, + (left, right) => left == right, + }; + if !same_family { + return Err(Phase1DraftError::InvalidRevision); + } + Ok(Self { + target, + command, + media, + form, + }) + } +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +struct Phase1RevisionRecord { + target: Phase1RevisionTarget, + policy: Phase1RevisionPolicy, + retraction_draft_id: Option<[u8; 16]>, +} + +/// Honest aggregate state for a durable revision and its ordered child work. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum Phase1RevisionPhase { + ReplacementPending, + ReplacementFailed, + RetractionPending, + Complete, + PartialEffect, + Cancelled, +} + +#[derive(Clone, Debug)] +pub struct Phase1RevisionStatus { + replacement: Phase1DraftStatus, + retraction: Option<Phase1DraftStatus>, + target: Phase1RevisionTarget, + policy: Phase1RevisionPolicy, + phase: Phase1RevisionPhase, +} + +/// Durable kind-0 profile publication state. The profile fields remain inside +/// the canonical authored plan and are never duplicated in outbox metadata. +#[derive(Clone, Debug)] +pub struct Phase1ProfileStatus { + draft: AuthoredDraft, + state: Phase1OutboxState, + push: Option<PushStatus>, +} + +impl Phase1ProfileStatus { + pub const fn draft(&self) -> &AuthoredDraft { + &self.draft + } + + pub const fn state(&self) -> Phase1OutboxState { + self.state + } + + pub const fn push(&self) -> Option<&PushStatus> { + self.push.as_ref() + } +} + +impl Phase1RevisionStatus { + pub const fn replacement(&self) -> &Phase1DraftStatus { + &self.replacement + } + + pub const fn retraction(&self) -> Option<&Phase1DraftStatus> { + self.retraction.as_ref() + } + + pub const fn target(&self) -> &Phase1RevisionTarget { + &self.target + } + + pub const fn policy(&self) -> Phase1RevisionPolicy { + self.policy + } + + pub const fn phase(&self) -> Phase1RevisionPhase { + self.phase + } +} + +impl Phase1UploadPlan { + fn derive( + now_unix_ms: u64, + operation_id: [u8; 16], + artifact_id: [u8; 16], + ) -> Result<Self, Phase1DraftError> { + let now_unix_s = now_unix_ms / 1_000; + if now_unix_s == 0 { + return Err(Phase1DraftError::ClockUnavailable); + } + Ok(Self { + authorization_content: BLOSSOM_AUTHORIZATION_CONTENT.to_owned(), + authorization_created_at_unix_s: now_unix_s + .saturating_sub(BLOSSOM_AUTHORIZATION_BACKDATE_SECONDS), + authorization_lifetime_seconds: BLOSSOM_AUTHORIZATION_LIFETIME_SECONDS, + operation_id, + artifact_id, + signing_deadline_unix_ms: now_unix_ms + .checked_add(BLOSSOM_SIGNING_TIMEOUT_MS) + .ok_or(Phase1DraftError::DeadlineOverflow)?, + cancellation: Phase1CancellationPolicy::LocalCooperative, + updated_at_unix_ms: now_unix_ms, + }) + } +} + +/// Honest aggregate state for a local draft and its durable authored operation. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum Phase1OutboxState { + Draft, + MediaPreparing, + MediaUploading, + ReadyToSign, + Signing, + Signed, + Queued, + Delivering, + PartiallyDelivered, + Retryable, + Terminal, + Cancelled, + Complete, +} + +impl Phase1OutboxState { + pub const fn label(self) -> &'static str { + match self { + Self::Draft => "draft", + Self::MediaPreparing => "media_preparing", + Self::MediaUploading => "media_uploading", + Self::ReadyToSign => "ready_to_sign", + Self::Signing => "signing", + Self::Signed => "signed", + Self::Queued => "queued", + Self::Delivering => "delivering", + Self::PartiallyDelivered => "partially_delivered", + Self::Retryable => "retryable", + Self::Terminal => "terminal", + Self::Cancelled => "cancelled", + Self::Complete => "complete", + } + } +} + +/// Current reconstructable product view of one Add draft. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Phase1DraftStatus { + draft: AuthoredDraft, + kind: Phase1DraftKind, + command_type: AddCommandType, + form: Option<Phase1DraftFormSnapshot>, + media: Vec<Phase1MediaPrerequisite>, + state: Phase1OutboxState, + card_id: CardId, + push: Option<PushStatus>, + revision_policy: Option<Phase1RevisionPolicy>, +} + +impl Phase1DraftStatus { + pub const fn draft(&self) -> &AuthoredDraft { + &self.draft + } + pub const fn command_type(&self) -> AddCommandType { + self.command_type + } + pub const fn kind(&self) -> Phase1DraftKind { + self.kind + } + pub const fn form(&self) -> Option<&Phase1DraftFormSnapshot> { + self.form.as_ref() + } + pub fn media(&self) -> &[Phase1MediaPrerequisite] { + self.media.as_slice() + } + pub const fn state(&self) -> Phase1OutboxState { + self.state + } + pub const fn card_id(&self) -> CardId { + self.card_id + } + 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)] +pub enum Phase1DraftError { + #[error("authenticated draft identity is unavailable")] + IdentityUnavailable, + #[error("phase 1 draft input is invalid")] + InvalidDraft, + #[error("phase 1 media prerequisite is invalid")] + InvalidMedia, + #[error("phase 1 queue policy is invalid")] + InvalidQueuePolicy, + #[error("phase 1 draft revision conflicts with durable state")] + RevisionConflict, + #[error("phase 1 draft is not found")] + NotFound, + #[error("phase 1 draft is terminal")] + Terminal, + #[error("phase 1 draft media is not ready")] + MediaNotReady, + #[error("phase 1 authored operation is unavailable")] + OperationUnavailable, + #[error("phase 1 draft persistence failed")] + Storage, + #[error("phase 1 draft payload is corrupt")] + Corrupt, + #[error("phase 1 authored operation failed")] + Operation, + #[error("phase 1 Today overlay failed")] + Overlay, + #[error("phase 1 operation clock is unavailable")] + ClockUnavailable, + #[error("phase 1 operation deadline overflowed")] + DeadlineOverflow, + #[error("no configured relay authorizes publication")] + NoWritableRelay, + #[error("phase 1 revision input or ordering is invalid")] + InvalidRevision, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +struct Phase1DraftPayload { + schema_version: u16, + #[serde(default)] + kind: Phase1DraftKind, + command_type: AddCommandType, + #[serde(default)] + form: Option<Phase1DraftFormSnapshot>, + #[serde(default)] + target_card_id: Option<CardId>, + plan_wire_json: Vec<u8>, + media: Vec<Phase1MediaPrerequisite>, + queue: Option<Phase1QueuePolicy>, + #[serde(default)] + revision: Option<Phase1RevisionRecord>, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +struct Phase1ProfilePayload { + schema_version: u16, + plan_wire_json: Vec<u8>, + queue: Option<Phase1QueuePolicy>, +} + +impl Phase1ProfilePayload { + fn new(plan_wire_json: Vec<u8>) -> Result<Self, Phase1DraftError> { + let value = Self { + schema_version: DRAFT_SCHEMA_VERSION, + plan_wire_json, + queue: None, + }; + value.validate()?; + Ok(value) + } + + fn validate(&self) -> Result<(), Phase1DraftError> { + if self.schema_version != DRAFT_SCHEMA_VERSION { + return Err(Phase1DraftError::Corrupt); + } + let plan = PlanWireV1::from_json(self.plan_wire_json.as_slice()) + .map_err(|_| Phase1DraftError::Corrupt)?; + if plan.plan().body().kind() != 0 { + return Err(Phase1DraftError::Corrupt); + } + if let Some(queue) = &self.queue { + queue.materialize()?; + } + Ok(()) + } + + fn decode(draft: &AuthoredDraft) -> Result<Self, Phase1DraftError> { + if draft.payload_schema() != PROFILE_PAYLOAD_SCHEMA { + return Err(Phase1DraftError::Corrupt); + } + let value = serde_json::from_slice::<Self>(draft.payload()) + .map_err(|_| Phase1DraftError::Corrupt)?; + value.validate()?; + let plan = PlanWireV1::from_json(value.plan_wire_json.as_slice()) + .map_err(|_| Phase1DraftError::Corrupt)?; + if plan.plan().author().as_bytes() != draft.author() { + return Err(Phase1DraftError::Corrupt); + } + Ok(value) + } + + fn encode(&self) -> Result<Vec<u8>, Phase1DraftError> { + self.validate()?; + serde_json::to_vec(self).map_err(|_| Phase1DraftError::InvalidDraft) + } +} + +impl Phase1DraftPayload { + fn new( + command: &Phase1AddCommand, + plan_wire_json: Vec<u8>, + media: Vec<Phase1MediaPrerequisite>, + form: Option<Phase1DraftFormSnapshot>, + ) -> Result<Self, Phase1DraftError> { + let value = Self { + schema_version: DRAFT_SCHEMA_VERSION, + kind: Phase1DraftKind::Add, + command_type: command.command_type(), + form, + target_card_id: None, + plan_wire_json, + media, + queue: None, + revision: None, + }; + value.validate()?; + Ok(value) + } + + fn retraction( + command_type: AddCommandType, + target_card_id: CardId, + plan_wire_json: Vec<u8>, + ) -> Result<Self, Phase1DraftError> { + let value = Self { + schema_version: DRAFT_SCHEMA_VERSION, + kind: Phase1DraftKind::Retraction, + command_type, + form: None, + target_card_id: Some(target_card_id), + plan_wire_json, + media: Vec::new(), + queue: None, + revision: None, + }; + value.validate()?; + Ok(value) + } + + fn validate(&self) -> Result<(), Phase1DraftError> { + if self.schema_version != DRAFT_SCHEMA_VERSION || self.media.len() > DRAFT_MEDIA_MAX { + return Err(Phase1DraftError::Corrupt); + } + match self.kind { + Phase1DraftKind::Add => { + if self.target_card_id.is_some() { + return Err(Phase1DraftError::Corrupt); + } + if let Some(form) = &self.form { + form.validate(self.command_type, &self.media)?; + } + } + Phase1DraftKind::Retraction => { + if self.form.is_some() + || self.target_card_id.is_none() + || !self.media.is_empty() + || self.revision.is_some() + { + return Err(Phase1DraftError::Corrupt); + } + } + } + let integrity = PlanWireV1::from_json(self.plan_wire_json.as_slice()) + .map_err(|_| Phase1DraftError::Corrupt)?; + let plan = integrity.plan(); + if let Some(revision) = &self.revision { + revision.target.validate()?; + if self.kind != Phase1DraftKind::Add { + return Err(Phase1DraftError::Corrupt); + } + let expected_policy = if revision.target.source_kind == 1 { + Phase1RevisionPolicy::ReplaceThenRetract + } else { + Phase1RevisionPolicy::AddressableReplacement + }; + if revision.policy != expected_policy + || (expected_policy == Phase1RevisionPolicy::ReplaceThenRetract) + != revision.retraction_draft_id.is_some() + { + return Err(Phase1DraftError::Corrupt); + } + if let Some(child_id) = revision.retraction_draft_id { + AuthoredDraftId::new(child_id).map_err(|_| Phase1DraftError::Corrupt)?; + } + if expected_policy == Phase1RevisionPolicy::AddressableReplacement + && (plan.body().kind() != revision.target.source_kind + || card_id(self.command_type, plan)? != revision.target.card_id) + { + return Err(Phase1DraftError::InvalidRevision); + } + if expected_policy == Phase1RevisionPolicy::ReplaceThenRetract + && plan.body().kind() != 1 + { + return Err(Phase1DraftError::InvalidRevision); + } + } + let expected_media = media_urls(plan.body().tags())?; + let actual_media = self + .media + .iter() + .map(|media| { + media.validate()?; + Ok(media.url.as_str()) + }) + .collect::<Result<BTreeSet<_>, Phase1DraftError>>()?; + if expected_media != actual_media || actual_media.len() != self.media.len() { + return Err(Phase1DraftError::InvalidMedia); + } + if let Some(queue) = &self.queue { + queue.materialize()?; + } + Ok(()) + } + + fn decode(draft: &AuthoredDraft) -> Result<Self, Phase1DraftError> { + if draft.payload_schema() != DRAFT_PAYLOAD_SCHEMA { + return Err(Phase1DraftError::Corrupt); + } + let value = serde_json::from_slice::<Self>(draft.payload()) + .map_err(|_| Phase1DraftError::Corrupt)?; + value.validate()?; + let plan = PlanWireV1::from_json(value.plan_wire_json.as_slice()) + .map_err(|_| Phase1DraftError::Corrupt)?; + if plan.plan().author().as_bytes() != draft.author() { + return Err(Phase1DraftError::Corrupt); + } + Ok(value) + } + + fn encode(&self) -> Result<Vec<u8>, Phase1DraftError> { + self.validate()?; + serde_json::to_vec(self).map_err(|_| Phase1DraftError::InvalidDraft) + } +} + +impl RadrootsRuntime { + /// Persists a strict kind-0 profile intent before any signing or network + /// side effect. Identity and timestamps remain Rust-owned. + pub async fn phase1_save_profile_metadata( + &self, + command: ProfileMetadataCommand, + ) -> Result<Phase1ProfileStatus, Phase1DraftError> { + let now_unix_ms = phase1_operation_now_unix_ms()?; + let author = self.draft_author()?; + let draft_id = AuthoredDraftId::new(phase1_random_id()?) + .map_err(|_| Phase1DraftError::InvalidDraft)?; + let plan = AuthoredEventPlan::from_profile( + command.authored(), + now_unix_ms / 1_000, + hex::encode(author), + ) + .map_err(|_| Phase1DraftError::InvalidDraft)?; + let wire = PlanWireV1::from_plan(&plan) + .to_json() + .map_err(|_| Phase1DraftError::InvalidDraft)?; + let payload = Phase1ProfilePayload::new(wire)?; + let draft = AuthoredDraft::initial( + draft_id, + author, + PROFILE_PAYLOAD_SCHEMA, + payload.encode()?, + AuthoredDraftStage::Draft, + None, + now_unix_ms, + ) + .map_err(|_| Phase1DraftError::InvalidDraft)?; + let receipt = self + .storage()? + .append_authored_draft(draft, None) + .await + .map_err(map_draft_storage_error)?; + self.profile_status_from(receipt.draft().clone()).await + } + + pub async fn phase1_profile_status( + &self, + draft_id: [u8; 16], + ) -> Result<Phase1ProfileStatus, Phase1DraftError> { + let draft_id = + AuthoredDraftId::new(draft_id).map_err(|_| Phase1DraftError::InvalidDraft)?; + let head = self + .storage()? + .authored_draft_head(draft_id) + .await + .map_err(|_| Phase1DraftError::Storage)? + .ok_or(Phase1DraftError::NotFound)?; + self.profile_status_from(head).await + } + + /// Recovers and advances one profile operation through the same durable + /// sign/admit/deliver engine used by Add without exposing queue policy. + pub async fn phase1_advance_profile( + &self, + draft_id: [u8; 16], + ) -> Result<Phase1ProfileStatus, Phase1DraftError> { + let mut status = self.phase1_profile_status(draft_id).await?; + if status.draft.stage() == AuthoredDraftStage::Draft { + status = self + .phase1_queue_profile(draft_id, status.draft.revision().get()) + .await?; + } else if status.draft.stage() == AuthoredDraftStage::ReadyToSign { + status = self + .finish_profile_queue(status.draft.clone(), phase1_operation_now_unix_ms()?) + .await?; + } + if status.draft.stage() != AuthoredDraftStage::Queued + || matches!( + status.state, + Phase1OutboxState::Complete + | Phase1OutboxState::Terminal + | Phase1OutboxState::Cancelled + ) + { + return Ok(status); + } + let request = profile_push_request(&status.draft)?; + let operation_id = request.operation_id(); + let sync = self.sync()?; + let mut push = sync + .push_status(operation_id) + .await + .map_err(|_| Phase1DraftError::Operation)? + .ok_or(Phase1DraftError::Corrupt)?; + if matches!( + push.artifact().signing_state(), + SigningState::Planned | SigningState::Retryable + ) { + sync.sign_prepared(request) + .await + .map_err(|_| Phase1DraftError::Operation)?; + push = sync + .push_status(operation_id) + .await + .map_err(|_| Phase1DraftError::Operation)? + .ok_or(Phase1DraftError::Corrupt)?; + } + if push.artifact().signing_state() == SigningState::Signed + && matches!( + push.artifact().admission_state(), + AdmissionState::Pending | AdmissionState::Retryable + ) + { + sync.admit_signed(operation_id) + .await + .map_err(|_| Phase1DraftError::Operation)?; + push = sync + .push_status(operation_id) + .await + .map_err(|_| Phase1DraftError::Operation)? + .ok_or(Phase1DraftError::Corrupt)?; + } + if push.artifact().admission_state().is_admitted() + && matches!( + push.delivery_plan().state(), + AuthoredDeliveryState::Pending | AuthoredDeliveryState::Retryable + ) + { + sync.deliver_push(operation_id) + .await + .map_err(|_| Phase1DraftError::Operation)?; + } + self.phase1_profile_status(draft_id).await + } + + pub async fn phase1_cancel_profile( + &self, + draft_id: [u8; 16], + expected_revision: u64, + ) -> Result<Phase1ProfileStatus, 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)?; + Phase1ProfilePayload::decode(&head)?; + if head.stage() == AuthoredDraftStage::Cancelled { + return self.profile_status_from(head).await; + } + if head.revision() != expected { + return Err(Phase1DraftError::RevisionConflict); + } + let push = self.profile_push_status_for(&head).await?; + if let Some(status) = push { + if status.settlement().is_successful() { + return Err(Phase1DraftError::Terminal); + } + self.sync()? + .cancel_push(sync_id_for(&head)?) + .await + .map_err(|_| Phase1DraftError::Operation)?; + } + let next = head + .successor( + head.payload().to_vec(), + AuthoredDraftStage::Cancelled, + head.operation_id(), + phase1_operation_now_unix_ms()?, + ) + .map_err(|_| Phase1DraftError::RevisionConflict)?; + let receipt = storage + .append_authored_draft(next, Some(expected)) + .await + .map_err(map_draft_storage_error)?; + self.profile_status_from(receipt.draft().clone()).await + } + + /// Persists one complete Add intent with Rust-owned identity and time. + pub async fn phase1_save_add_intent( + &self, + intent: Phase1AddIntent, + ) -> Result<Phase1DraftStatus, Phase1DraftError> { + let now_unix_ms = phase1_operation_now_unix_ms()?; + let authored_at_unix_s = now_unix_ms / 1_000; + let (draft_id, expected_revision) = match intent.existing { + Some(existing) => (existing.draft_id, Some(existing.expected_revision)), + None => (phase1_random_id()?, None), + }; + self.phase1_save_draft_with_form( + draft_id, + intent.command, + authored_at_unix_s, + intent.media, + intent.form, + expected_revision, + now_unix_ms, + ) + .await + } + + /// Freezes the canonical Rust-owned queue policy for the active relay + /// profile before preparing the durable outbox operation. + pub async fn phase1_queue_add_intent( + &self, + intent: Phase1QueueIntent, + ) -> Result<Phase1DraftStatus, Phase1DraftError> { + let now_unix_ms = phase1_operation_now_unix_ms()?; + let policy = self.active_queue_policy(now_unix_ms)?; + self.phase1_queue_draft( + intent.draft_id, + intent.expected_revision, + policy, + now_unix_ms, + ) + .await + } + + fn active_queue_policy(&self, now_unix_ms: u64) -> Result<Phase1QueuePolicy, Phase1DraftError> { + let report = self + .client + .nostr_status() + .map_err(|_| Phase1DraftError::OperationUnavailable)? + .ok_or(Phase1DraftError::OperationUnavailable)?; + let relay_urls = report + .relays() + .iter() + .filter(|relay| relay.endpoint().access().can_write()) + .map(|relay| relay.endpoint().url().as_str().to_owned()) + .collect::<Vec<_>>(); + if relay_urls.is_empty() { + return Err(Phase1DraftError::NoWritableRelay); + } + let deadline = now_unix_ms + .checked_add(ADD_DELIVERY_TIMEOUT_MS) + .ok_or(Phase1DraftError::DeadlineOverflow)?; + Phase1QueuePolicy::new( + relay_urls, + Phase1RelaySatisfaction::AllAccepted, + deadline, + Phase1CancellationPolicy::LocalCooperative, + ) + } + + /// Resumes a durable queue checkpoint with a Rust-owned recovery time. + pub async fn phase1_recover_add_intent( + &self, + draft_id: [u8; 16], + ) -> Result<Phase1DraftStatus, Phase1DraftError> { + self.phase1_recover_draft_queue(draft_id, phase1_operation_now_unix_ms()?) + .await + } + + /// Plans authorization, signing, and state timestamps in Rust, then runs + /// one complete exact-byte upload attempt. + pub async fn phase1_upload_add_media_intent( + &self, + intent: Phase1UploadIntent, + ) -> Result<Phase1DraftStatus, 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 content = radroots_blossom::authorization::AuthorizationContent::parse( + &plan.authorization_content, + ) + .map_err(|_| Phase1DraftError::InvalidMedia)?; + self.phase1_upload_draft_media( + intent.draft_id, + intent.expected_revision, + request, + content, + plan.authorization_created_at_unix_s, + plan.authorization_lifetime_seconds, + plan.operation_id, + plan.artifact_id, + plan.signing_deadline_unix_ms, + plan.cancellation, + radroots_sdk::transport::BlossomCancellation::default(), + plan.updated_at_unix_ms, + ) + .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, + draft_id: [u8; 16], + expected_revision: u64, + ) -> Result<Phase1DraftStatus, Phase1DraftError> { + self.phase1_cancel_draft(draft_id, expected_revision, phase1_operation_now_unix_ms()?) + .await + } + + /// Persists the replacement half of one revision before any retraction can + /// exist. Standard kind-1 revisions receive a deterministic child draft ID + /// that remains inert until replacement settlement succeeds. + pub async fn phase1_save_revision_intent( + &self, + intent: Phase1ReviseIntent, + ) -> Result<Phase1RevisionStatus, Phase1DraftError> { + let now_unix_ms = phase1_operation_now_unix_ms()?; + let authored_at_unix_s = now_unix_ms / 1_000; + let author = self.draft_author()?; + if intent.target.author_public_key != hex::encode(author) { + return Err(Phase1DraftError::InvalidRevision); + } + let draft_id = AuthoredDraftId::new(phase1_random_id()?) + .map_err(|_| Phase1DraftError::InvalidDraft)?; + let plan = intent + .command + .authored_plan(authored_at_unix_s, hex::encode(author)) + .map_err(|_| Phase1DraftError::InvalidDraft)?; + let wire = PlanWireV1::from_plan(&plan) + .to_json() + .map_err(|_| Phase1DraftError::InvalidDraft)?; + let policy = if intent.target.source_kind == 1 { + Phase1RevisionPolicy::ReplaceThenRetract + } else { + Phase1RevisionPolicy::AddressableReplacement + }; + let retraction_draft_id = match policy { + Phase1RevisionPolicy::ReplaceThenRetract => { + let child_id = phase1_random_id()?; + if child_id == *draft_id.as_bytes() { + return Err(Phase1DraftError::OperationUnavailable); + } + Some(child_id) + } + Phase1RevisionPolicy::AddressableReplacement => None, + }; + let mut payload = + Phase1DraftPayload::new(&intent.command, wire, intent.media, Some(intent.form))?; + payload.revision = Some(Phase1RevisionRecord { + target: intent.target, + policy, + retraction_draft_id, + }); + let bytes = payload.encode()?; + let draft = AuthoredDraft::initial( + draft_id, + author, + DRAFT_PAYLOAD_SCHEMA, + bytes, + draft_stage_for_media(&payload.media), + None, + now_unix_ms, + ) + .map_err(|_| Phase1DraftError::InvalidDraft)?; + self.storage()? + .append_authored_draft(draft, None) + .await + .map_err(map_draft_storage_error)?; + self.phase1_revision_status(*draft_id.as_bytes()).await + } + + /// Reconstructs the complete ordered revision from durable replacement and + /// optional retraction child state after any process boundary. + pub async fn phase1_revision_status( + &self, + replacement_draft_id: [u8; 16], + ) -> Result<Phase1RevisionStatus, Phase1DraftError> { + let replacement = self.phase1_draft_status(replacement_draft_id).await?; + let payload = Phase1DraftPayload::decode(replacement.draft())?; + let revision = payload.revision.ok_or(Phase1DraftError::InvalidRevision)?; + let retraction = match revision.retraction_draft_id { + Some(id) => match self.phase1_draft_status(id).await { + Ok(status) => { + validate_revision_retraction(&status, &revision.target)?; + Some(status) + } + Err(Phase1DraftError::NotFound) => None, + Err(error) => return Err(error), + }, + None => None, + }; + let phase = revision_phase(&replacement, retraction.as_ref(), revision.policy); + Ok(Phase1RevisionStatus { + replacement, + retraction, + target: revision.target, + policy: revision.policy, + phase, + }) + } + + /// Advances the replacement first and creates the NIP-09 child only after + /// all configured replacement delivery targets accepted it. + pub async fn phase1_advance_revision( + &self, + replacement_draft_id: [u8; 16], + ) -> Result<Phase1RevisionStatus, Phase1DraftError> { + let mut status = self.phase1_revision_status(replacement_draft_id).await?; + if matches!( + status.replacement.state(), + Phase1OutboxState::Draft | Phase1OutboxState::ReadyToSign + ) { + self.phase1_queue_add_intent(Phase1QueueIntent::new( + replacement_draft_id, + status.replacement.draft().revision().get(), + )?) + .await?; + status = self.phase1_revision_status(replacement_draft_id).await?; + } + if matches!( + status.replacement.state(), + Phase1OutboxState::Queued + | Phase1OutboxState::Retryable + | Phase1OutboxState::PartiallyDelivered + ) { + self.phase1_advance_draft( + replacement_draft_id, + status.replacement.draft().revision().get(), + ) + .await?; + status = self.phase1_revision_status(replacement_draft_id).await?; + } + if status.replacement.state() != Phase1OutboxState::Complete + || status.policy != Phase1RevisionPolicy::ReplaceThenRetract + { + return Ok(status); + } + + let child_id = + revision_child_id(status.replacement.draft())?.ok_or(Phase1DraftError::Corrupt)?; + if status.retraction.is_none() { + self.phase1_create_revision_retraction(&status.target, child_id) + .await?; + status = self.phase1_revision_status(replacement_draft_id).await?; + } + let child = status + .retraction + .as_ref() + .ok_or(Phase1DraftError::Corrupt)?; + if matches!( + child.state(), + Phase1OutboxState::Draft | Phase1OutboxState::ReadyToSign + ) { + self.phase1_queue_add_intent(Phase1QueueIntent::new( + child_id, + child.draft().revision().get(), + )?) + .await?; + status = self.phase1_revision_status(replacement_draft_id).await?; + } + let child = status + .retraction + .as_ref() + .ok_or(Phase1DraftError::Corrupt)?; + if matches!( + child.state(), + Phase1OutboxState::Queued + | Phase1OutboxState::Retryable + | Phase1OutboxState::PartiallyDelivered + ) { + self.phase1_advance_draft(child_id, child.draft().revision().get()) + .await?; + } + self.phase1_revision_status(replacement_draft_id).await + } + + /// Cancels only still-pending work. If a kind-1 replacement is already + /// visible, a cancelled child records the deliberate partial effect and + /// prevents a later recovery from retracting the original unexpectedly. + pub async fn phase1_cancel_revision( + &self, + replacement_draft_id: [u8; 16], + ) -> Result<Phase1RevisionStatus, Phase1DraftError> { + let mut status = self.phase1_revision_status(replacement_draft_id).await?; + if !matches!( + status.replacement.state(), + Phase1OutboxState::Complete + | Phase1OutboxState::Terminal + | Phase1OutboxState::Cancelled + ) { + self.phase1_cancel_add_intent( + replacement_draft_id, + status.replacement.draft().revision().get(), + ) + .await?; + return self.phase1_revision_status(replacement_draft_id).await; + } + if status.replacement.state() == Phase1OutboxState::Complete + && status.policy == Phase1RevisionPolicy::ReplaceThenRetract + { + let child_id = + revision_child_id(status.replacement.draft())?.ok_or(Phase1DraftError::Corrupt)?; + if status.retraction.is_none() { + self.phase1_create_revision_retraction(&status.target, child_id) + .await?; + status = self.phase1_revision_status(replacement_draft_id).await?; + } + let child = status + .retraction + .as_ref() + .ok_or(Phase1DraftError::Corrupt)?; + if !matches!( + child.state(), + Phase1OutboxState::Complete + | Phase1OutboxState::Terminal + | Phase1OutboxState::Cancelled + ) { + self.phase1_cancel_add_intent(child_id, child.draft().revision().get()) + .await?; + } + } + self.phase1_revision_status(replacement_draft_id).await + } + + async fn phase1_create_revision_retraction( + &self, + target: &Phase1RevisionTarget, + draft_id: [u8; 16], + ) -> Result<Phase1DraftStatus, Phase1DraftError> { + match self.phase1_draft_status(draft_id).await { + Ok(existing) => return Ok(existing), + Err(Phase1DraftError::NotFound) => {} + Err(error) => return Err(error), + } + let now_unix_ms = phase1_operation_now_unix_ms()?; + self.phase1_save_retraction_draft( + draft_id, + target.command_type, + target.card_id, + &target.source_event_id, + target.source_kind, + target.source_address.as_deref(), + REVISION_RETRACTION_REASON, + now_unix_ms / 1_000, + now_unix_ms, + ) + .await + } + + /// Creates or replaces the editable content of one immutable-revision draft. + #[allow(clippy::too_many_arguments)] + pub async fn phase1_save_draft( + &self, + draft_id: [u8; 16], + command: Phase1AddCommand, + authored_at_unix_s: u64, + media: Vec<Phase1MediaPrerequisite>, + expected_revision: Option<u64>, + persisted_at_unix_ms: u64, + ) -> Result<Phase1DraftStatus, Phase1DraftError> { + self.phase1_save_draft_inner( + draft_id, + command, + authored_at_unix_s, + media, + None, + expected_revision, + persisted_at_unix_ms, + ) + .await + } + + /// Creates or replaces a draft while retaining its validated, reopenable form. + #[allow(clippy::too_many_arguments)] + pub async fn phase1_save_draft_with_form( + &self, + draft_id: [u8; 16], + command: Phase1AddCommand, + authored_at_unix_s: u64, + media: Vec<Phase1MediaPrerequisite>, + form: Phase1DraftFormSnapshot, + expected_revision: Option<u64>, + persisted_at_unix_ms: u64, + ) -> Result<Phase1DraftStatus, Phase1DraftError> { + self.phase1_save_draft_inner( + draft_id, + command, + authored_at_unix_s, + media, + Some(form), + expected_revision, + persisted_at_unix_ms, + ) + .await + } + + #[allow(clippy::too_many_arguments)] + async fn phase1_save_draft_inner( + &self, + draft_id: [u8; 16], + command: Phase1AddCommand, + authored_at_unix_s: u64, + media: Vec<Phase1MediaPrerequisite>, + form: Option<Phase1DraftFormSnapshot>, + expected_revision: Option<u64>, + persisted_at_unix_ms: u64, + ) -> Result<Phase1DraftStatus, Phase1DraftError> { + let author = self.draft_author()?; + let draft_id = + AuthoredDraftId::new(draft_id).map_err(|_| Phase1DraftError::InvalidDraft)?; + let plan = command + .authored_plan(authored_at_unix_s, hex::encode(author)) + .map_err(|_| Phase1DraftError::InvalidDraft)?; + let wire = PlanWireV1::from_plan(&plan) + .to_json() + .map_err(|_| Phase1DraftError::InvalidDraft)?; + let payload = Phase1DraftPayload::new(&command, wire, media, form)?; + let bytes = payload.encode()?; + let storage = self.storage()?; + let expected = expected_revision + .map(AuthoredDraftRevision::new) + .transpose() + .map_err(|_| Phase1DraftError::RevisionConflict)?; + let draft = if let Some(expected) = expected { + 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); + } + head.successor( + bytes, + draft_stage_for_media(&payload.media), + None, + persisted_at_unix_ms, + ) + .map_err(|_| Phase1DraftError::RevisionConflict)? + } else { + AuthoredDraft::initial( + draft_id, + author, + DRAFT_PAYLOAD_SCHEMA, + bytes, + draft_stage_for_media(&payload.media), + None, + persisted_at_unix_ms, + ) + .map_err(|_| Phase1DraftError::InvalidDraft)? + }; + let receipt = storage + .append_authored_draft(draft, expected) + .await + .map_err(map_draft_storage_error)?; + self.draft_status_from(receipt.draft().clone()).await + } + + /// Persists an independent strict NIP-09 retraction as a normal durable outbox item. + #[allow(clippy::too_many_arguments)] + pub async fn phase1_save_retraction_draft( + &self, + draft_id: [u8; 16], + command_type: AddCommandType, + target_card_id: CardId, + target_event_id: &str, + target_kind: u32, + target_address: Option<&str>, + reason: &str, + authored_at_unix_s: u64, + persisted_at_unix_ms: u64, + ) -> Result<Phase1DraftStatus, Phase1DraftError> { + let target_shape_valid = match command_type { + AddCommandType::CreateUpdate + | AddCommandType::CreatePhotoUpdate + | AddCommandType::CreateAsk => target_kind == 1 && target_address.is_none(), + AddCommandType::CreateEvent => { + matches!(target_kind, 31_922 | 31_923) && target_address.is_some() + } + AddCommandType::CreateFoodAvailability => { + target_kind == 30_402 && target_address.is_some() + } + }; + if !target_shape_valid || authored_at_unix_s == 0 || persisted_at_unix_ms == 0 { + return Err(Phase1DraftError::InvalidDraft); + } + let author = self.draft_author()?; + let draft_id = + AuthoredDraftId::new(draft_id).map_err(|_| Phase1DraftError::InvalidDraft)?; + let event_target = Nip09DeletionEventTarget::parse(target_event_id, target_kind) + .map_err(|_| Phase1DraftError::InvalidDraft)?; + let address_targets = target_address + .map(Nip09DeletionAddressTarget::parse) + .transpose() + .map_err(|_| Phase1DraftError::InvalidDraft)? + .into_iter() + .collect(); + let request = + AuthoredNip09DeletionRequest::new(reason, vec![event_target], address_targets) + .map_err(|_| Phase1DraftError::InvalidDraft)?; + let plan = phase1_retraction_plan(&request, authored_at_unix_s, hex::encode(author)) + .map_err(|_| Phase1DraftError::InvalidDraft)?; + let wire = PlanWireV1::from_plan(&plan) + .to_json() + .map_err(|_| Phase1DraftError::InvalidDraft)?; + let payload = Phase1DraftPayload::retraction(command_type, target_card_id, wire)?; + let bytes = payload.encode()?; + let draft = AuthoredDraft::initial( + draft_id, + author, + DRAFT_PAYLOAD_SCHEMA, + bytes, + AuthoredDraftStage::Draft, + None, + persisted_at_unix_ms, + ) + .map_err(|_| Phase1DraftError::InvalidDraft)?; + let receipt = self + .storage()? + .append_authored_draft(draft, None) + .await + .map_err(map_draft_storage_error)?; + self.draft_status_from(receipt.draft().clone()).await + } + + /// Advances one media prerequisite without mutating any prior revision. + pub async fn phase1_update_draft_media( + &self, + draft_id: [u8; 16], + expected_revision: u64, + url: &str, + stage: Phase1MediaStage, + failure_code: Option<String>, + 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 !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 = draft_stage_for_media(&payload.media); + let next = head + .successor(payload.encode()?, next_stage, 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 + } + + /// 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_stage = draft_stage_for_media(&payload.media); + let next = head + .successor(payload.encode()?, next_stage, 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_stage = draft_stage_for_media(&payload.media); + let next = head + .successor(payload.encode()?, next_stage, 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( + &self, + draft_id: [u8; 16], + expected_revision: u64, + policy: Phase1QueuePolicy, + queued_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 { + return Err(Phase1DraftError::RevisionConflict); + } + let mut payload = Phase1DraftPayload::decode(&head)?; + let ready = match head.stage() { + AuthoredDraftStage::ReadyToSign => { + if payload.queue.as_ref() != Some(&policy) { + return Err(Phase1DraftError::RevisionConflict); + } + head + } + AuthoredDraftStage::Queued => { + if payload.queue.as_ref() != Some(&policy) { + return Err(Phase1DraftError::RevisionConflict); + } + return self.draft_status_from(head).await; + } + AuthoredDraftStage::Cancelled => return Err(Phase1DraftError::Terminal), + AuthoredDraftStage::Draft + | AuthoredDraftStage::MediaPreparing + | AuthoredDraftStage::MediaUploading => { + if payload + .media + .iter() + .any(|media| !media.is_remote_verified()) + { + return Err(Phase1DraftError::MediaNotReady); + } + policy.materialize()?; + payload.queue = Some(policy); + let bytes = payload.encode()?; + let operation_id = operation_id(draft_id, bytes.as_slice())?; + let operation_id = OperationInstanceId::new(*operation_id.as_bytes()) + .map_err(|_| Phase1DraftError::InvalidDraft)?; + let ready = head + .successor( + bytes, + AuthoredDraftStage::ReadyToSign, + Some(operation_id), + queued_at_unix_ms, + ) + .map_err(|_| Phase1DraftError::RevisionConflict)?; + storage + .append_authored_draft(ready.clone(), Some(expected)) + .await + .map_err(map_draft_storage_error)?; + ready + } + }; + self.finish_queue(ready, queued_at_unix_ms).await + } + + /// Resumes a queue transition interrupted after its durable ready-to-sign + /// checkpoint, including the crash window after outbox preparation. + pub async fn phase1_recover_draft_queue( + &self, + draft_id: [u8; 16], + recovered_at_unix_ms: u64, + ) -> Result<Phase1DraftStatus, Phase1DraftError> { + let draft_id = + AuthoredDraftId::new(draft_id).map_err(|_| Phase1DraftError::InvalidDraft)?; + let head = self + .storage()? + .authored_draft_head(draft_id) + .await + .map_err(|_| Phase1DraftError::Storage)? + .ok_or(Phase1DraftError::NotFound)?; + match head.stage() { + AuthoredDraftStage::ReadyToSign => self.finish_queue(head, recovered_at_unix_ms).await, + AuthoredDraftStage::Queued | AuthoredDraftStage::Cancelled => { + self.draft_status_from(head).await + } + AuthoredDraftStage::Draft + | AuthoredDraftStage::MediaPreparing + | AuthoredDraftStage::MediaUploading => Err(Phase1DraftError::InvalidDraft), + } + } + + /// Invokes the configured opaque host signer for one durably queued draft. + /// + /// The canonical sync engine verifies author, event ID, exact fields, + /// signature, deadline, cancellation, and operation binding before the + /// signed artifact can be persisted. Delivery remains a separate phase. + pub async fn phase1_sign_queued_draft( + &self, + draft_id: [u8; 16], + expected_revision: 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 head = self + .storage()? + .authored_draft_head(draft_id) + .await + .map_err(|_| Phase1DraftError::Storage)? + .ok_or(Phase1DraftError::NotFound)?; + if head.revision() != expected || head.stage() != AuthoredDraftStage::Queued { + return Err(Phase1DraftError::RevisionConflict); + } + self.sync()? + .sign_prepared(push_request(&head)?) + .await + .map_err(|_| Phase1DraftError::Operation)?; + self.draft_status_from(head).await + } + + /// Advances one durably queued draft through signing, local admission, and + /// at most one bounded relay-delivery attempt. + pub async fn phase1_advance_draft( + &self, + draft_id: [u8; 16], + expected_revision: 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 head = self + .storage()? + .authored_draft_head(draft_id) + .await + .map_err(|_| Phase1DraftError::Storage)? + .ok_or(Phase1DraftError::NotFound)?; + if head.revision() != expected || head.stage() != AuthoredDraftStage::Queued { + return Err(Phase1DraftError::RevisionConflict); + } + let request = push_request(&head)?; + let operation_id = request.operation_id(); + let sync = self.sync()?; + let mut status = sync + .push_status(operation_id) + .await + .map_err(|_| Phase1DraftError::Operation)? + .ok_or(Phase1DraftError::Corrupt)?; + + if matches!( + status.artifact().signing_state(), + SigningState::Planned | SigningState::Retryable + ) { + sync.sign_prepared(request) + .await + .map_err(|_| Phase1DraftError::Operation)?; + status = sync + .push_status(operation_id) + .await + .map_err(|_| Phase1DraftError::Operation)? + .ok_or(Phase1DraftError::Corrupt)?; + } + if status.artifact().signing_state() == SigningState::Signed + && matches!( + status.artifact().admission_state(), + AdmissionState::Pending | AdmissionState::Retryable + ) + { + sync.admit_signed(operation_id) + .await + .map_err(|_| Phase1DraftError::Operation)?; + status = sync + .push_status(operation_id) + .await + .map_err(|_| Phase1DraftError::Operation)? + .ok_or(Phase1DraftError::Corrupt)?; + } + if status.artifact().admission_state().is_admitted() + && matches!( + status.delivery_plan().state(), + AuthoredDeliveryState::Pending | AuthoredDeliveryState::Retryable + ) + { + sync.deliver_push(operation_id) + .await + .map_err(|_| Phase1DraftError::Operation)?; + } + self.draft_status_from(head).await + } + + /// Signs one short-lived BUD-11 upload credential for HTTP use only. + /// + /// The returned value is not persisted and its distinct plan type cannot + /// enter the relay push pipeline. + #[allow(clippy::too_many_arguments)] + pub async fn phase1_authorize_blossom_upload( + &self, + operation_id: [u8; 16], + artifact_id: [u8; 16], + claim: AuthoredUploadClaim, + deadline_unix_ms: u64, + cancellation: Phase1CancellationPolicy, + ) -> Result<radroots_sdk::signing::AuthorizationHeader, Phase1DraftError> { + let public_key = self + .store_public_key + .ok_or(Phase1DraftError::IdentityUnavailable)?; + let actor = Actor::new(public_key, ActorSource::ExplicitPublicKey, AuthorRole::ALL) + .map_err(|_| Phase1DraftError::IdentityUnavailable)?; + let plan = radroots_sdk::signing::BlossomAuthorizationPlan::for_upload(&claim, public_key) + .map_err(|_| Phase1DraftError::InvalidMedia)?; + let operation_id = + SigningOperationId::new(operation_id).map_err(|_| Phase1DraftError::InvalidDraft)?; + let artifact_id = + AuthoredArtifactId::new(artifact_id).map_err(|_| Phase1DraftError::InvalidDraft)?; + let policy = SignPolicy::new(deadline_unix_ms, cancellation.signing()) + .map_err(|_| Phase1DraftError::InvalidDraft)?; + let request = radroots_sdk::signing::blossom_upload_request( + radroots_protocol::runtime::v1::OperationId::SyncPush, + SigningIntentId::new(operation_id, artifact_id), + actor, + plan, + policy, + ) + .map_err(|_| Phase1DraftError::Operation)?; + self.client + .signing() + .map_err(|_| Phase1DraftError::OperationUnavailable)? + .ok_or(Phase1DraftError::OperationUnavailable)? + .authorize_blossom_upload(request) + .await + .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 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(); + 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 claim = match blossom.authored_upload_claim( + &transaction, + 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(transaction, 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, + draft_id: [u8; 16], + ) -> Result<Phase1DraftStatus, Phase1DraftError> { + let draft_id = + AuthoredDraftId::new(draft_id).map_err(|_| Phase1DraftError::InvalidDraft)?; + let head = self + .storage()? + .authored_draft_head(draft_id) + .await + .map_err(|_| Phase1DraftError::Storage)? + .ok_or(Phase1DraftError::NotFound)?; + self.draft_status_from(head).await + } + + /// Lists the newest immutable revision of each draft for the active author. + pub async fn phase1_draft_heads( + &self, + limit: u16, + ) -> Result<Vec<Phase1DraftStatus>, Phase1DraftError> { + let drafts = self + .storage()? + .authored_draft_heads(self.draft_author()?, limit) + .await + .map_err(map_draft_storage_error)?; + let mut statuses = Vec::with_capacity(drafts.len()); + for draft in drafts { + statuses.push(self.draft_status_from(draft).await?); + } + Ok(statuses) + } + + /// Cancels still-pending authored work and records uploaded-but-unreferenced + /// media as possible orphans without deleting any evidence. + pub async fn phase1_cancel_draft( + &self, + draft_id: [u8; 16], + expected_revision: u64, + cancelled_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.stage() == AuthoredDraftStage::Cancelled { + return self.draft_status_from(head).await; + } + if head.revision() != expected { + return Err(Phase1DraftError::RevisionConflict); + } + let mut payload = Phase1DraftPayload::decode(&head)?; + let push = self.push_status_for(&head).await?; + if let Some(status) = &push { + self.sync()? + .cancel_push(sync_id_for(&head)?) + .await + .map_err(|_| Phase1DraftError::Operation)?; + if status.artifact().signed().is_none() { + mark_possible_orphans(&mut payload.media, cancelled_at_unix_ms); + } + } else { + mark_possible_orphans(&mut payload.media, cancelled_at_unix_ms); + } + let next = head + .successor( + payload.encode()?, + AuthoredDraftStage::Cancelled, + head.operation_id(), + cancelled_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 + } + + /// Applies the current durable operation state to an already-projected + /// active-author card. The overlay remains local and never changes event truth. + pub async fn phase1_apply_draft_overlay( + &self, + context: &LocalNetwork, + draft_id: [u8; 16], + ) -> Result<Phase1DraftStatus, Phase1DraftError> { + let status = self.phase1_draft_status(draft_id).await?; + let operation_id = status + .draft + .operation_id() + .map(|id| hex::encode(id.as_bytes())) + .ok_or(Phase1DraftError::OperationUnavailable)?; + self.phase1_set_local_author_overlay( + context, + status.card_id, + Some(LocalAuthorOverlay { + operation_id, + state: status.state.label().to_owned(), + }), + ) + .await + .map_err(map_overlay_error)?; + Ok(status) + } + + async fn phase1_queue_profile( + &self, + draft_id: [u8; 16], + expected_revision: u64, + ) -> Result<Phase1ProfileStatus, Phase1DraftError> { + let now_unix_ms = phase1_operation_now_unix_ms()?; + 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 { + return Err(Phase1DraftError::RevisionConflict); + } + let mut payload = Phase1ProfilePayload::decode(&head)?; + let ready = match head.stage() { + AuthoredDraftStage::Draft => { + payload.queue = Some(self.active_queue_policy(now_unix_ms)?); + let bytes = payload.encode()?; + let operation_id = operation_id(draft_id, bytes.as_slice())?; + let operation_id = OperationInstanceId::new(*operation_id.as_bytes()) + .map_err(|_| Phase1DraftError::InvalidDraft)?; + let ready = head + .successor( + bytes, + AuthoredDraftStage::ReadyToSign, + Some(operation_id), + now_unix_ms, + ) + .map_err(|_| Phase1DraftError::RevisionConflict)?; + storage + .append_authored_draft(ready.clone(), Some(expected)) + .await + .map_err(map_draft_storage_error)?; + ready + } + AuthoredDraftStage::ReadyToSign => head, + AuthoredDraftStage::Queued => return self.profile_status_from(head).await, + AuthoredDraftStage::Cancelled => return Err(Phase1DraftError::Terminal), + AuthoredDraftStage::MediaPreparing | AuthoredDraftStage::MediaUploading => { + return Err(Phase1DraftError::Corrupt); + } + }; + self.finish_profile_queue(ready, now_unix_ms).await + } + + async fn finish_profile_queue( + &self, + ready: AuthoredDraft, + queued_at_unix_ms: u64, + ) -> Result<Phase1ProfileStatus, Phase1DraftError> { + let request = profile_push_request(&ready)?; + self.sync()? + .prepare_push(request) + .await + .map_err(|_| Phase1DraftError::Operation)?; + let queued = ready + .successor( + ready.payload().to_vec(), + AuthoredDraftStage::Queued, + ready.operation_id(), + queued_at_unix_ms.max(ready.updated_at_unix_ms()), + ) + .map_err(|_| Phase1DraftError::RevisionConflict)?; + let receipt = self + .storage()? + .append_authored_draft(queued, Some(ready.revision())) + .await + .map_err(map_draft_storage_error)?; + self.profile_status_from(receipt.draft().clone()).await + } + + async fn profile_status_from( + &self, + draft: AuthoredDraft, + ) -> Result<Phase1ProfileStatus, Phase1DraftError> { + if draft.author() != &self.draft_author()? { + return Err(Phase1DraftError::Corrupt); + } + Phase1ProfilePayload::decode(&draft)?; + let push = self.profile_push_status_for(&draft).await?; + if draft.stage() == AuthoredDraftStage::Queued && push.is_none() { + return Err(Phase1DraftError::Corrupt); + } + let state = aggregate_state(&draft, push.as_ref()); + Ok(Phase1ProfileStatus { draft, state, push }) + } + + async fn profile_push_status_for( + &self, + draft: &AuthoredDraft, + ) -> Result<Option<PushStatus>, Phase1DraftError> { + let Some(_) = draft.operation_id() else { + return Ok(None); + }; + self.sync()? + .push_status(sync_id_for(draft)?) + .await + .map_err(|_| Phase1DraftError::Operation) + } + + async fn finish_queue( + &self, + ready: AuthoredDraft, + queued_at_unix_ms: u64, + ) -> Result<Phase1DraftStatus, Phase1DraftError> { + let request = push_request(&ready)?; + self.sync()? + .prepare_push(request) + .await + .map_err(|_| Phase1DraftError::Operation)?; + let queued = ready + .successor( + ready.payload().to_vec(), + AuthoredDraftStage::Queued, + ready.operation_id(), + queued_at_unix_ms.max(ready.updated_at_unix_ms()), + ) + .map_err(|_| Phase1DraftError::RevisionConflict)?; + let receipt = self + .storage()? + .append_authored_draft(queued, Some(ready.revision())) + .await + .map_err(map_draft_storage_error)?; + self.draft_status_from(receipt.draft().clone()).await + } + + async fn draft_status_from( + &self, + draft: AuthoredDraft, + ) -> Result<Phase1DraftStatus, Phase1DraftError> { + if draft.author() != &self.draft_author()? { + return Err(Phase1DraftError::Corrupt); + } + let payload = Phase1DraftPayload::decode(&draft)?; + let integrity = PlanWireV1::from_json(payload.plan_wire_json.as_slice()) + .map_err(|_| Phase1DraftError::Corrupt)?; + let card_id = payload + .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); + } + let state = aggregate_state(&draft, push.as_ref()); + Ok(Phase1DraftStatus { + draft, + kind: payload.kind, + command_type: payload.command_type, + form: payload.form, + media: payload.media, + state, + card_id, + push, + revision_policy, + }) + } + + async fn push_status_for( + &self, + draft: &AuthoredDraft, + ) -> Result<Option<PushStatus>, Phase1DraftError> { + let Some(_) = draft.operation_id() else { + return Ok(None); + }; + self.sync()? + .push_status(sync_id_for(draft)?) + .await + .map_err(|_| Phase1DraftError::Operation) + } + + fn storage(&self) -> Result<&dyn AuthoredDraftStore, Phase1DraftError> { + self.client + .storage() + .map(|storage| storage as &dyn AuthoredDraftStore) + .map_err(|_| Phase1DraftError::Storage) + } + + fn sync(&self) -> Result<radroots_sdk::sync::Operations<'_>, Phase1DraftError> { + self.client + .sync() + .map_err(|_| Phase1DraftError::OperationUnavailable)? + .ok_or(Phase1DraftError::OperationUnavailable) + } + + fn draft_author(&self) -> Result<[u8; 32], Phase1DraftError> { + self.store_public_key + .map(|key| *key.as_bytes()) + .ok_or(Phase1DraftError::IdentityUnavailable) + } +} + +fn push_request(draft: &AuthoredDraft) -> Result<PushRequest, Phase1DraftError> { + let payload = Phase1DraftPayload::decode(draft)?; + let policy = payload.queue.ok_or(Phase1DraftError::InvalidQueuePolicy)?; + let (targets, satisfaction, cancellation) = policy.materialize()?; + let plan = PlanWireV1::from_json(payload.plan_wire_json.as_slice()) + .map_err(|_| Phase1DraftError::Corrupt)? + .into_plan(); + let public_key = PublicKey::from_bytes(*draft.author()) + .map_err(|_| Phase1DraftError::IdentityUnavailable)?; + let actor = Actor::new(public_key, ActorSource::ExplicitPublicKey, AuthorRole::ALL) + .map_err(|_| Phase1DraftError::IdentityUnavailable)?; + let sync_id = sync_id_for(draft)?; + let idempotency = IdempotencyKey::parse(format!( + "phase1-draft-{}", + hex::encode(draft.draft_id().as_bytes()) + )) + .map_err(|_| Phase1DraftError::InvalidDraft)?; + PushRequest::new( + sync_id, + idempotency, + actor, + plan, + targets, + satisfaction, + policy.delivery_deadline_unix_ms, + cancellation, + ) + .map_err(|_| Phase1DraftError::InvalidQueuePolicy) +} + +fn profile_push_request(draft: &AuthoredDraft) -> Result<PushRequest, Phase1DraftError> { + let payload = Phase1ProfilePayload::decode(draft)?; + let policy = payload.queue.ok_or(Phase1DraftError::InvalidQueuePolicy)?; + let (targets, satisfaction, cancellation) = policy.materialize()?; + let plan = PlanWireV1::from_json(payload.plan_wire_json.as_slice()) + .map_err(|_| Phase1DraftError::Corrupt)? + .into_plan(); + let public_key = PublicKey::from_bytes(*draft.author()) + .map_err(|_| Phase1DraftError::IdentityUnavailable)?; + let actor = Actor::new(public_key, ActorSource::ExplicitPublicKey, AuthorRole::ALL) + .map_err(|_| Phase1DraftError::IdentityUnavailable)?; + let sync_id = sync_id_for(draft)?; + let idempotency = IdempotencyKey::parse(format!( + "phase1-profile-{}", + hex::encode(draft.draft_id().as_bytes()) + )) + .map_err(|_| Phase1DraftError::InvalidDraft)?; + PushRequest::new( + sync_id, + idempotency, + actor, + plan, + targets, + satisfaction, + policy.delivery_deadline_unix_ms, + cancellation, + ) + .map_err(|_| Phase1DraftError::InvalidQueuePolicy) +} + +fn operation_id( + draft_id: AuthoredDraftId, + ready_payload: &[u8], +) -> Result<SyncId, Phase1DraftError> { + let mut hasher = Sha256::new(); + hasher.update(DRAFT_OPERATION_DOMAIN); + hasher.update(draft_id.as_bytes()); + hasher.update( + u64::try_from(ready_payload.len()) + .map_err(|_| Phase1DraftError::InvalidDraft)? + .to_be_bytes(), + ); + hasher.update(ready_payload); + let digest: [u8; 32] = hasher.finalize().into(); + let mut value = [0_u8; 16]; + value.copy_from_slice(&digest[..16]); + if value.iter().all(|byte| *byte == 0) { + value[15] = 1; + } + SyncId::new(value).map_err(|_| Phase1DraftError::InvalidDraft) +} + +fn sync_id_for(draft: &AuthoredDraft) -> Result<SyncId, Phase1DraftError> { + draft + .operation_id() + .ok_or(Phase1DraftError::OperationUnavailable) + .and_then(|id| SyncId::new(*id.as_bytes()).map_err(|_| Phase1DraftError::Corrupt)) +} + +fn media_urls(tags: &[Vec<String>]) -> Result<BTreeSet<&str>, Phase1DraftError> { + let mut urls = BTreeSet::new(); + for tag in tags { + let candidate = match tag.first().map(String::as_str) { + Some("imeta") => tag + .iter() + .skip(1) + .find_map(|value| value.strip_prefix("url ")), + Some("image") => tag.get(1).map(String::as_str), + _ => None, + }; + if let Some(candidate) = candidate + && BlobUrl::parse(candidate).is_ok() + && !urls.insert(candidate) + { + return Err(Phase1DraftError::InvalidMedia); + } + } + Ok(urls) +} + +fn draft_stage_for_media(media: &[Phase1MediaPrerequisite]) -> AuthoredDraftStage { + if media.is_empty() { + AuthoredDraftStage::Draft + } else if media + .iter() + .any(|media| media.stage == Phase1MediaStage::Uploading) + { + AuthoredDraftStage::MediaUploading + } else { + AuthoredDraftStage::MediaPreparing + } +} + +fn mark_possible_orphans(media: &mut [Phase1MediaPrerequisite], recorded_at_unix_ms: u64) { + for media in media { + 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, + }); + } + } +} + +const fn valid_media_transition(previous: Phase1MediaStage, next: Phase1MediaStage) -> bool { + match previous { + Phase1MediaStage::Pending => matches!( + next, + Phase1MediaStage::Pending + | Phase1MediaStage::Preparing + | Phase1MediaStage::Uploading + | Phase1MediaStage::Failed + ), + Phase1MediaStage::Preparing => matches!( + next, + Phase1MediaStage::Preparing | Phase1MediaStage::Uploading | Phase1MediaStage::Failed + ), + Phase1MediaStage::Uploading => matches!( + next, + Phase1MediaStage::Uploading | Phase1MediaStage::Verified | Phase1MediaStage::Failed + ), + Phase1MediaStage::Failed => matches!( + next, + Phase1MediaStage::Preparing | Phase1MediaStage::Uploading | Phase1MediaStage::Failed + ), + Phase1MediaStage::Verified => matches!(next, Phase1MediaStage::Verified), + Phase1MediaStage::Orphaned => matches!(next, Phase1MediaStage::Orphaned), + } +} + +fn aggregate_state(draft: &AuthoredDraft, push: Option<&PushStatus>) -> Phase1OutboxState { + if draft.stage() == AuthoredDraftStage::Cancelled { + return Phase1OutboxState::Cancelled; + } + let Some(push) = push else { + return match draft.stage() { + AuthoredDraftStage::Draft => Phase1OutboxState::Draft, + AuthoredDraftStage::MediaPreparing => Phase1OutboxState::MediaPreparing, + AuthoredDraftStage::MediaUploading => Phase1OutboxState::MediaUploading, + AuthoredDraftStage::ReadyToSign => Phase1OutboxState::ReadyToSign, + AuthoredDraftStage::Queued => Phase1OutboxState::Queued, + AuthoredDraftStage::Cancelled => Phase1OutboxState::Cancelled, + }; + }; + if push.settlement().is_successful() { + return Phase1OutboxState::Complete; + } + if push.settlement().has_failures() { + return if has_delivery_success(push) { + Phase1OutboxState::PartiallyDelivered + } else if push.settlement().retryable() != 0 || push.settlement().delivery_retryable() != 0 + { + Phase1OutboxState::Retryable + } else if push.settlement().cancelled() != 0 || push.settlement().delivery_cancelled() != 0 + { + Phase1OutboxState::Cancelled + } else { + Phase1OutboxState::Terminal + }; + } + match push.artifact().signing_state() { + SigningState::Planned if push.artifact().signing_claim().is_some() => { + Phase1OutboxState::Signing + } + SigningState::Planned => Phase1OutboxState::Queued, + SigningState::Retryable => Phase1OutboxState::Retryable, + SigningState::Indeterminate | SigningState::FailedTerminal => Phase1OutboxState::Terminal, + SigningState::Cancelled => Phase1OutboxState::Cancelled, + SigningState::Signed => match push.artifact().admission_state() { + AdmissionState::Pending => Phase1OutboxState::Signed, + AdmissionState::Retryable => Phase1OutboxState::Retryable, + AdmissionState::Rejected => Phase1OutboxState::Terminal, + AdmissionState::Cancelled => Phase1OutboxState::Cancelled, + AdmissionState::Inserted | AdmissionState::Duplicate => match push + .delivery_plan() + .state() + { + AuthoredDeliveryState::Pending + if push.delivery_plan().claim_evidence().is_some() => + { + Phase1OutboxState::Delivering + } + AuthoredDeliveryState::Pending if push.delivery_plan().attempts().is_empty() => { + Phase1OutboxState::Queued + } + AuthoredDeliveryState::Pending => Phase1OutboxState::PartiallyDelivered, + AuthoredDeliveryState::Retryable if has_delivery_success(push) => { + Phase1OutboxState::PartiallyDelivered + } + AuthoredDeliveryState::Retryable => Phase1OutboxState::Retryable, + AuthoredDeliveryState::Satisfied => Phase1OutboxState::Complete, + AuthoredDeliveryState::Exhausted | AuthoredDeliveryState::FailedTerminal => { + Phase1OutboxState::Terminal + } + AuthoredDeliveryState::Cancelled => Phase1OutboxState::Cancelled, + }, + }, + } +} + +fn has_delivery_success(push: &PushStatus) -> bool { + push.delivery_plan().attempts().iter().any(|attempt| { + let evidence = match attempt.outcome() { + DeliveryAttemptOutcome::Receipt(receipt) => receipt.target_receipts(), + DeliveryAttemptOutcome::SinkFailure(failure) => failure.partial_evidence(), + }; + evidence.iter().any(|receipt| { + matches!( + receipt.outcome().kind(), + DeliveryOutcomeKind::Accepted | DeliveryOutcomeKind::Delivered + ) + }) + }) +} + +fn revision_phase( + replacement: &Phase1DraftStatus, + retraction: Option<&Phase1DraftStatus>, + policy: Phase1RevisionPolicy, +) -> Phase1RevisionPhase { + match replacement.state() { + Phase1OutboxState::Cancelled => return Phase1RevisionPhase::Cancelled, + Phase1OutboxState::Terminal => return Phase1RevisionPhase::ReplacementFailed, + Phase1OutboxState::Complete => {} + _ => return Phase1RevisionPhase::ReplacementPending, + } + if policy == Phase1RevisionPolicy::AddressableReplacement { + return Phase1RevisionPhase::Complete; + } + match retraction.map(Phase1DraftStatus::state) { + Some(Phase1OutboxState::Complete) => Phase1RevisionPhase::Complete, + Some(Phase1OutboxState::Cancelled | Phase1OutboxState::Terminal) => { + Phase1RevisionPhase::PartialEffect + } + Some(_) | None => Phase1RevisionPhase::RetractionPending, + } +} + +fn revision_child_id(draft: &AuthoredDraft) -> Result<Option<[u8; 16]>, Phase1DraftError> { + Ok(Phase1DraftPayload::decode(draft)? + .revision + .ok_or(Phase1DraftError::InvalidRevision)? + .retraction_draft_id) +} + +fn validate_revision_retraction( + status: &Phase1DraftStatus, + target: &Phase1RevisionTarget, +) -> Result<(), Phase1DraftError> { + if status.kind() != Phase1DraftKind::Retraction + || status.command_type() != target.command_type + || status.card_id() != target.card_id + { + return Err(Phase1DraftError::Corrupt); + } + let payload = Phase1DraftPayload::decode(status.draft())?; + let plan = PlanWireV1::from_json(payload.plan_wire_json.as_slice()) + .map_err(|_| Phase1DraftError::Corrupt)?; + if plan.plan().body().kind() != 5 + || !plan.plan().body().tags().iter().any(|tag| { + tag.first().map(String::as_str) == Some("e") + && tag.get(1).map(String::as_str) == Some(target.source_event_id()) + }) + || target.source_address().is_some_and(|address| { + !plan.plan().body().tags().iter().any(|tag| { + tag.first().map(String::as_str) == Some("a") + && tag.get(1).map(String::as_str) == Some(address) + }) + }) + { + return Err(Phase1DraftError::Corrupt); + } + Ok(()) +} + +fn parse_address(address: &str) -> Result<(u32, &str, &str), Phase1DraftError> { + let mut parts = address.splitn(3, ':'); + let kind = parts + .next() + .and_then(|value| value.parse::<u32>().ok()) + .ok_or(Phase1DraftError::InvalidRevision)?; + let author = parts.next().ok_or(Phase1DraftError::InvalidRevision)?; + let identifier = parts.next().ok_or(Phase1DraftError::InvalidRevision)?; + if author.len() != 64 || identifier.is_empty() { + return Err(Phase1DraftError::InvalidRevision); + } + Ok((kind, author, identifier)) +} + +fn card_id( + command_type: AddCommandType, + plan: &radroots_event_codec::authoring::AuthoredEventPlan, +) -> Result<CardId, Phase1DraftError> { + let card_type = match command_type { + AddCommandType::CreateUpdate => TodayCardType::Update, + AddCommandType::CreatePhotoUpdate => TodayCardType::PhotoUpdate, + AddCommandType::CreateAsk => TodayCardType::Ask, + AddCommandType::CreateEvent => TodayCardType::Event, + AddCommandType::CreateFoodAvailability => TodayCardType::FoodAvailability, + }; + let source = if (30_000..40_000).contains(&plan.body().kind()) { + let identifier = plan + .body() + .tags() + .iter() + .find(|tag| tag.first().map(String::as_str) == Some("d") && tag.len() == 2) + .and_then(|tag| tag.get(1)) + .ok_or(Phase1DraftError::Corrupt)?; + CardSourceIdentity::address( + plan.body().kind(), + plan.author().to_hex(), + identifier.clone(), + ) + .map_err(|_| Phase1DraftError::Corrupt)? + } else { + CardSourceIdentity::Event(*plan.expected_event_id()) + }; + Ok(CardId::derive(card_type, &source)) +} + +fn map_draft_storage_error(error: radroots_storage::Error) -> Phase1DraftError { + match error { + radroots_storage::Error::DraftRevisionConflict => Phase1DraftError::RevisionConflict, + radroots_storage::Error::DraftNotFound => Phase1DraftError::NotFound, + radroots_storage::Error::CorruptAuthoredDraft => Phase1DraftError::Corrupt, + _ => Phase1DraftError::Storage, + } +} + +fn map_overlay_error(_: TodayError) -> Phase1DraftError { + Phase1DraftError::Overlay +} + +/// Captures the canonical wall-clock input for Rust-owned Phase 1 policy. +pub fn phase1_operation_now_unix_ms() -> Result<u64, Phase1DraftError> { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .ok() + .and_then(|duration| u64::try_from(duration.as_millis()).ok()) + .filter(|value| *value >= 1_000) + .ok_or(Phase1DraftError::ClockUnavailable) +} + +/// Generates a canonical public identifier for an addressable Add form. +pub fn phase1_new_addressable_identifier() -> String { + uuid::Uuid::new_v4().simple().to_string() +} + +/// Generates one opaque operation identity for host-visible cancellation and +/// receipt correlation without delegating identity policy to the host. +pub fn phase1_new_operation_id() -> Result<[u8; 16], Phase1DraftError> { + phase1_random_id() +} + +fn phase1_random_id() -> Result<[u8; 16], Phase1DraftError> { + let value = *uuid::Uuid::new_v4().as_bytes(); + if value.iter().all(|byte| *byte == 0) { + return Err(Phase1DraftError::OperationUnavailable); + } + Ok(value) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::runtime::product_surface::{ + CANONICAL_ADD_COMMAND_TYPES, CreateAsk, CreateEvent, CreateFoodAvailability, + CreatePhotoUpdate, CreateUpdate, + }; + use crate::runtime::{ + builder::RuntimeBuilder, + store::{MobileUserStoreConfig, ProtectedDataAvailability}, + }; + use radroots_blossom::{BlobDescriptor, Sha256 as BlossomSha256}; + use radroots_event::{ + calendar::{AuthoredCalendarDateEvent, AuthoredCalendarTimeEvent, CalendarDate}, + food::availability::{ + FoodAvailabilityDetails, FoodAvailabilityDetailsParts, FoodAvailabilityStatus, + FoodContent, FoodCurrency, FoodIdentifier, FoodPrice, FoodPublishedAt, FoodText, + FoodUnit, + }, + media::AuthoredImage, + post::{AuthoredPostImage, PostImageDimensions}, + }; + use radroots_sdk::ClientBuilder; + + const AUTHOR: &str = "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798"; + const SECRET: &str = "0000000000000000000000000000000000000000000000000000000000000001"; + + fn runtime() -> RadrootsRuntime { + RadrootsRuntime::from_client_builder( + ClientBuilder::memory_default(), + Some(PublicKey::from_hex(AUTHOR).unwrap()), + None, + None, + None, + None, + ) + .unwrap() + } + + fn profiled_runtime(profile: radroots_sdk::transport::RelayProfile) -> RadrootsRuntime { + RadrootsRuntime::from_client_builder( + ClientBuilder::memory_default(), + Some(PublicKey::from_hex(AUTHOR).unwrap()), + None, + None, + Some(profile), + None, + ) + .unwrap() + } + + fn signing_runtime() -> RadrootsRuntime { + let signer = radroots_nostr::signing::LocalSigner::new( + radroots_nostr::key::SecretKey::parse(SECRET).unwrap(), + ) + .unwrap(); + RadrootsRuntime::from_client_builder( + ClientBuilder::memory_default(), + Some(PublicKey::from_hex(AUTHOR).unwrap()), + None, + Some(std::sync::Arc::new(signer)), + None, + None, + ) + .unwrap() + } + + fn policy() -> Phase1QueuePolicy { + Phase1QueuePolicy::new( + vec![ + "wss://relay-one.example".to_owned(), + "wss://relay-two.example".to_owned(), + ], + Phase1RelaySatisfaction::AllAccepted, + 2_000_000_000_000, + Phase1CancellationPolicy::LocalCooperative, + ) + .unwrap() + } + + fn update_form() -> Phase1DraftFormSnapshot { + Phase1DraftFormSnapshot { + command_type: AddCommandType::CreateUpdate, + content: "Harvest update".to_owned(), + identifier: None, + title: None, + summary: None, + location: None, + event_timing: None, + event_start_date: None, + event_end_date: None, + event_start_unix_s: None, + event_end_unix_s: None, + event_timezone: None, + price_amount: None, + currency: None, + unit: None, + quantity: None, + food_published_at_unix_s: None, + food_status: None, + media: Vec::new(), + } + } + + #[test] + fn upload_policy_derivation_is_exact_and_bounded() { + let plan = Phase1UploadPlan::derive(1_800_000_000_000, [7; 16], [8; 16]).unwrap(); + assert_eq!(plan.authorization_content, BLOSSOM_AUTHORIZATION_CONTENT); + assert_eq!(plan.authorization_created_at_unix_s, 1_799_999_995); + assert_eq!(plan.authorization_lifetime_seconds, 300); + assert_eq!(plan.operation_id, [7; 16]); + assert_eq!(plan.artifact_id, [8; 16]); + assert_eq!(plan.signing_deadline_unix_ms, 1_800_000_060_000); + assert_eq!( + plan.cancellation, + Phase1CancellationPolicy::LocalCooperative + ); + assert_eq!(plan.updated_at_unix_ms, 1_800_000_000_000); + assert_eq!( + Phase1UploadPlan::derive(u64::MAX, [7; 16], [8; 16]).unwrap_err(), + Phase1DraftError::DeadlineOverflow + ); + } + + #[tokio::test] + async fn add_intent_owns_draft_identity_time_and_writable_relay_policy() { + let profile = radroots_sdk::transport::RelayProfile::explicit( + radroots_sdk::transport::RelayProfileKind::Public, + [ + radroots_sdk::transport::RelayEndpoint::new( + "wss://read.example", + radroots_sdk::transport::RelayUrlPolicy::Public, + radroots_sdk::transport::RelayAccess::ReadOnly, + ) + .unwrap(), + radroots_sdk::transport::RelayEndpoint::new( + "wss://write.example", + radroots_sdk::transport::RelayUrlPolicy::Public, + radroots_sdk::transport::RelayAccess::ReadWrite, + ) + .unwrap(), + ], + ) + .unwrap(); + let runtime = profiled_runtime(profile); + let saved = runtime + .phase1_save_add_intent( + Phase1AddIntent::new( + Phase1AddCommand::CreateUpdate(CreateUpdate::new("Harvest update").unwrap()), + Vec::new(), + update_form(), + None, + ) + .unwrap(), + ) + .await + .unwrap(); + assert_ne!(saved.draft().draft_id().as_bytes(), &[0; 16]); + assert!(saved.draft().created_at_unix_ms() >= 1_700_000_000_000); + + let queued = runtime + .phase1_queue_add_intent( + Phase1QueueIntent::new( + *saved.draft().draft_id().as_bytes(), + saved.draft().revision().get(), + ) + .unwrap(), + ) + .await + .unwrap(); + let payload = Phase1DraftPayload::decode(queued.draft()).unwrap(); + let queue = payload.queue.unwrap(); + assert_eq!(queue.relay_urls, vec!["wss://write.example"]); + assert_eq!(queue.satisfaction, Phase1RelaySatisfaction::AllAccepted); + assert_eq!( + queue.cancellation, + Phase1CancellationPolicy::LocalCooperative + ); + assert!( + queue.delivery_deadline_unix_ms + >= queued.draft().updated_at_unix_ms() + ADD_DELIVERY_TIMEOUT_MS + ); + } + + #[tokio::test] + async fn profile_metadata_uses_the_durable_outbox_with_stable_operation_identity() { + let profile = radroots_sdk::transport::RelayProfile::explicit( + radroots_sdk::transport::RelayProfileKind::Public, + [radroots_sdk::transport::RelayEndpoint::new( + "wss://write.example", + radroots_sdk::transport::RelayUrlPolicy::Public, + radroots_sdk::transport::RelayAccess::ReadWrite, + ) + .unwrap()], + ) + .unwrap(); + let runtime = profiled_runtime(profile); + let saved = runtime + .phase1_save_profile_metadata( + ProfileMetadataCommand::new( + "grower".to_owned(), + Some("Local Grower".to_owned()), + Some("Seasonal produce".to_owned()), + None, + None, + Some("grower@farm.example".to_owned()), + Some(false), + ) + .unwrap(), + ) + .await + .unwrap(); + let operation_id = *saved.draft().draft_id().as_bytes(); + assert_eq!(saved.state(), Phase1OutboxState::Draft); + assert_eq!( + PlanWireV1::from_json( + Phase1ProfilePayload::decode(saved.draft()) + .unwrap() + .plan_wire_json + .as_slice(), + ) + .unwrap() + .plan() + .body() + .kind(), + 0 + ); + + let queued = runtime + .phase1_queue_profile(operation_id, saved.draft().revision().get()) + .await + .unwrap(); + assert_eq!(queued.state(), Phase1OutboxState::Queued); + assert_eq!(*queued.draft().draft_id().as_bytes(), operation_id); + let cancelled = runtime + .phase1_cancel_profile(operation_id, queued.draft().revision().get()) + .await + .unwrap(); + assert_eq!(cancelled.state(), Phase1OutboxState::Cancelled); + assert_eq!(*cancelled.draft().draft_id().as_bytes(), operation_id); + } + + #[tokio::test] + async fn add_queue_intent_fails_closed_without_a_writable_relay() { + let profile = radroots_sdk::transport::RelayProfile::explicit( + radroots_sdk::transport::RelayProfileKind::Public, + [radroots_sdk::transport::RelayEndpoint::new( + "wss://read.example", + radroots_sdk::transport::RelayUrlPolicy::Public, + radroots_sdk::transport::RelayAccess::ReadOnly, + ) + .unwrap()], + ) + .unwrap(); + let runtime = profiled_runtime(profile); + let saved = runtime + .phase1_save_add_intent( + Phase1AddIntent::new( + Phase1AddCommand::CreateUpdate(CreateUpdate::new("Harvest update").unwrap()), + Vec::new(), + update_form(), + None, + ) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!( + runtime + .phase1_queue_add_intent( + Phase1QueueIntent::new( + *saved.draft().draft_id().as_bytes(), + saved.draft().revision().get(), + ) + .unwrap(), + ) + .await + .unwrap_err(), + Phase1DraftError::NoWritableRelay + ); + } + + #[tokio::test] + async fn addressable_revision_preserves_every_form_field_and_colon_identifier() { + 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::from_source( + AddCommandType::CreateEvent, + target_card, + "b".repeat(64), + Some(format!("31923:{AUTHOR}:{identifier}")), + AUTHOR, + ) + .unwrap(); + let event = AuthoredCalendarTimeEvent::new(identifier, "Evening market", 1_900_003_600) + .unwrap() + .with_end(1_900_007_200) + .unwrap() + .with_start_tzid("America/Vancouver") + .unwrap() + .with_end_tzid("America/Vancouver") + .unwrap() + .with_locations(vec!["Town square".to_owned()]) + .unwrap() + .with_description("Bring reusable bags") + .unwrap(); + let form = Phase1DraftFormSnapshot { + command_type: AddCommandType::CreateEvent, + content: "Bring reusable bags".to_owned(), + identifier: Some(identifier.to_owned()), + title: Some("Evening market".to_owned()), + summary: Some("Local farms and neighbours".to_owned()), + location: Some("Town square".to_owned()), + event_timing: Some(Phase1DraftEventTiming::Timed), + event_start_date: None, + event_end_date: None, + event_start_unix_s: Some(1_900_003_600), + event_end_unix_s: Some(1_900_007_200), + event_timezone: Some("America/Vancouver".to_owned()), + price_amount: Some("5".to_owned()), + currency: Some("CAD".to_owned()), + unit: Some("entry".to_owned()), + quantity: Some("100".to_owned()), + food_published_at_unix_s: Some(1_900_000_000), + food_status: Some("active".to_owned()), + media: Vec::new(), + }; + let runtime = runtime(); + let saved = runtime + .phase1_save_revision_intent( + Phase1ReviseIntent::new( + target.clone(), + Phase1AddCommand::CreateEvent(CreateEvent::time(event)), + Vec::new(), + form.clone(), + ) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(saved.policy(), Phase1RevisionPolicy::AddressableReplacement); + assert_eq!(saved.phase(), Phase1RevisionPhase::ReplacementPending); + assert_eq!(saved.target(), &target); + assert_eq!(saved.replacement().card_id(), target_card); + assert_eq!(saved.replacement().form(), Some(&form)); + assert!(saved.retraction().is_none()); + assert_eq!( + parse_address(saved.target().source_address().unwrap()) + .unwrap() + .2, + identifier + ); + } + + #[tokio::test] + async fn addressable_revision_rejects_identity_change_and_preserves_date_boundary() { + let identifier = "winter:market"; + let target_card = CardId::derive( + TodayCardType::Event, + &CardSourceIdentity::address(31_922, AUTHOR, identifier).unwrap(), + ); + let target = Phase1RevisionTarget::new( + AddCommandType::CreateEvent, + target_card, + "d".repeat(64), + 31_922, + Some(format!("31922:{AUTHOR}:{identifier}")), + AUTHOR, + ) + .unwrap(); + let form = Phase1DraftFormSnapshot { + command_type: AddCommandType::CreateEvent, + content: "New Year farm market".to_owned(), + identifier: Some(identifier.to_owned()), + title: Some("Winter market".to_owned()), + summary: None, + location: Some("Barn".to_owned()), + event_timing: Some(Phase1DraftEventTiming::AllDay), + event_start_date: Some("2026-12-31".to_owned()), + event_end_date: Some("2027-01-01".to_owned()), + event_start_unix_s: None, + event_end_unix_s: None, + event_timezone: None, + price_amount: None, + currency: None, + unit: None, + quantity: None, + food_published_at_unix_s: None, + food_status: None, + media: Vec::new(), + }; + let event = AuthoredCalendarDateEvent::new( + identifier, + "Winter market", + CalendarDate::parse("2026-12-31").unwrap(), + ) + .unwrap() + .with_end(CalendarDate::parse("2027-01-01").unwrap()) + .unwrap() + .with_description("New Year farm market") + .unwrap() + .with_locations(vec!["Barn".to_owned()]) + .unwrap(); + let runtime = runtime(); + let saved = runtime + .phase1_save_revision_intent( + Phase1ReviseIntent::new( + target.clone(), + Phase1AddCommand::CreateEvent(CreateEvent::date(event)), + Vec::new(), + form.clone(), + ) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(saved.replacement().form(), Some(&form)); + + let changed_identity = AuthoredCalendarDateEvent::new( + "different-market", + "Winter market", + CalendarDate::parse("2026-12-31").unwrap(), + ) + .unwrap(); + assert_eq!( + runtime + .phase1_save_revision_intent( + Phase1ReviseIntent::new( + target, + Phase1AddCommand::CreateEvent(CreateEvent::date(changed_identity)), + Vec::new(), + Phase1DraftFormSnapshot { + identifier: Some("different-market".to_owned()), + ..form + }, + ) + .unwrap(), + ) + .await + .unwrap_err(), + Phase1DraftError::InvalidRevision + ); + } + + #[tokio::test] + async fn kind_one_revision_never_creates_retraction_before_replacement_acceptance() { + let profile = radroots_sdk::transport::RelayProfile::explicit( + radroots_sdk::transport::RelayProfileKind::Public, + [radroots_sdk::transport::RelayEndpoint::new( + "wss://offline.example", + radroots_sdk::transport::RelayUrlPolicy::Public, + radroots_sdk::transport::RelayAccess::ReadWrite, + ) + .unwrap()], + ) + .unwrap(); + let signer = radroots_nostr::signing::LocalSigner::new( + radroots_nostr::key::SecretKey::parse(SECRET).unwrap(), + ) + .unwrap(); + let runtime = RadrootsRuntime::from_client_builder( + ClientBuilder::memory_default(), + Some(PublicKey::from_hex(AUTHOR).unwrap()), + None, + Some(std::sync::Arc::new(signer)), + Some(profile), + None, + ) + .unwrap(); + let source_event_id = "b".repeat(64); + let source = + CardSourceIdentity::Event(radroots_event::EventId::parse(&source_event_id).unwrap()); + let target = Phase1RevisionTarget::new( + AddCommandType::CreatePhotoUpdate, + CardId::derive(TodayCardType::PhotoUpdate, &source), + source_event_id, + 1, + None, + AUTHOR, + ) + .unwrap(); + let (command, media) = photo_command(); + let replacement_content = format!("Harvest photo {}", media.url()); + let media_form = Phase1DraftMediaSnapshot { + opaque_reference: media.local_reference().to_owned(), + url: media.url().to_owned(), + sha256: media.sha256().to_owned(), + media_type: media.media_type().to_owned(), + byte_size: media.byte_size(), + width: 1200, + height: 900, + alt: "Harvest".to_owned(), + prepared_at_unix_s: 1_784_347_100, + }; + let saved = runtime + .phase1_save_revision_intent( + Phase1ReviseIntent::new( + target, + command, + vec![media], + Phase1DraftFormSnapshot { + command_type: AddCommandType::CreatePhotoUpdate, + content: replacement_content, + media: vec![media_form], + ..update_form() + }, + ) + .unwrap(), + ) + .await + .unwrap(); + let replacement_id = *saved.replacement().draft().draft_id().as_bytes(); + assert_eq!(saved.policy(), Phase1RevisionPolicy::ReplaceThenRetract); + assert_eq!(saved.replacement().media().len(), 1); + assert_eq!(saved.replacement().form().unwrap().media.len(), 1); + assert!(saved.retraction().is_none()); + + let queued = runtime + .phase1_queue_add_intent( + Phase1QueueIntent::new( + replacement_id, + saved.replacement().draft().revision().get(), + ) + .unwrap(), + ) + .await + .unwrap(); + runtime + .phase1_sign_queued_draft(replacement_id, queued.draft().revision().get()) + .await + .unwrap(); + let recovered = runtime + .phase1_revision_status(replacement_id) + .await + .unwrap(); + assert!(recovered.retraction().is_none()); + assert_eq!(recovered.phase(), Phase1RevisionPhase::ReplacementPending); + + let cancelled = runtime + .phase1_cancel_revision(replacement_id) + .await + .unwrap(); + assert_eq!(cancelled.phase(), Phase1RevisionPhase::Cancelled); + assert!(cancelled.retraction().is_none()); + } + + #[tokio::test] + async fn revision_coordinator_reopens_from_sqlite_without_losing_ordering() { + let root = tempfile::tempdir().unwrap(); + let store = MobileUserStoreConfig::from_encoded( + root.path(), + AUTHOR, + "0404040404040404040404040404040404040404040404040404040404040404", + 1_900_000_000_000, + ProtectedDataAvailability::Available, + ) + .unwrap(); + std::fs::create_dir_all(store.owner_directory()).unwrap(); + let runtime = RuntimeBuilder::new(store.clone()).build().await.unwrap(); + let source_event_id = "c".repeat(64); + let target = Phase1RevisionTarget::new( + AddCommandType::CreateAsk, + CardId::derive( + TodayCardType::Ask, + &CardSourceIdentity::Event( + radroots_event::EventId::parse(&source_event_id).unwrap(), + ), + ), + source_event_id, + 1, + None, + AUTHOR, + ) + .unwrap(); + let saved = runtime + .phase1_save_revision_intent( + Phase1ReviseIntent::new( + target.clone(), + Phase1AddCommand::CreateAsk( + CreateAsk::new("Who has seed potatoes now?", Vec::new()).unwrap(), + ), + Vec::new(), + Phase1DraftFormSnapshot { + command_type: AddCommandType::CreateAsk, + content: "Who has seed potatoes now?".to_owned(), + ..update_form() + }, + ) + .unwrap(), + ) + .await + .unwrap(); + let id = *saved.replacement().draft().draft_id().as_bytes(); + let queued = runtime + .phase1_queue_draft( + id, + saved.replacement().draft().revision().get(), + policy(), + saved.replacement().draft().updated_at_unix_ms() + 1, + ) + .await + .unwrap(); + runtime.shutdown().await.unwrap(); + drop(runtime); + + let reopened = RuntimeBuilder::new(store).build().await.unwrap(); + let recovered = reopened.phase1_revision_status(id).await.unwrap(); + assert_eq!(recovered.target(), &target); + assert_eq!(recovered.policy(), Phase1RevisionPolicy::ReplaceThenRetract); + assert_eq!(recovered.phase(), Phase1RevisionPhase::ReplacementPending); + assert_eq!( + recovered.replacement().draft().revision(), + queued.draft().revision() + ); + assert_eq!(recovered.replacement().state(), Phase1OutboxState::Queued); + assert!(recovered.retraction().is_none()); + assert_eq!( + recovered.replacement().form().unwrap().content, + "Who has seed potatoes now?" + ); + reopened.shutdown().await.unwrap(); + } + + #[tokio::test] + async fn form_snapshots_reopen_exactly_and_freeze_after_queue() { + let runtime = runtime(); + let id = [6; 16]; + let saved = runtime + .phase1_save_draft_with_form( + id, + Phase1AddCommand::CreateUpdate(CreateUpdate::new("Harvest update").unwrap()), + 1_900_000_000, + Vec::new(), + update_form(), + None, + 10, + ) + .await + .unwrap(); + assert_eq!(saved.kind(), Phase1DraftKind::Add); + assert_eq!(saved.form(), Some(&update_form())); + assert_eq!( + runtime.phase1_draft_status(id).await.unwrap().form(), + Some(&update_form()) + ); + + let queued = runtime + .phase1_queue_draft(id, 1, policy(), 11) + .await + .unwrap(); + assert_eq!(queued.form(), Some(&update_form())); + assert_eq!( + runtime + .phase1_save_draft_with_form( + id, + Phase1AddCommand::CreateUpdate(CreateUpdate::new("Changed").unwrap()), + 1_900_000_001, + Vec::new(), + update_form(), + Some(queued.draft().revision().get()), + 12, + ) + .await + .unwrap_err(), + Phase1DraftError::RevisionConflict + ); + } + + #[tokio::test] + async fn retraction_is_independent_and_add_advance_attempts_delivery() { + let runtime = signing_runtime(); + let target = CardId::parse(&"a".repeat(64)).unwrap(); + let retraction = runtime + .phase1_save_retraction_draft( + [5; 16], + AddCommandType::CreateUpdate, + target, + &"b".repeat(64), + 1, + None, + "Replaced by a corrected copy", + 1_900_000_000, + 20, + ) + .await + .unwrap(); + assert_eq!(retraction.kind(), Phase1DraftKind::Retraction); + assert_eq!(retraction.card_id(), target); + assert!(retraction.form().is_none()); + let queued = runtime + .phase1_queue_draft([5; 16], retraction.draft().revision().get(), policy(), 21) + .await + .unwrap(); + let signed_retraction = runtime + .phase1_sign_queued_draft([5; 16], queued.draft().revision().get()) + .await; + let signed_retraction = signed_retraction.unwrap(); + assert_eq!(signed_retraction.kind(), Phase1DraftKind::Retraction); + let push = signed_retraction + .push() + .expect("durable retraction operation"); + assert_eq!( + push.artifact() + .signed() + .expect("signed retraction") + .event() + .kind(), + 5 + ); + + let saved = runtime + .phase1_save_draft( + [4; 16], + Phase1AddCommand::CreateUpdate(CreateUpdate::new("Deliver me").unwrap()), + 1_900_000_001, + Vec::new(), + None, + 30, + ) + .await + .unwrap(); + let queued = runtime + .phase1_queue_draft([4; 16], saved.draft().revision().get(), policy(), 31) + .await + .unwrap(); + let _ = runtime + .phase1_advance_draft([4; 16], queued.draft().revision().get()) + .await; + let advanced = runtime.phase1_draft_status([4; 16]).await.unwrap(); + let push = advanced.push().expect("durable add operation"); + assert!(push.artifact().admission_state().is_admitted()); + assert!(!push.delivery_plan().attempts().is_empty()); + assert!(matches!( + advanced.state(), + Phase1OutboxState::Retryable + | Phase1OutboxState::PartiallyDelivered + | Phase1OutboxState::Complete + | Phase1OutboxState::Terminal + )); + } + + #[tokio::test] + async fn media_free_draft_queues_offline_and_recovers_exactly() { + let runtime = runtime(); + let id = [7; 16]; + let draft = runtime + .phase1_save_draft( + id, + Phase1AddCommand::CreateUpdate(CreateUpdate::new("Harvest").unwrap()), + 1_900_000_000, + Vec::new(), + None, + 10, + ) + .await + .unwrap(); + assert_eq!(draft.state(), Phase1OutboxState::Draft); + let queued = runtime + .phase1_queue_draft(id, 1, policy(), 11) + .await + .unwrap(); + assert_eq!(queued.state(), Phase1OutboxState::Queued); + assert_eq!(queued.draft().revision().get(), 3); + assert!(queued.push().is_some()); + let recovered = runtime.phase1_recover_draft_queue(id, 12).await.unwrap(); + assert_eq!(recovered.draft(), queued.draft()); + assert_eq!(recovered.push(), queued.push()); + } + + #[tokio::test] + async fn queue_recovery_closes_both_preparation_crash_windows() { + let runtime = runtime(); + for (id_byte, prepare_before_recovery) in [(10, false), (11, true)] { + let id = [id_byte; 16]; + let saved = runtime + .phase1_save_draft( + id, + Phase1AddCommand::CreateUpdate(CreateUpdate::new("Recover").unwrap()), + 1_900_000_010, + Vec::new(), + None, + 40, + ) + .await + .unwrap(); + let mut payload = Phase1DraftPayload::decode(saved.draft()).unwrap(); + payload.queue = Some(policy()); + let bytes = payload.encode().unwrap(); + let draft_id = saved.draft().draft_id(); + let operation = operation_id(draft_id, bytes.as_slice()).unwrap(); + let operation = OperationInstanceId::new(*operation.as_bytes()).unwrap(); + let ready = saved + .draft() + .successor(bytes, AuthoredDraftStage::ReadyToSign, Some(operation), 41) + .unwrap(); + runtime + .storage() + .unwrap() + .append_authored_draft(ready.clone(), Some(saved.draft().revision())) + .await + .unwrap(); + if prepare_before_recovery { + runtime + .sync() + .unwrap() + .prepare_push(push_request(&ready).unwrap()) + .await + .unwrap(); + } + let recovered = runtime.phase1_recover_draft_queue(id, 42).await.unwrap(); + assert_eq!(recovered.draft().stage(), AuthoredDraftStage::Queued); + assert_eq!(recovered.draft().revision().get(), 3); + assert!(recovered.push().is_some()); + } + } + + #[tokio::test] + async fn all_five_add_flows_queue_and_sign_without_network_access() { + let runtime = signing_runtime(); + let (photo, media) = photo_command(); + let commands = [ + ( + Phase1AddCommand::CreateUpdate(CreateUpdate::new("Harvest update").unwrap()), + Vec::new(), + ), + (photo, vec![media]), + ( + Phase1AddCommand::CreateAsk(CreateAsk::new("Who has basil?", Vec::new()).unwrap()), + Vec::new(), + ), + ( + Phase1AddCommand::CreateEvent(CreateEvent::date( + AuthoredCalendarDateEvent::new( + "market-day", + "Saturday Market", + CalendarDate::parse("2026-08-08").unwrap(), + ) + .unwrap(), + )), + Vec::new(), + ), + ( + Phase1AddCommand::CreateFoodAvailability(CreateFoodAvailability::new(food())), + Vec::new(), + ), + ]; + for (index, (command, media)) in commands.into_iter().enumerate() { + let mut id = [30; 16]; + id[15] = u8::try_from(index + 1).unwrap(); + let saved = runtime + .phase1_save_draft( + id, + command, + 1_784_347_200, + media, + None, + 100 + u64::try_from(index).unwrap() * 10, + ) + .await + .unwrap(); + let queued = runtime + .phase1_queue_draft( + id, + saved.draft().revision().get(), + policy(), + 101 + u64::try_from(index).unwrap() * 10, + ) + .await + .unwrap(); + assert_eq!(queued.state(), Phase1OutboxState::Queued); + assert_eq!(queued.command_type(), CANONICAL_ADD_COMMAND_TYPES[index]); + let signed = runtime + .phase1_sign_queued_draft(id, queued.draft().revision().get()) + .await + .unwrap(); + assert_eq!(signed.state(), Phase1OutboxState::Signed); + assert_eq!( + signed + .push() + .and_then(|push| push.artifact().signed()) + .expect("signed artifact") + .event() + .kind(), + match index { + 0..=2 => 1, + 3 => 31_922, + 4 => 30_402, + _ => unreachable!(), + } + ); + } + } + + #[tokio::test] + async fn blossom_authorization_uses_the_same_opaque_signer_but_never_the_outbox() { + use radroots_blossom::authorization::{ + AuthorizationContent, AuthorizationTarget, AuthorizationValidation, ServerDomain, + }; + + let runtime = signing_runtime(); + let hash = BlossomSha256::digest(b"exact upload bytes"); + let server = ServerDomain::parse("media.example").unwrap(); + let claim = AuthoredUploadClaim::new( + AuthorizationContent::parse("Upload exact Radroots image").unwrap(), + server.clone(), + hash, + 1_900_000_000, + 60, + ) + .unwrap(); + let header = runtime + .phase1_authorize_blossom_upload( + [71; 16], + [72; 16], + claim, + u64::MAX, + Phase1CancellationPolicy::LocalCooperative, + ) + .await + .unwrap(); + let verified = radroots_nostr::blossom::decode_verify_authorization_header( + header.as_str(), + &AuthorizationValidation::bud11( + AuthorizationTarget::Upload(hash), + server, + 1_900_000_001, + ), + ) + .unwrap(); + assert_eq!(verified.claim().hashes(), &[hash]); + assert!(runtime.phase1_draft_heads(10).await.unwrap().is_empty()); + } + + #[tokio::test] + async fn cancellation_is_terminal_and_preserves_operation_evidence() { + let runtime = runtime(); + let id = [8; 16]; + runtime + .phase1_save_draft( + id, + Phase1AddCommand::CreateUpdate(CreateUpdate::new("Cancelled").unwrap()), + 1_900_000_001, + Vec::new(), + None, + 20, + ) + .await + .unwrap(); + let queued = runtime + .phase1_queue_draft(id, 1, policy(), 21) + .await + .unwrap(); + let cancelled = runtime + .phase1_cancel_draft(id, queued.draft().revision().get(), 22) + .await + .unwrap(); + assert_eq!(cancelled.state(), Phase1OutboxState::Cancelled); + let push = cancelled.push().expect("retained push evidence"); + assert_eq!(push.artifact().signing_state(), SigningState::Cancelled); + assert_eq!( + push.delivery_plan().state(), + AuthoredDeliveryState::Cancelled + ); + assert!(push.settlement().is_settled()); + } + + #[tokio::test] + 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) + .await + .unwrap(); + assert_eq!(saved.state(), Phase1OutboxState::MediaPreparing); + assert_eq!( + runtime + .phase1_queue_draft(id, 1, policy(), 31) + .await + .unwrap_err(), + Phase1DraftError::MediaNotReady + ); + let preparing = runtime + .phase1_update_draft_media( + id, + 1, + saved.media()[0].url(), + Phase1MediaStage::Preparing, + None, + 31, + ) + .await + .unwrap(); + let uploading = runtime + .phase1_update_draft_media( + id, + 2, + preparing.media()[0].url(), + Phase1MediaStage::Uploading, + None, + 32, + ) + .await + .unwrap(); + assert_eq!( + runtime + .phase1_update_draft_media( + id, + 3, + uploading.media()[0].url(), + Phase1MediaStage::Verified, + None, + 33, + ) + .await + .unwrap_err(), + Phase1DraftError::InvalidMedia + ); + 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); + } + + #[test] + fn queue_policy_rejects_duplicates_and_noncanonical_relays() { + assert!( + Phase1QueuePolicy::new( + vec!["wss://relay.example".into(), "wss://relay.example".into()], + Phase1RelaySatisfaction::AnyAccepted, + 1, + Phase1CancellationPolicy::LocalCooperative, + ) + .is_err() + ); + assert!( + Phase1QueuePolicy::new( + vec!["WSS://relay.example".into()], + Phase1RelaySatisfaction::AnyAccepted, + 1, + Phase1CancellationPolicy::LocalCooperative, + ) + .is_err() + ); + } + + fn photo_command() -> (Phase1AddCommand, Phase1MediaPrerequisite) { + let bytes = b"harvest-photo"; + let hash = BlossomSha256::digest(bytes); + let url = format!("https://media.example/{hash}.webp"); + let media_type = MediaType::parse("image/webp").unwrap(); + let descriptor = BlobDescriptor::new( + BlobUrl::parse(url.as_str()).unwrap(), + hash, + bytes.len() as u64, + media_type.clone(), + 1_784_347_100, + ) + .unwrap() + .approve_reference() + .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(), + "Harvest", + ) + .unwrap(); + let command = Phase1AddCommand::CreatePhotoUpdate( + CreatePhotoUpdate::new(format!("Harvest photo {url}"), vec![image]).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) + } + + fn food() -> FoodAvailabilityDetails { + FoodAvailabilityDetails::new(FoodAvailabilityDetailsParts { + content: FoodContent::new("Carrots available this week.").unwrap(), + identifier: FoodIdentifier::parse("nantes-carrots").unwrap(), + title: FoodText::new("Nantes Carrots").unwrap(), + summary: FoodText::new("Fresh bunches").unwrap(), + published_at: FoodPublishedAt::new(1_784_347_100).unwrap(), + location: FoodText::new("Central Saanich, BC").unwrap(), + price: FoodPrice::new("3", FoodCurrency::parse("CAD").unwrap(), FoodUnit::Pound) + .unwrap(), + quantity: None, + status: FoodAvailabilityStatus::Active, + images: Vec::new(), + }) + .unwrap() + } +} diff --git a/core/crates/tera_core/src/runtime/product_surface/projection.rs b/core/crates/tera_core/src/runtime/product_surface/projection.rs @@ -0,0 +1,646 @@ +use radroots_event::food::availability::FoodAvailabilityStatus; +use radroots_event_codec::{ + admission::RadrootsAdmittedEvent, decode::post::RadrootsPostClassification, +}; +use serde::{Deserialize, Serialize}; + +use super::{ + CardId, CardLifecycleState, CardSourceIdentity, ClassifiedCard, ContextAdmission, + LocalNetworkAdmission, MediaReference, Phase1StructuralMediaReference, SupportingProfile, + TodayCardType, +}; + +const CLASSIFIED_CARD_SCHEMA_VERSION: u16 = 1; + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum ProductEventClassification { + Card(Box<ClassifiedCard>), + Supporting(SupportingProfile), + Excluded(ProductEventExclusion), +} + +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "PascalCase")] +pub enum ProductEventExclusion { + LocalityNonmatch, + UnsupportedProfile, + InvalidSourceIdentity, +} + +/// Classifies an already signature/id-verified and standard-profile-admitted event. +/// +/// The caller must supply the result of the selected LocalNetwork admission. +/// Replacement and deletion are applied by storage before the event reaches +/// this boundary. No content prose or remote media retrieval influences type. +pub fn classify_admitted_event( + admitted: &RadrootsAdmittedEvent, + context: LocalNetworkAdmission, +) -> ProductEventClassification { + let context = match context { + LocalNetworkAdmission::Included(context) => context, + LocalNetworkAdmission::Excluded { .. } => { + return ProductEventClassification::Excluded(ProductEventExclusion::LocalityNonmatch); + } + }; + + match admitted { + RadrootsAdmittedEvent::Profile(_) => supporting(SupportingProfile::Profile), + RadrootsAdmittedEvent::Reply(_) => supporting(SupportingProfile::Reply), + RadrootsAdmittedEvent::Comment(_) => supporting(SupportingProfile::Comment), + RadrootsAdmittedEvent::DeletionRequest(_) => supporting(SupportingProfile::Deletion), + RadrootsAdmittedEvent::RootPost(event) => { + let card_type = match event.projection().classification() { + RadrootsPostClassification::Update => TodayCardType::Update, + RadrootsPostClassification::PhotoUpdate => TodayCardType::PhotoUpdate, + RadrootsPostClassification::Ask => TodayCardType::Ask, + RadrootsPostClassification::ThreadExcluded => { + return ProductEventClassification::Excluded( + ProductEventExclusion::UnsupportedProfile, + ); + } + _ => { + return ProductEventClassification::Excluded( + ProductEventExclusion::UnsupportedProfile, + ); + } + }; + card( + admitted, + card_type, + context, + post_media(event.projection()), + CardLifecycleState::Active, + ) + } + RadrootsAdmittedEvent::FoodAvailability(event) => { + let lifecycle = match event.projection().status() { + FoodAvailabilityStatus::Active => CardLifecycleState::Active, + FoodAvailabilityStatus::Sold => CardLifecycleState::Sold, + }; + card( + admitted, + TodayCardType::FoodAvailability, + context, + food_media(event.projection()), + lifecycle, + ) + } + RadrootsAdmittedEvent::ContractValidated(event) => match event.contract_id() { + "radroots.calendar.date_event.v1" | "radroots.calendar.time_event.v1" => card( + admitted, + TodayCardType::Event, + context, + calendar_media(event.event().tags_as_vec()), + CardLifecycleState::Active, + ), + _ => ProductEventClassification::Excluded(ProductEventExclusion::UnsupportedProfile), + }, + _ => ProductEventClassification::Excluded(ProductEventExclusion::UnsupportedProfile), + } +} + +const fn supporting(profile: SupportingProfile) -> ProductEventClassification { + ProductEventClassification::Supporting(profile) +} + +fn card( + admitted: &RadrootsAdmittedEvent, + card_type: TodayCardType, + context: ContextAdmission, + media: Vec<MediaReference>, + lifecycle: CardLifecycleState, +) -> ProductEventClassification { + let event = admitted.event(); + let source = match card_type { + TodayCardType::Update | TodayCardType::PhotoUpdate | TodayCardType::Ask => { + CardSourceIdentity::Event(*event.id()) + } + TodayCardType::Event | TodayCardType::FoodAvailability => { + let identifier = event + .tags_as_vec() + .into_iter() + .find(|tag| tag.first().map(String::as_str) == Some("d")) + .and_then(|tag| tag.get(1).cloned()); + let Some(identifier) = identifier else { + return ProductEventClassification::Excluded( + ProductEventExclusion::InvalidSourceIdentity, + ); + }; + let Ok(source) = + CardSourceIdentity::address(event.kind_u32(), event.author().to_hex(), identifier) + else { + return ProductEventClassification::Excluded( + ProductEventExclusion::InvalidSourceIdentity, + ); + }; + source + } + }; + let source_address = match &source { + CardSourceIdentity::Event(_) => None, + CardSourceIdentity::Address { + kind, + author_pubkey, + identifier, + } => Some(format!("{kind}:{author_pubkey}:{identifier}")), + }; + let tags = event.tags_as_vec(); + let title = tag_value(&tags, &["title", "name"]); + let location = matches!( + card_type, + TodayCardType::Event | TodayCardType::FoodAvailability + ) + .then(|| tag_value(&tags, &["location"])) + .flatten(); + let price = matches!(card_type, TodayCardType::FoodAvailability) + .then(|| tag_values(&tags, "price")) + .flatten(); + let price_amount = price.as_ref().and_then(|values| values.first().cloned()); + let price_currency = price.as_ref().and_then(|values| values.get(1).cloned()); + let price_unit = matches!(card_type, TodayCardType::FoodAvailability) + .then(|| tag_value(&tags, &["radroots:price_unit"])) + .flatten(); + let quantity = matches!(card_type, TodayCardType::FoodAvailability) + .then(|| tag_values(&tags, "radroots:quantity")) + .flatten() + .and_then(|values| values.first().cloned()); + let food_summary = matches!(card_type, TodayCardType::FoodAvailability) + .then(|| tag_value(&tags, &["summary"])) + .flatten(); + let food_published_at = matches!(card_type, TodayCardType::FoodAvailability) + .then(|| tag_time(&tags, "published_at")) + .flatten(); + let food_status = matches!(card_type, TodayCardType::FoodAvailability) + .then(|| tag_value(&tags, &["status"])) + .flatten(); + let (effective_at, event_start, event_end) = match card_type { + TodayCardType::Event => { + let start = tag_time(&tags, "start").unwrap_or_else(|| event.created_at_u64()); + (start, Some(start), tag_time(&tags, "end")) + } + TodayCardType::FoodAvailability => ( + tag_time(&tags, "published_at").unwrap_or_else(|| event.created_at_u64()), + None, + None, + ), + TodayCardType::Update | TodayCardType::PhotoUpdate | TodayCardType::Ask => { + (event.created_at_u64(), None, None) + } + }; + ProductEventClassification::Card(Box::new(ClassifiedCard { + schema_version: CLASSIFIED_CARD_SCHEMA_VERSION, + card_id: CardId::derive(card_type, &source), + card_type, + source_event_id: event.id_hex(), + source_address, + author_pubkey: event.author().to_hex(), + contract_id: admitted.contract_id().to_owned(), + title, + content: event.content().to_owned(), + authored_at: event.created_at_u64(), + effective_at, + event_start, + event_end, + location, + price_amount, + price_currency, + price_unit, + quantity, + food_summary, + food_published_at, + food_status, + context_rank: context.rank, + inclusion_reason: context.reason.to_owned(), + media, + lifecycle, + rank: None, + })) +} + +fn tag_value(tags: &[Vec<String>], names: &[&str]) -> Option<String> { + tags.iter().find_map(|tag| { + names + .contains(&tag.first()?.as_str()) + .then(|| tag.get(1).cloned()) + .flatten() + }) +} + +fn tag_values(tags: &[Vec<String>], name: &str) -> Option<Vec<String>> { + tags.iter().find_map(|tag| { + (tag.first().map(String::as_str) == Some(name) && tag.len() > 1).then(|| tag[1..].to_vec()) + }) +} + +fn tag_time(tags: &[Vec<String>], name: &str) -> Option<u64> { + let value = tag_value(tags, &[name])?; + value.parse().ok().or_else(|| { + chrono::NaiveDate::parse_from_str(&value, "%Y-%m-%d") + .ok()? + .and_hms_opt(0, 0, 0)? + .and_utc() + .timestamp() + .try_into() + .ok() + }) +} + +fn post_media( + projection: &radroots_event_codec::decode::post::RadrootsInboundPostProjection, +) -> Vec<MediaReference> { + projection + .imeta() + .iter() + .filter_map(|media| { + let dimensions = media.dimensions(); + media_reference( + media.url()?, + media.sha256().map(str::to_owned), + media.media_type().map(str::to_owned), + dimensions.map(|value| value.width()), + dimensions.map(|value| value.height()), + media.size(), + media.alt().map(str::to_owned), + ) + }) + .collect() +} + +fn food_media( + projection: &radroots_event_codec::decode::food_availability::RadrootsInboundFoodAvailabilityProjection, +) -> Vec<MediaReference> { + projection + .images() + .iter() + .filter_map(|media| { + let dimensions = media.dimensions(); + media_reference( + media.url()?, + blossom_digest(media.url()?), + None, + dimensions.map(|value| value.width()), + dimensions.map(|value| value.height()), + None, + None, + ) + }) + .collect() +} + +fn calendar_media(tags: Vec<Vec<String>>) -> Vec<MediaReference> { + tags.into_iter() + .find(|tag| tag.first().map(String::as_str) == Some("image")) + .and_then(|tag| tag.get(1).cloned()) + .and_then(|url| media_reference(&url, blossom_digest(&url), None, None, None, None, None)) + .into_iter() + .collect() +} + +#[allow(clippy::too_many_arguments)] +fn media_reference( + url: &str, + sha256: Option<String>, + media_type: Option<String>, + width: Option<u32>, + height: Option<u32>, + byte_size: Option<u64>, + alt: Option<String>, +) -> Option<MediaReference> { + Phase1StructuralMediaReference::new(url, sha256, media_type, width, height, byte_size, alt) + .and_then(MediaReference::new) + .ok() +} + +fn blossom_digest(url: &str) -> Option<String> { + let path = url.split_once("://")?.1.split_once('/')?.1; + let candidate = path.split(['.', '/', '?', '#']).next()?; + (candidate.len() == 64 + && candidate + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))) + .then(|| candidate.to_owned()) +} + +#[cfg(test)] +mod tests { + use super::*; + use nostr::secp256k1::Message; + use nostr::{Keys, SECP256K1}; + use radroots_event::{ + Event, envelope::EventEnvelopeParts, wire::compute_canonical_nip01_event_id, + }; + use radroots_event_codec::{admission::admit_verified_event, verify::verify_nip01_event}; + + const SECRET: &str = "10c5304d6c9ae3a1a16f7860f1cc8f5e3a76225a2663b3a989a0d775919b7df5"; + + fn admitted(kind: u32, tags: Vec<Vec<&str>>, content: &str) -> RadrootsAdmittedEvent { + admitted_owned( + kind, + tags.into_iter() + .map(|tag| tag.into_iter().map(str::to_owned).collect()) + .collect(), + content, + ) + } + + fn admitted_owned(kind: u32, tags: Vec<Vec<String>>, content: &str) -> RadrootsAdmittedEvent { + let keys = Keys::parse(SECRET).expect("key"); + let author = keys.public_key().to_string(); + let created_at = 2_000_000_000; + let id = compute_canonical_nip01_event_id(&author, created_at, kind, &tags, content) + .expect("id"); + let message = Message::from_digest(*id.as_bytes()); + let signature = SECP256K1.sign_schnorr_no_aux_rand( + &message, + &nostr::secp256k1::Keypair::from_secret_key(SECP256K1, keys.secret_key()), + ); + let event = Event::new(EventEnvelopeParts { + id: id.to_hex(), + author, + created_at, + kind, + tags, + content: content.into(), + sig: signature.to_string(), + }) + .expect("event"); + let verified = verify_nip01_event(event).expect("verified"); + admit_verified_event(verified).expect("admitted") + } + + const fn context() -> LocalNetworkAdmission { + LocalNetworkAdmission::Included(ContextAdmission { + rank: super::super::ContextRank::MissingLocalityFallback, + reason: "locality_missing_fallback", + }) + } + + fn card_type(event: RadrootsAdmittedEvent) -> TodayCardType { + match classify_admitted_event(&event, context()) { + ProductEventClassification::Card(card) => card.card_type, + other => panic!("expected card, got {other:?}"), + } + } + + fn card(event: RadrootsAdmittedEvent) -> ClassifiedCard { + match classify_admitted_event(&event, context()) { + ProductEventClassification::Card(card) => *card, + other => panic!("expected card, got {other:?}"), + } + } + + #[test] + fn exact_five_card_classifier_precedence_is_protocol_structural() { + assert_eq!( + card_type(admitted(1, vec![], "ordinary note")), + TodayCardType::Update + ); + let digest_tag = format!("x {}", "a".repeat(64)); + assert_eq!( + card_type(admitted( + 1, + vec![vec![ + "imeta", + "url https://media.example/a.jpg", + &digest_tag, + "m image/jpeg", + "dim 10x20", + "size 123", + "alt field photo" + ]], + "photo https://media.example/a.jpg", + )), + TodayCardType::PhotoUpdate + ); + assert_eq!( + card_type(admitted( + 1, + vec![vec!["t", " RADROOTS-ASK "], vec!["imeta", "broken"]], + "Anyone have carrots", + )), + TodayCardType::Ask + ); + assert_eq!( + card_type(admitted( + 31_923, + vec![ + vec!["d", "market-2026"], + vec!["title", "Saturday market"], + vec!["start", "2000000100"], + vec!["end", "2000000200"], + vec!["D", "23148"], + ], + "Farm market", + )), + TodayCardType::Event + ); + assert_eq!( + card_type(admitted( + 30_402, + vec![ + vec!["d", "carrots"], + vec!["title", "Carrots"], + vec!["summary", "Fresh bunches"], + vec!["published_at", "1999999999"], + vec!["location", "Saanich"], + vec!["price", "3", "CAD"], + vec!["radroots:price_unit", "lb"], + vec!["status", "active"], + ], + "Carrots available", + )), + TodayCardType::FoodAvailability + ); + } + + #[test] + fn event_and_food_cards_preserve_required_rendering_fields() { + let event = card(admitted( + 31_923, + vec![ + vec!["d", "market-2026"], + vec!["title", "Saturday market"], + vec!["start", "2000000100"], + vec!["D", "23148"], + vec!["location", "Town square"], + ], + "Farm market", + )); + assert_eq!(event.location.as_deref(), Some("Town square")); + assert_eq!(event.price_amount, None); + + let food = card(admitted( + 30_402, + vec![ + vec!["d", "carrots"], + vec!["title", "Carrots"], + vec!["summary", "Fresh bunches"], + vec!["published_at", "1999999999"], + vec!["location", "Saanich"], + vec!["price", "3", "CAD"], + vec!["radroots:price_unit", "lb"], + vec!["radroots:quantity", "12", "lb"], + vec!["status", "active"], + ], + "Carrots available", + )); + assert_eq!(food.location.as_deref(), Some("Saanich")); + assert_eq!(food.price_amount.as_deref(), Some("3")); + assert_eq!(food.price_currency.as_deref(), Some("CAD")); + assert_eq!(food.price_unit.as_deref(), Some("lb")); + assert_eq!(food.quantity.as_deref(), Some("12")); + assert_eq!(food.food_summary.as_deref(), Some("Fresh bunches")); + assert_eq!(food.food_published_at, Some(1_999_999_999)); + assert_eq!(food.food_status.as_deref(), Some("active")); + } + + #[test] + fn ordinary_standard_kind_one_needs_no_product_marker() { + let event = admitted(1, vec![vec!["t", "gardening"]], "Seedlings are ready"); + let ProductEventClassification::Card(card) = classify_admitted_event(&event, context()) + else { + panic!("standard kind-1 must remain admitted"); + }; + assert_eq!(card.card_type, TodayCardType::Update); + assert_eq!( + card.context_rank, + super::super::ContextRank::MissingLocalityFallback + ); + } + + #[test] + fn malformed_address_and_media_helpers_fail_closed() { + assert_eq!(tag_value(&[Vec::new()], &["title"]), None); + assert_eq!(tag_value(&[vec!["title".to_owned()]], &["title"]), None); + assert_eq!(tag_values(&[Vec::new()], "price"), None); + assert_eq!(tag_values(&[vec!["price".to_owned()]], "price"), None); + assert_eq!( + tag_values( + &[vec!["price".to_owned(), "3".to_owned(), "CAD".to_owned()]], + "price" + ), + Some(vec!["3".to_owned(), "CAD".to_owned()]) + ); + assert_eq!( + tag_time(&[vec!["start".to_owned(), "bad".to_owned()]], "start"), + None + ); + assert!( + tag_time( + &[vec!["start".to_owned(), "2026-08-13".to_owned()]], + "start" + ) + .is_some() + ); + + assert_eq!(blossom_digest("not-a-url"), None); + assert_eq!(blossom_digest("https://example.test"), None); + assert_eq!(blossom_digest("https://example.test/short"), None); + assert_eq!( + blossom_digest(&format!("https://example.test/{}", "G".repeat(64))), + None + ); + assert_eq!( + blossom_digest(&format!("https://example.test/{}/file.jpg", "a".repeat(64))), + Some("a".repeat(64)) + ); + assert_eq!( + media_reference("not-a-url", None, None, None, None, None, None,), + None + ); + } + + #[test] + fn supporting_profiles_never_become_cards_and_nonmatches_are_excluded() { + let profile = admitted(0, vec![], r#"{"name":"Farm"}"#); + let reply = admitted(1, vec![vec!["e", &"a".repeat(64), "", "root"]], "Reply"); + let author = Keys::parse(SECRET).expect("key").public_key().to_string(); + let root_id = "a".repeat(64); + let comment = admitted_owned( + 1_111, + vec![ + vec!["E".into(), root_id.clone(), String::new(), author.clone()], + vec!["K".into(), "30402".into()], + vec!["P".into(), author.clone()], + vec!["e".into(), root_id.clone(), String::new(), author.clone()], + vec!["k".into(), "30402".into()], + vec!["p".into(), author], + ], + "Comment", + ); + let deletion = admitted(5, vec![vec!["e", &root_id]], "Superseded"); + for (event, expected) in [ + (profile, SupportingProfile::Profile), + (reply, SupportingProfile::Reply), + (comment, SupportingProfile::Comment), + (deletion, SupportingProfile::Deletion), + ] { + assert_eq!( + classify_admitted_event(&event, context()), + ProductEventClassification::Supporting(expected) + ); + } + + let nip98 = admitted( + 27_235, + vec![ + vec!["u", "https://media.example/upload"], + vec!["method", "GET"], + ], + "{}", + ); + assert_eq!( + classify_admitted_event(&nip98, context()), + ProductEventClassification::Excluded(ProductEventExclusion::UnsupportedProfile) + ); + + let update = admitted(1, vec![], "ordinary note"); + assert_eq!( + classify_admitted_event( + &update, + LocalNetworkAdmission::Excluded { + reason: "locality_nonmatch" + } + ), + ProductEventClassification::Excluded(ProductEventExclusion::LocalityNonmatch) + ); + } + + #[test] + fn addressable_replacements_keep_stable_card_identity() { + let first = admitted( + 30_402, + vec![ + vec!["d", "carrots"], + vec!["title", "Carrots"], + vec!["summary", "Fresh"], + vec!["published_at", "1999999999"], + vec!["location", "Saanich"], + vec!["price", "3", "CAD"], + vec!["radroots:price_unit", "lb"], + vec!["status", "active"], + ], + "First", + ); + let second = admitted( + 30_402, + vec![ + vec!["d", "carrots"], + vec!["title", "Carrots"], + vec!["summary", "Fresh"], + vec!["published_at", "1999999999"], + vec!["location", "Saanich"], + vec!["price", "4", "CAD"], + vec!["radroots:price_unit", "lb"], + vec!["status", "active"], + ], + "Second", + ); + let ids = [first, second].map(|event| match classify_admitted_event(&event, context()) { + ProductEventClassification::Card(card) => card.card_id, + other => panic!("expected card, got {other:?}"), + }); + assert_eq!(ids[0], ids[1]); + } +} diff --git a/core/crates/tera_core/src/runtime/product_surface/ranking.rs b/core/crates/tera_core/src/runtime/product_surface/ranking.rs @@ -0,0 +1,259 @@ +use core::cmp::Ordering; + +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use thiserror::Error; + +use super::{CardId, ContextRank, TodayCardType}; + +pub const TODAY_RANK_SCHEMA_VERSION: u16 = 1; +pub const TODAY_RANK_ALGORITHM_VERSION: u16 = 1; +const RANK_DIGEST_DOMAIN: &[u8] = b"radroots.today-rank.v1\0"; +const UPCOMING_EVENT_WINDOW_SECONDS: u64 = 7 * 24 * 60 * 60; + +/// Exact time inputs used by the deliberately small Phase 1 ranking function. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum TimeRelevance { + Published, + Event { start: u64, end: Option<u64> }, + FoodAvailability { active: bool }, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct TodayRankInput { + pub card_type: TodayCardType, + pub context_rank: ContextRank, + pub as_of: u64, + pub effective_at: u64, + pub time: TimeRelevance, + pub card_id: CardId, +} + +#[derive(Clone, Copy, Debug, Error, Eq, PartialEq)] +pub enum RankError { + #[error("card type and time-relevance input do not match")] + MismatchedTimeProfile, + #[error("event end must be later than its start")] + InvalidEventRange, +} + +/// Lexicographic Today order key. +/// +/// Its [`Ord`] implementation sorts directly into feed order: higher context, +/// time relevance, and effective time first, then lower card ID. +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct TodayRank { + pub schema_version: u16, + pub algorithm_version: u16, + pub context_rank: ContextRank, + pub time_relevance_rank: u8, + pub effective_at: u64, + pub card_id: CardId, +} + +impl TodayRank { + pub fn derive(input: TodayRankInput) -> Result<Self, RankError> { + let time_relevance_rank = time_relevance_rank(input)?; + Ok(Self { + schema_version: TODAY_RANK_SCHEMA_VERSION, + algorithm_version: TODAY_RANK_ALGORITHM_VERSION, + context_rank: input.context_rank, + time_relevance_rank, + effective_at: input.effective_at, + card_id: input.card_id, + }) + } + + pub fn digest(self) -> [u8; 32] { + let mut digest = Sha256::new(); + digest.update(RANK_DIGEST_DOMAIN); + digest.update(self.schema_version.to_be_bytes()); + digest.update(self.algorithm_version.to_be_bytes()); + digest.update([self.context_rank.value()]); + digest.update([self.time_relevance_rank]); + digest.update(self.effective_at.to_be_bytes()); + digest.update(self.card_id.as_bytes()); + digest.finalize().into() + } + + pub fn digest_hex(self) -> String { + hex::encode(self.digest()) + } +} + +impl Ord for TodayRank { + fn cmp(&self, other: &Self) -> Ordering { + other + .context_rank + .cmp(&self.context_rank) + .then_with(|| other.time_relevance_rank.cmp(&self.time_relevance_rank)) + .then_with(|| other.effective_at.cmp(&self.effective_at)) + .then_with(|| self.card_id.cmp(&other.card_id)) + } +} + +impl PartialOrd for TodayRank { + fn partial_cmp(&self, other: &Self) -> Option<Ordering> { + Some(self.cmp(other)) + } +} + +fn time_relevance_rank(input: TodayRankInput) -> Result<u8, RankError> { + match (input.card_type, input.time) { + ( + TodayCardType::Update | TodayCardType::PhotoUpdate | TodayCardType::Ask, + TimeRelevance::Published, + ) => Ok(1), + (TodayCardType::FoodAvailability, TimeRelevance::FoodAvailability { active }) => { + Ok(if active { 3 } else { 0 }) + } + (TodayCardType::Event, TimeRelevance::Event { start, end }) => { + if end.is_some_and(|end| end <= start) { + return Err(RankError::InvalidEventRange); + } + if end.is_some_and(|end| input.as_of >= end) { + return Ok(0); + } + if input.as_of >= start { + return Ok(4); + } + if start.saturating_sub(input.as_of) <= UPCOMING_EVENT_WINDOW_SECONDS { + Ok(3) + } else { + Ok(2) + } + } + _ => Err(RankError::MismatchedTimeProfile), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn id(value: char) -> CardId { + CardId::parse(&value.to_string().repeat(64)).expect("card id") + } + + fn input(card_type: TodayCardType, time: TimeRelevance) -> TodayRankInput { + TodayRankInput { + card_type, + context_rank: ContextRank::LocalityMatch, + as_of: 2_000_000_000, + effective_at: 1_999_999_900, + time, + card_id: id('a'), + } + } + + #[test] + fn time_relevance_boundaries_are_exact() { + assert_eq!( + TodayRank::derive(input(TodayCardType::Update, TimeRelevance::Published)) + .expect("update") + .time_relevance_rank, + 1 + ); + assert_eq!( + TodayRank::derive(input( + TodayCardType::FoodAvailability, + TimeRelevance::FoodAvailability { active: true } + )) + .expect("food") + .time_relevance_rank, + 3 + ); + assert_eq!( + TodayRank::derive(input( + TodayCardType::FoodAvailability, + TimeRelevance::FoodAvailability { active: false } + )) + .expect("sold food") + .time_relevance_rank, + 0 + ); + for (start, end, expected) in [ + (1_999_999_900, Some(2_000_000_100), 4), + (1_999_999_900, None, 4), + (2_000_604_800, Some(2_000_604_900), 3), + (2_000_604_801, None, 2), + (1_999_999_000, Some(2_000_000_000), 0), + ] { + assert_eq!( + TodayRank::derive(input( + TodayCardType::Event, + TimeRelevance::Event { start, end } + )) + .expect("event") + .time_relevance_rank, + expected + ); + } + } + + #[test] + fn tuple_sorts_in_locked_feed_order_and_has_a_fixed_digest() { + let exact = TodayRank::derive(input(TodayCardType::Update, TimeRelevance::Published)) + .expect("rank"); + assert_eq!( + exact.digest_hex(), + "c7792876c8177f6f5420cc0f9aa84fb3c478f0bc6555c94ea5a7288502d6e4db" + ); + let fallback = TodayRank { + context_rank: ContextRank::MissingLocalityFallback, + time_relevance_rank: 4, + effective_at: exact.effective_at + 100, + card_id: id('e'), + ..exact + }; + let lower_time = TodayRank { + time_relevance_rank: 0, + effective_at: exact.effective_at + 200, + card_id: id('d'), + ..exact + }; + let older = TodayRank { + effective_at: exact.effective_at - 1, + card_id: id('c'), + ..exact + }; + let tie_high_id = TodayRank { + effective_at: exact.effective_at + 1, + card_id: id('b'), + ..exact + }; + let tie_low_id = TodayRank { + effective_at: exact.effective_at + 1, + card_id: id('a'), + ..exact + }; + let mut values = vec![fallback, lower_time, older, tie_high_id, tie_low_id]; + values.sort(); + assert_eq!( + values, + vec![tie_low_id, tie_high_id, older, lower_time, fallback] + ); + } + + #[test] + fn mismatched_and_invalid_time_inputs_fail_closed() { + assert_eq!( + TodayRank::derive(input( + TodayCardType::Update, + TimeRelevance::FoodAvailability { active: true } + )), + Err(RankError::MismatchedTimeProfile) + ); + assert_eq!( + TodayRank::derive(input( + TodayCardType::Event, + TimeRelevance::Event { + start: 10, + end: Some(10), + } + )), + Err(RankError::InvalidEventRange) + ); + } +} diff --git a/core/crates/tera_core/src/runtime/product_surface/settings.rs b/core/crates/tera_core/src/runtime/product_surface/settings.rs @@ -0,0 +1,1669 @@ +//! Versioned, secret-safe mobile identity and product configuration policy. + +use std::collections::BTreeSet; + +use radroots_event::{ + media::AuthoredImage, + profile::{AuthoredProfile, Nip05Identifier}, +}; +use radroots_identity::PublicKey; +use radroots_storage::projection::{ + ProjectionDocument, ProjectionGeneration, ProjectionId, ProjectionStore, +}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; + +use super::super::RadrootsRuntime; + +pub const DEFAULT_PUBLIC_RELAY: &str = "wss://radroots.org"; + +pub const MOBILE_SETTINGS_SCHEMA_VERSION: u16 = 1; +pub const DEFAULT_PUBLIC_BLOSSOM_ORIGIN: &str = "https://blossom.radroots.org"; +pub const DEFAULT_SIMULATOR_RELAY: &str = "ws://127.0.0.1:21000"; +pub const DEFAULT_SIMULATOR_BLOSSOM_ORIGIN: &str = "http://127.0.0.1:21100"; + +const SETTINGS_PROJECTION_ID: &str = "radroots.mobile.settings.v1"; +const SETTINGS_DOCUMENT_KEY: &str = "settings.current"; +const SETTINGS_GENERATION_DOMAIN: &[u8] = b"radroots.mobile.settings.generation.v1"; +const IDENTITY_ID_MAX_BYTES: usize = 128; +const OPERATION_ID_MAX_BYTES: usize = 128; +const PROFILE_NAME_MAX_BYTES: usize = 256; +const PROFILE_DISPLAY_NAME_MAX_BYTES: usize = 512; +const PROFILE_ABOUT_MAX_BYTES: usize = 8 * 1024; +const RELAY_ENDPOINT_MAX: usize = 32; +const BLOSSOM_FALLBACK_MAX: usize = 15; +const MEDIA_CACHE_MIN_BYTES: u64 = 16 * 1024 * 1024; +const MEDIA_CACHE_MAX_BYTES: u64 = 2 * 1024 * 1024 * 1024; +const MEDIA_CACHE_MAX_ARTIFACTS: u32 = 10_000; + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum IdentityLockState { + Locked, + Unlocked, +} + +/// Secret-safe reference to one Apple-custodied identity. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct IdentityRecord { + id: String, + public_key_hex: String, +} + +impl IdentityRecord { + pub fn new(id: impl Into<String>, public_key_hex: &str) -> Result<Self, IdentitySettingsError> { + let id = id.into(); + validate_identifier(&id, IDENTITY_ID_MAX_BYTES) + .then_some(()) + .ok_or(IdentitySettingsError::InvalidIdentityId)?; + let public_key = PublicKey::from_hex(public_key_hex) + .map_err(|_| IdentitySettingsError::InvalidPublicKey)?; + Ok(Self { + id, + public_key_hex: public_key.to_hex(), + }) + } + + pub fn id(&self) -> &str { + self.id.as_str() + } + + pub fn public_key_hex(&self) -> &str { + self.public_key_hex.as_str() + } +} + +/// Durable identity selection. It contains public metadata only; key material +/// and user-presence state remain in the native custody provider. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct IdentityState { + identities: Vec<IdentityRecord>, + active_identity_id: Option<String>, + lock_state: IdentityLockState, + pending_import_operation_id: Option<String>, +} + +impl Default for IdentityState { + fn default() -> Self { + Self { + identities: Vec::new(), + active_identity_id: None, + lock_state: IdentityLockState::Locked, + pending_import_operation_id: None, + } + } +} + +impl IdentityState { + pub fn new( + identities: Vec<IdentityRecord>, + active_identity_id: Option<String>, + lock_state: IdentityLockState, + pending_import_operation_id: Option<String>, + ) -> Result<Self, IdentitySettingsError> { + let mut ids = BTreeSet::new(); + let mut public_keys = BTreeSet::new(); + for identity in &identities { + if !ids.insert(identity.id()) { + return Err(IdentitySettingsError::DuplicateIdentityId); + } + if !public_keys.insert(identity.public_key_hex()) { + return Err(IdentitySettingsError::DuplicatePublicKey); + } + } + if let Some(active) = active_identity_id.as_deref() + && !ids.contains(active) + { + return Err(IdentitySettingsError::UnknownIdentity); + } + if let Some(operation_id) = pending_import_operation_id.as_deref() + && !validate_identifier(operation_id, OPERATION_ID_MAX_BYTES) + { + return Err(IdentitySettingsError::InvalidOperationId); + } + if active_identity_id.is_none() && lock_state == IdentityLockState::Unlocked { + return Err(IdentitySettingsError::NoActiveIdentity); + } + Ok(Self { + identities, + active_identity_id, + lock_state, + pending_import_operation_id, + }) + } + + pub fn identities(&self) -> &[IdentityRecord] { + self.identities.as_slice() + } + + pub fn active_identity_id(&self) -> Option<&str> { + self.active_identity_id.as_deref() + } + + pub const fn lock_state(&self) -> IdentityLockState { + self.lock_state + } + + pub fn pending_import_operation_id(&self) -> Option<&str> { + self.pending_import_operation_id.as_deref() + } + + /// Applies a secret-free identity state transition after the native host + /// has completed any required Keychain or user-presence operation. + pub fn apply(&self, command: IdentityCommand) -> Result<Self, IdentitySettingsError> { + let mut next = self.clone(); + match command { + IdentityCommand::BeginImport { operation_id } => { + if next.pending_import_operation_id.is_some() { + return Err(IdentitySettingsError::ImportAlreadyPending); + } + if !validate_identifier(&operation_id, OPERATION_ID_MAX_BYTES) { + return Err(IdentitySettingsError::InvalidOperationId); + } + next.pending_import_operation_id = Some(operation_id); + } + IdentityCommand::CompleteImport { + operation_id, + identity, + } => { + if next.pending_import_operation_id.as_deref() != Some(operation_id.as_str()) { + return Err(IdentitySettingsError::ImportOperationMismatch); + } + if next + .identities + .iter() + .any(|value| value.id() == identity.id()) + { + return Err(IdentitySettingsError::DuplicateIdentityId); + } + if next + .identities + .iter() + .any(|value| value.public_key_hex() == identity.public_key_hex()) + { + return Err(IdentitySettingsError::DuplicatePublicKey); + } + next.active_identity_id = Some(identity.id().to_owned()); + next.identities.push(identity); + next.lock_state = IdentityLockState::Locked; + next.pending_import_operation_id = None; + } + IdentityCommand::CancelImport { operation_id } => { + if next.pending_import_operation_id.as_deref() != Some(operation_id.as_str()) { + return Err(IdentitySettingsError::ImportOperationMismatch); + } + next.pending_import_operation_id = None; + } + IdentityCommand::Select { identity_id } => { + if !next + .identities + .iter() + .any(|identity| identity.id() == identity_id) + { + return Err(IdentitySettingsError::UnknownIdentity); + } + next.active_identity_id = Some(identity_id); + next.lock_state = IdentityLockState::Locked; + } + IdentityCommand::Lock => { + if next.active_identity_id.is_none() { + return Err(IdentitySettingsError::NoActiveIdentity); + } + next.lock_state = IdentityLockState::Locked; + } + IdentityCommand::Unlock => { + if next.active_identity_id.is_none() { + return Err(IdentitySettingsError::NoActiveIdentity); + } + next.lock_state = IdentityLockState::Unlocked; + } + IdentityCommand::Recover => { + if next.active_identity_id.is_none() { + return Err(IdentitySettingsError::NoActiveIdentity); + } + next.lock_state = IdentityLockState::Locked; + next.pending_import_operation_id = None; + } + } + Ok(next) + } +} + +/// Secret-free intent/result commands. `CompleteImport` carries only the +/// public identity returned by the Apple custody provider. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum IdentityCommand { + BeginImport { + operation_id: String, + }, + CompleteImport { + operation_id: String, + identity: IdentityRecord, + }, + CancelImport { + operation_id: String, + }, + Select { + identity_id: String, + }, + Lock, + Unlock, + Recover, +} + +#[derive(Clone, Debug, Eq, PartialEq, thiserror::Error)] +pub enum IdentitySettingsError { + #[error("identity id is invalid")] + InvalidIdentityId, + #[error("identity public key is invalid")] + InvalidPublicKey, + #[error("identity operation id is invalid")] + InvalidOperationId, + #[error("identity id is duplicated")] + DuplicateIdentityId, + #[error("identity public key is duplicated")] + DuplicatePublicKey, + #[error("identity is unknown")] + UnknownIdentity, + #[error("no active identity exists")] + NoActiveIdentity, + #[error("an identity import is already pending")] + ImportAlreadyPending, + #[error("identity import operation does not match")] + ImportOperationMismatch, +} + +impl IdentitySettingsError { + pub const fn code(&self) -> &'static str { + match self { + Self::InvalidIdentityId => "invalid_identity_id", + Self::InvalidPublicKey => "invalid_public_key", + Self::InvalidOperationId => "invalid_identity_operation_id", + Self::DuplicateIdentityId => "duplicate_identity_id", + Self::DuplicatePublicKey => "duplicate_identity_public_key", + Self::UnknownIdentity => "unknown_identity", + Self::NoActiveIdentity => "no_active_identity", + Self::ImportAlreadyPending => "identity_import_already_pending", + Self::ImportOperationMismatch => "identity_import_operation_mismatch", + } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum RelayAccessPreference { + ReadOnly, + ReadWrite, +} + +impl RelayAccessPreference { + pub const fn can_write(self) -> bool { + matches!(self, Self::ReadWrite) + } + + fn parse(value: &str) -> Result<Self, SettingsError> { + match value { + "read_only" => Ok(Self::ReadOnly), + "read_write" => Ok(Self::ReadWrite), + _ => Err(SettingsError::UnknownRelayAccess), + } + } + + fn as_str(self) -> &'static str { + match self { + Self::ReadOnly => "read_only", + Self::ReadWrite => "read_write", + } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum MobileNetworkEnvironment { + Public, + Simulator, + PhysicalDevice, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct RelayEndpointPreference { + url: String, + access: RelayAccessPreference, +} + +impl RelayEndpointPreference { + pub fn new( + environment: MobileNetworkEnvironment, + url: impl AsRef<str>, + access: RelayAccessPreference, + ) -> Result<Self, SettingsError> { + let policy = match environment { + MobileNetworkEnvironment::Public => radroots_sdk::transport::RelayUrlPolicy::Public, + MobileNetworkEnvironment::Simulator => radroots_sdk::transport::RelayUrlPolicy::Local, + MobileNetworkEnvironment::PhysicalDevice => { + radroots_sdk::transport::RelayUrlPolicy::PrivateNetwork + } + }; + let url = radroots_sdk::transport::RelayUrl::parse(url, policy) + .map_err(|_| SettingsError::InvalidRelayEndpoint)?; + Ok(Self { + url: url.to_string(), + access, + }) + } + + pub fn url(&self) -> &str { + self.url.as_str() + } + + pub const fn access(&self) -> RelayAccessPreference { + self.access + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct RelayPreferences { + environment: MobileNetworkEnvironment, + endpoints: Vec<RelayEndpointPreference>, +} + +impl RelayPreferences { + pub fn new( + environment: MobileNetworkEnvironment, + endpoints: Vec<RelayEndpointPreference>, + ) -> Result<Self, SettingsError> { + if endpoints.is_empty() || endpoints.len() > RELAY_ENDPOINT_MAX { + return Err(SettingsError::InvalidRelayEndpointCount); + } + let mut seen = BTreeSet::new(); + for endpoint in &endpoints { + let validated = + RelayEndpointPreference::new(environment, endpoint.url(), endpoint.access())?; + if validated != *endpoint || !seen.insert(endpoint.url()) { + return Err(SettingsError::DuplicateRelayEndpoint); + } + } + Ok(Self { + environment, + endpoints, + }) + } + + pub fn production_default() -> Self { + Self::new( + MobileNetworkEnvironment::Public, + vec![ + RelayEndpointPreference::new( + MobileNetworkEnvironment::Public, + DEFAULT_PUBLIC_RELAY, + RelayAccessPreference::ReadWrite, + ) + .expect("bundled public relay is valid"), + ], + ) + .expect("bundled public relay profile is valid") + } + + pub fn simulator_default() -> Self { + Self::new( + MobileNetworkEnvironment::Simulator, + vec![ + RelayEndpointPreference::new( + MobileNetworkEnvironment::Simulator, + DEFAULT_SIMULATOR_RELAY, + RelayAccessPreference::ReadWrite, + ) + .expect("bundled simulator relay is valid"), + ], + ) + .expect("bundled simulator relay profile is valid") + } + + pub const fn environment(&self) -> MobileNetworkEnvironment { + self.environment + } + + pub fn endpoints(&self) -> &[RelayEndpointPreference] { + self.endpoints.as_slice() + } + + pub fn sdk_profile(&self) -> Result<radroots_sdk::transport::RelayProfile, SettingsError> { + let kind = match self.environment { + MobileNetworkEnvironment::Public => radroots_sdk::transport::RelayProfileKind::Public, + MobileNetworkEnvironment::Simulator => { + radroots_sdk::transport::RelayProfileKind::Simulator + } + MobileNetworkEnvironment::PhysicalDevice => { + radroots_sdk::transport::RelayProfileKind::Device + } + }; + let policy = match self.environment { + MobileNetworkEnvironment::Public => radroots_sdk::transport::RelayUrlPolicy::Public, + MobileNetworkEnvironment::Simulator => radroots_sdk::transport::RelayUrlPolicy::Local, + MobileNetworkEnvironment::PhysicalDevice => { + radroots_sdk::transport::RelayUrlPolicy::PrivateNetwork + } + }; + let endpoints = self + .endpoints + .iter() + .map(|endpoint| { + let access = match endpoint.access { + RelayAccessPreference::ReadOnly => { + radroots_sdk::transport::RelayAccess::ReadOnly + } + RelayAccessPreference::ReadWrite => { + radroots_sdk::transport::RelayAccess::ReadWrite + } + }; + radroots_sdk::transport::RelayEndpoint::new(endpoint.url.as_str(), policy, access) + }) + .collect::<Result<Vec<_>, _>>() + .map_err(|_| SettingsError::InvalidRelayEndpoint)?; + radroots_sdk::transport::RelayProfile::explicit(kind, endpoints) + .map_err(|_| SettingsError::InvalidRelayEndpoint) + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum BlossomEndpointAuthorityPreference { + PublicWebPki, + LoopbackDevelopment, + PrivateNetworkDevelopment, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct BlossomPreferences { + environment: MobileNetworkEnvironment, + authority: BlossomEndpointAuthorityPreference, + primary_origin: String, + fallback_origins: Vec<String>, +} + +impl BlossomPreferences { + pub fn new( + environment: MobileNetworkEnvironment, + authority: BlossomEndpointAuthorityPreference, + primary_origin: impl Into<String>, + fallback_origins: Vec<String>, + ) -> Result<Self, SettingsError> { + if fallback_origins.len() > BLOSSOM_FALLBACK_MAX { + return Err(SettingsError::InvalidBlossomEndpointCount); + } + let candidate = Self { + environment, + authority, + primary_origin: primary_origin.into(), + fallback_origins, + }; + let profile = candidate.sdk_profile()?; + Ok(Self { + primary_origin: profile.primary().origin().to_owned(), + fallback_origins: profile + .fallbacks() + .iter() + .map(|endpoint| endpoint.origin().to_owned()) + .collect(), + ..candidate + }) + } + + pub fn production_default() -> Self { + Self::new( + MobileNetworkEnvironment::Public, + BlossomEndpointAuthorityPreference::PublicWebPki, + DEFAULT_PUBLIC_BLOSSOM_ORIGIN, + Vec::new(), + ) + .expect("bundled public Blossom origin is valid") + } + + pub fn simulator_default() -> Self { + Self::new( + MobileNetworkEnvironment::Simulator, + BlossomEndpointAuthorityPreference::LoopbackDevelopment, + DEFAULT_SIMULATOR_BLOSSOM_ORIGIN, + Vec::new(), + ) + .expect("bundled simulator Blossom origin is valid") + } + + pub const fn environment(&self) -> MobileNetworkEnvironment { + self.environment + } + + pub const fn authority(&self) -> BlossomEndpointAuthorityPreference { + self.authority + } + + pub fn primary_origin(&self) -> &str { + self.primary_origin.as_str() + } + + pub fn fallback_origins(&self) -> &[String] { + self.fallback_origins.as_slice() + } + + pub fn sdk_profile(&self) -> Result<radroots_sdk::transport::BlossomProfile, SettingsError> { + let host_kind = match self.environment { + MobileNetworkEnvironment::Public => radroots_sdk::transport::BlossomHostKind::Native, + MobileNetworkEnvironment::Simulator => { + radroots_sdk::transport::BlossomHostKind::Simulator + } + MobileNetworkEnvironment::PhysicalDevice => { + radroots_sdk::transport::BlossomHostKind::PhysicalDevice + } + }; + let authority = match self.authority { + BlossomEndpointAuthorityPreference::PublicWebPki => { + radroots_sdk::transport::BlossomEndpointAuthority::PublicWebPki + } + BlossomEndpointAuthorityPreference::LoopbackDevelopment => { + radroots_sdk::transport::BlossomEndpointAuthority::LoopbackDevelopment + } + BlossomEndpointAuthorityPreference::PrivateNetworkDevelopment => { + radroots_sdk::transport::BlossomEndpointAuthority::PrivateNetworkDevelopment + } + }; + radroots_sdk::transport::BlossomProfile::new( + host_kind, + authority, + self.primary_origin.as_str(), + self.fallback_origins.iter().map(String::as_str), + ) + .map_err(|_| SettingsError::InvalidBlossomEndpoint) + } +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct MediaNetworkPolicy { + allow_cellular_downloads: bool, + allow_cellular_uploads: bool, + allow_background_transfers: bool, +} + +impl MediaNetworkPolicy { + pub const fn new( + allow_cellular_downloads: bool, + allow_cellular_uploads: bool, + allow_background_transfers: bool, + ) -> Self { + Self { + allow_cellular_downloads, + allow_cellular_uploads, + allow_background_transfers, + } + } + + pub const fn allow_cellular_downloads(&self) -> bool { + self.allow_cellular_downloads + } + + pub const fn allow_cellular_uploads(&self) -> bool { + self.allow_cellular_uploads + } + + pub const fn allow_background_transfers(&self) -> bool { + self.allow_background_transfers + } +} + +impl Default for MediaNetworkPolicy { + fn default() -> Self { + Self::new(true, true, true) + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct LocalStoragePolicy { + media_cache_bytes: u64, + media_cache_artifacts: u32, +} + +impl LocalStoragePolicy { + pub fn new(media_cache_bytes: u64, media_cache_artifacts: u32) -> Result<Self, SettingsError> { + if !(MEDIA_CACHE_MIN_BYTES..=MEDIA_CACHE_MAX_BYTES).contains(&media_cache_bytes) { + return Err(SettingsError::InvalidMediaCacheBytes); + } + if media_cache_artifacts == 0 || media_cache_artifacts > MEDIA_CACHE_MAX_ARTIFACTS { + return Err(SettingsError::InvalidMediaCacheArtifacts); + } + Ok(Self { + media_cache_bytes, + media_cache_artifacts, + }) + } + + pub const fn media_cache_bytes(&self) -> u64 { + self.media_cache_bytes + } + + pub const fn media_cache_artifacts(&self) -> u32 { + self.media_cache_artifacts + } +} + +impl Default for LocalStoragePolicy { + fn default() -> Self { + Self::new(256 * 1024 * 1024, 2_000).expect("default storage policy is valid") + } +} + +/// Complete replacement command for the adopted kind-0 metadata surface. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ProfileMetadataCommand(AuthoredProfile); + +impl ProfileMetadataCommand { + #[allow(clippy::too_many_arguments)] + pub fn new( + name: String, + display_name: Option<String>, + about: Option<String>, + picture: Option<AuthoredImage>, + banner: Option<AuthoredImage>, + nip05: Option<String>, + bot: Option<bool>, + ) -> Result<Self, ProfileMetadataError> { + validate_profile_text(&name, PROFILE_NAME_MAX_BYTES, false) + .then_some(()) + .ok_or(ProfileMetadataError::InvalidName)?; + if display_name.as_deref().is_some_and(|value| { + !validate_profile_text(value, PROFILE_DISPLAY_NAME_MAX_BYTES, true) + }) { + return Err(ProfileMetadataError::InvalidDisplayName); + } + if about + .as_deref() + .is_some_and(|value| !validate_profile_text(value, PROFILE_ABOUT_MAX_BYTES, true)) + { + return Err(ProfileMetadataError::InvalidAbout); + } + let nip05 = nip05 + .as_deref() + .map(Nip05Identifier::parse) + .transpose() + .map_err(|_| ProfileMetadataError::InvalidNip05)?; + let mut profile = + AuthoredProfile::new(name).map_err(|_| ProfileMetadataError::InvalidName)?; + if let Some(value) = display_name { + profile = profile.with_display_name(value); + } + if let Some(value) = about { + profile = profile.with_about(value); + } + if let Some(value) = picture { + profile = profile.with_picture(value); + } + if let Some(value) = banner { + profile = profile.with_banner(value); + } + if let Some(value) = nip05 { + profile = profile.with_nip05(value); + } + if let Some(value) = bot { + profile = profile.with_bot(value); + } + Ok(Self(profile)) + } + + pub const fn authored(&self) -> &AuthoredProfile { + &self.0 + } +} + +#[derive(Clone, Debug, Eq, PartialEq, thiserror::Error)] +pub enum ProfileMetadataError { + #[error("profile name is invalid")] + InvalidName, + #[error("profile display name is invalid")] + InvalidDisplayName, + #[error("profile about text is invalid")] + InvalidAbout, + #[error("profile NIP-05 identifier is invalid")] + InvalidNip05, +} + +impl ProfileMetadataError { + pub const fn code(&self) -> &'static str { + match self { + Self::InvalidName => "invalid_profile_name", + Self::InvalidDisplayName => "invalid_profile_display_name", + Self::InvalidAbout => "invalid_profile_about", + Self::InvalidNip05 => "invalid_profile_nip05", + } + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct MobileSettings { + revision: u64, + identity: IdentityState, + relays: RelayPreferences, + blossom: BlossomPreferences, + media_network: MediaNetworkPolicy, + local_storage: LocalStoragePolicy, +} + +impl Default for MobileSettings { + fn default() -> Self { + Self { + revision: 1, + identity: IdentityState::default(), + relays: RelayPreferences::production_default(), + blossom: BlossomPreferences::production_default(), + media_network: MediaNetworkPolicy::default(), + local_storage: LocalStoragePolicy::default(), + } + } +} + +impl MobileSettings { + pub const fn revision(&self) -> u64 { + self.revision + } + + pub const fn identity(&self) -> &IdentityState { + &self.identity + } + + pub const fn relays(&self) -> &RelayPreferences { + &self.relays + } + + pub const fn blossom(&self) -> &BlossomPreferences { + &self.blossom + } + + pub const fn media_network(&self) -> &MediaNetworkPolicy { + &self.media_network + } + + pub const fn local_storage(&self) -> &LocalStoragePolicy { + &self.local_storage + } + + #[must_use] + pub fn with_identity(mut self, identity: IdentityState) -> Self { + self.identity = identity; + self + } + + #[must_use] + pub fn with_relays(mut self, relays: RelayPreferences) -> Self { + self.relays = relays; + self + } + + #[must_use] + pub fn with_blossom(mut self, blossom: BlossomPreferences) -> Self { + self.blossom = blossom; + self + } + + #[must_use] + pub fn with_media_network(mut self, media_network: MediaNetworkPolicy) -> Self { + self.media_network = media_network; + self + } + + #[must_use] + pub fn with_local_storage(mut self, local_storage: LocalStoragePolicy) -> Self { + self.local_storage = local_storage; + self + } + + fn validate(&self) -> Result<(), SettingsError> { + if self.relays.environment() != self.blossom.environment() { + return Err(SettingsError::NetworkEnvironmentMismatch); + } + Ok(()) + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ReplaceMobileSettings { + expected_revision: u64, + settings: MobileSettings, +} + +impl ReplaceMobileSettings { + pub fn new(expected_revision: u64, settings: MobileSettings) -> Result<Self, SettingsError> { + if expected_revision == 0 || settings.revision != expected_revision { + return Err(SettingsError::RevisionConflict); + } + settings.validate()?; + Ok(Self { + expected_revision, + settings, + }) + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct SettingsTransition { + pub settings: MobileSettings, + pub runtime_restart_required: bool, + pub outbox_requeue_required: bool, + pub media_cache_invalidation_required: bool, +} + +#[derive(Clone, Debug, Eq, PartialEq, thiserror::Error)] +pub enum SettingsError { + #[error("relay access value is unknown")] + UnknownRelayAccess, + #[error("relay endpoint is invalid")] + InvalidRelayEndpoint, + #[error("relay endpoint count is invalid")] + InvalidRelayEndpointCount, + #[error("relay endpoint is duplicated")] + DuplicateRelayEndpoint, + #[error("Blossom endpoint is invalid")] + InvalidBlossomEndpoint, + #[error("Blossom endpoint count is invalid")] + InvalidBlossomEndpointCount, + #[error("relay and Blossom network environments do not match")] + NetworkEnvironmentMismatch, + #[error("media cache byte quota is invalid")] + InvalidMediaCacheBytes, + #[error("media cache artifact quota is invalid")] + InvalidMediaCacheArtifacts, + #[error("settings revision conflicts with durable state")] + RevisionConflict, + #[error("settings revision is exhausted")] + RevisionExhausted, + #[error("settings schema version is unsupported")] + UnsupportedSchema, + #[error("settings document is corrupt")] + CorruptDocument, + #[error("settings storage is unavailable")] + Storage, + #[error("identity settings are invalid: {0}")] + Identity(#[from] IdentitySettingsError), +} + +impl SettingsError { + pub const fn code(&self) -> &'static str { + match self { + Self::UnknownRelayAccess => "unknown_relay_access", + Self::InvalidRelayEndpoint => "invalid_relay_endpoint", + Self::InvalidRelayEndpointCount => "invalid_relay_endpoint_count", + Self::DuplicateRelayEndpoint => "duplicate_relay_endpoint", + Self::InvalidBlossomEndpoint => "invalid_blossom_endpoint", + Self::InvalidBlossomEndpointCount => "invalid_blossom_endpoint_count", + Self::NetworkEnvironmentMismatch => "network_environment_mismatch", + Self::InvalidMediaCacheBytes => "invalid_media_cache_bytes", + Self::InvalidMediaCacheArtifacts => "invalid_media_cache_artifacts", + Self::RevisionConflict => "settings_revision_conflict", + Self::RevisionExhausted => "settings_revision_exhausted", + Self::UnsupportedSchema => "unsupported_settings_schema", + Self::CorruptDocument => "corrupt_settings_document", + Self::Storage => "settings_storage_unavailable", + Self::Identity(error) => error.code(), + } + } +} + +impl RadrootsRuntime { + pub async fn phase1_settings(&self) -> Result<MobileSettings, SettingsError> { + let storage = self.client.storage().map_err(|_| SettingsError::Storage)?; + let mut settings = load_settings(storage).await?; + let session = self.identity_session.read().await; + if let Some((revision, identity)) = session.as_ref() + && *revision == settings.revision + { + settings.identity = identity.clone(); + } + Ok(settings) + } + + pub async fn phase1_replace_settings( + &self, + command: ReplaceMobileSettings, + ) -> Result<SettingsTransition, SettingsError> { + let _guard = self.settings_lock.lock().await; + let storage = self.client.storage().map_err(|_| SettingsError::Storage)?; + let transition = replace_settings(storage, command).await?; + *self.identity_session.write().await = None; + Ok(transition) + } + + /// Applies one secret-free identity transition atomically against the + /// current settings revision. Unlock evidence is process-local: it is + /// observable for the current runtime but never written to durable state. + pub async fn phase1_apply_identity_command( + &self, + expected_revision: u64, + command: IdentityCommand, + ) -> Result<SettingsTransition, SettingsError> { + let _guard = self.settings_lock.lock().await; + let storage = self.client.storage().map_err(|_| SettingsError::Storage)?; + let mut prior = load_settings(storage).await?; + if prior.revision != expected_revision { + return Err(SettingsError::RevisionConflict); + } + if let Some((revision, identity)) = self.identity_session.read().await.as_ref() + && *revision == prior.revision + { + prior.identity = identity.clone(); + } + let next_identity = prior.identity.apply(command.clone())?; + if matches!(command, IdentityCommand::Unlock) { + *self.identity_session.write().await = Some((prior.revision, next_identity.clone())); + return Ok(SettingsTransition { + settings: prior.with_identity(next_identity), + runtime_restart_required: false, + outbox_requeue_required: false, + media_cache_invalidation_required: false, + }); + } + let command = + ReplaceMobileSettings::new(prior.revision, prior.with_identity(next_identity))?; + let transition = replace_settings(storage, command).await?; + *self.identity_session.write().await = None; + Ok(transition) + } +} + +async fn load_settings( + storage: &dyn radroots_storage::Storage, +) -> Result<MobileSettings, SettingsError> { + let document = ProjectionStore::projection_document( + storage, + settings_projection_id()?, + settings_generation()?, + SETTINGS_DOCUMENT_KEY.to_owned(), + ) + .await + .map_err(|_| SettingsError::Storage)?; + document + .map(|document| decode_settings(document.value())) + .transpose() + .map(Option::unwrap_or_default) +} + +async fn replace_settings( + storage: &dyn radroots_storage::Storage, + command: ReplaceMobileSettings, +) -> Result<SettingsTransition, SettingsError> { + let prior = load_settings(storage).await?; + if prior.revision != command.expected_revision { + return Err(SettingsError::RevisionConflict); + } + let mut next = command.settings; + next.revision = prior + .revision + .checked_add(1) + .ok_or(SettingsError::RevisionExhausted)?; + // Unlock is a native, process-local custody result. A durable settings + // write must never make a later process assume user presence succeeded. + next.identity.lock_state = IdentityLockState::Locked; + let transition = settings_transition(&prior, next); + ProjectionStore::put_projection_document( + storage, + settings_projection_id()?, + settings_generation()?, + ProjectionDocument::new( + SETTINGS_DOCUMENT_KEY.to_owned(), + encode_settings(&transition.settings)?, + ) + .map_err(|_| SettingsError::CorruptDocument)?, + ) + .await + .map_err(|_| SettingsError::Storage)?; + Ok(transition) +} + +fn settings_transition(prior: &MobileSettings, settings: MobileSettings) -> SettingsTransition { + let identity_changed = prior.identity != settings.identity; + let relay_changed = prior.relays != settings.relays; + let blossom_changed = prior.blossom != settings.blossom; + let media_changed = prior.media_network != settings.media_network; + let storage_changed = prior.local_storage != settings.local_storage; + SettingsTransition { + runtime_restart_required: identity_changed || relay_changed || blossom_changed, + outbox_requeue_required: identity_changed || relay_changed || blossom_changed, + media_cache_invalidation_required: identity_changed + || blossom_changed + || media_changed + || storage_changed, + settings, + } +} + +#[derive(Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct StoredSettingsV1 { + schema_version: u16, + revision: u64, + identity: StoredIdentity, + relays: StoredRelays, + blossom: StoredBlossom, + media_network: MediaNetworkPolicy, + local_storage: StoredLocalStorage, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct StoredSettingsV0 { + schema_version: u16, + revision: u64, + identity: StoredIdentity, + relays: StoredRelays, + blossom: StoredBlossom, + allow_cellular_downloads: bool, + allow_cellular_uploads: bool, + media_cache_bytes: u64, + media_cache_artifacts: u32, +} + +#[derive(Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct StoredIdentity { + identities: Vec<StoredIdentityRecord>, + active_identity_id: Option<String>, + lock_state: IdentityLockState, + pending_import_operation_id: Option<String>, +} + +#[derive(Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct StoredIdentityRecord { + id: String, + public_key_hex: String, +} + +#[derive(Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct StoredRelays { + environment: MobileNetworkEnvironment, + endpoints: Vec<StoredRelayEndpoint>, +} + +#[derive(Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct StoredRelayEndpoint { + url: String, + access: String, +} + +#[derive(Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct StoredBlossom { + environment: MobileNetworkEnvironment, + authority: BlossomEndpointAuthorityPreference, + primary_origin: String, + fallback_origins: Vec<String>, +} + +#[derive(Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct StoredLocalStorage { + media_cache_bytes: u64, + media_cache_artifacts: u32, +} + +#[derive(Deserialize)] +struct VersionProbe { + schema_version: u16, +} + +fn encode_settings(settings: &MobileSettings) -> Result<Vec<u8>, SettingsError> { + serde_json::to_vec(&StoredSettingsV1::from(settings)) + .map_err(|_| SettingsError::CorruptDocument) +} + +fn decode_settings(value: &[u8]) -> Result<MobileSettings, SettingsError> { + let version = serde_json::from_slice::<VersionProbe>(value) + .map_err(|_| SettingsError::CorruptDocument)? + .schema_version; + match version { + 0 => serde_json::from_slice::<StoredSettingsV0>(value) + .map_err(|_| SettingsError::CorruptDocument)? + .try_into(), + MOBILE_SETTINGS_SCHEMA_VERSION => serde_json::from_slice::<StoredSettingsV1>(value) + .map_err(|_| SettingsError::CorruptDocument)? + .try_into(), + _ => Err(SettingsError::UnsupportedSchema), + } +} + +impl From<&MobileSettings> for StoredSettingsV1 { + fn from(value: &MobileSettings) -> Self { + Self { + schema_version: MOBILE_SETTINGS_SCHEMA_VERSION, + revision: value.revision, + identity: StoredIdentity::from(&value.identity), + relays: StoredRelays::from(&value.relays), + blossom: StoredBlossom::from(&value.blossom), + media_network: value.media_network.clone(), + local_storage: StoredLocalStorage::from(&value.local_storage), + } + } +} + +impl TryFrom<StoredSettingsV1> for MobileSettings { + type Error = SettingsError; + + fn try_from(value: StoredSettingsV1) -> Result<Self, Self::Error> { + if value.schema_version != MOBILE_SETTINGS_SCHEMA_VERSION || value.revision == 0 { + return Err(SettingsError::CorruptDocument); + } + let settings = Self { + revision: value.revision, + identity: value.identity.try_into()?, + relays: value.relays.try_into()?, + blossom: value.blossom.try_into()?, + media_network: value.media_network, + local_storage: value.local_storage.try_into()?, + }; + settings.validate()?; + Ok(settings) + } +} + +impl TryFrom<StoredSettingsV0> for MobileSettings { + type Error = SettingsError; + + fn try_from(value: StoredSettingsV0) -> Result<Self, Self::Error> { + if value.schema_version != 0 || value.revision == 0 { + return Err(SettingsError::CorruptDocument); + } + let settings = Self { + revision: value.revision, + identity: value.identity.try_into()?, + relays: value.relays.try_into()?, + blossom: value.blossom.try_into()?, + media_network: MediaNetworkPolicy::new( + value.allow_cellular_downloads, + value.allow_cellular_uploads, + false, + ), + local_storage: LocalStoragePolicy::new( + value.media_cache_bytes, + value.media_cache_artifacts, + )?, + }; + settings.validate()?; + Ok(settings) + } +} + +impl From<&IdentityState> for StoredIdentity { + fn from(value: &IdentityState) -> Self { + Self { + identities: value + .identities + .iter() + .map(|identity| StoredIdentityRecord { + id: identity.id.clone(), + public_key_hex: identity.public_key_hex.clone(), + }) + .collect(), + active_identity_id: value.active_identity_id.clone(), + lock_state: IdentityLockState::Locked, + pending_import_operation_id: value.pending_import_operation_id.clone(), + } + } +} + +impl TryFrom<StoredIdentity> for IdentityState { + type Error = SettingsError; + + fn try_from(value: StoredIdentity) -> Result<Self, Self::Error> { + let identities = value + .identities + .into_iter() + .map(|identity| IdentityRecord::new(identity.id, &identity.public_key_hex)) + .collect::<Result<Vec<_>, _>>()?; + IdentityState::new( + identities, + value.active_identity_id, + IdentityLockState::Locked, + value.pending_import_operation_id, + ) + .map_err(SettingsError::from) + } +} + +impl From<&RelayPreferences> for StoredRelays { + fn from(value: &RelayPreferences) -> Self { + Self { + environment: value.environment, + endpoints: value + .endpoints + .iter() + .map(|endpoint| StoredRelayEndpoint { + url: endpoint.url.clone(), + access: endpoint.access.as_str().to_owned(), + }) + .collect(), + } + } +} + +impl TryFrom<StoredRelays> for RelayPreferences { + type Error = SettingsError; + + fn try_from(value: StoredRelays) -> Result<Self, Self::Error> { + let endpoints = value + .endpoints + .into_iter() + .map(|endpoint| { + RelayEndpointPreference::new( + value.environment, + endpoint.url, + RelayAccessPreference::parse(&endpoint.access)?, + ) + }) + .collect::<Result<Vec<_>, _>>()?; + Self::new(value.environment, endpoints) + } +} + +impl From<&BlossomPreferences> for StoredBlossom { + fn from(value: &BlossomPreferences) -> Self { + Self { + environment: value.environment, + authority: value.authority, + primary_origin: value.primary_origin.clone(), + fallback_origins: value.fallback_origins.clone(), + } + } +} + +impl TryFrom<StoredBlossom> for BlossomPreferences { + type Error = SettingsError; + + fn try_from(value: StoredBlossom) -> Result<Self, Self::Error> { + Self::new( + value.environment, + value.authority, + value.primary_origin, + value.fallback_origins, + ) + } +} + +impl From<&LocalStoragePolicy> for StoredLocalStorage { + fn from(value: &LocalStoragePolicy) -> Self { + Self { + media_cache_bytes: value.media_cache_bytes, + media_cache_artifacts: value.media_cache_artifacts, + } + } +} + +impl TryFrom<StoredLocalStorage> for LocalStoragePolicy { + type Error = SettingsError; + + fn try_from(value: StoredLocalStorage) -> Result<Self, Self::Error> { + Self::new(value.media_cache_bytes, value.media_cache_artifacts) + } +} + +fn settings_projection_id() -> Result<ProjectionId, SettingsError> { + ProjectionId::parse(SETTINGS_PROJECTION_ID).map_err(|_| SettingsError::CorruptDocument) +} + +fn settings_generation() -> Result<ProjectionGeneration, SettingsError> { + ProjectionGeneration::new(Sha256::digest(SETTINGS_GENERATION_DOMAIN).into()) + .map_err(|_| SettingsError::CorruptDocument) +} + +fn validate_identifier(value: &str, max_bytes: usize) -> bool { + !value.is_empty() + && value.len() <= max_bytes + && value == value.trim() + && value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-' | b'.' | b':')) +} + +fn validate_profile_text(value: &str, max_bytes: usize, allow_empty: bool) -> bool { + value.len() <= max_bytes + && (allow_empty || !value.trim().is_empty()) + && !value.chars().any(char::is_control) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::runtime::{ + builder::RuntimeBuilder, + store::{MobileUserStoreConfig, ProtectedDataAvailability}, + }; + + const PUBLIC_KEY: &str = "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798"; + + #[test] + fn identity_transitions_never_accept_or_serialize_private_key_material() { + let state = IdentityState::default() + .apply(IdentityCommand::BeginImport { + operation_id: "import:1".to_owned(), + }) + .unwrap() + .apply(IdentityCommand::CompleteImport { + operation_id: "import:1".to_owned(), + identity: IdentityRecord::new("primary", PUBLIC_KEY).unwrap(), + }) + .unwrap(); + assert_eq!(state.active_identity_id(), Some("primary")); + assert_eq!(state.lock_state(), IdentityLockState::Locked); + let settings = MobileSettings::default().with_identity(state); + let encoded = String::from_utf8(encode_settings(&settings).unwrap()).unwrap(); + assert!(encoded.contains(PUBLIC_KEY)); + assert!(!encoded.contains("private")); + assert!(!encoded.contains("secret")); + } + + #[test] + fn durable_identity_state_always_reopens_locked() { + let identity = IdentityRecord::new("primary", PUBLIC_KEY).unwrap(); + let state = IdentityState::new( + vec![identity], + Some("primary".to_owned()), + IdentityLockState::Unlocked, + None, + ) + .unwrap(); + let settings = MobileSettings::default().with_identity(state); + let encoded = encode_settings(&settings).unwrap(); + assert!(!String::from_utf8_lossy(&encoded).contains("unlocked")); + assert_eq!( + decode_settings(&encoded).unwrap().identity().lock_state(), + IdentityLockState::Locked + ); + } + + #[test] + fn unknown_relay_access_fails_closed_during_decode() { + let mut stored = StoredSettingsV1::from(&MobileSettings::default()); + stored.relays.endpoints[0].access = "write".to_owned(); + let encoded = serde_json::to_vec(&stored).unwrap(); + assert_eq!( + decode_settings(&encoded).unwrap_err(), + SettingsError::UnknownRelayAccess + ); + } + + #[test] + fn validated_relay_preferences_preserve_read_only_access() { + let preferences = RelayPreferences::new( + MobileNetworkEnvironment::Public, + vec![ + RelayEndpointPreference::new( + MobileNetworkEnvironment::Public, + "wss://read.example", + RelayAccessPreference::ReadOnly, + ) + .unwrap(), + ], + ) + .unwrap(); + let profile = preferences.sdk_profile().unwrap(); + assert_eq!(profile.endpoints().len(), 1); + assert!(!profile.endpoints()[0].access().can_write()); + } + + #[test] + fn production_and_simulator_defaults_use_canonical_origins() { + let production = MobileSettings::default(); + assert_eq!( + production.relays().endpoints()[0].url(), + DEFAULT_PUBLIC_RELAY + ); + assert_eq!( + production.blossom().primary_origin(), + DEFAULT_PUBLIC_BLOSSOM_ORIGIN + ); + assert_eq!( + RelayPreferences::simulator_default().endpoints()[0].url(), + DEFAULT_SIMULATOR_RELAY + ); + assert_eq!( + BlossomPreferences::simulator_default().primary_origin(), + DEFAULT_SIMULATOR_BLOSSOM_ORIGIN + ); + } + + #[test] + fn version_zero_migrates_background_transfers_to_disabled() { + let current = StoredSettingsV1::from(&MobileSettings::default()); + let legacy = serde_json::json!({ + "schema_version": 0, + "revision": current.revision, + "identity": current.identity, + "relays": current.relays, + "blossom": current.blossom, + "allow_cellular_downloads": true, + "allow_cellular_uploads": false, + "media_cache_bytes": current.local_storage.media_cache_bytes, + "media_cache_artifacts": current.local_storage.media_cache_artifacts, + }); + let migrated = decode_settings(&serde_json::to_vec(&legacy).unwrap()).unwrap(); + assert!(!migrated.media_network().allow_background_transfers()); + assert!(!migrated.media_network().allow_cellular_uploads()); + } + + #[test] + fn future_or_unknown_fields_fail_safely() { + let mut future = + serde_json::to_value(StoredSettingsV1::from(&MobileSettings::default())).unwrap(); + future["schema_version"] = serde_json::json!(2); + assert_eq!( + decode_settings(&serde_json::to_vec(&future).unwrap()).unwrap_err(), + SettingsError::UnsupportedSchema + ); + + let mut unknown = + serde_json::to_value(StoredSettingsV1::from(&MobileSettings::default())).unwrap(); + unknown["write_all_relays"] = serde_json::json!(true); + assert_eq!( + decode_settings(&serde_json::to_vec(&unknown).unwrap()).unwrap_err(), + SettingsError::CorruptDocument + ); + } + + #[test] + fn profile_metadata_command_validates_the_adopted_kind_zero_fields() { + let command = ProfileMetadataCommand::new( + "grower".to_owned(), + Some("Local Grower".to_owned()), + Some("Seasonal produce".to_owned()), + None, + None, + Some("grower@farm.example".to_owned()), + Some(false), + ) + .unwrap(); + assert_eq!(command.authored().name(), "grower"); + assert_eq!( + command.authored().nip05().map(Nip05Identifier::as_str), + Some("grower@farm.example") + ); + assert_eq!( + ProfileMetadataCommand::new( + "grower".to_owned(), + None, + None, + None, + None, + Some("GROWER@farm.example".to_owned()), + None, + ) + .unwrap_err() + .code(), + "invalid_profile_nip05" + ); + } + + #[tokio::test] + async fn settings_are_revision_checked_persisted_and_report_exact_effects() { + let runtime = RadrootsRuntime::test_memory().unwrap(); + let settings = runtime.phase1_settings().await.unwrap(); + let next = settings + .clone() + .with_media_network(MediaNetworkPolicy::new(false, true, false)); + let transition = runtime + .phase1_replace_settings(ReplaceMobileSettings::new(settings.revision(), next).unwrap()) + .await + .unwrap(); + assert_eq!(transition.settings.revision(), 2); + assert!(!transition.runtime_restart_required); + assert!(!transition.outbox_requeue_required); + assert!(transition.media_cache_invalidation_required); + assert_eq!( + runtime.phase1_settings().await.unwrap(), + transition.settings + ); + + let conflict = runtime + .phase1_replace_settings( + ReplaceMobileSettings::new(settings.revision(), settings).unwrap(), + ) + .await + .unwrap_err(); + assert_eq!(conflict, SettingsError::RevisionConflict); + } + + #[test] + fn settings_reject_mixed_network_environments() { + let settings = + MobileSettings::default().with_blossom(BlossomPreferences::simulator_default()); + assert_eq!( + ReplaceMobileSettings::new(settings.revision(), settings).unwrap_err(), + SettingsError::NetworkEnvironmentMismatch + ); + } + + #[tokio::test] + async fn atomic_identity_commands_persist_public_state_but_keep_unlock_process_local() { + let runtime = RadrootsRuntime::test_memory().unwrap(); + let begun = runtime + .phase1_apply_identity_command( + 1, + IdentityCommand::BeginImport { + operation_id: "import-1".to_owned(), + }, + ) + .await + .unwrap(); + assert_eq!(begun.settings.revision(), 2); + assert_eq!( + begun.settings.identity().pending_import_operation_id(), + Some("import-1") + ); + + let completed = runtime + .phase1_apply_identity_command( + 2, + IdentityCommand::CompleteImport { + operation_id: "import-1".to_owned(), + identity: IdentityRecord::new( + "primary", + "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", + ) + .unwrap(), + }, + ) + .await + .unwrap(); + assert_eq!(completed.settings.revision(), 3); + assert_eq!( + completed.settings.identity().active_identity_id(), + Some("primary") + ); + + let unlocked = runtime + .phase1_apply_identity_command(3, IdentityCommand::Unlock) + .await + .unwrap(); + assert_eq!(unlocked.settings.revision(), 3); + assert_eq!( + unlocked.settings.identity().lock_state(), + IdentityLockState::Unlocked + ); + assert_eq!( + runtime + .phase1_settings() + .await + .unwrap() + .identity() + .lock_state(), + IdentityLockState::Unlocked + ); + + let locked = runtime + .phase1_apply_identity_command(3, IdentityCommand::Lock) + .await + .unwrap(); + assert_eq!(locked.settings.revision(), 4); + assert_eq!( + locked.settings.identity().lock_state(), + IdentityLockState::Locked + ); + } + + #[tokio::test] + async fn concurrent_replacements_cannot_both_commit_the_same_revision() { + let runtime = RadrootsRuntime::test_memory().unwrap(); + let settings = runtime.phase1_settings().await.unwrap(); + let first = ReplaceMobileSettings::new( + settings.revision(), + settings + .clone() + .with_media_network(MediaNetworkPolicy::new(false, true, true)), + ) + .unwrap(); + let second = ReplaceMobileSettings::new( + settings.revision(), + settings.with_media_network(MediaNetworkPolicy::new(true, false, true)), + ) + .unwrap(); + + let (first, second) = tokio::join!( + runtime.phase1_replace_settings(first), + runtime.phase1_replace_settings(second) + ); + assert_eq!(usize::from(first.is_ok()) + usize::from(second.is_ok()), 1); + let failure = first.err().or_else(|| second.err()).unwrap(); + assert_eq!(failure, SettingsError::RevisionConflict); + } + + #[tokio::test] + async fn sqlite_settings_survive_a_runtime_restart() { + let root = tempfile::tempdir().unwrap(); + let store = MobileUserStoreConfig::from_encoded( + root.path(), + PUBLIC_KEY, + "0303030303030303030303030303030303030303030303030303030303030303", + 1_800_000_000_000, + ProtectedDataAvailability::Available, + ) + .unwrap(); + std::fs::create_dir_all(store.owner_directory()).unwrap(); + let runtime = RuntimeBuilder::new(store.clone()).build().await.unwrap(); + let settings = runtime.phase1_settings().await.unwrap(); + let transition = runtime + .phase1_replace_settings( + ReplaceMobileSettings::new( + settings.revision(), + settings.with_media_network(MediaNetworkPolicy::new(false, false, false)), + ) + .unwrap(), + ) + .await + .unwrap(); + runtime.shutdown().await.unwrap(); + drop(runtime); + + let reopened = RuntimeBuilder::new(store).build().await.unwrap(); + assert_eq!( + reopened.phase1_settings().await.unwrap(), + transition.settings + ); + reopened.shutdown().await.unwrap(); + } +} diff --git a/core/crates/tera_core/src/runtime/product_surface/today.rs b/core/crates/tera_core/src/runtime/product_surface/today.rs @@ -0,0 +1,3776 @@ +use std::collections::BTreeMap; + +use radroots_event_codec::{ + admission::{RadrootsAdmittedEvent, admit_verified_event}, + verify::verify_nip01_event, +}; +use radroots_storage::{ + EventStore, ProjectionStore, + event::{ + AdmissionReceipt, EventAdmission, EventPosition, EventQuery, EventQueryBounds, + EventSequence, + }, + projection::{ + ProjectionCheckpoint, ProjectionDocument, ProjectionGeneration, ProjectionId, + ProjectionSnapshot, + }, +}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use thiserror::Error; + +#[cfg(feature = "mobile-social")] +use radroots_blossom::{BlobUrl, MediaType}; +#[cfg(feature = "mobile-social")] +use radroots_event::admission::ContractValidatedEvent; +#[cfg(feature = "mobile-social")] +use radroots_sdk::transport::{ + BlossomCancellation, BlossomError, BlossomImageDimensions, BlossomInboundRequest, +}; +#[cfg(feature = "mobile-social")] +use radroots_sync::{ + PullRequest, + ingest::{AdmissionDecision, AdmissionPolicy}, + pull::PullTermination, +}; +#[cfg(feature = "mobile-social")] +use radroots_transport::{ + Target, outcome::FetchTargetState, source::FetchSelector, target::TargetSet, +}; + +#[cfg(feature = "mobile-social")] +use super::Phase1LocalMediaArtifact; +use super::{ + CardId, CardLifecycleState, ClassifiedCard, CursorError, CursorScope, LocalAuthorOverlay, + LocalNetwork, LocalityEvidence, MeSnapshot, MediaReference, Phase1InboundMediaError, + Phase1InboundMediaFailure, Phase1InboundMediaPending, Phase1InboundMediaState, + Phase1MediaArtifactId, Phase1MediaCacheIndex, Phase1MediaCacheStatus, + Phase1MediaConfigurationFingerprint, Phase1StructuralMediaReference, + ProductEventClassification, ProfileSummary, SearchResult, SearchResultType, SupportingProfile, + ThreadEntry, ThreadReference, TimeRelevance, TodayCard, TodayCardType, TodayCursor, + TodayCursorPosition, TodayPage, TodayRank, TodayRankInput, classify_admitted_event, +}; +#[cfg(any(feature = "mobile-social", test))] +use super::{Phase1MediaCachePolicy, Phase1VerifiedMediaReceipt}; +use crate::runtime::RadrootsRuntime; + +const TODAY_PROJECTION_ID: &str = "radroots.today.v1"; +const TODAY_PROJECTION_DOCUMENT_SCHEMA_VERSION: u16 = 1; +const TODAY_SNAPSHOT_SCHEMA_VERSION: u16 = 1; +const TODAY_PAGE_LIMIT_MAX: u16 = 100; +const TODAY_SEARCH_LIMIT_MAX: u16 = 100; +#[cfg(feature = "mobile-social")] +const TODAY_SYNC_PAGE_LIMIT: u16 = 500; +#[cfg(feature = "mobile-social")] +const TODAY_SYNC_MAX_PAGES: u16 = 8; +#[cfg(feature = "mobile-social")] +const TODAY_SYNC_KINDS: [u32; 7] = [0, 1, 5, 1111, 30_402, 31_922, 31_923]; +const PROJECTION_GENERATION_DOMAIN: &[u8] = b"radroots.today-projection.v1\0"; +const PROJECTION_CONTENT_DOMAIN: &[u8] = b"radroots.today-content-generation.v1\0"; +const PROJECTION_DOCUMENT_KEY_DOMAIN: &[u8] = b"radroots.today-document-key.v1\0"; +const SNAPSHOT_ID_DOMAIN: &[u8] = b"radroots.today-snapshot-id.v1\0"; + +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "PascalCase")] +pub enum TodayProjectionUpdate { + Incremental, + Rebuild, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct TodayRefreshReceipt { + pub update: TodayProjectionUpdate, + pub source_events: u64, + pub visible_cards: u64, + pub profiles: u64, + pub thread_entries: u64, + pub content_generation: u64, + pub changed: bool, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct TodayIngestReceipt { + pub event_id: String, + pub disposition: String, + pub source_sequence: u64, + pub projection: TodayRefreshReceipt, +} + +#[cfg(feature = "mobile-social")] +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "PascalCase")] +pub enum TodayRelaySyncState { + Complete, + Partial, + Offline, +} + +#[cfg(feature = "mobile-social")] +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct TodaySyncReceipt { + pub relay_state: TodayRelaySyncState, + pub pages_fetched: u16, + pub events_observed: u64, + pub events_admitted: u64, + pub events_rejected: u64, + pub projection: TodayRefreshReceipt, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct TodayPageRequest { + pub limit: u16, + pub as_of: Option<u64>, + pub cursor: Option<String>, +} + +impl TodayPageRequest { + pub const fn first(limit: u16, as_of: u64) -> Self { + Self { + limit, + as_of: Some(as_of), + cursor: None, + } + } + + pub fn after(limit: u16, cursor: String) -> Self { + Self { + limit, + as_of: None, + cursor: Some(cursor), + } + } +} + +#[derive(Debug, Error)] +pub enum TodayError { + #[error("today runtime is unavailable")] + RuntimeUnavailable, + #[error("today request is invalid")] + InvalidRequest, + #[error("today projection has not been refreshed")] + ProjectionMissing, + #[error("today frozen snapshot is unavailable")] + SnapshotMissing, + #[error("today cursor position is absent from its frozen snapshot")] + CursorPositionMissing, + #[error("today event was not admitted as visible")] + EventNotVisible, + #[error("today projection state is corrupt")] + CorruptProjection, + #[error(transparent)] + Cursor(#[from] CursorError), + #[error(transparent)] + Storage(#[from] radroots_storage::Error), + #[error("today projection serialization failed")] + Serialization, + #[error(transparent)] + InboundMedia(#[from] Phase1InboundMediaError), + #[cfg(feature = "mobile-social")] + #[error(transparent)] + InboundRetrieval(#[from] BlossomError), +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +struct ProjectedCard { + card: ClassifiedCard, + locality: Vec<LocalityTag>, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Ord, PartialOrd, Serialize)] +#[serde(rename_all = "camelCase")] +struct LocalityTag { + kind: String, + value: String, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +struct TodayProjectionState { + schema_version: u16, + context_id: String, + context_generation: u64, + store_generation: [u8; 32], + source_events: u64, + content_generation: u64, + cards: Vec<ProjectedCard>, + profiles: BTreeMap<String, ProfileSummary>, + thread: Vec<ThreadEntry>, + overlays: BTreeMap<String, LocalAuthorOverlay>, + #[serde(default)] + media_cache: Phase1MediaCacheIndex, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +struct FrozenTodaySnapshot { + schema_version: u16, + context_id: String, + context_generation: u64, + as_of: u64, + store_generation: [u8; 32], + projection_generation: u64, + items: Vec<TodayCard>, +} + +impl RadrootsRuntime { + /// Pulls bounded Today-relevant relay pages, canonically admits valid + /// observations, and materializes the selected LocalNetwork projection. + #[cfg(feature = "mobile-social")] + pub async fn phase1_sync_today( + &self, + context: &LocalNetwork, + now_unix_seconds: u64, + update: TodayProjectionUpdate, + ) -> Result<TodaySyncReceipt, TodayError> { + if now_unix_seconds == 0 { + return Err(TodayError::InvalidRequest); + } + let targets = context + .relay_urls + .iter() + .map(Target::nostr_relay) + .collect::<Result<Vec<_>, _>>() + .map_err(|_| TodayError::InvalidRequest)?; + let targets = TargetSet::new(targets).map_err(|_| TodayError::InvalidRequest)?; + let selector = FetchSelector::all() + .with_kinds(TODAY_SYNC_KINDS.to_vec()) + .map_err(|_| TodayError::InvalidRequest)?; + let request = PullRequest::new(targets, TODAY_SYNC_PAGE_LIMIT, TODAY_SYNC_MAX_PAGES) + .map_err(|_| TodayError::RuntimeUnavailable)? + .with_selector(selector); + let sync = self + .client + .sync() + .map_err(|_| TodayError::RuntimeUnavailable)? + .ok_or(TodayError::RuntimeUnavailable)?; + let pull = sync + .pull(request, &TodayAdmissionPolicy) + .await + .map_err(|_| TodayError::RuntimeUnavailable)?; + let projection = self + .phase1_refresh_today(context, now_unix_seconds, update) + .await?; + let events_admitted = pull + .ingest_outcomes() + .iter() + .filter(|outcome| outcome.is_ok()) + .count() as u64; + let events_observed = u64::try_from(pull.events_observed()).unwrap_or(u64::MAX); + let events_rejected = events_observed.saturating_sub(events_admitted); + let target_complete = !pull.target_outcomes().is_empty() + && pull + .target_outcomes() + .iter() + .all(|outcome| outcome.state() == FetchTargetState::Complete); + let relay_state = match pull.termination() { + PullTermination::Complete if target_complete => TodayRelaySyncState::Complete, + PullTermination::SourceFailed if pull.pages_fetched() == 0 => { + TodayRelaySyncState::Offline + } + _ => TodayRelaySyncState::Partial, + }; + Ok(TodaySyncReceipt { + relay_state, + pages_fetched: pull.pages_fetched(), + events_observed, + events_admitted, + events_rejected, + projection, + }) + } + + /// Durably admits one already verified and visibility-authorized relay observation, + /// then advances the selected LocalNetwork projection. + pub async fn phase1_ingest_visible( + &self, + admission: EventAdmission, + context: &LocalNetwork, + now_unix_seconds: u64, + ) -> Result<TodayIngestReceipt, TodayError> { + if admission.visible_event().is_none() { + return Err(TodayError::EventNotVisible); + } + let storage = self + .client + .storage() + .map_err(|_| TodayError::RuntimeUnavailable)?; + let receipt = EventStore::admit(storage, admission).await?; + let projection = self + .phase1_refresh_today( + context, + now_unix_seconds, + TodayProjectionUpdate::Incremental, + ) + .await?; + Ok(ingest_receipt(receipt, projection)) + } + + /// Materializes current visible event truth for one LocalNetwork. + pub async fn phase1_refresh_today( + &self, + context: &LocalNetwork, + now_unix_seconds: u64, + update: TodayProjectionUpdate, + ) -> Result<TodayRefreshReceipt, TodayError> { + if now_unix_seconds == 0 { + return Err(TodayError::InvalidRequest); + } + let requested_updated_at_unix_ms = now_unix_seconds + .checked_mul(1_000) + .ok_or(TodayError::InvalidRequest)?; + let storage = self + .client + .storage() + .map_err(|_| TodayError::RuntimeUnavailable)?; + if update == TodayProjectionUpdate::Rebuild { + EventStore::rebuild_visibility(storage).await?; + } + let event_status = EventStore::status(storage).await?; + let generation = projection_generation()?; + let projection_id = projection_id()?; + let key = projection_document_key(context); + let prior = load_state(storage, context, generation).await?; + + if update == TodayProjectionUpdate::Incremental + && prior + .as_ref() + .is_some_and(|state| state.source_events == event_status.raw_events()) + { + let state = prior.expect("checked present"); + return Ok(refresh_receipt(update, &state, false)); + } + + let visible = query_all_visible(storage).await?; + let local_media = prior.as_ref().map(local_media_evidence).unwrap_or_default(); + let overlays = prior + .as_ref() + .map_or_else(BTreeMap::new, |state| state.overlays.clone()); + let media_cache = + prior.map_or_else(Phase1MediaCacheIndex::default, |state| state.media_cache); + let mut state = project_state( + context, + event_status.generation().as_bytes(), + event_status.raw_events(), + visible, + overlays, + )?; + state.media_cache = media_cache; + apply_local_media_evidence(&mut state, &local_media); + state.content_generation = content_generation(&state)?; + let encoded = encode(&state)?; + let changed = ProjectionStore::projection_document( + storage, + projection_id.clone(), + generation, + key.clone(), + ) + .await? + .is_none_or(|document| document.value() != encoded); + ProjectionStore::put_projection_document( + storage, + projection_id.clone(), + generation, + ProjectionDocument::new(key, encoded)?, + ) + .await?; + + let source_position = if event_status.raw_events() == 0 { + None + } else { + Some(EventPosition::new( + event_status.generation(), + EventSequence::new(event_status.raw_events())?, + )) + }; + let prior_updated_at = ProjectionStore::status(storage, projection_id.clone()) + .await? + .and_then(|status| { + status + .checkpoint() + .map(ProjectionCheckpoint::updated_at_unix_ms) + }) + .unwrap_or(0); + let updated_at_unix_ms = requested_updated_at_unix_ms.max(prior_updated_at); + ProjectionStore::checkpoint( + storage, + ProjectionCheckpoint::new( + projection_id, + generation, + source_position, + event_status.raw_events(), + updated_at_unix_ms, + )?, + ) + .await?; + Ok(refresh_receipt(update, &state, changed)) + } + + /// Returns one page from a durable frozen Today snapshot. + pub async fn phase1_today_page( + &self, + context: &LocalNetwork, + request: TodayPageRequest, + ) -> Result<TodayPage, TodayError> { + if request.limit == 0 || request.limit > TODAY_PAGE_LIMIT_MAX { + return Err(TodayError::InvalidRequest); + } + let storage = self + .client + .storage() + .map_err(|_| TodayError::RuntimeUnavailable)?; + let event_status = EventStore::status(storage).await?; + let algorithm_generation = projection_generation()?; + let projection_id = projection_id()?; + + let (scope, snapshot, after) = if let Some(cursor) = request.cursor.as_deref() { + let scope = TodayCursor::scope(cursor)?; + if scope.context_id != context.id || scope.context_generation != context.generation { + return Err(CursorError::ContextMismatch.into()); + } + if request.as_of.is_some_and(|as_of| as_of != scope.as_of) { + return Err(CursorError::SnapshotMismatch.into()); + } + if scope.store_generation != *event_status.generation().as_bytes() { + return Err(CursorError::Stale.into()); + } + let position = TodayCursor::decode(cursor, &scope)?; + let mut snapshot = load_snapshot(storage, projection_id, algorithm_generation, &scope) + .await? + .ok_or(TodayError::SnapshotMissing)?; + let current = load_state(storage, context, algorithm_generation) + .await? + .ok_or(TodayError::ProjectionMissing)?; + sanitize_snapshot_media(&mut snapshot, &current.media_cache); + (scope, snapshot, Some(position.rank)) + } else { + let as_of = request + .as_of + .filter(|value| *value != 0) + .ok_or(TodayError::InvalidRequest)?; + let state = load_state(storage, context, algorithm_generation) + .await? + .ok_or(TodayError::ProjectionMissing)?; + if state.store_generation != *event_status.generation().as_bytes() { + return Err(CursorError::Stale.into()); + } + let scope = CursorScope::new( + context.id.clone(), + context.generation, + as_of, + state.store_generation, + state.content_generation, + )?; + let snapshot = frozen_snapshot(&state, context, as_of)?; + persist_snapshot(storage, algorithm_generation, &scope, &snapshot).await?; + (scope, snapshot, None) + }; + + page_from_snapshot(snapshot, scope, after, request.limit) + } + + /// Searches the current local projection using Today visibility and context rules. + pub async fn phase1_search( + &self, + context: &LocalNetwork, + query: &str, + limit: u16, + as_of: u64, + ) -> Result<Vec<SearchResult>, TodayError> { + if limit == 0 || limit > TODAY_SEARCH_LIMIT_MAX || as_of == 0 { + return Err(TodayError::InvalidRequest); + } + let needle = query.trim().to_lowercase(); + if needle.is_empty() || needle.len() > 256 || query.chars().any(char::is_control) { + return Err(TodayError::InvalidRequest); + } + let storage = self + .client + .storage() + .map_err(|_| TodayError::RuntimeUnavailable)?; + let state = load_state(storage, context, projection_generation()?) + .await? + .ok_or(TodayError::ProjectionMissing)?; + let cards = ranked_cards(&state, context, as_of)?; + let mut results = Vec::new(); + for card in cards { + let searchable = format!( + "{} {} {} {}", + card.card.title.as_deref().unwrap_or(""), + card.card.content, + card.card.author_pubkey, + card.author_profile + .as_ref() + .and_then(|profile| profile.display_name.as_deref().or(profile.name.as_deref())) + .unwrap_or("") + ) + .to_lowercase(); + if searchable.contains(&needle) { + results.push(SearchResult { + result_type: SearchResultType::Card, + stable_id: card.card.card_id.to_hex(), + card: Some(card), + profile: None, + }); + if results.len() == usize::from(limit) { + return Ok(results); + } + } + } + for profile in state.profiles.values() { + let searchable = format!( + "{} {} {} {} {}", + profile.name.as_deref().unwrap_or(""), + profile.display_name.as_deref().unwrap_or(""), + profile.about.as_deref().unwrap_or(""), + profile.website.as_deref().unwrap_or(""), + profile.lightning_address.as_deref().unwrap_or("") + ) + .to_lowercase(); + if searchable.contains(&needle) { + results.push(SearchResult { + result_type: SearchResultType::Profile, + stable_id: profile.author_pubkey.clone(), + card: None, + profile: Some(profile.clone()), + }); + if results.len() == usize::from(limit) { + break; + } + } + } + Ok(results) + } + + /// Returns current active-identity attribution and visible Phase 1 content. + pub async fn phase1_me( + &self, + context: &LocalNetwork, + public_key: &str, + as_of: u64, + ) -> Result<MeSnapshot, TodayError> { + if !valid_public_key(public_key) || as_of == 0 { + return Err(TodayError::InvalidRequest); + } + if self + .authenticated_store_public_key_hex() + .is_some_and(|store_key| store_key != public_key) + { + return Err(TodayError::InvalidRequest); + } + let storage = self + .client + .storage() + .map_err(|_| TodayError::RuntimeUnavailable)?; + let state = load_state(storage, context, projection_generation()?) + .await? + .ok_or(TodayError::ProjectionMissing)?; + let cards = ranked_cards(&state, context, as_of)? + .into_iter() + .filter(|card| card.card.author_pubkey == public_key) + .collect(); + Ok(MeSnapshot { + public_key: public_key.to_owned(), + profile: state.profiles.get(public_key).cloned(), + cards, + }) + } + + /// Starts one typed retrieval for every occurrence of the exact structural + /// reference. URL equality alone is deliberately insufficient. + pub async fn phase1_begin_media_retrieval( + &self, + context: &LocalNetwork, + reference_fingerprint: [u8; 32], + pending: Phase1InboundMediaPending, + ) -> Result<bool, TodayError> { + let storage = self + .client + .storage() + .map_err(|_| TodayError::RuntimeUnavailable)?; + let generation = projection_generation()?; + let mut state = load_state(storage, context, generation) + .await? + .ok_or(TodayError::ProjectionMissing)?; + let mut trial = state.clone(); + let prior_configuration = trial.media_cache.status()?.configuration; + if prior_configuration.is_some_and(|value| value != pending.configuration()) { + return Err(Phase1InboundMediaError::ConfigurationMismatch.into()); + } + trial + .media_cache + .invalidate_configuration(pending.configuration()); + let changed = mutate_matching_media(&mut trial, reference_fingerprint, |media| { + media.begin(pending.clone()) + })?; + if changed { + state = trial; + persist_media_state(storage, context, generation, &mut state).await?; + } + Ok(changed) + } + + /// Records a bounded, safe retrieval failure for the active operation. + pub async fn phase1_fail_media_retrieval( + &self, + context: &LocalNetwork, + reference_fingerprint: [u8; 32], + failure: Phase1InboundMediaFailure, + ) -> Result<bool, TodayError> { + let storage = self + .client + .storage() + .map_err(|_| TodayError::RuntimeUnavailable)?; + let generation = projection_generation()?; + let mut state = load_state(storage, context, generation) + .await? + .ok_or(TodayError::ProjectionMissing)?; + let changed = mutate_matching_media(&mut state, reference_fingerprint, |media| { + media.fail(failure.clone()) + })?; + if changed { + persist_media_state(storage, context, generation, &mut state).await?; + } + Ok(changed) + } + + /// Atomically binds exact-byte evidence to the matching reference and + /// admits its content-addressed cache entry under the active LRU quota. + #[cfg(any(feature = "mobile-social", test))] + pub(crate) async fn phase1_commit_media_receipt( + &self, + context: &LocalNetwork, + reference_fingerprint: [u8; 32], + operation_id: [u8; 16], + receipt: Phase1VerifiedMediaReceipt, + policy: Phase1MediaCachePolicy, + cached_at_unix_ms: u64, + ) -> Result<Vec<Phase1MediaArtifactId>, TodayError> { + let storage = self + .client + .storage() + .map_err(|_| TodayError::RuntimeUnavailable)?; + let generation = projection_generation()?; + let mut state = load_state(storage, context, generation) + .await? + .ok_or(TodayError::ProjectionMissing)?; + let mut trial = state.clone(); + let changed = mutate_matching_media(&mut trial, reference_fingerprint, |media| { + media.verify(operation_id, receipt.clone()) + })?; + if !changed { + return Err(TodayError::InvalidRequest); + } + let evicted = trial + .media_cache + .admit(&receipt, policy, cached_at_unix_ms)?; + for artifact_id in &evicted { + invalidate_artifact_references(&mut trial, *artifact_id); + } + state = trial; + persist_media_state(storage, context, generation, &mut state).await?; + Ok(evicted) + } + + /// Records one successful local artifact access for deterministic LRU. + pub async fn phase1_touch_media_artifact( + &self, + context: &LocalNetwork, + artifact_id: Phase1MediaArtifactId, + observed_at_unix_ms: u64, + ) -> Result<bool, TodayError> { + let storage = self + .client + .storage() + .map_err(|_| TodayError::RuntimeUnavailable)?; + let generation = projection_generation()?; + let mut state = load_state(storage, context, generation) + .await? + .ok_or(TodayError::ProjectionMissing)?; + let changed = state.media_cache.touch(artifact_id, observed_at_unix_ms)?; + if changed { + persist_media_state(storage, context, generation, &mut state).await?; + } + Ok(changed) + } + + /// Invalidates a missing, corrupt, or explicitly evicted local artifact. + pub async fn phase1_invalidate_media_artifact( + &self, + context: &LocalNetwork, + artifact_id: Phase1MediaArtifactId, + ) -> Result<bool, TodayError> { + #[cfg(feature = "mobile-social")] + let _guard = self.inbound_media_lock.lock().await; + let storage = self + .client + .storage() + .map_err(|_| TodayError::RuntimeUnavailable)?; + let generation = projection_generation()?; + let mut state = load_state(storage, context, generation) + .await? + .ok_or(TodayError::ProjectionMissing)?; + let cache_changed = state.media_cache.invalidate_artifact(artifact_id); + let references_changed = invalidate_artifact_references(&mut state, artifact_id); + if cache_changed || references_changed { + persist_media_state(storage, context, generation, &mut state).await?; + } + #[cfg(feature = "mobile-social")] + if let Some(directory) = self.inbound_media_directory.as_deref() { + super::media::remove_artifact_files(directory, artifact_id).await?; + } + Ok(cache_changed || references_changed) + } + + /// Clears all trust derived under an obsolete endpoint/network/cache + /// configuration and records the new configuration generation. + pub async fn phase1_invalidate_media_configuration( + &self, + context: &LocalNetwork, + configuration: Phase1MediaConfigurationFingerprint, + ) -> Result<Vec<Phase1MediaArtifactId>, TodayError> { + #[cfg(feature = "mobile-social")] + let _guard = self.inbound_media_lock.lock().await; + let storage = self + .client + .storage() + .map_err(|_| TodayError::RuntimeUnavailable)?; + let generation = projection_generation()?; + let mut state = load_state(storage, context, generation) + .await? + .ok_or(TodayError::ProjectionMissing)?; + let prior_configuration = state.media_cache.status()?.configuration; + let removed = state.media_cache.invalidate_configuration(configuration); + if prior_configuration != Some(configuration) { + for_each_media_mut(&mut state, |media| { + media.invalidate(); + }); + persist_media_state(storage, context, generation, &mut state).await?; + } + #[cfg(feature = "mobile-social")] + if let Some(directory) = self.inbound_media_directory.as_deref() { + for artifact_id in &removed { + super::media::remove_artifact_files(directory, *artifact_id).await?; + } + } + Ok(removed) + } + + pub async fn phase1_media_cache_status( + &self, + context: &LocalNetwork, + ) -> Result<Phase1MediaCacheStatus, TodayError> { + let storage = self + .client + .storage() + .map_err(|_| TodayError::RuntimeUnavailable)?; + let state = load_state(storage, context, projection_generation()?) + .await? + .ok_or(TodayError::ProjectionMissing)?; + state.media_cache.status().map_err(TodayError::from) + } + + /// Resolves one renderable artifact only after rechecking the exact local + /// file. Missing or corrupt bytes atomically revoke all matching receipts. + #[cfg(feature = "mobile-social")] + pub async fn phase1_verified_media_artifact( + &self, + context: &LocalNetwork, + artifact_id: Phase1MediaArtifactId, + observed_at_unix_ms: u64, + ) -> Result<Option<Phase1LocalMediaArtifact>, TodayError> { + let _guard = self.inbound_media_lock.lock().await; + let directory = self + .inbound_media_directory + .as_deref() + .ok_or(Phase1InboundMediaError::CacheUnavailable)?; + let storage = self + .client + .storage() + .map_err(|_| TodayError::RuntimeUnavailable)?; + let generation = projection_generation()?; + let mut state = load_state(storage, context, generation) + .await? + .ok_or(TodayError::ProjectionMissing)?; + let Some(receipt) = verified_receipt(&state, artifact_id) else { + return Ok(None); + }; + match super::media::verified_artifact(directory, &receipt).await { + Ok(artifact) => { + if state.media_cache.touch(artifact_id, observed_at_unix_ms)? { + persist_media_state(storage, context, generation, &mut state).await?; + } + Ok(Some(artifact)) + } + Err(error) => { + state.media_cache.invalidate_artifact(artifact_id); + invalidate_artifact_references(&mut state, artifact_id); + persist_media_state(storage, context, generation, &mut state).await?; + let _ = super::media::remove_artifact_files(directory, artifact_id).await; + Err(error.into()) + } + } + } + + /// Completes one bounded BUD-01 retrieval, exact-byte verification, and + /// atomic content-addressed cache commit under the configured Blossom slot. + #[cfg(feature = "mobile-social")] + pub async fn phase1_retrieve_media( + &self, + context: &LocalNetwork, + reference_fingerprint: [u8; 32], + operation_id: [u8; 16], + policy: Phase1MediaCachePolicy, + cancellation: BlossomCancellation, + ) -> Result<Phase1LocalMediaArtifact, TodayError> { + let _guard = self.inbound_media_lock.lock().await; + let directory = self + .inbound_media_directory + .as_deref() + .ok_or(Phase1InboundMediaError::CacheUnavailable)?; + let blossom = self + .client + .blossom() + .map_err(|_| TodayError::RuntimeUnavailable)? + .cloned() + .ok_or(TodayError::RuntimeUnavailable)?; + let sdk_configuration = blossom + .config_fingerprint() + .ok_or(TodayError::RuntimeUnavailable)?; + let configuration = + Phase1MediaConfigurationFingerprint::new(*sdk_configuration.as_bytes())?; + let structural = load_structural_reference(self, context, reference_fingerprint).await?; + let started_at_unix_ms = inbound_now_unix_ms()?; + self.phase1_begin_media_retrieval( + context, + reference_fingerprint, + Phase1InboundMediaPending::new(operation_id, configuration, started_at_unix_ms)?, + ) + .await?; + let request = match inbound_request(&structural) { + Ok(request) => request, + Err(error) => { + record_inbound_failure( + self, + context, + reference_fingerprint, + operation_id, + "invalid_reference", + false, + ) + .await; + return Err(error); + } + }; + let sdk_receipt = match blossom.retrieve(request, cancellation).await { + Ok(receipt) => receipt, + Err(error) => { + record_inbound_failure( + self, + context, + reference_fingerprint, + operation_id, + error.code().trim_start_matches("blossom_"), + error.retryable(), + ) + .await; + return Err(error.into()); + } + }; + if sdk_receipt.config_fingerprint() != sdk_configuration { + record_inbound_failure( + self, + context, + reference_fingerprint, + operation_id, + "configuration_changed", + false, + ) + .await; + return Err(Phase1InboundMediaError::ConfigurationMismatch.into()); + } + let dimensions = sdk_receipt.dimensions(); + let receipt = match Phase1VerifiedMediaReceipt::from_commitment( + &structural, + sdk_receipt.final_url().clone(), + sdk_receipt.commitment(), + dimensions.width(), + dimensions.height(), + configuration, + sdk_receipt.verified_at_unix_ms(), + ) { + Ok(receipt) => receipt, + Err(error) => { + record_inbound_failure( + self, + context, + reference_fingerprint, + operation_id, + "verification_failed", + false, + ) + .await; + return Err(error.into()); + } + }; + let artifact = + match super::media::write_verified_artifact(directory, &receipt, sdk_receipt.bytes()) + .await + { + Ok(artifact) => artifact, + Err(error) => { + record_inbound_failure( + self, + context, + reference_fingerprint, + operation_id, + "cache_write_failed", + true, + ) + .await; + return Err(error.into()); + } + }; + let evicted = match self + .phase1_commit_media_receipt( + context, + reference_fingerprint, + operation_id, + receipt, + policy, + sdk_receipt.verified_at_unix_ms(), + ) + .await + { + Ok(evicted) => evicted, + Err(error) => { + record_inbound_failure( + self, + context, + reference_fingerprint, + operation_id, + "cache_commit_failed", + true, + ) + .await; + return Err(error); + } + }; + for artifact_id in evicted { + super::media::remove_artifact_files(directory, artifact_id).await?; + } + Ok(artifact) + } + + /// Persists active-author delivery state as a local-only Today overlay. + pub async fn phase1_set_local_author_overlay( + &self, + context: &LocalNetwork, + card_id: CardId, + overlay: Option<LocalAuthorOverlay>, + ) -> Result<(), TodayError> { + let storage = self + .client + .storage() + .map_err(|_| TodayError::RuntimeUnavailable)?; + let generation = projection_generation()?; + let mut state = load_state(storage, context, generation) + .await? + .ok_or(TodayError::ProjectionMissing)?; + if overlay.as_ref().is_some_and(|overlay| { + overlay.operation_id.is_empty() + || overlay.operation_id.len() > 256 + || overlay.state.is_empty() + || overlay.state.len() > 96 + || overlay.state.chars().any(char::is_control) + }) { + return Err(TodayError::InvalidRequest); + } + let key = card_id.to_hex(); + let card = state + .cards + .iter() + .find(|projected| projected.card.card_id == card_id) + .ok_or(TodayError::InvalidRequest)?; + if self + .authenticated_store_public_key_hex() + .is_some_and(|store_key| store_key != card.card.author_pubkey) + { + return Err(TodayError::InvalidRequest); + } + if let Some(overlay) = overlay { + state.overlays.insert(key, overlay); + } else { + state.overlays.remove(&key); + } + state.content_generation = content_generation(&state)?; + store_state(storage, context, generation, &state).await + } +} + +#[cfg(feature = "mobile-social")] +async fn load_structural_reference( + runtime: &RadrootsRuntime, + context: &LocalNetwork, + reference_fingerprint: [u8; 32], +) -> Result<Phase1StructuralMediaReference, TodayError> { + let storage = runtime + .client + .storage() + .map_err(|_| TodayError::RuntimeUnavailable)?; + let state = load_state(storage, context, projection_generation()?) + .await? + .ok_or(TodayError::ProjectionMissing)?; + state + .cards + .iter() + .flat_map(|projected| projected.card.media.iter()) + .chain( + state + .profiles + .values() + .flat_map(|profile| [&profile.picture, &profile.banner].into_iter().flatten()), + ) + .find(|media| media.structural().fingerprint() == &reference_fingerprint) + .map(|media| media.structural().clone()) + .ok_or(TodayError::InvalidRequest) +} + +#[cfg(feature = "mobile-social")] +fn inbound_request( + structural: &Phase1StructuralMediaReference, +) -> Result<BlossomInboundRequest, TodayError> { + let url = BlobUrl::parse(structural.source_url()) + .map_err(|_| Phase1InboundMediaError::InvalidReference)?; + let media_type = structural + .expected_media_type() + .map(MediaType::parse) + .transpose() + .map_err(|_| Phase1InboundMediaError::InvalidMediaType)?; + let dimensions = match (structural.expected_width(), structural.expected_height()) { + (Some(width), Some(height)) => Some(BlossomImageDimensions::new(width, height)?), + (None, None) => None, + _ => return Err(Phase1InboundMediaError::InvalidDimensions.into()), + }; + BlossomInboundRequest::new(url, media_type, structural.expected_byte_size(), dimensions) + .map_err(TodayError::from) +} + +#[cfg(feature = "mobile-social")] +fn inbound_now_unix_ms() -> Result<u64, TodayError> { + use std::time::{SystemTime, UNIX_EPOCH}; + + SystemTime::now() + .duration_since(UNIX_EPOCH) + .ok() + .and_then(|duration| u64::try_from(duration.as_millis()).ok()) + .filter(|value| *value != 0) + .ok_or(TodayError::RuntimeUnavailable) +} + +#[cfg(feature = "mobile-social")] +async fn record_inbound_failure( + runtime: &RadrootsRuntime, + context: &LocalNetwork, + reference_fingerprint: [u8; 32], + operation_id: [u8; 16], + safe_code: &str, + retryable: bool, +) { + let Ok(failed_at_unix_ms) = inbound_now_unix_ms() else { + return; + }; + let Ok(failure) = + Phase1InboundMediaFailure::new(operation_id, safe_code, retryable, failed_at_unix_ms) + else { + return; + }; + let _ = runtime + .phase1_fail_media_retrieval(context, reference_fingerprint, failure) + .await; +} + +#[cfg(feature = "mobile-social")] +struct TodayAdmissionPolicy; + +#[cfg(feature = "mobile-social")] +impl AdmissionPolicy for TodayAdmissionPolicy { + fn policy_id(&self) -> &'static str { + "radroots.mobile.today.v1" + } + + fn select_contract( + &self, + event: &radroots_event::admission::SignatureVerifiedEvent, + ) -> Option<&'static str> { + verify_nip01_event(event.event().clone()) + .ok() + .and_then(|event| admit_verified_event(event).ok()) + .map(|event| event.contract().id) + } + + fn decide(&self, event: &ContractValidatedEvent) -> AdmissionDecision { + let admitted = verify_nip01_event(event.event().clone()) + .ok() + .and_then(|event| admit_verified_event(event).ok()); + if admitted.is_some() { + AdmissionDecision::Visible + } else { + AdmissionDecision::Reject + } + } +} + +fn ingest_receipt( + receipt: AdmissionReceipt, + projection: TodayRefreshReceipt, +) -> TodayIngestReceipt { + TodayIngestReceipt { + event_id: receipt.event_id().to_hex(), + disposition: format!("{:?}", receipt.disposition()).to_lowercase(), + source_sequence: receipt.position().sequence().get(), + projection, + } +} + +fn refresh_receipt( + update: TodayProjectionUpdate, + state: &TodayProjectionState, + changed: bool, +) -> TodayRefreshReceipt { + TodayRefreshReceipt { + update, + source_events: state.source_events, + visible_cards: state.cards.len().try_into().unwrap_or(u64::MAX), + profiles: state.profiles.len().try_into().unwrap_or(u64::MAX), + thread_entries: state.thread.len().try_into().unwrap_or(u64::MAX), + content_generation: state.content_generation, + changed, + } +} + +fn local_media_evidence( + state: &TodayProjectionState, +) -> BTreeMap<[u8; 32], Phase1InboundMediaState> { + let mut evidence = state + .cards + .iter() + .flat_map(|projected| projected.card.media.iter()) + .filter(|media| !matches!(media.retrieval(), Phase1InboundMediaState::Unavailable)) + .map(|media| (*media.structural().fingerprint(), media.retrieval().clone())) + .collect::<BTreeMap<_, _>>(); + for profile in state.profiles.values() { + for media in [&profile.picture, &profile.banner].into_iter().flatten() { + if !matches!(media.retrieval(), Phase1InboundMediaState::Unavailable) { + evidence.insert(*media.structural().fingerprint(), media.retrieval().clone()); + } + } + } + evidence +} + +fn apply_local_media_evidence( + state: &mut TodayProjectionState, + evidence: &BTreeMap<[u8; 32], Phase1InboundMediaState>, +) { + let cache = state.media_cache.clone(); + for_each_media_mut(state, |media| { + if let Some(retrieval) = evidence.get(media.structural().fingerprint()) + && media.restore(retrieval.clone(), &cache).is_err() + { + media.invalidate(); + } + }); + refresh_thread_profiles(state); +} + +fn for_each_media_mut( + state: &mut TodayProjectionState, + mut action: impl FnMut(&mut MediaReference), +) { + for projected in &mut state.cards { + for media in &mut projected.card.media { + action(media); + } + } + for profile in state.profiles.values_mut() { + for media in [&mut profile.picture, &mut profile.banner] + .into_iter() + .flatten() + { + action(media); + } + } +} + +fn mutate_matching_media( + state: &mut TodayProjectionState, + reference_fingerprint: [u8; 32], + mut action: impl FnMut(&mut MediaReference) -> Result<(), Phase1InboundMediaError>, +) -> Result<bool, TodayError> { + let mut found = false; + let mut failure = None; + for_each_media_mut(state, |media| { + if failure.is_none() && media.structural().fingerprint() == &reference_fingerprint { + found = true; + if let Err(error) = action(media) { + failure = Some(error); + } + } + }); + if let Some(error) = failure { + return Err(error.into()); + } + if found { + refresh_thread_profiles(state); + } + Ok(found) +} + +fn invalidate_artifact_references( + state: &mut TodayProjectionState, + artifact_id: Phase1MediaArtifactId, +) -> bool { + let mut changed = false; + for_each_media_mut(state, |media| { + if matches!( + media.retrieval(), + Phase1InboundMediaState::Verified(receipt) if receipt.artifact_id() == artifact_id + ) { + media.invalidate(); + changed = true; + } + }); + if changed { + refresh_thread_profiles(state); + } + changed +} + +#[cfg(feature = "mobile-social")] +fn verified_receipt( + state: &TodayProjectionState, + artifact_id: Phase1MediaArtifactId, +) -> Option<Phase1VerifiedMediaReceipt> { + state + .cards + .iter() + .flat_map(|projected| projected.card.media.iter()) + .chain( + state + .profiles + .values() + .flat_map(|profile| [&profile.picture, &profile.banner].into_iter().flatten()), + ) + .find_map(|media| match media.retrieval() { + Phase1InboundMediaState::Verified(receipt) if receipt.artifact_id() == artifact_id => { + Some((**receipt).clone()) + } + _ => None, + }) +} + +async fn persist_media_state( + storage: &dyn radroots_storage::Storage, + context: &LocalNetwork, + generation: ProjectionGeneration, + state: &mut TodayProjectionState, +) -> Result<(), TodayError> { + refresh_thread_profiles(state); + validate_media_state(state)?; + state.content_generation = content_generation(state)?; + store_state(storage, context, generation, state).await +} + +fn refresh_thread_profiles(state: &mut TodayProjectionState) { + for entry in &mut state.thread { + entry.author_profile = state.profiles.get(&entry.author_pubkey).cloned(); + } +} + +async fn query_all_visible( + storage: &dyn radroots_storage::Storage, +) -> Result<Vec<radroots_storage::event::StoredVisibleEvent>, TodayError> { + let mut items = Vec::new(); + let mut after = None; + loop { + let mut bounds = EventQueryBounds::first(radroots_storage::event::EVENT_QUERY_LIMIT_MAX)?; + if let Some(cursor) = after { + bounds = bounds.after(cursor); + } + let page = EventStore::query_visible(storage, EventQuery::all(bounds)).await?; + items.extend_from_slice(page.items()); + let Some(next) = page.next_cursor() else { + break; + }; + after = Some(next); + } + Ok(items) +} + +fn project_state( + context: &LocalNetwork, + store_generation: &[u8; 32], + source_events: u64, + visible: Vec<radroots_storage::event::StoredVisibleEvent>, + overlays: BTreeMap<String, LocalAuthorOverlay>, +) -> Result<TodayProjectionState, TodayError> { + let mut cards = Vec::new(); + let mut profiles = BTreeMap::new(); + let mut thread = Vec::new(); + for stored in visible { + let verified = verify_nip01_event(stored.event().envelope().clone()) + .map_err(|_| TodayError::CorruptProjection)?; + let admitted = admit_verified_event(verified).map_err(|_| TodayError::CorruptProjection)?; + match &admitted { + RadrootsAdmittedEvent::Profile(profile) => { + profiles.insert( + profile.event().author().to_hex(), + profile_summary(&admitted)?, + ); + } + RadrootsAdmittedEvent::Reply(_) => { + thread.push(reply_entry(&admitted)?); + } + RadrootsAdmittedEvent::Comment(_) => { + if let Some(entry) = comment_entry(&admitted) { + thread.push(entry); + } + } + _ => { + let locality = locality_tags(admitted.event().tags_as_vec()); + let evidence = locality_evidence(context.locality.as_deref(), &locality); + if let ProductEventClassification::Card(card) = + classify_admitted_event(&admitted, context.admit(evidence)) + { + cards.push(ProjectedCard { + card: *card, + locality, + }); + } + } + } + } + cards.sort_by_key(|projected| projected.card.card_id); + thread.sort_by(|left, right| left.event_id.cmp(&right.event_id)); + for entry in &mut thread { + entry.author_profile = profiles.get(&entry.author_pubkey).cloned(); + } + Ok(TodayProjectionState { + schema_version: TODAY_PROJECTION_DOCUMENT_SCHEMA_VERSION, + context_id: context.id.clone(), + context_generation: context.generation, + store_generation: *store_generation, + source_events, + content_generation: 0, + cards, + profiles, + thread, + overlays, + media_cache: Phase1MediaCacheIndex::default(), + }) +} + +fn profile_summary(admitted: &RadrootsAdmittedEvent) -> Result<ProfileSummary, TodayError> { + let RadrootsAdmittedEvent::Profile(profile) = admitted else { + return Err(TodayError::CorruptProjection); + }; + let metadata = profile.metadata(); + Ok(ProfileSummary { + author_pubkey: profile.event().author().to_hex(), + name: metadata.name().map(str::to_owned), + display_name: metadata.display_name().map(str::to_owned), + about: metadata.about().map(str::to_owned), + picture: metadata + .picture() + .map(|value| unverified_media(value.as_str())) + .transpose()?, + banner: metadata + .banner() + .map(|value| unverified_media(value.as_str())) + .transpose()?, + nip05: metadata.nip05().map(|value| value.as_str().to_owned()), + website: typed_profile_extra(metadata.raw_fields(), "website"), + lightning_address: typed_profile_extra(metadata.raw_fields(), "lud16"), + }) +} + +fn typed_profile_extra(fields: &BTreeMap<String, serde_json::Value>, key: &str) -> Option<String> { + fields + .get(key) + .and_then(serde_json::Value::as_str) + .filter(|value| { + !value.is_empty() && value.len() <= 2_048 && !value.chars().any(char::is_control) + }) + .map(str::to_owned) +} + +fn unverified_media(url: &str) -> Result<MediaReference, TodayError> { + MediaReference::new(Phase1StructuralMediaReference::new( + url, + blossom_digest(url), + None, + None, + None, + None, + None, + )?) + .map_err(TodayError::from) +} + +fn reply_entry(admitted: &RadrootsAdmittedEvent) -> Result<ThreadEntry, TodayError> { + let RadrootsAdmittedEvent::Reply(reply) = admitted else { + return Err(TodayError::CorruptProjection); + }; + Ok(ThreadEntry { + event_id: reply.event().id_hex(), + author_pubkey: reply.event().author().to_hex(), + content: reply.event().content().to_owned(), + authored_at: reply.event().created_at_u64(), + reference: ThreadReference { + profile: SupportingProfile::Reply, + root: reply.projection().root().event_id().to_hex(), + parent_event_id: reply.projection().parent().event_id().to_hex(), + }, + author_profile: None, + }) +} + +fn comment_entry(admitted: &RadrootsAdmittedEvent) -> Option<ThreadEntry> { + let RadrootsAdmittedEvent::Comment(comment) = admitted else { + return None; + }; + let tags = comment.event().tags_as_vec(); + let root = tag_value(&tags, &["E", "A"])?; + let parent = tag_value(&tags, &["e", "a"]).unwrap_or_else(|| root.clone()); + Some(ThreadEntry { + event_id: comment.event().id_hex(), + author_pubkey: comment.event().author().to_hex(), + content: comment.event().content().to_owned(), + authored_at: comment.event().created_at_u64(), + reference: ThreadReference { + profile: SupportingProfile::Comment, + root, + parent_event_id: parent, + }, + author_profile: None, + }) +} + +fn locality_tags(tags: Vec<Vec<String>>) -> Vec<LocalityTag> { + let mut locality = tags + .into_iter() + .filter_map(|tag| { + let kind = tag.first()?.as_str(); + if !matches!(kind, "g" | "location") { + return None; + } + let value = tag.get(1)?.trim().to_lowercase(); + (!value.is_empty()).then(|| LocalityTag { + kind: kind.to_owned(), + value, + }) + }) + .collect::<Vec<_>>(); + locality.sort(); + locality.dedup(); + locality +} + +fn locality_evidence(selected: Option<&str>, locality: &[LocalityTag]) -> LocalityEvidence { + let Some(selected) = selected else { + return LocalityEvidence::Missing; + }; + if locality.is_empty() { + return LocalityEvidence::Missing; + } + let selected = selected.trim().to_lowercase(); + if locality.iter().any(|tag| { + tag.value == selected + || (tag.kind == "g" + && (tag.value.starts_with(&selected) || selected.starts_with(&tag.value))) + }) { + LocalityEvidence::Match + } else { + LocalityEvidence::Nonmatch + } +} + +fn ranked_cards( + state: &TodayProjectionState, + context: &LocalNetwork, + as_of: u64, +) -> Result<Vec<TodayCard>, TodayError> { + let mut cards = Vec::new(); + for projected in &state.cards { + let evidence = locality_evidence(context.locality.as_deref(), &projected.locality); + let admission = match context.admit(evidence) { + super::LocalNetworkAdmission::Included(admission) => admission, + super::LocalNetworkAdmission::Excluded { .. } => continue, + }; + let mut card = projected.card.clone(); + card.context_rank = admission.rank; + card.inclusion_reason = admission.reason.to_owned(); + let time = match card.card_type { + TodayCardType::Update | TodayCardType::PhotoUpdate | TodayCardType::Ask => { + TimeRelevance::Published + } + TodayCardType::FoodAvailability => TimeRelevance::FoodAvailability { + active: card.lifecycle == CardLifecycleState::Active, + }, + TodayCardType::Event => TimeRelevance::Event { + start: card.event_start.unwrap_or(card.effective_at), + end: card.event_end, + }, + }; + let rank = TodayRank::derive(TodayRankInput { + card_type: card.card_type, + context_rank: card.context_rank, + as_of, + effective_at: card.effective_at, + time, + card_id: card.card_id, + }) + .map_err(|_| TodayError::CorruptProjection)?; + if card.card_type == TodayCardType::Event && rank.time_relevance_rank == 0 { + card.lifecycle = CardLifecycleState::Past; + } + card.rank = Some(rank); + let roots = [ + card.source_event_id.as_str(), + card.source_address.as_deref().unwrap_or(""), + ]; + let card_thread = state + .thread + .iter() + .filter(|entry| roots.contains(&entry.reference.root.as_str())) + .cloned() + .collect(); + cards.push(TodayCard { + author_profile: state.profiles.get(&card.author_pubkey).cloned(), + local_overlay: state.overlays.get(&card.card_id.to_hex()).cloned(), + card, + thread: card_thread, + }); + } + cards.sort_by_key(|card| card.card.rank.expect("assigned rank")); + Ok(cards) +} + +fn frozen_snapshot( + state: &TodayProjectionState, + context: &LocalNetwork, + as_of: u64, +) -> Result<FrozenTodaySnapshot, TodayError> { + Ok(FrozenTodaySnapshot { + schema_version: TODAY_SNAPSHOT_SCHEMA_VERSION, + context_id: context.id.clone(), + context_generation: context.generation, + as_of, + store_generation: state.store_generation, + projection_generation: state.content_generation, + items: ranked_cards(state, context, as_of)?, + }) +} + +fn page_from_snapshot( + snapshot: FrozenTodaySnapshot, + scope: CursorScope, + after: Option<TodayRank>, + limit: u16, +) -> Result<TodayPage, TodayError> { + validate_snapshot(&snapshot, &scope)?; + let start = if let Some(after) = after { + snapshot + .items + .iter() + .position(|card| card.card.rank == Some(after)) + .map(|index| index + 1) + .ok_or(TodayError::CursorPositionMissing)? + } else { + 0 + }; + let end = start + .saturating_add(usize::from(limit)) + .min(snapshot.items.len()); + let items = snapshot.items[start..end].to_vec(); + let next_cursor = if end < snapshot.items.len() { + items + .last() + .and_then(|card| card.card.rank) + .map(|rank| TodayCursor::encode(&scope, TodayCursorPosition { rank })) + .transpose()? + .map(|cursor| cursor.as_str().to_owned()) + } else { + None + }; + Ok(TodayPage { + as_of: snapshot.as_of, + items, + next_cursor, + }) +} + +fn validate_snapshot( + snapshot: &FrozenTodaySnapshot, + scope: &CursorScope, +) -> Result<(), TodayError> { + if snapshot.schema_version != TODAY_SNAPSHOT_SCHEMA_VERSION + || snapshot.context_id != scope.context_id + || snapshot.context_generation != scope.context_generation + || snapshot.as_of != scope.as_of + || snapshot.store_generation != scope.store_generation + || snapshot.projection_generation != scope.projection_generation + { + return Err(TodayError::CorruptProjection); + } + Ok(()) +} + +async fn load_state( + storage: &dyn radroots_storage::Storage, + context: &LocalNetwork, + generation: ProjectionGeneration, +) -> Result<Option<TodayProjectionState>, TodayError> { + let document = ProjectionStore::projection_document( + storage, + projection_id()?, + generation, + projection_document_key(context), + ) + .await?; + let Some(document) = document else { + return Ok(None); + }; + let (state, migrated) = decode_state_document(document.value())?; + if migrated { + store_state(storage, context, generation, &state).await?; + } + Ok(Some(state)) +} + +async fn store_state( + storage: &dyn radroots_storage::Storage, + context: &LocalNetwork, + generation: ProjectionGeneration, + state: &TodayProjectionState, +) -> Result<(), TodayError> { + ProjectionStore::put_projection_document( + storage, + projection_id()?, + generation, + ProjectionDocument::new(projection_document_key(context), encode(state)?)?, + ) + .await?; + Ok(()) +} + +async fn persist_snapshot( + storage: &dyn radroots_storage::Storage, + generation: ProjectionGeneration, + scope: &CursorScope, + snapshot: &FrozenTodaySnapshot, +) -> Result<(), TodayError> { + ProjectionStore::put_projection_snapshot( + storage, + ProjectionSnapshot::new( + projection_id()?, + snapshot_id(scope), + generation, + scope + .as_of + .checked_mul(1_000) + .ok_or(TodayError::InvalidRequest)?, + encode(snapshot)?, + )?, + ) + .await?; + Ok(()) +} + +async fn load_snapshot( + storage: &dyn radroots_storage::Storage, + projection_id: ProjectionId, + generation: ProjectionGeneration, + scope: &CursorScope, +) -> Result<Option<FrozenTodaySnapshot>, TodayError> { + ProjectionStore::projection_snapshot(storage, projection_id, snapshot_id(scope)) + .await? + .map(|snapshot| { + if snapshot.generation() != generation { + return Err(TodayError::CorruptProjection); + } + decode_snapshot(snapshot.value()) + }) + .transpose() +} + +#[cfg(test)] +fn decode_state(value: &[u8]) -> Result<TodayProjectionState, TodayError> { + decode_state_document(value).map(|(state, _)| state) +} + +fn decode_state_document(value: &[u8]) -> Result<(TodayProjectionState, bool), TodayError> { + if let Ok(state) = serde_json::from_slice::<TodayProjectionState>(value) + && state.schema_version == TODAY_PROJECTION_DOCUMENT_SCHEMA_VERSION + && state.content_generation != 0 + && content_generation(&state)? == state.content_generation + && validate_media_state(&state).is_ok() + { + return Ok((state, false)); + } + let state = migrate_legacy_state(value)?; + if state.schema_version != TODAY_PROJECTION_DOCUMENT_SCHEMA_VERSION + || state.content_generation == 0 + || content_generation(&state)? != state.content_generation + || validate_media_state(&state).is_err() + { + return Err(TodayError::CorruptProjection); + } + Ok((state, true)) +} + +fn validate_media_state(state: &TodayProjectionState) -> Result<(), Phase1InboundMediaError> { + state.media_cache.status()?; + for projected in &state.cards { + for media in &projected.card.media { + media.validate()?; + if let Phase1InboundMediaState::Verified(receipt) = media.retrieval() + && !state.media_cache.contains(receipt) + { + return Err(Phase1InboundMediaError::CorruptState); + } + } + } + for profile in state.profiles.values() { + for media in [&profile.picture, &profile.banner].into_iter().flatten() { + media.validate()?; + if let Phase1InboundMediaState::Verified(receipt) = media.retrieval() + && !state.media_cache.contains(receipt) + { + return Err(Phase1InboundMediaError::CorruptState); + } + } + } + if state + .thread + .iter() + .any(|entry| entry.author_profile.as_ref() != state.profiles.get(&entry.author_pubkey)) + { + return Err(Phase1InboundMediaError::CorruptState); + } + Ok(()) +} + +fn sanitize_snapshot_media(snapshot: &mut FrozenTodaySnapshot, cache: &Phase1MediaCacheIndex) { + for item in &mut snapshot.items { + for media in &mut item.card.media { + if let Phase1InboundMediaState::Verified(receipt) = media.retrieval() + && !cache.contains(receipt) + { + media.invalidate(); + } + } + if let Some(profile) = &mut item.author_profile { + for media in [&mut profile.picture, &mut profile.banner] + .into_iter() + .flatten() + { + if let Phase1InboundMediaState::Verified(receipt) = media.retrieval() + && !cache.contains(receipt) + { + media.invalidate(); + } + } + } + for entry in &mut item.thread { + if let Some(profile) = &mut entry.author_profile { + for media in [&mut profile.picture, &mut profile.banner] + .into_iter() + .flatten() + { + if let Phase1InboundMediaState::Verified(receipt) = media.retrieval() + && !cache.contains(receipt) + { + media.invalidate(); + } + } + } + } + } +} + +fn decode_snapshot(value: &[u8]) -> Result<FrozenTodaySnapshot, TodayError> { + let snapshot: FrozenTodaySnapshot = match serde_json::from_slice(value) { + Ok(snapshot) => snapshot, + Err(_) => { + let mut legacy: serde_json::Value = decode(value)?; + migrate_legacy_media_values(&mut legacy)?; + serde_json::from_value(legacy).map_err(|_| TodayError::CorruptProjection)? + } + }; + if snapshot.schema_version != TODAY_SNAPSHOT_SCHEMA_VERSION { + return Err(TodayError::CorruptProjection); + } + Ok(snapshot) +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +struct LegacyMediaReference { + url: String, + sha256: Option<String>, + media_type: Option<String>, + width: Option<u32>, + height: Option<u32>, + byte_size: Option<u64>, + alt: Option<String>, + verification: LegacyMediaVerificationState, +} + +#[derive(Deserialize)] +#[serde(rename_all = "PascalCase")] +enum LegacyMediaVerificationState { + Pending, + Verified, + Failed, + Unavailable, +} + +fn migrate_legacy_state(value: &[u8]) -> Result<TodayProjectionState, TodayError> { + verify_legacy_content_generation(value)?; + let mut legacy: serde_json::Value = decode(value)?; + migrate_legacy_media_values(&mut legacy)?; + let object = legacy + .as_object_mut() + .ok_or(TodayError::CorruptProjection)?; + if object.contains_key("mediaCache") { + return Err(TodayError::CorruptProjection); + } + object.insert( + "mediaCache".to_owned(), + serde_json::to_value(Phase1MediaCacheIndex::default()) + .map_err(|_| TodayError::Serialization)?, + ); + object.insert("contentGeneration".to_owned(), serde_json::json!(0)); + let mut state: TodayProjectionState = + serde_json::from_value(legacy).map_err(|_| TodayError::CorruptProjection)?; + state.content_generation = content_generation(&state)?; + Ok(state) +} + +fn verify_legacy_content_generation(value: &[u8]) -> Result<(), TodayError> { + let parsed: serde_json::Value = decode(value)?; + let expected = parsed + .get("contentGeneration") + .and_then(serde_json::Value::as_u64) + .filter(|value| *value != 0) + .ok_or(TodayError::CorruptProjection)?; + if parsed + .get("schemaVersion") + .and_then(serde_json::Value::as_u64) + != Some(u64::from(TODAY_PROJECTION_DOCUMENT_SCHEMA_VERSION)) + { + return Err(TodayError::CorruptProjection); + } + let marker = b"\"contentGeneration\":"; + let starts = value + .windows(marker.len()) + .enumerate() + .filter_map(|(index, bytes)| (bytes == marker).then_some(index)) + .collect::<Vec<_>>(); + let [start] = starts.as_slice() else { + return Err(TodayError::CorruptProjection); + }; + let number_start = start + marker.len(); + let number_end = value[number_start..] + .iter() + .position(|byte| !byte.is_ascii_digit()) + .map(|offset| number_start + offset) + .ok_or(TodayError::CorruptProjection)?; + if number_end == number_start { + return Err(TodayError::CorruptProjection); + } + let mut canonical = Vec::with_capacity(value.len()); + canonical.extend_from_slice(&value[..number_start]); + canonical.push(b'0'); + canonical.extend_from_slice(&value[number_end..]); + let digest = Sha256::digest([PROJECTION_CONTENT_DOMAIN, canonical.as_slice()].concat()); + let observed = u64::from_be_bytes(digest[..8].try_into().expect("digest prefix")).max(1); + (observed == expected) + .then_some(()) + .ok_or(TodayError::CorruptProjection) +} + +fn migrate_legacy_media_values(value: &mut serde_json::Value) -> Result<(), TodayError> { + match value { + serde_json::Value::Array(values) => { + for value in values { + migrate_legacy_media_values(value)?; + } + } + serde_json::Value::Object(object) + if object.contains_key("verification") && object.contains_key("url") => + { + let legacy: LegacyMediaReference = + serde_json::from_value(value.clone()).map_err(|_| TodayError::CorruptProjection)?; + let _legacy_verification = legacy.verification; + let migrated = MediaReference::legacy_unavailable( + legacy.url, + legacy.sha256, + legacy.media_type, + legacy.width, + legacy.height, + legacy.byte_size, + legacy.alt, + )?; + *value = serde_json::to_value(migrated).map_err(|_| TodayError::Serialization)?; + } + serde_json::Value::Object(object) => { + for value in object.values_mut() { + migrate_legacy_media_values(value)?; + } + } + _ => {} + } + Ok(()) +} + +fn content_generation(state: &TodayProjectionState) -> Result<u64, TodayError> { + let mut canonical = state.clone(); + canonical.content_generation = 0; + let digest = + Sha256::digest([PROJECTION_CONTENT_DOMAIN, encode(&canonical)?.as_slice()].concat()); + let generation = u64::from_be_bytes(digest[..8].try_into().expect("digest prefix")); + Ok(generation.max(1)) +} + +fn projection_generation() -> Result<ProjectionGeneration, TodayError> { + ProjectionGeneration::new(Sha256::digest(PROJECTION_GENERATION_DOMAIN).into()) + .map_err(TodayError::from) +} + +fn projection_id() -> Result<ProjectionId, TodayError> { + ProjectionId::parse(TODAY_PROJECTION_ID).map_err(TodayError::from) +} + +fn projection_document_key(context: &LocalNetwork) -> String { + let mut digest = Sha256::new(); + digest.update(PROJECTION_DOCUMENT_KEY_DOMAIN); + digest.update(context.id.as_bytes()); + digest.update(context.generation.to_be_bytes()); + format!("context.{}", hex::encode(digest.finalize())) +} + +fn snapshot_id(scope: &CursorScope) -> [u8; 32] { + let mut digest = Sha256::new(); + digest.update(SNAPSHOT_ID_DOMAIN); + digest.update(scope.context_id.as_bytes()); + digest.update(scope.context_generation.to_be_bytes()); + digest.update(scope.as_of.to_be_bytes()); + digest.update(scope.store_generation); + digest.update(scope.projection_generation.to_be_bytes()); + digest.finalize().into() +} + +fn tag_value(tags: &[Vec<String>], names: &[&str]) -> Option<String> { + tags.iter().find_map(|tag| { + names + .contains(&tag.first()?.as_str()) + .then(|| tag.get(1).cloned()) + .flatten() + }) +} + +fn blossom_digest(url: &str) -> Option<String> { + let path = url.split_once("://")?.1.split_once('/')?.1; + let candidate = path.split(['.', '/', '?', '#']).next()?; + (candidate.len() == 64 + && candidate + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))) + .then(|| candidate.to_owned()) +} + +fn valid_public_key(value: &str) -> bool { + value.len() == 64 + && value + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) +} + +fn encode(value: &impl Serialize) -> Result<Vec<u8>, TodayError> { + serde_json::to_vec(value).map_err(|_| TodayError::Serialization) +} + +fn decode<T: for<'de> Deserialize<'de>>(value: &[u8]) -> Result<T, TodayError> { + serde_json::from_slice(value).map_err(|_| TodayError::CorruptProjection) +} + +#[cfg(test)] +mod tests { + use super::*; + #[cfg(feature = "mobile-social")] + use std::sync::{Arc, Mutex, RwLock, atomic::AtomicBool}; + #[cfg(feature = "mobile-social")] + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + #[cfg(feature = "mobile-social")] + use tokio::net::TcpListener; + + use nostr::secp256k1::Message; + use nostr::{Keys, SECP256K1}; + use radroots_blossom::{ + BlobUrl, MediaType, Sha256 as BlossomSha256, descriptor::ByteCommitment, + }; + use radroots_event::{ + SignedEvent, + admission::{AdmissionPolicy, RawEvent, VisibilityPolicy}, + wire::{Nip01EventWire, compute_canonical_nip01_event_id}, + }; + use radroots_event_codec::verify::Nip01SignatureVerifier; + #[cfg(feature = "mobile-social")] + use radroots_transport::{ + Error as TransportError, EventSource, FetchPage, FetchRequest, SourceStatus, + outcome::{FetchTargetOutcome, FetchTargetState}, + source::NextPage, + }; + use radroots_transport::{ + Target, TransportId, + source::{EventProvenance, ObservedEvent}, + }; + + const SECRET: &str = "10c5304d6c9ae3a1a16f7860f1cc8f5e3a76225a2663b3a989a0d775919b7df5"; + + struct Allow; + + #[cfg(feature = "mobile-social")] + struct TodaySource { + event: SignedEvent, + requested_kinds: Mutex<Vec<Vec<u32>>>, + } + + #[cfg(feature = "mobile-social")] + impl EventSource for TodaySource { + fn status( + &self, + ) -> radroots_transport::BoxFuture<'_, Result<SourceStatus, TransportError>> { + Box::pin(async { unreachable!("Today sync does not inspect source status") }) + } + + fn fetch( + &self, + request: FetchRequest, + ) -> radroots_transport::BoxFuture<'_, Result<FetchPage, TransportError>> { + Box::pin(async move { + self.requested_kinds + .lock() + .expect("requested kinds") + .push(request.selector().kinds().to_vec()); + let target = request.target_set().targets()[0].clone(); + let provenance = EventProvenance::new( + TransportId::NOSTR, + target.fingerprint().clone(), + 2_000_000_100_000, + )?; + FetchPage::for_request( + &request, + vec![ObservedEvent::new(self.event.clone(), provenance)], + vec![FetchTargetOutcome::new( + target.fingerprint().clone(), + FetchTargetState::Complete, + )], + NextPage::Complete, + ) + }) + } + } + + impl AdmissionPolicy for Allow { + type Error = core::convert::Infallible; + + fn policy_id(&self) -> &'static str { + "test.today.admission.v1" + } + + fn admit( + &self, + _event: &radroots_event::admission::ContractValidatedEvent, + ) -> Result<(), Self::Error> { + Ok(()) + } + } + + impl VisibilityPolicy for Allow { + type Error = core::convert::Infallible; + + fn policy_id(&self) -> &'static str { + "test.today.visibility.v1" + } + + fn make_visible( + &self, + _event: &radroots_event::admission::AdmittedEvent, + ) -> Result<(), Self::Error> { + Ok(()) + } + } + + fn context(locality: Option<&str>, generation: u64) -> LocalNetwork { + LocalNetwork::new( + "victoria".into(), + "Victoria".into(), + vec!["wss://relay.example".into()], + locality.map(str::to_owned), + Vec::new(), + generation, + ) + .expect("context") + } + + fn keys() -> Keys { + Keys::parse(SECRET).expect("keys") + } + + fn signed(kind: u32, tags: Vec<Vec<&str>>, content: &str, created_at: u64) -> SignedEvent { + signed_owned( + kind, + tags.into_iter() + .map(|tag| tag.into_iter().map(str::to_owned).collect()) + .collect(), + content, + created_at, + ) + } + + fn signed_owned( + kind: u32, + tags: Vec<Vec<String>>, + content: &str, + created_at: u64, + ) -> SignedEvent { + let keys = keys(); + let author = keys.public_key().to_string(); + let id = compute_canonical_nip01_event_id(&author, created_at, kind, &tags, content) + .expect("id"); + let message = Message::from_digest(*id.as_bytes()); + let signature = SECP256K1.sign_schnorr_no_aux_rand( + &message, + &nostr::secp256k1::Keypair::from_secret_key(SECP256K1, keys.secret_key()), + ); + let wire = Nip01EventWire { + id: id.to_hex(), + pubkey: author, + created_at, + kind, + tags, + content: content.to_owned(), + sig: signature.to_string(), + extra: Default::default(), + }; + let raw = serde_json::json!({ + "id": &wire.id, + "pubkey": &wire.pubkey, + "created_at": wire.created_at, + "kind": wire.kind, + "tags": &wire.tags, + "content": &wire.content, + "sig": &wire.sig, + }) + .to_string(); + SignedEvent::from_wire_verified_id(wire, raw).expect("signed event") + } + + fn visible_admission(event: SignedEvent, observed_at: u64) -> EventAdmission { + let selected = admit_verified_event( + verify_nip01_event(event.envelope().clone()).expect("codec verification"), + ) + .expect("codec admission") + .contract_id(); + let verified = RawEvent::new(event.envelope().clone()) + .verify_id() + .expect("id") + .verify_signature(&Nip01SignatureVerifier) + .expect("signature"); + let visible = verified + .validate_contract_for_admission(selected) + .expect("selected contract") + .admit_with(&Allow) + .expect("admission") + .make_visible_with(&Allow) + .expect("visibility"); + let target = Target::new(TransportId::NOSTR, "wss://relay.example").expect("target"); + let provenance = EventProvenance::new( + TransportId::NOSTR, + target.fingerprint().clone(), + observed_at, + ) + .expect("provenance"); + EventAdmission::visible(ObservedEvent::new(event, provenance), visible) + .expect("visible admission") + } + + fn raw_admission(event: SignedEvent, observed_at: u64) -> EventAdmission { + let target = Target::new(TransportId::NOSTR, "wss://relay.example").expect("target"); + let provenance = EventProvenance::new( + TransportId::NOSTR, + target.fingerprint().clone(), + observed_at, + ) + .expect("provenance"); + EventAdmission::raw(ObservedEvent::new(event, provenance)) + } + + fn admitted(event: &SignedEvent) -> RadrootsAdmittedEvent { + admit_verified_event( + verify_nip01_event(event.envelope().clone()).expect("codec verification"), + ) + .expect("codec admission") + } + + async fn ingest( + runtime: &RadrootsRuntime, + context: &LocalNetwork, + event: SignedEvent, + at: u64, + ) -> TodayIngestReceipt { + runtime + .phase1_ingest_visible(visible_admission(event, at * 1_000), context, at) + .await + .expect("ingest") + } + + #[allow(clippy::too_many_arguments)] + async fn verify_inbound_media( + runtime: &RadrootsRuntime, + context: &LocalNetwork, + source_url: &str, + bytes: &[u8], + media_type: &str, + width: u32, + height: u32, + operation_id: [u8; 16], + ) { + let storage = runtime.client.storage().expect("storage"); + let state = load_state(storage, context, projection_generation().unwrap()) + .await + .unwrap() + .expect("projection"); + let reference = state + .cards + .iter() + .flat_map(|value| value.card.media.iter()) + .chain( + state + .profiles + .values() + .flat_map(|profile| [&profile.picture, &profile.banner].into_iter().flatten()), + ) + .find(|media| media.structural().source_url() == source_url) + .expect("structural media") + .structural() + .clone(); + let configuration = Phase1MediaConfigurationFingerprint::new([9; 32]).unwrap(); + runtime + .phase1_begin_media_retrieval( + context, + *reference.fingerprint(), + Phase1InboundMediaPending::new(operation_id, configuration, 10).unwrap(), + ) + .await + .expect("begin retrieval"); + let commitment = ByteCommitment::from_bytes(bytes, MediaType::parse(media_type).unwrap()); + let receipt = Phase1VerifiedMediaReceipt::from_commitment( + &reference, + BlobUrl::parse(source_url).unwrap(), + &commitment, + width, + height, + configuration, + 11, + ) + .expect("byte receipt"); + runtime + .phase1_commit_media_receipt( + context, + *reference.fingerprint(), + operation_id, + receipt, + Phase1MediaCachePolicy::new(1_024, 8).unwrap(), + 12, + ) + .await + .expect("commit receipt"); + } + + fn downgrade_media_to_legacy(value: &mut serde_json::Value) { + match value { + serde_json::Value::Array(values) => { + for value in values { + downgrade_media_to_legacy(value); + } + } + serde_json::Value::Object(object) + if object.contains_key("structural") && object.contains_key("retrieval") => + { + let structural = object + .get("structural") + .and_then(serde_json::Value::as_object) + .expect("structural object"); + *value = serde_json::json!({ + "url": structural.get("sourceUrl").cloned().unwrap(), + "sha256": structural.get("expectedSha256").cloned().unwrap(), + "mediaType": structural.get("expectedMediaType").cloned().unwrap(), + "width": structural.get("expectedWidth").cloned().unwrap(), + "height": structural.get("expectedHeight").cloned().unwrap(), + "byteSize": structural.get("expectedByteSize").cloned().unwrap(), + "alt": structural.get("alt").cloned().unwrap(), + "verification": "Verified", + }); + } + serde_json::Value::Object(object) => { + for value in object.values_mut() { + downgrade_media_to_legacy(value); + } + } + _ => {} + } + } + + fn legacy_state_bytes(state: &TodayProjectionState) -> Vec<u8> { + let mut value = serde_json::to_value(state).expect("state value"); + let object = value.as_object_mut().expect("state object"); + object.remove("mediaCache").expect("new cache field"); + object.insert("contentGeneration".to_owned(), serde_json::json!(0)); + downgrade_media_to_legacy(&mut value); + let canonical = serde_json::to_vec(&value).expect("legacy canonical"); + let digest = + sha2::Sha256::digest([PROJECTION_CONTENT_DOMAIN, canonical.as_slice()].concat()); + let generation = u64::from_be_bytes(digest[..8].try_into().expect("digest prefix")).max(1); + value["contentGeneration"] = serde_json::json!(generation); + serde_json::to_vec(&value).expect("legacy state") + } + + #[cfg(feature = "mobile-social")] + 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 + } + + #[cfg(feature = "mobile-social")] + async fn serve_one_blob(listener: TcpListener, bytes: Vec<u8>) { + let (mut stream, _) = listener.accept().await.expect("accept"); + let mut request = Vec::new(); + while !request.windows(4).any(|value| value == b"\r\n\r\n") { + let mut chunk = [0_u8; 1024]; + let read = stream.read(&mut chunk).await.expect("request read"); + assert_ne!(read, 0); + request.extend_from_slice(&chunk[..read]); + assert!(request.len() <= 64 * 1024); + } + let headers = String::from_utf8_lossy(&request); + assert!(headers.starts_with("GET /")); + assert!( + headers + .to_ascii_lowercase() + .contains("accept-encoding: identity") + ); + assert!(!headers.to_ascii_lowercase().contains("authorization:")); + let response = format!( + "HTTP/1.1 200 OK\r\nContent-Type: image/png\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", + bytes.len() + ); + stream.write_all(response.as_bytes()).await.expect("head"); + stream.write_all(&bytes).await.expect("body"); + stream.shutdown().await.expect("close"); + } + + #[cfg(feature = "mobile-social")] + #[tokio::test] + async fn relay_sync_fetches_the_exact_today_selector_and_projects_real_events() { + let source = Arc::new(TodaySource { + event: signed(1, Vec::new(), "Fresh from the field", 2_000_000_000), + requested_kinds: Mutex::new(Vec::new()), + }); + let client = radroots_sdk::ClientBuilder::memory_default() + .source(source.clone()) + .host_sync(radroots_sdk::sync::HostPolicy::standard()) + .build() + .expect("client"); + let runtime = RadrootsRuntime { + client, + started_unix_ms: 1, + shutting_down: AtomicBool::new(false), + platform_app: RwLock::new(None), + store_public_key: None, + settings_lock: tokio::sync::Mutex::new(()), + identity_session: tokio::sync::RwLock::new(None), + inbound_media_directory: None, + inbound_media_lock: tokio::sync::Mutex::new(()), + }; + let context = context(None, 1); + + let receipt = runtime + .phase1_sync_today(&context, 2_000_000_200, TodayProjectionUpdate::Incremental) + .await + .expect("Today sync"); + assert_eq!(receipt.relay_state, TodayRelaySyncState::Complete); + assert_eq!(receipt.pages_fetched, 1); + assert_eq!(receipt.events_observed, 1); + assert_eq!(receipt.events_admitted, 1); + assert_eq!(receipt.events_rejected, 0); + assert_eq!(receipt.projection.visible_cards, 1); + assert_eq!( + source + .requested_kinds + .lock() + .expect("requested kinds") + .as_slice(), + &[TODAY_SYNC_KINDS.to_vec()] + ); + let page = runtime + .phase1_today_page(&context, TodayPageRequest::first(20, 2_000_000_200)) + .await + .expect("Today page"); + assert_eq!(page.items.len(), 1); + assert_eq!(page.items[0].card.card_type, TodayCardType::Update); + assert_eq!(page.items[0].card.content, "Fresh from the field"); + } + + #[tokio::test] + async fn equal_timestamp_pages_are_complete_and_remain_frozen_across_ingest() { + let runtime = RadrootsRuntime::test_memory().expect("runtime"); + let context = context(None, 1); + for content in ["alpha", "bravo", "charlie"] { + ingest( + &runtime, + &context, + signed(1, Vec::new(), content, 2_000_000_000), + 2_000_000_100, + ) + .await; + } + let first = runtime + .phase1_today_page(&context, TodayPageRequest::first(1, 2_000_000_200)) + .await + .expect("first page"); + assert_eq!(first.items.len(), 1); + let frozen_cursor = first.next_cursor.clone().expect("cursor"); + + ingest( + &runtime, + &context, + signed(1, Vec::new(), "delta", 2_000_000_001), + 2_000_000_101, + ) + .await; + + let mut ids = first + .items + .iter() + .map(|card| card.card.card_id.to_hex()) + .collect::<Vec<_>>(); + let mut cursor = Some(frozen_cursor); + while let Some(value) = cursor { + let page = runtime + .phase1_today_page(&context, TodayPageRequest::after(1, value)) + .await + .expect("continued frozen page"); + ids.extend(page.items.iter().map(|card| card.card.card_id.to_hex())); + cursor = page.next_cursor; + } + ids.sort(); + ids.dedup(); + assert_eq!(ids.len(), 3, "frozen snapshot has no loss or duplicates"); + + let current = runtime + .phase1_today_page(&context, TodayPageRequest::first(100, 2_000_000_201)) + .await + .expect("current page"); + assert_eq!(current.items.len(), 4); + } + + #[tokio::test] + async fn profile_thread_media_search_me_context_and_rebuild_share_one_projection() { + let runtime = RadrootsRuntime::test_memory().expect("runtime"); + let context = context(Some("victoria"), 7); + let author = keys().public_key().to_string(); + let profile_bytes = b"profile picture"; + let profile_digest = BlossomSha256::digest(profile_bytes).to_hex(); + let profile_url = format!("https://blob.example/{profile_digest}.jpg"); + let profile = signed( + 0, + Vec::new(), + &format!( + r#"{{"name":"moss","display_name":"Moss Farm","about":"Local roots","picture":"{profile_url}","website":"https://moss.example","lud16":"moss@example.com"}}"# + ), + 2_000_000_000, + ); + ingest(&runtime, &context, profile, 2_000_000_100).await; + + let photo_bytes = b"photo"; + let digest = BlossomSha256::digest(photo_bytes).to_hex(); + let photo_url = format!("https://blob.example/{digest}.jpg"); + let photo_content = format!("Fresh field photo {photo_url}"); + let photo = signed_owned( + 1, + vec![ + vec!["location".into(), "Victoria".into()], + vec![ + "imeta".into(), + format!("url {photo_url}"), + format!("x {digest}"), + "m image/jpeg".into(), + "dim 120x80".into(), + format!("size {}", photo_bytes.len()), + "alt Fresh field photo".into(), + ], + ], + &photo_content, + 2_000_000_001, + ); + let root_id = photo.id().to_hex(); + ingest(&runtime, &context, photo, 2_000_000_101).await; + + let nonmatch = signed( + 1, + vec![vec!["location", "Elsewhere"]], + "far away", + 2_000_000_002, + ); + ingest(&runtime, &context, nonmatch, 2_000_000_102).await; + + let reply = signed_owned( + 1, + vec![ + vec![ + "e".into(), + root_id.clone(), + "wss://relay.example".into(), + "root".into(), + ], + vec!["p".into(), author.clone()], + ], + "Looks good", + 2_000_000_003, + ); + ingest(&runtime, &context, reply, 2_000_000_103).await; + + let food = signed( + 30_402, + vec![ + vec!["d", "today-carrots"], + vec!["title", "Today carrots"], + vec!["summary", "Fresh"], + vec!["published_at", "2000000004"], + vec!["location", "Victoria"], + vec!["price", "3", "CAD"], + vec!["radroots:price_unit", "lb"], + vec!["status", "active"], + ], + "Fresh carrots", + 2_000_000_004, + ); + let food_id = food.id().to_hex(); + ingest(&runtime, &context, food, 2_000_000_104).await; + + let comment = signed_owned( + 1_111, + vec![ + vec![ + "E".into(), + food_id.clone(), + "wss://relay.example".into(), + author.clone(), + ], + vec!["K".into(), "30402".into()], + vec!["P".into(), author.clone(), "wss://relay.example".into()], + vec![ + "e".into(), + food_id, + "wss://relay.example".into(), + author.clone(), + ], + vec!["k".into(), "30402".into()], + vec!["p".into(), author.clone(), "wss://relay.example".into()], + ], + "A NIP-22 comment", + 2_000_000_005, + ); + ingest(&runtime, &context, comment, 2_000_000_105).await; + + verify_inbound_media( + &runtime, + &context, + &photo_url, + photo_bytes, + "image/jpeg", + 120, + 80, + [1; 16], + ) + .await; + verify_inbound_media( + &runtime, + &context, + &profile_url, + profile_bytes, + "image/jpeg", + 24, + 24, + [2; 16], + ) + .await; + let live = runtime + .phase1_today_page(&context, TodayPageRequest::first(100, 2_000_000_200)) + .await + .expect("page"); + assert_eq!(live.items.len(), 2, "known locality nonmatch is excluded"); + let card = live + .items + .iter() + .find(|card| card.card.card_type == TodayCardType::PhotoUpdate) + .unwrap_or_else(|| panic!("photo card missing from {:#?}", live.items)); + assert_eq!(card.card.card_type, TodayCardType::PhotoUpdate); + assert!(matches!( + card.card.media[0].retrieval(), + Phase1InboundMediaState::Verified(_) + )); + assert_eq!(card.thread.len(), 1); + assert_eq!( + live.items + .iter() + .find(|card| card.card.card_type == TodayCardType::FoodAvailability) + .expect("food card") + .thread + .len(), + 1 + ); + let profile = card.author_profile.as_ref().expect("profile enrichment"); + assert_eq!(profile.website.as_deref(), Some("https://moss.example")); + assert_eq!( + profile.lightning_address.as_deref(), + Some("moss@example.com") + ); + assert!(matches!( + profile + .picture + .as_ref() + .expect("profile picture") + .retrieval(), + Phase1InboundMediaState::Verified(_) + )); + + runtime + .phase1_set_local_author_overlay( + &context, + card.card.card_id, + Some(LocalAuthorOverlay { + operation_id: "publish-photo-1".into(), + state: "delivered".into(), + }), + ) + .await + .expect("active author overlay"); + assert!(matches!( + runtime + .phase1_set_local_author_overlay( + &context, + CardId::parse(&"f".repeat(64)).expect("unknown card id"), + None, + ) + .await, + Err(TodayError::InvalidRequest) + )); + + let search = runtime + .phase1_search(&context, "moss farm", 10, 2_000_000_200) + .await + .expect("search"); + assert!(search.iter().any(|result| result.profile.is_some())); + let card_search = runtime + .phase1_search(&context, "fresh field photo", 10, 2_000_000_200) + .await + .expect("card search"); + assert!(card_search.iter().any(|result| result.card.is_some())); + let profile_limited = runtime + .phase1_search(&context, "moss@example.com", 1, 2_000_000_200) + .await + .expect("profile-limited search"); + assert_eq!(profile_limited.len(), 1); + assert!(profile_limited[0].profile.is_some()); + let me = runtime + .phase1_me(&context, &author, 2_000_000_200) + .await + .expect("me"); + assert_eq!(me.cards.len(), 2); + assert_eq!( + me.profile.expect("me profile").name.as_deref(), + Some("moss") + ); + + let rebuilt = runtime + .phase1_refresh_today(&context, 2_000_000_201, TodayProjectionUpdate::Rebuild) + .await + .expect("rebuild"); + assert!(!rebuilt.changed, "rebuild is byte-equivalent to live state"); + let after = runtime + .phase1_today_page(&context, TodayPageRequest::first(100, 2_000_000_202)) + .await + .expect("rebuilt page"); + let rebuilt_photo = after + .items + .iter() + .find(|card| card.card.card_type == TodayCardType::PhotoUpdate) + .expect("rebuilt photo"); + assert!(matches!( + rebuilt_photo.card.media[0].retrieval(), + Phase1InboundMediaState::Verified(_) + )); + assert!(matches!( + rebuilt_photo + .author_profile + .as_ref() + .and_then(|profile| profile.picture.as_ref()) + .expect("rebuilt profile picture") + .retrieval(), + Phase1InboundMediaState::Verified(_) + )); + assert_eq!( + rebuilt_photo + .local_overlay + .as_ref() + .map(|overlay| overlay.state.as_str()), + Some("delivered") + ); + assert_eq!(rebuilt_photo.thread.len(), 1); + + let photo_reference = rebuilt_photo.card.media[0].structural().clone(); + let photo_receipt = match rebuilt_photo.card.media[0].retrieval() { + Phase1InboundMediaState::Verified(receipt) => (**receipt).clone(), + state => panic!("unexpected photo state: {state:?}"), + }; + let profile_artifact = match rebuilt_photo + .author_profile + .as_ref() + .and_then(|profile| profile.picture.as_ref()) + .expect("profile picture before eviction") + .retrieval() + { + Phase1InboundMediaState::Verified(receipt) => receipt.artifact_id(), + state => panic!("unexpected profile state: {state:?}"), + }; + assert!(matches!( + runtime + .phase1_fail_media_retrieval( + &context, + *photo_reference.fingerprint(), + Phase1InboundMediaFailure::new([3; 16], "network", true, 29).unwrap(), + ) + .await, + Err(TodayError::InboundMedia( + Phase1InboundMediaError::OperationMismatch + )) + )); + assert!(matches!( + runtime + .phase1_begin_media_retrieval( + &context, + *photo_reference.fingerprint(), + Phase1InboundMediaPending::new( + [3; 16], + Phase1MediaConfigurationFingerprint::new([8; 32]).unwrap(), + 29, + ) + .unwrap(), + ) + .await, + Err(TodayError::InboundMedia( + Phase1InboundMediaError::ConfigurationMismatch + )) + )); + assert!(matches!( + runtime + .phase1_commit_media_receipt( + &context, + [0; 32], + [3; 16], + photo_receipt.clone(), + Phase1MediaCachePolicy::new(1_024, 8).unwrap(), + 29, + ) + .await, + Err(TodayError::InvalidRequest) + )); + runtime + .phase1_begin_media_retrieval( + &context, + *photo_reference.fingerprint(), + Phase1InboundMediaPending::new( + [3; 16], + Phase1MediaConfigurationFingerprint::new([9; 32]).unwrap(), + 30, + ) + .unwrap(), + ) + .await + .expect("repeat retrieval"); + let evicted = runtime + .phase1_commit_media_receipt( + &context, + *photo_reference.fingerprint(), + [3; 16], + photo_receipt, + Phase1MediaCachePolicy::new(1_024, 1).unwrap(), + 31, + ) + .await + .expect("quota commit"); + assert_eq!(evicted, vec![profile_artifact]); + let quota_page = runtime + .phase1_today_page(&context, TodayPageRequest::first(100, 2_000_000_203)) + .await + .expect("quota page"); + let quota_photo = quota_page + .items + .iter() + .find(|card| card.card.card_type == TodayCardType::PhotoUpdate) + .expect("quota photo"); + assert!(matches!( + quota_photo.card.media[0].retrieval(), + Phase1InboundMediaState::Verified(_) + )); + assert!(matches!( + quota_photo + .author_profile + .as_ref() + .and_then(|profile| profile.picture.as_ref()) + .expect("evicted profile picture") + .retrieval(), + Phase1InboundMediaState::Unavailable + )); + + let removed = runtime + .phase1_invalidate_media_configuration( + &context, + Phase1MediaConfigurationFingerprint::new([8; 32]).unwrap(), + ) + .await + .expect("configuration invalidation"); + assert_eq!(removed.len(), 1); + assert!( + runtime + .phase1_invalidate_media_configuration( + &context, + Phase1MediaConfigurationFingerprint::new([8; 32]).unwrap(), + ) + .await + .expect("idempotent configuration invalidation") + .is_empty() + ); + assert!( + !runtime + .phase1_invalidate_media_artifact( + &context, + Phase1MediaArtifactId::parse(&"f".repeat(64)).unwrap(), + ) + .await + .expect("missing artifact invalidation") + ); + let invalidated = runtime + .phase1_today_page(&context, TodayPageRequest::first(100, 2_000_000_203)) + .await + .expect("page after configuration change"); + let invalidated_photo = invalidated + .items + .iter() + .find(|card| card.card.card_type == TodayCardType::PhotoUpdate) + .expect("invalidated photo"); + assert!(matches!( + invalidated_photo.card.media[0].retrieval(), + Phase1InboundMediaState::Unavailable + )); + assert!(matches!( + invalidated_photo + .author_profile + .as_ref() + .and_then(|profile| profile.picture.as_ref()) + .expect("invalidated profile picture") + .retrieval(), + Phase1InboundMediaState::Unavailable + )); + } + + #[tokio::test] + async fn legacy_enum_only_media_migrates_to_unavailable_and_repersists() { + let runtime = RadrootsRuntime::test_memory().expect("runtime"); + let context = context(None, 1); + let bytes = b"legacy profile"; + let hash = BlossomSha256::digest(bytes).to_hex(); + let url = format!("https://blob.example/{hash}.jpg"); + ingest( + &runtime, + &context, + signed( + 0, + Vec::new(), + &format!(r#"{{"name":"legacy","picture":"{url}"}}"#), + 2_000_000_000, + ), + 2_000_000_100, + ) + .await; + let storage = runtime.client.storage().expect("storage"); + let generation = projection_generation().unwrap(); + let state = load_state(storage, &context, generation) + .await + .unwrap() + .expect("state"); + let legacy = legacy_state_bytes(&state); + ProjectionStore::put_projection_document( + storage, + projection_id().unwrap(), + generation, + ProjectionDocument::new(projection_document_key(&context), legacy.clone()).unwrap(), + ) + .await + .unwrap(); + + let migrated = load_state(storage, &context, generation) + .await + .unwrap() + .expect("migrated state"); + let picture = migrated + .profiles + .values() + .next() + .and_then(|profile| profile.picture.as_ref()) + .expect("picture"); + assert!(matches!( + picture.retrieval(), + Phase1InboundMediaState::Unavailable + )); + assert_eq!(migrated.media_cache.status().unwrap().artifacts, 0); + let stored = ProjectionStore::projection_document( + storage, + projection_id().unwrap(), + generation, + projection_document_key(&context), + ) + .await + .unwrap() + .expect("repersisted state"); + let text = std::str::from_utf8(stored.value()).unwrap(); + assert!(!text.contains("\"verification\"")); + assert!(text.contains("\"mediaCache\"")); + + let mut tampered: serde_json::Value = serde_json::from_slice(&legacy).unwrap(); + tampered["profiles"] + .as_object_mut() + .unwrap() + .values_mut() + .next() + .unwrap()["name"] = serde_json::json!("tampered"); + assert!(matches!( + decode_state(&serde_json::to_vec(&tampered).unwrap()), + Err(TodayError::CorruptProjection) + )); + } + + #[tokio::test] + async fn replacement_and_deletion_change_only_current_today_truth() { + let runtime = RadrootsRuntime::test_memory().expect("runtime"); + let context = context(None, 1); + let active = signed( + 30_402, + vec![ + vec!["d", "carrots"], + vec!["title", "Carrots"], + vec!["summary", "Fresh"], + vec!["published_at", "2000000000"], + vec!["location", "Victoria"], + vec!["price", "3", "CAD"], + vec!["radroots:price_unit", "lb"], + vec!["status", "active"], + ], + "available", + 2_000_000_000, + ); + ingest(&runtime, &context, active, 2_000_000_100).await; + let sold = signed( + 30_402, + vec![ + vec!["d", "carrots"], + vec!["title", "Carrots"], + vec!["summary", "Gone"], + vec!["published_at", "2000000001"], + vec!["location", "Victoria"], + vec!["price", "3", "CAD"], + vec!["radroots:price_unit", "lb"], + vec!["status", "sold"], + ], + "sold out", + 2_000_000_001, + ); + let sold_id = sold.id().to_hex(); + ingest(&runtime, &context, sold, 2_000_000_101).await; + let current = runtime + .phase1_today_page(&context, TodayPageRequest::first(10, 2_000_000_200)) + .await + .expect("current"); + assert_eq!(current.items.len(), 1); + assert_eq!(current.items[0].card.source_event_id, sold_id); + assert_eq!(current.items[0].card.lifecycle, CardLifecycleState::Sold); + + let deletion = signed_owned(5, vec![vec!["e".into(), sold_id]], "", 2_000_000_002); + ingest(&runtime, &context, deletion, 2_000_000_102).await; + let deleted = runtime + .phase1_today_page(&context, TodayPageRequest::first(10, 2_000_000_201)) + .await + .expect("deleted"); + assert!(deleted.items.is_empty()); + } + + #[tokio::test] + async fn sqlite_reopen_preserves_materialized_state_and_frozen_cursor_pages() { + use crate::runtime::{ + builder::RuntimeBuilder, + store::{MobileUserStoreConfig, ProtectedDataAvailability}, + }; + + let root = tempfile::tempdir().expect("root"); + let store = MobileUserStoreConfig::from_encoded( + root.path(), + &keys().public_key().to_string(), + "3131313131313131313131313131313131313131313131313131313131313131", + 2_000_000_000_000, + ProtectedDataAvailability::Available, + ) + .expect("store"); + std::fs::create_dir_all(store.owner_directory()).expect("owner directory"); + let context = context(None, 1); + let runtime = RuntimeBuilder::new(store.clone()) + .build() + .await + .expect("runtime"); + ingest( + &runtime, + &context, + signed(1, Vec::new(), "persisted alpha", 2_000_000_000), + 2_000_000_100, + ) + .await; + ingest( + &runtime, + &context, + signed(1, Vec::new(), "persisted bravo", 2_000_000_000), + 2_000_000_101, + ) + .await; + let first = runtime + .phase1_today_page(&context, TodayPageRequest::first(1, 2_000_000_200)) + .await + .expect("first page"); + let cursor = first.next_cursor.expect("frozen cursor"); + runtime.shutdown().await.expect("shutdown"); + + let reopened = RuntimeBuilder::new(store).build().await.expect("reopen"); + let second = reopened + .phase1_today_page(&context, TodayPageRequest::after(1, cursor)) + .await + .expect("continued page after reopen"); + assert_eq!(second.items.len(), 1); + assert_ne!(first.items[0].card.card_id, second.items[0].card.card_id); + assert!(second.next_cursor.is_none()); + let search = reopened + .phase1_search(&context, "persisted", 10, 2_000_000_200) + .await + .expect("search after reopen"); + assert_eq!(search.len(), 2); + reopened.shutdown().await.expect("shutdown reopened"); + } + + #[tokio::test] + async fn sqlite_reopen_preserves_receipts_and_missing_artifacts_fail_closed() { + use crate::runtime::{ + builder::RuntimeBuilder, + store::{MobileUserStoreConfig, ProtectedDataAvailability}, + }; + + let root = tempfile::tempdir().expect("root"); + let public_key = keys().public_key().to_string(); + let store = MobileUserStoreConfig::from_encoded( + root.path(), + &public_key, + "4141414141414141414141414141414141414141414141414141414141414141", + 2_000_000_000_000, + ProtectedDataAvailability::Available, + ) + .expect("store"); + std::fs::create_dir_all(store.owner_directory()).expect("owner directory"); + let context = context(None, 1); + let runtime = RuntimeBuilder::new(store.clone()) + .build() + .await + .expect("runtime"); + let bytes = b"sqlite profile"; + let hash = BlossomSha256::digest(bytes).to_hex(); + let url = format!("https://blob.example/{hash}.jpg"); + ingest( + &runtime, + &context, + signed( + 0, + Vec::new(), + &format!(r#"{{"name":"sqlite","picture":"{url}"}}"#), + 2_000_000_000, + ), + 2_000_000_100, + ) + .await; + verify_inbound_media( + &runtime, + &context, + &url, + bytes, + "image/jpeg", + 20, + 20, + [6; 16], + ) + .await; + assert_eq!( + runtime + .phase1_media_cache_status(&context) + .await + .unwrap() + .artifacts, + 1 + ); + runtime.shutdown().await.expect("shutdown"); + + let reopened = RuntimeBuilder::new(store).build().await.expect("reopen"); + let profile = reopened + .phase1_me(&context, &public_key, 2_000_000_200) + .await + .unwrap() + .profile + .expect("profile"); + let picture = profile.picture.expect("picture"); + let artifact_id = match picture.retrieval() { + Phase1InboundMediaState::Verified(receipt) => receipt.artifact_id(), + state => panic!("unexpected state: {state:?}"), + }; + assert!( + reopened + .phase1_invalidate_media_artifact(&context, artifact_id) + .await + .unwrap() + ); + assert_eq!( + reopened + .phase1_media_cache_status(&context) + .await + .unwrap() + .artifacts, + 0 + ); + let profile = reopened + .phase1_me(&context, &public_key, 2_000_000_201) + .await + .unwrap() + .profile + .expect("profile after invalidation"); + assert!(matches!( + profile.picture.unwrap().retrieval(), + Phase1InboundMediaState::Unavailable + )); + reopened.shutdown().await.expect("shutdown reopened"); + } + + #[cfg(feature = "mobile-social")] + #[tokio::test] + async fn hardened_retrieval_atomically_writes_and_invalidates_the_local_artifact() { + use crate::runtime::{ + builder::RuntimeBuilder, + store::{MobileUserStoreConfig, ProtectedDataAvailability}, + }; + use radroots_sdk::transport::{ + BlossomCancellation, BlossomConfig, BlossomEndpointAuthority, BlossomHostKind, + BlossomProfile, + }; + + let bytes = png(2, 3); + let hash = BlossomSha256::digest(bytes.as_slice()).to_hex(); + let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind"); + let origin = format!("http://{}", listener.local_addr().expect("address")); + let url = format!("{origin}/{hash}.png"); + let server = tokio::spawn(serve_one_blob(listener, bytes.clone())); + let root = tempfile::tempdir().expect("root"); + let public_key = keys().public_key().to_string(); + let store = MobileUserStoreConfig::from_encoded( + root.path(), + &public_key, + "6161616161616161616161616161616161616161616161616161616161616161", + 2_000_000_000_000, + ProtectedDataAvailability::Available, + ) + .expect("store"); + std::fs::create_dir_all(store.owner_directory()).expect("owner directory"); + let owner_directory = store.owner_directory().to_path_buf(); + let blossom = BlossomConfig::from_profile( + BlossomProfile::new( + BlossomHostKind::Simulator, + BlossomEndpointAuthority::LoopbackDevelopment, + origin, + std::iter::empty::<String>(), + ) + .expect("profile"), + ) + .with_network_policy( + std::time::Duration::from_millis(100), + std::time::Duration::from_millis(100), + 1, + std::time::Duration::from_millis(1), + ) + .expect("network policy"); + let runtime = RuntimeBuilder::new(store) + .blossom_config(blossom) + .build() + .await + .expect("runtime"); + let context = context(None, 1); + ingest( + &runtime, + &context, + signed( + 0, + Vec::new(), + &format!(r#"{{"name":"retrieved","picture":"{url}"}}"#), + 2_000_000_000, + ), + 2_000_000_100, + ) + .await; + let picture = runtime + .phase1_me(&context, &public_key, 2_000_000_200) + .await + .expect("me") + .profile + .expect("profile") + .picture + .expect("picture"); + let artifact = runtime + .phase1_retrieve_media( + &context, + *picture.structural().fingerprint(), + [9; 16], + Phase1MediaCachePolicy::new(1_024, 8).unwrap(), + BlossomCancellation::default(), + ) + .await + .expect("verified retrieval"); + server.await.expect("server"); + assert!( + artifact + .local_path() + .starts_with(owner_directory.join("inbound_media.v1")) + ); + assert_eq!(tokio::fs::read(artifact.local_path()).await.unwrap(), bytes); + assert_eq!(artifact.bytes(), bytes); + assert!( + !tokio::fs::symlink_metadata(artifact.local_path()) + .await + .unwrap() + .file_type() + .is_symlink() + ); + let verified = runtime + .phase1_me(&context, &public_key, 2_000_000_201) + .await + .expect("verified me") + .profile + .expect("verified profile") + .picture + .expect("verified picture"); + assert!(matches!( + verified.retrieval(), + Phase1InboundMediaState::Verified(_) + )); + let mut corrupt = bytes.clone(); + let last = corrupt.len() - 1; + corrupt[last] ^= 1; + tokio::fs::write(artifact.local_path(), corrupt) + .await + .expect("corrupt cached bytes"); + assert!(matches!( + runtime + .phase1_verified_media_artifact( + &context, + artifact.artifact_id(), + 2_000_000_203_000, + ) + .await, + Err(TodayError::InboundMedia( + Phase1InboundMediaError::CorruptArtifact + )) + )); + assert!(!artifact.local_path().exists()); + let current_configuration = runtime + .phase1_media_cache_status(&context) + .await + .unwrap() + .configuration + .expect("configuration"); + let replacement_bytes = if current_configuration.as_bytes() == &[1; 32] { + [2; 32] + } else { + [1; 32] + }; + let replacement_configuration = + Phase1MediaConfigurationFingerprint::new(replacement_bytes).unwrap(); + runtime + .phase1_invalidate_media_configuration(&context, replacement_configuration) + .await + .expect("invalidate configuration"); + let unavailable = runtime + .phase1_me(&context, &public_key, 2_000_000_202) + .await + .expect("unavailable me") + .profile + .expect("unavailable profile") + .picture + .expect("unavailable picture"); + assert!(matches!( + unavailable.retrieval(), + Phase1InboundMediaState::Unavailable + )); + runtime.shutdown().await.expect("shutdown"); + } + + #[tokio::test] + async fn fail_closed_requests_cursors_overlays_and_projection_guards_are_executable() { + let mut runtime = RadrootsRuntime::test_memory().expect("runtime"); + let context = context(None, 1); + let note = signed(1, Vec::new(), "guarded alpha", 2_000_000_000); + assert!(matches!( + runtime + .phase1_ingest_visible(raw_admission(note.clone(), 2_000_000_000_000), &context, 1) + .await, + Err(TodayError::EventNotVisible) + )); + assert!(matches!( + runtime + .phase1_refresh_today(&context, 0, TodayProjectionUpdate::Incremental) + .await, + Err(TodayError::InvalidRequest) + )); + assert!(matches!( + runtime + .phase1_refresh_today(&context, u64::MAX, TodayProjectionUpdate::Incremental) + .await, + Err(TodayError::InvalidRequest) + )); + let empty = runtime + .phase1_refresh_today(&context, 1, TodayProjectionUpdate::Incremental) + .await + .expect("empty projection"); + assert_eq!(empty.source_events, 0); + assert!(empty.changed); + assert!( + !runtime + .phase1_refresh_today(&context, 2, TodayProjectionUpdate::Incremental) + .await + .expect("unchanged projection") + .changed + ); + + for request in [ + TodayPageRequest::first(0, 1), + TodayPageRequest::first(TODAY_PAGE_LIMIT_MAX + 1, 1), + TodayPageRequest { + limit: 1, + as_of: None, + cursor: None, + }, + TodayPageRequest::first(1, 0), + ] { + assert!(matches!( + runtime.phase1_today_page(&context, request).await, + Err(TodayError::InvalidRequest) + )); + } + for (query, limit, as_of) in [ + ("valid", 0, 1), + ("valid", TODAY_SEARCH_LIMIT_MAX + 1, 1), + ("valid", 1, 0), + (" ", 1, 1), + (&"x".repeat(257), 1, 1), + ("bad\nquery", 1, 1), + ] { + assert!(matches!( + runtime.phase1_search(&context, query, limit, as_of).await, + Err(TodayError::InvalidRequest) + )); + } + assert!(matches!( + runtime.phase1_me(&context, "bad", 1).await, + Err(TodayError::InvalidRequest) + )); + assert!(matches!( + runtime + .phase1_me(&context, &keys().public_key().to_string(), 0) + .await, + Err(TodayError::InvalidRequest) + )); + assert!( + !runtime + .phase1_begin_media_retrieval( + &context, + [3; 32], + Phase1InboundMediaPending::new( + [4; 16], + Phase1MediaConfigurationFingerprint::new([5; 32]).unwrap(), + 1, + ) + .unwrap(), + ) + .await + .expect("unmatched media") + ); + + ingest(&runtime, &context, note.clone(), 2_000_000_100).await; + let page = runtime + .phase1_today_page(&context, TodayPageRequest::first(1, 2_000_000_200)) + .await + .expect("page"); + let card_id = page.items[0].card.card_id; + let rank = page.items[0].card.rank.expect("rank"); + let generation = projection_generation().expect("generation"); + let storage = runtime.client.storage().expect("storage"); + let state = load_state(storage, &context, generation) + .await + .expect("load state") + .expect("state"); + let scope = CursorScope::new( + context.id.clone(), + context.generation, + 2_000_000_200, + state.store_generation, + state.content_generation, + ) + .expect("scope"); + + let cursor_for = |scope: CursorScope| { + TodayCursor::encode(&scope, TodayCursorPosition { rank }) + .expect("cursor") + .as_str() + .to_owned() + }; + let mut wrong_context = scope.clone(); + wrong_context.context_id = "elsewhere".into(); + assert!(matches!( + runtime + .phase1_today_page( + &context, + TodayPageRequest::after(1, cursor_for(wrong_context)) + ) + .await, + Err(TodayError::Cursor(CursorError::ContextMismatch)) + )); + let mut wrong_context_generation = scope.clone(); + wrong_context_generation.context_generation += 1; + assert!(matches!( + runtime + .phase1_today_page( + &context, + TodayPageRequest::after(1, cursor_for(wrong_context_generation)), + ) + .await, + Err(TodayError::Cursor(CursorError::ContextMismatch)) + )); + assert!(matches!( + runtime + .phase1_today_page( + &context, + TodayPageRequest { + limit: 1, + as_of: Some(scope.as_of + 1), + cursor: Some(cursor_for(scope.clone())), + }, + ) + .await, + Err(TodayError::Cursor(CursorError::SnapshotMismatch)) + )); + let mut stale = scope.clone(); + stale.store_generation = [9; 32]; + assert!(matches!( + runtime + .phase1_today_page(&context, TodayPageRequest::after(1, cursor_for(stale))) + .await, + Err(TodayError::Cursor(CursorError::Stale)) + )); + let mut missing = scope.clone(); + missing.projection_generation = missing.projection_generation.wrapping_add(1).max(1); + assert!(matches!( + runtime + .phase1_today_page(&context, TodayPageRequest::after(1, cursor_for(missing))) + .await, + Err(TodayError::SnapshotMissing) + )); + + let snapshot = frozen_snapshot(&state, &context, scope.as_of).expect("snapshot"); + assert!(validate_snapshot(&snapshot, &scope).is_ok()); + for invalid in [ + { + let mut value = snapshot.clone(); + value.schema_version += 1; + value + }, + { + let mut value = snapshot.clone(); + value.context_id = "other".into(); + value + }, + { + let mut value = snapshot.clone(); + value.context_generation += 1; + value + }, + { + let mut value = snapshot.clone(); + value.as_of += 1; + value + }, + { + let mut value = snapshot.clone(); + value.store_generation = [8; 32]; + value + }, + { + let mut value = snapshot.clone(); + value.projection_generation = value.projection_generation.wrapping_add(1); + value + }, + ] { + assert!(matches!( + validate_snapshot(&invalid, &scope), + Err(TodayError::CorruptProjection) + )); + } + let mut absent_rank = rank; + absent_rank.card_id = CardId::parse(&"f".repeat(64)).expect("absent card id"); + assert!(matches!( + page_from_snapshot(snapshot.clone(), scope.clone(), Some(absent_rank), 1), + Err(TodayError::CursorPositionMissing) + )); + + for invalid in [ + { + let mut value = state.clone(); + value.schema_version += 1; + value + }, + { + let mut value = state.clone(); + value.content_generation = 0; + value + }, + { + let mut value = state.clone(); + value.source_events += 1; + value + }, + ] { + assert!(matches!( + decode_state(&encode(&invalid).expect("encode invalid state")), + Err(TodayError::CorruptProjection) + )); + } + let mut invalid_snapshot = snapshot.clone(); + invalid_snapshot.schema_version += 1; + assert!(matches!( + decode_snapshot(&encode(&invalid_snapshot).expect("encode invalid snapshot")), + Err(TodayError::CorruptProjection) + )); + assert!(matches!( + decode_state(b"not-json"), + Err(TodayError::CorruptProjection) + )); + + for overlay in [ + LocalAuthorOverlay { + operation_id: String::new(), + state: "queued".into(), + }, + LocalAuthorOverlay { + operation_id: "x".repeat(257), + state: "queued".into(), + }, + LocalAuthorOverlay { + operation_id: "operation".into(), + state: String::new(), + }, + LocalAuthorOverlay { + operation_id: "operation".into(), + state: "x".repeat(97), + }, + LocalAuthorOverlay { + operation_id: "operation".into(), + state: "bad\nstate".into(), + }, + ] { + assert!(matches!( + runtime + .phase1_set_local_author_overlay(&context, card_id, Some(overlay)) + .await, + Err(TodayError::InvalidRequest) + )); + } + let other_keys = Keys::generate(); + runtime.store_public_key = Some( + radroots_identity::PublicKey::from_hex(&other_keys.public_key().to_string()) + .expect("other public key"), + ); + assert!(matches!( + runtime + .phase1_me(&context, &keys().public_key().to_string(), 1) + .await, + Err(TodayError::InvalidRequest) + )); + assert!(matches!( + runtime + .phase1_set_local_author_overlay( + &context, + card_id, + Some(LocalAuthorOverlay { + operation_id: "operation".into(), + state: "queued".into(), + }), + ) + .await, + Err(TodayError::InvalidRequest) + )); + runtime.store_public_key = None; + runtime + .phase1_set_local_author_overlay( + &context, + card_id, + Some(LocalAuthorOverlay { + operation_id: "operation".into(), + state: "queued".into(), + }), + ) + .await + .expect("set overlay"); + runtime + .phase1_set_local_author_overlay(&context, card_id, None) + .await + .expect("remove overlay"); + assert_eq!( + runtime + .phase1_search(&context, "guarded", 1, 2_000_000_200) + .await + .expect("card-limited search") + .len(), + 1 + ); + + let mut event_state = state.clone(); + event_state.cards[0].card.card_type = TodayCardType::Event; + event_state.cards[0].card.event_start = Some(100); + event_state.cards[0].card.event_end = Some(200); + event_state.cards[0].card.effective_at = 100; + assert_eq!( + ranked_cards(&event_state, &context, 150).expect("live event")[0] + .card + .lifecycle, + CardLifecycleState::Active + ); + assert_eq!( + ranked_cards(&event_state, &context, 200).expect("past event")[0] + .card + .lifecycle, + CardLifecycleState::Past + ); + + let root = admitted(&note); + assert!(matches!( + profile_summary(&root), + Err(TodayError::CorruptProjection) + )); + assert!(matches!( + reply_entry(&root), + Err(TodayError::CorruptProjection) + )); + assert!(comment_entry(&root).is_none()); + assert_eq!(tag_value(&[Vec::new()], &["x"]), None); + assert_eq!(tag_value(&[vec!["x".into()]], &["x"]), None); + assert_eq!(blossom_digest("not-a-url"), None); + assert_eq!(blossom_digest("https://blob.example/short"), None); + assert_eq!( + blossom_digest(&format!("https://blob.example/{}", "A".repeat(64))), + None + ); + + let tags = locality_tags(vec![ + Vec::new(), + vec!["x".into(), "ignored".into()], + vec!["g".into()], + vec!["location".into(), " ".into()], + vec!["g".into(), "u10hr".into()], + ]); + assert_eq!(tags.len(), 1); + assert_eq!( + locality_evidence(Some("u10"), &tags), + LocalityEvidence::Match + ); + assert_eq!( + locality_evidence(Some("u10hr7"), &tags), + LocalityEvidence::Match + ); + assert_eq!( + locality_evidence(Some("other"), &tags), + LocalityEvidence::Nonmatch + ); + assert_eq!( + locality_evidence(Some("selected"), &[]), + LocalityEvidence::Missing + ); + + let mut fields = BTreeMap::new(); + fields.insert("value".into(), serde_json::json!(1)); + assert_eq!(typed_profile_extra(&fields, "missing"), None); + assert_eq!(typed_profile_extra(&fields, "value"), None); + for value in [String::new(), "x".repeat(2_049), "bad\nvalue".into()] { + fields.insert("value".into(), serde_json::Value::String(value)); + assert_eq!(typed_profile_extra(&fields, "value"), None); + } + } + + #[test] + fn malformed_cursor_and_request_bounds_fail_closed() { + assert!(matches!( + TodayCursor::scope("bad"), + Err(CursorError::Malformed) + )); + assert!(!valid_public_key("short")); + assert!(!valid_public_key("A".repeat(64).as_str())); + assert!(valid_public_key("a".repeat(64).as_str())); + assert_eq!(locality_evidence(None, &[]), LocalityEvidence::Missing); + assert_eq!(blossom_digest("https://blob.example"), None); + assert_eq!( + blossom_digest(&format!( + "https://blob.example/{}/image.jpg", + "a".repeat(64) + )), + Some("a".repeat(64)) + ); + assert_eq!(TODAY_PAGE_LIMIT_MAX, 100); + assert_eq!(TODAY_SEARCH_LIMIT_MAX, 100); + } +} diff --git a/core/crates/tera_core/src/runtime/sdk.rs b/core/crates/tera_core/src/runtime/sdk.rs @@ -0,0 +1,532 @@ +use radroots_sdk::capability::{Availability, Maturity}; + +pub use radroots_sdk::trade::{ + RadrootsRhiEvidenceReportV1, RadrootsTradeEvidenceCoverageV1, RadrootsTradeEvidenceManifestV1, + RadrootsTradeEvidenceOutcomeV1, +}; + +use super::RadrootsRuntime; +#[cfg(feature = "mobile-social")] +use super::product_surface::{BlossomPreferences, RelayPreferences}; +use crate::RadrootsAppError; + +#[derive(Clone, Debug)] +pub struct SdkCapabilityRecord { + pub id: String, + pub compiled: bool, + pub configured: bool, + pub availability: String, + pub maturity: String, +} + +#[derive(Clone, Debug)] +pub struct SdkStorageStatusRecord { + pub backend: String, + pub open_mode: String, + pub shutdown: String, + pub integrity: String, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum SdkRelayAccessRecord { + ReadOnly, + ReadWrite, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct SdkRelayStatusRecord { + pub relay_url: String, + pub access: SdkRelayAccessRecord, + pub read_state: String, + pub write_state: String, + pub read_last_attempt_unix_ms: Option<u64>, + pub write_last_attempt_unix_ms: Option<u64>, + pub read_next_attempt_unix_ms: Option<u64>, + pub write_next_attempt_unix_ms: Option<u64>, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct SdkRelayStatusReportRecord { + pub profile: String, + pub state: String, + pub read_availability: String, + pub write_availability: String, + pub relays: Vec<SdkRelayStatusRecord>, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct SdkBlossomConfigurationRecord { + pub host_kind: String, + pub endpoint_authority: String, + pub primary_origin: String, + pub fallback_origins: Vec<String>, + pub config_fingerprint: String, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct SdkBlossomEvidenceRecord { + pub schema_version: u16, + pub origin: String, + pub config_fingerprint: String, + pub state: String, + pub last_successful_state: String, + pub transport_security: String, + pub observed_at_unix_ms: Option<u64>, + pub http_status: Option<u16>, + pub error_code: Option<String>, + pub server_error_code: Option<String>, + pub error_phase: Option<String>, + pub retryable: bool, + pub possible_orphan: bool, + pub attempts: u8, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct SdkShutdownRecord { + pub state: String, + pub already_closed: bool, +} + +impl RadrootsRuntime { + pub fn sdk_capabilities(&self) -> Vec<SdkCapabilityRecord> { + self.client + .capabilities() + .iter() + .map(|status| SdkCapabilityRecord { + id: status.id().as_str().to_owned(), + compiled: status.is_compiled(), + configured: status.is_configured(), + availability: availability_label(status.availability()).to_owned(), + maturity: maturity_label(status.maturity()).to_owned(), + }) + .collect() + } + + pub async fn sdk_storage_status(&self) -> Result<SdkStorageStatusRecord, RadrootsAppError> { + let status = self + .client + .storage_status() + .await + .map_err(RadrootsAppError::from_sdk)?; + Ok(SdkStorageStatusRecord { + backend: status.backend().as_str().to_owned(), + open_mode: status.open_mode().as_str().to_owned(), + shutdown: status.shutdown().as_str().to_owned(), + integrity: status.integrity().health().as_str().to_owned(), + }) + } + + /// Installs a validated public relay profile without probing it. + #[cfg(feature = "mobile-social")] + pub fn configure_public_relays( + &self, + writable_relays: Vec<String>, + ) -> Result<(), RadrootsAppError> { + self.configure_relay_endpoints( + radroots_sdk::transport::RelayProfileKind::Public, + radroots_sdk::transport::RelayUrlPolicy::Public, + writable_relays, + ) + } + + /// Installs an exact-loopback simulator profile without probing it. + #[cfg(feature = "mobile-social")] + pub fn configure_simulator_relays( + &self, + loopback_relays: Vec<String>, + ) -> Result<(), RadrootsAppError> { + self.configure_relay_endpoints( + radroots_sdk::transport::RelayProfileKind::Simulator, + radroots_sdk::transport::RelayUrlPolicy::Local, + loopback_relays, + ) + } + + /// Installs an explicit physical-device TLS relay profile without probing it. + #[cfg(feature = "mobile-social")] + pub fn configure_device_relays( + &self, + writable_relays: Vec<String>, + ) -> Result<(), RadrootsAppError> { + self.configure_relay_endpoints( + radroots_sdk::transport::RelayProfileKind::Device, + radroots_sdk::transport::RelayUrlPolicy::PrivateNetwork, + writable_relays, + ) + } + + #[cfg(feature = "mobile-social")] + fn configure_relay_endpoints( + &self, + kind: radroots_sdk::transport::RelayProfileKind, + policy: radroots_sdk::transport::RelayUrlPolicy, + relays: Vec<String>, + ) -> Result<(), RadrootsAppError> { + let endpoints = relays + .into_iter() + .map(|relay| { + radroots_sdk::transport::RelayEndpoint::new( + relay, + policy, + radroots_sdk::transport::RelayAccess::ReadWrite, + ) + }) + .collect::<Result<Vec<_>, _>>() + .map_err(|error| RadrootsAppError::runtime(error.to_string()))?; + let profile = radroots_sdk::transport::RelayProfile::explicit(kind, endpoints) + .map_err(|error| RadrootsAppError::runtime(error.to_string()))?; + self.configure_relay_profile(profile) + } + + #[cfg(feature = "mobile-social")] + fn configure_relay_profile( + &self, + profile: radroots_sdk::transport::RelayProfile, + ) -> Result<(), RadrootsAppError> { + self.client + .configure_nostr(profile) + .map_err(RadrootsAppError::from_sdk) + } + + /// Installs the exact validated relay preferences persisted by the mobile product. + #[cfg(feature = "mobile-social")] + pub fn configure_relay_preferences( + &self, + preferences: &RelayPreferences, + ) -> Result<(), RadrootsAppError> { + self.configure_relay_profile( + preferences + .sdk_profile() + .map_err(|error| RadrootsAppError::runtime(error.code()))?, + ) + } + + /// Installs one canonical inert Blossom configuration without probing it. + #[cfg(feature = "mobile-social")] + pub fn configure_blossom( + &self, + host_kind: radroots_sdk::transport::BlossomHostKind, + endpoint_authority: radroots_sdk::transport::BlossomEndpointAuthority, + primary_origin: String, + fallback_origins: Vec<String>, + ) -> Result<(), RadrootsAppError> { + self.configure_blossom_profile( + radroots_sdk::transport::BlossomProfile::new( + host_kind, + endpoint_authority, + primary_origin, + fallback_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) + } + + /// Installs the exact validated Blossom preferences persisted by the mobile product. + #[cfg(feature = "mobile-social")] + pub fn configure_blossom_preferences( + &self, + preferences: &BlossomPreferences, + ) -> Result<(), RadrootsAppError> { + self.configure_blossom_profile( + preferences + .sdk_profile() + .map_err(|error| RadrootsAppError::runtime(error.code()))?, + ) + } + + /// Returns the configured adapter slot for Rust-owned media binding. + #[cfg(feature = "mobile-social")] + pub fn sdk_blossom_slot( + &self, + ) -> Result<Option<radroots_sdk::transport::BlossomSlot>, RadrootsAppError> { + self.client + .blossom() + .map(|slot| slot.cloned()) + .map_err(RadrootsAppError::from_sdk) + } + + /// Returns the complete inert Blossom configuration, when configured. + #[cfg(feature = "mobile-social")] + pub fn sdk_blossom_configuration( + &self, + ) -> Result<Option<SdkBlossomConfigurationRecord>, RadrootsAppError> { + let configuration = self + .client + .blossom() + .map_err(RadrootsAppError::from_sdk)? + .and_then(radroots_sdk::transport::BlossomSlot::configuration); + Ok( + configuration.map(|(profile, fingerprint)| SdkBlossomConfigurationRecord { + host_kind: blossom_host_kind_label(profile.host_kind()).to_owned(), + endpoint_authority: blossom_authority_label(profile.authority()).to_owned(), + primary_origin: profile.primary().origin().to_owned(), + fallback_origins: profile + .fallbacks() + .iter() + .map(|endpoint| endpoint.origin().to_owned()) + .collect(), + config_fingerprint: fingerprint.to_hex(), + }), + ) + } + + /// Returns the latest passive Blossom evidence without network I/O. + #[cfg(feature = "mobile-social")] + pub fn sdk_blossom_evidence( + &self, + ) -> Result<Option<SdkBlossomEvidenceRecord>, RadrootsAppError> { + let evidence = self + .client + .blossom() + .map_err(RadrootsAppError::from_sdk)? + .and_then(radroots_sdk::transport::BlossomSlot::evidence); + Ok(evidence.map(sdk_blossom_evidence_record)) + } + + /// Explicitly probes the primary Blossom origin without mutation or authorization. + #[cfg(feature = "mobile-social")] + pub async fn probe_blossom(&self) -> Result<SdkBlossomEvidenceRecord, RadrootsAppError> { + let blossom = self + .client + .blossom() + .map_err(RadrootsAppError::from_sdk)? + .ok_or_else(|| RadrootsAppError::runtime("blossom_endpoint_not_configured"))?; + blossom + .probe(radroots_sdk::transport::BlossomCancellation::default()) + .await + .map(sdk_blossom_evidence_record) + .map_err(|error| RadrootsAppError::runtime(error.code())) + } + + /// Returns passive relay evidence without DNS, socket, or probe work. + #[cfg(feature = "mobile-social")] + pub fn sdk_relay_status(&self) -> Result<Option<SdkRelayStatusReportRecord>, RadrootsAppError> { + let report = self + .client + .nostr_status() + .map_err(RadrootsAppError::from_sdk)?; + Ok(report.map(|report| SdkRelayStatusReportRecord { + profile: relay_profile_label(report.profile_kind()).to_owned(), + state: relay_aggregate_label(report.state()).to_owned(), + read_availability: transport_availability_label(report.read_availability()).to_owned(), + write_availability: transport_availability_label(report.write_availability()) + .to_owned(), + relays: report + .relays() + .iter() + .map(|relay| SdkRelayStatusRecord { + relay_url: relay.endpoint().url().to_string(), + access: if relay.endpoint().access().can_write() { + SdkRelayAccessRecord::ReadWrite + } else { + 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(), + write_last_attempt_unix_ms: relay.write().last_attempt_unix_ms(), + read_next_attempt_unix_ms: relay.read().next_attempt_unix_ms(), + write_next_attempt_unix_ms: relay.write().next_attempt_unix_ms(), + }) + .collect(), + })) + } +} + +#[cfg(feature = "mobile-social")] +fn sdk_blossom_evidence_record( + value: radroots_sdk::transport::BlossomEndpointEvidence, +) -> SdkBlossomEvidenceRecord { + SdkBlossomEvidenceRecord { + schema_version: value.schema_version(), + origin: value.origin().to_owned(), + config_fingerprint: value.config_fingerprint().to_hex(), + state: blossom_evidence_label(value.state()).to_owned(), + last_successful_state: blossom_evidence_label(value.last_successful_state()).to_owned(), + transport_security: blossom_transport_security_label(value.transport_security()).to_owned(), + observed_at_unix_ms: value.observed_at_unix_ms(), + http_status: value.http_status(), + error_code: value.error_code().map(str::to_owned), + server_error_code: value.server_error_code().map(str::to_owned), + error_phase: value + .error_phase() + .map(blossom_phase_label) + .map(str::to_owned), + retryable: value.retryable(), + possible_orphan: value.possible_orphan(), + attempts: value.attempts(), + } +} + +#[cfg(feature = "mobile-social")] +const fn blossom_evidence_label( + value: radroots_sdk::transport::BlossomEvidenceState, +) -> &'static str { + match value { + radroots_sdk::transport::BlossomEvidenceState::ConfiguredUnobserved => { + "configured_unobserved" + } + radroots_sdk::transport::BlossomEvidenceState::DnsPolicyValidated => "dns_policy_validated", + radroots_sdk::transport::BlossomEvidenceState::TlsHttpObserved => "tls_http_observed", + radroots_sdk::transport::BlossomEvidenceState::UploadVerified => "upload_verified", + radroots_sdk::transport::BlossomEvidenceState::RetrievalVerified => "retrieval_verified", + radroots_sdk::transport::BlossomEvidenceState::RetryableFailure => "retryable_failure", + radroots_sdk::transport::BlossomEvidenceState::TerminalFailure => "terminal_failure", + _ => "unknown", + } +} + +#[cfg(feature = "mobile-social")] +const fn blossom_transport_security_label( + value: radroots_sdk::transport::BlossomTransportSecurity, +) -> &'static str { + match value { + radroots_sdk::transport::BlossomTransportSecurity::PublicWebPki => "public_webpki", + radroots_sdk::transport::BlossomTransportSecurity::DevelopmentTls => "development_tls", + radroots_sdk::transport::BlossomTransportSecurity::DevelopmentCleartext => { + "development_cleartext" + } + _ => "unknown", + } +} + +#[cfg(feature = "mobile-social")] +const fn blossom_phase_label(value: radroots_sdk::transport::BlossomPhase) -> &'static str { + match value { + radroots_sdk::transport::BlossomPhase::Configuration => "configuration", + radroots_sdk::transport::BlossomPhase::Probe => "probe", + radroots_sdk::transport::BlossomPhase::Authorization => "authorization", + radroots_sdk::transport::BlossomPhase::Upload => "upload", + radroots_sdk::transport::BlossomPhase::Descriptor => "descriptor", + radroots_sdk::transport::BlossomPhase::Retrieval => "retrieval", + radroots_sdk::transport::BlossomPhase::Verification => "verification", + _ => "unknown", + } +} + +#[cfg(feature = "mobile-social")] +const fn blossom_host_kind_label(value: radroots_sdk::transport::BlossomHostKind) -> &'static str { + match value { + radroots_sdk::transport::BlossomHostKind::Native => "native", + radroots_sdk::transport::BlossomHostKind::Simulator => "simulator", + radroots_sdk::transport::BlossomHostKind::PhysicalDevice => "physical_device", + _ => "unknown", + } +} + +#[cfg(feature = "mobile-social")] +const fn blossom_authority_label( + value: radroots_sdk::transport::BlossomEndpointAuthority, +) -> &'static str { + match value { + radroots_sdk::transport::BlossomEndpointAuthority::PublicWebPki => "public_webpki", + radroots_sdk::transport::BlossomEndpointAuthority::LoopbackDevelopment => { + "loopback_development" + } + radroots_sdk::transport::BlossomEndpointAuthority::PrivateNetworkDevelopment => { + "private_network_development" + } + _ => "unknown", + } +} + +#[cfg(feature = "mobile-social")] +const fn relay_evidence_label(value: radroots_sdk::transport::RelayEvidenceState) -> &'static str { + match value { + radroots_sdk::transport::RelayEvidenceState::Unsupported => "unsupported", + radroots_sdk::transport::RelayEvidenceState::Unobserved => "unobserved", + radroots_sdk::transport::RelayEvidenceState::Connecting => "connecting", + radroots_sdk::transport::RelayEvidenceState::Available => "available", + radroots_sdk::transport::RelayEvidenceState::Unavailable => "unavailable", + _ => "unknown", + } +} + +#[cfg(feature = "mobile-social")] +const fn relay_profile_label(value: radroots_sdk::transport::RelayProfileKind) -> &'static str { + match value { + radroots_sdk::transport::RelayProfileKind::Public => "public", + radroots_sdk::transport::RelayProfileKind::Simulator => "simulator_local", + radroots_sdk::transport::RelayProfileKind::Device => "device_development", + _ => "unknown", + } +} + +#[cfg(feature = "mobile-social")] +const fn relay_aggregate_label( + value: radroots_sdk::transport::RelayAggregateState, +) -> &'static str { + match value { + radroots_sdk::transport::RelayAggregateState::Configured => "configured", + radroots_sdk::transport::RelayAggregateState::Connecting => "connecting", + radroots_sdk::transport::RelayAggregateState::ReadOnly => "read_only", + radroots_sdk::transport::RelayAggregateState::Writable => "writable", + radroots_sdk::transport::RelayAggregateState::Degraded => "degraded", + radroots_sdk::transport::RelayAggregateState::Offline => "offline", + radroots_sdk::transport::RelayAggregateState::Failed => "failed", + _ => "unknown", + } +} + +#[cfg(feature = "mobile-social")] +const fn transport_availability_label( + value: radroots_transport::capability::Availability, +) -> &'static str { + match value { + radroots_transport::capability::Availability::Available => "available", + radroots_transport::capability::Availability::Degraded => "degraded", + radroots_transport::capability::Availability::Unavailable => "unavailable", + } +} + +const fn availability_label(value: Availability) -> &'static str { + match value { + Availability::Available => "available", + Availability::Degraded => "degraded", + Availability::Unavailable => "unavailable", + Availability::Unsupported => "unsupported", + } +} + +const fn maturity_label(value: Maturity) -> &'static str { + match value { + Maturity::Stable => "stable", + Maturity::Preview => "preview", + Maturity::Experimental => "experimental", + } +} + +#[cfg(test)] +#[cfg_attr(coverage_nightly, coverage(off))] +mod tests { + use super::RadrootsRuntime; + + #[tokio::test] + async fn explicit_test_runtime_is_memory_backed() { + let runtime = RadrootsRuntime::test_memory().expect("runtime"); + let capabilities = runtime.sdk_capabilities(); + assert!(capabilities.iter().any(|capability| { + capability.id == "storage.canonical" + && capability.configured + && capability.availability == "available" + && capability.maturity == "stable" + })); + let status = runtime.sdk_storage_status().await.expect("storage status"); + assert_eq!(status.backend, "memory"); + assert_eq!(status.integrity, "healthy"); + runtime.shutdown().await.expect("shutdown"); + assert!(runtime.sdk_storage_status().await.is_err()); + } +} diff --git a/core/crates/tera_core/src/runtime/store.rs b/core/crates/tera_core/src/runtime/store.rs @@ -0,0 +1,257 @@ +//! Validated host contract for one authenticated mobile user's durable store. + +use std::{ + path::{Component, Path, PathBuf}, + time::Duration, +}; + +use radroots_identity::PublicKey; +use radroots_storage::event::SourceGeneration; + +use crate::RadrootsAppError; + +const PRODUCT_DIRECTORY: &str = "radroots"; +const USER_DIRECTORY: &str = "users"; +const GENERATION_HEX_LENGTH: usize = 64; + +/// Host-observed Apple protected-data state at runtime construction time. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ProtectedDataAvailability { + Available, + Unavailable, +} + +/// Validated composition for one authenticated user's SQLite owner directory. +/// +/// The Apple host owns directory creation and data-protection attributes. Rust +/// derives the exact identity-scoped suffix and refuses alternate, relative, +/// or symlinked directory layouts before SQLite is opened. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct MobileUserStoreConfig { + application_support_directory: PathBuf, + owner_directory: PathBuf, + public_key: PublicKey, + source_generation: SourceGeneration, + source_generation_created_at_unix_ms: u64, + protected_data: ProtectedDataAvailability, +} + +impl MobileUserStoreConfig { + /// Validates encoded host values without touching SQLite. + pub fn from_encoded( + application_support_directory: impl Into<PathBuf>, + public_key_hex: &str, + source_generation_hex: &str, + source_generation_created_at_unix_ms: u64, + protected_data: ProtectedDataAvailability, + ) -> Result<Self, RadrootsAppError> { + let public_key = PublicKey::from_hex(public_key_hex) + .map_err(|_| RadrootsAppError::store_invalid_configuration())?; + let source_generation = parse_source_generation(source_generation_hex)?; + Self::new( + application_support_directory, + public_key, + source_generation, + source_generation_created_at_unix_ms, + protected_data, + ) + } + + /// Creates a validated store configuration from canonical typed values. + pub fn new( + application_support_directory: impl Into<PathBuf>, + public_key: PublicKey, + source_generation: SourceGeneration, + source_generation_created_at_unix_ms: u64, + protected_data: ProtectedDataAvailability, + ) -> Result<Self, RadrootsAppError> { + let application_support_directory = application_support_directory.into(); + validate_absolute_normal_directory(&application_support_directory)?; + if source_generation_created_at_unix_ms == 0 + || i64::try_from(source_generation_created_at_unix_ms).is_err() + { + return Err(RadrootsAppError::store_invalid_configuration()); + } + let owner_directory = application_support_directory + .join(PRODUCT_DIRECTORY) + .join(USER_DIRECTORY) + .join(public_key.to_hex()); + Ok(Self { + application_support_directory, + owner_directory, + public_key, + source_generation, + source_generation_created_at_unix_ms, + protected_data, + }) + } + + /// Returns the host-owned Application Support root. + pub fn application_support_directory(&self) -> &Path { + self.application_support_directory.as_path() + } + + /// Returns the exact existing directory that must own both SQLite files. + pub fn owner_directory(&self) -> &Path { + self.owner_directory.as_path() + } + + /// Returns the authenticated identity that scopes this store. + pub const fn public_key(&self) -> PublicKey { + self.public_key + } + + pub(crate) const fn protected_data(&self) -> ProtectedDataAvailability { + self.protected_data + } + + pub(crate) fn validate_host_filesystem(&self) -> Result<(), RadrootsAppError> { + let directories = [ + self.application_support_directory.clone(), + self.application_support_directory + .join(PRODUCT_DIRECTORY) + .to_path_buf(), + self.application_support_directory + .join(PRODUCT_DIRECTORY) + .join(USER_DIRECTORY) + .to_path_buf(), + self.owner_directory.clone(), + ]; + for directory in directories { + let metadata = std::fs::symlink_metadata(&directory) + .map_err(|_| RadrootsAppError::store_path_unavailable())?; + if metadata.file_type().is_symlink() || !metadata.is_dir() { + return Err(RadrootsAppError::store_invalid_configuration()); + } + } + Ok(()) + } + + pub(crate) fn sqlite_options( + &self, + ) -> Result<radroots_sdk::storage::SqliteOptions, RadrootsAppError> { + let paths = radroots_sdk::storage::SqlitePaths::from_directory(&self.owner_directory) + .map_err(|_| RadrootsAppError::store_invalid_configuration())?; + radroots_sdk::storage::SqliteOptions::new( + paths, + radroots_sdk::storage::SqliteOpenMode::Create, + ) + .with_busy_timeout(Duration::from_secs(5)) + .and_then(|options| { + options.with_source_generation( + self.source_generation, + self.source_generation_created_at_unix_ms, + ) + }) + .map_err(|_| RadrootsAppError::store_invalid_configuration()) + } +} + +fn parse_source_generation(value: &str) -> Result<SourceGeneration, RadrootsAppError> { + if value.len() != GENERATION_HEX_LENGTH { + return Err(RadrootsAppError::store_invalid_configuration()); + } + let bytes = hex::decode(value).map_err(|_| RadrootsAppError::store_invalid_configuration())?; + let bytes: [u8; 32] = bytes + .try_into() + .map_err(|_| RadrootsAppError::store_invalid_configuration())?; + SourceGeneration::new(bytes).map_err(|_| RadrootsAppError::store_invalid_configuration()) +} + +fn validate_absolute_normal_directory(path: &Path) -> Result<(), RadrootsAppError> { + if !path.is_absolute() + || path + .components() + .any(|component| matches!(component, Component::CurDir | Component::ParentDir)) + { + return Err(RadrootsAppError::store_invalid_configuration()); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + const PUBLIC_KEY: &str = "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798"; + const GENERATION: &str = "0101010101010101010101010101010101010101010101010101010101010101"; + + #[test] + fn encoded_scope_derives_the_exact_user_directory() { + let root = tempfile::tempdir().expect("tempdir"); + let config = MobileUserStoreConfig::from_encoded( + root.path(), + PUBLIC_KEY, + GENERATION, + 1_800_000_000_000, + ProtectedDataAvailability::Available, + ) + .expect("config"); + assert_eq!( + config.owner_directory(), + root.path().join("radroots").join("users").join(PUBLIC_KEY) + ); + assert_eq!(config.public_key().to_hex(), PUBLIC_KEY); + } + + #[test] + fn encoded_scope_rejects_invalid_identity_generation_time_and_path() { + let root = tempfile::tempdir().expect("tempdir"); + for result in [ + MobileUserStoreConfig::from_encoded( + "relative", + PUBLIC_KEY, + GENERATION, + 1, + ProtectedDataAvailability::Available, + ), + MobileUserStoreConfig::from_encoded( + root.path(), + "bad", + GENERATION, + 1, + ProtectedDataAvailability::Available, + ), + MobileUserStoreConfig::from_encoded( + root.path(), + PUBLIC_KEY, + "00", + 1, + ProtectedDataAvailability::Available, + ), + MobileUserStoreConfig::from_encoded( + root.path(), + PUBLIC_KEY, + GENERATION, + 0, + ProtectedDataAvailability::Available, + ), + ] { + assert!(matches!(result, Err(RadrootsAppError::Store { .. }))); + } + } + + #[cfg(unix)] + #[test] + fn host_filesystem_rejects_a_symlinked_user_scope() { + use std::os::unix::fs::symlink; + + let root = tempfile::tempdir().expect("tempdir"); + let config = MobileUserStoreConfig::from_encoded( + root.path(), + PUBLIC_KEY, + GENERATION, + 1, + ProtectedDataAvailability::Available, + ) + .expect("config"); + std::fs::create_dir_all(root.path().join(PRODUCT_DIRECTORY).join(USER_DIRECTORY)) + .expect("parents"); + let target = tempfile::tempdir().expect("target"); + symlink(target.path(), config.owner_directory()).expect("symlink"); + assert!(matches!( + config.validate_host_filesystem(), + Err(RadrootsAppError::Store { .. }) + )); + } +} diff --git a/core/crates/tera_core/tests/durable_runtime.rs b/core/crates/tera_core/tests/durable_runtime.rs @@ -0,0 +1,111 @@ +use tera_core::{ + RadrootsAppError, + runtime::{ + builder::RuntimeBuilder, + store::{MobileUserStoreConfig, ProtectedDataAvailability}, + }, +}; + +mod support; + +fn other_generation_store(root: &std::path::Path) -> MobileUserStoreConfig { + MobileUserStoreConfig::from_encoded( + root, + support::PUBLIC_KEY, + "0505050505050505050505050505050505050505050505050505050505050505", + 1_800_000_000_001, + ProtectedDataAvailability::Available, + ) + .expect("alternate store config") +} + +#[tokio::test] +async fn cold_create_shutdown_and_reopen_preserve_the_sqlite_store() { + let root = tempfile::tempdir().expect("tempdir"); + let store = support::store(root.path()); + let runtime = RuntimeBuilder::new(store.clone()) + .build() + .await + .expect("cold create"); + let status = runtime.sdk_storage_status().await.expect("status"); + assert_eq!( + runtime.authenticated_store_public_key_hex().as_deref(), + Some(support::PUBLIC_KEY) + ); + assert_eq!(status.backend, "sqlite"); + assert_eq!(status.open_mode, "create"); + assert_eq!(status.integrity, "unknown"); + assert!(store.owner_directory().join("runtime.sqlite").is_file()); + assert!(store.owner_directory().join("private.sqlite").is_file()); + runtime.shutdown().await.expect("shutdown"); + + let reopened = RuntimeBuilder::new(store).build().await.expect("reopen"); + assert_eq!( + reopened.sdk_storage_status().await.expect("status").backend, + "sqlite" + ); + reopened.shutdown().await.expect("shutdown"); +} + +#[tokio::test] +async fn one_authenticated_user_store_has_one_writable_runtime() { + let root = tempfile::tempdir().expect("tempdir"); + let store = support::store(root.path()); + let first = RuntimeBuilder::new(store.clone()) + .build() + .await + .expect("first runtime"); + let second = RuntimeBuilder::new(store.clone()).build().await; + let Err(RadrootsAppError::Sdk { report }) = second else { + panic!("second writable runtime must fail with a typed SDK error"); + }; + assert_eq!(report.code, "database_busy"); + assert!(report.retryable); + + first.shutdown().await.expect("first shutdown"); + let recovered = RuntimeBuilder::new(store) + .build() + .await + .expect("writer lock recovery"); + recovered.shutdown().await.expect("recovered shutdown"); +} + +#[tokio::test] +async fn source_generation_mismatch_is_integrity_classified() { + let root = tempfile::tempdir().expect("tempdir"); + let store = support::store(root.path()); + let runtime = RuntimeBuilder::new(store).build().await.expect("runtime"); + runtime.shutdown().await.expect("shutdown"); + + let result = RuntimeBuilder::new(other_generation_store(root.path())) + .build() + .await; + let Err(RadrootsAppError::Sdk { report }) = result else { + panic!("generation mismatch must fail with a typed SDK error"); + }; + assert_eq!(report.code, "storage_integrity_failed"); + assert!(!report.retryable); +} + +#[tokio::test] +async fn unrecognized_sqlite_bytes_are_corruption_classified() { + let root = tempfile::tempdir().expect("tempdir"); + let store = support::store(root.path()); + let runtime = RuntimeBuilder::new(store.clone()) + .build() + .await + .expect("runtime"); + runtime.shutdown().await.expect("shutdown"); + std::fs::write( + store.owner_directory().join("runtime.sqlite"), + b"not a sqlite database", + ) + .expect("replace runtime database with corrupt fixture"); + + let result = RuntimeBuilder::new(store).build().await; + let Err(RadrootsAppError::Sdk { report }) = result else { + panic!("corrupt store must fail with a typed SDK error"); + }; + assert_eq!(report.code, "storage_integrity_failed"); + assert!(!report.retryable); +} diff --git a/core/crates/tera_core/tests/package_boundary.rs b/core/crates/tera_core/tests/package_boundary.rs @@ -0,0 +1,101 @@ +const MANIFEST: &str = include_str!("../Cargo.toml"); +const LIB: &str = include_str!("../src/lib.rs"); +const ERROR: &str = include_str!("../src/error.rs"); +const RUNTIME: &str = include_str!("../src/runtime/mod.rs"); +const APP_INFO: &str = include_str!("../src/runtime/app_info.rs"); +const INFO: &str = include_str!("../src/runtime/info.rs"); +const PRODUCT_SURFACE: &str = include_str!("../src/runtime/product_surface.rs"); +const PRODUCT_AUTHORING: &str = include_str!("../src/runtime/product_surface/authoring.rs"); +const PRODUCT_CONTEXT: &str = include_str!("../src/runtime/product_surface/context.rs"); +const PRODUCT_CURSOR: &str = include_str!("../src/runtime/product_surface/cursor.rs"); +const PRODUCT_IDENTITY: &str = include_str!("../src/runtime/product_surface/identity.rs"); +const PRODUCT_MODEL: &str = include_str!("../src/runtime/product_surface/model.rs"); +const PRODUCT_OUTBOX: &str = include_str!("../src/runtime/product_surface/outbox.rs"); +const PRODUCT_PROJECTION: &str = include_str!("../src/runtime/product_surface/projection.rs"); +const PRODUCT_RANKING: &str = include_str!("../src/runtime/product_surface/ranking.rs"); +const SDK: &str = include_str!("../src/runtime/sdk.rs"); +const BUILDER: &str = include_str!("../src/runtime/builder.rs"); +const STORE: &str = include_str!("../src/runtime/store.rs"); + +#[test] +fn core_owns_no_uniffi_or_process_global_logging_policy() { + for (name, source) in [ + ("Cargo.toml", MANIFEST), + ("src/lib.rs", LIB), + ("src/error.rs", ERROR), + ("src/runtime/mod.rs", RUNTIME), + ("src/runtime/app_info.rs", APP_INFO), + ("src/runtime/info.rs", INFO), + ("src/runtime/product_surface.rs", PRODUCT_SURFACE), + ( + "src/runtime/product_surface/authoring.rs", + PRODUCT_AUTHORING, + ), + ("src/runtime/product_surface/context.rs", PRODUCT_CONTEXT), + ("src/runtime/product_surface/cursor.rs", PRODUCT_CURSOR), + ("src/runtime/product_surface/identity.rs", PRODUCT_IDENTITY), + ("src/runtime/product_surface/model.rs", PRODUCT_MODEL), + ("src/runtime/product_surface/outbox.rs", PRODUCT_OUTBOX), + ( + "src/runtime/product_surface/projection.rs", + PRODUCT_PROJECTION, + ), + ("src/runtime/product_surface/ranking.rs", PRODUCT_RANKING), + ("src/runtime/sdk.rs", SDK), + ] { + assert!( + !source.to_ascii_lowercase().contains("uniffi"), + "{name} contains UniFFI boundary policy" + ); + assert!( + !source.contains("tracing_subscriber") && !source.contains("set_global_default"), + "{name} contains process-global logging policy" + ); + } +} + +#[test] +fn mobile_runtime_has_no_secret_taking_or_local_signer_slot_surface() { + for source in [ + MANIFEST, + RUNTIME, + BUILDER, + PRODUCT_AUTHORING, + PRODUCT_OUTBOX, + ] { + assert!(!source.contains("signing::Slot")); + assert!(!source.contains("secret_key: String")); + assert!(!source.contains("Provider::slot")); + } + assert!(!MANIFEST.contains("radroots_sdk/local-signing")); +} + +#[test] +fn production_runtime_requires_validated_sqlite_and_memory_is_test_only() { + assert!(MANIFEST.contains("radroots_sdk = { workspace = true, features = [\"sqlite\"] }")); + assert_eq!(BUILDER.matches("ClientBuilder::sqlite").count(), 1); + assert!(!BUILDER.contains("memory_default") && !STORE.contains("memory_default")); + assert!(RUNTIME.contains("#[cfg(test)]\n pub(crate) fn test_memory()")); + assert!(!RUNTIME.contains("pub fn new()")); +} + +#[test] +fn mobile_core_reuses_the_final_sdk_evidence_vocabulary() { + for required in [ + "RadrootsRhiEvidenceReportV1", + "RadrootsTradeEvidenceCoverageV1", + "RadrootsTradeEvidenceManifestV1", + "RadrootsTradeEvidenceOutcomeV1", + ] { + assert!( + SDK.contains(required), + "missing SDK evidence type `{required}`" + ); + } + for forbidden in ["SecretKey", "sign_event", "publish_event", "tokio::spawn"] { + assert!( + !SDK.contains(forbidden), + "mobile evidence projection gained forbidden authority `{forbidden}`" + ); + } +} diff --git a/core/crates/tera_core/tests/sdk_runtime.rs b/core/crates/tera_core/tests/sdk_runtime.rs @@ -0,0 +1,55 @@ +use std::sync::Arc; + +use tera_core::{RadrootsAppError, RadrootsRuntime}; + +mod support; + +#[tokio::test] +async fn runtime_is_send_sync_and_shares_one_sdk_lifecycle() { + fn require_send_sync<T: Send + Sync>() {} + require_send_sync::<RadrootsRuntime>(); + + let (_root, runtime) = support::runtime().await; + let runtime = Arc::new(runtime); + let worker = { + let runtime = Arc::clone(&runtime); + std::thread::spawn(move || runtime.sdk_capabilities()) + }; + let capabilities = worker.join().expect("worker"); + assert!( + capabilities + .iter() + .any(|capability| capability.id == "storage.canonical") + ); + let first = runtime.shutdown().await.expect("first shutdown"); + let second = runtime.shutdown().await.expect("second shutdown"); + assert!(!first.already_closed); + assert!(second.already_closed); + assert!(runtime.info().sdk_closed); +} + +#[tokio::test] +async fn operations_fail_safely_after_explicit_close() { + let (_root, runtime) = support::runtime().await; + assert_eq!( + runtime.sdk_storage_status().await.expect("status").backend, + "sqlite" + ); + runtime.shutdown().await.expect("shutdown"); + assert!(matches!( + runtime.sdk_storage_status().await, + Err(RadrootsAppError::Sdk { .. }) + )); +} + +#[tokio::test] +async fn dropping_unpolled_shutdown_has_no_effect_and_retry_closes() { + let (_root, runtime) = support::runtime().await; + drop(runtime.shutdown()); + assert!(!runtime.info().sdk_closed); + assert!(!runtime.info().app.shutting_down); + + runtime.shutdown().await.expect("retry shutdown"); + assert!(runtime.info().sdk_closed); + assert!(runtime.info().app.shutting_down); +} diff --git a/core/crates/tera_core/tests/support/mod.rs b/core/crates/tera_core/tests/support/mod.rs @@ -0,0 +1,33 @@ +use tera_core::{ + RadrootsRuntime, + runtime::{ + builder::RuntimeBuilder, + store::{MobileUserStoreConfig, ProtectedDataAvailability}, + }, +}; + +pub const PUBLIC_KEY: &str = "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798"; +pub const GENERATION: &str = "0303030303030303030303030303030303030303030303030303030303030303"; + +pub fn store(root: &std::path::Path) -> MobileUserStoreConfig { + let store = MobileUserStoreConfig::from_encoded( + root, + PUBLIC_KEY, + GENERATION, + 1_800_000_000_000, + ProtectedDataAvailability::Available, + ) + .expect("store config"); + std::fs::create_dir_all(store.owner_directory()).expect("owner directory"); + store +} + +#[allow(dead_code)] +pub async fn runtime() -> (tempfile::TempDir, RadrootsRuntime) { + let root = tempfile::tempdir().expect("tempdir"); + let runtime = RuntimeBuilder::new(store(root.path())) + .build() + .await + .expect("runtime"); + (root, runtime) +} diff --git a/core/provenance/tera_core/LICENSE-APACHE b/core/provenance/tera_core/LICENSE-APACHE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + +Copyright 2025 Tyson Lupul + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. diff --git a/core/provenance/tera_core/LICENSE-GPL-3.0-only b/core/provenance/tera_core/LICENSE-GPL-3.0-only @@ -0,0 +1,674 @@ + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/> + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Use with the GNU Affero General Public License. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + <one line to give the program's name and a brief idea of what it does.> + Copyright (C) <year> <name of author> + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see <https://www.gnu.org/licenses/>. + +Also add information on how to contact you by electronic and paper mail. + + If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + <program> Copyright (C) <year> <name of author> + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU GPL, see +<https://www.gnu.org/licenses/>. + + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +<https://www.gnu.org/licenses/why-not-lgpl.html>. diff --git a/core/provenance/tera_core/LICENSE-GPL-3.0-or-later b/core/provenance/tera_core/LICENSE-GPL-3.0-or-later @@ -0,0 +1,674 @@ + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/> + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Use with the GNU Affero General Public License. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + <one line to give the program's name and a brief idea of what it does.> + Copyright (C) <year> <name of author> + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see <https://www.gnu.org/licenses/>. + +Also add information on how to contact you by electronic and paper mail. + + If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + <program> Copyright (C) <year> <name of author> + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU GPL, see +<https://www.gnu.org/licenses/>. + + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +<https://www.gnu.org/licenses/why-not-lgpl.html>. diff --git a/core/provenance/tera_core/LICENSE-MIT b/core/provenance/tera_core/LICENSE-MIT @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2025 Tyson Lupul + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE.