commit a64fe38236edd357c9ad4d1b055b1bf6af4bd29e
parent 773bc8afb53eae5efb44506e892f62a89b275fc4
Author: triesap <tyson@radroots.org>
Date: Wed, 9 Sep 2026 22:54:37 +0000
workspace: Retire extracted mobile application packages
- Remove the four transferred application packages and active producer routes
- Preserve package attribution and reject application dependency reintroduction
- Regenerate coherent inventories without changing retained dependency versions
- Verify full coverage, workspace, SDK generators and release preflight
Diffstat:
63 files changed, 521 insertions(+), 26818 deletions(-)
diff --git a/AGENTS.md b/AGENTS.md
@@ -33,9 +33,11 @@ This file exists for compatibility with tools that look for AGENTS.md.
Service-host and service-owned operator contracts must implement or narrow
that boundary without adding a second transport, exit map, or readiness
authority.
-- Source-lock consumer identities include `sdk`, `mobile`, `myc`, and `rhi`.
- Only the first two are generated-artifact product identities;
+- Source-lock consumer identities include `sdk`, `myc`, and `rhi`.
+ Only `sdk` is a generated-artifact product identity;
accepting a service consumer marker must not expose an artifact route.
+ Tera owns its application packages and native/WASM artifact routes. Lib must
+ not regain an application package or a dependency on the Tera application.
- The canonical service source lock is the bounded, canonical
`radroots.service.source-lock.v2.toml` model. It binds the exact active public
Lib repository and full revision, Lib source-archive and workspace-catalog
diff --git a/Cargo.lock b/Cargo.lock
@@ -240,19 +240,6 @@ dependencies = [
]
[[package]]
-name = "async-compat"
-version = "0.2.5"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a1ba85bc55464dcbf728b56d97e119d673f4cf9062be330a9a26f3acf504a590"
-dependencies = [
- "futures-core",
- "futures-io",
- "once_cell",
- "pin-project-lite",
- "tokio",
-]
-
-[[package]]
name = "async-trait"
version = "0.1.91"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -817,15 +804,6 @@ dependencies = [
]
[[package]]
-name = "crossbeam-channel"
-version = "0.5.16"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d85363c37faeca707aef026efa9f3b34d077bce547e48f770770625c6013679e"
-dependencies = [
- "crossbeam-utils",
-]
-
-[[package]]
name = "crossbeam-queue"
version = "0.3.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2396,29 +2374,6 @@ dependencies = [
[[package]]
name = "nostr"
-version = "0.44.1"
-source = "git+https://github.com/rust-nostr/nostr.git?rev=5bba5163eb77107f82c4a8262cf29d7f33a73219#5bba5163eb77107f82c4a8262cf29d7f33a73219"
-dependencies = [
- "base64",
- "bech32",
- "bip39",
- "bitcoin_hashes",
- "cbc",
- "chacha20 0.9.1",
- "chacha20poly1305",
- "getrandom 0.2.17",
- "hex",
- "instant",
- "scrypt",
- "secp256k1",
- "serde",
- "serde_json",
- "unicode-normalization",
- "url",
-]
-
-[[package]]
-name = "nostr"
version = "0.44.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c7d3d987ea7078dc36947cde532637c472a229426702e4331dd7667325378bd9"
@@ -2450,17 +2405,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7462c9d8ae5ef6a28d66a192d399ad2530f1f2130b13186296dbb11bdef5b3d1"
dependencies = [
"lru",
- "nostr 0.44.7",
- "tokio",
-]
-
-[[package]]
-name = "nostr-database"
-version = "0.44.0"
-source = "git+https://github.com/rust-nostr/nostr.git?rev=5bba5163eb77107f82c4a8262cf29d7f33a73219#5bba5163eb77107f82c4a8262cf29d7f33a73219"
-dependencies = [
- "lru",
- "nostr 0.44.1",
+ "nostr",
"tokio",
]
@@ -2470,48 +2415,7 @@ version = "0.44.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ade30de16869618919c6b5efc8258f47b654a98b51541eb77f85e8ec5e3c83a6"
dependencies = [
- "nostr 0.44.7",
-]
-
-[[package]]
-name = "nostr-gossip"
-version = "0.44.0"
-source = "git+https://github.com/rust-nostr/nostr.git?rev=5bba5163eb77107f82c4a8262cf29d7f33a73219#5bba5163eb77107f82c4a8262cf29d7f33a73219"
-dependencies = [
- "nostr 0.44.1",
-]
-
-[[package]]
-name = "nostr-relay-builder"
-version = "0.44.0"
-source = "git+https://github.com/rust-nostr/nostr.git?rev=5bba5163eb77107f82c4a8262cf29d7f33a73219#5bba5163eb77107f82c4a8262cf29d7f33a73219"
-dependencies = [
- "async-utility",
- "async-wsocket",
- "atomic-destructor",
- "hex",
- "negentropy",
- "nostr 0.44.1",
- "nostr-database 0.44.0 (git+https://github.com/rust-nostr/nostr.git?rev=5bba5163eb77107f82c4a8262cf29d7f33a73219)",
- "tokio",
- "tracing",
-]
-
-[[package]]
-name = "nostr-relay-pool"
-version = "0.44.0"
-source = "git+https://github.com/rust-nostr/nostr.git?rev=5bba5163eb77107f82c4a8262cf29d7f33a73219#5bba5163eb77107f82c4a8262cf29d7f33a73219"
-dependencies = [
- "async-utility",
- "async-wsocket",
- "atomic-destructor",
- "hex",
- "lru",
- "negentropy",
- "nostr 0.44.1",
- "nostr-database 0.44.0 (git+https://github.com/rust-nostr/nostr.git?rev=5bba5163eb77107f82c4a8262cf29d7f33a73219)",
- "tokio",
- "tracing",
+ "nostr",
]
[[package]]
@@ -2526,22 +2430,8 @@ dependencies = [
"hex",
"lru",
"negentropy",
- "nostr 0.44.7",
- "nostr-database 0.44.0 (registry+https://github.com/rust-lang/crates.io-index)",
- "tokio",
- "tracing",
-]
-
-[[package]]
-name = "nostr-sdk"
-version = "0.44.0"
-source = "git+https://github.com/rust-nostr/nostr.git?rev=5bba5163eb77107f82c4a8262cf29d7f33a73219#5bba5163eb77107f82c4a8262cf29d7f33a73219"
-dependencies = [
- "async-utility",
- "nostr 0.44.1",
- "nostr-database 0.44.0 (git+https://github.com/rust-nostr/nostr.git?rev=5bba5163eb77107f82c4a8262cf29d7f33a73219)",
- "nostr-gossip 0.44.0 (git+https://github.com/rust-nostr/nostr.git?rev=5bba5163eb77107f82c4a8262cf29d7f33a73219)",
- "nostr-relay-pool 0.44.0",
+ "nostr",
+ "nostr-database",
"tokio",
"tracing",
]
@@ -2553,10 +2443,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "471732576710e779b64f04c55e3f8b5292f865fea228436daf19694f0bf70393"
dependencies = [
"async-utility",
- "nostr 0.44.7",
- "nostr-database 0.44.0 (registry+https://github.com/rust-lang/crates.io-index)",
- "nostr-gossip 0.44.0 (registry+https://github.com/rust-lang/crates.io-index)",
- "nostr-relay-pool 0.44.3",
+ "nostr",
+ "nostr-database",
+ "nostr-gossip",
+ "nostr-relay-pool",
"tokio",
"tracing",
]
@@ -3091,7 +2981,7 @@ name = "radroots_event_codec"
version = "0.1.0-alpha"
dependencies = [
"hex",
- "nostr 0.44.7",
+ "nostr",
"radroots_blossom",
"radroots_core",
"radroots_event",
@@ -3108,7 +2998,7 @@ name = "radroots_event_codec_wasm"
version = "0.1.0-alpha"
dependencies = [
"hex",
- "nostr 0.44.7",
+ "nostr",
"radroots_blossom",
"radroots_core",
"radroots_event",
@@ -3180,84 +3070,11 @@ dependencies = [
]
[[package]]
-name = "radroots_mobile_bindgen"
-version = "0.1.0-alpha"
-dependencies = [
- "uniffi",
-]
-
-[[package]]
-name = "radroots_mobile_core"
-version = "0.1.0-alpha"
-dependencies = [
- "chrono",
- "hex",
- "nostr 0.44.7",
- "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 = "radroots_mobile_ffi"
-version = "0.1.0-alpha"
-dependencies = [
- "async-trait",
- "hex",
- "libc",
- "nostr 0.44.1",
- "nostr-relay-builder",
- "nostr-sdk 0.44.0",
- "radroots_blossom",
- "radroots_event",
- "radroots_mobile_core",
- "radroots_sdk",
- "radroots_signing",
- "radroots_storage",
- "rustix 1.1.4",
- "secp256k1",
- "serde_json",
- "tempfile",
- "thiserror 1.0.69",
- "tokio",
- "tracing",
- "tracing-appender",
- "tracing-subscriber",
- "uniffi",
-]
-
-[[package]]
-name = "radroots_mobile_wasm"
-version = "0.1.0-alpha"
-dependencies = [
- "radroots_mobile_core",
- "serde_json",
- "wasm-bindgen",
-]
-
-[[package]]
name = "radroots_nostr"
version = "0.1.0-alpha"
dependencies = [
"base64",
- "nostr 0.44.7",
+ "nostr",
"radroots_blossom",
"radroots_event",
"radroots_event_codec",
@@ -3274,7 +3091,7 @@ dependencies = [
name = "radroots_nostr_connect"
version = "0.1.0-alpha"
dependencies = [
- "nostr 0.44.7",
+ "nostr",
"radroots_event",
"radroots_identity",
"radroots_nostr",
@@ -3292,7 +3109,7 @@ version = "0.1.0-alpha"
dependencies = [
"futures",
"hex",
- "nostr 0.44.7",
+ "nostr",
"nostrdb",
"radroots_nostr",
"serde_json",
@@ -3361,7 +3178,7 @@ version = "0.1.0-alpha"
dependencies = [
"base64",
"hex",
- "nostr 0.44.7",
+ "nostr",
"radroots_core",
"radroots_event",
"radroots_event_codec",
@@ -3431,7 +3248,7 @@ dependencies = [
name = "radroots_sdk"
version = "0.1.0-alpha"
dependencies = [
- "nostr 0.44.7",
+ "nostr",
"radroots_blossom",
"radroots_core",
"radroots_event",
@@ -3542,7 +3359,7 @@ name = "radroots_signing"
version = "0.1.0-alpha"
dependencies = [
"hex",
- "nostr 0.44.7",
+ "nostr",
"radroots_blossom",
"radroots_event",
"radroots_event_codec",
@@ -3734,8 +3551,8 @@ version = "0.1.0-alpha"
dependencies = [
"async-wsocket",
"futures",
- "nostr-relay-pool 0.44.3",
- "nostr-sdk 0.44.1",
+ "nostr-relay-pool",
+ "nostr-sdk",
"radroots_event_codec",
"radroots_nostr",
"radroots_protocol",
@@ -4410,15 +4227,6 @@ dependencies = [
]
[[package]]
-name = "sharded-slab"
-version = "0.1.7"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6"
-dependencies = [
- "lazy_static",
-]
-
-[[package]]
name = "shlex"
version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -4650,12 +4458,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
[[package]]
-name = "symlink"
-version = "0.1.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a7973cce6668464ea31f176d85b13c7ab3bba2cb3b77a2ed26abd7801688010a"
-
-[[package]]
name = "syn"
version = "2.0.117"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -4771,15 +4573,6 @@ dependencies = [
]
[[package]]
-name = "thread_local"
-version = "1.1.10"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070"
-dependencies = [
- "cfg-if",
-]
-
-[[package]]
name = "time"
version = "0.3.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -5035,19 +4828,6 @@ dependencies = [
]
[[package]]
-name = "tracing-appender"
-version = "0.2.5"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "050686193eb999b4bb3bc2acfa891a13da00f79734704c4b8b4ef1a10b368a3c"
-dependencies = [
- "crossbeam-channel",
- "symlink",
- "thiserror 2.0.18",
- "time",
- "tracing-subscriber",
-]
-
-[[package]]
name = "tracing-attributes"
version = "0.1.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -5065,32 +4845,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
dependencies = [
"once_cell",
- "valuable",
-]
-
-[[package]]
-name = "tracing-log"
-version = "0.2.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3"
-dependencies = [
- "log",
- "once_cell",
- "tracing-core",
-]
-
-[[package]]
-name = "tracing-subscriber"
-version = "0.3.23"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319"
-dependencies = [
- "nu-ansi-term",
- "sharded-slab",
- "smallvec",
- "thread_local",
- "tracing-core",
- "tracing-log",
]
[[package]]
@@ -5212,7 +4966,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f38a9a27529ccff732f8efddb831b65b1e07f7dea3fd4cacd4a35a8c4b253b98"
dependencies = [
"anyhow",
- "async-compat",
"bytes",
"once_cell",
"static_assertions",
@@ -5391,12 +5144,6 @@ dependencies = [
]
[[package]]
-name = "valuable"
-version = "0.1.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
-
-[[package]]
name = "vcpkg"
version = "0.2.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
diff --git a/Cargo.toml b/Cargo.toml
@@ -38,10 +38,6 @@ members = [
"crates/mesh",
"crates/mesh_agent_client",
"crates/mesh_agent_proto",
- "crates/mobile_bindgen",
- "crates/mobile_core",
- "crates/mobile_ffi",
- "crates/mobile_wasm",
"crates/core_bindings",
"crates/event_bindings",
"crates/event_codec_wasm",
@@ -157,10 +153,6 @@ radroots_transport = { package = "radroots_transport", path = "crates/transport"
radroots_mesh = { path = "crates/mesh", version = "=0.1.0-alpha", default-features = false }
radroots_mesh_agent_client = { path = "crates/mesh_agent_client", version = "=0.1.0-alpha", default-features = false }
radroots_mesh_agent_proto = { path = "crates/mesh_agent_proto", version = "=0.1.0-alpha", default-features = false }
-radroots_mobile_bindgen = { path = "crates/mobile_bindgen", version = "=0.1.0-alpha" }
-radroots_mobile_core = { path = "crates/mobile_core", version = "=0.1.0-alpha", default-features = false }
-radroots_mobile_ffi = { path = "crates/mobile_ffi", version = "=0.1.0-alpha" }
-radroots_mobile_wasm = { path = "crates/mobile_wasm", version = "=0.1.0-alpha" }
radroots_simplex_app_store = { path = "crates/simplex_app_store", version = "=0.1.0-alpha", default-features = false }
radroots_sdk = { path = "crates/sdk", version = "=0.1.0-alpha", default-features = false }
radroots_sdk_ffi = { path = "crates/sdk_ffi", version = "=0.1.0-alpha" }
@@ -268,9 +260,7 @@ tokio-tungstenite = { version = "0.26.2", default-features = false, features = [
] }
toml = { version = "0.8" }
tracing = { version = "0.1", default-features = false }
-tracing-appender = { version = "0.2" }
tracing-log = { version = "0.2" }
-tracing-subscriber = { version = "0.3" }
unicode-general-category = { version = "=1.1.0" }
url = { version = "2" }
url_nostd = { package = "url", version = "2", default-features = false }
diff --git a/contracts/architecture/decisions/tera_application_ownership.v1.json b/contracts/architecture/decisions/tera_application_ownership.v1.json
@@ -0,0 +1,222 @@
+{
+ "schema": "radroots.tera.application-ownership.v1",
+ "source_repository": "https://github.com/radrootslabs/lib",
+ "source_catalog_sha256": "deca0c080deae187ff8186c0708903e42f41ea57f77c5f91581e23aa561164a4",
+ "target_repository": "https://github.com/radrootslabs/tera",
+ "target_revision": "2b33144e628bf10e1ddd29829f801ae94cf922b6",
+ "transfer_evidence_sha256": "9b8d3f5c295a899be45f4a15b4711a5828b4c1140d3388f3d63b0c597a2b72b9",
+ "package": [
+ {
+ "former_catalog_entry": {
+ "name": "radroots_mobile_core",
+ "path": "crates/mobile_core",
+ "state": "active",
+ "tier": "runtime",
+ "visibility": "private_runtime",
+ "publish": false,
+ "version": "0.1.0-alpha",
+ "license": "GPL-3.0-or-later",
+ "platforms": [
+ "native",
+ "wasm32"
+ ],
+ "groups": [
+ "coverage_required",
+ "mobile"
+ ],
+ "owners": [
+ "mobile"
+ ],
+ "permitted_dependency_tiers": [
+ "foundation",
+ "domain",
+ "spi",
+ "adapter",
+ "orchestration",
+ "sdk",
+ "facade",
+ "runtime"
+ ],
+ "provenance_kind": "imported",
+ "source_repository": "https://github.com/radrootslabs/app_rt",
+ "source_revision": "7ab1a8624d50890d6d18545ffb47d8083afa8c67",
+ "source_path": "crates/core",
+ "source_tree_sha256": "8ae546ee60a97bfc145c12ea94dcd6d528bf5f49e974159442738c29eb3d6ee1",
+ "compatibility": [
+ "product",
+ "lifecycle",
+ "package_private"
+ ],
+ "replaces": [
+ "radroots_app_core"
+ ]
+ },
+ "target_name": "tera_core",
+ "target_path": "core/crates/tera_core",
+ "projected_commit": "d2f667e0de3b05f68c7735a0733854bd30ba4fe5",
+ "projected_tree": "312c0c4c6b157302ed5944169fefa34671bad3af"
+ },
+ {
+ "former_catalog_entry": {
+ "name": "radroots_mobile_ffi",
+ "path": "crates/mobile_ffi",
+ "state": "active",
+ "tier": "boundary",
+ "visibility": "private_boundary",
+ "publish": false,
+ "version": "0.1.0-alpha",
+ "license": "GPL-3.0-or-later",
+ "platforms": [
+ "apple",
+ "android"
+ ],
+ "groups": [
+ "coverage_required",
+ "mobile",
+ "boundaries"
+ ],
+ "owners": [
+ "mobile"
+ ],
+ "permitted_dependency_tiers": [
+ "foundation",
+ "domain",
+ "spi",
+ "adapter",
+ "orchestration",
+ "sdk",
+ "facade",
+ "runtime",
+ "boundary"
+ ],
+ "provenance_kind": "imported",
+ "source_repository": "https://github.com/radrootslabs/app_rt",
+ "source_revision": "7ab1a8624d50890d6d18545ffb47d8083afa8c67",
+ "source_path": "crates/ffi",
+ "source_tree_sha256": "9a38699ad6d39b9f8764fed6a67bd55908ff54cb2e3cae9a3890ce1a46789b2b",
+ "compatibility": [
+ "ffi",
+ "swift",
+ "kotlin",
+ "lifecycle",
+ "package_private"
+ ],
+ "replaces": [
+ "radroots_app_ffi"
+ ]
+ },
+ "target_name": "tera_ffi",
+ "target_path": "core/crates/tera_ffi",
+ "projected_commit": "eaeffef63efd0f23383c3320fb889bb85451a5ab",
+ "projected_tree": "3d76114aa1a63dce814c38bc2cc97652965608d5"
+ },
+ {
+ "former_catalog_entry": {
+ "name": "radroots_mobile_wasm",
+ "path": "crates/mobile_wasm",
+ "state": "active",
+ "tier": "boundary",
+ "visibility": "private_boundary",
+ "publish": false,
+ "version": "0.1.0-alpha",
+ "license": "GPL-3.0-or-later",
+ "platforms": [
+ "wasm32"
+ ],
+ "groups": [
+ "coverage_required",
+ "mobile",
+ "wasm",
+ "boundaries"
+ ],
+ "owners": [
+ "mobile"
+ ],
+ "permitted_dependency_tiers": [
+ "foundation",
+ "domain",
+ "spi",
+ "adapter",
+ "orchestration",
+ "sdk",
+ "facade",
+ "runtime",
+ "boundary"
+ ],
+ "provenance_kind": "imported",
+ "source_repository": "https://github.com/radrootslabs/app_rt",
+ "source_revision": "7ab1a8624d50890d6d18545ffb47d8083afa8c67",
+ "source_path": "crates/wasm",
+ "source_tree_sha256": "8c65c867eafc0643ca9fd86150766af4f4ab2f0f8321d66306418261a8f43124",
+ "compatibility": [
+ "wasm",
+ "lifecycle",
+ "package_private"
+ ],
+ "replaces": [
+ "radroots_app_wasm"
+ ]
+ },
+ "target_name": "tera_wasm",
+ "target_path": "core/crates/tera_wasm",
+ "projected_commit": "5aa29339dbc51376015090a6c3ad9acf5ce0d3ad",
+ "projected_tree": "b2a3a71ed8d7de20e4a894a1a6c5e7b531c2fe29"
+ },
+ {
+ "former_catalog_entry": {
+ "name": "radroots_mobile_bindgen",
+ "path": "crates/mobile_bindgen",
+ "state": "active",
+ "tier": "codegen",
+ "visibility": "private_codegen",
+ "publish": false,
+ "version": "0.1.0-alpha",
+ "license": "GPL-3.0-or-later",
+ "platforms": [
+ "native"
+ ],
+ "groups": [
+ "coverage_required",
+ "mobile",
+ "boundaries"
+ ],
+ "owners": [
+ "mobile"
+ ],
+ "permitted_dependency_tiers": [
+ "foundation",
+ "domain",
+ "spi",
+ "adapter",
+ "orchestration",
+ "sdk",
+ "facade",
+ "runtime",
+ "boundary",
+ "codegen"
+ ],
+ "provenance_kind": "imported",
+ "source_repository": "https://github.com/radrootslabs/app_rt",
+ "source_revision": "7ab1a8624d50890d6d18545ffb47d8083afa8c67",
+ "source_path": "crates/bindgen",
+ "source_tree_sha256": "f0a9d7ec9794257bc56063117208e6e83a34efe1b852e8af6d6a56a1693d27fa",
+ "compatibility": [
+ "generated",
+ "swift",
+ "kotlin",
+ "package_private"
+ ],
+ "replaces": [
+ "radroots_app_bindgen"
+ ]
+ },
+ "target_name": "tera_bindgen",
+ "target_path": "core/crates/tera_bindgen",
+ "projected_commit": "979f6dab4d47f8dbeae487e948906ea3e2f29984",
+ "projected_tree": "b2b8bedfa87e99907ce98e18e2aff4252dd7958b"
+ }
+ ],
+ "source_catalog_revision": "ec17de580ec174b2c0915d9b915e5cdf9272dfe8",
+ "transfer_donor_commit": "ad17b7d3455a7147cfa303d976fc5c70c3a4c0cb",
+ "transfer_donor_tree": "3d8cdf4b928d37e29a2dbc8aa785e7e4e4938a62"
+}
diff --git a/contracts/coverage.toml b/contracts/coverage.toml
@@ -45,11 +45,6 @@ require_branches = false
temporary = true
reason = "branch coverage is not applicable while the UniFFI facade has no measured branch records"
-[overrides.radroots_mobile_bindgen]
-require_branches = false
-temporary = true
-reason = "branch coverage is not applicable while the coverage-only bindgen entry point has no measured branch records"
-
[overrides.radroots_test_fixtures]
require_branches = false
temporary = true
@@ -71,10 +66,6 @@ crates = [
"radroots_mesh",
"radroots_mesh_agent_client",
"radroots_mesh_agent_proto",
- "radroots_mobile_bindgen",
- "radroots_mobile_core",
- "radroots_mobile_ffi",
- "radroots_mobile_wasm",
"radroots_nostr",
"radroots_nostr_connect",
"radroots_nostrdb",
diff --git a/contracts/crates/catalog.v2.toml b/contracts/crates/catalog.v2.toml
@@ -7,13 +7,17 @@ rust_version = "1.97.1"
edition = "2024"
resolver = "3"
public_package_count = 19
-package_count = 55
+package_count = 51
digest_algorithm = "sha256-raw-bytes-v1"
source_tree_digest_algorithm = "sha256-git-ls-tree-r-v1"
native_introduction_tree_digest_algorithm = "sha256-git-tree-records-z-v1"
source_provenance_policy = "imported_revision_tree_or_native_introduction_tree"
provenance_correction_contract = "approved_correction_record_required"
retired_packages = [
+ "radroots_mobile_bindgen",
+ "radroots_mobile_core",
+ "radroots_mobile_ffi",
+ "radroots_mobile_wasm",
"radroots_app_core",
"radroots_app_ffi",
"radroots_app_wasm",
@@ -1363,127 +1367,3 @@ source_path = "crates/sql_wasm_runtime"
source_tree_sha256 = "6ca1dca6f248c50b6bcd4c684b4ad31acd17b8bfc36a8b0072872f6db927c303"
compatibility = ["wasm", "data", "package_private"]
replaces = []
-
-[[package]]
-name = "radroots_mobile_core"
-path = "crates/mobile_core"
-state = "active"
-tier = "runtime"
-visibility = "private_runtime"
-publish = false
-version = "0.1.0-alpha"
-license = "GPL-3.0-or-later"
-platforms = ["native", "wasm32"]
-groups = ["coverage_required", "mobile"]
-owners = ["mobile"]
-permitted_dependency_tiers = [
- "foundation",
- "domain",
- "spi",
- "adapter",
- "orchestration",
- "sdk",
- "facade",
- "runtime",
-]
-provenance_kind = "imported"
-source_repository = "https://github.com/radrootslabs/app_rt"
-source_revision = "7ab1a8624d50890d6d18545ffb47d8083afa8c67"
-source_path = "crates/core"
-source_tree_sha256 = "8ae546ee60a97bfc145c12ea94dcd6d528bf5f49e974159442738c29eb3d6ee1"
-compatibility = ["product", "lifecycle", "package_private"]
-replaces = ["radroots_app_core"]
-
-[[package]]
-name = "radroots_mobile_ffi"
-path = "crates/mobile_ffi"
-state = "active"
-tier = "boundary"
-visibility = "private_boundary"
-publish = false
-version = "0.1.0-alpha"
-license = "GPL-3.0-or-later"
-platforms = ["apple", "android"]
-groups = ["coverage_required", "mobile", "boundaries"]
-owners = ["mobile"]
-permitted_dependency_tiers = [
- "foundation",
- "domain",
- "spi",
- "adapter",
- "orchestration",
- "sdk",
- "facade",
- "runtime",
- "boundary",
-]
-provenance_kind = "imported"
-source_repository = "https://github.com/radrootslabs/app_rt"
-source_revision = "7ab1a8624d50890d6d18545ffb47d8083afa8c67"
-source_path = "crates/ffi"
-source_tree_sha256 = "9a38699ad6d39b9f8764fed6a67bd55908ff54cb2e3cae9a3890ce1a46789b2b"
-compatibility = ["ffi", "swift", "kotlin", "lifecycle", "package_private"]
-replaces = ["radroots_app_ffi"]
-
-[[package]]
-name = "radroots_mobile_wasm"
-path = "crates/mobile_wasm"
-state = "active"
-tier = "boundary"
-visibility = "private_boundary"
-publish = false
-version = "0.1.0-alpha"
-license = "GPL-3.0-or-later"
-platforms = ["wasm32"]
-groups = ["coverage_required", "mobile", "wasm", "boundaries"]
-owners = ["mobile"]
-permitted_dependency_tiers = [
- "foundation",
- "domain",
- "spi",
- "adapter",
- "orchestration",
- "sdk",
- "facade",
- "runtime",
- "boundary",
-]
-provenance_kind = "imported"
-source_repository = "https://github.com/radrootslabs/app_rt"
-source_revision = "7ab1a8624d50890d6d18545ffb47d8083afa8c67"
-source_path = "crates/wasm"
-source_tree_sha256 = "8c65c867eafc0643ca9fd86150766af4f4ab2f0f8321d66306418261a8f43124"
-compatibility = ["wasm", "lifecycle", "package_private"]
-replaces = ["radroots_app_wasm"]
-
-[[package]]
-name = "radroots_mobile_bindgen"
-path = "crates/mobile_bindgen"
-state = "active"
-tier = "codegen"
-visibility = "private_codegen"
-publish = false
-version = "0.1.0-alpha"
-license = "GPL-3.0-or-later"
-platforms = ["native"]
-groups = ["coverage_required", "mobile", "boundaries"]
-owners = ["mobile"]
-permitted_dependency_tiers = [
- "foundation",
- "domain",
- "spi",
- "adapter",
- "orchestration",
- "sdk",
- "facade",
- "runtime",
- "boundary",
- "codegen",
-]
-provenance_kind = "imported"
-source_repository = "https://github.com/radrootslabs/app_rt"
-source_revision = "7ab1a8624d50890d6d18545ffb47d8083afa8c67"
-source_path = "crates/bindgen"
-source_tree_sha256 = "f0a9d7ec9794257bc56063117208e6e83a34efe1b852e8af6d6a56a1693d27fa"
-compatibility = ["generated", "swift", "kotlin", "package_private"]
-replaces = ["radroots_app_bindgen"]
diff --git a/contracts/crates/generated/package_groups.v1.toml b/contracts/crates/generated/package_groups.v1.toml
@@ -1,22 +1,16 @@
schema = "radroots.workspace.package-groups.v1"
-catalog_sha256 = "deca0c080deae187ff8186c0708903e42f41ea57f77c5f91581e23aa561164a4"
+catalog_sha256 = "ee295f2352e2577a4052d980624415aec9871197d4fc9910a4c21c83a9179200"
[[group]]
id = "boundaries"
-packages = ["radroots_core_bindings", "radroots_event_bindings", "radroots_event_codec_wasm", "radroots_identity_bindings", "radroots_mobile_bindgen", "radroots_mobile_ffi", "radroots_mobile_wasm", "radroots_replica_schema_bindings", "radroots_replica_store_wasm", "radroots_replica_sync_wasm", "radroots_sdk_ffi", "radroots_trade_bindings"]
-active_packages = ["radroots_core_bindings", "radroots_event_bindings", "radroots_event_codec_wasm", "radroots_identity_bindings", "radroots_mobile_bindgen", "radroots_mobile_ffi", "radroots_mobile_wasm", "radroots_replica_schema_bindings", "radroots_replica_store_wasm", "radroots_replica_sync_wasm", "radroots_sdk_ffi", "radroots_trade_bindings"]
+packages = ["radroots_core_bindings", "radroots_event_bindings", "radroots_event_codec_wasm", "radroots_identity_bindings", "radroots_replica_schema_bindings", "radroots_replica_store_wasm", "radroots_replica_sync_wasm", "radroots_sdk_ffi", "radroots_trade_bindings"]
+active_packages = ["radroots_core_bindings", "radroots_event_bindings", "radroots_event_codec_wasm", "radroots_identity_bindings", "radroots_replica_schema_bindings", "radroots_replica_store_wasm", "radroots_replica_sync_wasm", "radroots_sdk_ffi", "radroots_trade_bindings"]
reserved_packages = []
[[group]]
id = "coverage_required"
-packages = ["radroots", "radroots_blossom", "radroots_core", "radroots_core_bindings", "radroots_event", "radroots_event_bindings", "radroots_event_codec", "radroots_event_codec_wasm", "radroots_geonames", "radroots_identity", "radroots_identity_bindings", "radroots_mesh", "radroots_mesh_agent_client", "radroots_mesh_agent_proto", "radroots_mobile_bindgen", "radroots_mobile_core", "radroots_mobile_ffi", "radroots_mobile_wasm", "radroots_nostr", "radroots_nostr_connect", "radroots_nostrdb", "radroots_protocol", "radroots_replica_schema", "radroots_replica_schema_bindings", "radroots_replica_store", "radroots_replica_store_wasm", "radroots_replica_sync", "radroots_replica_sync_wasm", "radroots_runtime_distribution", "radroots_runtime_manager", "radroots_runtime_paths", "radroots_sdk", "radroots_sdk_ffi", "radroots_sdk_sql_wasm_runtime", "radroots_secrets", "radroots_service_host", "radroots_service_sqlite", "radroots_signing", "radroots_sql_core", "radroots_storage", "radroots_storage_sqlite", "radroots_sync", "radroots_test_fixtures", "radroots_trade", "radroots_trade_bindings", "radroots_transport", "radroots_transport_nostr", "radroots_transport_reticulum", "xtask"]
-active_packages = ["radroots", "radroots_blossom", "radroots_core", "radroots_core_bindings", "radroots_event", "radroots_event_bindings", "radroots_event_codec", "radroots_event_codec_wasm", "radroots_geonames", "radroots_identity", "radroots_identity_bindings", "radroots_mesh", "radroots_mesh_agent_client", "radroots_mesh_agent_proto", "radroots_mobile_bindgen", "radroots_mobile_core", "radroots_mobile_ffi", "radroots_mobile_wasm", "radroots_nostr", "radroots_nostr_connect", "radroots_nostrdb", "radroots_protocol", "radroots_replica_schema", "radroots_replica_schema_bindings", "radroots_replica_store", "radroots_replica_store_wasm", "radroots_replica_sync", "radroots_replica_sync_wasm", "radroots_runtime_distribution", "radroots_runtime_manager", "radroots_runtime_paths", "radroots_sdk", "radroots_sdk_ffi", "radroots_sdk_sql_wasm_runtime", "radroots_secrets", "radroots_service_host", "radroots_service_sqlite", "radroots_signing", "radroots_sql_core", "radroots_storage", "radroots_storage_sqlite", "radroots_sync", "radroots_test_fixtures", "radroots_trade", "radroots_trade_bindings", "radroots_transport", "radroots_transport_nostr", "radroots_transport_reticulum", "xtask"]
-reserved_packages = []
-
-[[group]]
-id = "mobile"
-packages = ["radroots_mobile_bindgen", "radroots_mobile_core", "radroots_mobile_ffi", "radroots_mobile_wasm"]
-active_packages = ["radroots_mobile_bindgen", "radroots_mobile_core", "radroots_mobile_ffi", "radroots_mobile_wasm"]
+packages = ["radroots", "radroots_blossom", "radroots_core", "radroots_core_bindings", "radroots_event", "radroots_event_bindings", "radroots_event_codec", "radroots_event_codec_wasm", "radroots_geonames", "radroots_identity", "radroots_identity_bindings", "radroots_mesh", "radroots_mesh_agent_client", "radroots_mesh_agent_proto", "radroots_nostr", "radroots_nostr_connect", "radroots_nostrdb", "radroots_protocol", "radroots_replica_schema", "radroots_replica_schema_bindings", "radroots_replica_store", "radroots_replica_store_wasm", "radroots_replica_sync", "radroots_replica_sync_wasm", "radroots_runtime_distribution", "radroots_runtime_manager", "radroots_runtime_paths", "radroots_sdk", "radroots_sdk_ffi", "radroots_sdk_sql_wasm_runtime", "radroots_secrets", "radroots_service_host", "radroots_service_sqlite", "radroots_signing", "radroots_sql_core", "radroots_storage", "radroots_storage_sqlite", "radroots_sync", "radroots_test_fixtures", "radroots_trade", "radroots_trade_bindings", "radroots_transport", "radroots_transport_nostr", "radroots_transport_reticulum", "xtask"]
+active_packages = ["radroots", "radroots_blossom", "radroots_core", "radroots_core_bindings", "radroots_event", "radroots_event_bindings", "radroots_event_codec", "radroots_event_codec_wasm", "radroots_geonames", "radroots_identity", "radroots_identity_bindings", "radroots_mesh", "radroots_mesh_agent_client", "radroots_mesh_agent_proto", "radroots_nostr", "radroots_nostr_connect", "radroots_nostrdb", "radroots_protocol", "radroots_replica_schema", "radroots_replica_schema_bindings", "radroots_replica_store", "radroots_replica_store_wasm", "radroots_replica_sync", "radroots_replica_sync_wasm", "radroots_runtime_distribution", "radroots_runtime_manager", "radroots_runtime_paths", "radroots_sdk", "radroots_sdk_ffi", "radroots_sdk_sql_wasm_runtime", "radroots_secrets", "radroots_service_host", "radroots_service_sqlite", "radroots_signing", "radroots_sql_core", "radroots_storage", "radroots_storage_sqlite", "radroots_sync", "radroots_test_fixtures", "radroots_trade", "radroots_trade_bindings", "radroots_transport", "radroots_transport_nostr", "radroots_transport_reticulum", "xtask"]
reserved_packages = []
[[group]]
@@ -51,6 +45,6 @@ reserved_packages = []
[[group]]
id = "wasm"
-packages = ["radroots_event_codec_wasm", "radroots_mobile_wasm", "radroots_replica_store_wasm", "radroots_replica_sync_wasm", "radroots_sdk_sql_wasm_runtime", "radroots_sql_core"]
-active_packages = ["radroots_event_codec_wasm", "radroots_mobile_wasm", "radroots_replica_store_wasm", "radroots_replica_sync_wasm", "radroots_sdk_sql_wasm_runtime", "radroots_sql_core"]
+packages = ["radroots_event_codec_wasm", "radroots_replica_store_wasm", "radroots_replica_sync_wasm", "radroots_sdk_sql_wasm_runtime", "radroots_sql_core"]
+active_packages = ["radroots_event_codec_wasm", "radroots_replica_store_wasm", "radroots_replica_sync_wasm", "radroots_sdk_sql_wasm_runtime", "radroots_sql_core"]
reserved_packages = []
diff --git a/contracts/crates/generated/platform_inventory.v1.toml b/contracts/crates/generated/platform_inventory.v1.toml
@@ -1,22 +1,14 @@
schema = "radroots.workspace.platform-inventory.v1"
-catalog_sha256 = "deca0c080deae187ff8186c0708903e42f41ea57f77c5f91581e23aa561164a4"
-
-[[platform]]
-id = "android"
-packages = ["radroots_mobile_ffi"]
+catalog_sha256 = "ee295f2352e2577a4052d980624415aec9871197d4fc9910a4c21c83a9179200"
[[platform]]
id = "any"
packages = ["radroots", "radroots_blossom", "radroots_core", "radroots_event", "radroots_event_codec", "radroots_identity", "radroots_mesh_agent_proto", "radroots_nostr", "radroots_nostr_connect", "radroots_protocol", "radroots_replica_schema", "radroots_sdk", "radroots_signing", "radroots_simplex_agent_proto", "radroots_simplex_chat_proto", "radroots_simplex_smp_crypto", "radroots_simplex_smp_proto", "radroots_test_fixtures", "radroots_trade", "radroots_transport"]
[[platform]]
-id = "apple"
-packages = ["radroots_mobile_ffi"]
-
-[[platform]]
id = "native"
-packages = ["radroots_core_bindings", "radroots_event_bindings", "radroots_geonames", "radroots_identity_bindings", "radroots_mesh", "radroots_mesh_agent_client", "radroots_mobile_bindgen", "radroots_mobile_core", "radroots_nostrdb", "radroots_replica_schema_bindings", "radroots_replica_store", "radroots_replica_sync", "radroots_runtime_distribution", "radroots_runtime_manager", "radroots_runtime_paths", "radroots_sdk_ffi", "radroots_secrets", "radroots_service_host", "radroots_service_sqlite", "radroots_simplex_app_store", "radroots_simplex_smp_transport", "radroots_sql_core", "radroots_storage", "radroots_storage_sqlite", "radroots_sync", "radroots_trade_bindings", "radroots_transport_nostr", "radroots_transport_reticulum", "xtask"]
+packages = ["radroots_core_bindings", "radroots_event_bindings", "radroots_geonames", "radroots_identity_bindings", "radroots_mesh", "radroots_mesh_agent_client", "radroots_nostrdb", "radroots_replica_schema_bindings", "radroots_replica_store", "radroots_replica_sync", "radroots_runtime_distribution", "radroots_runtime_manager", "radroots_runtime_paths", "radroots_sdk_ffi", "radroots_secrets", "radroots_service_host", "radroots_service_sqlite", "radroots_simplex_app_store", "radroots_simplex_smp_transport", "radroots_sql_core", "radroots_storage", "radroots_storage_sqlite", "radroots_sync", "radroots_trade_bindings", "radroots_transport_nostr", "radroots_transport_reticulum", "xtask"]
[[platform]]
id = "wasm32"
-packages = ["radroots_event_codec_wasm", "radroots_mobile_core", "radroots_mobile_wasm", "radroots_replica_store_wasm", "radroots_replica_sync_wasm", "radroots_sdk_sql_wasm_runtime", "radroots_sql_core"]
+packages = ["radroots_event_codec_wasm", "radroots_replica_store_wasm", "radroots_replica_sync_wasm", "radroots_sdk_sql_wasm_runtime", "radroots_sql_core"]
diff --git a/contracts/crates/generated/release_inventory.v2.toml b/contracts/crates/generated/release_inventory.v2.toml
@@ -1,7 +1,7 @@
schema = "radroots.workspace.release-inventory.v2"
-catalog_sha256 = "deca0c080deae187ff8186c0708903e42f41ea57f77c5f91581e23aa561164a4"
+catalog_sha256 = "ee295f2352e2577a4052d980624415aec9871197d4fc9910a4c21c83a9179200"
architecture = "radroots.crates.release.v2"
version = "0.1.0-alpha"
public_packages = ["radroots", "radroots_blossom", "radroots_core", "radroots_event", "radroots_event_codec", "radroots_geonames", "radroots_identity", "radroots_nostr", "radroots_nostr_connect", "radroots_protocol", "radroots_sdk", "radroots_secrets", "radroots_signing", "radroots_storage", "radroots_storage_sqlite", "radroots_sync", "radroots_trade", "radroots_transport", "radroots_transport_nostr"]
-private_packages = ["radroots_core_bindings", "radroots_event_bindings", "radroots_event_codec_wasm", "radroots_identity_bindings", "radroots_mesh", "radroots_mesh_agent_client", "radroots_mesh_agent_proto", "radroots_mobile_bindgen", "radroots_mobile_core", "radroots_mobile_ffi", "radroots_mobile_wasm", "radroots_nostrdb", "radroots_replica_schema", "radroots_replica_schema_bindings", "radroots_replica_store", "radroots_replica_store_wasm", "radroots_replica_sync", "radroots_replica_sync_wasm", "radroots_runtime_distribution", "radroots_runtime_manager", "radroots_runtime_paths", "radroots_sdk_ffi", "radroots_sdk_sql_wasm_runtime", "radroots_service_host", "radroots_service_sqlite", "radroots_simplex_agent_proto", "radroots_simplex_app_store", "radroots_simplex_chat_proto", "radroots_simplex_smp_crypto", "radroots_simplex_smp_proto", "radroots_simplex_smp_transport", "radroots_sql_core", "radroots_test_fixtures", "radroots_trade_bindings", "radroots_transport_reticulum", "xtask"]
+private_packages = ["radroots_core_bindings", "radroots_event_bindings", "radroots_event_codec_wasm", "radroots_identity_bindings", "radroots_mesh", "radroots_mesh_agent_client", "radroots_mesh_agent_proto", "radroots_nostrdb", "radroots_replica_schema", "radroots_replica_schema_bindings", "radroots_replica_store", "radroots_replica_store_wasm", "radroots_replica_sync", "radroots_replica_sync_wasm", "radroots_runtime_distribution", "radroots_runtime_manager", "radroots_runtime_paths", "radroots_sdk_ffi", "radroots_sdk_sql_wasm_runtime", "radroots_service_host", "radroots_service_sqlite", "radroots_simplex_agent_proto", "radroots_simplex_app_store", "radroots_simplex_chat_proto", "radroots_simplex_smp_crypto", "radroots_simplex_smp_proto", "radroots_simplex_smp_transport", "radroots_sql_core", "radroots_test_fixtures", "radroots_trade_bindings", "radroots_transport_reticulum", "xtask"]
reserved_packages = []
diff --git a/contracts/releases/publish_policy.toml b/contracts/releases/publish_policy.toml
@@ -52,7 +52,6 @@ external_packages = []
[workspace_classification]
private = [
- "radroots_mobile_core",
"radroots_runtime_distribution",
"radroots_runtime_manager",
"radroots_runtime_paths",
@@ -66,9 +65,6 @@ build_codegen = [
"radroots_event_bindings",
"radroots_event_codec_wasm",
"radroots_identity_bindings",
- "radroots_mobile_bindgen",
- "radroots_mobile_ffi",
- "radroots_mobile_wasm",
"radroots_replica_schema_bindings",
"radroots_replica_store_wasm",
"radroots_replica_sync_wasm",
diff --git a/crates/mobile_bindgen/Cargo.toml b/crates/mobile_bindgen/Cargo.toml
@@ -1,23 +0,0 @@
-[package]
-name = "radroots_mobile_bindgen"
-description = "Private UniFFI binding generator for Radroots mobile artifacts"
-version = "0.1.0-alpha"
-edition.workspace = true
-authors = ["Radroots Authors"]
-rust-version.workspace = true
-license = "GPL-3.0-or-later"
-repository.workspace = true
-homepage.workspace = true
-readme.workspace = true
-publish = false
-include = ["src/**", "Cargo.toml"]
-
-[[bin]]
-name = "radroots-mobile-bindgen"
-path = "src/main.rs"
-
-[lints.rust]
-unexpected_cfgs = { level = "warn", check-cfg = ['cfg(coverage_nightly)'] }
-
-[dependencies]
-uniffi = { workspace = true, features = ["cli"] }
diff --git a/crates/mobile_bindgen/src/main.rs b/crates/mobile_bindgen/src/main.rs
@@ -1,19 +0,0 @@
-fn main() {
- run_bindgen();
-}
-
-#[cfg(not(coverage_nightly))]
-fn run_bindgen() {
- uniffi::uniffi_bindgen_main()
-}
-
-#[cfg(coverage_nightly)]
-fn run_bindgen() {}
-
-#[cfg(all(test, coverage_nightly))]
-mod tests {
- #[test]
- fn main_is_callable_in_coverage_builds() {
- super::main();
- }
-}
diff --git a/crates/mobile_core/Cargo.toml b/crates/mobile_core/Cargo.toml
@@ -1,78 +0,0 @@
-[package]
-name = "radroots_mobile_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/crates/mobile_core/build.rs b/crates/mobile_core/build.rs
@@ -1,59 +0,0 @@
-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/crates/mobile_core/src/error.rs b/crates/mobile_core/src/error.rs
@@ -1,187 +0,0 @@
-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/crates/mobile_core/src/lib.rs b/crates/mobile_core/src/lib.rs
@@ -1,12 +0,0 @@
-// 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/crates/mobile_core/src/provenance.rs b/crates/mobile_core/src/provenance.rs
@@ -1,26 +0,0 @@
-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/crates/mobile_core/src/runtime/app_info.rs b/crates/mobile_core/src/runtime/app_info.rs
@@ -1,26 +0,0 @@
-#[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/crates/mobile_core/src/runtime/builder.rs b/crates/mobile_core/src/runtime/builder.rs
@@ -1,250 +0,0 @@
-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/crates/mobile_core/src/runtime/info.rs b/crates/mobile_core/src/runtime/info.rs
@@ -1,79 +0,0 @@
-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/crates/mobile_core/src/runtime/mod.rs b/crates/mobile_core/src/runtime/mod.rs
@@ -1,223 +0,0 @@
-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/crates/mobile_core/src/runtime/product_surface.rs b/crates/mobile_core/src/runtime/product_surface.rs
@@ -1,154 +0,0 @@
-//! 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/crates/mobile_core/src/runtime/product_surface/authoring.rs b/crates/mobile_core/src/runtime/product_surface/authoring.rs
@@ -1,310 +0,0 @@
-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/crates/mobile_core/src/runtime/product_surface/context.rs b/crates/mobile_core/src/runtime/product_surface/context.rs
@@ -1,466 +0,0 @@
-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/crates/mobile_core/src/runtime/product_surface/cursor.rs b/crates/mobile_core/src/runtime/product_surface/cursor.rs
@@ -1,457 +0,0 @@
-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/crates/mobile_core/src/runtime/product_surface/identity.rs b/crates/mobile_core/src/runtime/product_surface/identity.rs
@@ -1,185 +0,0 @@
-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/crates/mobile_core/src/runtime/product_surface/media.rs b/crates/mobile_core/src/runtime/product_surface/media.rs
@@ -1,2172 +0,0 @@
-//! 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/crates/mobile_core/src/runtime/product_surface/model.rs b/crates/mobile_core/src/runtime/product_surface/model.rs
@@ -1,251 +0,0 @@
-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/crates/mobile_core/src/runtime/product_surface/outbox.rs b/crates/mobile_core/src/runtime/product_surface/outbox.rs
@@ -1,4402 +0,0 @@
-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/crates/mobile_core/src/runtime/product_surface/projection.rs b/crates/mobile_core/src/runtime/product_surface/projection.rs
@@ -1,646 +0,0 @@
-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/crates/mobile_core/src/runtime/product_surface/ranking.rs b/crates/mobile_core/src/runtime/product_surface/ranking.rs
@@ -1,259 +0,0 @@
-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/crates/mobile_core/src/runtime/product_surface/settings.rs b/crates/mobile_core/src/runtime/product_surface/settings.rs
@@ -1,1669 +0,0 @@
-//! 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/crates/mobile_core/src/runtime/product_surface/today.rs b/crates/mobile_core/src/runtime/product_surface/today.rs
@@ -1,3776 +0,0 @@
-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, ¤t.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(¬e);
- 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/crates/mobile_core/src/runtime/sdk.rs b/crates/mobile_core/src/runtime/sdk.rs
@@ -1,532 +0,0 @@
-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/crates/mobile_core/src/runtime/store.rs b/crates/mobile_core/src/runtime/store.rs
@@ -1,257 +0,0 @@
-//! 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/crates/mobile_core/tests/durable_runtime.rs b/crates/mobile_core/tests/durable_runtime.rs
@@ -1,111 +0,0 @@
-use radroots_mobile_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/crates/mobile_core/tests/package_boundary.rs b/crates/mobile_core/tests/package_boundary.rs
@@ -1,101 +0,0 @@
-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/crates/mobile_core/tests/sdk_runtime.rs b/crates/mobile_core/tests/sdk_runtime.rs
@@ -1,55 +0,0 @@
-use std::sync::Arc;
-
-use radroots_mobile_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/crates/mobile_core/tests/support/mod.rs b/crates/mobile_core/tests/support/mod.rs
@@ -1,33 +0,0 @@
-use radroots_mobile_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/crates/mobile_ffi/Cargo.toml b/crates/mobile_ffi/Cargo.toml
@@ -1,54 +0,0 @@
-[package]
-name = "radroots_mobile_ffi"
-description = "Native FFI bindings for Radroots mobile apps"
-version = "0.1.0-alpha"
-edition.workspace = true
-authors = ["Radroots Authors"]
-rust-version.workspace = true
-license = "GPL-3.0-or-later"
-repository.workspace = true
-homepage.workspace = true
-readme.workspace = true
-publish = false
-include = ["src/**", "uniffi.toml", "Cargo.toml"]
-
-[lib]
-crate-type = ["rlib", "staticlib", "cdylib"]
-
-[lints.rust]
-unexpected_cfgs = { level = "warn", check-cfg = ['cfg(coverage_nightly)'] }
-
-[dependencies]
-async-trait = { workspace = true }
-hex = { workspace = true }
-radroots_blossom = { workspace = true, features = ["std"] }
-radroots_event = { workspace = true, features = ["serde", "std"] }
-radroots_mobile_core = { workspace = true, features = ["mobile-social"] }
-radroots_sdk = { workspace = true, features = ["blossom"] }
-radroots_signing = { workspace = true, features = ["serde", "std"] }
-radroots_storage = { workspace = true }
-serde_json = { workspace = true, features = ["std"] }
-tracing = { workspace = true }
-tracing-appender = { workspace = true }
-tracing-subscriber = { workspace = true }
-thiserror = { workspace = true }
-uniffi = { workspace = true, features = ["tokio"] }
-
-[target.'cfg(unix)'.dependencies]
-libc = { workspace = true }
-rustix = { workspace = true }
-
-[dev-dependencies]
-nostr = { git = "https://github.com/rust-nostr/nostr.git", rev = "5bba5163eb77107f82c4a8262cf29d7f33a73219", package = "nostr" }
-nostr-relay-builder = { git = "https://github.com/rust-nostr/nostr.git", rev = "5bba5163eb77107f82c4a8262cf29d7f33a73219", package = "nostr-relay-builder" }
-nostr-sdk = { git = "https://github.com/rust-nostr/nostr.git", rev = "5bba5163eb77107f82c4a8262cf29d7f33a73219", package = "nostr-sdk" }
-secp256k1 = { workspace = true }
-tempfile = { workspace = true }
-tokio = { workspace = true, features = [
- "io-util",
- "macros",
- "net",
- "rt-multi-thread",
- "sync",
- "time",
-] }
diff --git a/crates/mobile_ffi/src/dto.rs b/crates/mobile_ffi/src/dto.rs
@@ -1,2999 +0,0 @@
-//! Focused, versioned value types owned by the native boundary.
-
-#[cfg(unix)]
-use std::os::fd::{FromRawFd, OwnedFd, RawFd};
-#[cfg(unix)]
-use std::os::unix::fs::FileExt;
-
-use radroots_blossom::{BlobDescriptor, MediaType, Sha256};
-use radroots_event::{
- calendar::{AuthoredCalendarDateEvent, AuthoredCalendarTimeEvent, CalendarDate},
- food::availability::{
- FoodAvailabilityDetails, FoodAvailabilityDetailsParts, FoodAvailabilityImage,
- FoodAvailabilityStatus, FoodContent, FoodCurrency, FoodIdentifier, FoodImageDimensions,
- FoodPrice, FoodPublishedAt, FoodQuantity, FoodText, FoodUnit,
- },
- media::AuthoredImage,
- post::{AuthoredPostImage, PostImageDimensions},
-};
-use radroots_mobile_core::runtime::{
- app_info::AppInfoPlatform,
- info::{AppInfo, RuntimeBuildInfo, RuntimeInfo},
- product_surface::{
- AddCommandType, CardLifecycleState, CreateAsk, CreateEvent, CreateFoodAvailability,
- CreatePhotoUpdate, CreateUpdate, LocalNetwork, LocalNetworkRelayPolicy, MeSnapshot,
- MediaReference, Phase1AddCommand, Phase1CancellationPolicy, Phase1DraftEventTiming,
- Phase1DraftFormSnapshot, Phase1DraftKind, Phase1DraftMediaSnapshot, Phase1DraftStatus,
- Phase1InboundMediaState, Phase1MediaPrerequisite, Phase1MediaStage, Phase1OutboxState,
- Phase1QueuePolicy, Phase1RelaySatisfaction, Phase1UploadIntent, ProfileSummary,
- SearchResult, SearchResultType, SupportingProfile, ThreadEntry, TodayCard, TodayCardType,
- TodayPage, TodayProjectionUpdate, TodayRefreshReceipt, TodayRelaySyncState,
- TodaySyncReceipt,
- },
- sdk::{
- SdkBlossomConfigurationRecord, SdkBlossomEvidenceRecord, SdkCapabilityRecord,
- SdkRelayAccessRecord, SdkRelayStatusRecord, SdkRelayStatusReportRecord, SdkShutdownRecord,
- SdkStorageStatusRecord,
- },
-};
-
-use crate::RadrootsAppError;
-
-pub const MOBILE_FFI_SCHEMA_VERSION: u16 = 1;
-const MEDIA_FILE_MAX_BYTES: u64 = 10 * 1024 * 1024;
-const MEDIA_REFERENCE_MAX_BYTES: usize = 256;
-
-/// Final four-state trade-evidence coverage vocabulary.
-#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)]
-pub enum FfiTradeEvidenceCoverage {
- Missing,
- Partial,
- ScopeSatisfied,
- Unsupported,
-}
-
-/// Final three-state trade-evidence outcome vocabulary.
-#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)]
-pub enum FfiTradeEvidenceOutcome {
- Valid,
- Invalid,
- Indeterminate,
-}
-
-/// Secret-free projection of one canonical evidence manifest.
-#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)]
-pub struct FfiTradeEvidenceManifestRecord {
- pub schema_version: u16,
- pub contract_id: String,
- pub contract_version: u16,
- pub trade_id: String,
- pub trade_generation: String,
- pub observed_at_unix_s: String,
- pub coverage: FfiTradeEvidenceCoverage,
- pub evidence_policy_digest: String,
- pub manifest_digest: String,
- pub canonical_bytes_hex: String,
-}
-
-/// Secret-free projection of one canonical RHI evidence report.
-#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)]
-pub struct FfiRhiEvidenceReportRecord {
- pub schema_version: u16,
- pub contract_id: String,
- pub contract_version: u16,
- pub issuer_pubkey: String,
- pub trade_id: String,
- pub claim_mutation_id: String,
- pub outcome: FfiTradeEvidenceOutcome,
- pub reason_codes: Vec<String>,
- pub projection_digest: String,
- pub evidence_manifest_digest: String,
- pub evidence_policy_digest: String,
- pub observed_at_unix_s: String,
- pub trade_generation: String,
- pub statement_digest: String,
- pub supersedes_report_id: Option<String>,
- pub supersedes_event_id: Option<String>,
- pub canonical_content: String,
-}
-
-/// Unsigned typed event plan ready for host-owned signing.
-#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)]
-pub struct FfiTypedEvidenceEventPlanRecord {
- pub schema_version: u16,
- pub contract_id: String,
- pub kind: u32,
- pub author_pubkey: String,
- pub created_at_unix_s: String,
- pub expected_event_id: String,
- pub tags: Vec<Vec<String>>,
- pub content: String,
-}
-
-/// Signed NIP-01 event input for verified attestation admission.
-#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)]
-pub struct FfiSignedEvidenceEventRecord {
- pub id: String,
- pub author_pubkey: String,
- pub created_at_unix_s: u64,
- pub kind: u32,
- pub tags: Vec<Vec<String>>,
- pub content: String,
- pub signature: String,
-}
-
-/// Secret-free supersession projection from one verified attestation.
-#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)]
-pub struct FfiRhiEvidenceAttestationSupersessionRecord {
- pub report_id: String,
- pub event_id: String,
-}
-
-/// Verified final RHI evidence attestation.
-#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)]
-pub struct FfiRhiEvidenceAttestationRecord {
- pub schema_version: u16,
- pub issuer_pubkey: String,
- pub trade_id: String,
- pub claim_mutation_id: String,
- pub outcome: FfiTradeEvidenceOutcome,
- pub observed_at_unix_s: String,
- pub trade_generation: String,
- pub statement_digest: String,
- pub supersession: Option<FfiRhiEvidenceAttestationSupersessionRecord>,
- pub canonical_content: String,
-}
-
-#[uniffi::export]
-pub fn parse_trade_evidence_manifest(
- canonical_bytes: Vec<u8>,
-) -> Result<FfiTradeEvidenceManifestRecord, RadrootsAppError> {
- let manifest = radroots_sdk::trade::parse_evidence_manifest(&canonical_bytes)
- .map_err(|_| RadrootsAppError::invalid_argument("invalid_evidence_manifest"))?;
- Ok(FfiTradeEvidenceManifestRecord {
- schema_version: MOBILE_FFI_SCHEMA_VERSION,
- contract_id: manifest.contract_id().to_owned(),
- contract_version: manifest.contract_version(),
- trade_id: manifest.trade_id().to_string(),
- trade_generation: manifest.trade_generation().get().to_string(),
- observed_at_unix_s: manifest.observed_at_unix_s().to_string(),
- coverage: manifest.coverage().into(),
- evidence_policy_digest: manifest.evidence_policy_digest().to_hex(),
- manifest_digest: manifest.digest().to_hex(),
- canonical_bytes_hex: hex::encode(manifest.canonical_bytes()),
- })
-}
-
-#[uniffi::export]
-pub fn parse_rhi_evidence_report(
- canonical_content: String,
-) -> Result<FfiRhiEvidenceReportRecord, RadrootsAppError> {
- let report = radroots_sdk::trade::parse_rhi_evidence_report(canonical_content.as_bytes())
- .map_err(|_| RadrootsAppError::invalid_argument("invalid_evidence_report"))?;
- let supersession = report.supersession();
- Ok(FfiRhiEvidenceReportRecord {
- schema_version: MOBILE_FFI_SCHEMA_VERSION,
- contract_id: report.contract_id().to_owned(),
- contract_version: report.contract_version(),
- issuer_pubkey: report.issuer_public_key().to_hex(),
- trade_id: report.trade_id().to_string(),
- claim_mutation_id: report.claim_mutation_id().to_string(),
- outcome: report.outcome().into(),
- reason_codes: report
- .reason_codes()
- .iter()
- .map(|code| code.as_str().to_owned())
- .collect(),
- projection_digest: report.projection_digest().to_hex(),
- evidence_manifest_digest: report.evidence_manifest_digest().to_hex(),
- evidence_policy_digest: report.evidence_policy_digest().to_hex(),
- observed_at_unix_s: report.observed_at_unix_s().to_string(),
- trade_generation: report.trade_generation().get().to_string(),
- statement_digest: report.statement_digest().to_hex(),
- supersedes_report_id: supersession.map(|value| value.report_id().to_hex()),
- supersedes_event_id: supersession.map(|value| value.event_id().to_hex()),
- canonical_content: report.canonical_content().to_owned(),
- })
-}
-
-#[uniffi::export]
-pub fn prepare_rhi_evidence_attestation(
- canonical_content: String,
- created_at_unix_s: u64,
-) -> Result<FfiTypedEvidenceEventPlanRecord, RadrootsAppError> {
- let report = radroots_sdk::trade::parse_rhi_evidence_report(canonical_content.as_bytes())
- .map_err(|_| RadrootsAppError::invalid_argument("invalid_evidence_report"))?;
- let plan = radroots_sdk::trade::prepare_rhi_evidence_attestation(&report, created_at_unix_s)
- .map_err(|_| RadrootsAppError::invalid_argument("invalid_evidence_attestation_plan"))?;
- Ok(FfiTypedEvidenceEventPlanRecord {
- schema_version: MOBILE_FFI_SCHEMA_VERSION,
- contract_id: plan.body().contract().contract_id().as_str().to_owned(),
- kind: plan.body().kind(),
- author_pubkey: plan.author().to_hex(),
- created_at_unix_s: plan.created_at().to_string(),
- expected_event_id: plan.expected_event_id().to_hex(),
- tags: plan.body().tags().to_vec(),
- content: plan.body().content().to_owned(),
- })
-}
-
-#[uniffi::export]
-pub fn validate_rhi_evidence_attestation(
- event: FfiSignedEvidenceEventRecord,
-) -> Result<FfiRhiEvidenceAttestationRecord, RadrootsAppError> {
- let event = radroots_event::envelope::EventEnvelope::new(
- radroots_event::envelope::EventEnvelopeParts {
- id: event.id,
- author: event.author_pubkey,
- created_at: event.created_at_unix_s,
- kind: event.kind,
- tags: event.tags,
- content: event.content,
- sig: event.signature,
- },
- )
- .map_err(|_| RadrootsAppError::invalid_argument("invalid_signed_event"))?;
- let attestation = radroots_sdk::trade::validate_rhi_evidence_attestation(event).map_err(
- |error| match error {
- radroots_sdk::trade::EvidenceAttestationValidationError::Signature => {
- RadrootsAppError::invalid_argument("invalid_event_signature")
- }
- radroots_sdk::trade::EvidenceAttestationValidationError::Contract => {
- RadrootsAppError::invalid_argument("invalid_evidence_attestation")
- }
- },
- )?;
- Ok(FfiRhiEvidenceAttestationRecord {
- schema_version: MOBILE_FFI_SCHEMA_VERSION,
- issuer_pubkey: attestation.issuer().to_hex(),
- trade_id: attestation.trade_id().to_string(),
- claim_mutation_id: attestation.claim_mutation_id().to_string(),
- outcome: match attestation.outcome() {
- radroots_sdk::trade::RadrootsRhiEvidenceAttestationOutcomeV1::Valid => {
- FfiTradeEvidenceOutcome::Valid
- }
- radroots_sdk::trade::RadrootsRhiEvidenceAttestationOutcomeV1::Invalid => {
- FfiTradeEvidenceOutcome::Invalid
- }
- radroots_sdk::trade::RadrootsRhiEvidenceAttestationOutcomeV1::Indeterminate => {
- FfiTradeEvidenceOutcome::Indeterminate
- }
- },
- observed_at_unix_s: attestation.observed_at_unix_s().to_string(),
- trade_generation: attestation.trade_generation().get().to_string(),
- statement_digest: hex::encode(attestation.statement_digest()),
- supersession: attestation.supersession().map(|value| {
- FfiRhiEvidenceAttestationSupersessionRecord {
- report_id: hex::encode(value.report_id()),
- event_id: value.event_id().to_hex(),
- }
- }),
- canonical_content: attestation.canonical_content().to_owned(),
- })
-}
-
-impl From<radroots_sdk::trade::RadrootsTradeEvidenceCoverageV1> for FfiTradeEvidenceCoverage {
- fn from(value: radroots_sdk::trade::RadrootsTradeEvidenceCoverageV1) -> Self {
- match value {
- radroots_sdk::trade::RadrootsTradeEvidenceCoverageV1::Missing => Self::Missing,
- radroots_sdk::trade::RadrootsTradeEvidenceCoverageV1::Partial => Self::Partial,
- radroots_sdk::trade::RadrootsTradeEvidenceCoverageV1::ScopeSatisfied => {
- Self::ScopeSatisfied
- }
- radroots_sdk::trade::RadrootsTradeEvidenceCoverageV1::Unsupported => Self::Unsupported,
- }
- }
-}
-
-impl From<radroots_sdk::trade::RadrootsTradeEvidenceOutcomeV1> for FfiTradeEvidenceOutcome {
- fn from(value: radroots_sdk::trade::RadrootsTradeEvidenceOutcomeV1) -> Self {
- match value {
- radroots_sdk::trade::RadrootsTradeEvidenceOutcomeV1::Valid => Self::Valid,
- radroots_sdk::trade::RadrootsTradeEvidenceOutcomeV1::Invalid => Self::Invalid,
- radroots_sdk::trade::RadrootsTradeEvidenceOutcomeV1::Indeterminate => {
- Self::Indeterminate
- }
- }
- }
-}
-
-#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)]
-pub struct FfiBuildInfoRecord {
- pub schema_version: u16,
- 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>,
-}
-
-// These exhaustive field-for-field adapters are verified by the generated API
-// snapshot and Swift compilation. Excluding the mechanical projection glue
-// keeps the coverage gate focused on validation and behavior.
-#[cfg_attr(coverage_nightly, coverage(off))]
-impl From<RuntimeBuildInfo> for FfiBuildInfoRecord {
- fn from(value: RuntimeBuildInfo) -> Self {
- Self {
- schema_version: MOBILE_FFI_SCHEMA_VERSION,
- crate_name: value.crate_name,
- crate_version: value.crate_version,
- rustc: value.rustc,
- profile: value.profile,
- lib_revision: value.lib_revision,
- consumer_revision: value.consumer_revision,
- build_time_unix: value.build_time_unix,
- }
- }
-}
-
-#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)]
-pub struct FfiAppPlatformRecord {
- pub schema_version: u16,
- pub platform: Option<String>,
- pub bundle_id: Option<String>,
- pub version: Option<String>,
- pub build_number: Option<String>,
- pub build_sha: Option<String>,
-}
-
-#[cfg_attr(coverage_nightly, coverage(off))]
-impl From<AppInfoPlatform> for FfiAppPlatformRecord {
- fn from(value: AppInfoPlatform) -> Self {
- Self {
- schema_version: MOBILE_FFI_SCHEMA_VERSION,
- platform: value.platform,
- bundle_id: value.bundle_id,
- version: value.version,
- build_number: value.build_number,
- build_sha: value.build_sha,
- }
- }
-}
-
-#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)]
-pub struct FfiAppInfoRecord {
- pub schema_version: u16,
- pub build: FfiBuildInfoRecord,
- pub started_unix_ms: i64,
- pub uptime_millis: i64,
- pub shutting_down: bool,
- pub platform: Option<FfiAppPlatformRecord>,
-}
-
-#[cfg_attr(coverage_nightly, coverage(off))]
-impl From<AppInfo> for FfiAppInfoRecord {
- fn from(value: AppInfo) -> Self {
- Self {
- schema_version: MOBILE_FFI_SCHEMA_VERSION,
- build: value.build.into(),
- started_unix_ms: value.started_unix_ms,
- uptime_millis: value.uptime_millis,
- shutting_down: value.shutting_down,
- platform: value.platform.map(Into::into),
- }
- }
-}
-
-#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)]
-pub struct FfiRuntimeInfoRecord {
- pub schema_version: u16,
- pub app: FfiAppInfoRecord,
- pub sdk: FfiBuildInfoRecord,
- pub sdk_closed: bool,
-}
-
-#[cfg_attr(coverage_nightly, coverage(off))]
-impl From<RuntimeInfo> for FfiRuntimeInfoRecord {
- fn from(value: RuntimeInfo) -> Self {
- Self {
- schema_version: MOBILE_FFI_SCHEMA_VERSION,
- app: value.app.into(),
- sdk: value.sdk.into(),
- sdk_closed: value.sdk_closed,
- }
- }
-}
-
-#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)]
-pub struct FfiIdentityStatusRecord {
- pub schema_version: u16,
- pub public_key: String,
- pub host_signer_configured: bool,
-}
-
-#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)]
-pub enum FfiTodayCardType {
- Update,
- PhotoUpdate,
- Ask,
- Event,
- FoodAvailability,
-}
-
-#[cfg_attr(coverage_nightly, coverage(off))]
-impl From<TodayCardType> for FfiTodayCardType {
- fn from(value: TodayCardType) -> Self {
- match value {
- TodayCardType::Update => Self::Update,
- TodayCardType::PhotoUpdate => Self::PhotoUpdate,
- TodayCardType::Ask => Self::Ask,
- TodayCardType::Event => Self::Event,
- TodayCardType::FoodAvailability => Self::FoodAvailability,
- }
- }
-}
-
-#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)]
-pub enum FfiAddCommandType {
- CreateUpdate,
- CreatePhotoUpdate,
- CreateAsk,
- CreateEvent,
- CreateFoodAvailability,
-}
-
-#[cfg_attr(coverage_nightly, coverage(off))]
-impl From<AddCommandType> for FfiAddCommandType {
- fn from(value: AddCommandType) -> Self {
- match value {
- AddCommandType::CreateUpdate => Self::CreateUpdate,
- AddCommandType::CreatePhotoUpdate => Self::CreatePhotoUpdate,
- AddCommandType::CreateAsk => Self::CreateAsk,
- AddCommandType::CreateEvent => Self::CreateEvent,
- AddCommandType::CreateFoodAvailability => Self::CreateFoodAvailability,
- }
- }
-}
-
-#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)]
-pub struct FfiCardAddParityRecord {
- pub schema_version: u16,
- pub card_type: FfiTodayCardType,
- pub command_type: FfiAddCommandType,
-}
-
-#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)]
-pub struct FfiLocalNetworkRecord {
- pub schema_version: u16,
- pub id: String,
- pub label: String,
- pub relay_urls: Vec<String>,
- pub locality: Option<String>,
- pub followed_authors: Vec<String>,
- pub generation: u64,
-}
-
-impl TryFrom<FfiLocalNetworkRecord> for LocalNetwork {
- type Error = RadrootsAppError;
-
- fn try_from(value: FfiLocalNetworkRecord) -> Result<Self, Self::Error> {
- value.try_into_with_relay_policy(LocalNetworkRelayPolicy::Public)
- }
-}
-
-impl FfiLocalNetworkRecord {
- pub(crate) fn try_into_with_relay_policy(
- self,
- relay_policy: LocalNetworkRelayPolicy,
- ) -> Result<LocalNetwork, RadrootsAppError> {
- require_schema(self.schema_version)?;
- LocalNetwork::new_for_relay_policy(
- self.id,
- self.label,
- self.relay_urls,
- self.locality,
- self.followed_authors,
- self.generation,
- relay_policy,
- )
- .map_err(|_| RadrootsAppError::invalid_argument("invalid_local_network"))
- }
-}
-
-#[cfg_attr(coverage_nightly, coverage(off))]
-impl From<LocalNetwork> for FfiLocalNetworkRecord {
- fn from(value: LocalNetwork) -> Self {
- Self {
- schema_version: MOBILE_FFI_SCHEMA_VERSION,
- id: value.id,
- label: value.label,
- relay_urls: value.relay_urls,
- locality: value.locality,
- followed_authors: value.followed_authors,
- generation: value.generation,
- }
- }
-}
-
-#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)]
-pub enum FfiMediaVerificationState {
- Pending,
- Verified,
- Failed,
- Unavailable,
-}
-
-#[cfg_attr(coverage_nightly, coverage(off))]
-impl From<&Phase1InboundMediaState> for FfiMediaVerificationState {
- fn from(value: &Phase1InboundMediaState) -> Self {
- match value {
- Phase1InboundMediaState::Pending(_) => Self::Pending,
- Phase1InboundMediaState::Verified(_) => Self::Verified,
- Phase1InboundMediaState::Failed(_) => Self::Failed,
- Phase1InboundMediaState::Unavailable => Self::Unavailable,
- }
- }
-}
-
-#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)]
-pub struct FfiMediaReferenceRecord {
- pub schema_version: u16,
- pub reference_fingerprint: String,
- pub url: String,
- pub sha256: Option<String>,
- pub media_type: Option<String>,
- pub width: Option<u32>,
- pub height: Option<u32>,
- pub byte_size: Option<u64>,
- pub alt: Option<String>,
- pub verification: FfiMediaVerificationState,
-}
-
-#[cfg_attr(coverage_nightly, coverage(off))]
-impl From<MediaReference> for FfiMediaReferenceRecord {
- fn from(value: MediaReference) -> Self {
- let structural = value.structural();
- Self {
- schema_version: MOBILE_FFI_SCHEMA_VERSION,
- reference_fingerprint: hex::encode(structural.fingerprint()),
- url: structural.source_url().to_owned(),
- sha256: structural.expected_sha256().map(str::to_owned),
- media_type: structural.expected_media_type().map(str::to_owned),
- width: structural.expected_width(),
- height: structural.expected_height(),
- byte_size: structural.expected_byte_size(),
- alt: structural.alt().map(str::to_owned),
- verification: value.retrieval().into(),
- }
- }
-}
-
-#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)]
-pub struct FfiProfileRecord {
- pub schema_version: u16,
- pub author_public_key: String,
- pub name: Option<String>,
- pub display_name: Option<String>,
- pub about: Option<String>,
- pub picture: Option<FfiMediaReferenceRecord>,
- pub banner: Option<FfiMediaReferenceRecord>,
- pub nip05: Option<String>,
- pub website: Option<String>,
- pub lightning_address: Option<String>,
-}
-
-#[cfg_attr(coverage_nightly, coverage(off))]
-impl From<ProfileSummary> for FfiProfileRecord {
- fn from(value: ProfileSummary) -> Self {
- Self {
- schema_version: MOBILE_FFI_SCHEMA_VERSION,
- author_public_key: value.author_pubkey,
- name: value.name,
- display_name: value.display_name,
- about: value.about,
- picture: value.picture.map(Into::into),
- banner: value.banner.map(Into::into),
- nip05: value.nip05,
- website: value.website,
- lightning_address: value.lightning_address,
- }
- }
-}
-
-#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)]
-pub enum FfiThreadProfile {
- Profile,
- Reply,
- Comment,
- Deletion,
-}
-
-#[cfg_attr(coverage_nightly, coverage(off))]
-impl From<SupportingProfile> for FfiThreadProfile {
- fn from(value: SupportingProfile) -> Self {
- match value {
- SupportingProfile::Profile => Self::Profile,
- SupportingProfile::Reply => Self::Reply,
- SupportingProfile::Comment => Self::Comment,
- SupportingProfile::Deletion => Self::Deletion,
- }
- }
-}
-
-#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)]
-pub struct FfiThreadEntryRecord {
- pub schema_version: u16,
- pub event_id: String,
- pub author_public_key: String,
- pub content: String,
- pub authored_at_unix_s: u64,
- pub profile: FfiThreadProfile,
- pub root: String,
- pub parent_event_id: String,
- pub author_profile: Option<FfiProfileRecord>,
-}
-
-#[cfg_attr(coverage_nightly, coverage(off))]
-impl From<ThreadEntry> for FfiThreadEntryRecord {
- fn from(value: ThreadEntry) -> Self {
- Self {
- schema_version: MOBILE_FFI_SCHEMA_VERSION,
- event_id: value.event_id,
- author_public_key: value.author_pubkey,
- content: value.content,
- authored_at_unix_s: value.authored_at,
- profile: value.reference.profile.into(),
- root: value.reference.root,
- parent_event_id: value.reference.parent_event_id,
- author_profile: value.author_profile.map(Into::into),
- }
- }
-}
-
-#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)]
-pub enum FfiCardLifecycleState {
- Active,
- Sold,
- Past,
-}
-
-#[cfg_attr(coverage_nightly, coverage(off))]
-impl From<CardLifecycleState> for FfiCardLifecycleState {
- fn from(value: CardLifecycleState) -> Self {
- match value {
- CardLifecycleState::Active => Self::Active,
- CardLifecycleState::Sold => Self::Sold,
- CardLifecycleState::Past => Self::Past,
- }
- }
-}
-
-#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)]
-pub struct FfiTodayCardRecord {
- pub schema_version: u16,
- pub card_id: String,
- pub card_type: FfiTodayCardType,
- pub source_event_id: String,
- pub source_address: Option<String>,
- pub author_public_key: String,
- pub contract_id: String,
- pub title: Option<String>,
- pub content: String,
- pub authored_at_unix_s: u64,
- pub effective_at_unix_s: u64,
- pub event_start_unix_s: Option<u64>,
- pub event_end_unix_s: Option<u64>,
- pub location: Option<String>,
- pub price_amount: Option<String>,
- pub price_currency: Option<String>,
- pub price_unit: Option<String>,
- pub quantity: Option<String>,
- pub food_summary: Option<String>,
- pub food_published_at_unix_s: Option<u64>,
- pub food_status: Option<String>,
- pub context_rank: u8,
- pub inclusion_reason: String,
- pub media: Vec<FfiMediaReferenceRecord>,
- pub lifecycle: FfiCardLifecycleState,
- pub rank_digest: Option<String>,
- pub author_profile: Option<FfiProfileRecord>,
- pub thread: Vec<FfiThreadEntryRecord>,
- pub local_operation_id: Option<String>,
- pub local_operation_state: Option<String>,
-}
-
-#[cfg_attr(coverage_nightly, coverage(off))]
-impl From<TodayCard> for FfiTodayCardRecord {
- fn from(value: TodayCard) -> Self {
- let card = value.card;
- Self {
- schema_version: MOBILE_FFI_SCHEMA_VERSION,
- card_id: card.card_id.to_hex(),
- card_type: card.card_type.into(),
- source_event_id: card.source_event_id,
- source_address: card.source_address,
- author_public_key: card.author_pubkey,
- contract_id: card.contract_id,
- title: card.title,
- content: card.content,
- authored_at_unix_s: card.authored_at,
- effective_at_unix_s: card.effective_at,
- event_start_unix_s: card.event_start,
- event_end_unix_s: card.event_end,
- location: card.location,
- price_amount: card.price_amount,
- price_currency: card.price_currency,
- price_unit: card.price_unit,
- quantity: card.quantity,
- food_summary: card.food_summary,
- food_published_at_unix_s: card.food_published_at,
- food_status: card.food_status,
- context_rank: card.context_rank.value(),
- inclusion_reason: card.inclusion_reason,
- media: card.media.into_iter().map(Into::into).collect(),
- lifecycle: card.lifecycle.into(),
- rank_digest: card.rank.map(|rank| rank.digest_hex()),
- author_profile: value.author_profile.map(Into::into),
- thread: value.thread.into_iter().map(Into::into).collect(),
- local_operation_id: value
- .local_overlay
- .as_ref()
- .map(|overlay| overlay.operation_id.clone()),
- local_operation_state: value.local_overlay.map(|overlay| overlay.state),
- }
- }
-}
-
-#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)]
-pub struct FfiTodayPageRecord {
- pub schema_version: u16,
- pub as_of_unix_s: u64,
- pub items: Vec<FfiTodayCardRecord>,
- pub next_cursor: Option<String>,
-}
-
-#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)]
-pub enum FfiTodayProjectionUpdate {
- Incremental,
- Rebuild,
-}
-
-#[cfg_attr(coverage_nightly, coverage(off))]
-impl From<FfiTodayProjectionUpdate> for TodayProjectionUpdate {
- fn from(value: FfiTodayProjectionUpdate) -> Self {
- match value {
- FfiTodayProjectionUpdate::Incremental => Self::Incremental,
- FfiTodayProjectionUpdate::Rebuild => Self::Rebuild,
- }
- }
-}
-
-#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)]
-pub struct FfiTodayRefreshRecord {
- pub schema_version: u16,
- pub update: FfiTodayProjectionUpdate,
- pub source_events: u64,
- pub visible_cards: u64,
- pub profiles: u64,
- pub thread_entries: u64,
- pub content_generation: u64,
- pub changed: bool,
-}
-
-#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)]
-pub enum FfiTodayRelaySyncState {
- Complete,
- Partial,
- Offline,
-}
-
-#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)]
-pub struct FfiTodaySyncRecord {
- pub schema_version: u16,
- pub relay_state: FfiTodayRelaySyncState,
- pub pages_fetched: u16,
- pub events_observed: u64,
- pub events_admitted: u64,
- pub events_rejected: u64,
- pub projection: FfiTodayRefreshRecord,
-}
-
-#[cfg_attr(coverage_nightly, coverage(off))]
-impl From<TodaySyncReceipt> for FfiTodaySyncRecord {
- fn from(value: TodaySyncReceipt) -> Self {
- Self {
- schema_version: MOBILE_FFI_SCHEMA_VERSION,
- relay_state: match value.relay_state {
- TodayRelaySyncState::Complete => FfiTodayRelaySyncState::Complete,
- TodayRelaySyncState::Partial => FfiTodayRelaySyncState::Partial,
- TodayRelaySyncState::Offline => FfiTodayRelaySyncState::Offline,
- },
- pages_fetched: value.pages_fetched,
- events_observed: value.events_observed,
- events_admitted: value.events_admitted,
- events_rejected: value.events_rejected,
- projection: value.projection.into(),
- }
- }
-}
-
-#[cfg_attr(coverage_nightly, coverage(off))]
-impl From<TodayRefreshReceipt> for FfiTodayRefreshRecord {
- fn from(value: TodayRefreshReceipt) -> Self {
- Self {
- schema_version: MOBILE_FFI_SCHEMA_VERSION,
- update: match value.update {
- TodayProjectionUpdate::Incremental => FfiTodayProjectionUpdate::Incremental,
- TodayProjectionUpdate::Rebuild => FfiTodayProjectionUpdate::Rebuild,
- },
- source_events: value.source_events,
- visible_cards: value.visible_cards,
- profiles: value.profiles,
- thread_entries: value.thread_entries,
- content_generation: value.content_generation,
- changed: value.changed,
- }
- }
-}
-
-#[cfg_attr(coverage_nightly, coverage(off))]
-impl From<TodayPage> for FfiTodayPageRecord {
- fn from(value: TodayPage) -> Self {
- Self {
- schema_version: MOBILE_FFI_SCHEMA_VERSION,
- as_of_unix_s: value.as_of,
- items: value.items.into_iter().map(Into::into).collect(),
- next_cursor: value.next_cursor,
- }
- }
-}
-
-#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)]
-pub enum FfiSearchResultType {
- Card,
- Profile,
-}
-
-#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)]
-pub struct FfiSearchResultRecord {
- pub schema_version: u16,
- pub result_type: FfiSearchResultType,
- pub stable_id: String,
- pub card: Option<FfiTodayCardRecord>,
- pub profile: Option<FfiProfileRecord>,
-}
-
-#[cfg_attr(coverage_nightly, coverage(off))]
-impl From<SearchResult> for FfiSearchResultRecord {
- fn from(value: SearchResult) -> Self {
- Self {
- schema_version: MOBILE_FFI_SCHEMA_VERSION,
- result_type: match value.result_type {
- SearchResultType::Card => FfiSearchResultType::Card,
- SearchResultType::Profile => FfiSearchResultType::Profile,
- },
- stable_id: value.stable_id,
- card: value.card.map(Into::into),
- profile: value.profile.map(Into::into),
- }
- }
-}
-
-#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)]
-pub struct FfiMeRecord {
- pub schema_version: u16,
- pub public_key: String,
- pub profile: Option<FfiProfileRecord>,
- pub cards: Vec<FfiTodayCardRecord>,
-}
-
-#[cfg_attr(coverage_nightly, coverage(off))]
-impl From<MeSnapshot> for FfiMeRecord {
- fn from(value: MeSnapshot) -> Self {
- Self {
- schema_version: MOBILE_FFI_SCHEMA_VERSION,
- public_key: value.public_key,
- profile: value.profile.map(Into::into),
- cards: value.cards.into_iter().map(Into::into).collect(),
- }
- }
-}
-
-#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)]
-pub enum FfiAddFieldKind {
- Text,
- MultilineText,
- Date,
- DateTime,
- Decimal,
- Choice,
- Location,
- Media,
-}
-
-#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)]
-pub struct FfiAddFieldRecord {
- pub schema_version: u16,
- pub id: String,
- pub label: String,
- pub kind: FfiAddFieldKind,
- pub required: bool,
- pub choices: Vec<String>,
- pub max_bytes: Option<u64>,
- pub max_items: Option<u16>,
-}
-
-#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)]
-pub struct FfiAddSchemaRecord {
- pub schema_version: u16,
- pub command_type: FfiAddCommandType,
- pub label: String,
- pub fields: Vec<FfiAddFieldRecord>,
-}
-
-pub fn add_schemas() -> Vec<FfiAddSchemaRecord> {
- use FfiAddCommandType as Command;
- use FfiAddFieldKind as Kind;
-
- let field =
- |id: &str, label: &str, kind, required, choices: &[&str], max_bytes| FfiAddFieldRecord {
- schema_version: MOBILE_FFI_SCHEMA_VERSION,
- id: id.to_owned(),
- label: label.to_owned(),
- kind,
- required,
- choices: choices.iter().map(|value| (*value).to_owned()).collect(),
- max_bytes,
- max_items: None,
- };
- let media_field = |label: &str, required: bool, max_items| FfiAddFieldRecord {
- max_items: Some(max_items),
- ..field(
- "media",
- label,
- Kind::Media,
- required,
- &[],
- Some(MEDIA_FILE_MAX_BYTES),
- )
- };
- vec![
- FfiAddSchemaRecord {
- schema_version: MOBILE_FFI_SCHEMA_VERSION,
- command_type: Command::CreateUpdate,
- label: "Update".to_owned(),
- fields: vec![field(
- "content",
- "Update",
- Kind::MultilineText,
- true,
- &[],
- Some(65_535),
- )],
- },
- FfiAddSchemaRecord {
- schema_version: MOBILE_FFI_SCHEMA_VERSION,
- command_type: Command::CreatePhotoUpdate,
- label: "Photo update".to_owned(),
- fields: vec![
- field(
- "content",
- "Update",
- Kind::MultilineText,
- true,
- &[],
- Some(65_535),
- ),
- media_field("Photos", true, 20),
- ],
- },
- FfiAddSchemaRecord {
- schema_version: MOBILE_FFI_SCHEMA_VERSION,
- command_type: Command::CreateAsk,
- label: "Ask".to_owned(),
- fields: vec![
- field(
- "content",
- "Question",
- Kind::MultilineText,
- true,
- &[],
- Some(65_535),
- ),
- media_field("Photos", false, 20),
- ],
- },
- FfiAddSchemaRecord {
- schema_version: MOBILE_FFI_SCHEMA_VERSION,
- command_type: Command::CreateEvent,
- label: "Event".to_owned(),
- fields: vec![
- field("identifier", "Identifier", Kind::Text, true, &[], Some(256)),
- field("title", "Title", Kind::Text, true, &[], Some(256)),
- field(
- "content",
- "Description",
- Kind::MultilineText,
- false,
- &[],
- Some(65_535),
- ),
- field("event_start", "Starts", Kind::DateTime, true, &[], None),
- field("event_end", "Ends", Kind::DateTime, false, &[], None),
- field(
- "location",
- "Location",
- Kind::Location,
- false,
- &[],
- Some(256),
- ),
- media_field("Photo", false, 1),
- ],
- },
- FfiAddSchemaRecord {
- schema_version: MOBILE_FFI_SCHEMA_VERSION,
- command_type: Command::CreateFoodAvailability,
- label: "Food availability".to_owned(),
- fields: vec![
- field("identifier", "Identifier", Kind::Text, true, &[], Some(256)),
- field("title", "Food", Kind::Text, true, &[], Some(256)),
- field("summary", "Summary", Kind::Text, true, &[], Some(256)),
- field(
- "content",
- "Details",
- Kind::MultilineText,
- true,
- &[],
- Some(65_535),
- ),
- field(
- "location",
- "Pickup location",
- Kind::Location,
- true,
- &[],
- Some(256),
- ),
- field("price_amount", "Price", Kind::Decimal, true, &[], Some(64)),
- field("currency", "Currency", Kind::Choice, true, &[], Some(3)),
- field(
- "unit",
- "Unit",
- Kind::Choice,
- true,
- &[
- "g", "kg", "lb", "oz", "each", "dozen", "bunch", "punnet", "bag", "basket",
- ],
- None,
- ),
- field(
- "quantity",
- "Available quantity",
- Kind::Decimal,
- false,
- &[],
- Some(64),
- ),
- media_field("Photos", false, 20),
- ],
- },
- ]
-}
-
-#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)]
-pub enum FfiEventTimingKind {
- AllDay,
- Timed,
-}
-
-#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)]
-pub struct FfiPreparedMediaInput {
- pub schema_version: u16,
- pub opaque_reference: String,
- pub file_descriptor: u64,
- 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,
-}
-
-#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)]
-pub struct FfiAddDraftInput {
- pub schema_version: u16,
- pub command_type: FfiAddCommandType,
- pub content: String,
- pub identifier: Option<String>,
- pub title: Option<String>,
- pub summary: Option<String>,
- pub location: Option<String>,
- pub event_timing: Option<FfiEventTimingKind>,
- 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>,
- pub food_published_at_unix_s: Option<u64>,
- pub food_status: Option<String>,
- pub media: Vec<FfiPreparedMediaInput>,
-}
-
-#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)]
-pub struct FfiRetractionDraftInput {
- pub schema_version: u16,
- pub command_type: FfiAddCommandType,
- pub target_card_id: String,
- pub target_event_id: String,
- pub target_kind: u32,
- pub target_address: Option<String>,
- pub reason: String,
-}
-
-#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)]
-pub struct FfiBlossomUploadInput {
- pub schema_version: u16,
- pub draft_id: String,
- pub expected_revision: u64,
- pub media: FfiPreparedMediaInput,
- pub authorization_content: String,
- pub authorization_created_at_unix_s: u64,
- pub authorization_lifetime_seconds: u64,
- pub operation_id: String,
- pub artifact_id: String,
- pub signing_deadline_unix_ms: u64,
- pub signing_cancellation: FfiCancellationPolicy,
- pub verified_at_unix_ms: u64,
- pub updated_at_unix_ms: u64,
-}
-
-/// Minimal host input for a Rust-planned exact-byte upload attempt.
-#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)]
-pub struct FfiBlossomUploadIntent {
- pub schema_version: u16,
- pub draft_id: String,
- pub expected_revision: u64,
- pub media: FfiPreparedMediaInput,
-}
-
-/// Secret-bearing native job. Hosts may use the authorization header for the
-/// immediate OS request but must not persist it in application metadata.
-#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)]
-pub struct FfiNativeUploadJobRecord {
- pub schema_version: u16,
- pub operation_id: String,
- pub draft: FfiDraftStatusRecord,
- pub remote_url: String,
- pub authorization_header: String,
- pub expected_sha256: String,
- pub media_type: String,
- pub byte_size: u64,
-}
-
-#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)]
-pub struct FfiNativeUploadCompletionInput {
- pub schema_version: u16,
- pub draft_id: String,
- pub expected_revision: u64,
- pub media: FfiPreparedMediaInput,
- pub status_code: u16,
- pub response_media_type: Option<String>,
- pub response_content_encoding: Option<String>,
- pub response_body: Vec<u8>,
-}
-
-impl FfiAddDraftInput {
- pub(crate) fn command_and_media(
- self,
- authored_at_unix_s: u64,
- blossom: Option<&radroots_sdk::transport::BlossomSlot>,
- ) -> Result<(Phase1AddCommand, Vec<Phase1MediaPrerequisite>), RadrootsAppError> {
- self.command_media_and_form(authored_at_unix_s, blossom)
- .map(|(command, media, _)| (command, media))
- }
-
- pub(crate) fn command_media_and_form(
- self,
- authored_at_unix_s: u64,
- blossom: Option<&radroots_sdk::transport::BlossomSlot>,
- ) -> Result<
- (
- Phase1AddCommand,
- Vec<Phase1MediaPrerequisite>,
- Phase1DraftFormSnapshot,
- ),
- RadrootsAppError,
- > {
- require_schema(self.schema_version)?;
- if authored_at_unix_s == 0 || self.media.len() > 20 {
- return Err(RadrootsAppError::invalid_argument("invalid_add_draft"));
- }
- let prepared = self
- .media
- .iter()
- .cloned()
- .map(PreparedMedia::try_from)
- .map(|media| {
- media.and_then(|media| {
- let blossom = blossom.ok_or_else(|| {
- RadrootsAppError::invalid_argument("blossom_not_configured")
- })?;
- media.bind(blossom)
- })
- })
- .collect::<Result<Vec<BoundPreparedMedia>, _>>()?;
- let prerequisites = prepared
- .iter()
- .map(|value| {
- Phase1MediaPrerequisite::new(
- value.media.opaque_reference.clone(),
- &value.descriptor,
- )
- })
- .collect::<Result<Vec<_>, _>>()
- .map_err(|_| RadrootsAppError::invalid_argument("invalid_media_reference"))?;
- let post_images = prepared
- .iter()
- .map(BoundPreparedMedia::post_image)
- .collect::<Result<Vec<_>, _>>()?;
- let form = self.form_snapshot(&prepared);
- let command = match self.command_type {
- FfiAddCommandType::CreateUpdate => {
- reject_media(&prepared)?;
- Phase1AddCommand::CreateUpdate(
- CreateUpdate::new(self.content)
- .map_err(|_| RadrootsAppError::invalid_argument("invalid_update"))?,
- )
- }
- FfiAddCommandType::CreatePhotoUpdate => Phase1AddCommand::CreatePhotoUpdate(
- CreatePhotoUpdate::new(
- content_with_media_references(self.content, &prepared)?,
- post_images,
- )
- .map_err(|_| RadrootsAppError::invalid_argument("invalid_photo_update"))?,
- ),
- FfiAddCommandType::CreateAsk => Phase1AddCommand::CreateAsk(
- CreateAsk::new(
- content_with_media_references(self.content, &prepared)?,
- post_images,
- )
- .map_err(|_| RadrootsAppError::invalid_argument("invalid_ask"))?,
- ),
- FfiAddCommandType::CreateEvent => {
- Phase1AddCommand::CreateEvent(event_command(&self, prepared.first())?)
- }
- FfiAddCommandType::CreateFoodAvailability => Phase1AddCommand::CreateFoodAvailability(
- food_command(self, authored_at_unix_s, &prepared)?,
- ),
- };
- Ok((command, prerequisites, form))
- }
-
- fn form_snapshot(&self, prepared: &[BoundPreparedMedia]) -> Phase1DraftFormSnapshot {
- Phase1DraftFormSnapshot {
- command_type: match self.command_type {
- FfiAddCommandType::CreateUpdate => AddCommandType::CreateUpdate,
- FfiAddCommandType::CreatePhotoUpdate => AddCommandType::CreatePhotoUpdate,
- FfiAddCommandType::CreateAsk => AddCommandType::CreateAsk,
- FfiAddCommandType::CreateEvent => AddCommandType::CreateEvent,
- FfiAddCommandType::CreateFoodAvailability => AddCommandType::CreateFoodAvailability,
- },
- content: self.content.clone(),
- identifier: self.identifier.clone(),
- title: self.title.clone(),
- summary: self.summary.clone(),
- location: self.location.clone(),
- event_timing: self.event_timing.map(|value| match value {
- FfiEventTimingKind::AllDay => Phase1DraftEventTiming::AllDay,
- FfiEventTimingKind::Timed => Phase1DraftEventTiming::Timed,
- }),
- event_start_date: self.event_start_date.clone(),
- event_end_date: self.event_end_date.clone(),
- event_start_unix_s: self.event_start_unix_s,
- event_end_unix_s: self.event_end_unix_s,
- event_timezone: self.event_timezone.clone(),
- price_amount: self.price_amount.clone(),
- currency: self.currency.clone(),
- unit: self.unit.clone(),
- quantity: self.quantity.clone(),
- food_published_at_unix_s: self.food_published_at_unix_s,
- food_status: self.food_status.clone(),
- media: prepared
- .iter()
- .map(|value| Phase1DraftMediaSnapshot {
- opaque_reference: value.media.opaque_reference.clone(),
- url: value.descriptor.url().as_str().to_owned(),
- sha256: value.media.sha256.to_hex(),
- media_type: value.media.media_type.as_str().to_owned(),
- byte_size: value.media.byte_size,
- width: value.media.width,
- height: value.media.height,
- alt: value.media.alt.clone(),
- prepared_at_unix_s: value.media.prepared_at_unix_s,
- })
- .collect(),
- }
- }
-}
-
-pub(crate) struct PreparedMedia {
- opaque_reference: String,
- sha256: Sha256,
- byte_size: u64,
- prepared_at_unix_s: u64,
- bytes: std::sync::Arc<[u8]>,
- media_type: MediaType,
- width: u32,
- height: u32,
- alt: String,
-}
-
-struct BoundPreparedMedia {
- media: PreparedMedia,
- descriptor: radroots_blossom::ByteVerifiedDescriptor,
-}
-
-impl TryFrom<FfiPreparedMediaInput> for PreparedMedia {
- type Error = RadrootsAppError;
-
- fn try_from(value: FfiPreparedMediaInput) -> Result<Self, Self::Error> {
- require_schema(value.schema_version)?;
- if !opaque_media_reference_is_valid(&value.opaque_reference)
- || value.byte_size == 0
- || value.byte_size > MEDIA_FILE_MAX_BYTES
- || value.width == 0
- || value.height == 0
- || value.prepared_at_unix_s == 0
- || value.alt.trim().is_empty()
- || value.alt.len() > 1_024
- {
- return Err(RadrootsAppError::invalid_argument(
- "invalid_media_reference",
- ));
- }
- let byte_size = usize::try_from(value.byte_size)
- .map_err(|_| RadrootsAppError::invalid_argument("media_size_mismatch"))?;
- let bytes = read_media_file_descriptor(value.file_descriptor, value.byte_size, byte_size)?;
- let media_type = MediaType::parse(&value.media_type)
- .map_err(|_| RadrootsAppError::invalid_argument("invalid_media_type"))?;
- let sha256 = Sha256::from_hex(&value.sha256)
- .map_err(|_| RadrootsAppError::invalid_argument("invalid_media_digest"))?;
- if Sha256::digest(&bytes) != sha256 {
- return Err(RadrootsAppError::invalid_argument(
- "media_verification_failed",
- ));
- }
- let dimensions =
- radroots_sdk::transport::BlossomImageDimensions::new(value.width, value.height)
- .map_err(|_| RadrootsAppError::invalid_argument("invalid_image_dimensions"))?;
- let verified_at_unix_ms = value
- .prepared_at_unix_s
- .checked_mul(1_000)
- .ok_or_else(|| RadrootsAppError::invalid_argument("invalid_media_reference"))?;
- radroots_sdk::transport::BlossomUploadRequest::new(
- bytes.clone().into(),
- media_type.clone(),
- dimensions,
- verified_at_unix_ms,
- )
- .map_err(|_| RadrootsAppError::invalid_argument("media_verification_failed"))?;
- Ok(Self {
- opaque_reference: value.opaque_reference,
- sha256,
- byte_size: value.byte_size,
- prepared_at_unix_s: value.prepared_at_unix_s,
- bytes: bytes.into(),
- media_type,
- width: value.width,
- height: value.height,
- alt: value.alt,
- })
- }
-}
-
-#[cfg(unix)]
-fn read_media_file_descriptor(
- file_descriptor: u64,
- expected_size: u64,
- byte_size: usize,
-) -> Result<Vec<u8>, RadrootsAppError> {
- let raw_file_descriptor = RawFd::try_from(file_descriptor)
- .map_err(|_| RadrootsAppError::invalid_argument("media_handle_unavailable"))?;
- // SAFETY: `fcntl(F_DUPFD_CLOEXEC)` accepts any in-range integer descriptor
- // and reports EBADF for an unavailable one. No borrowed or owned Rust
- // descriptor is constructed until the kernel has duplicated it.
- let duplicated = unsafe { libc::fcntl(raw_file_descriptor, libc::F_DUPFD_CLOEXEC, 0) };
- if duplicated < 0 {
- return Err(RadrootsAppError::invalid_argument(
- "media_handle_unavailable",
- ));
- }
- // SAFETY: a nonnegative F_DUPFD_CLOEXEC result is a new descriptor owned by
- // this call. The host's original descriptor remains independently owned.
- let owned = unsafe { OwnedFd::from_raw_fd(duplicated) };
- let file = std::fs::File::from(owned);
- let metadata = file
- .metadata()
- .map_err(|_| RadrootsAppError::invalid_argument("media_handle_unavailable"))?;
- if !metadata.is_file() || metadata.len() != expected_size {
- return Err(RadrootsAppError::invalid_argument("media_size_mismatch"));
- }
- let mut bytes = vec![0; byte_size];
- file.read_exact_at(&mut bytes, 0)
- .map_err(|_| RadrootsAppError::invalid_argument("media_read_failed"))?;
- Ok(bytes)
-}
-
-#[cfg(not(unix))]
-fn read_media_file_descriptor(
- _file_descriptor: u64,
- _expected_size: u64,
- _byte_size: usize,
-) -> Result<Vec<u8>, RadrootsAppError> {
- Err(RadrootsAppError::failure(
- "media_handle_unsupported",
- "capability",
- false,
- &[],
- "Protected media handles are unsupported on this platform.",
- ))
-}
-
-impl PreparedMedia {
- pub(crate) fn into_authored_image(
- self,
- blossom: &radroots_sdk::transport::BlossomSlot,
- ) -> Result<AuthoredImage, RadrootsAppError> {
- self.bind(blossom)?.authored_image()
- }
-
- pub(crate) fn into_upload_intent(
- self,
- draft_id: [u8; 16],
- expected_revision: u64,
- ) -> Result<Phase1UploadIntent, RadrootsAppError> {
- Phase1UploadIntent::new(
- draft_id,
- expected_revision,
- self.bytes,
- self.media_type,
- self.width,
- self.height,
- )
- .map_err(|_| RadrootsAppError::invalid_argument("invalid_blossom_upload"))
- }
-
- pub(crate) fn upload_request(
- &self,
- verified_at_unix_ms: u64,
- ) -> Result<radroots_sdk::transport::BlossomUploadRequest, RadrootsAppError> {
- let dimensions =
- radroots_sdk::transport::BlossomImageDimensions::new(self.width, self.height)
- .map_err(|_| RadrootsAppError::invalid_argument("invalid_image_dimensions"))?;
- radroots_sdk::transport::BlossomUploadRequest::new(
- std::sync::Arc::clone(&self.bytes),
- self.media_type.clone(),
- dimensions,
- verified_at_unix_ms,
- )
- .map_err(|_| RadrootsAppError::invalid_argument("invalid_blossom_upload"))
- }
-
- fn bind(
- self,
- blossom: &radroots_sdk::transport::BlossomSlot,
- ) -> Result<BoundPreparedMedia, RadrootsAppError> {
- let verified_at_unix_ms = self
- .prepared_at_unix_s
- .checked_mul(1_000)
- .ok_or_else(|| RadrootsAppError::invalid_argument("invalid_media_reference"))?;
- let transaction = blossom
- .prepare_upload(self.upload_request(verified_at_unix_ms)?)
- .map_err(|error| RadrootsAppError::invalid_argument(error.code()))?;
- let descriptor = BlobDescriptor::new(
- transaction.expected_url().clone(),
- self.sha256,
- self.byte_size,
- self.media_type.clone(),
- self.prepared_at_unix_s,
- )
- .and_then(BlobDescriptor::approve_reference)
- .and_then(|descriptor| descriptor.verify_bytes(&self.bytes, &self.media_type))
- .map_err(|_| RadrootsAppError::invalid_argument("media_verification_failed"))?;
- Ok(BoundPreparedMedia {
- media: self,
- descriptor,
- })
- }
-}
-
-impl BoundPreparedMedia {
- fn authored_image(&self) -> Result<AuthoredImage, RadrootsAppError> {
- AuthoredImage::try_from_verified_descriptor(self.descriptor.clone())
- .map_err(|_| RadrootsAppError::invalid_argument("invalid_image_media"))
- }
-
- fn post_image(&self) -> Result<AuthoredPostImage, RadrootsAppError> {
- AuthoredPostImage::new(
- self.authored_image()?,
- PostImageDimensions::new(self.media.width, self.media.height)
- .map_err(|_| RadrootsAppError::invalid_argument("invalid_image_dimensions"))?,
- self.media.alt.clone(),
- )
- .map_err(|_| RadrootsAppError::invalid_argument("invalid_image"))
- }
-}
-
-fn event_command(
- input: &FfiAddDraftInput,
- image: Option<&BoundPreparedMedia>,
-) -> Result<CreateEvent, RadrootsAppError> {
- if input.media.len() > 1 {
- return Err(RadrootsAppError::invalid_argument("event_image_limit"));
- }
- let identifier = required(input.identifier.as_deref(), "event_identifier_required")?;
- let title = required(input.title.as_deref(), "event_title_required")?;
- let timing = input
- .event_timing
- .ok_or_else(|| RadrootsAppError::invalid_argument("event_timing_required"))?;
- match timing {
- FfiEventTimingKind::AllDay => {
- let start = CalendarDate::parse(required(
- input.event_start_date.as_deref(),
- "event_start_date_required",
- )?)
- .map_err(|_| RadrootsAppError::invalid_argument("invalid_event_start_date"))?;
- let mut event = AuthoredCalendarDateEvent::new(identifier, title, start)
- .map_err(|_| RadrootsAppError::invalid_argument("invalid_event"))?;
- if let Some(end) = input.event_end_date.as_deref() {
- event = event
- .with_end(CalendarDate::parse(end).map_err(|_| {
- RadrootsAppError::invalid_argument("invalid_event_end_date")
- })?)
- .map_err(|_| RadrootsAppError::invalid_argument("invalid_event_range"))?;
- }
- if !input.content.is_empty() {
- event = event
- .with_description(input.content.clone())
- .map_err(|_| RadrootsAppError::invalid_argument("invalid_event_description"))?;
- }
- if let Some(location) = input.location.clone() {
- event = event
- .with_locations(vec![location])
- .map_err(|_| RadrootsAppError::invalid_argument("invalid_event_location"))?;
- }
- if let Some(image) = image {
- event = event
- .with_image(image.authored_image()?)
- .map_err(|_| RadrootsAppError::invalid_argument("invalid_event_image"))?;
- }
- Ok(CreateEvent::date(event))
- }
- FfiEventTimingKind::Timed => {
- let start = input
- .event_start_unix_s
- .filter(|value| *value != 0)
- .ok_or_else(|| RadrootsAppError::invalid_argument("event_start_required"))?;
- let mut event = AuthoredCalendarTimeEvent::new(identifier, title, start)
- .map_err(|_| RadrootsAppError::invalid_argument("invalid_event"))?;
- if let Some(end) = input.event_end_unix_s {
- event = event
- .with_end(end)
- .map_err(|_| RadrootsAppError::invalid_argument("invalid_event_range"))?;
- }
- if let Some(timezone) = input.event_timezone.as_deref() {
- event = event
- .with_start_tzid(timezone)
- .map_err(|_| RadrootsAppError::invalid_argument("invalid_event_timezone"))?;
- }
- if !input.content.is_empty() {
- event = event
- .with_description(input.content.clone())
- .map_err(|_| RadrootsAppError::invalid_argument("invalid_event_description"))?;
- }
- if let Some(location) = input.location.clone() {
- event = event
- .with_locations(vec![location])
- .map_err(|_| RadrootsAppError::invalid_argument("invalid_event_location"))?;
- }
- if let Some(image) = image {
- event = event
- .with_image(image.authored_image()?)
- .map_err(|_| RadrootsAppError::invalid_argument("invalid_event_image"))?;
- }
- Ok(CreateEvent::time(event))
- }
- }
-}
-
-fn food_command(
- input: FfiAddDraftInput,
- authored_at_unix_s: u64,
- media: &[BoundPreparedMedia],
-) -> Result<CreateFoodAvailability, RadrootsAppError> {
- let unit = FoodUnit::parse(required(input.unit.as_deref(), "food_unit_required")?)
- .map_err(|_| RadrootsAppError::invalid_argument("invalid_food_unit"))?;
- let images = media
- .iter()
- .map(|image| {
- Ok(FoodAvailabilityImage::new(
- image.authored_image()?,
- FoodImageDimensions::new(image.media.width, image.media.height)
- .map_err(|_| RadrootsAppError::invalid_argument("invalid_image_dimensions"))?,
- ))
- })
- .collect::<Result<Vec<_>, RadrootsAppError>>()?;
- let status = input.food_status.as_deref().unwrap_or("active");
- let details = FoodAvailabilityDetails::new(FoodAvailabilityDetailsParts {
- content: FoodContent::new(input.content)
- .map_err(|_| RadrootsAppError::invalid_argument("invalid_food_content"))?,
- identifier: FoodIdentifier::parse(required(
- input.identifier.as_deref(),
- "food_identifier_required",
- )?)
- .map_err(|_| RadrootsAppError::invalid_argument("invalid_food_identifier"))?,
- title: FoodText::new(required(input.title, "food_title_required")?)
- .map_err(|_| RadrootsAppError::invalid_argument("invalid_food_title"))?,
- summary: FoodText::new(required(input.summary, "food_summary_required")?)
- .map_err(|_| RadrootsAppError::invalid_argument("invalid_food_summary"))?,
- published_at: FoodPublishedAt::new(
- input.food_published_at_unix_s.unwrap_or(authored_at_unix_s),
- )
- .map_err(|_| RadrootsAppError::invalid_argument("invalid_food_published_at"))?,
- location: FoodText::new(required(input.location, "food_location_required")?)
- .map_err(|_| RadrootsAppError::invalid_argument("invalid_food_location"))?,
- price: FoodPrice::new(
- required(input.price_amount, "food_price_required")?,
- FoodCurrency::parse(required(input.currency, "food_currency_required")?)
- .map_err(|_| RadrootsAppError::invalid_argument("invalid_food_currency"))?,
- unit,
- )
- .map_err(|_| RadrootsAppError::invalid_argument("invalid_food_price"))?,
- quantity: input
- .quantity
- .map(|quantity| FoodQuantity::new(quantity, unit))
- .transpose()
- .map_err(|_| RadrootsAppError::invalid_argument("invalid_food_quantity"))?,
- status: FoodAvailabilityStatus::parse(status)
- .map_err(|_| RadrootsAppError::invalid_argument("invalid_food_status"))?,
- images,
- })
- .map_err(|_| RadrootsAppError::invalid_argument("invalid_food_availability"))?;
- Ok(CreateFoodAvailability::new(details))
-}
-
-fn reject_media(media: &[BoundPreparedMedia]) -> Result<(), RadrootsAppError> {
- if media.is_empty() {
- Ok(())
- } else {
- Err(RadrootsAppError::invalid_argument("media_not_allowed"))
- }
-}
-
-fn content_with_media_references(
- mut content: String,
- media: &[BoundPreparedMedia],
-) -> Result<String, RadrootsAppError> {
- if content.trim().is_empty() {
- return Err(RadrootsAppError::invalid_argument("content_required"));
- }
- for item in media {
- let url = item.descriptor.url().as_str();
- match content.match_indices(url).count() {
- 0 => {
- if !content.ends_with('\n') {
- content.push('\n');
- }
- content.push_str(url);
- }
- 1 => {}
- _ => {
- return Err(RadrootsAppError::invalid_argument(
- "duplicate_media_reference",
- ));
- }
- }
- }
- Ok(content)
-}
-
-fn required<T>(value: Option<T>, code: &'static str) -> Result<T, RadrootsAppError> {
- value.ok_or_else(|| RadrootsAppError::invalid_argument(code))
-}
-
-fn opaque_media_reference_is_valid(value: &str) -> bool {
- value.len() > "media:".len()
- && value.len() <= MEDIA_REFERENCE_MAX_BYTES
- && value.starts_with("media:")
- && value["media:".len()..].bytes().all(|byte| {
- byte.is_ascii_lowercase() || byte.is_ascii_digit() || matches!(byte, b'-' | b'_')
- })
-}
-
-#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)]
-pub enum FfiMediaStage {
- Pending,
- Preparing,
- Uploading,
- Verified,
- Failed,
- Orphaned,
-}
-
-#[cfg_attr(coverage_nightly, coverage(off))]
-impl From<Phase1MediaStage> for FfiMediaStage {
- fn from(value: Phase1MediaStage) -> Self {
- match value {
- Phase1MediaStage::Pending => Self::Pending,
- Phase1MediaStage::Preparing => Self::Preparing,
- Phase1MediaStage::Uploading => Self::Uploading,
- Phase1MediaStage::Verified => Self::Verified,
- Phase1MediaStage::Failed => Self::Failed,
- Phase1MediaStage::Orphaned => Self::Orphaned,
- }
- }
-}
-
-#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)]
-pub struct FfiDraftMediaRecord {
- pub schema_version: u16,
- pub url: String,
- pub stage: FfiMediaStage,
- pub upload_attempts: u8,
- pub verified_at_unix_ms: Option<u64>,
- pub possible_orphan: bool,
- pub orphan_reason_code: Option<String>,
- pub orphan_recorded_at_unix_ms: Option<u64>,
-}
-
-#[cfg_attr(coverage_nightly, coverage(off))]
-impl From<&Phase1MediaPrerequisite> for FfiDraftMediaRecord {
- fn from(value: &Phase1MediaPrerequisite) -> Self {
- let orphan = value.orphan();
- Self {
- schema_version: MOBILE_FFI_SCHEMA_VERSION,
- url: value.url().to_owned(),
- stage: value.stage().into(),
- upload_attempts: value.upload_attempts(),
- verified_at_unix_ms: value.verified_at_unix_ms(),
- possible_orphan: orphan.is_some(),
- orphan_reason_code: orphan.map(|value| value.reason_code().to_owned()),
- orphan_recorded_at_unix_ms: orphan.map(|value| value.recorded_at_unix_ms()),
- }
- }
-}
-
-#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)]
-pub enum FfiOutboxState {
- Draft,
- MediaPreparing,
- MediaUploading,
- ReadyToSign,
- Signing,
- Signed,
- Queued,
- Delivering,
- PartiallyDelivered,
- Retryable,
- Terminal,
- Cancelled,
- Complete,
-}
-
-#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)]
-pub enum FfiDraftKind {
- Add,
- Retraction,
-}
-
-#[cfg_attr(coverage_nightly, coverage(off))]
-impl From<Phase1DraftKind> for FfiDraftKind {
- fn from(value: Phase1DraftKind) -> Self {
- match value {
- Phase1DraftKind::Add => Self::Add,
- Phase1DraftKind::Retraction => Self::Retraction,
- }
- }
-}
-
-#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)]
-pub struct FfiDraftFormMediaRecord {
- pub schema_version: u16,
- 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,
-}
-
-#[cfg_attr(coverage_nightly, coverage(off))]
-impl From<&Phase1DraftMediaSnapshot> for FfiDraftFormMediaRecord {
- fn from(value: &Phase1DraftMediaSnapshot) -> Self {
- Self {
- schema_version: MOBILE_FFI_SCHEMA_VERSION,
- opaque_reference: value.opaque_reference.clone(),
- url: value.url.clone(),
- sha256: value.sha256.clone(),
- media_type: value.media_type.clone(),
- byte_size: value.byte_size,
- width: value.width,
- height: value.height,
- alt: value.alt.clone(),
- prepared_at_unix_s: value.prepared_at_unix_s,
- }
- }
-}
-
-#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)]
-pub struct FfiDraftFormRecord {
- pub schema_version: u16,
- pub command_type: FfiAddCommandType,
- pub content: String,
- pub identifier: Option<String>,
- pub title: Option<String>,
- pub summary: Option<String>,
- pub location: Option<String>,
- pub event_timing: Option<FfiEventTimingKind>,
- 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>,
- pub food_published_at_unix_s: Option<u64>,
- pub food_status: Option<String>,
- pub media: Vec<FfiDraftFormMediaRecord>,
-}
-
-#[cfg_attr(coverage_nightly, coverage(off))]
-impl From<&Phase1DraftFormSnapshot> for FfiDraftFormRecord {
- fn from(value: &Phase1DraftFormSnapshot) -> Self {
- Self {
- schema_version: MOBILE_FFI_SCHEMA_VERSION,
- command_type: value.command_type.into(),
- content: value.content.clone(),
- identifier: value.identifier.clone(),
- title: value.title.clone(),
- summary: value.summary.clone(),
- location: value.location.clone(),
- event_timing: value.event_timing.map(|value| match value {
- Phase1DraftEventTiming::AllDay => FfiEventTimingKind::AllDay,
- Phase1DraftEventTiming::Timed => FfiEventTimingKind::Timed,
- }),
- event_start_date: value.event_start_date.clone(),
- event_end_date: value.event_end_date.clone(),
- event_start_unix_s: value.event_start_unix_s,
- event_end_unix_s: value.event_end_unix_s,
- event_timezone: value.event_timezone.clone(),
- price_amount: value.price_amount.clone(),
- currency: value.currency.clone(),
- unit: value.unit.clone(),
- quantity: value.quantity.clone(),
- food_published_at_unix_s: value.food_published_at_unix_s,
- food_status: value.food_status.clone(),
- media: value.media.iter().map(Into::into).collect(),
- }
- }
-}
-
-#[cfg_attr(coverage_nightly, coverage(off))]
-impl From<Phase1OutboxState> for FfiOutboxState {
- fn from(value: Phase1OutboxState) -> Self {
- match value {
- Phase1OutboxState::Draft => Self::Draft,
- Phase1OutboxState::MediaPreparing => Self::MediaPreparing,
- Phase1OutboxState::MediaUploading => Self::MediaUploading,
- Phase1OutboxState::ReadyToSign => Self::ReadyToSign,
- Phase1OutboxState::Signing => Self::Signing,
- Phase1OutboxState::Signed => Self::Signed,
- Phase1OutboxState::Queued => Self::Queued,
- Phase1OutboxState::Delivering => Self::Delivering,
- Phase1OutboxState::PartiallyDelivered => Self::PartiallyDelivered,
- Phase1OutboxState::Retryable => Self::Retryable,
- Phase1OutboxState::Terminal => Self::Terminal,
- Phase1OutboxState::Cancelled => Self::Cancelled,
- Phase1OutboxState::Complete => Self::Complete,
- }
- }
-}
-
-#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)]
-pub struct FfiDraftStatusRecord {
- pub schema_version: u16,
- pub draft_id: String,
- pub revision: u64,
- pub author_public_key: String,
- pub kind: FfiDraftKind,
- pub command_type: FfiAddCommandType,
- pub form: Option<FfiDraftFormRecord>,
- pub state: FfiOutboxState,
- pub card_id: String,
- pub operation_id: Option<String>,
- pub created_at_unix_ms: u64,
- pub updated_at_unix_ms: u64,
- pub media: Vec<FfiDraftMediaRecord>,
- pub settlement: Option<FfiOperationSettlementRecord>,
- pub is_revision: bool,
-}
-
-#[cfg_attr(coverage_nightly, coverage(off))]
-impl From<Phase1DraftStatus> for FfiDraftStatusRecord {
- fn from(value: Phase1DraftStatus) -> Self {
- let draft = value.draft();
- let settlement = value.push().map(|push| push.settlement());
- Self {
- schema_version: MOBILE_FFI_SCHEMA_VERSION,
- draft_id: hex::encode(draft.draft_id().as_bytes()),
- revision: draft.revision().get(),
- author_public_key: hex::encode(draft.author()),
- kind: value.kind().into(),
- command_type: value.command_type().into(),
- form: value.form().map(Into::into),
- state: value.state().into(),
- card_id: value.card_id().to_hex(),
- operation_id: draft.operation_id().map(|id| hex::encode(id.as_bytes())),
- created_at_unix_ms: draft.created_at_unix_ms(),
- updated_at_unix_ms: draft.updated_at_unix_ms(),
- media: value.media().iter().map(Into::into).collect(),
- settlement: settlement.map(Into::into),
- is_revision: value.revision_policy().is_some(),
- }
- }
-}
-
-#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Record)]
-pub struct FfiOperationSettlementRecord {
- pub schema_version: u16,
- pub artifacts: u16,
- pub signed: u16,
- pub admitted: u16,
- pub pending: u16,
- pub retryable: u16,
- pub indeterminate: u16,
- pub failed_terminal: u16,
- pub cancelled: u16,
- pub delivery_plans: u16,
- pub delivery_satisfied: u16,
- pub delivery_pending: u16,
- pub delivery_retryable: u16,
- pub delivery_exhausted: u16,
- pub delivery_failed_terminal: u16,
- pub delivery_cancelled: u16,
-}
-
-#[cfg_attr(coverage_nightly, coverage(off))]
-impl From<radroots_storage::authored::OperationSettlement> for FfiOperationSettlementRecord {
- fn from(value: radroots_storage::authored::OperationSettlement) -> Self {
- Self {
- schema_version: MOBILE_FFI_SCHEMA_VERSION,
- artifacts: value.artifacts(),
- signed: value.signed(),
- admitted: value.admitted(),
- pending: value.pending(),
- retryable: value.retryable(),
- indeterminate: value.indeterminate(),
- failed_terminal: value.failed_terminal(),
- cancelled: value.cancelled(),
- delivery_plans: value.delivery_plans(),
- delivery_satisfied: value.delivery_satisfied(),
- delivery_pending: value.delivery_pending(),
- delivery_retryable: value.delivery_retryable(),
- delivery_exhausted: value.delivery_exhausted(),
- delivery_failed_terminal: value.delivery_failed_terminal(),
- delivery_cancelled: value.delivery_cancelled(),
- }
- }
-}
-
-#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)]
-pub enum FfiRelaySatisfaction {
- AnyAccepted,
- AllAccepted,
- AnyDelivered,
- AllDelivered,
-}
-
-#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)]
-pub enum FfiCancellationPolicy {
- PreservePublishedRequest,
- LocalCooperative,
-}
-
-impl FfiCancellationPolicy {
- pub(crate) const fn core(self) -> Phase1CancellationPolicy {
- match self {
- Self::PreservePublishedRequest => Phase1CancellationPolicy::PreservePublishedRequest,
- Self::LocalCooperative => Phase1CancellationPolicy::LocalCooperative,
- }
- }
-}
-
-#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)]
-pub struct FfiQueuePolicyRecord {
- pub schema_version: u16,
- pub relay_urls: Vec<String>,
- pub satisfaction: FfiRelaySatisfaction,
- pub delivery_deadline_unix_ms: u64,
- pub cancellation: FfiCancellationPolicy,
-}
-
-impl TryFrom<FfiQueuePolicyRecord> for Phase1QueuePolicy {
- type Error = RadrootsAppError;
-
- fn try_from(value: FfiQueuePolicyRecord) -> Result<Self, Self::Error> {
- require_schema(value.schema_version)?;
- Phase1QueuePolicy::new(
- value.relay_urls,
- match value.satisfaction {
- FfiRelaySatisfaction::AnyAccepted => Phase1RelaySatisfaction::AnyAccepted,
- FfiRelaySatisfaction::AllAccepted => Phase1RelaySatisfaction::AllAccepted,
- FfiRelaySatisfaction::AnyDelivered => Phase1RelaySatisfaction::AnyDelivered,
- FfiRelaySatisfaction::AllDelivered => Phase1RelaySatisfaction::AllDelivered,
- },
- value.delivery_deadline_unix_ms,
- match value.cancellation {
- FfiCancellationPolicy::PreservePublishedRequest => {
- Phase1CancellationPolicy::PreservePublishedRequest
- }
- FfiCancellationPolicy::LocalCooperative => {
- Phase1CancellationPolicy::LocalCooperative
- }
- },
- )
- .map_err(|_| RadrootsAppError::invalid_argument("invalid_queue_policy"))
- }
-}
-
-#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)]
-pub struct FfiCapabilityRecord {
- pub schema_version: u16,
- pub id: String,
- pub compiled: bool,
- pub configured: bool,
- pub availability: String,
- pub maturity: String,
-}
-
-#[cfg_attr(coverage_nightly, coverage(off))]
-impl From<SdkCapabilityRecord> for FfiCapabilityRecord {
- fn from(value: SdkCapabilityRecord) -> Self {
- Self {
- schema_version: MOBILE_FFI_SCHEMA_VERSION,
- id: value.id,
- compiled: value.compiled,
- configured: value.configured,
- availability: value.availability,
- maturity: value.maturity,
- }
- }
-}
-
-#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)]
-pub struct FfiStorageStatusRecord {
- pub schema_version: u16,
- pub backend: String,
- pub open_mode: String,
- pub shutdown: String,
- pub integrity: String,
-}
-
-#[cfg_attr(coverage_nightly, coverage(off))]
-impl From<SdkStorageStatusRecord> for FfiStorageStatusRecord {
- fn from(value: SdkStorageStatusRecord) -> Self {
- Self {
- schema_version: MOBILE_FFI_SCHEMA_VERSION,
- backend: value.backend,
- open_mode: value.open_mode,
- shutdown: value.shutdown,
- integrity: value.integrity,
- }
- }
-}
-
-#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)]
-pub enum FfiRelayAccessRecord {
- ReadOnly,
- ReadWrite,
-}
-
-#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)]
-pub struct FfiRelayStatusRecord {
- pub schema_version: u16,
- pub relay_url: String,
- pub access: FfiRelayAccessRecord,
- 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>,
-}
-
-#[cfg_attr(coverage_nightly, coverage(off))]
-impl From<SdkRelayStatusRecord> for FfiRelayStatusRecord {
- fn from(value: SdkRelayStatusRecord) -> Self {
- Self {
- schema_version: MOBILE_FFI_SCHEMA_VERSION,
- relay_url: value.relay_url,
- access: match value.access {
- SdkRelayAccessRecord::ReadOnly => FfiRelayAccessRecord::ReadOnly,
- SdkRelayAccessRecord::ReadWrite => FfiRelayAccessRecord::ReadWrite,
- },
- read_state: value.read_state,
- write_state: value.write_state,
- read_last_attempt_unix_ms: value.read_last_attempt_unix_ms,
- write_last_attempt_unix_ms: value.write_last_attempt_unix_ms,
- read_next_attempt_unix_ms: value.read_next_attempt_unix_ms,
- write_next_attempt_unix_ms: value.write_next_attempt_unix_ms,
- }
- }
-}
-
-#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)]
-pub struct FfiRelayStatusReportRecord {
- pub schema_version: u16,
- pub profile: String,
- pub state: String,
- pub read_availability: String,
- pub write_availability: String,
- pub relays: Vec<FfiRelayStatusRecord>,
-}
-
-#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)]
-pub enum FfiBlossomHostKind {
- Native,
- Simulator,
- PhysicalDevice,
-}
-
-impl From<FfiBlossomHostKind> for radroots_sdk::transport::BlossomHostKind {
- fn from(value: FfiBlossomHostKind) -> Self {
- match value {
- FfiBlossomHostKind::Native => Self::Native,
- FfiBlossomHostKind::Simulator => Self::Simulator,
- FfiBlossomHostKind::PhysicalDevice => Self::PhysicalDevice,
- }
- }
-}
-
-#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)]
-pub enum FfiBlossomEndpointAuthority {
- PublicWebPki,
- LoopbackDevelopment,
- PrivateNetworkDevelopment,
-}
-
-impl From<FfiBlossomEndpointAuthority> for radroots_sdk::transport::BlossomEndpointAuthority {
- fn from(value: FfiBlossomEndpointAuthority) -> Self {
- match value {
- FfiBlossomEndpointAuthority::PublicWebPki => Self::PublicWebPki,
- FfiBlossomEndpointAuthority::LoopbackDevelopment => Self::LoopbackDevelopment,
- FfiBlossomEndpointAuthority::PrivateNetworkDevelopment => {
- Self::PrivateNetworkDevelopment
- }
- }
- }
-}
-
-#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)]
-pub struct FfiBlossomConfigurationRecord {
- pub schema_version: u16,
- pub host_kind: String,
- pub endpoint_authority: String,
- pub primary_origin: String,
- pub fallback_origins: Vec<String>,
- pub config_fingerprint: String,
-}
-
-#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)]
-pub struct FfiBlossomEvidenceRecord {
- 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,
-}
-
-#[cfg_attr(coverage_nightly, coverage(off))]
-impl From<SdkBlossomConfigurationRecord> for FfiBlossomConfigurationRecord {
- fn from(value: SdkBlossomConfigurationRecord) -> Self {
- Self {
- schema_version: MOBILE_FFI_SCHEMA_VERSION,
- host_kind: value.host_kind,
- endpoint_authority: value.endpoint_authority,
- primary_origin: value.primary_origin,
- fallback_origins: value.fallback_origins,
- config_fingerprint: value.config_fingerprint,
- }
- }
-}
-
-#[cfg_attr(coverage_nightly, coverage(off))]
-impl From<SdkBlossomEvidenceRecord> for FfiBlossomEvidenceRecord {
- fn from(value: SdkBlossomEvidenceRecord) -> Self {
- Self {
- schema_version: value.schema_version,
- origin: value.origin,
- config_fingerprint: value.config_fingerprint,
- state: value.state,
- last_successful_state: value.last_successful_state,
- transport_security: value.transport_security,
- observed_at_unix_ms: value.observed_at_unix_ms,
- http_status: value.http_status,
- error_code: value.error_code,
- server_error_code: value.server_error_code,
- error_phase: value.error_phase,
- retryable: value.retryable,
- possible_orphan: value.possible_orphan,
- attempts: value.attempts,
- }
- }
-}
-
-#[cfg_attr(coverage_nightly, coverage(off))]
-impl From<SdkRelayStatusReportRecord> for FfiRelayStatusReportRecord {
- fn from(value: SdkRelayStatusReportRecord) -> Self {
- Self {
- schema_version: MOBILE_FFI_SCHEMA_VERSION,
- profile: value.profile,
- state: value.state,
- read_availability: value.read_availability,
- write_availability: value.write_availability,
- relays: value.relays.into_iter().map(Into::into).collect(),
- }
- }
-}
-
-#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)]
-pub struct FfiShutdownRecord {
- pub schema_version: u16,
- pub state: String,
- pub already_closed: bool,
-}
-
-#[cfg_attr(coverage_nightly, coverage(off))]
-impl From<SdkShutdownRecord> for FfiShutdownRecord {
- fn from(value: SdkShutdownRecord) -> Self {
- Self {
- schema_version: MOBILE_FFI_SCHEMA_VERSION,
- state: value.state,
- already_closed: value.already_closed,
- }
- }
-}
-
-pub(crate) fn decode_id(value: &str, code: &'static str) -> Result<[u8; 16], RadrootsAppError> {
- if value.len() != 32 {
- return Err(RadrootsAppError::invalid_argument(code));
- }
- let bytes = hex::decode(value).map_err(|_| RadrootsAppError::invalid_argument(code))?;
- bytes
- .try_into()
- .map_err(|_| RadrootsAppError::invalid_argument(code))
-}
-
-fn require_schema(schema_version: u16) -> Result<(), RadrootsAppError> {
- if schema_version == MOBILE_FFI_SCHEMA_VERSION {
- Ok(())
- } else {
- Err(RadrootsAppError::invalid_argument(
- "unsupported_schema_version",
- ))
- }
-}
-
-#[cfg(test)]
-mod tests {
- use core::num::NonZeroU64;
- use std::io::Write;
- use std::os::fd::AsRawFd;
-
- use radroots_event::id::TradeId;
-
- use super::*;
-
- fn rhi_attestation_fixture() -> serde_json::Value {
- let fixture: serde_json::Value = serde_json::from_str(include_str!(
- "../../../contracts/conformance/vectors/event/authored_operations.v1.json"
- ))
- .expect("authored corpus");
- fixture["vectors"]
- .as_array()
- .expect("operations")
- .iter()
- .find(|entry| entry["id"] == "typed_rhi_evidence_attestation_017")
- .expect("RHI operation")
- .get("expected")
- .expect("expected")
- .clone()
- }
-
- #[test]
- fn evidence_report_plan_and_verified_event_use_final_mobile_vocabulary() {
- let expected = rhi_attestation_fixture();
- let content = expected["content"].as_str().expect("content").to_owned();
- let report = parse_rhi_evidence_report(content.clone()).expect("report");
- assert_eq!(report.outcome, FfiTradeEvidenceOutcome::Indeterminate);
- assert_eq!(report.trade_generation, "7");
- assert_eq!(report.observed_at_unix_s, "1800000000");
-
- let plan = prepare_rhi_evidence_attestation(content, 1_784_347_200).expect("plan");
- assert_eq!(plan.kind, 3_441);
- assert_eq!(plan.created_at_unix_s, "1784347200");
- assert_eq!(
- plan.expected_event_id,
- expected["event_id"].as_str().expect("event id")
- );
-
- let raw: serde_json::Value =
- serde_json::from_str(expected["raw_json"].as_str().expect("raw event"))
- .expect("raw event JSON");
- let signed = FfiSignedEvidenceEventRecord {
- id: raw["id"].as_str().expect("id").to_owned(),
- author_pubkey: raw["pubkey"].as_str().expect("pubkey").to_owned(),
- created_at_unix_s: raw["created_at"].as_u64().expect("created_at"),
- kind: u32::try_from(raw["kind"].as_u64().expect("kind")).expect("u32 kind"),
- tags: serde_json::from_value(raw["tags"].clone()).expect("tags"),
- content: raw["content"].as_str().expect("content").to_owned(),
- signature: raw["sig"].as_str().expect("signature").to_owned(),
- };
- let attestation = validate_rhi_evidence_attestation(signed).expect("attestation");
- assert_eq!(attestation.outcome, FfiTradeEvidenceOutcome::Indeterminate);
- assert_eq!(attestation.trade_generation, "7");
- }
-
- #[test]
- fn evidence_manifest_and_supersession_project_the_complete_vocabulary() {
- use radroots_sdk::trade::{
- RadrootsTradeEvidenceManifestSourceResultV1, RadrootsTradeEvidenceManifestV1,
- RadrootsTradeEvidencePolicyDigestV1, RadrootsTradeEvidenceScopePrerequisitesV1,
- RadrootsTradeEvidenceSourceCompletionV1, RadrootsTradeEvidenceSourceIdV1,
- RadrootsTradeEvidenceSourceRequirementV1, RadrootsTradeEvidenceSourceResultDigestV1,
- RadrootsTradeEvidenceSourceResultV1,
- };
-
- assert_eq!(
- FfiTradeEvidenceCoverage::from(
- radroots_sdk::trade::RadrootsTradeEvidenceCoverageV1::Missing
- ),
- FfiTradeEvidenceCoverage::Missing
- );
- assert_eq!(
- FfiTradeEvidenceCoverage::from(
- radroots_sdk::trade::RadrootsTradeEvidenceCoverageV1::Partial
- ),
- FfiTradeEvidenceCoverage::Partial
- );
- assert_eq!(
- FfiTradeEvidenceCoverage::from(
- radroots_sdk::trade::RadrootsTradeEvidenceCoverageV1::ScopeSatisfied
- ),
- FfiTradeEvidenceCoverage::ScopeSatisfied
- );
- assert_eq!(
- FfiTradeEvidenceCoverage::from(
- radroots_sdk::trade::RadrootsTradeEvidenceCoverageV1::Unsupported
- ),
- FfiTradeEvidenceCoverage::Unsupported
- );
- assert_eq!(
- FfiTradeEvidenceOutcome::from(
- radroots_sdk::trade::RadrootsTradeEvidenceOutcomeV1::Valid
- ),
- FfiTradeEvidenceOutcome::Valid
- );
- assert_eq!(
- FfiTradeEvidenceOutcome::from(
- radroots_sdk::trade::RadrootsTradeEvidenceOutcomeV1::Invalid
- ),
- FfiTradeEvidenceOutcome::Invalid
- );
- assert_eq!(
- FfiTradeEvidenceOutcome::from(
- radroots_sdk::trade::RadrootsTradeEvidenceOutcomeV1::Indeterminate
- ),
- FfiTradeEvidenceOutcome::Indeterminate
- );
-
- let source = RadrootsTradeEvidenceManifestSourceResultV1::new(
- RadrootsTradeEvidenceSourceIdV1::parse("typed_source").expect("source id"),
- RadrootsTradeEvidenceSourceResultV1::new(
- RadrootsTradeEvidenceSourceRequirementV1::Required,
- RadrootsTradeEvidenceSourceCompletionV1::Complete,
- 0,
- )
- .expect("source result"),
- RadrootsTradeEvidenceSourceResultDigestV1::from_bytes([0x33; 32]),
- );
- let manifest = RadrootsTradeEvidenceManifestV1::new(
- TradeId::from_bytes([0x11; 16]),
- NonZeroU64::new(1).expect("nonzero generation"),
- RadrootsTradeEvidencePolicyDigestV1::from_bytes([0x22; 32]),
- 1_800_000_000,
- RadrootsTradeEvidenceScopePrerequisitesV1::Satisfied,
- [source],
- [],
- )
- .expect("manifest");
- let projected = parse_trade_evidence_manifest(manifest.canonical_bytes().to_vec())
- .expect("manifest projection");
- assert_eq!(projected.coverage, FfiTradeEvidenceCoverage::ScopeSatisfied);
- assert_eq!(
- projected.canonical_bytes_hex,
- hex::encode(manifest.canonical_bytes())
- );
-
- let decisions: serde_json::Value = serde_json::from_str(include_str!(
- "../../../contracts/conformance/vectors/rhi/evidence_attestation_decision.v1.json"
- ))
- .expect("RHI decision corpus");
- let superseding = decisions["vectors"]
- .as_array()
- .expect("RHI decision vectors")
- .iter()
- .find(|entry| entry["id"] == "rhi_evidence_attestation_superseding_002")
- .expect("superseding vector");
- let report = parse_rhi_evidence_report(
- superseding["expected"]["canonical_event_content_utf8"]
- .as_str()
- .expect("canonical event content")
- .to_owned(),
- )
- .expect("superseding report");
- assert_eq!(report.outcome, FfiTradeEvidenceOutcome::Valid);
- assert_eq!(
- report.supersedes_report_id.as_deref(),
- Some("7777777777777777777777777777777777777777777777777777777777777777")
- );
- assert_eq!(
- report.supersedes_event_id.as_deref(),
- Some("8888888888888888888888888888888888888888888888888888888888888888")
- );
- }
-
- #[test]
- fn transport_policy_enums_map_every_closed_variant() {
- assert_eq!(
- FfiCancellationPolicy::PreservePublishedRequest.core(),
- Phase1CancellationPolicy::PreservePublishedRequest
- );
- assert_eq!(
- FfiCancellationPolicy::LocalCooperative.core(),
- Phase1CancellationPolicy::LocalCooperative
- );
- assert_eq!(
- radroots_sdk::transport::BlossomHostKind::from(FfiBlossomHostKind::Native),
- radroots_sdk::transport::BlossomHostKind::Native
- );
- assert_eq!(
- radroots_sdk::transport::BlossomHostKind::from(FfiBlossomHostKind::Simulator),
- radroots_sdk::transport::BlossomHostKind::Simulator
- );
- assert_eq!(
- radroots_sdk::transport::BlossomHostKind::from(FfiBlossomHostKind::PhysicalDevice),
- radroots_sdk::transport::BlossomHostKind::PhysicalDevice
- );
- }
-
- #[test]
- fn evidence_errors_do_not_render_untrusted_content() {
- let secret = "mobile-private-evidence";
- let error = parse_rhi_evidence_report(secret.to_owned()).expect_err("malformed report");
- assert!(!error.to_string().contains(secret));
- assert!(!format!("{error:?}").contains(secret));
- }
-
- fn png(width: u32, height: u32) -> Vec<u8> {
- let mut bytes = b"\x89PNG\r\n\x1a\n\0\0\0\rIHDR".to_vec();
- bytes.extend_from_slice(&width.to_be_bytes());
- bytes.extend_from_slice(&height.to_be_bytes());
- bytes
- }
-
- fn blossom_slot() -> radroots_sdk::transport::BlossomSlot {
- let profile = radroots_sdk::transport::BlossomProfile::new(
- radroots_sdk::transport::BlossomHostKind::Simulator,
- radroots_sdk::transport::BlossomEndpointAuthority::LoopbackDevelopment,
- "http://127.0.0.1:3000",
- std::iter::empty::<&str>(),
- )
- .unwrap();
- let slot = radroots_sdk::transport::BlossomSlot::new();
- slot.configure(radroots_sdk::transport::BlossomConfig::from_profile(
- profile,
- ))
- .unwrap();
- slot
- }
-
- fn photo_input(file_descriptor: u64, bytes: &[u8], digest: String) -> FfiAddDraftInput {
- FfiAddDraftInput {
- schema_version: MOBILE_FFI_SCHEMA_VERSION,
- command_type: FfiAddCommandType::CreatePhotoUpdate,
- content: "Fresh carrots from this morning.".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![FfiPreparedMediaInput {
- schema_version: MOBILE_FFI_SCHEMA_VERSION,
- opaque_reference: "media:carrots-01".to_owned(),
- file_descriptor,
- sha256: digest,
- media_type: "image/png".to_owned(),
- byte_size: bytes.len() as u64,
- width: 2,
- height: 2,
- alt: "A basket of carrots".to_owned(),
- prepared_at_unix_s: 1_800_000_000,
- }],
- }
- }
-
- fn text_input(command_type: FfiAddCommandType) -> FfiAddDraftInput {
- FfiAddDraftInput {
- schema_version: MOBILE_FFI_SCHEMA_VERSION,
- command_type,
- content: "Fresh from the farm".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 exact_five_add_inputs_build_their_typed_core_commands() {
- let (update, media) = text_input(FfiAddCommandType::CreateUpdate)
- .command_and_media(1_800_000_000, None)
- .expect("update");
- assert!(matches!(update, Phase1AddCommand::CreateUpdate(_)));
- assert!(media.is_empty());
-
- let (ask, media) = text_input(FfiAddCommandType::CreateAsk)
- .command_and_media(1_800_000_000, None)
- .expect("ask");
- assert!(matches!(ask, Phase1AddCommand::CreateAsk(_)));
- assert!(media.is_empty());
-
- let mut all_day = text_input(FfiAddCommandType::CreateEvent);
- all_day.identifier = Some("market-day".to_owned());
- all_day.title = Some("Farmers market".to_owned());
- all_day.location = Some("Town square".to_owned());
- all_day.event_timing = Some(FfiEventTimingKind::AllDay);
- all_day.event_start_date = Some("2026-08-08".to_owned());
- all_day.event_end_date = Some("2026-08-09".to_owned());
- let (event, media) = all_day
- .command_and_media(1_800_000_000, None)
- .expect("all-day event");
- assert!(matches!(event, Phase1AddCommand::CreateEvent(_)));
- assert!(media.is_empty());
-
- let mut timed = text_input(FfiAddCommandType::CreateEvent);
- timed.identifier = Some("harvest-tour".to_owned());
- timed.title = Some("Harvest tour".to_owned());
- timed.event_timing = Some(FfiEventTimingKind::Timed);
- timed.event_start_unix_s = Some(1_800_000_000);
- timed.event_end_unix_s = Some(1_800_003_600);
- timed.event_timezone = Some("America/Vancouver".to_owned());
- let (event, media) = timed
- .command_and_media(1_800_000_000, None)
- .expect("timed event");
- assert!(matches!(event, Phase1AddCommand::CreateEvent(_)));
- assert!(media.is_empty());
-
- let bytes = png(2, 2);
- let mut file = tempfile::NamedTempFile::new().expect("media file");
- file.write_all(&bytes).expect("write media");
- file.flush().expect("flush media");
- let digest = Sha256::digest(&bytes).to_hex();
- let mut food = photo_input(file.as_file().as_raw_fd() as u64, &bytes, digest);
- food.command_type = FfiAddCommandType::CreateFoodAvailability;
- food.identifier = Some("carrots-2026-08".to_owned());
- food.title = Some("Carrots".to_owned());
- food.summary = Some("Fresh bunches".to_owned());
- food.location = Some("Victoria".to_owned());
- food.price_amount = Some("4.5".to_owned());
- food.currency = Some("CAD".to_owned());
- food.unit = Some("bunch".to_owned());
- food.quantity = Some("12".to_owned());
- food.food_status = Some("active".to_owned());
- let blossom = blossom_slot();
- let (food, media) = food
- .command_and_media(1_800_000_000, Some(&blossom))
- .expect("food availability");
- assert!(matches!(food, Phase1AddCommand::CreateFoodAvailability(_)));
- assert_eq!(media.len(), 1);
- }
-
- #[test]
- fn add_validation_reports_schema_shape_media_and_required_field_failures() {
- let mut wrong_schema = text_input(FfiAddCommandType::CreateUpdate);
- wrong_schema.schema_version = MOBILE_FFI_SCHEMA_VERSION + 1;
- assert_eq!(
- wrong_schema
- .command_and_media(1_800_000_000, None)
- .expect_err("schema")
- .report()
- .code,
- "unsupported_schema_version"
- );
- assert_eq!(
- text_input(FfiAddCommandType::CreateUpdate)
- .command_and_media(0, None)
- .expect_err("authored time")
- .report()
- .code,
- "invalid_add_draft"
- );
- assert_eq!(
- text_input(FfiAddCommandType::CreateEvent)
- .command_and_media(1_800_000_000, None)
- .expect_err("event identity")
- .report()
- .code,
- "event_identifier_required"
- );
- let mut food = text_input(FfiAddCommandType::CreateFoodAvailability);
- food.unit = Some("crate".to_owned());
- assert_eq!(
- food.command_and_media(1_800_000_000, None)
- .expect_err("food unit")
- .report()
- .code,
- "invalid_food_unit"
- );
- }
-
- #[test]
- fn prepared_media_accepts_only_the_exact_bounded_file_descriptor_bytes() {
- let bytes = png(2, 2);
- let mut file = tempfile::NamedTempFile::new().expect("media file");
- file.write_all(&bytes).expect("write media");
- file.flush().expect("flush media");
- let digest = Sha256::digest(&bytes).to_hex();
- let input = photo_input(file.as_file().as_raw_fd() as u64, &bytes, digest.clone());
- let blossom = blossom_slot();
-
- let (command, media) = input
- .command_and_media(1_800_000_000, Some(&blossom))
- .expect("verified media input");
- assert!(matches!(command, Phase1AddCommand::CreatePhotoUpdate(_)));
- assert_eq!(media.len(), 1);
- assert_eq!(
- media[0].url(),
- format!("http://127.0.0.1:3000/{digest}.png")
- );
- assert_eq!(
- file.as_file().metadata().expect("caller-owned media").len(),
- bytes.len() as u64
- );
- }
-
- #[test]
- fn prepared_media_rejects_file_descriptors_outside_the_platform_range() {
- let bytes = png(2, 2);
- let blossom = blossom_slot();
- let input = photo_input(u64::MAX, &bytes, Sha256::digest(&bytes).to_hex());
-
- assert_eq!(
- input
- .command_and_media(1_800_000_000, Some(&blossom))
- .expect_err("out-of-range descriptor")
- .report()
- .code,
- "media_handle_unavailable"
- );
- }
-
- #[cfg(unix)]
- #[test]
- fn prepared_media_rejects_unavailable_in_range_file_descriptors() {
- let bytes = png(2, 2);
- let blossom = blossom_slot();
- let input = photo_input(i32::MAX as u64, &bytes, Sha256::digest(&bytes).to_hex());
-
- assert_eq!(
- input
- .command_and_media(1_800_000_000, Some(&blossom))
- .expect_err("unavailable in-range descriptor")
- .report()
- .code,
- "media_handle_unavailable"
- );
- }
-
- #[test]
- fn prepared_media_rejects_digest_tamper_and_path_like_references() {
- let bytes = png(2, 2);
- let mut file = tempfile::NamedTempFile::new().expect("media file");
- file.write_all(&bytes).expect("write media");
- file.flush().expect("flush media");
-
- let tampered = photo_input(
- file.as_file().as_raw_fd() as u64,
- &bytes,
- Sha256::digest(b"other").to_hex(),
- );
- let blossom = blossom_slot();
- assert_eq!(
- tampered
- .command_and_media(1_800_000_000, Some(&blossom))
- .expect_err("digest mismatch")
- .report()
- .code,
- "media_verification_failed"
- );
-
- let mut path_like = photo_input(
- file.as_file().as_raw_fd() as u64,
- &bytes,
- Sha256::digest(&bytes).to_hex(),
- );
- path_like.media[0].opaque_reference = "file:/private/media.jpg".to_owned();
- assert_eq!(
- path_like
- .command_and_media(1_800_000_000, Some(&blossom))
- .expect_err("path-like reference")
- .report()
- .code,
- "invalid_media_reference"
- );
- }
-
- #[test]
- fn prepared_media_constructs_a_bounded_verified_upload_request() {
- let bytes = b"\x89PNG\r\n\x1a\n\0\0\0\rIHDR\0\0\0\x02\0\0\0\x02";
- let mut file = tempfile::NamedTempFile::new().expect("media file");
- file.write_all(bytes).expect("write media");
- file.flush().expect("flush media");
- let digest = Sha256::digest(bytes).to_hex();
- let mut input = photo_input(file.as_file().as_raw_fd() as u64, bytes, digest.clone());
- input.media[0].media_type = "image/png".to_owned();
-
- let prepared = PreparedMedia::try_from(input.media.remove(0)).expect("prepared media");
- let request = prepared
- .upload_request(1_800_000_000_000)
- .expect("bounded upload request");
- assert_eq!(request.sha256().to_hex(), digest);
- assert_eq!(request.byte_size(), bytes.len() as u64);
- assert_eq!(request.media_type().as_str(), "image/png");
- assert_eq!(request.dimensions().width(), 2);
- assert_eq!(request.dimensions().height(), 2);
- }
-
- #[test]
- fn media_validation_executes_every_bounded_shape_guard() {
- let bytes = png(2, 2);
- let mut file = tempfile::NamedTempFile::new().expect("media file");
- file.write_all(&bytes).expect("write media");
- file.flush().expect("flush media");
- let digest = Sha256::digest(&bytes).to_hex();
- let valid = photo_input(file.as_file().as_raw_fd() as u64, &bytes, digest).media[0].clone();
-
- let mut invalid_values = Vec::new();
- let mut value = valid.clone();
- value.byte_size = 0;
- invalid_values.push(value);
- let mut value = valid.clone();
- value.byte_size = MEDIA_FILE_MAX_BYTES + 1;
- invalid_values.push(value);
- let mut value = valid.clone();
- value.width = 0;
- invalid_values.push(value);
- let mut value = valid.clone();
- value.height = 0;
- invalid_values.push(value);
- let mut value = valid.clone();
- value.prepared_at_unix_s = 0;
- invalid_values.push(value);
- let mut value = valid.clone();
- value.alt = " ".to_owned();
- invalid_values.push(value);
- let mut value = valid.clone();
- value.alt = "a".repeat(1_025);
- invalid_values.push(value);
-
- for value in invalid_values {
- assert_eq!(
- PreparedMedia::try_from(value)
- .err()
- .expect("invalid bounded media")
- .report()
- .code,
- "invalid_media_reference"
- );
- }
-
- let mut wrong_size = valid.clone();
- wrong_size.byte_size += 1;
- assert_eq!(
- PreparedMedia::try_from(wrong_size)
- .err()
- .expect("descriptor size")
- .report()
- .code,
- "media_size_mismatch"
- );
- for reference in ["media:", "Media:item", "media:BAD", "media:item/path"] {
- assert!(!opaque_media_reference_is_valid(reference));
- }
- assert!(opaque_media_reference_is_valid("media:item_01-a"));
- assert!(!opaque_media_reference_is_valid(&format!(
- "media:{}",
- "a".repeat(MEDIA_REFERENCE_MAX_BYTES)
- )));
- }
-
- #[test]
- fn event_and_post_optional_branches_remain_strict_and_complete() {
- let mut minimal_date = text_input(FfiAddCommandType::CreateEvent);
- minimal_date.content.clear();
- minimal_date.identifier = Some("minimal-date".to_owned());
- minimal_date.title = Some("Minimal date".to_owned());
- minimal_date.event_timing = Some(FfiEventTimingKind::AllDay);
- minimal_date.event_start_date = Some("2026-08-08".to_owned());
- assert!(minimal_date.command_and_media(1_800_000_000, None).is_ok());
-
- let mut minimal_time = text_input(FfiAddCommandType::CreateEvent);
- minimal_time.content.clear();
- minimal_time.identifier = Some("minimal-time".to_owned());
- minimal_time.title = Some("Minimal time".to_owned());
- minimal_time.event_timing = Some(FfiEventTimingKind::Timed);
- minimal_time.event_start_unix_s = Some(1_800_000_000);
- assert!(minimal_time.command_and_media(1_800_000_000, None).is_ok());
-
- let bytes = png(2, 2);
- let mut file = tempfile::NamedTempFile::new().expect("media file");
- file.write_all(&bytes).expect("write media");
- file.flush().expect("flush media");
- let digest = Sha256::digest(&bytes).to_hex();
- let mut event = photo_input(file.as_file().as_raw_fd() as u64, &bytes, digest);
- event.command_type = FfiAddCommandType::CreateEvent;
- event.identifier = Some("event-image".to_owned());
- event.title = Some("Event image".to_owned());
- event.event_timing = Some(FfiEventTimingKind::Timed);
- event.event_start_unix_s = Some(1_800_000_000);
- let blossom = blossom_slot();
- assert!(
- event
- .clone()
- .command_and_media(1_800_000_000, Some(&blossom))
- .is_ok()
- );
-
- let mut second = event.media[0].clone();
- second.opaque_reference = "media:event-image-two".to_owned();
- event.media.push(second);
- assert_eq!(
- event
- .command_and_media(1_800_000_000, Some(&blossom))
- .expect_err("event media limit")
- .report()
- .code,
- "event_image_limit"
- );
- }
-
- #[test]
- fn content_references_and_identifier_decoding_cover_all_outcomes() {
- let bytes = png(2, 2);
- let mut file = tempfile::NamedTempFile::new().expect("media file");
- file.write_all(&bytes).expect("write media");
- file.flush().expect("flush media");
- let digest = Sha256::digest(&bytes).to_hex();
- let input = photo_input(file.as_file().as_raw_fd() as u64, &bytes, digest);
- let blossom = blossom_slot();
- let prepared = PreparedMedia::try_from(input.media[0].clone())
- .expect("prepared media")
- .bind(&blossom)
- .expect("bound media");
- let url = prepared.descriptor.url().as_str().to_owned();
-
- assert_eq!(
- content_with_media_references(" ".to_owned(), std::slice::from_ref(&prepared))
- .expect_err("blank content")
- .report()
- .code,
- "content_required"
- );
- assert_eq!(
- content_with_media_references(
- format!("caption\n{url}"),
- std::slice::from_ref(&prepared)
- )
- .expect("one existing reference")
- .match_indices(&url)
- .count(),
- 1
- );
- assert!(
- content_with_media_references("caption\n".to_owned(), std::slice::from_ref(&prepared))
- .expect("newline append")
- .ends_with(&url)
- );
- assert_eq!(
- content_with_media_references(
- format!("{url}\n{url}"),
- std::slice::from_ref(&prepared),
- )
- .expect_err("duplicate reference")
- .report()
- .code,
- "duplicate_media_reference"
- );
-
- let mut update = text_input(FfiAddCommandType::CreateUpdate);
- update.media.push(input.media[0].clone());
- assert_eq!(
- update
- .command_and_media(1_800_000_000, Some(&blossom))
- .expect_err("update media")
- .report()
- .code,
- "media_not_allowed"
- );
- let mut over_limit = text_input(FfiAddCommandType::CreateUpdate);
- over_limit.media = vec![input.media[0].clone(); 21];
- assert_eq!(
- over_limit
- .command_and_media(1_800_000_000, None)
- .expect_err("media count")
- .report()
- .code,
- "invalid_add_draft"
- );
-
- assert_eq!(decode_id(&"01".repeat(16), "bad").expect("id"), [1; 16]);
- assert_eq!(
- decode_id("01", "bad").expect_err("short id").report().code,
- "bad"
- );
- assert_eq!(
- decode_id(&"gg".repeat(16), "bad")
- .expect_err("non-hex id")
- .report()
- .code,
- "bad"
- );
- }
-}
diff --git a/crates/mobile_ffi/src/error.rs b/crates/mobile_ffi/src/error.rs
@@ -1,483 +0,0 @@
-use radroots_mobile_core::runtime::product_surface::{
- Phase1DraftError, ProfileMetadataError, SettingsError, TodayError,
-};
-use thiserror::Error;
-
-use crate::MOBILE_FFI_SCHEMA_VERSION;
-
-#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)]
-pub struct RadrootsErrorRecord {
- pub schema_version: u16,
- pub code: String,
- pub category: String,
- pub retryable: bool,
- pub recovery_actions: Vec<String>,
- pub operation_id: Option<String>,
- pub capability_id: Option<String>,
- pub safe_message: String,
-}
-
-/// The only error envelope exported across the native language boundary.
-#[derive(Debug, Error, uniffi::Error)]
-pub enum RadrootsAppError {
- #[error("radroots operation failed: {report:?}")]
- Failure { report: RadrootsErrorRecord },
-}
-
-impl RadrootsAppError {
- pub(crate) fn initialization(_message: impl Into<String>) -> Self {
- Self::failure(
- "initialization_failed",
- "initialization",
- true,
- &["retry"],
- "The Radroots runtime could not be initialized.",
- )
- }
-
- pub(crate) fn invalid_argument(code: impl Into<String>) -> Self {
- Self::Failure {
- report: RadrootsErrorRecord {
- schema_version: MOBILE_FFI_SCHEMA_VERSION,
- code: code.into(),
- category: "validation".to_owned(),
- retryable: false,
- recovery_actions: vec!["correct_input".to_owned()],
- operation_id: None,
- capability_id: None,
- safe_message: "The request is invalid.".to_owned(),
- },
- }
- }
-
- pub(crate) fn failure(
- code: &str,
- category: &str,
- retryable: bool,
- recovery_actions: &[&str],
- safe_message: &str,
- ) -> Self {
- Self::Failure {
- report: RadrootsErrorRecord {
- schema_version: MOBILE_FFI_SCHEMA_VERSION,
- code: code.to_owned(),
- category: category.to_owned(),
- retryable,
- recovery_actions: recovery_actions
- .iter()
- .map(|action| (*action).to_owned())
- .collect(),
- operation_id: None,
- capability_id: None,
- safe_message: safe_message.to_owned(),
- },
- }
- }
-
- pub fn report(&self) -> &RadrootsErrorRecord {
- match self {
- Self::Failure { report } => report,
- }
- }
-
- pub(crate) fn with_operation_id(mut self, operation_id: String) -> Self {
- match &mut self {
- Self::Failure { report } => report.operation_id = Some(operation_id),
- }
- self
- }
-}
-
-impl From<radroots_mobile_core::RadrootsAppError> for RadrootsAppError {
- fn from(error: radroots_mobile_core::RadrootsAppError) -> Self {
- match error {
- radroots_mobile_core::RadrootsAppError::Sdk { report } => Self::Failure {
- report: RadrootsErrorRecord {
- schema_version: MOBILE_FFI_SCHEMA_VERSION,
- code: report.code,
- category: report.class,
- retryable: report.retryable,
- recovery_actions: report.recovery_actions,
- operation_id: report.operation_id,
- capability_id: report.capability_id,
- safe_message: report.message,
- },
- },
- radroots_mobile_core::RadrootsAppError::Store { report } => Self::Failure {
- report: RadrootsErrorRecord {
- schema_version: MOBILE_FFI_SCHEMA_VERSION,
- code: report.code,
- category: report.class,
- retryable: report.retryable,
- recovery_actions: report.recovery_actions,
- operation_id: None,
- capability_id: None,
- safe_message: report.message,
- },
- },
- radroots_mobile_core::RadrootsAppError::Initialization(_) => {
- Self::initialization("redacted")
- }
- radroots_mobile_core::RadrootsAppError::Runtime(_) => Self::failure(
- "runtime_failed",
- "runtime",
- true,
- &["retry"],
- "The runtime operation failed.",
- ),
- radroots_mobile_core::RadrootsAppError::Unsupported(_) => Self::failure(
- "unsupported",
- "capability",
- false,
- &[],
- "The requested capability is unsupported.",
- ),
- radroots_mobile_core::RadrootsAppError::Internal(_) => Self::failure(
- "internal_failure",
- "internal",
- false,
- &["restart"],
- "An internal Radroots error occurred.",
- ),
- }
- }
-}
-
-impl From<TodayError> for RadrootsAppError {
- fn from(error: TodayError) -> Self {
- let (code, retryable, actions) = match error {
- TodayError::InvalidRequest | TodayError::EventNotVisible => {
- ("today_invalid_request", false, &["correct_input"][..])
- }
- TodayError::ProjectionMissing | TodayError::SnapshotMissing => {
- ("today_refresh_required", true, &["refresh"][..])
- }
- TodayError::CursorPositionMissing | TodayError::Cursor(_) => {
- ("today_cursor_invalid", true, &["restart_pagination"][..])
- }
- TodayError::RuntimeUnavailable => ("today_runtime_unavailable", true, &["retry"][..]),
- TodayError::InboundMedia(_) => ("today_media_invalid", false, &["retry_media"][..]),
- TodayError::InboundRetrieval(error) => {
- if error.retryable() {
- ("today_media_retrieval_failed", true, &["retry_media"][..])
- } else {
- ("today_media_retrieval_failed", false, &["review_media"][..])
- }
- }
- TodayError::CorruptProjection | TodayError::Serialization | TodayError::Storage(_) => {
- ("today_state_failed", true, &["rebuild", "retry"][..])
- }
- };
- Self::failure(
- code,
- "today",
- retryable,
- actions,
- "The Today operation could not be completed.",
- )
- }
-}
-
-impl From<Phase1DraftError> for RadrootsAppError {
- fn from(error: Phase1DraftError) -> Self {
- let (code, retryable, actions) = match error {
- Phase1DraftError::IdentityUnavailable => (
- "identity_unavailable",
- true,
- &["unlock_identity", "retry"][..],
- ),
- Phase1DraftError::InvalidDraft => ("draft_invalid", false, &["correct_input"][..]),
- Phase1DraftError::InvalidMedia => {
- ("draft_media_invalid", false, &["replace_media"][..])
- }
- Phase1DraftError::InvalidQueuePolicy => {
- ("draft_queue_policy_invalid", false, &["correct_input"][..])
- }
- Phase1DraftError::RevisionConflict => {
- ("draft_revision_conflict", true, &["refresh"][..])
- }
- Phase1DraftError::NotFound => ("draft_not_found", false, &[][..]),
- Phase1DraftError::Terminal => ("draft_terminal", false, &[][..]),
- Phase1DraftError::MediaNotReady => {
- ("draft_media_not_ready", true, &["retry_media"][..])
- }
- Phase1DraftError::OperationUnavailable => {
- ("authoring_unavailable", true, &["retry"][..])
- }
- Phase1DraftError::Operation | Phase1DraftError::Storage | Phase1DraftError::Overlay => {
- ("authoring_failed", true, &["retry", "inspect_outbox"][..])
- }
- Phase1DraftError::Corrupt => ("draft_corrupt", false, &["recover_draft"][..]),
- Phase1DraftError::ClockUnavailable => {
- ("operation_clock_unavailable", true, &["retry"][..])
- }
- Phase1DraftError::DeadlineOverflow => ("operation_deadline_overflow", false, &[][..]),
- Phase1DraftError::NoWritableRelay => (
- "writable_relay_unavailable",
- true,
- &["configure_relay", "retry"][..],
- ),
- Phase1DraftError::InvalidRevision => {
- ("revision_invalid", false, &["review_revision"][..])
- }
- };
- Self::failure(
- code,
- "authoring",
- retryable,
- actions,
- "The authored operation could not be completed.",
- )
- }
-}
-
-impl From<SettingsError> for RadrootsAppError {
- fn from(error: SettingsError) -> Self {
- let retryable = matches!(
- error,
- SettingsError::RevisionConflict
- | SettingsError::RevisionExhausted
- | SettingsError::Storage
- );
- let actions = if matches!(error, SettingsError::RevisionConflict) {
- &["refresh"] as &[&str]
- } else if retryable {
- &["retry"] as &[&str]
- } else {
- &["correct_input"] as &[&str]
- };
- Self::failure(
- error.code(),
- "settings",
- retryable,
- actions,
- "The settings operation could not be completed.",
- )
- }
-}
-
-impl From<ProfileMetadataError> for RadrootsAppError {
- fn from(error: ProfileMetadataError) -> Self {
- Self::failure(
- error.code(),
- "profile",
- false,
- &["correct_input"],
- "The profile metadata is invalid.",
- )
- }
-}
-
-#[cfg(test)]
-mod tests {
- use super::*;
-
- #[test]
- fn internal_core_messages_are_not_copied_to_the_ffi_record() {
- let error = RadrootsAppError::from(radroots_mobile_core::RadrootsAppError::internal(
- "private/path/secret-value",
- ));
- assert_eq!(error.report().code, "internal_failure");
- assert!(!format!("{error:?}").contains("secret-value"));
- assert!(!error.report().safe_message.contains("secret-value"));
- }
-
- #[test]
- fn every_core_error_class_maps_to_a_versioned_redacted_record() {
- let sdk = radroots_mobile_core::RadrootsAppError::Sdk {
- report: radroots_mobile_core::SdkErrorRecord {
- schema_version: 1,
- code: "relay_unavailable".to_owned(),
- class: "transport".to_owned(),
- retryable: true,
- recovery_actions: vec!["retry".to_owned()],
- operation_id: Some("operation".to_owned()),
- capability_id: Some("relay".to_owned()),
- message: "Safe relay failure".to_owned(),
- },
- };
- let sdk = RadrootsAppError::from(sdk);
- assert_eq!(sdk.report().code, "relay_unavailable");
- assert_eq!(sdk.report().operation_id.as_deref(), Some("operation"));
-
- let store = radroots_mobile_core::RadrootsAppError::Store {
- report: radroots_mobile_core::StoreErrorRecord {
- schema_version: 1,
- code: "store_locked".to_owned(),
- class: "storage".to_owned(),
- retryable: true,
- recovery_actions: vec!["unlock".to_owned()],
- message: "Safe store failure".to_owned(),
- },
- };
- let store = RadrootsAppError::from(store);
- assert_eq!(store.report().code, "store_locked");
- assert!(store.report().operation_id.is_none());
-
- for (core, code, category) in [
- (
- radroots_mobile_core::RadrootsAppError::initialization("secret"),
- "initialization_failed",
- "initialization",
- ),
- (
- radroots_mobile_core::RadrootsAppError::runtime("secret"),
- "runtime_failed",
- "runtime",
- ),
- (
- radroots_mobile_core::RadrootsAppError::unsupported("secret"),
- "unsupported",
- "capability",
- ),
- (
- radroots_mobile_core::RadrootsAppError::internal("secret"),
- "internal_failure",
- "internal",
- ),
- ] {
- let ffi = RadrootsAppError::from(core);
- assert_eq!(ffi.report().code, code);
- assert_eq!(ffi.report().category, category);
- assert!(!ffi.report().safe_message.contains("secret"));
- }
- }
-
- #[test]
- fn today_and_draft_failures_have_stable_recovery_classes() {
- for error in [
- TodayError::InvalidRequest,
- TodayError::EventNotVisible,
- TodayError::ProjectionMissing,
- TodayError::SnapshotMissing,
- TodayError::CursorPositionMissing,
- TodayError::RuntimeUnavailable,
- TodayError::CorruptProjection,
- TodayError::Serialization,
- ] {
- let ffi = RadrootsAppError::from(error);
- assert_eq!(ffi.report().category, "today");
- assert!(!ffi.report().safe_message.is_empty());
- }
-
- for error in [
- Phase1DraftError::IdentityUnavailable,
- Phase1DraftError::InvalidDraft,
- Phase1DraftError::InvalidMedia,
- Phase1DraftError::InvalidQueuePolicy,
- Phase1DraftError::RevisionConflict,
- Phase1DraftError::NotFound,
- Phase1DraftError::Terminal,
- Phase1DraftError::MediaNotReady,
- Phase1DraftError::OperationUnavailable,
- Phase1DraftError::Operation,
- Phase1DraftError::Storage,
- Phase1DraftError::Overlay,
- Phase1DraftError::Corrupt,
- Phase1DraftError::ClockUnavailable,
- Phase1DraftError::DeadlineOverflow,
- Phase1DraftError::NoWritableRelay,
- Phase1DraftError::InvalidRevision,
- ] {
- let ffi = RadrootsAppError::from(error);
- assert_eq!(ffi.report().category, "authoring");
- assert!(!ffi.report().safe_message.is_empty());
- }
- }
-
- #[test]
- fn cursor_media_settings_and_profile_failures_cover_every_stable_class() {
- use radroots_mobile_core::runtime::product_surface::{
- CursorError, IdentitySettingsError, Phase1InboundMediaError,
- };
-
- for cursor in [
- CursorError::InvalidContext,
- CursorError::Malformed,
- CursorError::Integrity,
- CursorError::Version,
- CursorError::ContextMismatch,
- CursorError::SnapshotMismatch,
- CursorError::Stale,
- CursorError::InvalidPosition,
- ] {
- let error = RadrootsAppError::from(TodayError::Cursor(cursor));
- assert_eq!(error.report().code, "today_cursor_invalid");
- }
- for media in [
- Phase1InboundMediaError::InvalidReference,
- Phase1InboundMediaError::InvalidDigest,
- Phase1InboundMediaError::MissingDigest,
- Phase1InboundMediaError::InvalidMediaType,
- Phase1InboundMediaError::InvalidDimensions,
- Phase1InboundMediaError::InvalidByteSize,
- Phase1InboundMediaError::InvalidAlt,
- Phase1InboundMediaError::MetadataMismatch,
- Phase1InboundMediaError::InvalidOperation,
- Phase1InboundMediaError::OperationMismatch,
- Phase1InboundMediaError::InvalidFailure,
- Phase1InboundMediaError::InvalidConfiguration,
- Phase1InboundMediaError::ConfigurationMismatch,
- Phase1InboundMediaError::InvalidVerificationTime,
- Phase1InboundMediaError::InvalidCachePolicy,
- Phase1InboundMediaError::InvalidCacheObservation,
- Phase1InboundMediaError::CacheQuotaExceeded,
- Phase1InboundMediaError::ArtifactCollision,
- Phase1InboundMediaError::CorruptReceipt,
- Phase1InboundMediaError::CorruptState,
- Phase1InboundMediaError::UnsupportedSchema,
- Phase1InboundMediaError::CacheUnavailable,
- Phase1InboundMediaError::CacheIo,
- Phase1InboundMediaError::CorruptArtifact,
- ] {
- let error = RadrootsAppError::from(TodayError::InboundMedia(media));
- assert_eq!(error.report().code, "today_media_invalid");
- }
- for settings in [
- SettingsError::UnknownRelayAccess,
- SettingsError::InvalidRelayEndpoint,
- SettingsError::InvalidRelayEndpointCount,
- SettingsError::DuplicateRelayEndpoint,
- SettingsError::InvalidBlossomEndpoint,
- SettingsError::InvalidBlossomEndpointCount,
- SettingsError::NetworkEnvironmentMismatch,
- SettingsError::InvalidMediaCacheBytes,
- SettingsError::InvalidMediaCacheArtifacts,
- SettingsError::RevisionConflict,
- SettingsError::RevisionExhausted,
- SettingsError::UnsupportedSchema,
- SettingsError::CorruptDocument,
- SettingsError::Storage,
- SettingsError::Identity(IdentitySettingsError::InvalidIdentityId),
- ] {
- let expected_retryable = matches!(
- settings,
- SettingsError::RevisionConflict
- | SettingsError::RevisionExhausted
- | SettingsError::Storage
- );
- let error = RadrootsAppError::from(settings);
- assert_eq!(error.report().retryable, expected_retryable);
- }
- for profile in [
- ProfileMetadataError::InvalidName,
- ProfileMetadataError::InvalidDisplayName,
- ProfileMetadataError::InvalidAbout,
- ProfileMetadataError::InvalidNip05,
- ] {
- assert_eq!(RadrootsAppError::from(profile).report().category, "profile");
- }
- assert_eq!(
- RadrootsAppError::initialization("private").report().code,
- "initialization_failed"
- );
- assert_eq!(
- RadrootsAppError::invalid_argument("bad")
- .with_operation_id("operation".to_owned())
- .report()
- .operation_id
- .as_deref(),
- Some("operation")
- );
- }
-}
diff --git a/crates/mobile_ffi/src/lib.rs b/crates/mobile_ffi/src/lib.rs
@@ -1,46 +0,0 @@
-// UniFFI serializes the complete versioned error record across the language
-// boundary; keeping it by value preserves the generated wire contract.
-#![allow(clippy::result_large_err)]
-#![cfg_attr(coverage_nightly, feature(coverage_attribute))]
-
-uniffi::setup_scaffolding!("radroots_mobile_core");
-
-mod dto;
-pub mod logging;
-mod operations;
-mod runtime;
-mod signer;
-mod subscription;
-
-pub use dto::*;
-pub use error::{RadrootsAppError, RadrootsErrorRecord};
-pub use operations::*;
-pub use runtime::{ProtectedDataAvailability, RadrootsRuntime};
-pub use signer::{
- HostSigningOutcome, HostSigningPurpose, HostSigningRequest, HostSigningResult,
- RadrootsHostSigner, SignerAvailabilityRecord, SignerStatusRecord,
-};
-pub use subscription::{
- FfiRuntimeChangeKind, FfiRuntimeChangeRecord, FfiSubscriptionHandle, RadrootsRuntimeObserver,
-};
-
-mod error;
-
-#[allow(
- clippy::if_same_then_else,
- reason = "coverage probe intentionally exercises both paths with a stable value"
-)]
-pub fn coverage_branch_probe(input: bool) -> &'static str {
- if input { "ffi" } else { "ffi" }
-}
-
-#[cfg(test)]
-mod tests {
- use super::coverage_branch_probe;
-
- #[test]
- fn coverage_branch_probe_hits_both_paths() {
- assert_eq!(coverage_branch_probe(true), "ffi");
- assert_eq!(coverage_branch_probe(false), "ffi");
- }
-}
diff --git a/crates/mobile_ffi/src/logging.rs b/crates/mobile_ffi/src/logging.rs
@@ -1,284 +0,0 @@
-mod writer;
-
-use std::fs;
-use std::path::{Component, Path, PathBuf};
-use std::sync::Mutex;
-
-use tracing_appender::non_blocking::WorkerGuard;
-use tracing_subscriber::prelude::*;
-
-use self::writer::{LogRotation, SizeRotatingWriter};
-
-#[derive(Debug, Clone, PartialEq, Eq)]
-struct LoggingOptions {
- dir: Option<PathBuf>,
- file_name: String,
- stdout: bool,
- rotation: LogRotation,
-}
-
-impl Default for LoggingOptions {
- fn default() -> Self {
- Self {
- dir: None,
- file_name: "radroots.log".to_owned(),
- stdout: true,
- rotation: LogRotation::default(),
- }
- }
-}
-
-struct ActiveLogging {
- options: LoggingOptions,
- _file_guard: Option<WorkerGuard>,
-}
-
-static LOGGING: Mutex<Option<ActiveLogging>> = Mutex::new(None);
-
-#[cfg_attr(not(coverage_nightly), uniffi::export)]
-pub fn init_logging(
- dir: Option<String>,
- file_name: Option<String>,
- is_stdout: Option<bool>,
-) -> Result<(), crate::RadrootsAppError> {
- let opts = logging_options(dir, file_name, is_stdout);
- initialize(opts).map_err(crate::RadrootsAppError::initialization)
-}
-
-fn logging_options(
- dir: Option<String>,
- file_name: Option<String>,
- is_stdout: Option<bool>,
-) -> LoggingOptions {
- LoggingOptions {
- dir: dir.map(PathBuf::from),
- file_name: file_name.unwrap_or_else(|| "radroots.log".to_string()),
- stdout: is_stdout.unwrap_or(true),
- ..LoggingOptions::default()
- }
-}
-
-fn initialize(options: LoggingOptions) -> Result<(), String> {
- validate_options(&options)?;
- let mut active = LOGGING
- .lock()
- .map_err(|_| "logging initialization state is poisoned".to_owned())?;
- if let Some(existing) = active.as_ref() {
- return if existing.options == options {
- Ok(())
- } else {
- Err("logging is already initialized with a different configuration".to_owned())
- };
- }
-
- let (file_writer, file_guard) = build_file_writer(&options)?;
- let file_layer = file_writer.as_ref().map(|writer| {
- tracing_subscriber::fmt::layer()
- .with_writer(writer.clone())
- .with_ansi(false)
- .with_target(false)
- });
- let stdout_layer = options.stdout.then(|| {
- tracing_subscriber::fmt::layer()
- .with_writer(std::io::stdout)
- .with_target(false)
- });
- tracing_subscriber::registry()
- .with(file_layer)
- .with(stdout_layer)
- .try_init()
- .map_err(|error| error.to_string())?;
- *active = Some(ActiveLogging {
- options,
- _file_guard: file_guard,
- });
- Ok(())
-}
-
-fn validate_options(options: &LoggingOptions) -> Result<(), String> {
- if options.dir.is_none() && !options.stdout {
- return Err("logging requires at least one configured output".to_owned());
- }
- if options.file_name.is_empty()
- || Path::new(&options.file_name).components().count() != 1
- || !matches!(
- Path::new(&options.file_name).components().next(),
- Some(Component::Normal(_))
- )
- {
- return Err("log file_name must be a safe basename".to_owned());
- }
- Ok(())
-}
-
-type FileWriter = tracing_appender::non_blocking::NonBlocking;
-
-fn build_file_writer(
- options: &LoggingOptions,
-) -> Result<(Option<FileWriter>, Option<WorkerGuard>), String> {
- #[cfg(windows)]
- if options.dir.is_some() {
- return Err(
- "secure file logging is unavailable until Windows ACL and reparse-point enforcement is active"
- .to_owned(),
- );
- }
- let Some(dir) = options.dir.as_ref() else {
- return Ok((None, None));
- };
- fs::create_dir_all(dir).map_err(|error| error.to_string())?;
- let metadata = fs::symlink_metadata(dir).map_err(|error| error.to_string())?;
- if metadata.file_type().is_symlink() || !metadata.is_dir() {
- return Err("log directory is not a safe directory".to_owned());
- }
- let writer = SizeRotatingWriter::new(dir.join(&options.file_name), options.rotation)
- .map_err(|error| error.to_string())?;
- let (writer, guard) = tracing_appender::non_blocking::NonBlockingBuilder::default()
- .buffered_lines_limit(8192)
- .lossy(false)
- .thread_name("radroots-app-log-writer")
- .finish(writer);
- Ok((Some(writer), Some(guard)))
-}
-
-#[cfg_attr(not(coverage_nightly), uniffi::export)]
-pub fn init_logging_stdout() -> Result<(), crate::RadrootsAppError> {
- initialize(LoggingOptions::default()).map_err(crate::RadrootsAppError::initialization)
-}
-
-#[cfg_attr(not(coverage_nightly), uniffi::export)]
-pub fn log_info(msg: String) -> Result<(), crate::RadrootsAppError> {
- tracing::info!("{msg}");
- Ok(())
-}
-
-#[cfg_attr(not(coverage_nightly), uniffi::export)]
-pub fn log_error(msg: String) -> Result<(), crate::RadrootsAppError> {
- tracing::error!("{msg}");
- Ok(())
-}
-
-#[cfg_attr(not(coverage_nightly), uniffi::export)]
-pub fn log_debug(msg: String) -> Result<(), crate::RadrootsAppError> {
- tracing::debug!("{msg}");
- Ok(())
-}
-
-#[cfg(test)]
-#[cfg_attr(coverage_nightly, coverage(off))]
-mod tests {
- use super::{
- LogRotation, LoggingOptions, build_file_writer, initialize, log_debug, log_error, log_info,
- logging_options, validate_options,
- };
- use std::path::PathBuf;
-
- #[test]
- fn logging_options_adopt_bounded_library_defaults() {
- let options = logging_options(
- Some("logs".to_owned()),
- Some("mobile.log".to_owned()),
- Some(false),
- );
-
- assert_eq!(options.dir, Some(PathBuf::from("logs")));
- assert_eq!(options.file_name, "mobile.log");
- assert!(!options.stdout);
- assert_eq!(options.rotation, LogRotation::default());
- }
-
- #[test]
- fn logging_options_preserve_public_api_defaults() {
- let options = logging_options(None, None, None);
-
- assert_eq!(options.file_name, "radroots.log");
- assert!(options.stdout);
- assert_eq!(options, LoggingOptions::default());
- }
-
- #[test]
- fn validation_rejects_missing_outputs_and_unsafe_names() {
- let mut options = LoggingOptions {
- stdout: false,
- ..LoggingOptions::default()
- };
- assert!(validate_options(&options).is_err());
-
- options.stdout = true;
- for name in ["", "../radroots.log", "nested/radroots.log", "."] {
- options.file_name = name.to_owned();
- assert!(validate_options(&options).is_err(), "accepted {name:?}");
- }
-
- options.dir = Some(PathBuf::from("logs"));
- options.file_name = "radroots.log".to_owned();
- options.stdout = false;
- validate_options(&options).expect("file output is sufficient");
- }
-
- #[test]
- fn file_writer_is_optional_and_rejects_non_directories() {
- let options = LoggingOptions::default();
- let (writer, guard) = build_file_writer(&options).expect("stdout only");
- assert!(writer.is_none());
- assert!(guard.is_none());
-
- let directory = tempfile::tempdir().expect("temporary directory");
- let file = directory.path().join("not-a-directory");
- std::fs::write(&file, b"not a directory").expect("fixture");
- let options = LoggingOptions {
- dir: Some(file),
- ..LoggingOptions::default()
- };
- assert!(build_file_writer(&options).is_err());
-
- #[cfg(unix)]
- {
- let target = directory.path().join("real-directory");
- std::fs::create_dir(&target).expect("real directory");
- let link = directory.path().join("linked-directory");
- std::os::unix::fs::symlink(&target, &link).expect("directory symlink");
- let options = LoggingOptions {
- dir: Some(link),
- ..LoggingOptions::default()
- };
- assert!(build_file_writer(&options).is_err());
- }
- }
-
- #[test]
- fn logging_entry_points_accept_secret_safe_messages() {
- log_info("info".to_owned()).expect("info");
- log_error("error".to_owned()).expect("error");
- log_debug("debug".to_owned()).expect("debug");
- }
-
- #[cfg(windows)]
- #[test]
- fn windows_file_logging_fails_before_filesystem_mutation() {
- let directory = tempfile::tempdir().expect("temporary directory");
- let requested = directory.path().join("must-not-exist");
- let options = LoggingOptions {
- dir: Some(requested.clone()),
- stdout: false,
- ..LoggingOptions::default()
- };
- let error = build_file_writer(&options).expect_err("ACL capability must fail closed");
- assert!(error.contains("ACL"));
- assert!(error.contains("reparse-point"));
- assert!(!requested.exists());
- }
-
- #[test]
- fn initialization_is_idempotent_but_rejects_reconfiguration() {
- let directory = tempfile::tempdir().expect("temporary directory");
- let options = LoggingOptions {
- dir: Some(directory.path().to_owned()),
- stdout: false,
- ..LoggingOptions::default()
- };
- initialize(options.clone()).expect("first initialization");
- initialize(options).expect("idempotent initialization");
- assert!(initialize(LoggingOptions::default()).is_err());
- }
-}
diff --git a/crates/mobile_ffi/src/logging/writer.rs b/crates/mobile_ffi/src/logging/writer.rs
@@ -1,436 +0,0 @@
-use std::ffi::{OsStr, OsString};
-#[cfg(any(test, not(unix)))]
-use std::fs;
-use std::fs::File;
-use std::io::{self, Write};
-use std::path::{Path, PathBuf};
-
-const DEFAULT_MAX_FILE_BYTES: u64 = 10 * 1024 * 1024;
-const DEFAULT_RETAINED_FILES: usize = 5;
-
-#[derive(Debug, Clone, Copy, PartialEq, Eq)]
-pub(super) struct LogRotation {
- max_file_bytes: u64,
- retained_files: usize,
-}
-
-impl Default for LogRotation {
- fn default() -> Self {
- Self {
- max_file_bytes: DEFAULT_MAX_FILE_BYTES,
- retained_files: DEFAULT_RETAINED_FILES,
- }
- }
-}
-
-pub(super) struct SizeRotatingWriter {
- #[cfg(not(unix))]
- path: PathBuf,
- #[cfg(unix)]
- directory: File,
- file_name: OsString,
- file: Option<File>,
- bytes_written: u64,
- policy: LogRotation,
-}
-
-impl SizeRotatingWriter {
- pub(super) fn new(path: PathBuf, policy: LogRotation) -> io::Result<Self> {
- let file_name = path
- .file_name()
- .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "log target has no name"))?
- .to_owned();
- #[cfg(unix)]
- let directory = open_secure_directory(path.parent().ok_or_else(|| {
- io::Error::new(io::ErrorKind::InvalidInput, "log target has no parent")
- })?)?;
- #[cfg(unix)]
- let file = open_append_at(&directory, &file_name)?;
- #[cfg(not(unix))]
- let file = open_append(&path)?;
- let bytes_written = file.metadata()?.len();
- let mut writer = Self {
- #[cfg(not(unix))]
- path,
- #[cfg(unix)]
- directory,
- file_name,
- file: Some(file),
- bytes_written,
- policy,
- };
- if writer.bytes_written >= writer.policy.max_file_bytes {
- writer.rotate()?;
- }
- Ok(writer)
- }
-
- fn rotate(&mut self) -> io::Result<()> {
- if let Some(mut file) = self.file.take() {
- file.flush()?;
- file.sync_data()?;
- }
- if self.policy.retained_files == 1 {
- self.remove_if_present(&self.file_name)?;
- } else {
- self.remove_if_present(&rotated_name(
- &self.file_name,
- self.policy.retained_files - 1,
- ))?;
- for index in (2..self.policy.retained_files).rev() {
- self.rename_if_present(
- &rotated_name(&self.file_name, index - 1),
- &rotated_name(&self.file_name, index),
- )?;
- }
- self.rename_if_present(&self.file_name, &rotated_name(&self.file_name, 1))?;
- }
- #[cfg(unix)]
- let file = open_append_at(&self.directory, &self.file_name)?;
- #[cfg(not(unix))]
- let file = open_append(&self.path)?;
- self.file = Some(file);
- self.bytes_written = 0;
- Ok(())
- }
-
- fn remove_if_present(&self, name: &OsStr) -> io::Result<()> {
- #[cfg(unix)]
- {
- use rustix::fs::{AtFlags, unlinkat};
- match unlinkat(&self.directory, name, AtFlags::empty()) {
- Ok(()) => Ok(()),
- Err(error) if error == rustix::io::Errno::NOENT => Ok(()),
- Err(error) => Err(io::Error::from_raw_os_error(error.raw_os_error())),
- }
- }
- #[cfg(not(unix))]
- remove_if_present(&self.path.with_file_name(name))
- }
-
- fn rename_if_present(&self, source: &OsStr, target: &OsStr) -> io::Result<()> {
- #[cfg(unix)]
- {
- use rustix::fs::renameat;
- match renameat(&self.directory, source, &self.directory, target) {
- Ok(()) => Ok(()),
- Err(error) if error == rustix::io::Errno::NOENT => Ok(()),
- Err(error) => Err(io::Error::from_raw_os_error(error.raw_os_error())),
- }
- }
- #[cfg(not(unix))]
- rename_if_present(
- &self.path.with_file_name(source),
- &self.path.with_file_name(target),
- )
- }
-
- fn file_mut(&mut self) -> io::Result<&mut File> {
- self.file
- .as_mut()
- .ok_or_else(|| io::Error::other("log file is unavailable"))
- }
-}
-
-impl Write for SizeRotatingWriter {
- fn write(&mut self, buffer: &[u8]) -> io::Result<usize> {
- let incoming = u64::try_from(buffer.len())
- .map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "log event is too large"))?;
- if incoming > self.policy.max_file_bytes {
- return Err(io::Error::new(
- io::ErrorKind::InvalidData,
- "log event exceeds the configured file limit",
- ));
- }
- if self.bytes_written > 0
- && self.bytes_written.saturating_add(incoming) > self.policy.max_file_bytes
- {
- self.rotate()?;
- }
- let written = self.file_mut()?.write(buffer)?;
- self.bytes_written = self
- .bytes_written
- .saturating_add(u64::try_from(written).unwrap_or(u64::MAX));
- Ok(written)
- }
-
- fn flush(&mut self) -> io::Result<()> {
- self.file_mut()?.flush()
- }
-}
-
-#[cfg(test)]
-fn reject_unsafe_target(path: &Path) -> io::Result<()> {
- match fs::symlink_metadata(path) {
- Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_file() => Err(
- io::Error::new(io::ErrorKind::InvalidInput, "log target is not a safe file"),
- ),
- Ok(_) => Ok(()),
- Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()),
- Err(error) => Err(error),
- }
-}
-
-#[cfg(unix)]
-fn open_secure_directory(path: &Path) -> io::Result<File> {
- use rustix::fs::{FileType, Mode, OFlags, fstat, open};
- use rustix::process::geteuid;
-
- let directory = open(
- path,
- OFlags::RDONLY | OFlags::DIRECTORY | OFlags::NOFOLLOW | OFlags::CLOEXEC,
- Mode::empty(),
- )
- .map_err(errno_to_io)?;
- let status = fstat(&directory).map_err(errno_to_io)?;
- if FileType::from_raw_mode(status.st_mode) != FileType::Directory
- || status.st_uid != geteuid().as_raw()
- || status.st_mode & 0o022 != 0
- {
- return Err(io::Error::new(
- io::ErrorKind::PermissionDenied,
- "log parent must be an owner-controlled non-writable directory",
- ));
- }
- Ok(File::from(directory))
-}
-
-#[cfg(unix)]
-fn open_append_at(directory: &File, name: &OsStr) -> io::Result<File> {
- use rustix::fs::{FileType, Mode, OFlags, fchmod, fstat, openat};
-
- let descriptor = openat(
- directory,
- name,
- OFlags::WRONLY | OFlags::APPEND | OFlags::CREATE | OFlags::NOFOLLOW | OFlags::CLOEXEC,
- Mode::RUSR | Mode::WUSR,
- )
- .map_err(errno_to_io)?;
- let status = fstat(&descriptor).map_err(errno_to_io)?;
- if FileType::from_raw_mode(status.st_mode) != FileType::RegularFile || status.st_nlink != 1 {
- return Err(io::Error::new(
- io::ErrorKind::InvalidInput,
- "log target must be one regular file link",
- ));
- }
- fchmod(&descriptor, Mode::RUSR | Mode::WUSR).map_err(errno_to_io)?;
- Ok(File::from(descriptor))
-}
-
-#[cfg(unix)]
-fn errno_to_io(error: rustix::io::Errno) -> io::Error {
- io::Error::from_raw_os_error(error.raw_os_error())
-}
-
-#[cfg(not(unix))]
-fn open_append(_path: &Path) -> io::Result<File> {
- Err(io::Error::new(
- io::ErrorKind::Unsupported,
- "secure file logging is unavailable without a no-follow ACL implementation",
- ))
-}
-
-#[cfg(test)]
-fn rotated_path(path: &Path, index: usize) -> PathBuf {
- let mut value = path.as_os_str().to_owned();
- value.push(format!(".{index}"));
- PathBuf::from(value)
-}
-
-fn rotated_name(name: &OsStr, index: usize) -> OsString {
- let mut value = name.to_owned();
- value.push(format!(".{index}"));
- value
-}
-
-#[cfg(any(test, not(unix)))]
-fn remove_if_present(path: &Path) -> io::Result<()> {
- match fs::remove_file(path) {
- Ok(()) => Ok(()),
- Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()),
- Err(error) => Err(error),
- }
-}
-
-#[cfg(any(test, not(unix)))]
-fn rename_if_present(source: &Path, target: &Path) -> io::Result<()> {
- match fs::rename(source, target) {
- Ok(()) => Ok(()),
- Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()),
- Err(error) => Err(error),
- }
-}
-
-#[cfg(test)]
-#[cfg_attr(coverage_nightly, coverage(off))]
-mod tests {
- use super::{
- LogRotation, SizeRotatingWriter, reject_unsafe_target, remove_if_present,
- rename_if_present, rotated_path,
- };
- use std::ffi::OsStr;
- use std::io::Write;
-
- #[test]
- fn rotation_is_size_bounded_and_retention_is_finite() {
- let directory = tempfile::tempdir().expect("temporary log directory");
- let path = directory.path().join("radroots.log");
- let mut writer = SizeRotatingWriter::new(
- path.clone(),
- LogRotation {
- max_file_bytes: 5,
- retained_files: 3,
- },
- )
- .expect("writer");
- for value in [b"1111", b"2222", b"3333", b"4444"] {
- writer.write_all(value).expect("log entry");
- }
- writer.flush().expect("flush");
- assert_eq!(std::fs::read(&path).expect("current"), b"4444");
- assert_eq!(
- std::fs::read(rotated_path(&path, 1)).expect("first retained"),
- b"3333"
- );
- assert_eq!(
- std::fs::read(rotated_path(&path, 2)).expect("second retained"),
- b"2222"
- );
- assert!(!rotated_path(&path, 3).exists());
- }
-
- #[test]
- fn single_file_rotation_removes_the_previous_file() {
- let directory = tempfile::tempdir().expect("temporary log directory");
- let path = directory.path().join("radroots.log");
- std::fs::write(&path, b"full!").expect("fixture");
- let mut writer = SizeRotatingWriter::new(
- path.clone(),
- LogRotation {
- max_file_bytes: 5,
- retained_files: 1,
- },
- )
- .expect("writer");
- writer.write_all(b"next").expect("write");
- writer.flush().expect("flush");
- assert_eq!(std::fs::read(path).expect("current"), b"next");
- }
-
- #[test]
- fn oversized_events_and_unavailable_files_fail_closed() {
- let directory = tempfile::tempdir().expect("temporary log directory");
- let path = directory.path().join("radroots.log");
- let mut writer = SizeRotatingWriter::new(
- path,
- LogRotation {
- max_file_bytes: 3,
- retained_files: 2,
- },
- )
- .expect("writer");
- assert_eq!(
- writer.write(b"four").expect_err("oversized").kind(),
- std::io::ErrorKind::InvalidData
- );
- writer.write_all(b"a").expect("first short write");
- writer.write_all(b"b").expect("second short write");
- writer.file = None;
- assert_eq!(
- writer.flush().expect_err("missing file").kind(),
- std::io::ErrorKind::Other
- );
- }
-
- #[test]
- fn unsafe_targets_and_absent_rotation_files_are_handled() {
- let directory = tempfile::tempdir().expect("temporary directory");
- assert!(reject_unsafe_target(directory.path()).is_err());
- let regular = directory.path().join("regular");
- std::fs::write(®ular, b"regular").expect("regular file");
- assert!(reject_unsafe_target(®ular).is_ok());
- let missing = directory.path().join("missing");
- assert!(reject_unsafe_target(&missing).is_ok());
- assert!(remove_if_present(&missing).is_ok());
- assert!(rename_if_present(&missing, &directory.path().join("target")).is_ok());
- assert!(remove_if_present(directory.path()).is_err());
-
- let source = directory.path().join("source");
- std::fs::write(&source, b"source").expect("source");
- assert!(rename_if_present(&source, directory.path()).is_err());
-
- #[cfg(unix)]
- {
- std::os::unix::fs::symlink(directory.path(), &missing).expect("symlink");
- assert!(reject_unsafe_target(&missing).is_err());
- }
- }
-
- #[cfg(unix)]
- #[test]
- fn descriptor_relative_open_rejects_symlinks_and_multiple_links() {
- let directory = tempfile::tempdir().expect("temporary directory");
- let outside = tempfile::NamedTempFile::new().expect("outside file");
- let symlink = directory.path().join("radroots.log");
- std::os::unix::fs::symlink(outside.path(), &symlink).expect("symlink");
- assert!(SizeRotatingWriter::new(symlink.clone(), LogRotation::default()).is_err());
-
- std::fs::remove_file(&symlink).expect("remove symlink");
- std::fs::hard_link(outside.path(), &symlink).expect("hard link");
- assert!(SizeRotatingWriter::new(symlink, LogRotation::default()).is_err());
-
- let link_holder = tempfile::tempdir().expect("link holder");
- let parent_link = link_holder.path().join("parent-link");
- std::os::unix::fs::symlink(directory.path(), &parent_link).expect("parent symlink");
- assert!(
- SizeRotatingWriter::new(parent_link.join("other.log"), LogRotation::default()).is_err()
- );
-
- use std::os::unix::fs::PermissionsExt;
- let insecure = tempfile::tempdir().expect("insecure parent");
- std::fs::set_permissions(insecure.path(), std::fs::Permissions::from_mode(0o777))
- .expect("insecure permissions");
- assert!(
- SizeRotatingWriter::new(insecure.path().join("radroots.log"), LogRotation::default())
- .is_err()
- );
- }
-
- #[cfg(unix)]
- #[test]
- fn descriptor_relative_rotation_propagates_non_missing_errors() {
- let directory = tempfile::tempdir().expect("temporary directory");
- let mut writer = SizeRotatingWriter::new(
- directory.path().join("radroots.log"),
- LogRotation::default(),
- )
- .expect("writer");
- writer.flush().expect("flush");
-
- std::fs::create_dir(directory.path().join("blocked")).expect("blocked directory");
- assert!(writer.remove_if_present(OsStr::new("blocked")).is_err());
- std::fs::write(directory.path().join("source"), b"source").expect("source");
- assert!(
- writer
- .rename_if_present(OsStr::new("source"), OsStr::new("blocked"))
- .is_err()
- );
- }
-
- #[test]
- fn rotation_without_an_open_file_recreates_the_target() {
- let directory = tempfile::tempdir().expect("temporary directory");
- let path = directory.path().join("radroots.log");
- let mut writer = SizeRotatingWriter::new(
- path,
- LogRotation {
- max_file_bytes: 3,
- retained_files: 2,
- },
- )
- .expect("writer");
- writer.file = None;
- writer.rotate().expect("rotation");
- writer.write_all(b"ok").expect("write after rotation");
- }
-}
diff --git a/crates/mobile_ffi/src/operations.rs b/crates/mobile_ffi/src/operations.rs
@@ -1,1008 +0,0 @@
-//! Focused secret-safe operation records for settings, profile, revision, and inbound media.
-
-use radroots_mobile_core::runtime::product_surface::{
- AddCommandType, BlossomEndpointAuthorityPreference, BlossomPreferences, IdentityCommand,
- IdentityLockState, IdentityRecord, IdentityState, LocalStoragePolicy, MediaNetworkPolicy,
- MobileNetworkEnvironment, MobileSettings, Phase1LocalMediaArtifact, Phase1MediaCacheStatus,
- Phase1ProfileStatus, Phase1RevisionPhase, Phase1RevisionPolicy, Phase1RevisionStatus,
- Phase1RevisionTarget, ProfileMetadataCommand, RelayAccessPreference, RelayEndpointPreference,
- RelayPreferences, SettingsTransition, phase1_new_operation_id,
-};
-
-use crate::dto::PreparedMedia;
-use crate::{
- FfiAddDraftInput, FfiDraftStatusRecord, FfiOperationSettlementRecord, FfiOutboxState,
- FfiPreparedMediaInput, MOBILE_FFI_SCHEMA_VERSION, RadrootsAppError,
-};
-
-impl From<crate::FfiAddCommandType> for AddCommandType {
- fn from(value: crate::FfiAddCommandType) -> Self {
- match value {
- crate::FfiAddCommandType::CreateUpdate => Self::CreateUpdate,
- crate::FfiAddCommandType::CreatePhotoUpdate => Self::CreatePhotoUpdate,
- crate::FfiAddCommandType::CreateAsk => Self::CreateAsk,
- crate::FfiAddCommandType::CreateEvent => Self::CreateEvent,
- crate::FfiAddCommandType::CreateFoodAvailability => Self::CreateFoodAvailability,
- }
- }
-}
-
-#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)]
-pub enum FfiIdentityLockState {
- Locked,
- Unlocked,
-}
-
-impl From<IdentityLockState> for FfiIdentityLockState {
- fn from(value: IdentityLockState) -> Self {
- match value {
- IdentityLockState::Locked => Self::Locked,
- IdentityLockState::Unlocked => Self::Unlocked,
- }
- }
-}
-
-#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)]
-pub struct FfiSettingsIdentityRecord {
- pub schema_version: u16,
- pub id: String,
- pub public_key: String,
-}
-
-#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)]
-pub struct FfiIdentityStateRecord {
- pub schema_version: u16,
- pub identities: Vec<FfiSettingsIdentityRecord>,
- pub active_identity_id: Option<String>,
- pub lock_state: FfiIdentityLockState,
- pub pending_import_operation_id: Option<String>,
-}
-
-impl From<&IdentityState> for FfiIdentityStateRecord {
- fn from(value: &IdentityState) -> Self {
- Self {
- schema_version: MOBILE_FFI_SCHEMA_VERSION,
- identities: value
- .identities()
- .iter()
- .map(|identity| FfiSettingsIdentityRecord {
- schema_version: MOBILE_FFI_SCHEMA_VERSION,
- id: identity.id().to_owned(),
- public_key: identity.public_key_hex().to_owned(),
- })
- .collect(),
- active_identity_id: value.active_identity_id().map(str::to_owned),
- lock_state: value.lock_state().into(),
- pending_import_operation_id: value.pending_import_operation_id().map(str::to_owned),
- }
- }
-}
-
-#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)]
-pub enum FfiIdentityCommandKind {
- BeginImport,
- CompleteImport,
- CancelImport,
- Select,
- Lock,
- Unlock,
- Recover,
-}
-
-#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)]
-pub struct FfiIdentityCommandRecord {
- pub schema_version: u16,
- pub kind: FfiIdentityCommandKind,
- pub operation_id: Option<String>,
- pub identity_id: Option<String>,
- pub public_key: Option<String>,
-}
-
-impl TryFrom<FfiIdentityCommandRecord> for IdentityCommand {
- type Error = RadrootsAppError;
-
- fn try_from(value: FfiIdentityCommandRecord) -> Result<Self, Self::Error> {
- require_schema(value.schema_version)?;
- let no_operation = value.operation_id.is_none();
- let no_identity = value.identity_id.is_none() && value.public_key.is_none();
- match value.kind {
- FfiIdentityCommandKind::BeginImport if no_identity => Ok(Self::BeginImport {
- operation_id: required(value.operation_id, "identity_operation_id_required")?,
- }),
- FfiIdentityCommandKind::CompleteImport => {
- let operation_id = required(value.operation_id, "identity_operation_id_required")?;
- let identity_id = required(value.identity_id, "identity_id_required")?;
- let public_key = required(value.public_key, "identity_public_key_required")?;
- Ok(Self::CompleteImport {
- operation_id,
- identity: IdentityRecord::new(identity_id, &public_key)
- .map_err(|error| RadrootsAppError::invalid_argument(error.code()))?,
- })
- }
- FfiIdentityCommandKind::CancelImport if no_identity => Ok(Self::CancelImport {
- operation_id: required(value.operation_id, "identity_operation_id_required")?,
- }),
- FfiIdentityCommandKind::Select if no_operation && value.public_key.is_none() => {
- Ok(Self::Select {
- identity_id: required(value.identity_id, "identity_id_required")?,
- })
- }
- FfiIdentityCommandKind::Lock if no_operation && no_identity => Ok(Self::Lock),
- FfiIdentityCommandKind::Unlock if no_operation && no_identity => Ok(Self::Unlock),
- FfiIdentityCommandKind::Recover if no_operation && no_identity => Ok(Self::Recover),
- _ => Err(RadrootsAppError::invalid_argument(
- "invalid_identity_command",
- )),
- }
- }
-}
-
-#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)]
-pub enum FfiMobileNetworkEnvironment {
- Public,
- Simulator,
- PhysicalDevice,
-}
-
-impl From<FfiMobileNetworkEnvironment> for MobileNetworkEnvironment {
- fn from(value: FfiMobileNetworkEnvironment) -> Self {
- match value {
- FfiMobileNetworkEnvironment::Public => Self::Public,
- FfiMobileNetworkEnvironment::Simulator => Self::Simulator,
- FfiMobileNetworkEnvironment::PhysicalDevice => Self::PhysicalDevice,
- }
- }
-}
-
-impl From<MobileNetworkEnvironment> for FfiMobileNetworkEnvironment {
- fn from(value: MobileNetworkEnvironment) -> Self {
- match value {
- MobileNetworkEnvironment::Public => Self::Public,
- MobileNetworkEnvironment::Simulator => Self::Simulator,
- MobileNetworkEnvironment::PhysicalDevice => Self::PhysicalDevice,
- }
- }
-}
-
-#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)]
-pub enum FfiRelayAccessPreference {
- ReadOnly,
- ReadWrite,
-}
-
-impl From<FfiRelayAccessPreference> for RelayAccessPreference {
- fn from(value: FfiRelayAccessPreference) -> Self {
- match value {
- FfiRelayAccessPreference::ReadOnly => Self::ReadOnly,
- FfiRelayAccessPreference::ReadWrite => Self::ReadWrite,
- }
- }
-}
-
-impl From<RelayAccessPreference> for FfiRelayAccessPreference {
- fn from(value: RelayAccessPreference) -> Self {
- match value {
- RelayAccessPreference::ReadOnly => Self::ReadOnly,
- RelayAccessPreference::ReadWrite => Self::ReadWrite,
- }
- }
-}
-
-#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)]
-pub struct FfiRelayPreferenceRecord {
- pub schema_version: u16,
- pub url: String,
- pub access: FfiRelayAccessPreference,
-}
-
-#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)]
-pub struct FfiRelayPreferencesRecord {
- pub schema_version: u16,
- pub environment: FfiMobileNetworkEnvironment,
- pub endpoints: Vec<FfiRelayPreferenceRecord>,
-}
-
-#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)]
-pub enum FfiBlossomAuthorityPreference {
- PublicWebPki,
- LoopbackDevelopment,
- PrivateNetworkDevelopment,
-}
-
-impl From<FfiBlossomAuthorityPreference> for BlossomEndpointAuthorityPreference {
- fn from(value: FfiBlossomAuthorityPreference) -> Self {
- match value {
- FfiBlossomAuthorityPreference::PublicWebPki => Self::PublicWebPki,
- FfiBlossomAuthorityPreference::LoopbackDevelopment => Self::LoopbackDevelopment,
- FfiBlossomAuthorityPreference::PrivateNetworkDevelopment => {
- Self::PrivateNetworkDevelopment
- }
- }
- }
-}
-
-impl From<BlossomEndpointAuthorityPreference> for FfiBlossomAuthorityPreference {
- fn from(value: BlossomEndpointAuthorityPreference) -> Self {
- match value {
- BlossomEndpointAuthorityPreference::PublicWebPki => Self::PublicWebPki,
- BlossomEndpointAuthorityPreference::LoopbackDevelopment => Self::LoopbackDevelopment,
- BlossomEndpointAuthorityPreference::PrivateNetworkDevelopment => {
- Self::PrivateNetworkDevelopment
- }
- }
- }
-}
-
-#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)]
-pub struct FfiBlossomPreferencesRecord {
- pub schema_version: u16,
- pub environment: FfiMobileNetworkEnvironment,
- pub authority: FfiBlossomAuthorityPreference,
- pub primary_origin: String,
- pub fallback_origins: Vec<String>,
-}
-
-#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Record)]
-pub struct FfiMediaNetworkPolicyRecord {
- pub schema_version: u16,
- pub allow_cellular_downloads: bool,
- pub allow_cellular_uploads: bool,
- pub allow_background_transfers: bool,
-}
-
-#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Record)]
-pub struct FfiLocalStoragePolicyRecord {
- pub schema_version: u16,
- pub media_cache_bytes: u64,
- pub media_cache_artifacts: u32,
-}
-
-#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)]
-pub struct FfiMobileSettingsRecord {
- pub schema_version: u16,
- pub revision: u64,
- pub identity: FfiIdentityStateRecord,
- pub relays: FfiRelayPreferencesRecord,
- pub blossom: FfiBlossomPreferencesRecord,
- pub media_network: FfiMediaNetworkPolicyRecord,
- pub local_storage: FfiLocalStoragePolicyRecord,
-}
-
-impl From<&MobileSettings> for FfiMobileSettingsRecord {
- fn from(value: &MobileSettings) -> Self {
- Self {
- schema_version: MOBILE_FFI_SCHEMA_VERSION,
- revision: value.revision(),
- identity: value.identity().into(),
- relays: FfiRelayPreferencesRecord {
- schema_version: MOBILE_FFI_SCHEMA_VERSION,
- environment: value.relays().environment().into(),
- endpoints: value
- .relays()
- .endpoints()
- .iter()
- .map(|endpoint| FfiRelayPreferenceRecord {
- schema_version: MOBILE_FFI_SCHEMA_VERSION,
- url: endpoint.url().to_owned(),
- access: endpoint.access().into(),
- })
- .collect(),
- },
- blossom: FfiBlossomPreferencesRecord {
- schema_version: MOBILE_FFI_SCHEMA_VERSION,
- environment: value.blossom().environment().into(),
- authority: value.blossom().authority().into(),
- primary_origin: value.blossom().primary_origin().to_owned(),
- fallback_origins: value.blossom().fallback_origins().to_vec(),
- },
- media_network: FfiMediaNetworkPolicyRecord {
- schema_version: MOBILE_FFI_SCHEMA_VERSION,
- allow_cellular_downloads: value.media_network().allow_cellular_downloads(),
- allow_cellular_uploads: value.media_network().allow_cellular_uploads(),
- allow_background_transfers: value.media_network().allow_background_transfers(),
- },
- local_storage: FfiLocalStoragePolicyRecord {
- schema_version: MOBILE_FFI_SCHEMA_VERSION,
- media_cache_bytes: value.local_storage().media_cache_bytes(),
- media_cache_artifacts: value.local_storage().media_cache_artifacts(),
- },
- }
- }
-}
-
-#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)]
-pub struct FfiReplaceSettingsRecord {
- pub schema_version: u16,
- pub expected_revision: u64,
- pub relays: FfiRelayPreferencesRecord,
- pub blossom: FfiBlossomPreferencesRecord,
- pub media_network: FfiMediaNetworkPolicyRecord,
- pub local_storage: FfiLocalStoragePolicyRecord,
-}
-
-impl FfiReplaceSettingsRecord {
- pub(crate) fn apply(self, current: MobileSettings) -> Result<MobileSettings, RadrootsAppError> {
- require_schema(self.schema_version)?;
- if current.revision() != self.expected_revision {
- return Err(RadrootsAppError::invalid_argument(
- "settings_revision_conflict",
- ));
- }
- require_schema(self.relays.schema_version)?;
- let relay_environment: MobileNetworkEnvironment = self.relays.environment.into();
- let relay_endpoints = self
- .relays
- .endpoints
- .into_iter()
- .map(|endpoint| {
- require_schema(endpoint.schema_version)?;
- RelayEndpointPreference::new(
- relay_environment,
- endpoint.url,
- endpoint.access.into(),
- )
- .map_err(Into::into)
- })
- .collect::<Result<Vec<_>, RadrootsAppError>>()?;
- let relays = RelayPreferences::new(relay_environment, relay_endpoints)?;
-
- require_schema(self.blossom.schema_version)?;
- let blossom = BlossomPreferences::new(
- self.blossom.environment.into(),
- self.blossom.authority.into(),
- self.blossom.primary_origin,
- self.blossom.fallback_origins,
- )?;
- require_schema(self.media_network.schema_version)?;
- let media_network = MediaNetworkPolicy::new(
- self.media_network.allow_cellular_downloads,
- self.media_network.allow_cellular_uploads,
- self.media_network.allow_background_transfers,
- );
- require_schema(self.local_storage.schema_version)?;
- let local_storage = LocalStoragePolicy::new(
- self.local_storage.media_cache_bytes,
- self.local_storage.media_cache_artifacts,
- )?;
- Ok(current
- .with_relays(relays)
- .with_blossom(blossom)
- .with_media_network(media_network)
- .with_local_storage(local_storage))
- }
-}
-
-#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)]
-pub struct FfiSettingsTransitionRecord {
- pub schema_version: u16,
- pub settings: FfiMobileSettingsRecord,
- pub runtime_restart_required: bool,
- pub outbox_requeue_required: bool,
- pub media_cache_invalidation_required: bool,
-}
-
-impl From<SettingsTransition> for FfiSettingsTransitionRecord {
- fn from(value: SettingsTransition) -> Self {
- Self {
- schema_version: MOBILE_FFI_SCHEMA_VERSION,
- settings: (&value.settings).into(),
- runtime_restart_required: value.runtime_restart_required,
- outbox_requeue_required: value.outbox_requeue_required,
- media_cache_invalidation_required: value.media_cache_invalidation_required,
- }
- }
-}
-
-#[derive(Clone, Debug, uniffi::Record)]
-pub struct FfiProfileMetadataInputRecord {
- pub schema_version: u16,
- pub name: String,
- pub display_name: Option<String>,
- pub about: Option<String>,
- pub picture: Option<FfiPreparedMediaInput>,
- pub banner: Option<FfiPreparedMediaInput>,
- pub nip05: Option<String>,
- pub bot: Option<bool>,
-}
-
-impl FfiProfileMetadataInputRecord {
- pub(crate) fn command(
- self,
- blossom: Option<&radroots_sdk::transport::BlossomSlot>,
- ) -> Result<ProfileMetadataCommand, RadrootsAppError> {
- require_schema(self.schema_version)?;
- let picture = self
- .picture
- .map(PreparedMedia::try_from)
- .transpose()?
- .map(|media| {
- let blossom = blossom.ok_or_else(|| {
- RadrootsAppError::failure(
- "blossom_unconfigured",
- "profile",
- true,
- &["configure_blossom"],
- "Profile media configuration is unavailable.",
- )
- })?;
- media.into_authored_image(blossom)
- })
- .transpose()?;
- let banner = self
- .banner
- .map(PreparedMedia::try_from)
- .transpose()?
- .map(|media| {
- let blossom = blossom.ok_or_else(|| {
- RadrootsAppError::failure(
- "blossom_unconfigured",
- "profile",
- true,
- &["configure_blossom"],
- "Profile media configuration is unavailable.",
- )
- })?;
- media.into_authored_image(blossom)
- })
- .transpose()?;
- ProfileMetadataCommand::new(
- self.name,
- self.display_name,
- self.about,
- picture,
- banner,
- self.nip05,
- self.bot,
- )
- .map_err(Into::into)
- }
-}
-
-#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)]
-pub struct FfiProfileStatusRecord {
- pub schema_version: u16,
- pub operation_id: String,
- pub revision: u64,
- pub author_public_key: String,
- pub state: FfiOutboxState,
- pub delivery_id: Option<String>,
- pub created_at_unix_ms: u64,
- pub updated_at_unix_ms: u64,
- pub settlement: Option<FfiOperationSettlementRecord>,
-}
-
-impl From<Phase1ProfileStatus> for FfiProfileStatusRecord {
- fn from(value: Phase1ProfileStatus) -> Self {
- let draft = value.draft();
- Self {
- schema_version: MOBILE_FFI_SCHEMA_VERSION,
- operation_id: hex::encode(draft.draft_id().as_bytes()),
- revision: draft.revision().get(),
- author_public_key: hex::encode(draft.author()),
- state: value.state().into(),
- delivery_id: draft.operation_id().map(|id| hex::encode(id.as_bytes())),
- created_at_unix_ms: draft.created_at_unix_ms(),
- updated_at_unix_ms: draft.updated_at_unix_ms(),
- settlement: value.push().map(|push| push.settlement().into()),
- }
- }
-}
-
-#[derive(Clone, Debug, uniffi::Record)]
-pub struct FfiRevisionInputRecord {
- pub schema_version: u16,
- pub card_id: String,
- pub source_event_id: String,
- pub source_address: Option<String>,
- pub author_public_key: String,
- pub replacement: FfiAddDraftInput,
-}
-
-impl FfiRevisionInputRecord {
- pub(crate) fn target(&self) -> Result<Phase1RevisionTarget, RadrootsAppError> {
- require_schema(self.schema_version)?;
- Phase1RevisionTarget::from_source(
- self.replacement.command_type.into(),
- radroots_mobile_core::runtime::product_surface::CardId::parse(&self.card_id)
- .map_err(|_| RadrootsAppError::invalid_argument("invalid_card_id"))?,
- self.source_event_id.clone(),
- self.source_address.clone(),
- self.author_public_key.clone(),
- )
- .map_err(Into::into)
- }
-}
-
-#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)]
-pub enum FfiRevisionPolicy {
- ReplaceThenRetract,
- AddressableReplacement,
-}
-
-#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)]
-pub enum FfiRevisionPhase {
- ReplacementPending,
- ReplacementFailed,
- RetractionPending,
- Complete,
- PartialEffect,
- Cancelled,
-}
-
-#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)]
-pub struct FfiRevisionStatusRecord {
- pub schema_version: u16,
- pub operation_id: String,
- pub replacement: FfiDraftStatusRecord,
- pub retraction: Option<FfiDraftStatusRecord>,
- pub policy: FfiRevisionPolicy,
- pub phase: FfiRevisionPhase,
-}
-
-impl From<Phase1RevisionStatus> for FfiRevisionStatusRecord {
- fn from(value: Phase1RevisionStatus) -> Self {
- let operation_id = hex::encode(value.replacement().draft().draft_id().as_bytes());
- let retraction = value.retraction().cloned().map(Into::into);
- let replacement = value.replacement().clone().into();
- Self {
- schema_version: MOBILE_FFI_SCHEMA_VERSION,
- operation_id,
- replacement,
- retraction,
- policy: match value.policy() {
- Phase1RevisionPolicy::ReplaceThenRetract => FfiRevisionPolicy::ReplaceThenRetract,
- Phase1RevisionPolicy::AddressableReplacement => {
- FfiRevisionPolicy::AddressableReplacement
- }
- },
- phase: match value.phase() {
- Phase1RevisionPhase::ReplacementPending => FfiRevisionPhase::ReplacementPending,
- Phase1RevisionPhase::ReplacementFailed => FfiRevisionPhase::ReplacementFailed,
- Phase1RevisionPhase::RetractionPending => FfiRevisionPhase::RetractionPending,
- Phase1RevisionPhase::Complete => FfiRevisionPhase::Complete,
- Phase1RevisionPhase::PartialEffect => FfiRevisionPhase::PartialEffect,
- Phase1RevisionPhase::Cancelled => FfiRevisionPhase::Cancelled,
- },
- }
- }
-}
-
-#[derive(uniffi::Object)]
-pub struct FfiMediaOperation {
- operation_id: [u8; 16],
- cancellation: radroots_sdk::transport::BlossomCancellation,
- claimed: std::sync::atomic::AtomicBool,
-}
-
-#[uniffi::export]
-impl FfiMediaOperation {
- #[uniffi::constructor]
- pub fn new() -> Result<Self, RadrootsAppError> {
- Ok(Self {
- operation_id: phase1_new_operation_id().map_err(RadrootsAppError::from)?,
- cancellation: radroots_sdk::transport::BlossomCancellation::default(),
- claimed: std::sync::atomic::AtomicBool::new(false),
- })
- }
-
- pub fn operation_id(&self) -> String {
- hex::encode(self.operation_id)
- }
-
- pub fn cancel(&self) {
- self.cancellation.cancel();
- }
-
- pub fn is_cancelled(&self) -> bool {
- self.cancellation.is_cancelled()
- }
-}
-
-impl FfiMediaOperation {
- pub(crate) fn claim(&self) -> Result<(), RadrootsAppError> {
- self.claimed
- .compare_exchange(
- false,
- true,
- std::sync::atomic::Ordering::AcqRel,
- std::sync::atomic::Ordering::Acquire,
- )
- .map(|_| ())
- .map_err(|_| RadrootsAppError::invalid_argument("media_operation_already_used"))
- }
-
- pub(crate) const fn id(&self) -> [u8; 16] {
- self.operation_id
- }
-
- pub(crate) fn cancellation(&self) -> radroots_sdk::transport::BlossomCancellation {
- self.cancellation.clone()
- }
-}
-
-#[derive(Clone, Eq, PartialEq, uniffi::Record)]
-pub struct FfiVerifiedMediaArtifactRecord {
- pub schema_version: u16,
- pub operation_id: Option<String>,
- pub artifact_id: String,
- pub bytes: Vec<u8>,
- pub byte_size: u64,
- pub media_type: String,
- pub width: u32,
- pub height: u32,
-}
-
-impl std::fmt::Debug for FfiVerifiedMediaArtifactRecord {
- fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
- formatter
- .debug_struct("FfiVerifiedMediaArtifactRecord")
- .field("schema_version", &self.schema_version)
- .field("operation_id", &self.operation_id)
- .field("artifact_id", &self.artifact_id)
- .field("bytes", &"<redacted>")
- .field("byte_size", &self.byte_size)
- .field("media_type", &self.media_type)
- .field("width", &self.width)
- .field("height", &self.height)
- .finish()
- }
-}
-
-impl FfiVerifiedMediaArtifactRecord {
- pub(crate) fn from_artifact(
- value: Phase1LocalMediaArtifact,
- operation_id: Option<String>,
- ) -> Self {
- Self {
- schema_version: MOBILE_FFI_SCHEMA_VERSION,
- operation_id,
- artifact_id: value.artifact_id().to_hex(),
- bytes: value.bytes().to_vec(),
- byte_size: value.byte_size(),
- media_type: value.media_type().to_owned(),
- width: value.width(),
- height: value.height(),
- }
- }
-}
-
-#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)]
-pub struct FfiMediaCacheStatusRecord {
- pub schema_version: u16,
- pub artifact_count: u32,
- pub total_bytes: u64,
- pub configuration_fingerprint: Option<String>,
-}
-
-impl From<Phase1MediaCacheStatus> for FfiMediaCacheStatusRecord {
- fn from(value: Phase1MediaCacheStatus) -> Self {
- Self {
- schema_version: MOBILE_FFI_SCHEMA_VERSION,
- artifact_count: value.artifacts,
- total_bytes: value.bytes,
- configuration_fingerprint: value.configuration.map(|value| value.to_hex()),
- }
- }
-}
-
-pub(crate) fn require_schema(schema_version: u16) -> Result<(), RadrootsAppError> {
- if schema_version == MOBILE_FFI_SCHEMA_VERSION {
- Ok(())
- } else {
- Err(RadrootsAppError::invalid_argument(
- "unsupported_schema_version",
- ))
- }
-}
-
-fn required(value: Option<String>, code: &'static str) -> Result<String, RadrootsAppError> {
- value.ok_or_else(|| RadrootsAppError::invalid_argument(code))
-}
-
-pub(crate) fn decode_artifact_id(
- value: &str,
-) -> Result<radroots_mobile_core::runtime::product_surface::Phase1MediaArtifactId, RadrootsAppError>
-{
- radroots_mobile_core::runtime::product_surface::Phase1MediaArtifactId::parse(value)
- .map_err(|_| RadrootsAppError::invalid_argument("invalid_media_artifact_id"))
-}
-
-pub(crate) fn decode_configuration(
- value: &str,
-) -> Result<
- radroots_mobile_core::runtime::product_surface::Phase1MediaConfigurationFingerprint,
- RadrootsAppError,
-> {
- radroots_mobile_core::runtime::product_surface::Phase1MediaConfigurationFingerprint::parse(
- value,
- )
- .map_err(|_| RadrootsAppError::invalid_argument("invalid_media_configuration"))
-}
-
-pub(crate) fn decode_reference_fingerprint(value: &str) -> Result<[u8; 32], RadrootsAppError> {
- let bytes = hex::decode(value)
- .map_err(|_| RadrootsAppError::invalid_argument("invalid_media_reference_fingerprint"))?;
- bytes
- .try_into()
- .map_err(|_| RadrootsAppError::invalid_argument("invalid_media_reference_fingerprint"))
-}
-
-#[cfg(test)]
-mod tests {
- use super::*;
- use radroots_mobile_core::runtime::product_surface::Phase1MediaConfigurationFingerprint;
-
- const PUBLIC_KEY: &str = "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798";
-
- #[test]
- fn media_operation_can_be_claimed_exactly_once() {
- let operation = FfiMediaOperation::new().unwrap();
- operation.claim().unwrap();
- let error = operation.claim().unwrap_err();
- assert_eq!(error.report().code, "media_operation_already_used");
- assert_eq!(operation.operation_id().len(), 32);
- }
-
- #[test]
- fn verified_media_artifact_debug_never_exposes_renderable_bytes() {
- let artifact = FfiVerifiedMediaArtifactRecord {
- schema_version: MOBILE_FFI_SCHEMA_VERSION,
- operation_id: None,
- artifact_id: "11".repeat(32),
- bytes: b"private farm image".to_vec(),
- byte_size: 18,
- media_type: "image/png".to_owned(),
- width: 1,
- height: 1,
- };
- let debug = format!("{artifact:?}");
- assert!(debug.contains("<redacted>"));
- assert!(!debug.contains("private farm image"));
- }
-
- #[test]
- fn identity_commands_reject_fields_outside_the_selected_variant() {
- let error = IdentityCommand::try_from(FfiIdentityCommandRecord {
- schema_version: MOBILE_FFI_SCHEMA_VERSION,
- kind: FfiIdentityCommandKind::Lock,
- operation_id: Some("unexpected".to_owned()),
- identity_id: None,
- public_key: None,
- })
- .unwrap_err();
- assert_eq!(error.report().code, "invalid_identity_command");
- }
-
- #[test]
- fn boundary_enums_and_identity_commands_cover_the_closed_vocabularies() {
- for (ffi, core) in [
- (
- crate::FfiAddCommandType::CreateUpdate,
- AddCommandType::CreateUpdate,
- ),
- (
- crate::FfiAddCommandType::CreatePhotoUpdate,
- AddCommandType::CreatePhotoUpdate,
- ),
- (
- crate::FfiAddCommandType::CreateAsk,
- AddCommandType::CreateAsk,
- ),
- (
- crate::FfiAddCommandType::CreateEvent,
- AddCommandType::CreateEvent,
- ),
- (
- crate::FfiAddCommandType::CreateFoodAvailability,
- AddCommandType::CreateFoodAvailability,
- ),
- ] {
- assert_eq!(AddCommandType::from(ffi), core);
- }
- for environment in [
- FfiMobileNetworkEnvironment::Public,
- FfiMobileNetworkEnvironment::Simulator,
- FfiMobileNetworkEnvironment::PhysicalDevice,
- ] {
- assert_eq!(
- FfiMobileNetworkEnvironment::from(MobileNetworkEnvironment::from(environment)),
- environment
- );
- }
- for access in [
- FfiRelayAccessPreference::ReadOnly,
- FfiRelayAccessPreference::ReadWrite,
- ] {
- assert_eq!(
- FfiRelayAccessPreference::from(RelayAccessPreference::from(access)),
- access
- );
- }
- for authority in [
- FfiBlossomAuthorityPreference::PublicWebPki,
- FfiBlossomAuthorityPreference::LoopbackDevelopment,
- FfiBlossomAuthorityPreference::PrivateNetworkDevelopment,
- ] {
- assert_eq!(
- FfiBlossomAuthorityPreference::from(BlossomEndpointAuthorityPreference::from(
- authority
- )),
- authority
- );
- }
-
- let commands = [
- FfiIdentityCommandRecord {
- schema_version: MOBILE_FFI_SCHEMA_VERSION,
- kind: FfiIdentityCommandKind::BeginImport,
- operation_id: Some("operation".to_owned()),
- identity_id: None,
- public_key: None,
- },
- FfiIdentityCommandRecord {
- schema_version: MOBILE_FFI_SCHEMA_VERSION,
- kind: FfiIdentityCommandKind::CompleteImport,
- operation_id: Some("operation".to_owned()),
- identity_id: Some("primary".to_owned()),
- public_key: Some(PUBLIC_KEY.to_owned()),
- },
- FfiIdentityCommandRecord {
- schema_version: MOBILE_FFI_SCHEMA_VERSION,
- kind: FfiIdentityCommandKind::CancelImport,
- operation_id: Some("operation".to_owned()),
- identity_id: None,
- public_key: None,
- },
- FfiIdentityCommandRecord {
- schema_version: MOBILE_FFI_SCHEMA_VERSION,
- kind: FfiIdentityCommandKind::Select,
- operation_id: None,
- identity_id: Some("primary".to_owned()),
- public_key: None,
- },
- FfiIdentityCommandRecord {
- schema_version: MOBILE_FFI_SCHEMA_VERSION,
- kind: FfiIdentityCommandKind::Lock,
- operation_id: None,
- identity_id: None,
- public_key: None,
- },
- FfiIdentityCommandRecord {
- schema_version: MOBILE_FFI_SCHEMA_VERSION,
- kind: FfiIdentityCommandKind::Unlock,
- operation_id: None,
- identity_id: None,
- public_key: None,
- },
- FfiIdentityCommandRecord {
- schema_version: MOBILE_FFI_SCHEMA_VERSION,
- kind: FfiIdentityCommandKind::Recover,
- operation_id: None,
- identity_id: None,
- public_key: None,
- },
- ];
- for command in commands {
- assert!(IdentityCommand::try_from(command).is_ok());
- }
- for command in [
- FfiIdentityCommandRecord {
- schema_version: MOBILE_FFI_SCHEMA_VERSION,
- kind: FfiIdentityCommandKind::BeginImport,
- operation_id: Some("operation".to_owned()),
- identity_id: Some("unexpected".to_owned()),
- public_key: None,
- },
- FfiIdentityCommandRecord {
- schema_version: MOBILE_FFI_SCHEMA_VERSION,
- kind: FfiIdentityCommandKind::CancelImport,
- operation_id: Some("operation".to_owned()),
- identity_id: None,
- public_key: Some(PUBLIC_KEY.to_owned()),
- },
- FfiIdentityCommandRecord {
- schema_version: MOBILE_FFI_SCHEMA_VERSION,
- kind: FfiIdentityCommandKind::Select,
- operation_id: Some("unexpected".to_owned()),
- identity_id: Some("primary".to_owned()),
- public_key: None,
- },
- FfiIdentityCommandRecord {
- schema_version: MOBILE_FFI_SCHEMA_VERSION,
- kind: FfiIdentityCommandKind::Select,
- operation_id: None,
- identity_id: Some("primary".to_owned()),
- public_key: Some(PUBLIC_KEY.to_owned()),
- },
- FfiIdentityCommandRecord {
- schema_version: MOBILE_FFI_SCHEMA_VERSION,
- kind: FfiIdentityCommandKind::Lock,
- operation_id: Some("unexpected".to_owned()),
- identity_id: None,
- public_key: None,
- },
- FfiIdentityCommandRecord {
- schema_version: MOBILE_FFI_SCHEMA_VERSION,
- kind: FfiIdentityCommandKind::Unlock,
- operation_id: None,
- identity_id: Some("unexpected".to_owned()),
- public_key: None,
- },
- FfiIdentityCommandRecord {
- schema_version: MOBILE_FFI_SCHEMA_VERSION,
- kind: FfiIdentityCommandKind::Recover,
- operation_id: None,
- identity_id: None,
- public_key: Some(PUBLIC_KEY.to_owned()),
- },
- ] {
- assert!(IdentityCommand::try_from(command).is_err());
- }
- assert!(
- IdentityCommand::try_from(FfiIdentityCommandRecord {
- schema_version: MOBILE_FFI_SCHEMA_VERSION + 1,
- kind: FfiIdentityCommandKind::Lock,
- operation_id: None,
- identity_id: None,
- public_key: None,
- })
- .is_err()
- );
-
- let identity = IdentityRecord::new("primary", PUBLIC_KEY).unwrap();
- for lock_state in [IdentityLockState::Locked, IdentityLockState::Unlocked] {
- let state = IdentityState::new(
- vec![identity.clone()],
- Some("primary".to_owned()),
- lock_state,
- None,
- )
- .unwrap();
- let record = FfiIdentityStateRecord::from(&state);
- assert_eq!(record.identities.len(), 1);
- assert_eq!(record.lock_state, lock_state.into());
- }
- }
-
- #[test]
- fn media_decoders_status_and_private_operation_accessors_are_exhaustive() {
- assert!(require_schema(MOBILE_FFI_SCHEMA_VERSION).is_ok());
- assert!(require_schema(MOBILE_FFI_SCHEMA_VERSION + 1).is_err());
- for invalid in ["", "not-hex", "00"] {
- assert!(decode_artifact_id(invalid).is_err());
- assert!(decode_configuration(invalid).is_err());
- assert!(decode_reference_fingerprint(invalid).is_err());
- }
- let digest = "11".repeat(32);
- assert!(decode_artifact_id(&digest).is_ok());
- assert!(decode_configuration(&digest).is_ok());
- assert_eq!(decode_reference_fingerprint(&digest).unwrap(), [0x11; 32]);
-
- let configuration = Phase1MediaConfigurationFingerprint::new([7; 32]).unwrap();
- let status = FfiMediaCacheStatusRecord::from(Phase1MediaCacheStatus {
- artifacts: 2,
- bytes: 9,
- configuration: Some(configuration),
- });
- assert_eq!(status.artifact_count, 2);
- assert_eq!(status.total_bytes, 9);
- assert_eq!(status.configuration_fingerprint, Some("07".repeat(32)));
- assert!(
- FfiMediaCacheStatusRecord::from(Phase1MediaCacheStatus {
- artifacts: 0,
- bytes: 0,
- configuration: None,
- })
- .configuration_fingerprint
- .is_none()
- );
-
- let operation = FfiMediaOperation::new().unwrap();
- assert_eq!(operation.id(), operation.operation_id);
- assert!(!operation.cancellation().is_cancelled());
- assert!(!operation.is_cancelled());
- operation.cancel();
- assert!(operation.is_cancelled());
- assert!(operation.cancellation().is_cancelled());
- }
-}
diff --git a/crates/mobile_ffi/src/runtime.rs b/crates/mobile_ffi/src/runtime.rs
@@ -1,1190 +0,0 @@
-use std::sync::Arc;
-
-use radroots_mobile_core::runtime::product_surface::{
- LocalNetworkRelayPolicy, Phase1AddIntent, Phase1ExistingDraft, Phase1MediaCachePolicy,
- Phase1QueueIntent, Phase1ReviseIntent, ReplaceMobileSettings, TodayPageRequest,
- phase1_new_addressable_identifier, phase1_operation_now_unix_ms,
-};
-
-use crate::dto::PreparedMedia;
-use crate::operations::{
- FfiIdentityCommandRecord, FfiMediaCacheStatusRecord, FfiMediaOperation,
- FfiMobileSettingsRecord, FfiProfileMetadataInputRecord, FfiProfileStatusRecord,
- FfiReplaceSettingsRecord, FfiRevisionInputRecord, FfiRevisionStatusRecord,
- FfiSettingsTransitionRecord, FfiVerifiedMediaArtifactRecord, decode_artifact_id,
- decode_configuration, decode_reference_fingerprint,
-};
-use crate::signer::HostSignerAdapter;
-use crate::subscription::SubscriptionHub;
-use crate::{
- FfiAddDraftInput, FfiAddSchemaRecord, FfiBlossomConfigurationRecord,
- FfiBlossomEndpointAuthority, FfiBlossomEvidenceRecord, FfiBlossomHostKind,
- FfiBlossomUploadInput, FfiBlossomUploadIntent, FfiCapabilityRecord, FfiCardAddParityRecord,
- FfiDraftStatusRecord, FfiIdentityStatusRecord, FfiLocalNetworkRecord, FfiMeRecord,
- FfiQueuePolicyRecord, FfiRelayStatusReportRecord, FfiRetractionDraftInput,
- FfiRuntimeChangeKind, FfiRuntimeInfoRecord, FfiSearchResultRecord, FfiShutdownRecord,
- FfiStorageStatusRecord, FfiSubscriptionHandle, FfiTodayPageRecord, FfiTodayProjectionUpdate,
- FfiTodayRefreshRecord, FfiTodaySyncRecord, RadrootsAppError, RadrootsHostSigner,
- RadrootsRuntimeObserver, add_schemas, decode_id,
-};
-
-#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)]
-pub enum ProtectedDataAvailability {
- Available,
- Unavailable,
-}
-
-impl From<ProtectedDataAvailability>
- for radroots_mobile_core::runtime::store::ProtectedDataAvailability
-{
- fn from(value: ProtectedDataAvailability) -> Self {
- match value {
- ProtectedDataAvailability::Available => Self::Available,
- ProtectedDataAvailability::Unavailable => Self::Unavailable,
- }
- }
-}
-
-/// Native boundary object delegating all product behavior to the ordinary Rust core.
-#[derive(uniffi::Object)]
-pub struct RadrootsRuntime {
- inner: radroots_mobile_core::RadrootsRuntime,
- has_host_signer: bool,
- subscriptions: Arc<SubscriptionHub>,
-}
-
-#[cfg_attr(not(coverage_nightly), uniffi::export(async_runtime = "tokio"))]
-impl RadrootsRuntime {
- #[cfg_attr(not(coverage_nightly), uniffi::constructor)]
- pub async fn new(
- application_support_directory: String,
- public_key_hex: String,
- source_generation_hex: String,
- source_generation_created_at_unix_ms: u64,
- protected_data: ProtectedDataAvailability,
- ) -> Result<Self, RadrootsAppError> {
- build_runtime(
- application_support_directory,
- public_key_hex,
- source_generation_hex,
- source_generation_created_at_unix_ms,
- protected_data,
- None,
- )
- .await
- }
-
- #[cfg_attr(not(coverage_nightly), uniffi::constructor)]
- pub async fn with_host_signer(
- application_support_directory: String,
- public_key_hex: String,
- source_generation_hex: String,
- source_generation_created_at_unix_ms: u64,
- protected_data: ProtectedDataAvailability,
- host_signer: Box<dyn RadrootsHostSigner>,
- ) -> Result<Self, RadrootsAppError> {
- build_runtime(
- application_support_directory,
- public_key_hex,
- source_generation_hex,
- source_generation_created_at_unix_ms,
- protected_data,
- Some(host_signer),
- )
- .await
- }
-
- pub async fn shutdown(&self) -> Result<FfiShutdownRecord, RadrootsAppError> {
- let result = self
- .inner
- .shutdown()
- .await
- .map(Into::into)
- .map_err(Into::into);
- if result.is_ok() {
- self.subscriptions
- .notify(FfiRuntimeChangeKind::Lifecycle, None);
- self.subscriptions.close();
- }
- result
- }
-
- pub fn uptime_millis(&self) -> i64 {
- self.inner.uptime_millis()
- }
-
- pub fn info(&self) -> FfiRuntimeInfoRecord {
- self.inner.info().into()
- }
-
- pub fn info_json(&self) -> String {
- self.inner.info_json()
- }
-
- 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>,
- ) {
- self.inner
- .set_app_info_platform(platform, bundle_id, version, build_number, build_sha);
- }
-
- pub fn identity_status(&self) -> Result<FfiIdentityStatusRecord, RadrootsAppError> {
- let public_key = self
- .inner
- .authenticated_store_public_key_hex()
- .ok_or_else(|| {
- RadrootsAppError::failure(
- "identity_unavailable",
- "identity",
- true,
- &["unlock_identity"],
- "The active identity is unavailable.",
- )
- })?;
- Ok(FfiIdentityStatusRecord {
- schema_version: crate::MOBILE_FFI_SCHEMA_VERSION,
- public_key,
- host_signer_configured: self.has_host_signer,
- })
- }
-
- pub fn sdk_capabilities(&self) -> Vec<FfiCapabilityRecord> {
- self.inner
- .sdk_capabilities()
- .into_iter()
- .map(Into::into)
- .collect()
- }
-
- pub async fn sdk_storage_status(&self) -> Result<FfiStorageStatusRecord, RadrootsAppError> {
- self.inner
- .sdk_storage_status()
- .await
- .map(Into::into)
- .map_err(Into::into)
- }
-
- pub fn sdk_relay_status(&self) -> Result<Option<FfiRelayStatusReportRecord>, RadrootsAppError> {
- self.inner
- .sdk_relay_status()
- .map(|value| value.map(Into::into))
- .map_err(Into::into)
- }
-
- pub fn sdk_blossom_configuration(
- &self,
- ) -> Result<Option<FfiBlossomConfigurationRecord>, RadrootsAppError> {
- self.inner
- .sdk_blossom_configuration()
- .map(|value| value.map(Into::into))
- .map_err(Into::into)
- }
-
- pub fn sdk_blossom_evidence(
- &self,
- ) -> Result<Option<FfiBlossomEvidenceRecord>, RadrootsAppError> {
- self.inner
- .sdk_blossom_evidence()
- .map(|value| value.map(Into::into))
- .map_err(Into::into)
- }
-
- pub async fn probe_blossom(&self) -> Result<FfiBlossomEvidenceRecord, RadrootsAppError> {
- let evidence = self
- .inner
- .probe_blossom()
- .await
- .map(Into::into)
- .map_err(RadrootsAppError::from)?;
- self.subscriptions.notify(FfiRuntimeChangeKind::Media, None);
- Ok(evidence)
- }
-
- pub fn subscribe_changes(
- &self,
- observer: Box<dyn RadrootsRuntimeObserver>,
- ) -> Result<Arc<FfiSubscriptionHandle>, RadrootsAppError> {
- self.subscriptions.subscribe(observer)
- }
-
- pub fn configure_public_relays(
- &self,
- writable_relays: Vec<String>,
- ) -> Result<(), RadrootsAppError> {
- self.inner
- .configure_public_relays(writable_relays)
- .map_err(RadrootsAppError::from)?;
- self.subscriptions.notify(FfiRuntimeChangeKind::Relay, None);
- Ok(())
- }
-
- pub fn configure_simulator_relays(
- &self,
- loopback_relays: Vec<String>,
- ) -> Result<(), RadrootsAppError> {
- self.inner
- .configure_simulator_relays(loopback_relays)
- .map_err(RadrootsAppError::from)?;
- self.subscriptions.notify(FfiRuntimeChangeKind::Relay, None);
- Ok(())
- }
-
- pub fn configure_device_relays(
- &self,
- writable_relays: Vec<String>,
- ) -> Result<(), RadrootsAppError> {
- self.inner
- .configure_device_relays(writable_relays)
- .map_err(RadrootsAppError::from)?;
- self.subscriptions.notify(FfiRuntimeChangeKind::Relay, None);
- Ok(())
- }
-
- pub fn configure_blossom(
- &self,
- host_kind: FfiBlossomHostKind,
- endpoint_authority: FfiBlossomEndpointAuthority,
- primary_origin: String,
- fallback_origins: Vec<String>,
- ) -> Result<(), RadrootsAppError> {
- self.inner
- .configure_blossom(
- host_kind.into(),
- endpoint_authority.into(),
- primary_origin,
- fallback_origins,
- )
- .map_err(RadrootsAppError::from)?;
- self.subscriptions.notify(FfiRuntimeChangeKind::Media, None);
- Ok(())
- }
-
- pub fn phase1_card_add_parity(&self) -> Vec<FfiCardAddParityRecord> {
- self.inner
- .phase1_card_add_parity()
- .into_iter()
- .map(|value| FfiCardAddParityRecord {
- schema_version: crate::MOBILE_FFI_SCHEMA_VERSION,
- card_type: value.card_type.into(),
- command_type: value.add_command_type.into(),
- })
- .collect()
- }
-
- pub fn phase1_add_schemas(&self) -> Vec<FfiAddSchemaRecord> {
- add_schemas()
- }
-
- pub fn phase1_local_network(
- &self,
- context: FfiLocalNetworkRecord,
- ) -> Result<FfiLocalNetworkRecord, RadrootsAppError> {
- self.local_network(context).map(Into::into)
- }
-
- pub async fn phase1_today_page(
- &self,
- context: FfiLocalNetworkRecord,
- limit: u16,
- as_of_unix_s: Option<u64>,
- cursor: Option<String>,
- ) -> Result<FfiTodayPageRecord, RadrootsAppError> {
- if as_of_unix_s.is_some() == cursor.is_some() {
- return Err(RadrootsAppError::invalid_argument(
- "invalid_today_page_request",
- ));
- }
- let context = self.local_network(context)?;
- let request = match cursor {
- Some(cursor) => TodayPageRequest::after(limit, cursor),
- None => TodayPageRequest::first(
- limit,
- as_of_unix_s
- .ok_or_else(|| RadrootsAppError::invalid_argument("today_as_of_required"))?,
- ),
- };
- self.inner
- .phase1_today_page(&context, request)
- .await
- .map(Into::into)
- .map_err(Into::into)
- }
-
- pub async fn phase1_refresh_today(
- &self,
- context: FfiLocalNetworkRecord,
- now_unix_s: u64,
- update: FfiTodayProjectionUpdate,
- ) -> Result<FfiTodayRefreshRecord, RadrootsAppError> {
- let context = self.local_network(context)?;
- let receipt = self
- .inner
- .phase1_refresh_today(&context, now_unix_s, update.into())
- .await
- .map_err(RadrootsAppError::from)?;
- self.subscriptions.notify(FfiRuntimeChangeKind::Today, None);
- Ok(receipt.into())
- }
-
- pub async fn phase1_sync_today(
- &self,
- context: FfiLocalNetworkRecord,
- now_unix_s: u64,
- update: FfiTodayProjectionUpdate,
- ) -> Result<FfiTodaySyncRecord, RadrootsAppError> {
- let context = self.local_network(context)?;
- let receipt = self
- .inner
- .phase1_sync_today(&context, now_unix_s, update.into())
- .await
- .map_err(RadrootsAppError::from)?;
- self.subscriptions.notify(FfiRuntimeChangeKind::Today, None);
- Ok(receipt.into())
- }
-
- pub async fn phase1_search(
- &self,
- context: FfiLocalNetworkRecord,
- query: String,
- limit: u16,
- as_of_unix_s: u64,
- ) -> Result<Vec<FfiSearchResultRecord>, RadrootsAppError> {
- let context = self.local_network(context)?;
- self.inner
- .phase1_search(&context, &query, limit, as_of_unix_s)
- .await
- .map(|results| results.into_iter().map(Into::into).collect())
- .map_err(Into::into)
- }
-
- pub async fn phase1_me(
- &self,
- context: FfiLocalNetworkRecord,
- as_of_unix_s: u64,
- ) -> Result<FfiMeRecord, RadrootsAppError> {
- let context = self.local_network(context)?;
- let public_key = self
- .inner
- .authenticated_store_public_key_hex()
- .ok_or_else(|| {
- RadrootsAppError::failure(
- "identity_unavailable",
- "identity",
- true,
- &["unlock_identity"],
- "The active identity is unavailable.",
- )
- })?;
- self.inner
- .phase1_me(&context, &public_key, as_of_unix_s)
- .await
- .map(Into::into)
- .map_err(Into::into)
- }
-
- pub fn phase1_validate_add_draft(
- &self,
- input: FfiAddDraftInput,
- authored_at_unix_s: u64,
- ) -> Result<(), RadrootsAppError> {
- let blossom = self
- .inner
- .sdk_blossom_slot()
- .map_err(RadrootsAppError::from)?;
- input
- .command_and_media(authored_at_unix_s, blossom.as_ref())
- .map(|_| ())
- }
-
- /// Saves one new or existing Add form while Rust owns all identity and
- /// timestamp policy. Addressable identifiers are generated when omitted.
- pub async fn phase1_save_add_intent(
- &self,
- mut input: FfiAddDraftInput,
- existing_draft_id: Option<String>,
- expected_revision: Option<u64>,
- ) -> Result<FfiDraftStatusRecord, RadrootsAppError> {
- if input.identifier.is_none()
- && matches!(
- input.command_type,
- crate::FfiAddCommandType::CreateEvent
- | crate::FfiAddCommandType::CreateFoodAvailability
- )
- {
- input.identifier = Some(phase1_new_addressable_identifier());
- }
- let authored_at_unix_s =
- phase1_operation_now_unix_ms().map_err(RadrootsAppError::from)? / 1_000;
- let blossom = self
- .inner
- .sdk_blossom_slot()
- .map_err(RadrootsAppError::from)?;
- let (command, media, form) =
- input.command_media_and_form(authored_at_unix_s, blossom.as_ref())?;
- let existing = match (existing_draft_id, expected_revision) {
- (Some(draft_id), Some(revision)) => Some(
- Phase1ExistingDraft::new(decode_id(&draft_id, "invalid_draft_id")?, revision)
- .map_err(RadrootsAppError::from)?,
- ),
- (None, None) => None,
- _ => {
- return Err(RadrootsAppError::invalid_argument("invalid_existing_draft"));
- }
- };
- let status = self
- .inner
- .phase1_save_add_intent(
- Phase1AddIntent::new(command, media, form, existing)
- .map_err(RadrootsAppError::from)?,
- )
- .await
- .map_err(RadrootsAppError::from)?;
- let draft_id = hex::encode(status.draft().draft_id().as_bytes());
- self.subscriptions
- .notify(FfiRuntimeChangeKind::Drafts, Some(draft_id));
- Ok(status.into())
- }
-
- #[allow(clippy::too_many_arguments)]
- pub async fn phase1_save_draft(
- &self,
- draft_id: String,
- input: FfiAddDraftInput,
- authored_at_unix_s: u64,
- expected_revision: Option<u64>,
- persisted_at_unix_ms: u64,
- ) -> Result<FfiDraftStatusRecord, RadrootsAppError> {
- let blossom = self
- .inner
- .sdk_blossom_slot()
- .map_err(RadrootsAppError::from)?;
- let (command, media, form) =
- input.command_media_and_form(authored_at_unix_s, blossom.as_ref())?;
- let decoded_id = decode_id(&draft_id, "invalid_draft_id")?;
- let status = self
- .inner
- .phase1_save_draft_with_form(
- decoded_id,
- command,
- authored_at_unix_s,
- media,
- form,
- expected_revision,
- persisted_at_unix_ms,
- )
- .await
- .map_err(RadrootsAppError::from)?;
- self.subscriptions
- .notify(FfiRuntimeChangeKind::Drafts, Some(draft_id));
- Ok(status.into())
- }
-
- pub async fn phase1_save_retraction_draft(
- &self,
- draft_id: String,
- input: FfiRetractionDraftInput,
- authored_at_unix_s: u64,
- persisted_at_unix_ms: u64,
- ) -> Result<FfiDraftStatusRecord, RadrootsAppError> {
- if input.schema_version != crate::MOBILE_FFI_SCHEMA_VERSION {
- return Err(RadrootsAppError::invalid_argument(
- "unsupported_schema_version",
- ));
- }
- let decoded_id = decode_id(&draft_id, "invalid_draft_id")?;
- let command_type = match input.command_type {
- crate::FfiAddCommandType::CreateUpdate => {
- radroots_mobile_core::runtime::product_surface::AddCommandType::CreateUpdate
- }
- crate::FfiAddCommandType::CreatePhotoUpdate => {
- radroots_mobile_core::runtime::product_surface::AddCommandType::CreatePhotoUpdate
- }
- crate::FfiAddCommandType::CreateAsk => {
- radroots_mobile_core::runtime::product_surface::AddCommandType::CreateAsk
- }
- crate::FfiAddCommandType::CreateEvent => {
- radroots_mobile_core::runtime::product_surface::AddCommandType::CreateEvent
- }
- crate::FfiAddCommandType::CreateFoodAvailability => {
- radroots_mobile_core::runtime::product_surface::AddCommandType::CreateFoodAvailability
- }
- };
- let card_id =
- radroots_mobile_core::runtime::product_surface::CardId::parse(&input.target_card_id)
- .map_err(|_| RadrootsAppError::invalid_argument("invalid_card_id"))?;
- let status = self
- .inner
- .phase1_save_retraction_draft(
- decoded_id,
- command_type,
- card_id,
- &input.target_event_id,
- input.target_kind,
- input.target_address.as_deref(),
- &input.reason,
- authored_at_unix_s,
- persisted_at_unix_ms,
- )
- .await
- .map_err(RadrootsAppError::from)?;
- self.subscriptions
- .notify(FfiRuntimeChangeKind::Drafts, Some(draft_id));
- Ok(status.into())
- }
-
- pub async fn phase1_draft_status(
- &self,
- draft_id: String,
- ) -> Result<FfiDraftStatusRecord, RadrootsAppError> {
- self.inner
- .phase1_draft_status(decode_id(&draft_id, "invalid_draft_id")?)
- .await
- .map(Into::into)
- .map_err(Into::into)
- }
-
- pub async fn phase1_draft_heads(
- &self,
- limit: u16,
- ) -> Result<Vec<FfiDraftStatusRecord>, RadrootsAppError> {
- self.inner
- .phase1_draft_heads(limit)
- .await
- .map(|values| values.into_iter().map(Into::into).collect())
- .map_err(Into::into)
- }
-
- pub async fn phase1_queue_draft(
- &self,
- draft_id: String,
- expected_revision: u64,
- policy: FfiQueuePolicyRecord,
- queued_at_unix_ms: u64,
- ) -> Result<FfiDraftStatusRecord, RadrootsAppError> {
- let decoded_id = decode_id(&draft_id, "invalid_draft_id")?;
- let status = self
- .inner
- .phase1_queue_draft(
- decoded_id,
- expected_revision,
- policy.try_into()?,
- queued_at_unix_ms,
- )
- .await
- .map_err(RadrootsAppError::from)?;
- self.subscriptions
- .notify(FfiRuntimeChangeKind::Drafts, Some(draft_id));
- Ok(status.into())
- }
-
- /// Queues with the Rust-owned active relay and settlement policy.
- pub async fn phase1_queue_add_intent(
- &self,
- draft_id: String,
- expected_revision: u64,
- ) -> Result<FfiDraftStatusRecord, RadrootsAppError> {
- let decoded_id = decode_id(&draft_id, "invalid_draft_id")?;
- let intent = Phase1QueueIntent::new(decoded_id, expected_revision)
- .map_err(RadrootsAppError::from)?;
- let status = self
- .inner
- .phase1_queue_add_intent(intent)
- .await
- .map_err(RadrootsAppError::from)?;
- self.subscriptions
- .notify(FfiRuntimeChangeKind::Drafts, Some(draft_id));
- Ok(status.into())
- }
-
- pub async fn phase1_recover_draft_queue(
- &self,
- draft_id: String,
- recovered_at_unix_ms: u64,
- ) -> Result<FfiDraftStatusRecord, RadrootsAppError> {
- let decoded_id = decode_id(&draft_id, "invalid_draft_id")?;
- let status = self
- .inner
- .phase1_recover_draft_queue(decoded_id, recovered_at_unix_ms)
- .await
- .map_err(RadrootsAppError::from)?;
- self.subscriptions
- .notify(FfiRuntimeChangeKind::Drafts, Some(draft_id));
- Ok(status.into())
- }
-
- pub async fn phase1_recover_add_intent(
- &self,
- draft_id: String,
- ) -> Result<FfiDraftStatusRecord, RadrootsAppError> {
- let decoded_id = decode_id(&draft_id, "invalid_draft_id")?;
- let status = self
- .inner
- .phase1_recover_add_intent(decoded_id)
- .await
- .map_err(RadrootsAppError::from)?;
- self.subscriptions
- .notify(FfiRuntimeChangeKind::Drafts, Some(draft_id));
- Ok(status.into())
- }
-
- pub async fn phase1_sign_queued_draft(
- &self,
- draft_id: String,
- expected_revision: u64,
- ) -> Result<FfiDraftStatusRecord, RadrootsAppError> {
- let decoded_id = decode_id(&draft_id, "invalid_draft_id")?;
- let status = self
- .inner
- .phase1_sign_queued_draft(decoded_id, expected_revision)
- .await
- .map_err(RadrootsAppError::from)?;
- self.subscriptions
- .notify(FfiRuntimeChangeKind::Drafts, Some(draft_id));
- Ok(status.into())
- }
-
- pub async fn phase1_advance_draft(
- &self,
- draft_id: String,
- expected_revision: u64,
- ) -> Result<FfiDraftStatusRecord, RadrootsAppError> {
- let decoded_id = decode_id(&draft_id, "invalid_draft_id")?;
- let status = self
- .inner
- .phase1_advance_draft(decoded_id, expected_revision)
- .await
- .map_err(RadrootsAppError::from)?;
- self.subscriptions
- .notify(FfiRuntimeChangeKind::Drafts, Some(draft_id));
- Ok(status.into())
- }
-
- pub async fn phase1_upload_draft_media(
- &self,
- input: FfiBlossomUploadInput,
- ) -> Result<FfiDraftStatusRecord, RadrootsAppError> {
- if input.schema_version != crate::MOBILE_FFI_SCHEMA_VERSION {
- return Err(RadrootsAppError::invalid_argument(
- "unsupported_schema_version",
- ));
- }
- let draft_id = decode_id(&input.draft_id, "invalid_draft_id")?;
- let operation_id = decode_id(&input.operation_id, "invalid_operation_id")?;
- let artifact_id = decode_id(&input.artifact_id, "invalid_artifact_id")?;
- let media = PreparedMedia::try_from(input.media)?;
- let request = media.upload_request(input.verified_at_unix_ms)?;
- let content = radroots_blossom::authorization::AuthorizationContent::parse(
- &input.authorization_content,
- )
- .map_err(|_| RadrootsAppError::invalid_argument("invalid_blossom_authorization"))?;
- let status = self
- .inner
- .phase1_upload_draft_media(
- draft_id,
- input.expected_revision,
- request,
- content,
- input.authorization_created_at_unix_s,
- input.authorization_lifetime_seconds,
- operation_id,
- artifact_id,
- input.signing_deadline_unix_ms,
- input.signing_cancellation.core(),
- radroots_sdk::transport::BlossomCancellation::default(),
- input.updated_at_unix_ms,
- )
- .await
- .map_err(RadrootsAppError::from)?;
- self.subscriptions
- .notify(FfiRuntimeChangeKind::Media, Some(input.draft_id.clone()));
- self.subscriptions
- .notify(FfiRuntimeChangeKind::Drafts, Some(input.draft_id));
- Ok(status.into())
- }
-
- /// Runs a Rust-planned BUD-11/BUD-02/BUD-01 upload attempt. The host
- /// supplies only the selected bounded file handle and draft revision.
- pub async fn phase1_upload_add_media_intent(
- &self,
- input: FfiBlossomUploadIntent,
- ) -> Result<FfiDraftStatusRecord, RadrootsAppError> {
- if input.schema_version != crate::MOBILE_FFI_SCHEMA_VERSION {
- return Err(RadrootsAppError::invalid_argument(
- "unsupported_schema_version",
- ));
- }
- let draft_id = decode_id(&input.draft_id, "invalid_draft_id")?;
- let intent = PreparedMedia::try_from(input.media)?
- .into_upload_intent(draft_id, input.expected_revision)?;
- let status = self
- .inner
- .phase1_upload_add_media_intent(intent)
- .await
- .map_err(RadrootsAppError::from)?;
- self.subscriptions
- .notify(FfiRuntimeChangeKind::Media, Some(input.draft_id.clone()));
- self.subscriptions
- .notify(FfiRuntimeChangeKind::Drafts, Some(input.draft_id));
- Ok(status.into())
- }
-
- /// Persists the upload transition before returning an immutable native
- /// background-transfer job.
- pub async fn phase1_prepare_add_media_background(
- &self,
- input: crate::FfiBlossomUploadIntent,
- ) -> Result<crate::FfiNativeUploadJobRecord, RadrootsAppError> {
- if input.schema_version != crate::MOBILE_FFI_SCHEMA_VERSION {
- return Err(RadrootsAppError::invalid_argument(
- "unsupported_schema_version",
- ));
- }
- let draft_id = decode_id(&input.draft_id, "invalid_draft_id")?;
- let intent = PreparedMedia::try_from(input.media)?
- .into_upload_intent(draft_id, input.expected_revision)?;
- let (status, job) = self
- .inner
- .phase1_prepare_native_upload(intent)
- .await
- .map_err(RadrootsAppError::from)?;
- self.subscriptions
- .notify(FfiRuntimeChangeKind::Media, Some(input.draft_id.clone()));
- self.subscriptions
- .notify(FfiRuntimeChangeKind::Drafts, Some(input.draft_id));
- Ok(crate::FfiNativeUploadJobRecord {
- schema_version: crate::MOBILE_FFI_SCHEMA_VERSION,
- operation_id: hex::encode(job.operation_id()),
- draft: status.into(),
- remote_url: job.remote_url().to_owned(),
- authorization_header: job.authorization_header().to_owned(),
- expected_sha256: job.expected_sha256().to_owned(),
- media_type: job.media_type().to_owned(),
- byte_size: job.byte_size(),
- })
- }
-
- /// Accepts only bounded native HTTP evidence; Rust performs descriptor and
- /// exact-byte retrieval verification before advancing durable state.
- pub async fn phase1_complete_add_media_background(
- &self,
- input: crate::FfiNativeUploadCompletionInput,
- ) -> Result<FfiDraftStatusRecord, RadrootsAppError> {
- if input.schema_version != crate::MOBILE_FFI_SCHEMA_VERSION
- || input.response_body.len() > 16_384
- {
- return Err(RadrootsAppError::invalid_argument(
- "invalid_native_upload_completion",
- ));
- }
- let draft_id = decode_id(&input.draft_id, "invalid_draft_id")?;
- let intent = PreparedMedia::try_from(input.media)?
- .into_upload_intent(draft_id, input.expected_revision)?;
- let status = self
- .inner
- .phase1_complete_native_upload(
- intent,
- input.status_code,
- input.response_media_type.as_deref(),
- input.response_content_encoding.as_deref(),
- input.response_body.as_slice(),
- )
- .await
- .map_err(RadrootsAppError::from)?;
- self.subscriptions
- .notify(FfiRuntimeChangeKind::Media, Some(input.draft_id.clone()));
- self.subscriptions
- .notify(FfiRuntimeChangeKind::Drafts, Some(input.draft_id));
- Ok(status.into())
- }
-
- pub async fn phase1_cancel_draft(
- &self,
- draft_id: String,
- expected_revision: u64,
- cancelled_at_unix_ms: u64,
- ) -> Result<FfiDraftStatusRecord, RadrootsAppError> {
- let decoded_id = decode_id(&draft_id, "invalid_draft_id")?;
- let status = self
- .inner
- .phase1_cancel_draft(decoded_id, expected_revision, cancelled_at_unix_ms)
- .await
- .map_err(RadrootsAppError::from)?;
- self.subscriptions
- .notify(FfiRuntimeChangeKind::Drafts, Some(draft_id));
- Ok(status.into())
- }
-
- pub async fn phase1_cancel_add_intent(
- &self,
- draft_id: String,
- expected_revision: u64,
- ) -> Result<FfiDraftStatusRecord, RadrootsAppError> {
- let decoded_id = decode_id(&draft_id, "invalid_draft_id")?;
- let status = self
- .inner
- .phase1_cancel_add_intent(decoded_id, expected_revision)
- .await
- .map_err(RadrootsAppError::from)?;
- self.subscriptions
- .notify(FfiRuntimeChangeKind::Drafts, Some(draft_id));
- Ok(status.into())
- }
-
- pub async fn phase1_settings(&self) -> Result<FfiMobileSettingsRecord, RadrootsAppError> {
- self.inner
- .phase1_settings()
- .await
- .map(|settings| (&settings).into())
- .map_err(Into::into)
- }
-
- pub async fn phase1_apply_settings_to_runtime(
- &self,
- ) -> Result<FfiMobileSettingsRecord, RadrootsAppError> {
- let settings = self.inner.phase1_settings().await?;
- self.inner
- .configure_relay_preferences(settings.relays())
- .map_err(RadrootsAppError::from)?;
- self.inner
- .configure_blossom_preferences(settings.blossom())
- .map_err(RadrootsAppError::from)?;
- self.subscriptions
- .notify(FfiRuntimeChangeKind::Settings, None);
- Ok((&settings).into())
- }
-
- pub async fn phase1_replace_settings(
- &self,
- input: FfiReplaceSettingsRecord,
- ) -> Result<FfiSettingsTransitionRecord, RadrootsAppError> {
- let current = self.inner.phase1_settings().await?;
- let expected_revision = input.expected_revision;
- let next = input.apply(current)?;
- let transition = self
- .inner
- .phase1_replace_settings(ReplaceMobileSettings::new(expected_revision, next)?)
- .await?;
- self.subscriptions
- .notify(FfiRuntimeChangeKind::Settings, None);
- Ok(transition.into())
- }
-
- pub async fn phase1_apply_identity_command(
- &self,
- expected_revision: u64,
- command: FfiIdentityCommandRecord,
- ) -> Result<FfiSettingsTransitionRecord, RadrootsAppError> {
- let transition = self
- .inner
- .phase1_apply_identity_command(expected_revision, command.try_into()?)
- .await?;
- let identity_id = transition
- .settings
- .identity()
- .active_identity_id()
- .map(str::to_owned);
- self.subscriptions
- .notify(FfiRuntimeChangeKind::Identity, identity_id);
- Ok(transition.into())
- }
-
- pub async fn phase1_save_profile_metadata(
- &self,
- input: FfiProfileMetadataInputRecord,
- ) -> Result<FfiProfileStatusRecord, RadrootsAppError> {
- let blossom = self
- .inner
- .sdk_blossom_slot()
- .map_err(RadrootsAppError::from)?;
- let status = self
- .inner
- .phase1_save_profile_metadata(input.command(blossom.as_ref())?)
- .await?;
- let operation_id = hex::encode(status.draft().draft_id().as_bytes());
- self.subscriptions
- .notify(FfiRuntimeChangeKind::Profile, Some(operation_id));
- Ok(status.into())
- }
-
- pub async fn phase1_profile_status(
- &self,
- operation_id: String,
- ) -> Result<FfiProfileStatusRecord, RadrootsAppError> {
- self.inner
- .phase1_profile_status(decode_id(&operation_id, "invalid_operation_id")?)
- .await
- .map(Into::into)
- .map_err(Into::into)
- }
-
- pub async fn phase1_advance_profile(
- &self,
- operation_id: String,
- ) -> Result<FfiProfileStatusRecord, RadrootsAppError> {
- let status = self
- .inner
- .phase1_advance_profile(decode_id(&operation_id, "invalid_operation_id")?)
- .await?;
- self.subscriptions
- .notify(FfiRuntimeChangeKind::Profile, Some(operation_id));
- Ok(status.into())
- }
-
- pub async fn phase1_cancel_profile(
- &self,
- operation_id: String,
- expected_revision: u64,
- ) -> Result<FfiProfileStatusRecord, RadrootsAppError> {
- let status = self
- .inner
- .phase1_cancel_profile(
- decode_id(&operation_id, "invalid_operation_id")?,
- expected_revision,
- )
- .await?;
- self.subscriptions
- .notify(FfiRuntimeChangeKind::Profile, Some(operation_id));
- Ok(status.into())
- }
-
- pub async fn phase1_save_revision_intent(
- &self,
- mut input: FfiRevisionInputRecord,
- ) -> Result<FfiRevisionStatusRecord, RadrootsAppError> {
- let target = input.target()?;
- if input.replacement.identifier.is_none()
- && matches!(
- input.replacement.command_type,
- crate::FfiAddCommandType::CreateEvent
- | crate::FfiAddCommandType::CreateFoodAvailability
- )
- {
- input.replacement.identifier = Some(phase1_new_addressable_identifier());
- }
- let authored_at_unix_s = phase1_operation_now_unix_ms()? / 1_000;
- let blossom = self
- .inner
- .sdk_blossom_slot()
- .map_err(RadrootsAppError::from)?;
- let (command, media, form) = input
- .replacement
- .command_media_and_form(authored_at_unix_s, blossom.as_ref())?;
- let status = self
- .inner
- .phase1_save_revision_intent(Phase1ReviseIntent::new(target, command, media, form)?)
- .await?;
- let operation_id = hex::encode(status.replacement().draft().draft_id().as_bytes());
- self.subscriptions
- .notify(FfiRuntimeChangeKind::Drafts, Some(operation_id));
- Ok(status.into())
- }
-
- pub async fn phase1_revision_status(
- &self,
- operation_id: String,
- ) -> Result<FfiRevisionStatusRecord, RadrootsAppError> {
- self.inner
- .phase1_revision_status(decode_id(&operation_id, "invalid_operation_id")?)
- .await
- .map(Into::into)
- .map_err(Into::into)
- }
-
- pub async fn phase1_advance_revision(
- &self,
- operation_id: String,
- ) -> Result<FfiRevisionStatusRecord, RadrootsAppError> {
- let status = self
- .inner
- .phase1_advance_revision(decode_id(&operation_id, "invalid_operation_id")?)
- .await?;
- self.subscriptions
- .notify(FfiRuntimeChangeKind::Drafts, Some(operation_id));
- Ok(status.into())
- }
-
- pub async fn phase1_cancel_revision(
- &self,
- operation_id: String,
- ) -> Result<FfiRevisionStatusRecord, RadrootsAppError> {
- let status = self
- .inner
- .phase1_cancel_revision(decode_id(&operation_id, "invalid_operation_id")?)
- .await?;
- self.subscriptions
- .notify(FfiRuntimeChangeKind::Drafts, Some(operation_id));
- Ok(status.into())
- }
-
- pub async fn phase1_retrieve_media(
- &self,
- context: FfiLocalNetworkRecord,
- reference_fingerprint: String,
- operation: Arc<FfiMediaOperation>,
- ) -> Result<FfiVerifiedMediaArtifactRecord, RadrootsAppError> {
- let context = self.local_network(context)?;
- operation.claim()?;
- let operation_id = operation.operation_id();
- let settings = self.inner.phase1_settings().await?;
- let policy = Phase1MediaCachePolicy::new(
- settings.local_storage().media_cache_bytes(),
- settings.local_storage().media_cache_artifacts(),
- )
- .map_err(|_| RadrootsAppError::invalid_argument("invalid_media_cache_policy"))?;
- let artifact = self
- .inner
- .phase1_retrieve_media(
- &context,
- decode_reference_fingerprint(&reference_fingerprint)?,
- operation.id(),
- policy,
- operation.cancellation(),
- )
- .await
- .map_err(|error| {
- RadrootsAppError::from(error).with_operation_id(operation_id.clone())
- })?;
- self.subscriptions.notify(
- FfiRuntimeChangeKind::Media,
- Some(artifact.artifact_id().to_hex()),
- );
- Ok(FfiVerifiedMediaArtifactRecord::from_artifact(
- artifact,
- Some(operation_id),
- ))
- }
-
- pub async fn phase1_verified_media_artifact(
- &self,
- context: FfiLocalNetworkRecord,
- artifact_id: String,
- ) -> Result<Option<FfiVerifiedMediaArtifactRecord>, RadrootsAppError> {
- let context = self.local_network(context)?;
- self.inner
- .phase1_verified_media_artifact(
- &context,
- decode_artifact_id(&artifact_id)?,
- phase1_operation_now_unix_ms()?,
- )
- .await
- .map(|value| {
- value.map(|artifact| FfiVerifiedMediaArtifactRecord::from_artifact(artifact, None))
- })
- .map_err(Into::into)
- }
-
- pub async fn phase1_media_cache_status(
- &self,
- context: FfiLocalNetworkRecord,
- ) -> Result<FfiMediaCacheStatusRecord, RadrootsAppError> {
- let context = self.local_network(context)?;
- self.inner
- .phase1_media_cache_status(&context)
- .await
- .map(Into::into)
- .map_err(Into::into)
- }
-
- pub async fn phase1_invalidate_media_artifact(
- &self,
- context: FfiLocalNetworkRecord,
- artifact_id: String,
- ) -> Result<bool, RadrootsAppError> {
- let context = self.local_network(context)?;
- let changed = self
- .inner
- .phase1_invalidate_media_artifact(&context, decode_artifact_id(&artifact_id)?)
- .await?;
- if changed {
- self.subscriptions
- .notify(FfiRuntimeChangeKind::Media, Some(artifact_id));
- }
- Ok(changed)
- }
-
- pub async fn phase1_invalidate_media_configuration(
- &self,
- context: FfiLocalNetworkRecord,
- configuration_fingerprint: String,
- ) -> Result<Vec<String>, RadrootsAppError> {
- let context = self.local_network(context)?;
- let removed = self
- .inner
- .phase1_invalidate_media_configuration(
- &context,
- decode_configuration(&configuration_fingerprint)?,
- )
- .await?;
- self.subscriptions.notify(FfiRuntimeChangeKind::Media, None);
- Ok(removed.into_iter().map(|value| value.to_hex()).collect())
- }
-}
-
-impl RadrootsRuntime {
- fn local_network(
- &self,
- context: FfiLocalNetworkRecord,
- ) -> Result<radroots_mobile_core::runtime::product_surface::LocalNetwork, RadrootsAppError>
- {
- let profile = self.inner.sdk_relay_status()?.ok_or_else(|| {
- RadrootsAppError::failure(
- "relay_profile_unavailable",
- "relay",
- true,
- &["configure_relay"],
- "The relay profile is unavailable.",
- )
- })?;
- let relay_policy = match profile.profile.as_str() {
- "public" => LocalNetworkRelayPolicy::Public,
- "simulator_local" => LocalNetworkRelayPolicy::Simulator,
- "device_development" => LocalNetworkRelayPolicy::Device,
- _ => {
- return Err(RadrootsAppError::failure(
- "relay_profile_unsupported",
- "relay",
- false,
- &["configure_relay"],
- "The relay profile is unsupported.",
- ));
- }
- };
- context.try_into_with_relay_policy(relay_policy)
- }
-}
-
-async fn build_runtime(
- application_support_directory: String,
- public_key_hex: String,
- source_generation_hex: String,
- source_generation_created_at_unix_ms: u64,
- protected_data: ProtectedDataAvailability,
- host_signer: Option<Box<dyn RadrootsHostSigner>>,
-) -> Result<RadrootsRuntime, RadrootsAppError> {
- let store = radroots_mobile_core::runtime::store::MobileUserStoreConfig::from_encoded(
- application_support_directory,
- public_key_hex.as_str(),
- source_generation_hex.as_str(),
- source_generation_created_at_unix_ms,
- protected_data.into(),
- )?;
- let has_host_signer = host_signer.is_some();
- let mut builder = radroots_mobile_core::runtime::builder::RuntimeBuilder::new(store);
- if let Some(host_signer) = host_signer {
- builder = builder.signer(Arc::new(HostSignerAdapter::new(host_signer)));
- }
- builder
- .build()
- .await
- .map(|inner| RadrootsRuntime {
- inner,
- has_host_signer,
- subscriptions: SubscriptionHub::new(),
- })
- .map_err(Into::into)
-}
diff --git a/crates/mobile_ffi/src/signer.rs b/crates/mobile_ffi/src/signer.rs
@@ -1,404 +0,0 @@
-//! Opaque, secret-free host signing bridge.
-
-use std::sync::Arc;
-
-use radroots_event::{SignedEvent, wire::v1::Nip01EventWire};
-use radroots_signing::{
- Error, SignReceipt, SignRequest, Signer, SignerStatus,
- capability::{CancellationSupport, SignerCapability, SignerKind},
- error::Kind,
- recovery::ReplayCapability,
- signer::BoxFuture,
- status::SignerAvailability,
-};
-
-use crate::MOBILE_FFI_SCHEMA_VERSION;
-
-const SIGNED_EVENT_MAX_BYTES: usize = 1_048_576;
-
-#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)]
-pub enum SignerAvailabilityRecord {
- Ready,
- Busy,
- Locked,
- Unavailable,
-}
-
-#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)]
-pub struct SignerStatusRecord {
- pub schema_version: u16,
- pub availability: SignerAvailabilityRecord,
-}
-
-#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)]
-pub enum HostSigningPurpose {
- NostrEvent,
- BlossomUpload,
-}
-
-#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)]
-pub struct HostSigningRequest {
- pub schema_version: u16,
- pub operation_kind: String,
- pub operation_id: String,
- pub artifact_id: String,
- pub signer_request_id: String,
- pub public_key: String,
- pub purpose: HostSigningPurpose,
- pub deadline_unix_ms: u64,
- pub event_id_digest: Vec<u8>,
- pub expected_event_id: String,
- pub created_at_unix_s: u64,
- pub kind: u32,
- pub tags: Vec<Vec<String>>,
- pub content: String,
-}
-
-#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)]
-pub enum HostSigningOutcome {
- Signed,
- Locked,
- Cancelled,
- Rejected,
- TimedOut,
- Unavailable,
- Invalidated,
- Failed,
-}
-
-#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)]
-pub struct HostSigningResult {
- pub schema_version: u16,
- pub outcome: HostSigningOutcome,
- pub operation_id: String,
- pub signer_request_id: String,
- pub public_key: String,
- pub purpose: HostSigningPurpose,
- pub signature_hex: Option<String>,
- pub completed_at_unix_ms: u64,
-}
-
-#[uniffi::export(callback_interface)]
-#[async_trait::async_trait]
-pub trait RadrootsHostSigner: Send + Sync {
- async fn signer_status(&self) -> SignerStatusRecord;
- async fn sign(&self, request: HostSigningRequest) -> HostSigningResult;
-}
-
-pub(crate) struct HostSignerAdapter {
- host: Arc<dyn RadrootsHostSigner>,
-}
-
-impl HostSignerAdapter {
- pub(crate) fn new(host: Box<dyn RadrootsHostSigner>) -> Self {
- Self {
- host: Arc::from(host),
- }
- }
-}
-
-impl Signer for HostSignerAdapter {
- fn status(&self) -> BoxFuture<'_, Result<SignerStatus, Error>> {
- Box::pin(async move {
- let status = self.host.signer_status().await;
- if status.schema_version != MOBILE_FFI_SCHEMA_VERSION {
- return Err(Error::new(Kind::SignerOutputInvalid));
- }
- let availability = match status.availability {
- SignerAvailabilityRecord::Ready => SignerAvailability::Ready,
- SignerAvailabilityRecord::Busy => SignerAvailability::Busy,
- SignerAvailabilityRecord::Locked => SignerAvailability::AwaitingAuthentication,
- SignerAvailabilityRecord::Unavailable => SignerAvailability::Unavailable,
- };
- Ok(SignerStatus::new(
- availability,
- vec![SignerCapability::new(
- SignerKind::HostMediated,
- ReplayCapability::ExactReplayByRequestId,
- CancellationSupport::BeforeAndAfterPublication,
- false,
- true,
- )],
- None,
- ))
- })
- }
-
- fn sign(&self, request: SignRequest) -> BoxFuture<'_, Result<SignReceipt, Error>> {
- Box::pin(async move {
- request.ensure_active(now_unix_ms())?;
- let ffi_request = HostSigningRequest::from_request(&request)?;
- let result = self.host.sign(ffi_request.clone()).await;
- request.ensure_active(now_unix_ms())?;
- request.ensure_active(result.completed_at_unix_ms)?;
- validate_result_binding(&ffi_request, &result)?;
- match result.outcome {
- HostSigningOutcome::Signed => signed_receipt(&request, result),
- HostSigningOutcome::Locked | HostSigningOutcome::Unavailable => {
- Err(Error::new(Kind::SignerUnavailable))
- }
- HostSigningOutcome::Cancelled => Err(Error::new(Kind::SignerCancelled)),
- HostSigningOutcome::Rejected => Err(Error::new(Kind::SignerRejected)),
- HostSigningOutcome::TimedOut => Err(Error::new(Kind::SignerTimeout)),
- HostSigningOutcome::Invalidated => Err(Error::new(Kind::SignerOutputInvalid)),
- HostSigningOutcome::Failed => Err(Error::new(Kind::InternalError)),
- }
- })
- }
-}
-
-impl HostSigningRequest {
- fn from_request(request: &SignRequest) -> Result<Self, Error> {
- let purpose = match request.purpose() {
- radroots_signing::SigningPurpose::AuthoredEvent => HostSigningPurpose::NostrEvent,
- radroots_signing::SigningPurpose::BlossomUploadAuthorization => {
- HostSigningPurpose::BlossomUpload
- }
- _ => return Err(Error::new(Kind::SignerOutputInvalid)),
- };
- Ok(Self {
- schema_version: MOBILE_FFI_SCHEMA_VERSION,
- operation_kind: request.operation_kind().as_str().to_owned(),
- operation_id: uuid_string(request.intent_id().operation_id().as_bytes()),
- artifact_id: hex::encode(request.intent_id().artifact_id().as_bytes()),
- signer_request_id: request.signer_request_id().to_hex(),
- public_key: request.expected_author().to_hex(),
- purpose,
- deadline_unix_ms: request.policy().deadline_unix_ms(),
- event_id_digest: request.expected_event_id().as_bytes().to_vec(),
- expected_event_id: request.expected_event_id().to_hex(),
- created_at_unix_s: request.created_at(),
- kind: request.kind(),
- tags: request.tags().to_vec(),
- content: request.content().to_owned(),
- })
- }
-}
-
-fn validate_result_binding(
- request: &HostSigningRequest,
- result: &HostSigningResult,
-) -> Result<(), Error> {
- if result.schema_version != MOBILE_FFI_SCHEMA_VERSION
- || result.operation_id != request.operation_id
- || result.signer_request_id != request.signer_request_id
- || result.public_key != request.public_key
- || result.purpose != request.purpose
- || result.completed_at_unix_ms == 0
- || (result.outcome == HostSigningOutcome::Signed) != result.signature_hex.is_some()
- {
- return Err(Error::new(Kind::SignerOutputInvalid));
- }
- Ok(())
-}
-
-fn signed_receipt(request: &SignRequest, result: HostSigningResult) -> Result<SignReceipt, Error> {
- let signature = result
- .signature_hex
- .filter(|value| {
- value.len() == 128
- && value
- .bytes()
- .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
- })
- .ok_or_else(|| Error::new(Kind::SignerOutputInvalid))?;
- let wire = Nip01EventWire {
- id: request.expected_event_id().to_hex(),
- pubkey: request.expected_author().to_hex(),
- created_at: request.created_at(),
- kind: request.kind(),
- tags: request.tags().to_vec(),
- content: request.content().to_owned(),
- sig: signature,
- extra: Default::default(),
- };
- let raw_json = serde_json::to_string(&wire).map_err(|_| Error::new(Kind::InternalError))?;
- if raw_json.len() > SIGNED_EVENT_MAX_BYTES {
- return Err(Error::new(Kind::SignerOutputInvalid));
- }
- let signed = SignedEvent::from_wire_verified_id(wire, raw_json)
- .map_err(|_| Error::new(Kind::SignerOutputInvalid))?;
- SignReceipt::from_signed_event(request, signed, result.completed_at_unix_ms)
-}
-
-fn uuid_string(bytes: &[u8; 16]) -> String {
- let hex = hex::encode(bytes);
- format!(
- "{}-{}-{}-{}-{}",
- &hex[0..8],
- &hex[8..12],
- &hex[12..16],
- &hex[16..20],
- &hex[20..32]
- )
-}
-
-fn now_unix_ms() -> u64 {
- std::time::SystemTime::now()
- .duration_since(std::time::UNIX_EPOCH)
- .map_or(0, |duration| {
- duration.as_millis().try_into().unwrap_or(u64::MAX)
- })
-}
-
-#[cfg(test)]
-mod tests {
- use super::*;
-
- struct StatusHost {
- schema_version: u16,
- availability: SignerAvailabilityRecord,
- }
-
- #[async_trait::async_trait]
- impl RadrootsHostSigner for StatusHost {
- async fn signer_status(&self) -> SignerStatusRecord {
- SignerStatusRecord {
- schema_version: self.schema_version,
- availability: self.availability,
- }
- }
-
- async fn sign(&self, request: HostSigningRequest) -> HostSigningResult {
- HostSigningResult {
- schema_version: MOBILE_FFI_SCHEMA_VERSION,
- outcome: HostSigningOutcome::Failed,
- operation_id: request.operation_id,
- signer_request_id: request.signer_request_id,
- public_key: request.public_key,
- purpose: request.purpose,
- signature_hex: None,
- completed_at_unix_ms: 1,
- }
- }
- }
-
- #[test]
- fn opaque_operation_identity_has_canonical_uuid_shape() {
- assert_eq!(
- uuid_string(&[0xabu8; 16]),
- "abababab-abab-abab-abab-abababababab"
- );
- }
-
- #[test]
- fn result_binding_rejects_signature_on_failure() {
- let request = HostSigningRequest {
- schema_version: 1,
- operation_kind: "sync.push".to_owned(),
- operation_id: "11111111-1111-1111-1111-111111111111".to_owned(),
- artifact_id: "22".repeat(16),
- signer_request_id: "33".repeat(32),
- public_key: "44".repeat(32),
- purpose: HostSigningPurpose::NostrEvent,
- deadline_unix_ms: 10,
- event_id_digest: vec![5; 32],
- expected_event_id: "55".repeat(32),
- created_at_unix_s: 1,
- kind: 1,
- tags: Vec::new(),
- content: "test".to_owned(),
- };
- let result = HostSigningResult {
- schema_version: 1,
- outcome: HostSigningOutcome::Failed,
- operation_id: request.operation_id.clone(),
- signer_request_id: request.signer_request_id.clone(),
- public_key: request.public_key.clone(),
- purpose: request.purpose,
- signature_hex: Some("66".repeat(64)),
- completed_at_unix_ms: 2,
- };
- assert_eq!(
- validate_result_binding(&request, &result)
- .expect_err("failure cannot carry signature")
- .kind(),
- Kind::SignerOutputInvalid
- );
-
- let valid = HostSigningResult {
- signature_hex: None,
- ..result.clone()
- };
- assert!(validate_result_binding(&request, &valid).is_ok());
- let invalid_results = [
- HostSigningResult {
- schema_version: 2,
- ..valid.clone()
- },
- HostSigningResult {
- operation_id: "different".to_owned(),
- ..valid.clone()
- },
- HostSigningResult {
- signer_request_id: "different".to_owned(),
- ..valid.clone()
- },
- HostSigningResult {
- public_key: "different".to_owned(),
- ..valid.clone()
- },
- HostSigningResult {
- purpose: HostSigningPurpose::BlossomUpload,
- ..valid.clone()
- },
- HostSigningResult {
- completed_at_unix_ms: 0,
- ..valid.clone()
- },
- HostSigningResult {
- outcome: HostSigningOutcome::Signed,
- signature_hex: None,
- ..valid
- },
- ];
- for invalid in invalid_results {
- assert_eq!(
- validate_result_binding(&request, &invalid)
- .expect_err("binding mismatch")
- .kind(),
- Kind::SignerOutputInvalid
- );
- }
- }
-
- #[tokio::test]
- async fn host_status_maps_every_availability_and_rejects_schema_drift() {
- for (ffi, expected) in [
- (SignerAvailabilityRecord::Ready, SignerAvailability::Ready),
- (SignerAvailabilityRecord::Busy, SignerAvailability::Busy),
- (
- SignerAvailabilityRecord::Locked,
- SignerAvailability::AwaitingAuthentication,
- ),
- (
- SignerAvailabilityRecord::Unavailable,
- SignerAvailability::Unavailable,
- ),
- ] {
- let adapter = HostSignerAdapter::new(Box::new(StatusHost {
- schema_version: MOBILE_FFI_SCHEMA_VERSION,
- availability: ffi,
- }));
- assert_eq!(
- Signer::status(&adapter)
- .await
- .expect("mapped host status")
- .availability(),
- expected
- );
- }
-
- let adapter = HostSignerAdapter::new(Box::new(StatusHost {
- schema_version: MOBILE_FFI_SCHEMA_VERSION + 1,
- availability: SignerAvailabilityRecord::Ready,
- }));
- assert_eq!(
- Signer::status(&adapter)
- .await
- .expect_err("schema drift")
- .kind(),
- Kind::SignerOutputInvalid
- );
- }
-}
diff --git a/crates/mobile_ffi/src/subscription.rs b/crates/mobile_ffi/src/subscription.rs
@@ -1,321 +0,0 @@
-//! Bounded, independent host subscriptions for focused runtime invalidation signals.
-
-use std::collections::BTreeMap;
-use std::panic::{AssertUnwindSafe, catch_unwind};
-use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
-use std::sync::mpsc::{SyncSender, TrySendError, sync_channel};
-use std::sync::{Arc, Mutex, Weak};
-
-use crate::{MOBILE_FFI_SCHEMA_VERSION, RadrootsAppError};
-
-const MAX_SUBSCRIPTIONS: usize = 32;
-const CHANGE_BUFFER_CAPACITY: usize = 16;
-
-#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)]
-pub enum FfiRuntimeChangeKind {
- Initial,
- Identity,
- Settings,
- Profile,
- Today,
- Drafts,
- Relay,
- Media,
- Lifecycle,
-}
-
-#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)]
-pub struct FfiRuntimeChangeRecord {
- pub schema_version: u16,
- pub generation: u64,
- pub kind: FfiRuntimeChangeKind,
- pub entity_id: Option<String>,
-}
-
-#[uniffi::export(callback_interface)]
-pub trait RadrootsRuntimeObserver: Send + Sync {
- fn on_change(&self, change: FfiRuntimeChangeRecord);
-}
-
-pub(crate) struct SubscriptionHub {
- next_id: AtomicU64,
- generation: AtomicU64,
- closed: AtomicBool,
- subscriptions: Mutex<BTreeMap<u64, SyncSender<FfiRuntimeChangeRecord>>>,
-}
-
-impl SubscriptionHub {
- pub(crate) fn new() -> Arc<Self> {
- Arc::new(Self {
- next_id: AtomicU64::new(1),
- generation: AtomicU64::new(1),
- closed: AtomicBool::new(false),
- subscriptions: Mutex::new(BTreeMap::new()),
- })
- }
-
- pub(crate) fn subscribe(
- self: &Arc<Self>,
- observer: Box<dyn RadrootsRuntimeObserver>,
- ) -> Result<Arc<FfiSubscriptionHandle>, RadrootsAppError> {
- if self.closed.load(Ordering::Acquire) {
- return Err(subscription_error("runtime_closed", false));
- }
- let id = self.next_id.fetch_add(1, Ordering::AcqRel);
- let (sender, receiver) = sync_channel(CHANGE_BUFFER_CAPACITY);
- let observer: Arc<dyn RadrootsRuntimeObserver> = Arc::from(observer);
- let hub = Arc::downgrade(self);
- std::thread::Builder::new()
- .name(format!("radroots-ffi-observer-{id}"))
- .spawn(move || {
- while let Ok(change) = receiver.recv() {
- if catch_unwind(AssertUnwindSafe(|| observer.on_change(change))).is_err() {
- break;
- }
- }
- if let Some(hub) = hub.upgrade() {
- hub.remove(id);
- }
- })
- .map_err(|_| subscription_error("subscription_worker_unavailable", true))?;
-
- {
- let mut subscriptions = self
- .subscriptions
- .lock()
- .unwrap_or_else(std::sync::PoisonError::into_inner);
- if self.closed.load(Ordering::Acquire) || subscriptions.len() >= MAX_SUBSCRIPTIONS {
- return Err(subscription_error("subscription_limit_reached", true));
- }
- subscriptions.insert(id, sender.clone());
- }
-
- let initial = FfiRuntimeChangeRecord {
- schema_version: MOBILE_FFI_SCHEMA_VERSION,
- generation: self.generation.load(Ordering::Acquire),
- kind: FfiRuntimeChangeKind::Initial,
- entity_id: None,
- };
- let _ = sender.try_send(initial);
- Ok(Arc::new(FfiSubscriptionHandle {
- hub: Arc::downgrade(self),
- id: Mutex::new(Some(id)),
- }))
- }
-
- pub(crate) fn notify(&self, kind: FfiRuntimeChangeKind, entity_id: Option<String>) {
- if self.closed.load(Ordering::Acquire) {
- return;
- }
- let change = FfiRuntimeChangeRecord {
- schema_version: MOBILE_FFI_SCHEMA_VERSION,
- generation: self.generation.fetch_add(1, Ordering::AcqRel) + 1,
- kind,
- entity_id,
- };
- let senders = self
- .subscriptions
- .lock()
- .unwrap_or_else(std::sync::PoisonError::into_inner)
- .iter()
- .map(|(id, sender)| (*id, sender.clone()))
- .collect::<Vec<_>>();
- let mut disconnected = Vec::new();
- for (id, sender) in senders {
- match sender.try_send(change.clone()) {
- Ok(()) | Err(TrySendError::Full(_)) => {}
- Err(TrySendError::Disconnected(_)) => disconnected.push(id),
- }
- }
- if !disconnected.is_empty() {
- let mut subscriptions = self
- .subscriptions
- .lock()
- .unwrap_or_else(std::sync::PoisonError::into_inner);
- for id in disconnected {
- subscriptions.remove(&id);
- }
- }
- }
-
- pub(crate) fn close(&self) {
- if !self.closed.swap(true, Ordering::AcqRel) {
- self.generation.fetch_add(1, Ordering::AcqRel);
- self.subscriptions
- .lock()
- .unwrap_or_else(std::sync::PoisonError::into_inner)
- .clear();
- }
- }
-
- fn remove(&self, id: u64) {
- self.subscriptions
- .lock()
- .unwrap_or_else(std::sync::PoisonError::into_inner)
- .remove(&id);
- }
-}
-
-#[derive(uniffi::Object)]
-pub struct FfiSubscriptionHandle {
- hub: Weak<SubscriptionHub>,
- id: Mutex<Option<u64>>,
-}
-
-#[uniffi::export]
-impl FfiSubscriptionHandle {
- pub fn unsubscribe(&self) {
- let id = self
- .id
- .lock()
- .unwrap_or_else(std::sync::PoisonError::into_inner)
- .take();
- if let (Some(hub), Some(id)) = (self.hub.upgrade(), id) {
- hub.remove(id);
- }
- }
-
- pub fn is_active(&self) -> bool {
- let id = *self
- .id
- .lock()
- .unwrap_or_else(std::sync::PoisonError::into_inner);
- let (Some(hub), Some(id)) = (self.hub.upgrade(), id) else {
- return false;
- };
- !hub.closed.load(Ordering::Acquire)
- && hub
- .subscriptions
- .lock()
- .unwrap_or_else(std::sync::PoisonError::into_inner)
- .contains_key(&id)
- }
-}
-
-impl Drop for FfiSubscriptionHandle {
- fn drop(&mut self) {
- let id = self
- .id
- .get_mut()
- .unwrap_or_else(std::sync::PoisonError::into_inner)
- .take();
- if let (Some(hub), Some(id)) = (self.hub.upgrade(), id) {
- hub.remove(id);
- }
- }
-}
-
-fn subscription_error(code: &str, retryable: bool) -> RadrootsAppError {
- RadrootsAppError::failure(
- code,
- "subscription",
- retryable,
- if retryable { &["retry"] } else { &[] },
- "The runtime change subscription is unavailable.",
- )
-}
-
-#[cfg(test)]
-mod tests {
- use std::sync::{Arc, Condvar};
- use std::time::{Duration, Instant};
-
- use super::*;
-
- struct NoopObserver;
-
- impl RadrootsRuntimeObserver for NoopObserver {
- fn on_change(&self, _change: FfiRuntimeChangeRecord) {}
- }
-
- struct PanicObserver;
-
- impl RadrootsRuntimeObserver for PanicObserver {
- fn on_change(&self, _change: FfiRuntimeChangeRecord) {
- panic!("observer panic is isolated");
- }
- }
-
- struct BlockingObserver(Arc<(Mutex<bool>, Condvar)>);
-
- impl RadrootsRuntimeObserver for BlockingObserver {
- fn on_change(&self, change: FfiRuntimeChangeRecord) {
- if change.kind == FfiRuntimeChangeKind::Initial {
- let (released, wake) = &*self.0;
- let guard = released
- .lock()
- .unwrap_or_else(std::sync::PoisonError::into_inner);
- let _guard = wake
- .wait_while(guard, |released| !*released)
- .unwrap_or_else(std::sync::PoisonError::into_inner);
- }
- }
- }
-
- #[test]
- fn closed_limit_and_detached_handle_paths_are_typed_and_idempotent() {
- let closed = SubscriptionHub::new();
- closed.close();
- closed.close();
- closed.notify(FfiRuntimeChangeKind::Today, None);
- let error = closed
- .subscribe(Box::new(NoopObserver))
- .err()
- .expect("closed hub");
- assert_eq!(error.report().code, "runtime_closed");
- assert!(!error.report().retryable);
-
- let hub = SubscriptionHub::new();
- let handles = (0..MAX_SUBSCRIPTIONS)
- .map(|_| hub.subscribe(Box::new(NoopObserver)).expect("subscription"))
- .collect::<Vec<_>>();
- let error = hub
- .subscribe(Box::new(NoopObserver))
- .err()
- .expect("bounded subscription limit");
- assert_eq!(error.report().code, "subscription_limit_reached");
- assert!(error.report().retryable);
- drop(handles);
-
- let detached_hub = SubscriptionHub::new();
- let detached = detached_hub
- .subscribe(Box::new(NoopObserver))
- .expect("detached subscription");
- drop(detached_hub);
- assert!(!detached.is_active());
- detached.unsubscribe();
- detached.unsubscribe();
- }
-
- #[test]
- fn callback_panics_and_full_buffers_never_escape_or_block_publishers() {
- let hub = SubscriptionHub::new();
- let panicking = hub
- .subscribe(Box::new(PanicObserver))
- .expect("panicking subscription");
- let deadline = Instant::now() + Duration::from_secs(1);
- while panicking.is_active() && Instant::now() < deadline {
- std::thread::yield_now();
- }
- assert!(!panicking.is_active());
-
- let release = Arc::new((Mutex::new(false), Condvar::new()));
- let blocked = hub
- .subscribe(Box::new(BlockingObserver(Arc::clone(&release))))
- .expect("blocked subscription");
- for generation in 0..=CHANGE_BUFFER_CAPACITY {
- hub.notify(
- FfiRuntimeChangeKind::Drafts,
- Some(format!("draft-{generation}")),
- );
- }
- assert!(blocked.is_active());
- let (released, wake) = &*release;
- *released
- .lock()
- .unwrap_or_else(std::sync::PoisonError::into_inner) = true;
- wake.notify_all();
- hub.close();
- assert!(!blocked.is_active());
- }
-}
diff --git a/crates/mobile_ffi/tests/local_mvp_real_io.rs b/crates/mobile_ffi/tests/local_mvp_real_io.rs
@@ -1,964 +0,0 @@
-use std::io::Write;
-use std::os::fd::AsRawFd;
-use std::sync::Arc;
-use std::time::Duration;
-
-use nostr::{EventBuilder, Keys, Kind, Metadata, Tag, Timestamp};
-use nostr_relay_builder::MockRelay;
-use nostr_sdk::Client;
-use radroots_blossom::Sha256;
-use radroots_mobile_ffi::{
- FfiAddCommandType, FfiAddDraftInput, FfiBlossomEndpointAuthority, FfiBlossomHostKind,
- FfiBlossomUploadInput, FfiCancellationPolicy, FfiEventTimingKind, FfiLocalNetworkRecord,
- FfiMediaOperation, FfiMediaStage, FfiOutboxState, FfiPreparedMediaInput, FfiQueuePolicyRecord,
- FfiRelaySatisfaction, FfiRetractionDraftInput, FfiTodayCardType, FfiTodayProjectionUpdate,
- FfiTodayRelaySyncState, HostSigningOutcome, HostSigningRequest, HostSigningResult,
- MOBILE_FFI_SCHEMA_VERSION, ProtectedDataAvailability, RadrootsHostSigner, RadrootsRuntime,
- SignerAvailabilityRecord, SignerStatusRecord,
-};
-use secp256k1::{Keypair, Message, Secp256k1, SecretKey};
-use tokio::io::{AsyncReadExt, AsyncWriteExt};
-use tokio::net::TcpListener;
-
-#[allow(dead_code)]
-mod support;
-
-const AUTHORED_AT: u64 = 1_786_000_000;
-const AS_OF: u64 = 1_786_200_000;
-const FIXTURE_SECRET: &str = "0000000000000000000000000000000000000000000000000000000000000001";
-const REPLY_SECRET: &str = "0000000000000000000000000000000000000000000000000000000000000002";
-
-struct FixtureHostSigner;
-
-#[async_trait::async_trait]
-impl RadrootsHostSigner for FixtureHostSigner {
- async fn signer_status(&self) -> SignerStatusRecord {
- SignerStatusRecord {
- schema_version: MOBILE_FFI_SCHEMA_VERSION,
- availability: SignerAvailabilityRecord::Ready,
- }
- }
-
- async fn sign(&self, request: HostSigningRequest) -> HostSigningResult {
- let secret = SecretKey::from_slice(&hex::decode(FIXTURE_SECRET).expect("fixture secret"))
- .expect("valid fixture secret");
- let keypair = Keypair::from_secret_key(&Secp256k1::new(), &secret);
- let digest: [u8; 32] = request
- .event_id_digest
- .clone()
- .try_into()
- .expect("32-byte event digest");
- assert_eq!(hex::encode(digest), request.expected_event_id);
- assert_eq!(
- keypair.x_only_public_key().0.to_string(),
- request.public_key
- );
- let signature = Secp256k1::new()
- .sign_schnorr_no_aux_rand(&Message::from_digest(digest), &keypair)
- .to_string();
- HostSigningResult {
- schema_version: MOBILE_FFI_SCHEMA_VERSION,
- outcome: HostSigningOutcome::Signed,
- operation_id: request.operation_id,
- signer_request_id: request.signer_request_id,
- public_key: request.public_key,
- purpose: request.purpose,
- signature_hex: Some(signature),
- completed_at_unix_ms: unix_time_ms(),
- }
- }
-}
-
-struct BlossomServer {
- origin: String,
- task: tokio::task::JoinHandle<()>,
-}
-
-impl BlossomServer {
- async fn spawn(bytes: Vec<u8>, corrupt_retrieval: bool) -> Self {
- Self::spawn_with_retrievals(bytes, corrupt_retrieval, 1).await
- }
-
- async fn spawn_with_retrievals(
- bytes: Vec<u8>,
- corrupt_retrieval: bool,
- retrievals: usize,
- ) -> Self {
- let listener = TcpListener::bind("127.0.0.1:0")
- .await
- .expect("bind Blossom server");
- let origin = format!("http://{}", listener.local_addr().expect("Blossom address"));
- let hash = Sha256::digest(bytes.as_slice()).to_string();
- let blob_url = format!("{origin}/{hash}.png");
- let descriptor = serde_json::to_vec(&serde_json::json!({
- "url": blob_url,
- "sha256": hash,
- "size": bytes.len(),
- "type": "image/png",
- "uploaded": AUTHORED_AT,
- }))
- .expect("descriptor JSON");
- let task = tokio::spawn(async move {
- let (mut upload, _) = listener.accept().await.expect("accept Blossom upload");
- let request = read_http_request(&mut upload).await;
- let request_text = String::from_utf8_lossy(&request);
- assert!(request_text.starts_with("PUT /upload HTTP/1.1\r\n"));
- assert!(
- request_text
- .to_ascii_lowercase()
- .contains("authorization: nostr ")
- );
- assert!(
- request_text
- .to_ascii_lowercase()
- .contains(format!("x-sha-256: {hash}").as_str())
- );
- write_http_response(&mut upload, "application/json", &descriptor).await;
-
- for _ in 0..retrievals {
- let (mut retrieval, _) = listener.accept().await.expect("accept Blossom retrieval");
- let request = read_http_request(&mut retrieval).await;
- assert!(
- String::from_utf8_lossy(&request)
- .starts_with(format!("GET /{hash}.png HTTP/1.1\r\n").as_str())
- );
- let mut response_bytes = bytes.clone();
- if corrupt_retrieval {
- response_bytes[0] ^= 1;
- }
- write_http_response(&mut retrieval, "image/png", &response_bytes).await;
- }
- });
- Self { origin, task }
- }
-
- async fn finish(self) {
- self.task.await.expect("Blossom server task");
- }
-}
-
-#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
-async fn public_runtime_completes_the_local_mvp_against_real_protocol_services() {
- let relay = MockRelay::run().await.expect("local NIP-01 relay");
- let relay_url = relay.url().await.to_string();
- let image_bytes = png(2, 3);
- let blossom = BlossomServer::spawn_with_retrievals(image_bytes.clone(), false, 2).await;
- let mut image_file = tempfile::tempfile().expect("media file");
- image_file.write_all(&image_bytes).expect("write media");
- let media = prepared_media(&blossom.origin, &image_bytes, &image_file, "Harvest photo");
-
- let publisher_root = tempfile::tempdir().expect("publisher root");
- support::prepare(publisher_root.path());
- let publisher = runtime_with_signer(publisher_root.path()).await;
- configure_simulator(&publisher, &relay_url, &blossom.origin);
- let context = local_network(&relay_url);
-
- let update_id = draft_id(1);
- let update = publisher
- .phase1_save_draft(
- update_id.clone(),
- add_input(
- FfiAddCommandType::CreateUpdate,
- "Equal-time harvest update",
- None,
- ),
- AUTHORED_AT,
- None,
- 1_800_000_000_000,
- )
- .await
- .expect("save offline update");
- let queued_update = queue(
- &publisher,
- &update_id,
- update.revision,
- &relay_url,
- false,
- 1_800_000_000_500,
- )
- .await;
- assert_eq!(queued_update.state, FfiOutboxState::Queued);
- publisher
- .shutdown()
- .await
- .expect("shutdown before delivery");
-
- let publisher = runtime_with_signer(publisher_root.path()).await;
- configure_simulator(&publisher, &relay_url, &blossom.origin);
- let recovered = publisher
- .phase1_recover_draft_queue(update_id.clone(), 1_800_000_001_000)
- .await
- .expect("recover queued update after restart");
- assert_eq!(recovered.state, FfiOutboxState::Queued);
- advance_complete(&publisher, &update_id, recovered.revision).await;
-
- let flows = [
- (
- 2,
- add_input(
- FfiAddCommandType::CreatePhotoUpdate,
- "Equal-time photo harvest",
- Some(media.clone()),
- ),
- ),
- (
- 3,
- add_input(
- FfiAddCommandType::CreateAsk,
- "Equal-time ask: who has basil?",
- None,
- ),
- ),
- (4, event_input("Equal-time Saturday market")),
- (5, food_input("Equal-time carrots", "today-carrots")),
- ];
- for (index, input) in flows {
- let id = draft_id(index);
- let saved = publisher
- .phase1_save_draft(
- id.clone(),
- input,
- AUTHORED_AT,
- None,
- 1_800_000_000_000 + u64::from(index),
- )
- .await
- .expect("save authored flow");
- let ready = if index == 2 {
- let uploaded = publisher
- .phase1_upload_draft_media(FfiBlossomUploadInput {
- schema_version: MOBILE_FFI_SCHEMA_VERSION,
- draft_id: id.clone(),
- expected_revision: saved.revision,
- media: media.clone(),
- authorization_content: "Upload the exact local harvest image".to_owned(),
- authorization_created_at_unix_s: AUTHORED_AT,
- authorization_lifetime_seconds: 300,
- operation_id: "21".repeat(16),
- artifact_id: "22".repeat(16),
- signing_deadline_unix_ms: u64::MAX,
- signing_cancellation: FfiCancellationPolicy::LocalCooperative,
- verified_at_unix_ms: 1_800_000_000_100,
- updated_at_unix_ms: 1_800_000_000_200,
- })
- .await
- .expect("upload and re-fetch exact media");
- assert_eq!(uploaded.media[0].stage, FfiMediaStage::Verified);
- assert_eq!(uploaded.state, FfiOutboxState::MediaPreparing);
- let evidence = publisher
- .sdk_blossom_evidence()
- .expect("Blossom evidence")
- .expect("configured evidence");
- assert_eq!(evidence.state, "retrieval_verified");
- assert_eq!(evidence.last_successful_state, "retrieval_verified");
- assert!(evidence.error_code.is_none());
- uploaded
- } else {
- saved
- };
- let queued = queue(
- &publisher,
- &id,
- ready.revision,
- &relay_url,
- false,
- 1_800_000_000_500 + u64::from(index),
- )
- .await;
- advance_complete(&publisher, &id, queued.revision).await;
- }
- let reader_root = tempfile::tempdir().expect("fresh reader root");
- support::prepare(reader_root.path());
- let reader = RadrootsRuntime::new(
- reader_root.path().to_string_lossy().into_owned(),
- support::PUBLIC_KEY.to_owned(),
- support::GENERATION.to_owned(),
- 1_800_000_000_000,
- ProtectedDataAvailability::Available,
- )
- .await
- .expect("fresh reader runtime");
- reader
- .configure_simulator_relays(vec![relay_url.clone()])
- .expect("reader relay profile");
- let first_sync = reader
- .phase1_sync_today(context.clone(), AS_OF, FfiTodayProjectionUpdate::Rebuild)
- .await
- .expect("fresh relay re-read");
- assert_eq!(first_sync.relay_state, FfiTodayRelaySyncState::Complete);
- assert_eq!(first_sync.events_admitted, 5);
-
- let cards = collect_pages(&reader, &context, 2, AS_OF).await;
- assert_eq!(cards.len(), 5, "all equal-time cards survive frozen paging");
- let mut card_types = cards.iter().map(|card| card.card_type).collect::<Vec<_>>();
- card_types.sort_by_key(|card_type| match card_type {
- FfiTodayCardType::Update => 0,
- FfiTodayCardType::PhotoUpdate => 1,
- FfiTodayCardType::Ask => 2,
- FfiTodayCardType::Event => 3,
- FfiTodayCardType::FoodAvailability => 4,
- });
- assert_eq!(
- card_types,
- vec![
- FfiTodayCardType::Update,
- FfiTodayCardType::PhotoUpdate,
- FfiTodayCardType::Ask,
- FfiTodayCardType::Event,
- FfiTodayCardType::FoodAvailability,
- ]
- );
- let photo = cards
- .iter()
- .find(|card| card.card_type == FfiTodayCardType::PhotoUpdate)
- .expect("photo card");
- assert_eq!(photo.media.len(), 1);
- assert_eq!(
- photo.media[0].sha256.as_deref(),
- Some(media.sha256.as_str())
- );
- reader
- .configure_blossom(
- FfiBlossomHostKind::Simulator,
- FfiBlossomEndpointAuthority::LoopbackDevelopment,
- blossom.origin.clone(),
- vec![],
- )
- .expect("reader Blossom profile");
- let artifact = reader
- .phase1_retrieve_media(
- context.clone(),
- photo.media[0].reference_fingerprint.clone(),
- Arc::new(FfiMediaOperation::new().expect("media operation")),
- )
- .await
- .expect("retrieve projected media");
- assert_eq!(artifact.bytes, image_bytes);
- assert_eq!(
- artifact.byte_size,
- u64::try_from(image_bytes.len()).unwrap()
- );
- assert!(
- reader
- .phase1_verified_media_artifact(context.clone(), artifact.artifact_id.clone())
- .await
- .expect("verify cached media")
- .is_some()
- );
- let media_cache = reader
- .phase1_media_cache_status(context.clone())
- .await
- .expect("media cache status");
- assert_eq!(media_cache.artifact_count, 1);
- assert_eq!(media_cache.total_bytes, artifact.byte_size);
- assert!(media_cache.configuration_fingerprint.is_some());
- assert!(
- reader
- .phase1_invalidate_media_artifact(context.clone(), artifact.artifact_id.clone())
- .await
- .expect("invalidate cached media")
- );
- assert!(
- reader
- .phase1_verified_media_artifact(context.clone(), artifact.artifact_id)
- .await
- .expect("verify invalidated media")
- .is_none()
- );
- blossom.finish().await;
- let update = cards
- .iter()
- .find(|card| card.card_type == FfiTodayCardType::Update)
- .expect("update card");
- let food = cards
- .iter()
- .find(|card| card.card_type == FfiTodayCardType::FoodAvailability)
- .expect("food card");
- assert_eq!(food.food_summary.as_deref(), Some("Freshly harvested"));
- assert_eq!(food.food_published_at_unix_s, Some(AUTHORED_AT));
- assert_eq!(food.food_status.as_deref(), Some("active"));
- let update_event_id = update.source_event_id.clone();
- let update_card_id = update.card_id.clone();
- let food_event_id = food.source_event_id.clone();
- let food_card_id = food.card_id.clone();
-
- publish_supporting_events(&relay_url, &update_event_id, &food_event_id).await;
- reader
- .phase1_sync_today(
- context.clone(),
- AS_OF,
- FfiTodayProjectionUpdate::Incremental,
- )
- .await
- .expect("sync profile and thread events");
- let enriched = reader
- .phase1_today_page(context.clone(), 20, Some(AS_OF), None)
- .await
- .expect("enriched Today");
- assert_eq!(
- enriched
- .items
- .iter()
- .find(|card| card.card_id == update_card_id)
- .expect("reply root")
- .thread
- .len(),
- 1
- );
- assert_eq!(
- enriched
- .items
- .iter()
- .find(|card| card.card_id == food_card_id)
- .expect("comment root")
- .thread
- .len(),
- 1
- );
-
- let offline_port = unused_loopback_port().await;
- let replacement_id = draft_id(6);
- let mut replacement_input = food_input("Corrected carrots from Moss Farm", "today-carrots");
- replacement_input.food_published_at_unix_s = Some(AUTHORED_AT);
- let replacement = publisher
- .phase1_save_draft(
- replacement_id.clone(),
- replacement_input,
- AUTHORED_AT + 10,
- None,
- 1_800_000_010_000,
- )
- .await
- .expect("save replacement");
- let replacement_queued = publisher
- .phase1_queue_draft(
- replacement_id.clone(),
- replacement.revision,
- FfiQueuePolicyRecord {
- schema_version: MOBILE_FFI_SCHEMA_VERSION,
- relay_urls: vec![relay_url.clone(), format!("ws://127.0.0.1:{offline_port}")],
- satisfaction: FfiRelaySatisfaction::AllAccepted,
- delivery_deadline_unix_ms: u64::MAX,
- cancellation: FfiCancellationPolicy::LocalCooperative,
- },
- 1_800_000_010_100,
- )
- .await
- .expect("queue replacement");
- let partial = publisher
- .phase1_advance_draft(replacement_id, replacement_queued.revision)
- .await
- .expect("attempt partial replacement delivery");
- assert_eq!(
- partial.state,
- FfiOutboxState::PartiallyDelivered,
- "partial delivery status: {partial:?}"
- );
- let settlement = partial.settlement.expect("partial settlement");
- assert_eq!(settlement.delivery_satisfied, 0);
- assert_eq!(settlement.delivery_exhausted, 1);
-
- reader
- .phase1_sync_today(
- context.clone(),
- AS_OF,
- FfiTodayProjectionUpdate::Incremental,
- )
- .await
- .expect("sync replacement");
- let replaced = reader
- .phase1_today_page(context.clone(), 20, Some(AS_OF), None)
- .await
- .expect("replacement projection");
- let current_food = replaced
- .items
- .iter()
- .find(|card| card.card_id == food_card_id)
- .expect("current food head");
- assert_eq!(current_food.content, "Corrected carrots from Moss Farm");
- assert_ne!(current_food.source_event_id, food_event_id);
-
- let retraction_id = draft_id(7);
- let retraction = publisher
- .phase1_save_retraction_draft(
- retraction_id.clone(),
- FfiRetractionDraftInput {
- schema_version: MOBILE_FFI_SCHEMA_VERSION,
- command_type: FfiAddCommandType::CreateUpdate,
- target_card_id: update_card_id.clone(),
- target_event_id: update_event_id,
- target_kind: 1,
- target_address: None,
- reason: "Superseded local update".to_owned(),
- },
- AUTHORED_AT + 20,
- 1_800_000_020_000,
- )
- .await
- .expect("save deletion request");
- let retraction_queued = queue(
- &publisher,
- &retraction_id,
- retraction.revision,
- &relay_url,
- false,
- 1_800_000_020_100,
- )
- .await;
- advance_complete(&publisher, &retraction_id, retraction_queued.revision).await;
- reader
- .phase1_sync_today(
- context.clone(),
- AS_OF,
- FfiTodayProjectionUpdate::Incremental,
- )
- .await
- .expect("sync deletion");
- let after_deletion = reader
- .phase1_today_page(context.clone(), 20, Some(AS_OF), None)
- .await
- .expect("projection after deletion");
- assert_eq!(after_deletion.items.len(), 4);
- assert!(
- after_deletion
- .items
- .iter()
- .all(|card| card.card_id != update_card_id)
- );
-
- let search = reader
- .phase1_search(context.clone(), "moss farm".to_owned(), 20, AS_OF)
- .await
- .expect("search current projection");
- assert!(search.iter().any(|result| result.profile.is_some()));
- assert!(search.iter().any(|result| {
- result
- .card
- .as_ref()
- .is_some_and(|card| card.content == "Corrected carrots from Moss Farm")
- }));
- let me = reader
- .phase1_me(context.clone(), AS_OF)
- .await
- .expect("Me projection");
- assert_eq!(
- me.profile
- .as_ref()
- .and_then(|profile| profile.display_name.as_deref()),
- Some("Moss Farm")
- );
- assert_eq!(me.cards.len(), 4);
-
- prove_corrupted_media_fails(&publisher, &image_bytes, &image_file).await;
-
- reader.shutdown().await.expect("reader shutdown");
- publisher.shutdown().await.expect("publisher shutdown");
- relay.shutdown();
-}
-
-async fn runtime_with_signer(root: &std::path::Path) -> RadrootsRuntime {
- RadrootsRuntime::with_host_signer(
- root.to_string_lossy().into_owned(),
- support::PUBLIC_KEY.to_owned(),
- support::GENERATION.to_owned(),
- 1_800_000_000_000,
- ProtectedDataAvailability::Available,
- Box::new(FixtureHostSigner),
- )
- .await
- .expect("runtime with fixture host signer")
-}
-
-fn configure_simulator(runtime: &RadrootsRuntime, relay_url: &str, blossom_origin: &str) {
- runtime
- .configure_simulator_relays(vec![relay_url.to_owned()])
- .expect("simulator relay profile");
- runtime
- .configure_blossom(
- FfiBlossomHostKind::Simulator,
- FfiBlossomEndpointAuthority::LoopbackDevelopment,
- blossom_origin.to_owned(),
- vec![],
- )
- .expect("simulator Blossom profile");
-}
-
-fn local_network(relay_url: &str) -> FfiLocalNetworkRecord {
- FfiLocalNetworkRecord {
- schema_version: MOBILE_FFI_SCHEMA_VERSION,
- id: "local-mvp".to_owned(),
- label: "Local MVP".to_owned(),
- relay_urls: vec![relay_url.to_owned()],
- locality: None,
- followed_authors: Vec::new(),
- generation: 1,
- }
-}
-
-fn add_input(
- command_type: FfiAddCommandType,
- content: &str,
- media: Option<FfiPreparedMediaInput>,
-) -> FfiAddDraftInput {
- FfiAddDraftInput {
- schema_version: MOBILE_FFI_SCHEMA_VERSION,
- command_type,
- content: content.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: media.into_iter().collect(),
- }
-}
-
-fn event_input(content: &str) -> FfiAddDraftInput {
- FfiAddDraftInput {
- identifier: Some("saturday-market".to_owned()),
- title: Some("Saturday Market".to_owned()),
- location: Some("Victoria".to_owned()),
- event_timing: Some(FfiEventTimingKind::AllDay),
- event_start_date: Some("2026-08-09".to_owned()),
- ..add_input(FfiAddCommandType::CreateEvent, content, None)
- }
-}
-
-fn food_input(content: &str, identifier: &str) -> FfiAddDraftInput {
- FfiAddDraftInput {
- identifier: Some(identifier.to_owned()),
- title: Some("Carrots".to_owned()),
- summary: Some("Freshly harvested".to_owned()),
- location: Some("Victoria".to_owned()),
- price_amount: Some("4.5".to_owned()),
- currency: Some("CAD".to_owned()),
- unit: Some("kg".to_owned()),
- quantity: Some("12".to_owned()),
- food_status: Some("active".to_owned()),
- ..add_input(FfiAddCommandType::CreateFoodAvailability, content, None)
- }
-}
-
-fn prepared_media(
- _origin: &str,
- bytes: &[u8],
- file: &std::fs::File,
- alt: &str,
-) -> FfiPreparedMediaInput {
- let hash = Sha256::digest(bytes).to_string();
- FfiPreparedMediaInput {
- schema_version: MOBILE_FFI_SCHEMA_VERSION,
- opaque_reference: format!("media:{hash}"),
- file_descriptor: u64::try_from(file.as_raw_fd()).expect("nonnegative media descriptor"),
- sha256: hash,
- media_type: "image/png".to_owned(),
- byte_size: u64::try_from(bytes.len()).expect("media size"),
- width: 2,
- height: 3,
- alt: alt.to_owned(),
- prepared_at_unix_s: AUTHORED_AT,
- }
-}
-
-async fn queue(
- runtime: &RadrootsRuntime,
- draft_id: &str,
- revision: u64,
- relay_url: &str,
- all: bool,
- queued_at_unix_ms: u64,
-) -> radroots_mobile_ffi::FfiDraftStatusRecord {
- runtime
- .phase1_queue_draft(
- draft_id.to_owned(),
- revision,
- FfiQueuePolicyRecord {
- schema_version: MOBILE_FFI_SCHEMA_VERSION,
- relay_urls: vec![relay_url.to_owned()],
- satisfaction: if all {
- FfiRelaySatisfaction::AllAccepted
- } else {
- FfiRelaySatisfaction::AnyAccepted
- },
- delivery_deadline_unix_ms: u64::MAX,
- cancellation: FfiCancellationPolicy::LocalCooperative,
- },
- queued_at_unix_ms,
- )
- .await
- .expect("queue draft")
-}
-
-async fn advance_complete(runtime: &RadrootsRuntime, draft_id: &str, revision: u64) {
- let status = match runtime
- .phase1_advance_draft(draft_id.to_owned(), revision)
- .await
- {
- Ok(status) => status,
- Err(error) => {
- let durable = runtime
- .phase1_draft_status(draft_id.to_owned())
- .await
- .expect("durable failure status");
- panic!("advance failed: {error:?}; durable status: {durable:?}");
- }
- };
- assert_eq!(status.state, FfiOutboxState::Complete);
- let settlement = status.settlement.expect("complete settlement");
- assert_eq!(settlement.signed, 1);
- assert_eq!(settlement.admitted, 1);
- assert_eq!(settlement.delivery_satisfied, 1);
-}
-
-async fn collect_pages(
- runtime: &RadrootsRuntime,
- context: &FfiLocalNetworkRecord,
- limit: u16,
- as_of: u64,
-) -> Vec<radroots_mobile_ffi::FfiTodayCardRecord> {
- let first = runtime
- .phase1_today_page(context.clone(), limit, Some(as_of), None)
- .await
- .expect("first Today page");
- let mut items = first.items;
- let mut cursor = first.next_cursor;
- while let Some(next) = cursor {
- let page = runtime
- .phase1_today_page(context.clone(), limit, None, Some(next))
- .await
- .expect("continued Today page");
- items.extend(page.items);
- cursor = page.next_cursor;
- }
- let mut unique = items.iter().map(|card| &card.card_id).collect::<Vec<_>>();
- unique.sort_unstable();
- unique.dedup();
- assert_eq!(unique.len(), items.len(), "frozen pages have no duplicates");
- items
-}
-
-async fn publish_supporting_events(relay_url: &str, update_id: &str, food_id: &str) {
- let profile_keys = Keys::parse(FIXTURE_SECRET).expect("profile keys");
- let profile_client = Client::new(profile_keys);
- profile_client
- .add_relay(relay_url)
- .await
- .expect("profile relay");
- profile_client.connect().await;
- profile_client
- .wait_for_connection(Duration::from_secs(2))
- .await;
- profile_client
- .send_event_builder(
- EventBuilder::metadata(
- &Metadata::new()
- .name("moss")
- .display_name("Moss Farm")
- .about("Local harvests"),
- )
- .custom_created_at(Timestamp::from_secs(AUTHORED_AT + 1)),
- )
- .await
- .expect("publish profile");
- profile_client.shutdown().await;
-
- let reply_keys = Keys::parse(REPLY_SECRET).expect("reply keys");
- let reply_author = reply_keys.public_key().to_string();
- let reply_client = Client::new(reply_keys);
- reply_client
- .add_relay(relay_url)
- .await
- .expect("reply relay");
- reply_client.connect().await;
- reply_client
- .wait_for_connection(Duration::from_secs(2))
- .await;
- reply_client
- .send_event_builder(
- EventBuilder::text_note("The farm stand is open")
- .tags([
- Tag::parse(["e", update_id, relay_url, "root"]).expect("reply root tag"),
- Tag::parse(["p", support::PUBLIC_KEY]).expect("reply author tag"),
- ])
- .custom_created_at(Timestamp::from_secs(AUTHORED_AT + 2)),
- )
- .await
- .expect("publish reply");
- reply_client
- .send_event_builder(
- EventBuilder::new(Kind::Custom(1_111), "Are these available Saturday?")
- .tags([
- Tag::parse(["E", food_id, relay_url, support::PUBLIC_KEY])
- .expect("comment root event tag"),
- Tag::parse(["K", "30402"]).expect("comment root kind tag"),
- Tag::parse(["P", support::PUBLIC_KEY, relay_url])
- .expect("comment root author tag"),
- Tag::parse(["e", food_id, relay_url, support::PUBLIC_KEY])
- .expect("comment parent event tag"),
- Tag::parse(["k", "30402"]).expect("comment parent kind tag"),
- Tag::parse(["p", support::PUBLIC_KEY, relay_url])
- .expect("comment parent author tag"),
- Tag::parse(["p", reply_author.as_str()]).expect("self author context tag"),
- ])
- .custom_created_at(Timestamp::from_secs(AUTHORED_AT + 3)),
- )
- .await
- .expect("publish comment");
- reply_client.shutdown().await;
-}
-
-async fn prove_corrupted_media_fails(
- runtime: &RadrootsRuntime,
- bytes: &[u8],
- image_file: &std::fs::File,
-) {
- let corrupt = BlossomServer::spawn(bytes.to_vec(), true).await;
- runtime
- .configure_blossom(
- FfiBlossomHostKind::Simulator,
- FfiBlossomEndpointAuthority::LoopbackDevelopment,
- corrupt.origin.clone(),
- vec![],
- )
- .expect("corrupt test Blossom profile");
- let media = prepared_media(
- &corrupt.origin,
- bytes,
- image_file,
- "Corrupt retrieval proof",
- );
- let id = draft_id(8);
- let saved = runtime
- .phase1_save_draft(
- id.clone(),
- add_input(
- FfiAddCommandType::CreatePhotoUpdate,
- "This image must fail closed",
- Some(media.clone()),
- ),
- AUTHORED_AT + 30,
- None,
- 1_800_000_030_000,
- )
- .await
- .expect("save corrupt-media draft");
- let error = runtime
- .phase1_upload_draft_media(FfiBlossomUploadInput {
- schema_version: MOBILE_FFI_SCHEMA_VERSION,
- draft_id: id.clone(),
- expected_revision: saved.revision,
- media,
- authorization_content: "Verify corrupt retrieval rejection".to_owned(),
- authorization_created_at_unix_s: AUTHORED_AT,
- authorization_lifetime_seconds: 300,
- operation_id: "31".repeat(16),
- artifact_id: "32".repeat(16),
- signing_deadline_unix_ms: u64::MAX,
- signing_cancellation: FfiCancellationPolicy::LocalCooperative,
- verified_at_unix_ms: 1_800_000_030_100,
- updated_at_unix_ms: 1_800_000_030_200,
- })
- .await
- .expect_err("corrupt media retrieval must fail");
- assert_eq!(error.report().code, "authoring_failed");
- let failed = runtime
- .phase1_draft_status(id)
- .await
- .expect("durable corrupt-media status");
- assert_eq!(failed.media[0].stage, FfiMediaStage::Failed);
- assert_eq!(failed.state, FfiOutboxState::MediaPreparing);
- assert!(failed.media[0].possible_orphan);
- let evidence = runtime
- .sdk_blossom_evidence()
- .expect("Blossom evidence")
- .expect("configured evidence");
- assert_eq!(evidence.state, "terminal_failure");
- assert_eq!(evidence.last_successful_state, "upload_verified");
- assert_eq!(
- evidence.error_code.as_deref(),
- Some("blossom_response_hash_mismatch")
- );
- assert_eq!(evidence.error_phase.as_deref(), Some("verification"));
- assert!(evidence.possible_orphan);
- corrupt.finish().await;
-}
-
-async fn unused_loopback_port() -> u16 {
- let listener = TcpListener::bind("127.0.0.1:0")
- .await
- .expect("reserve unused port");
- listener.local_addr().expect("unused address").port()
-}
-
-async fn read_http_request(stream: &mut tokio::net::TcpStream) -> Vec<u8> {
- let mut request = Vec::new();
- let header_end = loop {
- let mut chunk = [0_u8; 1_024];
- let read = stream.read(&mut chunk).await.expect("read HTTP request");
- assert_ne!(read, 0, "HTTP request ended before headers");
- request.extend_from_slice(&chunk[..read]);
- if let Some(index) = request.windows(4).position(|value| value == b"\r\n\r\n") {
- break index + 4;
- }
- assert!(request.len() < 64 * 1_024, "HTTP headers are bounded");
- };
- let content_length = String::from_utf8_lossy(&request[..header_end])
- .lines()
- .find_map(|line| {
- line.to_ascii_lowercase()
- .strip_prefix("content-length: ")
- .and_then(|value| value.trim().parse::<usize>().ok())
- })
- .unwrap_or_default();
- while request.len() - header_end < content_length {
- let mut chunk = [0_u8; 1_024];
- let read = stream.read(&mut chunk).await.expect("read HTTP body");
- assert_ne!(read, 0, "HTTP request ended before body");
- request.extend_from_slice(&chunk[..read]);
- }
- request
-}
-
-async fn write_http_response(stream: &mut tokio::net::TcpStream, content_type: &str, body: &[u8]) {
- let head = format!(
- "HTTP/1.1 200 OK\r\nContent-Type: {content_type}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
- body.len()
- );
- stream
- .write_all(head.as_bytes())
- .await
- .expect("write response head");
- stream.write_all(body).await.expect("write response body");
- stream.shutdown().await.expect("close HTTP response");
-}
-
-fn draft_id(index: u8) -> String {
- format!("{index:02x}").repeat(16)
-}
-
-fn png(width: u32, height: u32) -> Vec<u8> {
- let mut bytes = b"\x89PNG\r\n\x1a\n\0\0\0\rIHDR".to_vec();
- bytes.extend_from_slice(&width.to_be_bytes());
- bytes.extend_from_slice(&height.to_be_bytes());
- bytes
-}
-
-fn unix_time_ms() -> u64 {
- std::time::SystemTime::now()
- .duration_since(std::time::UNIX_EPOCH)
- .expect("system clock after epoch")
- .as_millis()
- .try_into()
- .expect("current time fits u64")
-}
diff --git a/crates/mobile_ffi/tests/logging_error.rs b/crates/mobile_ffi/tests/logging_error.rs
@@ -1,13 +0,0 @@
-use radroots_mobile_ffi::RadrootsAppError;
-use radroots_mobile_ffi::logging;
-
-#[test]
-fn init_logging_stdout_maps_global_subscriber_error() {
- let _ = tracing_subscriber::fmt().try_init();
- let err = logging::init_logging_stdout();
- assert!(matches!(
- err,
- Err(RadrootsAppError::Failure { report })
- if report.code == "initialization_failed"
- ));
-}
diff --git a/crates/mobile_ffi/tests/runtime_delegation.rs b/crates/mobile_ffi/tests/runtime_delegation.rs
@@ -1,850 +0,0 @@
-use std::sync::Arc;
-
-use radroots_mobile_ffi::{
- FfiAddCommandType, FfiAddDraftInput, FfiBlossomAuthorityPreference,
- FfiBlossomEndpointAuthority, FfiBlossomHostKind, FfiBlossomPreferencesRecord,
- FfiBlossomUploadIntent, FfiCancellationPolicy, FfiDraftKind, FfiEventTimingKind,
- FfiIdentityCommandKind, FfiIdentityCommandRecord, FfiIdentityLockState, FfiLocalNetworkRecord,
- FfiLocalStoragePolicyRecord, FfiMediaNetworkPolicyRecord, FfiMediaOperation,
- FfiMobileNetworkEnvironment, FfiNativeUploadCompletionInput, FfiOutboxState,
- FfiPreparedMediaInput, FfiProfileMetadataInputRecord, FfiQueuePolicyRecord,
- FfiRelayAccessPreference, FfiRelayAccessRecord, FfiRelayPreferenceRecord,
- FfiRelayPreferencesRecord, FfiRelaySatisfaction, FfiReplaceSettingsRecord,
- FfiRetractionDraftInput, FfiRevisionInputRecord, FfiRevisionPhase, FfiTodayCardType,
- FfiTodayProjectionUpdate, MOBILE_FFI_SCHEMA_VERSION, RadrootsAppError,
-};
-
-mod support;
-
-#[tokio::test]
-async fn native_boundary_delegates_the_complete_core_surface() {
- let (_root, runtime) = support::runtime().await;
- assert!(runtime.uptime_millis() >= 0);
- assert!(runtime.info_json().contains("sdk"));
- runtime.set_app_info_platform(
- Some("ios".to_owned()),
- Some("org.radroots.app".to_owned()),
- Some("0.1.0-alpha".to_owned()),
- Some("1".to_owned()),
- Some("0123456789abcdef0123456789abcdef01234567".to_owned()),
- );
- assert_eq!(
- runtime.info().app.platform.expect("platform").platform,
- Some("ios".to_owned())
- );
- assert!(!runtime.sdk_capabilities().is_empty());
- assert_eq!(
- runtime.sdk_storage_status().await.expect("storage").backend,
- "sqlite"
- );
-
- let public = runtime
- .sdk_relay_status()
- .expect("relay status")
- .expect("default public profile");
- assert_eq!(public.profile, "public");
- assert_eq!(public.state, "configured");
- assert_eq!(public.read_availability, "unavailable");
- assert_eq!(public.write_availability, "unavailable");
- assert_eq!(public.relays.len(), 1);
- assert_eq!(public.relays[0].access, FfiRelayAccessRecord::ReadWrite);
- assert_eq!(public.relays[0].read_state, "unobserved");
- assert_eq!(public.relays[0].write_state, "unobserved");
-
- runtime
- .configure_public_relays(vec!["wss://write.example".to_owned()])
- .expect("public relays");
- let public = runtime
- .sdk_relay_status()
- .expect("relay status")
- .expect("public profile");
- assert_eq!(public.relays.len(), 1);
- assert_eq!(public.relays[0].relay_url, "wss://write.example");
- assert_eq!(public.relays[0].access, FfiRelayAccessRecord::ReadWrite);
- assert!(
- runtime
- .configure_public_relays(vec!["ws://127.0.0.1:7447".to_owned()])
- .is_err()
- );
-
- let settings = runtime.phase1_settings().await.expect("settings");
- runtime
- .phase1_replace_settings(FfiReplaceSettingsRecord {
- schema_version: MOBILE_FFI_SCHEMA_VERSION,
- expected_revision: settings.revision,
- relays: FfiRelayPreferencesRecord {
- schema_version: MOBILE_FFI_SCHEMA_VERSION,
- environment: FfiMobileNetworkEnvironment::Public,
- endpoints: vec![
- FfiRelayPreferenceRecord {
- schema_version: MOBILE_FFI_SCHEMA_VERSION,
- url: "wss://read.example".to_owned(),
- access: FfiRelayAccessPreference::ReadOnly,
- },
- FfiRelayPreferenceRecord {
- schema_version: MOBILE_FFI_SCHEMA_VERSION,
- url: "wss://write.example".to_owned(),
- access: FfiRelayAccessPreference::ReadWrite,
- },
- ],
- },
- blossom: FfiBlossomPreferencesRecord {
- schema_version: MOBILE_FFI_SCHEMA_VERSION,
- environment: FfiMobileNetworkEnvironment::Public,
- authority: FfiBlossomAuthorityPreference::PublicWebPki,
- primary_origin: "https://media.example".to_owned(),
- fallback_origins: vec![],
- },
- media_network: FfiMediaNetworkPolicyRecord {
- schema_version: MOBILE_FFI_SCHEMA_VERSION,
- allow_cellular_downloads: true,
- allow_cellular_uploads: true,
- allow_background_transfers: true,
- },
- local_storage: FfiLocalStoragePolicyRecord {
- schema_version: MOBILE_FFI_SCHEMA_VERSION,
- media_cache_bytes: 256 * 1024 * 1024,
- media_cache_artifacts: 1024,
- },
- })
- .await
- .expect("replace settings");
- runtime
- .phase1_apply_settings_to_runtime()
- .await
- .expect("apply settings");
- let applied = runtime
- .sdk_relay_status()
- .expect("relay status")
- .expect("applied settings profile");
- assert_eq!(applied.relays.len(), 2);
- assert_eq!(applied.relays[0].access, FfiRelayAccessRecord::ReadOnly);
- assert_eq!(applied.relays[1].access, FfiRelayAccessRecord::ReadWrite);
-
- runtime
- .configure_simulator_relays(vec!["ws://127.0.0.1:7447".to_owned()])
- .expect("simulator relays");
- let simulator = runtime
- .sdk_relay_status()
- .expect("relay status")
- .expect("simulator profile");
- assert_eq!(simulator.profile, "simulator_local");
- assert_eq!(simulator.relays.len(), 1);
- assert_eq!(simulator.relays[0].access, FfiRelayAccessRecord::ReadWrite);
- assert!(
- runtime
- .phase1_local_network(FfiLocalNetworkRecord {
- schema_version: MOBILE_FFI_SCHEMA_VERSION,
- id: "simulator".to_owned(),
- label: "Simulator".to_owned(),
- relay_urls: vec!["ws://127.0.0.1:7447".to_owned()],
- locality: None,
- followed_authors: vec![],
- generation: 1,
- })
- .is_ok()
- );
- assert!(
- runtime
- .configure_simulator_relays(vec!["wss://relay.example".to_owned()])
- .is_err()
- );
-
- runtime
- .configure_device_relays(vec!["wss://10.0.0.5:7447".to_owned()])
- .expect("device relays");
- let device = runtime
- .sdk_relay_status()
- .expect("relay status")
- .expect("device profile");
- assert_eq!(device.profile, "device_development");
- assert_eq!(device.relays.len(), 1);
- assert_eq!(device.relays[0].relay_url, "wss://10.0.0.5:7447");
- assert_eq!(device.relays[0].access, FfiRelayAccessRecord::ReadWrite);
- assert!(
- runtime
- .configure_device_relays(vec!["wss://127.0.0.1:7447".to_owned()])
- .is_err()
- );
-
- runtime
- .configure_blossom(
- FfiBlossomHostKind::PhysicalDevice,
- FfiBlossomEndpointAuthority::PublicWebPki,
- "https://media.example".to_owned(),
- vec!["https://fallback.example".to_owned()],
- )
- .expect("public Blossom");
- let blossom = runtime
- .sdk_blossom_configuration()
- .expect("Blossom configuration")
- .expect("configured Blossom");
- assert_eq!(blossom.host_kind, "physical_device");
- assert_eq!(blossom.endpoint_authority, "public_webpki");
- assert_eq!(blossom.primary_origin, "https://media.example");
- assert_eq!(blossom.fallback_origins, ["https://fallback.example"]);
- assert_eq!(blossom.config_fingerprint.len(), 64);
- let evidence = runtime
- .sdk_blossom_evidence()
- .expect("Blossom evidence")
- .expect("configured evidence");
- assert_eq!(evidence.schema_version, 2);
- assert_eq!(evidence.origin, "https://media.example");
- assert_eq!(evidence.config_fingerprint, blossom.config_fingerprint);
- assert_eq!(evidence.state, "configured_unobserved");
- assert_eq!(evidence.transport_security, "public_webpki");
- assert!(evidence.observed_at_unix_ms.is_none());
- assert!(evidence.error_code.is_none());
- assert!(evidence.server_error_code.is_none());
- runtime
- .configure_blossom(
- FfiBlossomHostKind::Simulator,
- FfiBlossomEndpointAuthority::LoopbackDevelopment,
- "http://127.0.0.1:3100".to_owned(),
- vec![],
- )
- .expect("simulator Blossom");
- runtime
- .configure_blossom(
- FfiBlossomHostKind::Simulator,
- FfiBlossomEndpointAuthority::LoopbackDevelopment,
- "http://127.0.0.1:1".to_owned(),
- vec![],
- )
- .expect("unavailable simulator Blossom");
- assert!(runtime.probe_blossom().await.is_err());
- runtime
- .configure_blossom(
- FfiBlossomHostKind::PhysicalDevice,
- FfiBlossomEndpointAuthority::PrivateNetworkDevelopment,
- "https://10.0.0.5:3100".to_owned(),
- vec![],
- )
- .expect("device Blossom");
-
- assert_eq!(
- runtime
- .phase1_card_add_parity()
- .into_iter()
- .map(|item| item.card_type)
- .collect::<Vec<_>>(),
- vec![
- FfiTodayCardType::Update,
- FfiTodayCardType::PhotoUpdate,
- FfiTodayCardType::Ask,
- FfiTodayCardType::Event,
- FfiTodayCardType::FoodAvailability,
- ]
- );
- assert_eq!(
- runtime
- .phase1_add_schemas()
- .into_iter()
- .map(|schema| schema.command_type)
- .collect::<Vec<_>>(),
- vec![
- FfiAddCommandType::CreateUpdate,
- FfiAddCommandType::CreatePhotoUpdate,
- FfiAddCommandType::CreateAsk,
- FfiAddCommandType::CreateEvent,
- FfiAddCommandType::CreateFoodAvailability,
- ]
- );
- let parity = runtime.phase1_card_add_parity();
- let schemas = runtime.phase1_add_schemas();
- assert_eq!(parity.len(), 5);
- for (index, item) in parity.iter().enumerate() {
- assert_eq!(item.command_type, schemas[index].command_type);
- }
- assert_eq!(
- schemas[1]
- .fields
- .iter()
- .find(|field| field.id == "media")
- .and_then(|field| field.max_items),
- Some(20)
- );
- assert_eq!(
- schemas[3]
- .fields
- .iter()
- .find(|field| field.id == "media")
- .and_then(|field| field.max_items),
- Some(1)
- );
- let local_network = runtime
- .phase1_local_network(FfiLocalNetworkRecord {
- schema_version: MOBILE_FFI_SCHEMA_VERSION,
- id: "nearby".to_owned(),
- label: "Near me".to_owned(),
- relay_urls: vec!["ws://192.168.1.7:7447".to_owned()],
- locality: Some("u10h".to_owned()),
- followed_authors: vec!["a".repeat(64)],
- generation: 1,
- })
- .expect("valid local network");
- assert_eq!(local_network.id, "nearby");
- assert!(
- runtime
- .phase1_local_network(FfiLocalNetworkRecord {
- schema_version: MOBILE_FFI_SCHEMA_VERSION,
- id: "public-host".to_owned(),
- label: "Public host".to_owned(),
- relay_urls: vec!["wss://relay.example".to_owned()],
- locality: None,
- followed_authors: vec![],
- generation: 1,
- })
- .is_err()
- );
- let refresh = runtime
- .phase1_refresh_today(
- local_network.clone(),
- 1_800_000_001,
- FfiTodayProjectionUpdate::Incremental,
- )
- .await
- .expect("empty Today refresh");
- assert_eq!(refresh.update, FfiTodayProjectionUpdate::Incremental);
- assert_eq!(refresh.source_events, 0);
- assert_eq!(refresh.visible_cards, 0);
- assert!(refresh.content_generation > 0);
- let today = runtime
- .phase1_today_page(local_network.clone(), 20, Some(1_800_000_001), None)
- .await
- .expect("empty Today page");
- assert!(today.items.is_empty());
- assert!(today.next_cursor.is_none());
- assert!(
- runtime
- .phase1_today_page(local_network.clone(), 20, None, None)
- .await
- .is_err()
- );
- assert!(
- runtime
- .phase1_today_page(
- local_network.clone(),
- 20,
- Some(1_800_000_001),
- Some("opaque".to_owned()),
- )
- .await
- .is_err()
- );
- assert!(
- runtime
- .phase1_search(
- local_network.clone(),
- "carrots".to_owned(),
- 20,
- 1_800_000_001,
- )
- .await
- .expect("empty search")
- .is_empty()
- );
- let me = runtime
- .phase1_me(local_network.clone(), 1_800_000_001)
- .await
- .expect("Me snapshot");
- assert_eq!(me.public_key, support::PUBLIC_KEY);
-
- let add = FfiAddDraftInput {
- schema_version: MOBILE_FFI_SCHEMA_VERSION,
- command_type: FfiAddCommandType::CreateUpdate,
- content: "Farm stand opens at noon".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(),
- };
- runtime
- .phase1_validate_add_draft(add.clone(), 1_800_000_001)
- .expect("valid draft");
- let saved = runtime
- .phase1_save_add_intent(add, None, None)
- .await
- .expect("saved draft");
- let draft_id = saved.draft_id.clone();
- assert_eq!(draft_id.len(), 32);
- assert_eq!(saved.state, FfiOutboxState::Draft);
- assert_eq!(saved.kind, FfiDraftKind::Add);
- assert_eq!(
- saved.form.as_ref().map(|form| form.content.as_str()),
- Some("Farm stand opens at noon")
- );
- assert_eq!(
- runtime
- .phase1_draft_status(draft_id.clone())
- .await
- .expect("draft status")
- .revision,
- saved.revision
- );
- assert_eq!(
- runtime
- .phase1_draft_heads(10)
- .await
- .expect("draft heads")
- .len(),
- 1
- );
- let queued = runtime
- .phase1_queue_add_intent(draft_id.clone(), saved.revision)
- .await
- .expect("queued draft");
- assert_eq!(queued.state, FfiOutboxState::Queued);
- let recovered = runtime
- .phase1_recover_add_intent(draft_id.clone())
- .await
- .expect("recovered queue");
- assert_eq!(recovered.revision, queued.revision);
- let cancelled = runtime
- .phase1_cancel_add_intent(draft_id.clone(), recovered.revision)
- .await
- .expect("cancelled draft");
- assert_eq!(cancelled.state, FfiOutboxState::Cancelled);
- assert!(
- runtime
- .phase1_save_add_intent(
- FfiAddDraftInput {
- schema_version: MOBILE_FFI_SCHEMA_VERSION,
- command_type: FfiAddCommandType::CreateEvent,
- content: "Market opens Saturday".to_owned(),
- identifier: None,
- title: Some("Saturday market".to_owned()),
- summary: Some("Weekly market".to_owned()),
- location: Some("Town square".to_owned()),
- event_timing: Some(FfiEventTimingKind::AllDay),
- event_start_date: Some("2027-01-02".to_owned()),
- 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(),
- },
- None,
- Some(1),
- )
- .await
- .is_err()
- );
- assert!(
- runtime
- .phase1_advance_draft(draft_id.clone(), cancelled.revision)
- .await
- .is_err()
- );
-
- let retraction_id = "0a".repeat(16);
- let retraction_input = FfiRetractionDraftInput {
- schema_version: MOBILE_FFI_SCHEMA_VERSION,
- command_type: FfiAddCommandType::CreateUpdate,
- target_card_id: "c".repeat(64),
- target_event_id: "a".repeat(64),
- target_kind: 1,
- target_address: None,
- reason: "Replaced with a corrected copy".to_owned(),
- };
- let mut unsupported_retraction = retraction_input.clone();
- unsupported_retraction.schema_version += 1;
- assert_eq!(
- runtime
- .phase1_save_retraction_draft(
- retraction_id.clone(),
- unsupported_retraction,
- 1_800_000_005,
- 1_800_000_005_000,
- )
- .await
- .expect_err("unsupported retraction schema")
- .report()
- .code,
- "unsupported_schema_version"
- );
- let retraction = runtime
- .phase1_save_retraction_draft(
- retraction_id.clone(),
- retraction_input,
- 1_800_000_005,
- 1_800_000_005_000,
- )
- .await
- .expect("saved retraction");
- assert_eq!(retraction.kind, FfiDraftKind::Retraction);
- assert_eq!(retraction.card_id, "c".repeat(64));
- assert!(retraction.form.is_none());
- let queued_retraction = runtime
- .phase1_queue_draft(
- retraction_id.clone(),
- retraction.revision,
- FfiQueuePolicyRecord {
- schema_version: MOBILE_FFI_SCHEMA_VERSION,
- relay_urls: vec!["wss://write.example".to_owned()],
- satisfaction: FfiRelaySatisfaction::AllAccepted,
- delivery_deadline_unix_ms: 1_800_100_000_000,
- cancellation: FfiCancellationPolicy::LocalCooperative,
- },
- 1_800_000_006_000,
- )
- .await
- .expect("queued retraction");
- let cancelled_retraction = runtime
- .phase1_cancel_draft(retraction_id, queued_retraction.revision, 1_800_000_007_000)
- .await
- .expect("cancelled retraction");
- assert_eq!(cancelled_retraction.state, FfiOutboxState::Cancelled);
-
- let upload = FfiBlossomUploadIntent {
- schema_version: MOBILE_FFI_SCHEMA_VERSION + 1,
- draft_id,
- expected_revision: cancelled.revision,
- media: FfiPreparedMediaInput {
- schema_version: MOBILE_FFI_SCHEMA_VERSION,
- opaque_reference: "media:unused".to_owned(),
- file_descriptor: 0,
- sha256: "00".repeat(32),
- media_type: "image/png".to_owned(),
- byte_size: 1,
- width: 1,
- height: 1,
- alt: "unused".to_owned(),
- prepared_at_unix_s: 1_800_000_000,
- },
- };
- let upload_error = runtime
- .phase1_upload_add_media_intent(upload.clone())
- .await
- .expect_err("unsupported upload schema");
- assert_eq!(upload_error.report().code, "unsupported_schema_version");
- assert_eq!(
- runtime
- .phase1_prepare_add_media_background(upload.clone())
- .await
- .expect_err("unsupported background upload schema")
- .report()
- .code,
- "unsupported_schema_version"
- );
- assert_eq!(
- runtime
- .phase1_complete_add_media_background(FfiNativeUploadCompletionInput {
- schema_version: MOBILE_FFI_SCHEMA_VERSION + 1,
- draft_id: upload.draft_id.clone(),
- expected_revision: upload.expected_revision,
- media: upload.media.clone(),
- status_code: 200,
- response_media_type: Some("application/json".to_owned()),
- response_content_encoding: None,
- response_body: Vec::new(),
- })
- .await
- .expect_err("unsupported completion schema")
- .report()
- .code,
- "invalid_native_upload_completion"
- );
- assert_eq!(
- runtime
- .phase1_complete_add_media_background(FfiNativeUploadCompletionInput {
- schema_version: MOBILE_FFI_SCHEMA_VERSION,
- draft_id: upload.draft_id.clone(),
- expected_revision: upload.expected_revision,
- media: upload.media.clone(),
- status_code: 200,
- response_media_type: Some("application/json".to_owned()),
- response_content_encoding: None,
- response_body: vec![0; 16_385],
- })
- .await
- .expect_err("oversized completion body")
- .report()
- .code,
- "invalid_native_upload_completion"
- );
- let mut invalid_id_upload = upload;
- invalid_id_upload.schema_version = MOBILE_FFI_SCHEMA_VERSION;
- invalid_id_upload.draft_id = "not-a-draft-id".to_owned();
- assert_eq!(
- runtime
- .phase1_upload_add_media_intent(invalid_id_upload.clone())
- .await
- .expect_err("invalid draft id")
- .report()
- .code,
- "invalid_draft_id"
- );
- assert_eq!(
- runtime
- .phase1_prepare_add_media_background(invalid_id_upload.clone())
- .await
- .expect_err("invalid background draft id")
- .report()
- .code,
- "invalid_draft_id"
- );
- assert_eq!(
- runtime
- .phase1_complete_add_media_background(FfiNativeUploadCompletionInput {
- schema_version: MOBILE_FFI_SCHEMA_VERSION,
- draft_id: invalid_id_upload.draft_id,
- expected_revision: invalid_id_upload.expected_revision,
- media: invalid_id_upload.media,
- status_code: 200,
- response_media_type: Some("application/json".to_owned()),
- response_content_encoding: None,
- response_body: Vec::new(),
- })
- .await
- .expect_err("invalid completion draft id")
- .report()
- .code,
- "invalid_draft_id"
- );
- assert!(
- runtime
- .phase1_local_network(FfiLocalNetworkRecord {
- schema_version: MOBILE_FFI_SCHEMA_VERSION,
- id: "nearby".to_owned(),
- label: "Near me".to_owned(),
- relay_urls: Vec::new(),
- locality: None,
- followed_authors: Vec::new(),
- generation: 1,
- })
- .is_err()
- );
-
- let initial_settings = runtime.phase1_settings().await.expect("settings");
- let begun = runtime
- .phase1_apply_identity_command(
- initial_settings.revision,
- FfiIdentityCommandRecord {
- schema_version: MOBILE_FFI_SCHEMA_VERSION,
- kind: FfiIdentityCommandKind::BeginImport,
- operation_id: Some("import-ffi-1".to_owned()),
- identity_id: None,
- public_key: None,
- },
- )
- .await
- .expect("begin identity import");
- let completed = runtime
- .phase1_apply_identity_command(
- begun.settings.revision,
- FfiIdentityCommandRecord {
- schema_version: MOBILE_FFI_SCHEMA_VERSION,
- kind: FfiIdentityCommandKind::CompleteImport,
- operation_id: Some("import-ffi-1".to_owned()),
- identity_id: Some("primary".to_owned()),
- public_key: Some(support::PUBLIC_KEY.to_owned()),
- },
- )
- .await
- .expect("complete identity import");
- let unlocked = runtime
- .phase1_apply_identity_command(
- completed.settings.revision,
- FfiIdentityCommandRecord {
- schema_version: MOBILE_FFI_SCHEMA_VERSION,
- kind: FfiIdentityCommandKind::Unlock,
- operation_id: None,
- identity_id: None,
- public_key: None,
- },
- )
- .await
- .expect("record process-local unlock");
- assert_eq!(
- unlocked.settings.identity.lock_state,
- FfiIdentityLockState::Unlocked
- );
- assert_eq!(unlocked.settings.revision, completed.settings.revision);
-
- let source_event_id = "ab".repeat(32);
- let source = radroots_mobile_core::runtime::product_surface::CardSourceIdentity::Event(
- radroots_event::EventId::parse(&source_event_id).expect("source event id"),
- );
- let card_id = radroots_mobile_core::runtime::product_surface::CardId::derive(
- radroots_mobile_core::runtime::product_surface::TodayCardType::Update,
- &source,
- )
- .to_hex();
- let revision = runtime
- .phase1_save_revision_intent(FfiRevisionInputRecord {
- schema_version: MOBILE_FFI_SCHEMA_VERSION,
- card_id,
- source_event_id,
- source_address: None,
- author_public_key: support::PUBLIC_KEY.to_owned(),
- replacement: FfiAddDraftInput {
- schema_version: MOBILE_FFI_SCHEMA_VERSION,
- command_type: FfiAddCommandType::CreateUpdate,
- content: "Corrected farm stand hours".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(),
- },
- })
- .await
- .expect("save lossless revision intent");
- assert_eq!(revision.phase, FfiRevisionPhase::ReplacementPending);
- assert_eq!(revision.operation_id, revision.replacement.draft_id);
- assert!(revision.replacement.is_revision);
- assert_eq!(
- runtime
- .phase1_revision_status(revision.operation_id.clone())
- .await
- .expect("revision status")
- .phase,
- FfiRevisionPhase::ReplacementPending
- );
- assert!(
- runtime
- .phase1_advance_revision(revision.operation_id.clone())
- .await
- .is_err()
- );
- let cancelled_revision = runtime
- .phase1_cancel_revision(revision.operation_id.clone())
- .await
- .expect("cancel revision intent");
- assert_eq!(cancelled_revision.operation_id, revision.operation_id);
- assert_eq!(cancelled_revision.phase, FfiRevisionPhase::Cancelled);
-
- let profile = runtime
- .phase1_save_profile_metadata(FfiProfileMetadataInputRecord {
- schema_version: MOBILE_FFI_SCHEMA_VERSION,
- name: "grower".to_owned(),
- display_name: Some("Local Grower".to_owned()),
- about: Some("Seasonal produce".to_owned()),
- picture: None,
- banner: None,
- nip05: Some("grower@farm.example".to_owned()),
- bot: Some(false),
- })
- .await
- .expect("save profile intent");
- assert_eq!(profile.state, FfiOutboxState::Draft);
- assert_eq!(profile.operation_id.len(), 32);
- assert_eq!(
- runtime
- .phase1_profile_status(profile.operation_id.clone())
- .await
- .expect("profile status")
- .revision,
- profile.revision
- );
- let cancelled_profile = runtime
- .phase1_cancel_profile(profile.operation_id.clone(), profile.revision)
- .await
- .expect("cancel profile intent");
- assert_eq!(cancelled_profile.operation_id, profile.operation_id);
- assert_eq!(cancelled_profile.state, FfiOutboxState::Cancelled);
- assert_eq!(
- runtime
- .phase1_advance_profile(profile.operation_id.clone())
- .await
- .expect("advance cancelled profile")
- .state,
- FfiOutboxState::Cancelled
- );
-
- let cache = runtime
- .phase1_media_cache_status(local_network.clone())
- .await
- .expect("empty media cache status");
- assert_eq!(cache.artifact_count, 0);
- assert_eq!(cache.total_bytes, 0);
- assert!(cache.configuration_fingerprint.is_none());
- assert!(
- runtime
- .phase1_verified_media_artifact(local_network.clone(), "11".repeat(32))
- .await
- .expect("empty verified media lookup")
- .is_none()
- );
- assert!(
- !runtime
- .phase1_invalidate_media_artifact(local_network.clone(), "11".repeat(32))
- .await
- .expect("missing media invalidation")
- );
- assert!(
- runtime
- .phase1_invalidate_media_configuration(local_network.clone(), "22".repeat(32))
- .await
- .expect("empty configuration invalidation")
- .is_empty()
- );
- let media_operation = Arc::new(FfiMediaOperation::new().expect("media operation"));
- let media_error = runtime
- .phase1_retrieve_media(
- local_network.clone(),
- "invalid-reference".to_owned(),
- Arc::clone(&media_operation),
- )
- .await
- .expect_err("invalid media reference");
- assert_eq!(
- media_error.report().code,
- "invalid_media_reference_fingerprint"
- );
- assert_eq!(
- runtime
- .phase1_retrieve_media(local_network, "00".repeat(32), Arc::clone(&media_operation),)
- .await
- .expect_err("single-use media operation")
- .report()
- .code,
- "media_operation_already_used"
- );
-
- runtime.shutdown().await.expect("shutdown");
- assert!(matches!(
- runtime.sdk_storage_status().await,
- Err(RadrootsAppError::Failure { .. })
- ));
- assert!(matches!(
- runtime.sdk_relay_status(),
- Err(RadrootsAppError::Failure { .. })
- ));
- assert!(matches!(
- runtime.configure_public_relays(Vec::new()),
- Err(RadrootsAppError::Failure { .. })
- ));
-}
diff --git a/crates/mobile_ffi/tests/runtime_lifecycle.rs b/crates/mobile_ffi/tests/runtime_lifecycle.rs
@@ -1,44 +0,0 @@
-use std::{sync::Arc, time::Duration};
-
-use radroots_mobile_ffi::RadrootsAppError;
-
-mod support;
-
-#[tokio::test]
-async fn host_release_ordering_retains_close_and_finishes_within_deadline() {
- let (_root, runtime) = support::runtime().await;
- let host = Arc::new(runtime);
- let closing_owner = Arc::clone(&host);
- let close = tokio::spawn(async move { closing_owner.shutdown().await });
- drop(host);
-
- let result = tokio::time::timeout(Duration::from_secs(1), close)
- .await
- .expect("mobile shutdown exceeded its host deadline")
- .expect("shutdown task panicked")
- .expect("shutdown failed");
- assert_eq!(result.state, "closed");
- assert!(!result.already_closed);
-}
-
-#[tokio::test]
-async fn concurrent_host_references_converge_and_repeated_close_is_idempotent() {
- let (_root, runtime) = support::runtime().await;
- let runtime = Arc::new(runtime);
- let first = Arc::clone(&runtime);
- let second = Arc::clone(&runtime);
- let (first, second) = tokio::join!(first.shutdown(), second.shutdown());
-
- for outcome in [&first, &second] {
- assert!(
- outcome.is_ok()
- || matches!(
- outcome,
- Err(RadrootsAppError::Failure { report })
- if report.code == "client_close_in_progress"
- )
- );
- }
- let repeated = runtime.shutdown().await.expect("repeated close");
- assert!(repeated.already_closed);
-}
diff --git a/crates/mobile_ffi/tests/subscription_contract.rs b/crates/mobile_ffi/tests/subscription_contract.rs
@@ -1,67 +0,0 @@
-use std::sync::mpsc::{Receiver, Sender, channel};
-use std::time::Duration;
-
-use radroots_mobile_ffi::{FfiRuntimeChangeKind, FfiRuntimeChangeRecord, RadrootsRuntimeObserver};
-
-mod support;
-
-struct Observer(Sender<FfiRuntimeChangeRecord>);
-
-impl RadrootsRuntimeObserver for Observer {
- fn on_change(&self, change: FfiRuntimeChangeRecord) {
- let _ = self.0.send(change);
- }
-}
-
-fn observer() -> (
- Box<dyn RadrootsRuntimeObserver>,
- Receiver<FfiRuntimeChangeRecord>,
-) {
- let (sender, receiver) = channel();
- (Box::new(Observer(sender)), receiver)
-}
-
-fn receive(receiver: &Receiver<FfiRuntimeChangeRecord>) -> FfiRuntimeChangeRecord {
- receiver
- .recv_timeout(Duration::from_secs(1))
- .expect("bounded observer delivery")
-}
-
-#[tokio::test]
-async fn subscriptions_are_independent_bounded_handles_and_stop_individually() {
- let (_root, runtime) = support::runtime().await;
- let (first_observer, first_receiver) = observer();
- let (second_observer, second_receiver) = observer();
- let first = runtime
- .subscribe_changes(first_observer)
- .expect("first subscription");
- let second = runtime
- .subscribe_changes(second_observer)
- .expect("second subscription");
-
- assert_eq!(receive(&first_receiver).kind, FfiRuntimeChangeKind::Initial);
- assert_eq!(
- receive(&second_receiver).kind,
- FfiRuntimeChangeKind::Initial
- );
- first.unsubscribe();
- assert!(!first.is_active());
- assert!(second.is_active());
-
- runtime
- .configure_public_relays(vec!["wss://write.example".to_owned()])
- .expect("relay configuration");
- assert_eq!(receive(&second_receiver).kind, FfiRuntimeChangeKind::Relay);
- assert!(
- first_receiver
- .recv_timeout(Duration::from_millis(50))
- .is_err()
- );
-
- runtime.shutdown().await.expect("shutdown");
- assert_eq!(
- receive(&second_receiver).kind,
- FfiRuntimeChangeKind::Lifecycle
- );
- assert!(!second.is_active());
-}
diff --git a/crates/mobile_ffi/tests/support/mod.rs b/crates/mobile_ffi/tests/support/mod.rs
@@ -1,24 +0,0 @@
-use radroots_mobile_ffi::{ProtectedDataAvailability, RadrootsRuntime};
-
-pub const PUBLIC_KEY: &str = "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798";
-pub const GENERATION: &str = "0404040404040404040404040404040404040404040404040404040404040404";
-
-pub fn prepare(root: &std::path::Path) {
- std::fs::create_dir_all(root.join("radroots").join("users").join(PUBLIC_KEY))
- .expect("owner directory");
-}
-
-pub async fn runtime() -> (tempfile::TempDir, RadrootsRuntime) {
- let root = tempfile::tempdir().expect("tempdir");
- prepare(root.path());
- let runtime = RadrootsRuntime::new(
- root.path().to_string_lossy().into_owned(),
- PUBLIC_KEY.to_owned(),
- GENERATION.to_owned(),
- 1_800_000_000_000,
- ProtectedDataAvailability::Available,
- )
- .await
- .expect("runtime");
- (root, runtime)
-}
diff --git a/crates/mobile_ffi/tests/uniffi_contract.rs b/crates/mobile_ffi/tests/uniffi_contract.rs
@@ -1,258 +0,0 @@
-use radroots_mobile_ffi::{
- FfiAddCommandType, FfiAddDraftInput, FfiCancellationPolicy, FfiMediaOperation,
- FfiQueuePolicyRecord, FfiRelaySatisfaction, FfiTradeEvidenceCoverage, FfiTradeEvidenceOutcome,
- HostSigningOutcome, HostSigningRequest, HostSigningResult, MOBILE_FFI_SCHEMA_VERSION,
- ProtectedDataAvailability, RadrootsAppError, RadrootsHostSigner, RadrootsRuntime,
- SignerAvailabilityRecord, SignerStatusRecord,
-};
-use secp256k1::{Keypair, Message, Secp256k1, SecretKey};
-use std::sync::{Arc, Mutex};
-
-mod support;
-
-struct TestHostSigner(Arc<Mutex<HostSigningOutcome>>);
-
-#[async_trait::async_trait]
-impl RadrootsHostSigner for TestHostSigner {
- async fn signer_status(&self) -> SignerStatusRecord {
- SignerStatusRecord {
- schema_version: MOBILE_FFI_SCHEMA_VERSION,
- availability: SignerAvailabilityRecord::Ready,
- }
- }
-
- async fn sign(&self, request: HostSigningRequest) -> HostSigningResult {
- let outcome = *self
- .0
- .lock()
- .unwrap_or_else(std::sync::PoisonError::into_inner);
- let signature_hex = (outcome == HostSigningOutcome::Signed).then(|| {
- let mut secret_bytes = [0; 32];
- secret_bytes[31] = 1;
- let secret = SecretKey::from_slice(&secret_bytes).expect("fixture secret key");
- let keypair = Keypair::from_secret_key(&Secp256k1::new(), &secret);
- let digest: [u8; 32] = request
- .event_id_digest
- .clone()
- .try_into()
- .expect("32-byte event ID digest");
- assert_eq!(hex::encode(digest), request.expected_event_id);
- assert_eq!(
- keypair.x_only_public_key().0.to_string(),
- request.public_key
- );
- let message = Message::from_digest(digest);
- let signature = Secp256k1::new().sign_schnorr_no_aux_rand(&message, &keypair);
- Secp256k1::new()
- .verify_schnorr(&signature, &message, &keypair.x_only_public_key().0)
- .expect("fixture host signature verifies");
- signature.to_string()
- });
- let completed_at_unix_ms = std::time::SystemTime::now()
- .duration_since(std::time::UNIX_EPOCH)
- .expect("system clock after epoch")
- .as_millis()
- .try_into()
- .expect("current time fits u64");
- assert!(completed_at_unix_ms < request.deadline_unix_ms);
- HostSigningResult {
- schema_version: MOBILE_FFI_SCHEMA_VERSION,
- outcome,
- operation_id: request.operation_id,
- signer_request_id: request.signer_request_id,
- public_key: request.public_key,
- purpose: request.purpose,
- signature_hex,
- completed_at_unix_ms,
- }
- }
-}
-
-#[test]
-fn swift_module_names_preserve_the_host_contract() {
- let config = include_str!("../uniffi.toml");
- assert_eq!(
- config,
- "[bindings.swift]\nmodule_name = \"RadrootsKitBindings\"\nffi_module_name = \"RadrootsFFI\"\n"
- );
-}
-
-#[test]
-fn final_evidence_vocabularies_are_exact_at_the_mobile_boundary() {
- assert_eq!(
- [
- FfiTradeEvidenceCoverage::Missing,
- FfiTradeEvidenceCoverage::Partial,
- FfiTradeEvidenceCoverage::ScopeSatisfied,
- FfiTradeEvidenceCoverage::Unsupported,
- ]
- .len(),
- 4
- );
- assert_eq!(
- [
- FfiTradeEvidenceOutcome::Valid,
- FfiTradeEvidenceOutcome::Invalid,
- FfiTradeEvidenceOutcome::Indeterminate,
- ]
- .len(),
- 3
- );
-}
-
-#[test]
-fn media_cancellation_handle_owns_one_stable_opaque_operation_identity() {
- let operation = FfiMediaOperation::new().expect("media operation");
- let operation_id = operation.operation_id();
- assert_eq!(operation_id.len(), 32);
- assert!(!operation.is_cancelled());
- operation.cancel();
- assert!(operation.is_cancelled());
- assert_eq!(operation.operation_id(), operation_id);
-}
-
-#[tokio::test]
-async fn protected_data_failure_is_typed_and_opens_no_store() {
- let root = tempfile::tempdir().expect("tempdir");
- support::prepare(root.path());
- let result = RadrootsRuntime::new(
- root.path().to_string_lossy().into_owned(),
- support::PUBLIC_KEY.to_owned(),
- support::GENERATION.to_owned(),
- 1_800_000_000_000,
- ProtectedDataAvailability::Unavailable,
- )
- .await;
- let Err(RadrootsAppError::Failure { report }) = result else {
- panic!("protected data failure must remain typed across UniFFI");
- };
- assert_eq!(report.code, "protected_data_unavailable");
- assert!(report.retryable);
- assert!(
- !root
- .path()
- .join("radroots/users")
- .join(support::PUBLIC_KEY)
- .join("runtime.sqlite")
- .exists()
- );
-}
-
-#[tokio::test]
-async fn final_mobile_abi_uses_async_sdk_dtos_and_versioned_errors() {
- let (_root, runtime) = support::runtime().await;
- let storage = runtime.sdk_storage_status().await.expect("storage status");
- assert_eq!(storage.backend, "sqlite");
-
- runtime.shutdown().await.expect("shutdown");
- let error = runtime
- .sdk_storage_status()
- .await
- .expect_err("closed client must reject operations");
- let RadrootsAppError::Failure { report } = error;
- assert_eq!(report.schema_version, 1);
- assert_eq!(report.code, "client_closed");
- assert_eq!(report.category, "runtime");
- assert!(!report.retryable);
- assert_eq!(report.safe_message, "SDK client is closed");
-}
-
-#[tokio::test]
-async fn host_signer_constructor_exposes_only_an_opaque_configured_boundary() {
- let root = tempfile::tempdir().expect("tempdir");
- support::prepare(root.path());
- let outcome = Arc::new(Mutex::new(HostSigningOutcome::Rejected));
- let runtime = RadrootsRuntime::with_host_signer(
- root.path().to_string_lossy().into_owned(),
- support::PUBLIC_KEY.to_owned(),
- support::GENERATION.to_owned(),
- 1_800_000_000_000,
- ProtectedDataAvailability::Available,
- Box::new(TestHostSigner(Arc::clone(&outcome))),
- )
- .await
- .expect("runtime with host signer");
-
- let identity = runtime.identity_status().expect("identity status");
- assert_eq!(identity.public_key, support::PUBLIC_KEY);
- assert!(identity.host_signer_configured);
-
- for (index, host_outcome) in [
- HostSigningOutcome::Signed,
- HostSigningOutcome::Locked,
- HostSigningOutcome::Cancelled,
- HostSigningOutcome::Rejected,
- HostSigningOutcome::TimedOut,
- HostSigningOutcome::Unavailable,
- HostSigningOutcome::Invalidated,
- HostSigningOutcome::Failed,
- ]
- .into_iter()
- .enumerate()
- {
- *outcome
- .lock()
- .unwrap_or_else(std::sync::PoisonError::into_inner) = host_outcome;
- let draft_id = format!("{:02x}", index + 17).repeat(16);
- let saved = runtime
- .phase1_save_draft(
- draft_id.clone(),
- FfiAddDraftInput {
- schema_version: MOBILE_FFI_SCHEMA_VERSION,
- command_type: FfiAddCommandType::CreateUpdate,
- content: format!("Signer boundary test {index}"),
- 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(),
- },
- 1_800_000_000 + index as u64,
- None,
- 1_800_000_000_000 + index as u64,
- )
- .await
- .expect("saved draft");
- let queued = runtime
- .phase1_queue_draft(
- draft_id.clone(),
- saved.revision,
- FfiQueuePolicyRecord {
- schema_version: MOBILE_FFI_SCHEMA_VERSION,
- relay_urls: vec!["wss://relay.example".to_owned()],
- satisfaction: FfiRelaySatisfaction::AnyAccepted,
- delivery_deadline_unix_ms: u64::MAX,
- cancellation: FfiCancellationPolicy::PreservePublishedRequest,
- },
- 1_800_000_001_000 + index as u64,
- )
- .await
- .expect("queued draft");
- let signing_result = runtime
- .phase1_sign_queued_draft(draft_id, queued.revision)
- .await;
- if host_outcome == HostSigningOutcome::Signed {
- assert_eq!(
- signing_result.expect("valid host signature").state,
- radroots_mobile_ffi::FfiOutboxState::Signed
- );
- } else {
- let signing_error = signing_result.expect_err("host failure remains typed");
- assert_eq!(signing_error.report().category, "authoring");
- assert!(!signing_error.report().safe_message.contains("signature"));
- }
- }
- runtime.shutdown().await.expect("shutdown");
-}
diff --git a/crates/mobile_ffi/uniffi.toml b/crates/mobile_ffi/uniffi.toml
@@ -1,3 +0,0 @@
-[bindings.swift]
-module_name = "RadrootsKitBindings"
-ffi_module_name = "RadrootsFFI"
diff --git a/crates/mobile_wasm/Cargo.toml b/crates/mobile_wasm/Cargo.toml
@@ -1,21 +0,0 @@
-[package]
-name = "radroots_mobile_wasm"
-version = "0.1.0-alpha"
-edition.workspace = true
-authors = ["Radroots Authors"]
-rust-version.workspace = true
-license = "GPL-3.0-or-later"
-description = "WebAssembly bindings for Radroots web apps"
-repository.workspace = true
-homepage.workspace = true
-readme.workspace = true
-publish = false
-include = ["src/**", "Cargo.toml"]
-
-[lib]
-crate-type = ["cdylib", "rlib"]
-
-[dependencies]
-radroots_mobile_core = { workspace = true, default-features = false }
-serde_json = { workspace = true }
-wasm-bindgen = { workspace = true }
diff --git a/crates/mobile_wasm/src/lib.rs b/crates/mobile_wasm/src/lib.rs
@@ -1,39 +0,0 @@
-#![forbid(unsafe_code)]
-
-use wasm_bindgen::prelude::wasm_bindgen;
-
-#[wasm_bindgen]
-pub fn radroots_mobile_build_info_json() -> String {
- serde_json::to_string(&radroots_mobile_core::runtime::info::app_build_info())
- .expect("static build information must serialize")
-}
-
-#[allow(
- clippy::if_same_then_else,
- reason = "coverage probe intentionally exercises both paths with a stable value"
-)]
-pub fn coverage_branch_probe(input: bool) -> &'static str {
- if input {
- "radroots_mobile_wasm"
- } else {
- "radroots_mobile_wasm"
- }
-}
-
-#[cfg(test)]
-mod tests {
- use super::{coverage_branch_probe, radroots_mobile_build_info_json};
-
- #[test]
- fn radroots_mobile_build_info_json_contains_runtime_keys() {
- let json = radroots_mobile_build_info_json();
- assert!(json.contains("\"crate_name\""));
- assert!(json.contains("radroots_mobile_core"));
- }
-
- #[test]
- fn coverage_branch_probe_hits_both_paths() {
- assert_eq!(coverage_branch_probe(true), "radroots_mobile_wasm");
- assert_eq!(coverage_branch_probe(false), "radroots_mobile_wasm");
- }
-}
diff --git a/tools/xtask/src/build_control.rs b/tools/xtask/src/build_control.rs
@@ -95,8 +95,8 @@ impl ConsumerRoot {
.map_err(|error| format!("consumer marker is not UTF-8: {error}"))?
.trim()
.to_owned();
- if !matches!(product.as_str(), "sdk" | "mobile" | "myc" | "rhi") {
- return Err("consumer marker must contain sdk, mobile, myc, or rhi".to_owned());
+ if !matches!(product.as_str(), "sdk" | "myc" | "rhi") {
+ return Err("consumer marker must contain sdk, myc, or rhi".to_owned());
}
let source_lock_path = canonical.join(SOURCE_LOCK_NAME);
let source_lock = parse_source_lock(&source_lock_path)?;
@@ -538,7 +538,6 @@ pub fn artifact(
}
let external_names = match product {
"sdk" => vec!["radroots", "radroots_sdk"],
- "mobile" => vec!["RadrootsFFI", "RadrootsKitBindings"],
_ => return Err("unsupported artifact product".to_owned()),
};
let manifest = ArtifactManifest {
@@ -607,10 +606,6 @@ fn validate_artifact_route(product: &str, target: &str, language: &str) -> Resul
| ("ffi", "swift")
| ("ffi", "kotlin")
),
- "mobile" => matches!(
- (target, language),
- ("ios", "swift") | ("android", "kotlin") | ("wasm", "javascript")
- ),
_ => false,
};
if valid {
@@ -1163,6 +1158,37 @@ mod tests {
}
#[test]
+ fn retired_application_consumers_and_artifact_routes_fail_closed() {
+ for product in ["mobile", "tera"] {
+ let fixture = Fixture::new(product);
+ assert!(ConsumerRoot::open(&fixture.consumer).is_err());
+ for (target, language) in [
+ ("ios", "swift"),
+ ("android", "kotlin"),
+ ("wasm", "javascript"),
+ ] {
+ assert!(validate_artifact_route(product, target, language).is_err());
+ assert!(
+ artifact(
+ product,
+ target,
+ language,
+ Mode::Write,
+ &fixture.consumer,
+ &fixture.source,
+ Path::new("generated/retired.json"),
+ 1,
+ "fixture",
+ &[],
+ )
+ .is_err()
+ );
+ }
+ assert!(!fixture.consumer.join("generated").exists());
+ }
+ }
+
+ #[test]
fn source_lock_supports_a_contained_nested_lockfile() {
let mut fixture = Fixture::new("sdk");
let core = fixture.consumer.join("core");
@@ -1189,7 +1215,7 @@ mod tests {
#[test]
fn consumer_manifest_discovery_skips_swiftpm_build_output_only() {
- let fixture = Fixture::new("mobile");
+ let fixture = Fixture::new("sdk");
let swiftpm_checkout = fixture.consumer.join(".build/checkouts/dependency");
fs::create_dir_all(&swiftpm_checkout).expect("SwiftPM checkout directory");
fs::write(swiftpm_checkout.join("Cargo.toml"), "not valid TOML")
diff --git a/tools/xtask/src/catalog.rs b/tools/xtask/src/catalog.rs
@@ -23,6 +23,39 @@ const CATALOG_SCHEMA: &str = "radroots.workspace.catalog.v2";
const RELEASE_ID: &str = "radroots.crates.release.v2";
const CONSOLIDATION_ID: &str = "radroots.rust.consolidation.v1";
const VERSION: &str = "0.1.0-alpha";
+const APPLICATION_RETIREMENT: &str =
+ "contracts/architecture/decisions/tera_application_ownership.v1.json";
+const RETIRED_APPLICATION_PACKAGES: [&str; 4] = [
+ "radroots_mobile_bindgen",
+ "radroots_mobile_core",
+ "radroots_mobile_ffi",
+ "radroots_mobile_wasm",
+];
+
+#[derive(Debug, Deserialize)]
+#[serde(deny_unknown_fields)]
+struct ApplicationRetirement {
+ schema: String,
+ source_repository: String,
+ source_catalog_revision: String,
+ source_catalog_sha256: String,
+ transfer_donor_commit: String,
+ transfer_donor_tree: String,
+ target_repository: String,
+ target_revision: String,
+ transfer_evidence_sha256: String,
+ package: Vec<RetiredApplication>,
+}
+
+#[derive(Debug, Deserialize)]
+#[serde(deny_unknown_fields)]
+struct RetiredApplication {
+ former_catalog_entry: CatalogPackage,
+ target_name: String,
+ target_path: String,
+ projected_commit: String,
+ projected_tree: String,
+}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
@@ -145,6 +178,7 @@ struct CargoPackage {
#[derive(Debug, Deserialize)]
struct CargoDependency {
name: String,
+ source: Option<String>,
req: String,
path: Option<String>,
kind: Option<String>,
@@ -184,6 +218,7 @@ fn active_packages_matching(
) -> Result<Vec<String>, String> {
let catalog = parse_file::<Catalog>(workspace_root, CATALOG_RELATIVE)?;
validate_catalog(&catalog)?;
+ validate_application_retirement(&catalog, workspace_root)?;
let mut packages = catalog
.package
.iter()
@@ -232,6 +267,7 @@ fn load_and_validate(
let consolidation = parse_file::<ConsolidationV1>(workspace_root, CONSOLIDATION_RELATIVE)?;
let coverage = parse_file::<CoveragePolicy>(workspace_root, COVERAGE_RELATIVE)?;
validate_catalog(&catalog)?;
+ validate_application_retirement(&catalog, workspace_root)?;
validate_coverage_authority(&catalog, &coverage)?;
validate_release(&release, &catalog, workspace_root)?;
validate_consolidation(&consolidation)?;
@@ -402,7 +438,6 @@ fn validate_catalog(catalog: &Catalog) -> Result<(), String> {
let required_groups = BTreeSet::from([
"boundaries",
"coverage_required",
- "mobile",
"portable",
"preview",
"public_native",
@@ -756,6 +791,13 @@ fn validate_workspace_manifest(catalog: &Catalog, workspace_root: &Path) -> Resu
.and_then(|workspace| workspace.get("dependencies"))
.and_then(toml::Value::as_table)
.ok_or_else(|| "Cargo.toml lacks [workspace.dependencies]".to_owned())?;
+ for (name, value) in dependencies {
+ let name = value
+ .get("package")
+ .and_then(toml::Value::as_str)
+ .unwrap_or(name);
+ validate_foundation_dependency(name, value.get("git").and_then(toml::Value::as_str))?;
+ }
for package in catalog
.package
.iter()
@@ -781,6 +823,86 @@ fn validate_workspace_manifest(catalog: &Catalog, workspace_root: &Path) -> Resu
Ok(())
}
+fn validate_foundation_dependency(name: &str, source: Option<&str>) -> Result<(), String> {
+ if expected_retired_packages().contains(name)
+ || name == "tera"
+ || name.starts_with("tera_")
+ || source.is_some_and(|source| source.contains("radrootslabs/tera"))
+ {
+ return Err(format!(
+ "shared foundation cannot depend on retired or application package {name}"
+ ));
+ }
+ Ok(())
+}
+
+fn validate_application_retirement(catalog: &Catalog, root: &Path) -> Result<(), String> {
+ let bytes = read_regular_file(root, APPLICATION_RETIREMENT)?;
+ let retirement: ApplicationRetirement = serde_json::from_slice(&bytes)
+ .map_err(|error| format!("parse application ownership decision: {error}"))?;
+ if retirement.schema != "radroots.tera.application-ownership.v1"
+ || retirement.source_repository != "https://github.com/radrootslabs/lib"
+ || retirement.target_repository != "https://github.com/radrootslabs/tera"
+ || retirement.package.len() != RETIRED_APPLICATION_PACKAGES.len()
+ {
+ return Err("application ownership decision identity drifted".to_owned());
+ }
+ validate_oid(
+ &retirement.source_catalog_revision,
+ "former catalog revision",
+ )?;
+ validate_oid(
+ &retirement.transfer_donor_commit,
+ "application donor commit",
+ )?;
+ validate_oid(&retirement.transfer_donor_tree, "application donor tree")?;
+ validate_oid(&retirement.target_revision, "application target revision")?;
+ validate_sha256(&retirement.source_catalog_sha256, "former catalog digest")?;
+ validate_sha256(
+ &retirement.transfer_evidence_sha256,
+ "transfer evidence digest",
+ )?;
+ let mut names = BTreeSet::new();
+ for transferred in &retirement.package {
+ let former = &transferred.former_catalog_entry;
+ if !RETIRED_APPLICATION_PACKAGES.contains(&former.name.as_str())
+ || !names.insert(former.name.as_str())
+ || !catalog.retired_packages.contains(&former.name)
+ || catalog
+ .package
+ .iter()
+ .any(|package| package.name == former.name)
+ || former.state != "active"
+ || former.publish
+ || former.license != "GPL-3.0-or-later"
+ || former.path != format!("crates/{}", former.name.trim_start_matches("radroots_"))
+ || transferred.target_name != former.name.replacen("radroots_mobile_", "tera_", 1)
+ || transferred.target_path != format!("core/crates/{}", transferred.target_name)
+ {
+ return Err(
+ "application package retirement or retained attribution drifted".to_owned(),
+ );
+ }
+ validate_package_provenance(former)?;
+ validate_oid(
+ &transferred.projected_commit,
+ "application projected commit",
+ )?;
+ validate_oid(&transferred.projected_tree, "application projected tree")?;
+ match fs::symlink_metadata(root.join(&former.path)) {
+ Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
+ Err(error) => return Err(format!("inspect retired application path: {error}")),
+ Ok(_) => {
+ return Err(format!(
+ "retired application path {} remains present",
+ former.path
+ ));
+ }
+ }
+ }
+ Ok(())
+}
+
fn validate_metadata(
catalog: &Catalog,
metadata: &CargoMetadata,
@@ -848,6 +970,7 @@ fn validate_metadata(
return Err(format!("catalog and Cargo metadata drifted for {name}"));
}
for dependency in &cargo.dependencies {
+ validate_foundation_dependency(&dependency.name, dependency.source.as_deref())?;
let Some(target) = catalog_by_name.get(dependency.name.as_str()) else {
continue;
};
@@ -1257,6 +1380,10 @@ fn expected_retired_packages() -> BTreeSet<&'static str> {
"radroots_app_core",
"radroots_app_ffi",
"radroots_app_wasm",
+ "radroots_mobile_bindgen",
+ "radroots_mobile_core",
+ "radroots_mobile_ffi",
+ "radroots_mobile_wasm",
"radroots_sdk_xtask",
])
}
@@ -1544,6 +1671,82 @@ mod tests {
}
#[test]
+ fn retired_application_packages_cannot_return_as_dependencies_or_sources() {
+ for name in
+ RETIRED_APPLICATION_PACKAGES
+ .into_iter()
+ .chain(["tera", "tera_core", "tera_ffi"])
+ {
+ assert!(validate_foundation_dependency(name, None).is_err());
+ }
+ for source in [
+ "git+https://github.com/radrootslabs/tera?rev=immutable",
+ "https://github.com/radrootslabs/tera.git",
+ "ssh://git@github.com/radrootslabs/tera.git",
+ ] {
+ assert!(validate_foundation_dependency("renamed_application", Some(source)).is_err());
+ }
+ validate_foundation_dependency("radroots_sdk", None).expect("shared SDK remains owned");
+ validate_foundation_dependency("radroots_storage_sqlite", None)
+ .expect("shared storage remains owned");
+ let catalog = checked_in_catalog();
+ validate_application_retirement(&catalog, &crate::workspace_root())
+ .expect("retirement is coherent");
+ let fixture = tempfile::TempDir::new().expect("retirement fixture");
+ let decision = fixture.path().join(APPLICATION_RETIREMENT);
+ fs::create_dir_all(decision.parent().expect("decision parent"))
+ .expect("decision directory");
+ fs::copy(
+ crate::workspace_root().join(APPLICATION_RETIREMENT),
+ &decision,
+ )
+ .expect("decision");
+ validate_application_retirement(&catalog, fixture.path()).expect("no application source");
+ fs::create_dir_all(fixture.path().join("crates/mobile_core")).expect("reintroduced source");
+ assert!(validate_application_retirement(&catalog, fixture.path()).is_err());
+ }
+
+ #[test]
+ fn retirement_rejects_missing_duplicate_or_modified_package_evidence() {
+ let fixture = tempfile::TempDir::new().expect("retirement fixture");
+ let path = fixture.path().join(APPLICATION_RETIREMENT);
+ fs::create_dir_all(path.parent().expect("parent")).expect("decision directory");
+ let original: serde_json::Value = serde_json::from_slice(
+ &fs::read(crate::workspace_root().join(APPLICATION_RETIREMENT)).expect("decision"),
+ )
+ .expect("decision JSON");
+ let catalog = checked_in_catalog();
+ for case in 0..5 {
+ let mut changed = original.clone();
+ match case {
+ 0 => {
+ changed["package"].as_array_mut().expect("packages").pop();
+ }
+ 1 => {
+ changed["package"][1] = changed["package"][0].clone();
+ }
+ 2 => {
+ changed["package"][0]["former_catalog_entry"]["license"] = "MIT".into();
+ }
+ 3 => {
+ changed["package"][0]["target_path"] = "../outside".into();
+ }
+ _ => {
+ changed["unknown"] = true.into();
+ }
+ }
+ fs::write(&path, serde_json::to_vec(&changed).expect("encode"))
+ .expect("altered decision");
+ assert!(validate_application_retirement(&catalog, fixture.path()).is_err());
+ }
+ let mut reactivated = checked_in_catalog();
+ reactivated
+ .retired_packages
+ .retain(|name| name != "radroots_mobile_core");
+ assert!(validate_catalog(&reactivated).is_err());
+ }
+
+ #[test]
fn catalog_rejects_version_visibility_license_and_retirement_drift() {
let mut catalog = checked_in_catalog();
catalog
@@ -1567,8 +1770,8 @@ mod tests {
catalog
.package
.iter_mut()
- .find(|package| package.name == "radroots_mobile_core")
- .expect("mobile")
+ .find(|package| package.name == "radroots_sdk_ffi")
+ .expect("sdk ffi")
.version = "0.1.0-alpha.1".to_owned();
assert!(validate_catalog(&catalog).is_err());
diff --git a/tools/xtask/src/main.rs b/tools/xtask/src/main.rs
@@ -339,14 +339,12 @@ enum ServiceReleaseArtifactMode {
#[derive(Clone, Copy, Debug, ValueEnum)]
enum ArtifactProduct {
Sdk,
- Mobile,
}
impl ArtifactProduct {
fn as_str(self) -> &'static str {
match self {
Self::Sdk => "sdk",
- Self::Mobile => "mobile",
}
}
}
@@ -356,8 +354,6 @@ enum ArtifactTarget {
Typescript,
Wasm,
Ffi,
- Ios,
- Android,
Linux,
Macos,
Windows,
@@ -369,8 +365,6 @@ impl ArtifactTarget {
Self::Typescript => "typescript",
Self::Wasm => "wasm",
Self::Ffi => "ffi",
- Self::Ios => "ios",
- Self::Android => "android",
Self::Linux => "linux",
Self::Macos => "macos",
Self::Windows => "windows",
@@ -489,7 +483,7 @@ fn usage() {
" cargo xtask source archive-create --source-root <absolute-directory> --revision <full-sha> --output <absolute-bundle>"
);
eprintln!(
- " cargo xtask artifact --product <sdk|mobile> --target <target> --language <language> --mode <check|write> --consumer-root <absolute-directory> --source-root <absolute-directory> --output <relative-path> --source-date-epoch <seconds> --builder-id <id>"
+ " cargo xtask artifact --product sdk --target <target> --language <language> --mode <check|write> --consumer-root <absolute-directory> --source-root <absolute-directory> --output <relative-path> --source-date-epoch <seconds> --builder-id <id>"
);
}
@@ -581,10 +575,12 @@ fn release_preflight_at(root: &Path) -> Result<(), String> {
LaneId::ReleaseContracts => advisory_snapshot::validate_decision(root)
.and_then(|_| contract::validate_release_preflight(root)),
};
- if result.is_ok() {
- LaneState::Pass
- } else {
- LaneState::Failed
+ match result {
+ Ok(()) => LaneState::Pass,
+ Err(error) => {
+ eprintln!("release preflight {} failed: {error}", lane.as_str());
+ LaneState::Failed
+ }
}
})
.map(|_| ())
@@ -1107,34 +1103,20 @@ mod tests {
#[test]
fn artifact_cli_values_preserve_every_governed_identifier() {
- assert_eq!(
- [
- ArtifactProduct::Sdk.as_str(),
- ArtifactProduct::Mobile.as_str(),
- ],
- ["sdk", "mobile"]
- );
+ assert!(ArtifactProduct::from_str("mobile", false).is_err());
+ assert!(ArtifactTarget::from_str("ios", false).is_err());
+ assert!(ArtifactTarget::from_str("android", false).is_err());
+ assert_eq!([ArtifactProduct::Sdk.as_str()], ["sdk"]);
assert_eq!(
[
ArtifactTarget::Typescript.as_str(),
ArtifactTarget::Wasm.as_str(),
ArtifactTarget::Ffi.as_str(),
- ArtifactTarget::Ios.as_str(),
- ArtifactTarget::Android.as_str(),
ArtifactTarget::Linux.as_str(),
ArtifactTarget::Macos.as_str(),
ArtifactTarget::Windows.as_str(),
],
- [
- "typescript",
- "wasm",
- "ffi",
- "ios",
- "android",
- "linux",
- "macos",
- "windows",
- ]
+ ["typescript", "wasm", "ffi", "linux", "macos", "windows",]
);
assert_eq!(
[
diff --git a/tools/xtask/src/sdk_generation/bindings.rs b/tools/xtask/src/sdk_generation/bindings.rs
@@ -390,11 +390,11 @@ mod tests {
let expected = BTreeMap::from([
(
"generated/swift/radroots_sdk.swift",
- "0f81256c17243e485d9bca8f2dd4771c0ed2c2c1dc4fb03eb352389aab8599b0",
+ "38ff1a652ff49b45c48601d2b1c0064103c1e815df9e80604f3369af2e53f4ac",
),
(
"generated/swift/radroots_sdkFFI.h",
- "511d3a22f969ef21d1a6d7357241756c2fda5d96adbb8bb17f2da5e8c65bcc54",
+ "2efe02ecda4a68a495bacc61acf8ff41dc84bc01d9132a00b38353a57c51466a",
),
(
"generated/swift/radroots_sdkFFI.modulemap",
@@ -402,12 +402,16 @@ mod tests {
),
(
"generated/kotlin/uniffi/radroots_sdk/radroots_sdk.kt",
- "63a91b7c7790ee786d1c94cfad30cbe6e3ac5c737108ec61c74696fc068ef7da",
+ "cc57260fade24a3b6d8366f06f17fb2d3132a0293cb61dd2a12d30d95f4a4e66",
),
]);
for (relative, expected_sha256) in expected {
let bytes = fs::read(consumer.path().join(relative)).expect("generated binding");
- assert_eq!(format!("{:x}", Sha256::digest(bytes)), expected_sha256);
+ assert_eq!(
+ format!("{:x}", Sha256::digest(bytes)),
+ expected_sha256,
+ "generated SDK binding {relative}"
+ );
}
}
}