commit c0698f3d2671050896f25289f00246adefe0dbfc
parent a72c713ead7eea7463d98e952e0c630bb7cfc540
Author: triesap <tyson@radroots.org>
Date: Thu, 13 Aug 2026 15:36:08 +0000
workspace: narrow the supported package set
Remove inactive application package sources and workspace edges.
Reserve removed package identities and refresh catalog projections.
Align coverage, publication, supply-chain, and artifact contracts with the supported workspace.
Diffstat:
88 files changed, 113 insertions(+), 22563 deletions(-)
diff --git a/AGENTS.md b/AGENTS.md
@@ -33,8 +33,8 @@ 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`, `studio`, `myc`,
- and `rhi`. Only the first three are generated-artifact product identities;
+- Source-lock consumer identities include `sdk`, `mobile`, `myc`, and `rhi`.
+ Only the first two are generated-artifact product identities;
accepting a service consumer marker must not expose an artifact route.
- Current source and tests are implementation evidence. They do not silently
override `radroots.crates.release.v1`.
diff --git a/Cargo.lock b/Cargo.lock
@@ -153,17 +153,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
[[package]]
-name = "apple-native-keyring-store"
-version = "1.0.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "797f94b6a53d7d10b56dc18290e0d40a2158352f108bb4ff32350825081a9f29"
-dependencies = [
- "keyring-core",
- "log",
- "security-framework 3.7.0",
-]
-
-[[package]]
name = "arrayvec"
version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -175,20 +164,7 @@ version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d4744ed2eef2645831b441d8f5459689ade2ab27c854488fbab1fbe94fce1a7"
dependencies = [
- "askama_derive 0.13.1",
- "itoa",
- "percent-encoding",
- "serde",
- "serde_json",
-]
-
-[[package]]
-name = "askama"
-version = "0.16.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f1bf825125edd887a019d0a3a837dcc5499a68b0d034cc3eb594070c3e18addc"
-dependencies = [
- "askama_macros",
+ "askama_derive",
"itoa",
"percent-encoding",
"serde",
@@ -201,26 +177,8 @@ version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d661e0f57be36a5c14c48f78d09011e67e0cb618f269cca9f2fd8d15b68c46ac"
dependencies = [
- "askama_parser 0.13.0",
- "basic-toml",
- "memchr",
- "proc-macro2",
- "quote",
- "rustc-hash 2.1.3",
- "serde",
- "serde_derive",
- "syn 2.0.117",
-]
-
-[[package]]
-name = "askama_derive"
-version = "0.16.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e1c7065972a130eafa84215f21352ae15b4a7393da48c1f5e103904490736738"
-dependencies = [
- "askama_parser 0.16.0",
+ "askama_parser",
"basic-toml",
- "glob",
"memchr",
"proc-macro2",
"quote",
@@ -231,15 +189,6 @@ dependencies = [
]
[[package]]
-name = "askama_macros"
-version = "0.16.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0e23b1d2c4bd39a41971f6124cef4cc6fd0540913ecb90919b69ab3bbe44ae1a"
-dependencies = [
- "askama_derive 0.16.0",
-]
-
-[[package]]
name = "askama_parser"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -248,20 +197,7 @@ dependencies = [
"memchr",
"serde",
"serde_derive",
- "winnow 0.7.15",
-]
-
-[[package]]
-name = "askama_parser"
-version = "0.16.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7db09fde9143e7ac4513358fb32ee32847125b63b18ea715afd487956da715da"
-dependencies = [
- "rustc-hash 2.1.3",
- "serde",
- "serde_derive",
- "unicode-ident",
- "winnow 1.0.4",
+ "winnow",
]
[[package]]
@@ -304,30 +240,6 @@ dependencies = [
]
[[package]]
-name = "async-broadcast"
-version = "0.7.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532"
-dependencies = [
- "event-listener",
- "event-listener-strategy",
- "futures-core",
- "pin-project-lite",
-]
-
-[[package]]
-name = "async-channel"
-version = "2.5.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2"
-dependencies = [
- "concurrent-queue",
- "event-listener-strategy",
- "futures-core",
- "pin-project-lite",
-]
-
-[[package]]
name = "async-compat"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -341,102 +253,6 @@ dependencies = [
]
[[package]]
-name = "async-executor"
-version = "1.14.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a"
-dependencies = [
- "async-task",
- "concurrent-queue",
- "fastrand",
- "futures-lite",
- "pin-project-lite",
- "slab",
-]
-
-[[package]]
-name = "async-io"
-version = "2.6.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc"
-dependencies = [
- "autocfg",
- "cfg-if",
- "concurrent-queue",
- "futures-io",
- "futures-lite",
- "parking",
- "polling",
- "rustix 1.1.4",
- "slab",
- "windows-sys 0.61.2",
-]
-
-[[package]]
-name = "async-lock"
-version = "3.4.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311"
-dependencies = [
- "event-listener",
- "event-listener-strategy",
- "pin-project-lite",
-]
-
-[[package]]
-name = "async-process"
-version = "2.5.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75"
-dependencies = [
- "async-channel",
- "async-io",
- "async-lock",
- "async-signal",
- "async-task",
- "blocking",
- "cfg-if",
- "event-listener",
- "futures-lite",
- "rustix 1.1.4",
-]
-
-[[package]]
-name = "async-recursion"
-version = "1.1.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11"
-dependencies = [
- "proc-macro2",
- "quote",
- "syn 2.0.117",
-]
-
-[[package]]
-name = "async-signal"
-version = "0.2.14"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485"
-dependencies = [
- "async-io",
- "async-lock",
- "atomic-waker",
- "cfg-if",
- "futures-core",
- "futures-io",
- "rustix 1.1.4",
- "signal-hook-registry",
- "slab",
- "windows-sys 0.61.2",
-]
-
-[[package]]
-name = "async-task"
-version = "4.7.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de"
-
-[[package]]
name = "async-trait"
version = "0.1.91"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -665,19 +481,6 @@ dependencies = [
]
[[package]]
-name = "blocking"
-version = "1.6.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21"
-dependencies = [
- "async-channel",
- "async-task",
- "futures-io",
- "futures-lite",
- "piper",
-]
-
-[[package]]
name = "borrow-or-share"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -726,37 +529,13 @@ dependencies = [
]
[[package]]
-name = "cargo-platform"
-version = "0.3.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "dd0061da739915fae12ea00e16397555ed4371a6bb285431aab930f61b0aa4ba"
-dependencies = [
- "serde",
- "serde_core",
-]
-
-[[package]]
name = "cargo_metadata"
version = "0.19.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dd5eb614ed4c27c5d706420e4320fbe3216ab31fa1c33cd8246ac36dae4479ba"
dependencies = [
"camino",
- "cargo-platform 0.1.9",
- "semver",
- "serde",
- "serde_json",
- "thiserror 2.0.18",
-]
-
-[[package]]
-name = "cargo_metadata"
-version = "0.23.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ef987d17b0a113becdd19d3d0022d04d7ef41f9efe4f3fb63ac44ba61df3ade9"
-dependencies = [
- "camino",
- "cargo-platform 0.3.3",
+ "cargo-platform",
"semver",
"serde",
"serde_json",
@@ -1254,27 +1033,6 @@ dependencies = [
]
[[package]]
-name = "directories"
-version = "6.0.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "16f5094c54661b38d03bd7e50df373292118db60b585c08a411c6d840017fe7d"
-dependencies = [
- "dirs-sys",
-]
-
-[[package]]
-name = "dirs-sys"
-version = "0.5.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab"
-dependencies = [
- "libc",
- "option-ext",
- "redox_users",
- "windows-sys 0.61.2",
-]
-
-[[package]]
name = "displaydoc"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1440,33 +1198,6 @@ dependencies = [
]
[[package]]
-name = "endi"
-version = "1.1.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099"
-
-[[package]]
-name = "enumflags2"
-version = "0.7.12"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef"
-dependencies = [
- "enumflags2_derive",
- "serde",
-]
-
-[[package]]
-name = "enumflags2_derive"
-version = "0.7.12"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827"
-dependencies = [
- "proc-macro2",
- "quote",
- "syn 2.0.117",
-]
-
-[[package]]
name = "equivalent"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1494,16 +1225,6 @@ dependencies = [
]
[[package]]
-name = "event-listener-strategy"
-version = "0.5.4"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93"
-dependencies = [
- "event-listener",
- "pin-project-lite",
-]
-
-[[package]]
name = "fallible-iterator"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1674,15 +1395,6 @@ dependencies = [
]
[[package]]
-name = "fs-err"
-version = "3.3.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b91aa448ca50d7e79433bdf3ee8d99215430d2ec02ade5aefab2a073a1822e8a"
-dependencies = [
- "autocfg",
-]
-
-[[package]]
name = "fs2"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1752,19 +1464,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718"
[[package]]
-name = "futures-lite"
-version = "2.6.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad"
-dependencies = [
- "fastrand",
- "futures-core",
- "futures-io",
- "parking",
- "pin-project-lite",
-]
-
-[[package]]
name = "futures-macro"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1982,12 +1681,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
-name = "hermit-abi"
-version = "0.5.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c"
-
-[[package]]
name = "hex"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2468,27 +2161,6 @@ dependencies = [
]
[[package]]
-name = "keyring"
-version = "4.1.6"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "72585bb6cc9bc370d1d545b7e23fcce71dfd4461c5e15275e3cf51bdfd9a980a"
-dependencies = [
- "apple-native-keyring-store",
- "keyring-core",
- "windows-native-keyring-store",
- "zbus-secret-service-keyring-store",
-]
-
-[[package]]
-name = "keyring-core"
-version = "1.0.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "fb1e621458ca9c51aa110bd0339d4751a056b9576bf1253aee1aa560dda0fc9d"
-dependencies = [
- "log",
-]
-
-[[package]]
name = "lazy_static"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2670,15 +2342,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
[[package]]
-name = "memoffset"
-version = "0.9.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a"
-dependencies = [
- "autocfg",
-]
-
-[[package]]
name = "micromap"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -3113,22 +2776,6 @@ dependencies = [
]
[[package]]
-name = "option-ext"
-version = "0.2.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
-
-[[package]]
-name = "ordered-stream"
-version = "0.2.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50"
-dependencies = [
- "futures-core",
- "pin-project-lite",
-]
-
-[[package]]
name = "outref"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -3207,17 +2854,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
[[package]]
-name = "piper"
-version = "0.2.5"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1"
-dependencies = [
- "atomic-waker",
- "fastrand",
- "futures-io",
-]
-
-[[package]]
name = "pkcs8"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -3250,20 +2886,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6"
[[package]]
-name = "polling"
-version = "3.11.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218"
-dependencies = [
- "cfg-if",
- "concurrent-queue",
- "hermit-abi",
- "pin-project-lite",
- "rustix 1.1.4",
- "windows-sys 0.61.2",
-]
-
-[[package]]
name = "poly1305"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -3321,15 +2943,6 @@ dependencies = [
]
[[package]]
-name = "proc-macro-crate"
-version = "3.5.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f"
-dependencies = [
- "toml_edit 0.25.13+spec-1.1.0",
-]
-
-[[package]]
name = "proc-macro2"
version = "1.0.106"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -3578,7 +3191,7 @@ dependencies = [
name = "radroots_mobile_bindgen"
version = "0.1.0-alpha"
dependencies = [
- "uniffi 0.29.5",
+ "uniffi",
]
[[package]]
@@ -3633,7 +3246,7 @@ dependencies = [
"tracing",
"tracing-appender",
"tracing-subscriber",
- "uniffi 0.29.5",
+ "uniffi",
]
[[package]]
@@ -3854,7 +3467,7 @@ dependencies = [
"radroots_sdk",
"thiserror 1.0.69",
"tokio",
- "uniffi 0.29.5",
+ "uniffi",
]
[[package]]
@@ -3875,7 +3488,7 @@ dependencies = [
"chacha20poly1305",
"futures-executor",
"hex",
- "keyring 3.6.3",
+ "keyring",
"serde",
"serde_json",
"sha2",
@@ -4055,110 +3668,6 @@ dependencies = [
]
[[package]]
-name = "radroots_studio_application"
-version = "0.1.0-alpha"
-dependencies = [
- "radroots_studio_domain",
- "secrecy",
- "tokio",
-]
-
-[[package]]
-name = "radroots_studio_domain"
-version = "0.1.0-alpha"
-dependencies = [
- "bech32",
- "radroots_identity",
- "secrecy",
- "url",
- "zeroize",
-]
-
-[[package]]
-name = "radroots_studio_ffi"
-version = "0.1.0-alpha"
-dependencies = [
- "directories",
- "nostr 0.44.1",
- "nostr-relay-builder",
- "nostr-sdk 0.44.0",
- "quote",
- "radroots_studio_application",
- "radroots_studio_domain",
- "radroots_studio_nostr",
- "radroots_studio_runtime",
- "radroots_studio_storage",
- "sha2",
- "syn 2.0.117",
- "tempfile",
- "tokio",
- "uniffi 0.32.0",
-]
-
-[[package]]
-name = "radroots_studio_nostr"
-version = "0.1.0-alpha"
-dependencies = [
- "nostr 0.44.1",
- "nostr-relay-builder",
- "nostr-sdk 0.44.0",
- "radroots_identity",
- "radroots_studio_application",
- "radroots_studio_domain",
- "radroots_transport",
- "radroots_transport_nostr",
- "tokio",
-]
-
-[[package]]
-name = "radroots_studio_preferences"
-version = "0.1.0-alpha"
-dependencies = [
- "url",
-]
-
-[[package]]
-name = "radroots_studio_runtime"
-version = "0.1.0-alpha"
-dependencies = [
- "nostr 0.44.1",
- "nostr-relay-builder",
- "nostr-sdk 0.44.0",
- "radroots_studio_application",
- "radroots_studio_domain",
- "radroots_studio_nostr",
- "radroots_studio_storage",
- "tempfile",
- "tokio",
- "uuid",
-]
-
-[[package]]
-name = "radroots_studio_storage"
-version = "0.1.0-alpha"
-dependencies = [
- "fs2",
- "getrandom 0.2.17",
- "hmac",
- "keyring 4.1.6",
- "radroots_studio_application",
- "radroots_studio_domain",
- "refinery",
- "rusqlite",
- "rustix 1.1.4",
- "sha2",
- "tempfile",
- "zeroize",
-]
-
-[[package]]
-name = "radroots_studio_uniffi_bindgen"
-version = "0.1.0-alpha"
-dependencies = [
- "uniffi 0.32.0",
-]
-
-[[package]]
name = "radroots_sync"
version = "0.1.0-alpha"
dependencies = [
@@ -4376,17 +3885,6 @@ dependencies = [
]
[[package]]
-name = "redox_users"
-version = "0.5.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac"
-dependencies = [
- "getrandom 0.2.17",
- "libredox",
- "thiserror 2.0.18",
-]
-
-[[package]]
name = "ref-cast"
version = "1.0.26"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -4424,47 +3922,6 @@ dependencies = [
]
[[package]]
-name = "refinery"
-version = "0.9.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6e2a344cdb48871e27addeafbbaffab8828cc12cec2b9041119e9bea0c0f551a"
-dependencies = [
- "refinery-core",
- "refinery-macros",
-]
-
-[[package]]
-name = "refinery-core"
-version = "0.9.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "24eeafd893124f29183dd6afa9137a27e7bef59250223b8b660005279c60aea4"
-dependencies = [
- "async-trait",
- "cfg-if",
- "log",
- "regex",
- "rusqlite",
- "siphasher 1.0.3",
- "thiserror 2.0.18",
- "time",
- "url",
- "walkdir",
-]
-
-[[package]]
-name = "refinery-macros"
-version = "0.9.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a90cea6d11a9a4e8a85a884b6305461004101b28ca65dd35ec028e009a898e16"
-dependencies = [
- "proc-macro2",
- "quote",
- "refinery-core",
- "regex",
- "syn 2.0.117",
-]
-
-[[package]]
name = "regex"
version = "1.12.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -4573,7 +4030,6 @@ dependencies = [
"bitflags 2.11.0",
"fallible-iterator",
"fallible-streaming-iterator",
- "hashlink",
"libsqlite3-sys",
"smallvec",
"sqlite-wasm-rs",
@@ -4803,34 +4259,6 @@ dependencies = [
]
[[package]]
-name = "secrecy"
-version = "0.10.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e891af845473308773346dc847b2c23ee78fe442e0472ac50e22a18a93d3ae5a"
-dependencies = [
- "zeroize",
-]
-
-[[package]]
-name = "secret-service"
-version = "5.1.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9a62d7f86047af0077255a29494136b9aaaf697c76ff70b8e49cded4e2623c14"
-dependencies = [
- "aes",
- "cbc",
- "futures-util",
- "generic-array",
- "getrandom 0.2.17",
- "hkdf",
- "num",
- "once_cell",
- "serde",
- "sha2",
- "zbus",
-]
-
-[[package]]
name = "security-framework"
version = "2.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -4931,17 +4359,6 @@ dependencies = [
]
[[package]]
-name = "serde_repr"
-version = "0.1.21"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906"
-dependencies = [
- "proc-macro2",
- "quote",
- "syn 3.0.2",
-]
-
-[[package]]
name = "serde_spanned"
version = "0.6.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -4951,15 +4368,6 @@ dependencies = [
]
[[package]]
-name = "serde_spanned"
-version = "1.1.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26"
-dependencies = [
- "serde_core",
-]
-
-[[package]]
name = "serde_urlencoded"
version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -5040,16 +4448,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
[[package]]
-name = "signal-hook-registry"
-version = "1.4.8"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b"
-dependencies = [
- "errno",
- "libc",
-]
-
-[[package]]
name = "signature"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -5071,12 +4469,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "38b58827f4464d87d377d175e90bf58eb00fd8716ff0a62f80356b5e61555d0d"
[[package]]
-name = "siphasher"
-version = "1.0.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649"
-
-[[package]]
name = "slab"
version = "0.4.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -5586,24 +4978,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362"
dependencies = [
"serde",
- "serde_spanned 0.6.9",
- "toml_datetime 0.6.11",
- "toml_edit 0.22.27",
-]
-
-[[package]]
-name = "toml"
-version = "1.1.4+spec-1.1.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3aace63f4bbcdfc2c965b059de67119c89c4017a70d633be6c104910f67056f5"
-dependencies = [
- "indexmap",
- "serde_core",
- "serde_spanned 1.1.1",
- "toml_datetime 1.1.1+spec-1.1.0",
- "toml_parser",
- "toml_writer",
- "winnow 1.0.4",
+ "serde_spanned",
+ "toml_datetime",
+ "toml_edit",
]
[[package]]
@@ -5616,15 +4993,6 @@ dependencies = [
]
[[package]]
-name = "toml_datetime"
-version = "1.1.1+spec-1.1.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7"
-dependencies = [
- "serde_core",
-]
-
-[[package]]
name = "toml_edit"
version = "0.22.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -5632,31 +5000,10 @@ checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a"
dependencies = [
"indexmap",
"serde",
- "serde_spanned 0.6.9",
- "toml_datetime 0.6.11",
+ "serde_spanned",
+ "toml_datetime",
"toml_write",
- "winnow 0.7.15",
-]
-
-[[package]]
-name = "toml_edit"
-version = "0.25.13+spec-1.1.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6975367e4d2ef766d86af01ffad14b622fecc8d4357a998fbc4deb6e9bacaf9b"
-dependencies = [
- "indexmap",
- "toml_datetime 1.1.1+spec-1.1.0",
- "toml_parser",
- "winnow 1.0.4",
-]
-
-[[package]]
-name = "toml_parser"
-version = "1.1.3+spec-1.1.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56"
-dependencies = [
- "winnow 1.0.4",
+ "winnow",
]
[[package]]
@@ -5666,12 +5013,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801"
[[package]]
-name = "toml_writer"
-version = "1.1.2+spec-1.1.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2"
-
-[[package]]
name = "tower"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -5825,17 +5166,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
[[package]]
-name = "uds_windows"
-version = "1.2.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e"
-dependencies = [
- "memoffset",
- "tempfile",
- "windows-sys 0.61.2",
-]
-
-[[package]]
name = "unicode-bidi"
version = "0.3.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -5876,28 +5206,12 @@ checksum = "3291800a6b06569f7d3e15bdb6dc235e0f0c8bd3eb07177f430057feb076415f"
dependencies = [
"anyhow",
"camino",
- "cargo_metadata 0.19.2",
- "clap",
- "uniffi_bindgen 0.29.5",
- "uniffi_core 0.29.5",
- "uniffi_macros 0.29.5",
- "uniffi_pipeline 0.29.5",
-]
-
-[[package]]
-name = "uniffi"
-version = "0.32.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a782a48d72cfd7a2d65cfc7c691dbf5375c43104b3c195f7eccc716dcc3540c8"
-dependencies = [
- "anyhow",
- "camino",
- "cargo_metadata 0.23.1",
+ "cargo_metadata",
"clap",
- "uniffi_bindgen 0.32.0",
- "uniffi_core 0.32.0",
- "uniffi_macros 0.32.0",
- "uniffi_pipeline 0.32.0",
+ "uniffi_bindgen",
+ "uniffi_core",
+ "uniffi_macros",
+ "uniffi_pipeline",
]
[[package]]
@@ -5907,10 +5221,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a04b99fa7796eaaa7b87976a0dbdd1178dc1ee702ea00aca2642003aef9b669e"
dependencies = [
"anyhow",
- "askama 0.13.1",
+ "askama",
"camino",
- "cargo_metadata 0.19.2",
- "fs-err 2.11.0",
+ "cargo_metadata",
+ "fs-err",
"glob",
"goblin",
"heck",
@@ -5920,36 +5234,10 @@ dependencies = [
"tempfile",
"textwrap",
"toml 0.5.11",
- "uniffi_internal_macros 0.29.5",
- "uniffi_meta 0.29.5",
- "uniffi_pipeline 0.29.5",
- "uniffi_udl 0.29.5",
-]
-
-[[package]]
-name = "uniffi_bindgen"
-version = "0.32.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "533b0312c73e3b54eb78a4b257ceae390962dd4767995778309a74644643f9ac"
-dependencies = [
- "anyhow",
- "askama 0.16.0",
- "camino",
- "cargo_metadata 0.23.1",
- "fs-err 3.3.1",
- "glob",
- "goblin",
- "heck",
- "indexmap",
- "once_cell",
- "serde",
- "tempfile",
- "textwrap",
- "toml 1.1.4+spec-1.1.0",
- "uniffi_internal_macros 0.32.0",
- "uniffi_meta 0.32.0",
- "uniffi_pipeline 0.32.0",
- "uniffi_udl 0.32.0",
+ "uniffi_internal_macros",
+ "uniffi_meta",
+ "uniffi_pipeline",
+ "uniffi_udl",
]
[[package]]
@@ -5966,18 +5254,6 @@ dependencies = [
]
[[package]]
-name = "uniffi_core"
-version = "0.32.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8e32e261c5b0dfaba6488f536e71957dddd6b1a498ac7eb791bee56b60a086be"
-dependencies = [
- "anyhow",
- "bytes",
- "once_cell",
- "static_assertions",
-]
-
-[[package]]
name = "uniffi_internal_macros"
version = "0.29.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -5991,50 +5267,20 @@ dependencies = [
]
[[package]]
-name = "uniffi_internal_macros"
-version = "0.32.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "84ae78069a5e6772ef694fd5bdb628532c88d2c2f0e7142bf6a384636eadb1af"
-dependencies = [
- "anyhow",
- "indexmap",
- "proc-macro2",
- "quote",
- "syn 2.0.117",
-]
-
-[[package]]
name = "uniffi_macros"
version = "0.29.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5596f178c4f7aafa1a501c4e0b96236a96bc2ef92bdb453d83e609dad0040152"
dependencies = [
"camino",
- "fs-err 2.11.0",
+ "fs-err",
"once_cell",
"proc-macro2",
"quote",
"serde",
"syn 2.0.117",
"toml 0.5.11",
- "uniffi_meta 0.29.5",
-]
-
-[[package]]
-name = "uniffi_macros"
-version = "0.32.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "330be6770532e86320df31f54c70bb0be67588594e8e77fa56e9083a3fed5d0d"
-dependencies = [
- "camino",
- "fs-err 3.3.1",
- "once_cell",
- "proc-macro2",
- "quote",
- "serde",
- "syn 2.0.117",
- "toml 1.1.4+spec-1.1.0",
- "uniffi_meta 0.32.0",
+ "uniffi_meta",
]
[[package]]
@@ -6044,21 +5290,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "beadc1f460eb2e209263c49c4f5b19e9a02e00a3b2b393f78ad10d766346ecff"
dependencies = [
"anyhow",
- "siphasher 0.3.11",
- "uniffi_internal_macros 0.29.5",
- "uniffi_pipeline 0.29.5",
-]
-
-[[package]]
-name = "uniffi_meta"
-version = "0.32.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "78de021f5547e56ab16c665a49d67d4fd3d31e77422f7739a2e9359d328cd9e7"
-dependencies = [
- "anyhow",
- "siphasher 1.0.3",
- "uniffi_internal_macros 0.32.0",
- "uniffi_pipeline 0.32.0",
+ "siphasher",
+ "uniffi_internal_macros",
+ "uniffi_pipeline",
]
[[package]]
@@ -6071,20 +5305,7 @@ dependencies = [
"heck",
"indexmap",
"tempfile",
- "uniffi_internal_macros 0.29.5",
-]
-
-[[package]]
-name = "uniffi_pipeline"
-version = "0.32.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3f8201bb1907ed8a42d80e11cbc25c8a033e7a31c3cff1d911f56eedb81d4948"
-dependencies = [
- "anyhow",
- "heck",
- "indexmap",
- "tempfile",
- "uniffi_internal_macros 0.32.0",
+ "uniffi_internal_macros",
]
[[package]]
@@ -6095,19 +5316,7 @@ checksum = "4319cf905911d70d5b97ce0f46f101619a22e9a189c8c46d797a9955e9233716"
dependencies = [
"anyhow",
"textwrap",
- "uniffi_meta 0.29.5",
- "weedle2",
-]
-
-[[package]]
-name = "uniffi_udl"
-version = "0.32.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a6e57996bc58009cc29bf04845d627ae313c2547b87171c1c349d6c51a1656c0"
-dependencies = [
- "anyhow",
- "textwrap",
- "uniffi_meta 0.32.0",
+ "uniffi_meta",
"weedle2",
]
@@ -6203,7 +5412,6 @@ checksum = "bf3923a6f5c4c6382e0b653c4117f48d631ea17f38ed86e2a828e6f7412f5239"
dependencies = [
"getrandom 0.4.2",
"js-sys",
- "serde_core",
"wasm-bindgen",
]
@@ -6561,19 +5769,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
-name = "windows-native-keyring-store"
-version = "1.1.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "063426e76fdec7438d56bb777f67e318a84a25c707b07e575cb8b78e10c028f8"
-dependencies = [
- "byteorder",
- "keyring-core",
- "regex",
- "windows-sys 0.61.2",
- "zeroize",
-]
-
-[[package]]
name = "windows-result"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -6766,15 +5961,6 @@ dependencies = [
]
[[package]]
-name = "winnow"
-version = "1.0.4"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81"
-dependencies = [
- "memchr",
-]
-
-[[package]]
name = "wit-bindgen"
version = "0.51.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -6999,78 +6185,6 @@ dependencies = [
]
[[package]]
-name = "zbus"
-version = "5.18.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "fe18fb60dc696039e738717b76eaea21e7a4489bbb1885020b43c94236d7e98a"
-dependencies = [
- "async-broadcast",
- "async-executor",
- "async-io",
- "async-lock",
- "async-process",
- "async-recursion",
- "async-task",
- "async-trait",
- "blocking",
- "enumflags2",
- "event-listener",
- "futures-core",
- "futures-lite",
- "hex",
- "libc",
- "ordered-stream",
- "rustix 1.1.4",
- "serde",
- "serde_repr",
- "tracing",
- "uds_windows",
- "uuid",
- "windows-sys 0.61.2",
- "winnow 1.0.4",
- "zbus_macros",
- "zbus_names",
- "zvariant",
-]
-
-[[package]]
-name = "zbus-secret-service-keyring-store"
-version = "1.0.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "4ccede190ba363386a24e8021c7f3848393976609ec9f5d1f8c6c09ef37075b4"
-dependencies = [
- "keyring-core",
- "secret-service",
- "zbus",
-]
-
-[[package]]
-name = "zbus_macros"
-version = "5.18.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "fe96480bed92df2b442a1a30df364e12d08eed03aeb061f2b8dc6afb2be91119"
-dependencies = [
- "proc-macro-crate",
- "proc-macro2",
- "quote",
- "syn 2.0.117",
- "zbus_names",
- "zvariant",
- "zvariant_utils",
-]
-
-[[package]]
-name = "zbus_names"
-version = "4.3.4"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d8bf88b4a3ff53e883001e0e0115b297a9d53c31b9c1edd2bfdd853e3428624e"
-dependencies = [
- "serde",
- "winnow 1.0.4",
- "zvariant",
-]
-
-[[package]]
name = "zerocopy"
version = "0.8.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -7229,43 +6343,3 @@ dependencies = [
"cc",
"pkg-config",
]
-
-[[package]]
-name = "zvariant"
-version = "5.13.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "bee2a0bcd2a907786a456fff45aaaaf54c9ba5f50b71ae9ec1a4edd200c94911"
-dependencies = [
- "endi",
- "enumflags2",
- "serde",
- "winnow 1.0.4",
- "zvariant_derive",
- "zvariant_utils",
-]
-
-[[package]]
-name = "zvariant_derive"
-version = "5.13.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "38a708216a18780796770bfe3f4739c7c83a3e8f789b755534bbbc06e4e23e12"
-dependencies = [
- "proc-macro-crate",
- "proc-macro2",
- "quote",
- "syn 2.0.117",
- "zvariant_utils",
-]
-
-[[package]]
-name = "zvariant_utils"
-version = "3.5.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "90cb9383f9b45290407a1258b202d3f8f01db719eb60b4e4055c6375af4fc7c7"
-dependencies = [
- "proc-macro2",
- "quote",
- "serde",
- "syn 2.0.117",
- "winnow 1.0.4",
-]
diff --git a/Cargo.toml b/Cargo.toml
@@ -42,14 +42,6 @@ members = [
"crates/mobile_core",
"crates/mobile_ffi",
"crates/mobile_wasm",
- "crates/studio_application",
- "crates/studio_domain",
- "crates/studio_ffi",
- "crates/studio_nostr",
- "crates/studio_preferences",
- "crates/studio_runtime",
- "crates/studio_storage",
- "crates/studio_uniffi_bindgen",
"crates/core_bindings",
"crates/event_bindings",
"crates/event_codec_wasm",
@@ -169,14 +161,6 @@ radroots_mobile_bindgen = { path = "crates/mobile_bindgen", version = "=0.1.0-al
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_studio_application = { path = "crates/studio_application", version = "=0.1.0-alpha" }
-radroots_studio_domain = { path = "crates/studio_domain", version = "=0.1.0-alpha" }
-radroots_studio_ffi = { path = "crates/studio_ffi", version = "=0.1.0-alpha" }
-radroots_studio_nostr = { path = "crates/studio_nostr", version = "=0.1.0-alpha" }
-radroots_studio_preferences = { path = "crates/studio_preferences", version = "=0.1.0-alpha" }
-radroots_studio_runtime = { path = "crates/studio_runtime", version = "=0.1.0-alpha" }
-radroots_studio_storage = { path = "crates/studio_storage", version = "=0.1.0-alpha" }
-radroots_studio_uniffi_bindgen = { path = "crates/studio_uniffi_bindgen", 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" }
diff --git a/contracts/coverage-profiles.toml b/contracts/coverage-profiles.toml
@@ -27,12 +27,3 @@ test_threads = 1
no_default_features = false
features = ["full"]
test_threads = 1
-
-[profiles.crates."radroots_studio_application"]
-test_packages = ["radroots_studio_runtime", "radroots_studio_ffi"]
-
-[profiles.crates."radroots_studio_runtime"]
-test_packages = ["radroots_studio_ffi"]
-
-[profiles.crates."radroots_studio_storage"]
-test_packages = ["radroots_studio_runtime", "radroots_studio_ffi"]
diff --git a/contracts/coverage.toml b/contracts/coverage.toml
@@ -50,11 +50,6 @@ 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_studio_uniffi_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
@@ -88,14 +83,6 @@ crates = [
"radroots_signing",
"radroots_storage",
"radroots_storage_sqlite",
- "radroots_studio_application",
- "radroots_studio_domain",
- "radroots_studio_ffi",
- "radroots_studio_nostr",
- "radroots_studio_preferences",
- "radroots_studio_runtime",
- "radroots_studio_storage",
- "radroots_studio_uniffi_bindgen",
"radroots_sync",
"radroots_transport_nostr",
"radroots_transport_reticulum",
diff --git a/contracts/crates/catalog.v2.toml b/contracts/crates/catalog.v2.toml
@@ -7,7 +7,7 @@ rust_version = "1.97.1"
edition = "2024"
resolver = "3"
public_package_count = 19
-package_count = 63
+package_count = 55
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"
@@ -24,6 +24,14 @@ retired_packages = [
"radroots-studio-storage",
"radroots-studio-ffi",
"radroots-studio-uniffi-bindgen",
+ "radroots_studio_application",
+ "radroots_studio_domain",
+ "radroots_studio_ffi",
+ "radroots_studio_nostr",
+ "radroots_studio_preferences",
+ "radroots_studio_runtime",
+ "radroots_studio_storage",
+ "radroots_studio_uniffi_bindgen",
"radroots_sdk_xtask",
]
@@ -1479,260 +1487,3 @@ source_path = "crates/bindgen"
source_tree_sha256 = "f0a9d7ec9794257bc56063117208e6e83a34efe1b852e8af6d6a56a1693d27fa"
compatibility = ["generated", "swift", "kotlin", "package_private"]
replaces = ["radroots_app_bindgen"]
-
-[[package]]
-name = "radroots_studio_domain"
-path = "crates/studio_domain"
-state = "active"
-tier = "application_domain"
-visibility = "private_runtime"
-publish = false
-version = "0.1.0-alpha"
-license = "GPL-3.0-only"
-platforms = ["native"]
-groups = ["coverage_required", "studio"]
-owners = ["studio"]
-permitted_dependency_tiers = [
- "foundation",
- "domain",
- "spi",
- "adapter",
- "orchestration",
- "sdk",
- "facade",
- "application_domain",
-]
-provenance_kind = "imported"
-source_repository = "https://github.com/radrootslabs/studio_app"
-source_revision = "2b5fe5d8321fd0e248a9d304651b1ba54ee6a180"
-source_path = "core/crates/domain"
-source_tree_sha256 = "25f8abe2f8f4e9dfeb6450116be67eb3faad53f2d3879a4c142796238f74b0a9"
-compatibility = ["product", "data", "package_private"]
-replaces = ["radroots-studio-domain"]
-
-[[package]]
-name = "radroots_studio_preferences"
-path = "crates/studio_preferences"
-state = "active"
-tier = "application_domain"
-visibility = "private_runtime"
-publish = false
-version = "0.1.0-alpha"
-license = "MPL-2.0"
-platforms = ["native"]
-groups = ["coverage_required", "studio"]
-owners = ["studio"]
-permitted_dependency_tiers = [
- "foundation",
- "domain",
- "spi",
- "adapter",
- "orchestration",
- "sdk",
- "facade",
- "application_domain",
-]
-provenance_kind = "imported"
-source_repository = "https://github.com/radrootslabs/_radroots"
-source_revision = "6074a4745be361f21bb47d4778c74a14b2d57954"
-source_path = "studio_app/studio_app_core/crates/core"
-source_tree_sha256 = "0237265710a676ce1db0fb3091e1e5d9a5831339e00076ea2a33b96d6343834d"
-compatibility = ["product", "preferences", "package_private"]
-replaces = ["studio_app_core"]
-
-[[package]]
-name = "radroots_studio_application"
-path = "crates/studio_application"
-state = "active"
-tier = "application"
-visibility = "private_runtime"
-publish = false
-version = "0.1.0-alpha"
-license = "GPL-3.0-only"
-platforms = ["native"]
-groups = ["coverage_required", "studio"]
-owners = ["studio"]
-permitted_dependency_tiers = [
- "foundation",
- "domain",
- "spi",
- "adapter",
- "orchestration",
- "sdk",
- "facade",
- "application_domain",
- "application",
-]
-provenance_kind = "imported"
-source_repository = "https://github.com/radrootslabs/studio_app"
-source_revision = "2b5fe5d8321fd0e248a9d304651b1ba54ee6a180"
-source_path = "core/crates/application"
-source_tree_sha256 = "8b0b5d4d74dde0af3c5fb3dac979b0cf57cccf073d597f7bd35b1e73a711fe0b"
-compatibility = ["product", "behavior", "package_private"]
-replaces = ["radroots-studio-application"]
-
-[[package]]
-name = "radroots_studio_nostr"
-path = "crates/studio_nostr"
-state = "active"
-tier = "application_adapter"
-visibility = "private_adapter"
-publish = false
-version = "0.1.0-alpha"
-license = "GPL-3.0-only"
-platforms = ["native"]
-groups = ["coverage_required", "studio"]
-owners = ["studio"]
-permitted_dependency_tiers = [
- "foundation",
- "domain",
- "spi",
- "adapter",
- "orchestration",
- "sdk",
- "facade",
- "application_domain",
- "application",
- "application_adapter",
-]
-provenance_kind = "imported"
-source_repository = "https://github.com/radrootslabs/studio_app"
-source_revision = "2b5fe5d8321fd0e248a9d304651b1ba54ee6a180"
-source_path = "core/crates/nostr"
-source_tree_sha256 = "2ce5ce0227ab190102a94ffdf80d95b37ed35f5ef62286c4e3e19cd42a777115"
-compatibility = ["network", "security", "package_private"]
-replaces = ["radroots-studio-nostr"]
-
-[[package]]
-name = "radroots_studio_storage"
-path = "crates/studio_storage"
-state = "active"
-tier = "application_adapter"
-visibility = "private_adapter"
-publish = false
-version = "0.1.0-alpha"
-license = "GPL-3.0-only"
-platforms = ["native"]
-groups = ["coverage_required", "studio"]
-owners = ["studio"]
-permitted_dependency_tiers = [
- "foundation",
- "domain",
- "spi",
- "adapter",
- "orchestration",
- "sdk",
- "facade",
- "application_domain",
- "application",
- "application_adapter",
-]
-provenance_kind = "imported"
-source_repository = "https://github.com/radrootslabs/studio_app"
-source_revision = "2b5fe5d8321fd0e248a9d304651b1ba54ee6a180"
-source_path = "core/crates/storage"
-source_tree_sha256 = "f3addecbd7f6dbccda4a438597b4e433443a34b48a6c4ef9efa90e49e8f05c4b"
-compatibility = ["data", "keyring", "package_private"]
-replaces = ["radroots-studio-storage"]
-
-[[package]]
-name = "radroots_studio_runtime"
-path = "crates/studio_runtime"
-state = "active"
-tier = "runtime_composition"
-visibility = "private_runtime"
-publish = false
-version = "0.1.0-alpha"
-license = "GPL-3.0-only"
-platforms = ["native"]
-groups = ["coverage_required", "studio"]
-owners = ["studio"]
-permitted_dependency_tiers = [
- "foundation",
- "domain",
- "spi",
- "adapter",
- "orchestration",
- "sdk",
- "facade",
- "application_domain",
- "application",
- "application_adapter",
- "runtime_composition",
-]
-provenance_kind = "imported"
-source_repository = "https://github.com/radrootslabs/studio_app"
-source_revision = "2b5fe5d8321fd0e248a9d304651b1ba54ee6a180"
-source_path = "core/crates/storage"
-source_tree_sha256 = "f3addecbd7f6dbccda4a438597b4e433443a34b48a6c4ef9efa90e49e8f05c4b"
-compatibility = ["product", "lifecycle", "package_private"]
-replaces = []
-
-[[package]]
-name = "radroots_studio_ffi"
-path = "crates/studio_ffi"
-state = "active"
-tier = "boundary"
-visibility = "private_boundary"
-publish = false
-version = "0.1.0-alpha"
-license = "GPL-3.0-only"
-platforms = ["linux", "macos", "windows"]
-groups = ["coverage_required", "studio", "boundaries"]
-owners = ["studio"]
-permitted_dependency_tiers = [
- "foundation",
- "domain",
- "spi",
- "adapter",
- "orchestration",
- "sdk",
- "facade",
- "application_domain",
- "application",
- "application_adapter",
- "runtime_composition",
- "boundary",
-]
-provenance_kind = "imported"
-source_repository = "https://github.com/radrootslabs/studio_app"
-source_revision = "2b5fe5d8321fd0e248a9d304651b1ba54ee6a180"
-source_path = "core/crates/ffi"
-source_tree_sha256 = "0bb1d02eb963a606a288d9c35fb418de7bfd914dc5e2c4a38112af64c643151c"
-compatibility = ["ffi", "kotlin", "product", "lifecycle", "package_private"]
-replaces = ["radroots-studio-ffi"]
-
-[[package]]
-name = "radroots_studio_uniffi_bindgen"
-path = "crates/studio_uniffi_bindgen"
-state = "active"
-tier = "codegen"
-visibility = "private_codegen"
-publish = false
-version = "0.1.0-alpha"
-license = "GPL-3.0-only"
-platforms = ["native"]
-groups = ["coverage_required", "studio", "boundaries"]
-owners = ["studio"]
-permitted_dependency_tiers = [
- "foundation",
- "domain",
- "spi",
- "adapter",
- "orchestration",
- "sdk",
- "facade",
- "application_domain",
- "application",
- "application_adapter",
- "runtime_composition",
- "boundary",
- "codegen",
-]
-provenance_kind = "imported"
-source_repository = "https://github.com/radrootslabs/studio_app"
-source_revision = "2b5fe5d8321fd0e248a9d304651b1ba54ee6a180"
-source_path = "core/tools/uniffi-bindgen"
-source_tree_sha256 = "0eedd47c17fc7b2b84d953f2c4613aecf96575df466eb106450c7d7eee25ca9b"
-compatibility = ["generated", "kotlin", "package_private"]
-replaces = ["radroots-studio-uniffi-bindgen"]
diff --git a/contracts/crates/generated/package_groups.v1.toml b/contracts/crates/generated/package_groups.v1.toml
@@ -1,16 +1,16 @@
schema = "radroots.workspace.package-groups.v1"
-catalog_sha256 = "58d9d5ed0a98eeaec9198928d1a3ec8d722ad2eb08fbb5ab6ef4bb1eba898d0f"
+catalog_sha256 = "deca0c080deae187ff8186c0708903e42f41ea57f77c5f91581e23aa561164a4"
[[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_studio_ffi", "radroots_studio_uniffi_bindgen", "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_studio_ffi", "radroots_studio_uniffi_bindgen", "radroots_trade_bindings"]
+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"]
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_studio_application", "radroots_studio_domain", "radroots_studio_ffi", "radroots_studio_nostr", "radroots_studio_preferences", "radroots_studio_runtime", "radroots_studio_storage", "radroots_studio_uniffi_bindgen", "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_studio_application", "radroots_studio_domain", "radroots_studio_ffi", "radroots_studio_nostr", "radroots_studio_preferences", "radroots_studio_runtime", "radroots_studio_storage", "radroots_studio_uniffi_bindgen", "radroots_sync", "radroots_test_fixtures", "radroots_trade", "radroots_trade_bindings", "radroots_transport", "radroots_transport_nostr", "radroots_transport_reticulum", "xtask"]
+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]]
@@ -44,12 +44,6 @@ active_packages = ["radroots", "radroots_core_bindings", "radroots_event_binding
reserved_packages = []
[[group]]
-id = "studio"
-packages = ["radroots_studio_application", "radroots_studio_domain", "radroots_studio_ffi", "radroots_studio_nostr", "radroots_studio_preferences", "radroots_studio_runtime", "radroots_studio_storage", "radroots_studio_uniffi_bindgen"]
-active_packages = ["radroots_studio_application", "radroots_studio_domain", "radroots_studio_ffi", "radroots_studio_nostr", "radroots_studio_preferences", "radroots_studio_runtime", "radroots_studio_storage", "radroots_studio_uniffi_bindgen"]
-reserved_packages = []
-
-[[group]]
id = "tools"
packages = ["xtask"]
active_packages = ["xtask"]
diff --git a/contracts/crates/generated/platform_inventory.v1.toml b/contracts/crates/generated/platform_inventory.v1.toml
@@ -1,5 +1,5 @@
schema = "radroots.workspace.platform-inventory.v1"
-catalog_sha256 = "58d9d5ed0a98eeaec9198928d1a3ec8d722ad2eb08fbb5ab6ef4bb1eba898d0f"
+catalog_sha256 = "deca0c080deae187ff8186c0708903e42f41ea57f77c5f91581e23aa561164a4"
[[platform]]
id = "android"
@@ -14,21 +14,9 @@ id = "apple"
packages = ["radroots_mobile_ffi"]
[[platform]]
-id = "linux"
-packages = ["radroots_studio_ffi"]
-
-[[platform]]
-id = "macos"
-packages = ["radroots_studio_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_studio_application", "radroots_studio_domain", "radroots_studio_nostr", "radroots_studio_preferences", "radroots_studio_runtime", "radroots_studio_storage", "radroots_studio_uniffi_bindgen", "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_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"]
[[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"]
-
-[[platform]]
-id = "windows"
-packages = ["radroots_studio_ffi"]
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 = "58d9d5ed0a98eeaec9198928d1a3ec8d722ad2eb08fbb5ab6ef4bb1eba898d0f"
+catalog_sha256 = "deca0c080deae187ff8186c0708903e42f41ea57f77c5f91581e23aa561164a4"
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_studio_application", "radroots_studio_domain", "radroots_studio_ffi", "radroots_studio_nostr", "radroots_studio_preferences", "radroots_studio_runtime", "radroots_studio_storage", "radroots_studio_uniffi_bindgen", "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_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"]
reserved_packages = []
diff --git a/contracts/releases/publish_policy.toml b/contracts/releases/publish_policy.toml
@@ -60,12 +60,6 @@ private = [
"radroots_service_host",
"radroots_service_sqlite",
"radroots_sql_core",
- "radroots_studio_application",
- "radroots_studio_domain",
- "radroots_studio_nostr",
- "radroots_studio_preferences",
- "radroots_studio_runtime",
- "radroots_studio_storage",
]
build_codegen = [
"radroots_core_bindings",
@@ -79,8 +73,6 @@ build_codegen = [
"radroots_replica_store_wasm",
"radroots_replica_sync_wasm",
"radroots_sdk_sql_wasm_runtime",
- "radroots_studio_ffi",
- "radroots_studio_uniffi_bindgen",
"radroots_trade_bindings",
"xtask",
]
diff --git a/crates/studio_application/Cargo.toml b/crates/studio_application/Cargo.toml
@@ -1,25 +0,0 @@
-[package]
-name = "radroots_studio_application"
-description = "Private application policy and ports for Radroots Studio"
-version = "0.1.0-alpha"
-edition.workspace = true
-authors.workspace = true
-rust-version.workspace = true
-license = "GPL-3.0-only"
-repository.workspace = true
-homepage.workspace = true
-publish = false
-include = ["src/**", "tests/**", "Cargo.toml"]
-
-[dependencies]
-radroots_studio_domain.workspace = true
-secrecy = "=0.10.3"
-tokio = { version = "=1.47.1", features = [
- "macros",
- "rt-multi-thread",
- "sync",
- "time",
-] }
-
-[lints]
-workspace = true
diff --git a/crates/studio_application/src/accounts.rs b/crates/studio_application/src/accounts.rs
@@ -1,1822 +0,0 @@
-use std::sync::{Mutex, MutexGuard};
-
-use crate::{
- AccountOperationKind, AccountOperationPhase, AccountRepository, AppCore, AppStateRepository,
- Clock, DurableOperationKind, DurableOperationPhase, DurableOperationRepository,
- DurableOperationStart, DurableRequestId, DurableTerminalOutcome, OperationDiagnostic,
- OperationId, OperationJournal, OperationPriorState, PendingAccountOperation,
- RemovalConfirmationToken, SecretStore, StagedGeneratedKey, StateTransition,
-};
-use radroots_studio_domain::{
- AccountCreatedAt, AccountIdentity, AccountSummary, BindingAvailability, LocalSignerBinding,
- Nsec, PublicKey, SafeError, SafeErrorCode, SafeMessage, SecretKeyInput,
-};
-
-pub struct GenerateAccountReceipt {
- account: AccountSummary,
- generated_nsec: Nsec,
-}
-
-#[derive(Clone, Debug, Eq, PartialEq)]
-pub struct ImportAccountReceipt {
- account: AccountSummary,
-}
-
-impl ImportAccountReceipt {
- #[must_use]
- pub const fn account(&self) -> &AccountSummary {
- &self.account
- }
-}
-
-impl GenerateAccountReceipt {
- #[must_use]
- pub const fn account(&self) -> &AccountSummary {
- &self.account
- }
-
- #[must_use]
- pub const fn generated_nsec(&self) -> &Nsec {
- &self.generated_nsec
- }
-}
-
-impl AppCore {
- /// Commits a staged generated key only after its recovery acknowledgement.
- ///
- /// # Errors
- ///
- /// Returns a safe conflict, keyring, persistence, or recovery error.
- #[allow(clippy::too_many_arguments)]
- pub fn commit_staged_generated_key(
- &self,
- request_id: &DurableRequestId,
- staged: StagedGeneratedKey,
- accounts: &(impl AccountRepository + ?Sized),
- app_state: &(impl AppStateRepository + ?Sized),
- secrets: &(impl SecretStore + ?Sized),
- operations: &(impl DurableOperationRepository + ?Sized),
- clock: &(impl Clock + ?Sized),
- ) -> Result<ImportAccountReceipt, SafeError> {
- let expected_revision = staged.expected_revision();
- self.require_revision(expected_revision)?;
- let (account, secret) = staged.into_commit_parts();
- self.persist_account_durable(
- request_id,
- DurableOperationKind::Create,
- expected_revision,
- &account,
- secret,
- None,
- accounts,
- app_state,
- secrets,
- operations,
- clock,
- )?;
- Ok(ImportAccountReceipt { account })
- }
-
- /// Generates and commits one account under a durable caller request.
- ///
- /// # Errors
- ///
- /// Returns a safe conflict, keyring, persistence, or state error. Staged recovery transport
- /// replaces this transitional generated-secret receipt in the custody phase.
- #[allow(clippy::too_many_arguments)]
- pub fn generate_account_durable(
- &self,
- request_id: &DurableRequestId,
- expected_revision: u64,
- accounts: &(impl AccountRepository + ?Sized),
- app_state: &(impl AppStateRepository + ?Sized),
- secrets: &(impl SecretStore + ?Sized),
- operations: &(impl DurableOperationRepository + ?Sized),
- clock: &(impl Clock + ?Sized),
- ) -> Result<GenerateAccountReceipt, SafeError> {
- self.require_revision(expected_revision)?;
- let generated = self.key_material().generate()?;
- let (public_key, npub, secret, nsec) = generated.into_parts();
- let account = AccountSummary::new(
- AccountIdentity::verify(public_key, npub.as_str().to_owned())?,
- LocalSignerBinding::new(public_key, BindingAvailability::Available),
- None,
- AccountCreatedAt::new(clock.now()),
- None,
- )?;
- self.persist_account_durable(
- request_id,
- DurableOperationKind::Create,
- expected_revision,
- &account,
- secret,
- None,
- accounts,
- app_state,
- secrets,
- operations,
- clock,
- )?;
- Ok(GenerateAccountReceipt {
- account,
- generated_nsec: nsec,
- })
- }
-
- /// Imports or explicitly repairs one local account under a durable caller request.
- ///
- /// # Errors
- ///
- /// Returns a safe conflict, validation, keyring, persistence, or state error.
- #[allow(clippy::too_many_arguments)]
- pub fn import_secret_key_durable(
- &self,
- request_id: &DurableRequestId,
- expected_revision: u64,
- input: SecretKeyInput,
- accounts: &(impl AccountRepository + ?Sized),
- app_state: &(impl AppStateRepository + ?Sized),
- secrets: &(impl SecretStore + ?Sized),
- operations: &(impl DurableOperationRepository + ?Sized),
- clock: &(impl Clock + ?Sized),
- ) -> Result<ImportAccountReceipt, SafeError> {
- if let Some(existing) = operations.load_durable_operation(request_id)? {
- return if existing
- .terminal()
- .is_some_and(|receipt| receipt.outcome() == DurableTerminalOutcome::Completed)
- {
- accounts
- .find_account(existing.account())?
- .map(|account| ImportAccountReceipt { account })
- .ok_or_else(recovery_required)
- } else {
- Err(recovery_required())
- };
- }
- self.require_revision(expected_revision)?;
- let imported = self.key_material().import(input)?;
- let (public_key, npub, secret) = imported.into_parts();
- let previous = accounts.find_account(public_key)?;
- if let Some(existing) = &previous
- && (existing.signer().availability() != BindingAvailability::CredentialMissing
- || secrets.contains(public_key)?)
- {
- return Err(account_exists());
- }
- if previous.is_none() && secrets.contains(public_key)? {
- return Err(account_exists());
- }
- let account = if let Some(existing) = &previous {
- existing.with_binding_availability(BindingAvailability::Available)
- } else {
- AccountSummary::new(
- AccountIdentity::verify(public_key, npub.as_str().to_owned())?,
- LocalSignerBinding::new(public_key, BindingAvailability::Available),
- None,
- AccountCreatedAt::new(clock.now()),
- None,
- )?
- };
- let kind = if previous.is_some() {
- DurableOperationKind::Repair
- } else {
- DurableOperationKind::Import
- };
- self.persist_account_durable(
- request_id,
- kind,
- expected_revision,
- &account,
- secret,
- previous.as_ref(),
- accounts,
- app_state,
- secrets,
- operations,
- clock,
- )?;
- Ok(ImportAccountReceipt { account })
- }
-
- fn require_revision(&self, expected_revision: u64) -> Result<(), SafeError> {
- if self.snapshot().revision().value() != expected_revision {
- return Err(operation_conflict());
- }
- Ok(())
- }
-
- #[allow(clippy::too_many_arguments)]
- fn persist_account_durable(
- &self,
- request_id: &DurableRequestId,
- kind: DurableOperationKind,
- expected_revision: u64,
- account: &AccountSummary,
- secret: SecretKeyInput,
- previous: Option<&AccountSummary>,
- accounts: &(impl AccountRepository + ?Sized),
- app_state: &(impl AppStateRepository + ?Sized),
- secrets: &(impl SecretStore + ?Sized),
- operations: &(impl DurableOperationRepository + ?Sized),
- clock: &(impl Clock + ?Sized),
- ) -> Result<(), SafeError> {
- let prior = OperationPriorState::new(
- app_state.load_selected_account()?,
- previous.map(|account| account.signer().availability()),
- );
- match operations.begin_durable_operation(
- request_id,
- kind,
- account.public_key(),
- Some(expected_revision),
- prior,
- clock.now(),
- )? {
- DurableOperationStart::Started(_) => {}
- DurableOperationStart::Existing(operation) => {
- return if operation
- .terminal()
- .is_some_and(|receipt| receipt.outcome() == DurableTerminalOutcome::Completed)
- {
- Ok(())
- } else {
- Err(recovery_required())
- };
- }
- }
- secrets.put(account.public_key(), secret)?;
- operations.advance_durable_operation(
- request_id,
- DurableOperationPhase::IntentRecorded,
- DurableOperationPhase::CredentialWritten,
- clock.now(),
- None,
- )?;
- previous.map_or_else(
- || accounts.insert_account(account),
- |_| accounts.update_account(account),
- )?;
- operations.advance_durable_operation(
- request_id,
- DurableOperationPhase::CredentialWritten,
- DurableOperationPhase::MetadataCommitted,
- clock.now(),
- None,
- )?;
- app_state.save_selected_account(Some(account.public_key()))?;
- operations.advance_durable_operation(
- request_id,
- DurableOperationPhase::MetadataCommitted,
- DurableOperationPhase::SelectionCommitted,
- clock.now(),
- None,
- )?;
- let snapshot = self.apply_transition(StateTransition::ReplaceRegistry {
- accounts: accounts.list_accounts()?,
- selected: Some(account.public_key()),
- })?;
- operations.finalize_durable_operation(
- request_id,
- DurableOperationPhase::SelectionCommitted,
- DurableTerminalOutcome::Completed,
- Some(snapshot.revision().value()),
- clock.now(),
- )?;
- Ok(())
- }
-
- /// Issues a single-use confirmation bound to the target and current revision.
- ///
- /// # Errors
- ///
- /// Returns a safe account or application-state error.
- pub fn request_account_removal(
- &self,
- public_key: PublicKey,
- clock: &(impl Clock + ?Sized),
- ) -> Result<RemovalConfirmationToken, SafeError> {
- self.issue_removal_token(public_key, clock.now())
- }
-
- pub fn cancel_account_removal(&self, token: RemovalConfirmationToken) -> bool {
- self.cancel_removal_token(token)
- }
-
- /// Permanently removes a confirmed account and selects a deterministic fallback.
- ///
- /// # Errors
- ///
- /// Returns a safe confirmation, credential, persistence, recovery, or state error.
- pub fn confirm_account_removal(
- &self,
- token: RemovalConfirmationToken,
- accounts: &(impl AccountRepository + ?Sized),
- app_state: &(impl AppStateRepository + ?Sized),
- secrets: &(impl SecretStore + ?Sized),
- journal: &(impl OperationJournal + ?Sized),
- clock: &(impl Clock + ?Sized),
- ) -> Result<crate::AppSnapshot, SafeError> {
- let public_key = self.consume_removal_token(token, clock.now())?;
- let registry = accounts.list_accounts()?;
- let index = registry
- .iter()
- .position(|account| account.public_key() == public_key)
- .ok_or_else(account_not_found)?;
- let selected = if self.snapshot().selected_account() == Some(public_key) {
- registry
- .get(index + 1)
- .or_else(|| index.checked_sub(1).and_then(|before| registry.get(before)))
- .map(AccountSummary::public_key)
- } else {
- self.snapshot().selected_account()
- };
- let operation =
- journal.begin_operation(AccountOperationKind::Remove, public_key, clock.now())?;
- let was_active = self
- .snapshot()
- .active_account()
- .is_some_and(|active| active.account().public_key() == public_key);
- if was_active {
- self.sign_out()?;
- }
- let account = ®istry[index];
- match secrets.delete(public_key) {
- Ok(()) => {}
- Err(error)
- if error.code() == SafeErrorCode::CredentialMissing
- && account.signer().availability()
- == BindingAvailability::CredentialMissing => {}
- Err(error) => return Err(error),
- }
- journal.update_operation(
- operation,
- AccountOperationPhase::CredentialDeleted,
- clock.now(),
- None,
- )?;
- accounts.remove_account(public_key)?;
- app_state.save_selected_account(selected)?;
- journal.update_operation(
- operation,
- AccountOperationPhase::MetadataDeleted,
- clock.now(),
- None,
- )?;
- journal.finalize_operation(operation)?;
- self.apply_transition(StateTransition::ReplaceRegistryPreservingSession {
- accounts: accounts.list_accounts()?,
- selected,
- })
- }
-
- /// Confirms and executes an expiring removal plan as a durable request.
- ///
- /// # Errors
- ///
- /// Returns a safe expiry, conflict, credential, persistence, or recovery error.
- #[allow(clippy::too_many_arguments)]
- pub fn confirm_account_removal_durable(
- &self,
- request_id: &DurableRequestId,
- token: RemovalConfirmationToken,
- accounts: &(impl AccountRepository + ?Sized),
- app_state: &(impl AppStateRepository + ?Sized),
- secrets: &(impl SecretStore + ?Sized),
- operations: &(impl DurableOperationRepository + ?Sized),
- clock: &(impl Clock + ?Sized),
- ) -> Result<crate::AppSnapshot, SafeError> {
- let expected_revision = token.revision().value();
- let public_key = self.consume_removal_token(token, clock.now())?;
- self.require_revision(expected_revision)?;
- let registry = accounts.list_accounts()?;
- let index = registry
- .iter()
- .position(|account| account.public_key() == public_key)
- .ok_or_else(account_not_found)?;
- let selected = if self.snapshot().selected_account() == Some(public_key) {
- registry
- .get(index + 1)
- .or_else(|| index.checked_sub(1).and_then(|before| registry.get(before)))
- .map(AccountSummary::public_key)
- } else {
- self.snapshot().selected_account()
- };
- let account = ®istry[index];
- match operations.begin_durable_operation(
- request_id,
- DurableOperationKind::Remove,
- public_key,
- Some(expected_revision),
- OperationPriorState::new(selected, Some(account.signer().availability())),
- clock.now(),
- )? {
- DurableOperationStart::Started(_) => {}
- DurableOperationStart::Existing(operation) => {
- return if operation
- .terminal()
- .is_some_and(|receipt| receipt.outcome() == DurableTerminalOutcome::Completed)
- {
- Ok(self.snapshot())
- } else {
- Err(recovery_required())
- };
- }
- }
- if self
- .snapshot()
- .active_account()
- .is_some_and(|active| active.account().public_key() == public_key)
- {
- self.sign_out()?;
- }
- match secrets.delete(public_key) {
- Ok(()) => {}
- Err(error)
- if error.code() == SafeErrorCode::CredentialMissing
- && account.signer().availability()
- == BindingAvailability::CredentialMissing => {}
- Err(error) => return Err(error),
- }
- operations.advance_durable_operation(
- request_id,
- DurableOperationPhase::IntentRecorded,
- DurableOperationPhase::CredentialDeleted,
- clock.now(),
- None,
- )?;
- accounts.remove_account(public_key)?;
- operations.advance_durable_operation(
- request_id,
- DurableOperationPhase::CredentialDeleted,
- DurableOperationPhase::MetadataDeleted,
- clock.now(),
- None,
- )?;
- app_state.save_selected_account(selected)?;
- operations.advance_durable_operation(
- request_id,
- DurableOperationPhase::MetadataDeleted,
- DurableOperationPhase::SelectionCommitted,
- clock.now(),
- None,
- )?;
- let snapshot =
- self.apply_transition(StateTransition::ReplaceRegistryPreservingSession {
- accounts: accounts.list_accounts()?,
- selected,
- })?;
- operations.finalize_durable_operation(
- request_id,
- DurableOperationPhase::SelectionCommitted,
- DurableTerminalOutcome::Completed,
- Some(snapshot.revision().value()),
- clock.now(),
- )?;
- Ok(snapshot)
- }
-
- /// Persists and publishes a saved account selection without activating it.
- ///
- /// # Errors
- ///
- /// Returns a safe account, persistence, or application-state error.
- pub fn select_account(
- &self,
- public_key: PublicKey,
- accounts: &(impl AccountRepository + ?Sized),
- app_state: &(impl AppStateRepository + ?Sized),
- ) -> Result<crate::AppSnapshot, SafeError> {
- if accounts.find_account(public_key)?.is_none() {
- return Err(account_not_found());
- }
- app_state.save_selected_account(Some(public_key))?;
- self.apply_transition(StateTransition::Select(public_key))
- }
-
- /// Generates, stores, and selects one local Nostr account without activating it.
- ///
- /// # Errors
- ///
- /// Returns a safe key, credential, persistence, or application-state error.
- pub fn generate_account(
- &self,
- accounts: &(impl AccountRepository + ?Sized),
- app_state: &(impl AppStateRepository + ?Sized),
- secrets: &(impl SecretStore + ?Sized),
- journal: &(impl OperationJournal + ?Sized),
- clock: &(impl Clock + ?Sized),
- ) -> Result<GenerateAccountReceipt, SafeError> {
- let generated = self.key_material().generate()?;
- let (public_key, npub, secret, nsec) = generated.into_parts();
- let account = AccountSummary::new(
- AccountIdentity::verify(public_key, npub.as_str().to_owned())?,
- LocalSignerBinding::new(public_key, BindingAvailability::Available),
- None,
- AccountCreatedAt::new(clock.now()),
- None,
- )?;
- Self::persist_account_transaction(
- AccountOperationKind::Add,
- &account,
- secret,
- None,
- accounts,
- app_state,
- secrets,
- journal,
- clock,
- )?;
- let registry = accounts.list_accounts()?;
- self.apply_transition(StateTransition::ReplaceRegistry {
- accounts: registry,
- selected: Some(public_key),
- })?;
- Ok(GenerateAccountReceipt {
- account,
- generated_nsec: nsec,
- })
- }
-
- /// Imports, stores, and selects one local Nostr account without activating it.
- ///
- /// # Errors
- ///
- /// Returns a safe key, credential, persistence, or application-state error.
- pub fn import_secret_key(
- &self,
- input: SecretKeyInput,
- accounts: &(impl AccountRepository + ?Sized),
- app_state: &(impl AppStateRepository + ?Sized),
- secrets: &(impl SecretStore + ?Sized),
- journal: &(impl OperationJournal + ?Sized),
- clock: &(impl Clock + ?Sized),
- ) -> Result<ImportAccountReceipt, SafeError> {
- let imported = self.key_material().import(input)?;
- let (public_key, npub, secret) = imported.into_parts();
- if let Some(existing) = accounts.find_account(public_key)? {
- if existing.signer().availability() != BindingAvailability::CredentialMissing
- || secrets.contains(public_key)?
- {
- return Err(account_exists());
- }
- let repaired = existing.with_binding_availability(BindingAvailability::Available);
- Self::persist_account_transaction(
- AccountOperationKind::Import,
- &repaired,
- secret,
- Some(&existing),
- accounts,
- app_state,
- secrets,
- journal,
- clock,
- )?;
- self.apply_transition(StateTransition::ReplaceRegistry {
- accounts: accounts.list_accounts()?,
- selected: Some(public_key),
- })?;
- return Ok(ImportAccountReceipt { account: repaired });
- }
- if secrets.contains(public_key)? {
- return Err(account_exists());
- }
- let account = AccountSummary::new(
- AccountIdentity::verify(public_key, npub.as_str().to_owned())?,
- LocalSignerBinding::new(public_key, BindingAvailability::Available),
- None,
- AccountCreatedAt::new(clock.now()),
- None,
- )?;
- Self::persist_account_transaction(
- AccountOperationKind::Import,
- &account,
- secret,
- None,
- accounts,
- app_state,
- secrets,
- journal,
- clock,
- )?;
- self.apply_transition(StateTransition::ReplaceRegistry {
- accounts: accounts.list_accounts()?,
- selected: Some(public_key),
- })?;
- Ok(ImportAccountReceipt { account })
- }
-
- #[allow(clippy::too_many_arguments)]
- fn persist_account_transaction(
- kind: AccountOperationKind,
- account: &AccountSummary,
- secret: SecretKeyInput,
- previous: Option<&AccountSummary>,
- accounts: &(impl AccountRepository + ?Sized),
- app_state: &(impl AppStateRepository + ?Sized),
- secrets: &(impl SecretStore + ?Sized),
- journal: &(impl OperationJournal + ?Sized),
- clock: &(impl Clock + ?Sized),
- ) -> Result<(), SafeError> {
- let public_key = account.public_key();
- let previous_selection = app_state.load_selected_account()?;
- let operation = journal.begin_operation(kind, public_key, clock.now())?;
- if let Err(error) = secrets.put(public_key, secret) {
- let _ = journal.finalize_operation(operation);
- return Err(error);
- }
- if let Err(error) = journal.update_operation(
- operation,
- AccountOperationPhase::CredentialWritten,
- clock.now(),
- None,
- ) {
- return compensate_account_write(
- operation,
- public_key,
- error,
- None,
- previous_selection,
- accounts,
- app_state,
- secrets,
- journal,
- clock,
- );
- }
- let metadata_result = previous.map_or_else(
- || accounts.insert_account(account),
- |_| accounts.update_account(account),
- );
- if let Err(error) = metadata_result {
- return compensate_account_write(
- operation,
- public_key,
- error,
- previous,
- previous_selection,
- accounts,
- app_state,
- secrets,
- journal,
- clock,
- );
- }
- if let Err(error) = app_state.save_selected_account(Some(public_key)) {
- return compensate_account_write(
- operation,
- public_key,
- error,
- previous,
- previous_selection,
- accounts,
- app_state,
- secrets,
- journal,
- clock,
- );
- }
- journal.update_operation(
- operation,
- AccountOperationPhase::MetadataCommitted,
- clock.now(),
- None,
- )?;
- journal.finalize_operation(operation)
- }
-}
-
-#[allow(clippy::too_many_arguments)]
-fn compensate_account_write(
- operation: OperationId,
- public_key: PublicKey,
- original_error: SafeError,
- previous: Option<&AccountSummary>,
- previous_selection: Option<PublicKey>,
- accounts: &(impl AccountRepository + ?Sized),
- app_state: &(impl AppStateRepository + ?Sized),
- secrets: &(impl SecretStore + ?Sized),
- journal: &(impl OperationJournal + ?Sized),
- clock: &(impl Clock + ?Sized),
-) -> Result<(), SafeError> {
- let metadata_rollback = if let Some(previous) = previous {
- accounts.update_account(previous)
- } else {
- accounts.remove_account(public_key)
- };
- let selection_rollback = app_state.save_selected_account(previous_selection);
- let credential_rollback = secrets.delete(public_key);
- if metadata_rollback.is_err() || selection_rollback.is_err() || credential_rollback.is_err() {
- let _ = journal.update_operation(
- operation,
- AccountOperationPhase::CompensationPending,
- clock.now(),
- Some(OperationDiagnostic::CompensationFailed),
- );
- return Err(recovery_required());
- }
- let _ = journal.finalize_operation(operation);
- Err(original_error)
-}
-
-#[derive(Default)]
-pub struct InMemoryOperationJournal {
- state: Mutex<InMemoryJournalState>,
-}
-
-#[derive(Default)]
-struct InMemoryJournalState {
- next_id: u64,
- pending: Vec<PendingAccountOperation>,
-}
-
-impl OperationJournal for InMemoryOperationJournal {
- fn begin_operation(
- &self,
- kind: AccountOperationKind,
- subject: PublicKey,
- updated_at: radroots_studio_domain::UnixTimestamp,
- ) -> Result<OperationId, SafeError> {
- let mut state = self
- .state
- .lock()
- .unwrap_or_else(std::sync::PoisonError::into_inner);
- state.next_id = state.next_id.checked_add(1).ok_or_else(recovery_required)?;
- let id = OperationId::from_raw(state.next_id);
- state.pending.push(PendingAccountOperation::new(
- id,
- kind,
- subject,
- AccountOperationPhase::IntentRecorded,
- updated_at,
- None,
- ));
- Ok(id)
- }
-
- fn update_operation(
- &self,
- id: OperationId,
- phase: AccountOperationPhase,
- updated_at: radroots_studio_domain::UnixTimestamp,
- diagnostic: Option<OperationDiagnostic>,
- ) -> Result<(), SafeError> {
- let mut state = self
- .state
- .lock()
- .unwrap_or_else(std::sync::PoisonError::into_inner);
- let operation = state
- .pending
- .iter_mut()
- .find(|operation| operation.id() == id)
- .ok_or_else(recovery_required)?;
- *operation = PendingAccountOperation::new(
- id,
- operation.kind(),
- operation.subject(),
- phase,
- updated_at,
- diagnostic,
- );
- Ok(())
- }
-
- fn list_pending_operations(&self) -> Result<Vec<PendingAccountOperation>, SafeError> {
- Ok(self
- .state
- .lock()
- .unwrap_or_else(std::sync::PoisonError::into_inner)
- .pending
- .clone())
- }
-
- fn finalize_operation(&self, id: OperationId) -> Result<(), SafeError> {
- self.state
- .lock()
- .unwrap_or_else(std::sync::PoisonError::into_inner)
- .pending
- .retain(|operation| operation.id() != id);
- Ok(())
- }
-}
-
-#[derive(Default)]
-pub struct InMemoryAccountRepository {
- state: Mutex<InMemoryAccountState>,
-}
-
-#[derive(Default)]
-struct InMemoryAccountState {
- accounts: Vec<AccountSummary>,
- selected: Option<PublicKey>,
-}
-
-impl InMemoryAccountRepository {
- fn state(&self) -> MutexGuard<'_, InMemoryAccountState> {
- self.state
- .lock()
- .unwrap_or_else(std::sync::PoisonError::into_inner)
- }
-}
-
-impl AccountRepository for InMemoryAccountRepository {
- fn list_accounts(&self) -> Result<Vec<AccountSummary>, SafeError> {
- Ok(self.state().accounts.clone())
- }
-
- fn find_account(&self, public_key: PublicKey) -> Result<Option<AccountSummary>, SafeError> {
- Ok(self
- .state()
- .accounts
- .iter()
- .find(|account| account.public_key() == public_key)
- .cloned())
- }
-
- fn insert_account(&self, account: &AccountSummary) -> Result<(), SafeError> {
- let mut state = self.state();
- if state
- .accounts
- .iter()
- .any(|saved| saved.public_key() == account.public_key())
- {
- return Err(account_exists());
- }
- state.accounts.push(account.clone());
- state
- .accounts
- .sort_by_key(|saved| (saved.created_at().timestamp(), saved.public_key()));
- Ok(())
- }
-
- fn update_account(&self, account: &AccountSummary) -> Result<(), SafeError> {
- let mut state = self.state();
- let saved = state
- .accounts
- .iter_mut()
- .find(|saved| saved.public_key() == account.public_key())
- .ok_or_else(account_not_found)?;
- *saved = account.clone();
- Ok(())
- }
-
- fn remove_account(&self, public_key: PublicKey) -> Result<(), SafeError> {
- let mut state = self.state();
- state
- .accounts
- .retain(|account| account.public_key() != public_key);
- if state.selected == Some(public_key) {
- state.selected = None;
- }
- Ok(())
- }
-}
-
-impl AppStateRepository for InMemoryAccountRepository {
- fn load_selected_account(&self) -> Result<Option<PublicKey>, SafeError> {
- Ok(self.state().selected)
- }
-
- fn save_selected_account(&self, public_key: Option<PublicKey>) -> Result<(), SafeError> {
- let mut state = self.state();
- if public_key.is_some_and(|key| {
- !state
- .accounts
- .iter()
- .any(|account| account.public_key() == key)
- }) {
- return Err(account_not_found());
- }
- state.selected = public_key;
- Ok(())
- }
-}
-
-const fn account_exists() -> SafeError {
- SafeError::new(
- SafeErrorCode::AccountAlreadyExists,
- SafeMessage::new("The Nostr account is already saved."),
- )
-}
-
-const fn account_not_found() -> SafeError {
- SafeError::new(
- SafeErrorCode::AccountNotFound,
- SafeMessage::new("The account was not found."),
- )
-}
-
-const fn recovery_required() -> SafeError {
- SafeError::new(
- SafeErrorCode::PendingOperationRecoveryRequired,
- SafeMessage::new("Account recovery is required before this operation can continue."),
- )
-}
-
-const fn operation_conflict() -> SafeError {
- SafeError::new(
- SafeErrorCode::InvalidApplicationState,
- SafeMessage::new("The account operation conflicts with the current application state."),
- )
-}
-
-#[cfg(test)]
-mod tests {
- use std::sync::atomic::{AtomicBool, Ordering};
-
- use radroots_studio_domain::{
- AccountCreatedAt, AccountIdentity, AccountSummary, BindingAvailability, LocalSignerBinding,
- PublicKey, SafeError, SafeErrorCode, SafeMessage, SecretKeyInput, UnixTimestamp,
- };
-
- use super::InMemoryAccountRepository;
- use crate::{
- AccountOperationPhase, AccountRepository, AppCore, AppStateRepository, Clock,
- DurableOperationKind, DurableOperationPhase, FailureSecretStore, InMemoryOperationJournal,
- InMemorySecretStore, OperationJournal, ProfileRefreshStatus, ProfileRepository,
- RelayConfiguration, SecretStore, SecretStoreOperation, SessionState, StateTransition,
- recovery::tests::{TestDurableRepository, operation as durable_operation},
- };
-
- struct FixedClock;
-
- impl Clock for FixedClock {
- fn now(&self) -> UnixTimestamp {
- UnixTimestamp::from_seconds(10).expect("time")
- }
- }
-
- struct LateClock;
-
- impl Clock for LateClock {
- fn now(&self) -> UnixTimestamp {
- UnixTimestamp::from_seconds(311).expect("time")
- }
- }
-
- struct EmptyProfiles;
-
- impl ProfileRepository for EmptyProfiles {
- fn load_profile(
- &self,
- _public_key: PublicKey,
- ) -> Result<Option<crate::CachedProfile>, SafeError> {
- Ok(None)
- }
-
- fn save_profile(&self, _profile: &crate::CachedProfile) -> Result<(), SafeError> {
- Ok(())
- }
-
- fn record_refresh_status(
- &self,
- _public_key: PublicKey,
- _refreshed_at: UnixTimestamp,
- _status: ProfileRefreshStatus,
- ) -> Result<(), SafeError> {
- Ok(())
- }
-
- fn remove_profile(&self, _public_key: PublicKey) -> Result<(), SafeError> {
- Ok(())
- }
- }
-
- #[derive(Default)]
- struct FailingUpdateJournal(InMemoryOperationJournal);
-
- impl OperationJournal for FailingUpdateJournal {
- fn begin_operation(
- &self,
- kind: crate::AccountOperationKind,
- subject: PublicKey,
- updated_at: UnixTimestamp,
- ) -> Result<crate::OperationId, SafeError> {
- self.0.begin_operation(kind, subject, updated_at)
- }
-
- fn update_operation(
- &self,
- _id: crate::OperationId,
- _phase: AccountOperationPhase,
- _updated_at: UnixTimestamp,
- _diagnostic: Option<crate::OperationDiagnostic>,
- ) -> Result<(), SafeError> {
- Err(SafeError::new(
- SafeErrorCode::StorageUnavailable,
- SafeMessage::new("The test journal is unavailable."),
- ))
- }
-
- fn list_pending_operations(
- &self,
- ) -> Result<Vec<crate::PendingAccountOperation>, SafeError> {
- self.0.list_pending_operations()
- }
-
- fn finalize_operation(&self, id: crate::OperationId) -> Result<(), SafeError> {
- self.0.finalize_operation(id)
- }
- }
-
- #[derive(Default)]
- struct FailingInsertRepository {
- inner: InMemoryAccountRepository,
- }
-
- #[derive(Default)]
- struct FailingSelectionRepository {
- inner: InMemoryAccountRepository,
- fail_next_selection: AtomicBool,
- }
-
- impl AccountRepository for FailingSelectionRepository {
- fn list_accounts(&self) -> Result<Vec<AccountSummary>, SafeError> {
- self.inner.list_accounts()
- }
-
- fn find_account(&self, public_key: PublicKey) -> Result<Option<AccountSummary>, SafeError> {
- self.inner.find_account(public_key)
- }
-
- fn insert_account(&self, account: &AccountSummary) -> Result<(), SafeError> {
- self.inner.insert_account(account)
- }
-
- fn update_account(&self, account: &AccountSummary) -> Result<(), SafeError> {
- self.inner.update_account(account)
- }
-
- fn remove_account(&self, public_key: PublicKey) -> Result<(), SafeError> {
- self.inner.remove_account(public_key)
- }
- }
-
- impl AppStateRepository for FailingSelectionRepository {
- fn load_selected_account(&self) -> Result<Option<PublicKey>, SafeError> {
- self.inner.load_selected_account()
- }
-
- fn save_selected_account(&self, public_key: Option<PublicKey>) -> Result<(), SafeError> {
- if self.fail_next_selection.swap(false, Ordering::SeqCst) {
- return Err(SafeError::new(
- SafeErrorCode::StorageUnavailable,
- SafeMessage::new("The test selection repository is unavailable."),
- ));
- }
- self.inner.save_selected_account(public_key)
- }
- }
-
- impl AccountRepository for FailingInsertRepository {
- fn list_accounts(&self) -> Result<Vec<AccountSummary>, SafeError> {
- self.inner.list_accounts()
- }
-
- fn find_account(&self, public_key: PublicKey) -> Result<Option<AccountSummary>, SafeError> {
- self.inner.find_account(public_key)
- }
-
- fn insert_account(&self, _account: &AccountSummary) -> Result<(), SafeError> {
- Err(SafeError::new(
- SafeErrorCode::StorageUnavailable,
- SafeMessage::new("The test account repository is unavailable."),
- ))
- }
-
- fn update_account(&self, account: &AccountSummary) -> Result<(), SafeError> {
- self.inner.update_account(account)
- }
-
- fn remove_account(&self, public_key: PublicKey) -> Result<(), SafeError> {
- self.inner.remove_account(public_key)
- }
- }
-
- impl AppStateRepository for FailingInsertRepository {
- fn load_selected_account(&self) -> Result<Option<PublicKey>, SafeError> {
- self.inner.load_selected_account()
- }
-
- fn save_selected_account(&self, public_key: Option<PublicKey>) -> Result<(), SafeError> {
- self.inner.save_selected_account(public_key)
- }
- }
-
- #[test]
- fn generate_account_stores_selects_and_returns_one_time_nsec_without_activation() {
- let core = AppCore::in_memory(RelayConfiguration::default());
- let accounts = InMemoryAccountRepository::default();
- let secrets = InMemorySecretStore::default();
- let journal = InMemoryOperationJournal::default();
- core.bootstrap().expect("bootstrap");
-
- let receipt = core
- .generate_account(&accounts, &accounts, &secrets, &journal, &FixedClock)
- .expect("generate");
- let public_key = receipt.account().public_key();
- assert_eq!(public_key.to_hex().len(), 64);
- assert!(secrets.contains(public_key).expect("credential"));
- assert_eq!(
- accounts.load_selected_account().expect("selection"),
- Some(public_key)
- );
- assert_eq!(core.snapshot().selected_account(), Some(public_key));
- assert_eq!(core.snapshot().session(), SessionState::SignedOut);
- assert!(core.snapshot().active_account().is_none());
- assert_eq!(receipt.generated_nsec().with_exposed_secret(str::len), 63);
- assert!(!format!("{:?}", core.snapshot()).contains("nsec1"));
- }
-
- #[test]
- fn import_secret_key_accepts_nsec_and_hex_without_exposing_or_activating() {
- for input in [
- "nsec1vl029mgpspedva04g90vltkh6fvh240zqtv9k0t9af8935ke9laqsnlfe5",
- "7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7",
- ] {
- let core = AppCore::in_memory(RelayConfiguration::default());
- let accounts = InMemoryAccountRepository::default();
- let secrets = InMemorySecretStore::default();
- let journal = InMemoryOperationJournal::default();
- core.bootstrap().expect("bootstrap");
- let receipt = core
- .import_secret_key(
- SecretKeyInput::parse(input.to_owned()).expect("input"),
- &accounts,
- &accounts,
- &secrets,
- &journal,
- &FixedClock,
- )
- .expect("import");
- let public_key = receipt.account().public_key();
- assert!(secrets.contains(public_key).expect("credential"));
- assert_eq!(core.snapshot().selected_account(), Some(public_key));
- assert_eq!(core.snapshot().session(), SessionState::SignedOut);
- assert!(!format!("{:?}", core.snapshot()).contains(input));
- }
- }
-
- #[test]
- fn import_secret_key_rejects_invalid_nsec_checksum_before_persistence() {
- let core = AppCore::in_memory(RelayConfiguration::default());
- let accounts = InMemoryAccountRepository::default();
- let secrets = InMemorySecretStore::default();
- let journal = InMemoryOperationJournal::default();
- core.bootstrap().expect("bootstrap");
- let input = SecretKeyInput::parse(
- "nsec1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq".to_owned(),
- )
- .expect("domain shape");
- let error = core
- .import_secret_key(input, &accounts, &accounts, &secrets, &journal, &FixedClock)
- .expect_err("invalid import");
- assert_eq!(error.code(), SafeErrorCode::InvalidSecretKey);
- assert!(core.snapshot().accounts().is_empty());
- }
-
- #[test]
- fn duplicate_import_preserves_existing_credential_and_snapshot() {
- let core = AppCore::in_memory(RelayConfiguration::default());
- let accounts = InMemoryAccountRepository::default();
- let secrets = InMemorySecretStore::default();
- let journal = InMemoryOperationJournal::default();
- core.bootstrap().expect("bootstrap");
- let import = || {
- SecretKeyInput::parse(
- "7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7".to_owned(),
- )
- .expect("input")
- };
- core.import_secret_key(
- import(),
- &accounts,
- &accounts,
- &secrets,
- &journal,
- &FixedClock,
- )
- .expect("first import");
- let before = core.snapshot();
- let error = core
- .import_secret_key(
- import(),
- &accounts,
- &accounts,
- &secrets,
- &journal,
- &FixedClock,
- )
- .expect_err("duplicate");
- assert_eq!(error.code(), SafeErrorCode::AccountAlreadyExists);
- assert_eq!(core.snapshot(), before);
- assert_eq!(core.snapshot().accounts().len(), 1);
- }
-
- #[test]
- fn duplicate_import_repairs_only_explicit_missing_credential_account() {
- let core = AppCore::in_memory(RelayConfiguration::default());
- let accounts = InMemoryAccountRepository::default();
- let secrets = InMemorySecretStore::default();
- let journal = InMemoryOperationJournal::default();
- core.bootstrap().expect("bootstrap");
- let input = || {
- SecretKeyInput::parse(
- "7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7".to_owned(),
- )
- .expect("input")
- };
- let imported = core.key_material().import(input()).expect("derive");
- let (public_key, npub, _) = imported.into_parts();
- let missing = AccountSummary::new(
- AccountIdentity::verify(public_key, npub.as_str().to_owned()).expect("identity"),
- LocalSignerBinding::new(public_key, BindingAvailability::CredentialMissing),
- None,
- AccountCreatedAt::new(FixedClock.now()),
- None,
- )
- .expect("missing account");
- accounts.insert_account(&missing).expect("missing metadata");
- accounts
- .save_selected_account(Some(public_key))
- .expect("selection");
- core.apply_transition(StateTransition::ReplaceRegistry {
- accounts: vec![missing],
- selected: Some(public_key),
- })
- .expect("registry");
-
- let receipt = core
- .import_secret_key(
- input(),
- &accounts,
- &accounts,
- &secrets,
- &journal,
- &FixedClock,
- )
- .expect("repair");
- assert_eq!(
- receipt.account().signer().availability(),
- BindingAvailability::Available
- );
- assert!(secrets.contains(public_key).expect("credential"));
- assert_eq!(core.snapshot().accounts().len(), 1);
- }
-
- #[test]
- fn account_transaction_publishes_nothing_when_credential_write_fails() {
- let core = AppCore::in_memory(RelayConfiguration::default());
- let accounts = InMemoryAccountRepository::default();
- let secrets = FailureSecretStore::default();
- let journal = InMemoryOperationJournal::default();
- core.bootstrap().expect("bootstrap");
- secrets.fail_next(SecretStoreOperation::Put);
-
- let error = core
- .generate_account(&accounts, &accounts, &secrets, &journal, &FixedClock)
- .err()
- .expect("credential failure");
- assert_eq!(error.code(), SafeErrorCode::KeyringUnavailable);
- assert!(core.snapshot().accounts().is_empty());
- assert!(
- journal
- .list_pending_operations()
- .expect("journal")
- .is_empty()
- );
- }
-
- #[test]
- fn account_transaction_removes_written_credential_when_metadata_fails() {
- let core = AppCore::in_memory(RelayConfiguration::default());
- let accounts = FailingInsertRepository::default();
- let secrets = FailureSecretStore::default();
- let journal = InMemoryOperationJournal::default();
- core.bootstrap().expect("bootstrap");
-
- let error = core
- .generate_account(&accounts, &accounts, &secrets, &journal, &FixedClock)
- .err()
- .expect("metadata failure");
- assert_eq!(error.code(), SafeErrorCode::StorageUnavailable);
- let calls = secrets.calls();
- assert_eq!(calls[0].operation(), SecretStoreOperation::Put);
- assert_eq!(calls[1].operation(), SecretStoreOperation::Delete);
- assert_eq!(calls[0].public_key(), calls[1].public_key());
- assert!(core.snapshot().accounts().is_empty());
- assert!(
- journal
- .list_pending_operations()
- .expect("journal")
- .is_empty()
- );
- }
-
- #[test]
- fn account_transaction_rolls_back_metadata_and_credential_when_selection_fails() {
- let core = AppCore::in_memory(RelayConfiguration::default());
- let accounts = FailingSelectionRepository::default();
- let secrets = FailureSecretStore::default();
- let journal = InMemoryOperationJournal::default();
- core.bootstrap().expect("bootstrap");
- accounts.fail_next_selection.store(true, Ordering::SeqCst);
-
- let error = core
- .generate_account(&accounts, &accounts, &secrets, &journal, &FixedClock)
- .err()
- .expect("selection failure");
-
- assert_eq!(error.code(), SafeErrorCode::StorageUnavailable);
- assert!(accounts.list_accounts().expect("accounts").is_empty());
- assert_eq!(accounts.load_selected_account().expect("selection"), None);
- let calls = secrets.calls();
- assert_eq!(calls[0].operation(), SecretStoreOperation::Put);
- assert_eq!(calls[1].operation(), SecretStoreOperation::Delete);
- assert_eq!(calls[0].public_key(), calls[1].public_key());
- assert!(core.snapshot().accounts().is_empty());
- assert!(
- journal
- .list_pending_operations()
- .expect("journal")
- .is_empty()
- );
- }
-
- #[test]
- fn account_transaction_retains_non_secret_journal_when_compensation_fails() {
- let core = AppCore::in_memory(RelayConfiguration::default());
- let accounts = FailingInsertRepository::default();
- let secrets = FailureSecretStore::default();
- let journal = InMemoryOperationJournal::default();
- core.bootstrap().expect("bootstrap");
- secrets.fail_next(SecretStoreOperation::Delete);
-
- let error = core
- .generate_account(&accounts, &accounts, &secrets, &journal, &FixedClock)
- .err()
- .expect("recovery required");
- assert_eq!(
- error.code(),
- SafeErrorCode::PendingOperationRecoveryRequired
- );
- let pending = journal.list_pending_operations().expect("journal");
- assert_eq!(pending.len(), 1);
- assert_eq!(
- pending[0].phase(),
- AccountOperationPhase::CompensationPending
- );
- assert!(!format!("{pending:?}").contains("nsec1"));
- assert!(core.snapshot().accounts().is_empty());
- }
-
- #[test]
- fn select_account_persists_existing_choice_without_activating() {
- let core = AppCore::in_memory(RelayConfiguration::default());
- let accounts = InMemoryAccountRepository::default();
- let secrets = InMemorySecretStore::default();
- let journal = InMemoryOperationJournal::default();
- core.bootstrap().expect("bootstrap");
- let first = core
- .generate_account(&accounts, &accounts, &secrets, &journal, &FixedClock)
- .expect("first")
- .account()
- .public_key();
- core.generate_account(&accounts, &accounts, &secrets, &journal, &FixedClock)
- .expect("second");
-
- let selected = core
- .select_account(first, &accounts, &accounts)
- .expect("select first");
- assert_eq!(selected.selected_account(), Some(first));
- assert_eq!(selected.session(), SessionState::SignedOut);
- assert!(selected.active_account().is_none());
- assert_eq!(
- accounts.load_selected_account().expect("saved"),
- Some(first)
- );
- let missing = core
- .select_account(
- PublicKey::from_hex(
- "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af",
- )
- .expect("unknown public key"),
- &accounts,
- &accounts,
- )
- .expect_err("missing account");
- assert_eq!(missing.code(), SafeErrorCode::AccountNotFound);
- assert_eq!(core.snapshot(), selected);
- }
-
- #[test]
- fn remove_account_requires_fresh_single_use_confirmation_and_selects_next_fallback() {
- let core = AppCore::in_memory(RelayConfiguration::default());
- let accounts = InMemoryAccountRepository::default();
- let secrets = InMemorySecretStore::default();
- let journal = InMemoryOperationJournal::default();
- core.bootstrap().expect("bootstrap");
- let first = core
- .generate_account(&accounts, &accounts, &secrets, &journal, &FixedClock)
- .expect("first")
- .account()
- .public_key();
- let second = core
- .generate_account(&accounts, &accounts, &secrets, &journal, &FixedClock)
- .expect("second")
- .account()
- .public_key();
- core.select_account(first, &accounts, &accounts)
- .expect("select first");
- let stale = core
- .request_account_removal(first, &FixedClock)
- .expect("stale token");
- core.select_account(second, &accounts, &accounts)
- .expect("change revision");
- let stale_error = core
- .confirm_account_removal(stale, &accounts, &accounts, &secrets, &journal, &FixedClock)
- .expect_err("stale token");
- assert_eq!(stale_error.code(), SafeErrorCode::InvalidApplicationState);
- assert_eq!(core.snapshot().accounts().len(), 2);
-
- core.select_account(first, &accounts, &accounts)
- .expect("reselect first");
- let token = core
- .request_account_removal(first, &FixedClock)
- .expect("token");
- let removed = core
- .confirm_account_removal(token, &accounts, &accounts, &secrets, &journal, &FixedClock)
- .expect("remove");
- assert_eq!(removed.accounts().len(), 1);
- assert_eq!(removed.selected_account(), Some(second));
- assert!(!secrets.contains(first).expect("credential removed"));
- assert_eq!(removed.session(), SessionState::SignedOut);
- }
-
- #[test]
- fn removal_preflight_reports_impact_expires_and_can_be_cancelled() {
- let core = AppCore::in_memory(RelayConfiguration::default());
- let accounts = InMemoryAccountRepository::default();
- let secrets = InMemorySecretStore::default();
- let journal = InMemoryOperationJournal::default();
- core.bootstrap().expect("bootstrap");
- let account = core
- .generate_account(&accounts, &accounts, &secrets, &journal, &FixedClock)
- .expect("account")
- .account()
- .public_key();
- let expired = core
- .request_account_removal(account, &FixedClock)
- .expect("plan");
- assert!(expired.impact().deletes_local_credential());
- assert!(!expired.impact().signs_out());
- assert!(
- core.confirm_account_removal(
- expired, &accounts, &accounts, &secrets, &journal, &LateClock,
- )
- .is_err()
- );
- let cancelled = core
- .request_account_removal(account, &FixedClock)
- .expect("replacement plan");
- assert!(core.cancel_account_removal(cancelled));
- assert_eq!(core.snapshot().accounts().len(), 1);
- }
-
- #[test]
- fn import_rejects_orphan_credentials_and_durable_nonterminal_replays() {
- const SECRET: &str = "7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7";
- let core = AppCore::in_memory(RelayConfiguration::default());
- let accounts = InMemoryAccountRepository::default();
- let secrets = InMemorySecretStore::default();
- let journal = InMemoryOperationJournal::default();
- core.bootstrap().expect("bootstrap");
- let material = core
- .key_material()
- .import(SecretKeyInput::parse(SECRET.to_owned()).expect("secret"))
- .expect("key material");
- let (public_key, _npub, secret) = material.into_parts();
- secrets.put(public_key, secret).expect("orphan credential");
-
- assert_eq!(
- core.import_secret_key(
- SecretKeyInput::parse(SECRET.to_owned()).expect("secret"),
- &accounts,
- &accounts,
- &secrets,
- &journal,
- &FixedClock,
- )
- .expect_err("orphan credential must fail")
- .code(),
- SafeErrorCode::AccountAlreadyExists
- );
-
- let pending = durable_operation(
- DurableOperationKind::Import,
- DurableOperationPhase::IntentRecorded,
- public_key,
- None,
- );
- let request_id = pending.request_id().clone();
- let operations = TestDurableRepository::new(pending);
- assert_eq!(
- core.import_secret_key_durable(
- &request_id,
- core.snapshot().revision().value(),
- SecretKeyInput::parse(SECRET.to_owned()).expect("secret"),
- &accounts,
- &accounts,
- &secrets,
- &operations,
- &FixedClock,
- )
- .expect_err("unfinished replay must require recovery")
- .code(),
- SafeErrorCode::PendingOperationRecoveryRequired
- );
- }
-
- #[test]
- fn durable_import_covers_new_and_missing_credential_repair_paths() {
- const SECRET: &str = "7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7";
- for repair in [false, true] {
- let core = AppCore::in_memory(RelayConfiguration::default());
- let accounts = InMemoryAccountRepository::default();
- let secrets = InMemorySecretStore::default();
- let material = core
- .key_material()
- .import(SecretKeyInput::parse(SECRET.to_owned()).expect("secret"))
- .expect("key material");
- let (public_key, npub, secret) = material.into_parts();
- drop(secret);
- if repair {
- let account = AccountSummary::new(
- AccountIdentity::verify(public_key, npub.as_str().to_owned())
- .expect("identity"),
- LocalSignerBinding::new(public_key, BindingAvailability::CredentialMissing),
- None,
- AccountCreatedAt::new(FixedClock.now()),
- None,
- )
- .expect("account");
- accounts.insert_account(&account).expect("insert account");
- accounts
- .save_selected_account(Some(public_key))
- .expect("selection");
- core.apply_transition(StateTransition::BootstrapRegistry {
- accounts: vec![account],
- selected: Some(public_key),
- })
- .expect("registry");
- } else {
- core.bootstrap().expect("bootstrap");
- }
- let kind = if repair {
- DurableOperationKind::Repair
- } else {
- DurableOperationKind::Import
- };
- let pending = durable_operation(
- kind,
- DurableOperationPhase::IntentRecorded,
- public_key,
- repair.then_some(BindingAvailability::CredentialMissing),
- );
- let request_id = pending.request_id().clone();
- let operations = TestDurableRepository::fresh(pending);
- let receipt = core
- .import_secret_key_durable(
- &request_id,
- core.snapshot().revision().value(),
- SecretKeyInput::parse(SECRET.to_owned()).expect("secret"),
- &accounts,
- &accounts,
- &secrets,
- &operations,
- &FixedClock,
- )
- .expect("durable import");
- assert_eq!(receipt.account().public_key(), public_key);
- assert_eq!(
- operations.operation().phase(),
- DurableOperationPhase::Finalized
- );
- }
- }
-
- #[test]
- fn removal_of_unselected_account_preserves_the_current_selection() {
- let core = AppCore::in_memory(RelayConfiguration::default());
- let accounts = InMemoryAccountRepository::default();
- let secrets = InMemorySecretStore::default();
- let journal = InMemoryOperationJournal::default();
- core.bootstrap().expect("bootstrap");
- let first = core
- .generate_account(&accounts, &accounts, &secrets, &journal, &FixedClock)
- .expect("first")
- .account()
- .public_key();
- let second = core
- .generate_account(&accounts, &accounts, &secrets, &journal, &FixedClock)
- .expect("second")
- .account()
- .public_key();
- let token = core
- .request_account_removal(first, &FixedClock)
- .expect("removal token");
- let snapshot = core
- .confirm_account_removal(token, &accounts, &accounts, &secrets, &journal, &FixedClock)
- .expect("remove unselected account");
- assert_eq!(snapshot.selected_account(), Some(second));
-
- let missing = crate::test_support::valid_test_public_key(99).expect("missing key");
- assert!(accounts.insert_account(&snapshot.accounts()[0]).is_err());
- assert!(accounts.save_selected_account(Some(missing)).is_err());
-
- let third = core
- .generate_account(&accounts, &accounts, &secrets, &journal, &FixedClock)
- .expect("third")
- .account()
- .public_key();
- let token = core
- .request_account_removal(second, &FixedClock)
- .expect("durable removal token");
- let pending = durable_operation(
- DurableOperationKind::Remove,
- DurableOperationPhase::IntentRecorded,
- second,
- Some(BindingAvailability::Available),
- );
- let request_id = pending.request_id().clone();
- let operations = TestDurableRepository::fresh(pending);
- let snapshot = core
- .confirm_account_removal_durable(
- &request_id,
- token,
- &accounts,
- &accounts,
- &secrets,
- &operations,
- &FixedClock,
- )
- .expect("durable unselected removal");
- assert_eq!(snapshot.selected_account(), Some(third));
- }
-
- #[test]
- fn duplicate_missing_binding_with_orphan_credential_fails_closed() {
- const SECRET: &str = "7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7";
- let core = AppCore::in_memory(RelayConfiguration::default());
- let accounts = InMemoryAccountRepository::default();
- let secrets = InMemorySecretStore::default();
- let journal = InMemoryOperationJournal::default();
- let material = core
- .key_material()
- .import(SecretKeyInput::parse(SECRET.to_owned()).expect("secret"))
- .expect("key material");
- let (public_key, npub, secret) = material.into_parts();
- let account = AccountSummary::new(
- AccountIdentity::verify(public_key, npub.as_str().to_owned()).expect("identity"),
- LocalSignerBinding::new(public_key, BindingAvailability::CredentialMissing),
- None,
- AccountCreatedAt::new(FixedClock.now()),
- None,
- )
- .expect("account");
- accounts.insert_account(&account).expect("insert account");
- accounts
- .save_selected_account(Some(public_key))
- .expect("selection");
- secrets.put(public_key, secret).expect("credential");
- core.apply_transition(StateTransition::BootstrapRegistry {
- accounts: vec![account],
- selected: Some(public_key),
- })
- .expect("registry");
-
- assert_eq!(
- core.import_secret_key(
- SecretKeyInput::parse(SECRET.to_owned()).expect("secret"),
- &accounts,
- &accounts,
- &secrets,
- &journal,
- &FixedClock,
- )
- .expect_err("orphan credential must fail")
- .code(),
- SafeErrorCode::AccountAlreadyExists
- );
- let pending = durable_operation(
- DurableOperationKind::Repair,
- DurableOperationPhase::IntentRecorded,
- public_key,
- Some(BindingAvailability::CredentialMissing),
- );
- let request_id = pending.request_id().clone();
- let operations = TestDurableRepository::fresh(pending);
- assert_eq!(
- core.import_secret_key_durable(
- &request_id,
- core.snapshot().revision().value(),
- SecretKeyInput::parse(SECRET.to_owned()).expect("secret"),
- &accounts,
- &accounts,
- &secrets,
- &operations,
- &FixedClock,
- )
- .expect_err("orphan durable credential must fail")
- .code(),
- SafeErrorCode::AccountAlreadyExists
- );
- }
-
- #[test]
- fn removing_an_active_account_signs_out_for_legacy_and_durable_requests() {
- for durable in [false, true] {
- let core = AppCore::in_memory(RelayConfiguration::default());
- let accounts = InMemoryAccountRepository::default();
- let secrets = InMemorySecretStore::default();
- let journal = InMemoryOperationJournal::default();
- core.bootstrap().expect("bootstrap");
- let public_key = core
- .import_secret_key(
- SecretKeyInput::parse(
- "7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7"
- .to_owned(),
- )
- .expect("secret"),
- &accounts,
- &accounts,
- &secrets,
- &journal,
- &FixedClock,
- )
- .expect("account")
- .account()
- .public_key();
- core.activate_account(
- public_key,
- &accounts,
- &accounts,
- &EmptyProfiles,
- &secrets,
- &FixedClock,
- )
- .expect("activate account");
- let token = core
- .request_account_removal(public_key, &FixedClock)
- .expect("removal token");
- let snapshot = if durable {
- let pending = durable_operation(
- DurableOperationKind::Remove,
- DurableOperationPhase::IntentRecorded,
- public_key,
- Some(BindingAvailability::Available),
- );
- let request_id = pending.request_id().clone();
- let operations = TestDurableRepository::fresh(pending);
- core.confirm_account_removal_durable(
- &request_id,
- token,
- &accounts,
- &accounts,
- &secrets,
- &operations,
- &FixedClock,
- )
- .expect("durable removal")
- } else {
- core.confirm_account_removal(
- token,
- &accounts,
- &accounts,
- &secrets,
- &journal,
- &FixedClock,
- )
- .expect("removal")
- };
- assert_eq!(snapshot.session(), SessionState::SignedOut);
- }
- }
-
- #[test]
- fn account_transaction_compensates_a_journal_phase_failure() {
- let core = AppCore::in_memory(RelayConfiguration::default());
- let accounts = InMemoryAccountRepository::default();
- let secrets = InMemorySecretStore::default();
- let journal = FailingUpdateJournal::default();
- core.bootstrap().expect("bootstrap");
-
- assert_eq!(
- core.generate_account(&accounts, &accounts, &secrets, &journal, &FixedClock)
- .err()
- .expect("journal failure must be returned")
- .code(),
- SafeErrorCode::StorageUnavailable
- );
- assert!(accounts.list_accounts().unwrap().is_empty());
- }
-}
diff --git a/crates/studio_application/src/actor.rs b/crates/studio_application/src/actor.rs
@@ -1,787 +0,0 @@
-use std::num::{NonZeroU64, NonZeroUsize};
-use std::time::Instant;
-
-use radroots_studio_domain::{
- AccountIdentity, BindingAvailability, LocalSignerBinding, PublicKey, SafeError, SafeErrorCode,
- SafeMessage,
-};
-use tokio::sync::{mpsc, oneshot};
-
-use crate::SnapshotRevision;
-
-#[derive(Clone, Copy, Debug, Default, Eq, Hash, Ord, PartialEq, PartialOrd)]
-pub struct SessionGeneration(u64);
-
-impl SessionGeneration {
- #[must_use]
- pub const fn initial() -> Self {
- Self(0)
- }
-
- #[must_use]
- pub const fn from_value(value: u64) -> Self {
- Self(value)
- }
-
- #[must_use]
- pub const fn value(self) -> u64 {
- self.0
- }
-
- #[must_use]
- pub const fn next(self) -> Option<Self> {
- match self.0.checked_add(1) {
- Some(value) => Some(Self(value)),
- None => None,
- }
- }
-}
-
-#[derive(Clone, Debug, Eq, PartialEq)]
-pub struct ForegroundSessionBinding {
- identity: AccountIdentity,
- signer: LocalSignerBinding,
- generation: SessionGeneration,
-}
-
-impl ForegroundSessionBinding {
- /// Binds one foreground session to a ready local signer and generation.
- ///
- /// # Errors
- ///
- /// Returns a safe state error when account and binding differ or when the
- /// signer is unavailable.
- pub fn new(
- identity: AccountIdentity,
- signer: LocalSignerBinding,
- generation: SessionGeneration,
- ) -> Result<Self, SafeError> {
- if identity.public_key() != signer.account()
- || signer.availability() != BindingAvailability::Available
- {
- return Err(invalid_foreground_session());
- }
- Ok(Self {
- identity,
- signer,
- generation,
- })
- }
-
- #[must_use]
- pub const fn identity(&self) -> &AccountIdentity {
- &self.identity
- }
-
- #[must_use]
- pub const fn signer(&self) -> LocalSignerBinding {
- self.signer
- }
-
- #[must_use]
- pub const fn generation(&self) -> SessionGeneration {
- self.generation
- }
-}
-
-const fn invalid_foreground_session() -> SafeError {
- SafeError::new(
- SafeErrorCode::InvalidApplicationState,
- SafeMessage::new("The foreground session binding is invalid."),
- )
-}
-
-#[derive(Clone, Copy, Debug, Eq, PartialEq)]
-pub struct TaskCorrelation {
- request_id: RequestId,
- account: PublicKey,
- binding: LocalSignerBinding,
- expected_revision: SnapshotRevision,
- session_generation: SessionGeneration,
-}
-
-impl TaskCorrelation {
- #[must_use]
- pub const fn new(
- request_id: RequestId,
- account: PublicKey,
- binding: LocalSignerBinding,
- expected_revision: SnapshotRevision,
- session_generation: SessionGeneration,
- ) -> Self {
- Self {
- request_id,
- account,
- binding,
- expected_revision,
- session_generation,
- }
- }
-
- #[must_use]
- pub const fn request_id(self) -> RequestId {
- self.request_id
- }
-
- #[must_use]
- pub const fn account(self) -> PublicKey {
- self.account
- }
-
- #[must_use]
- pub const fn binding(self) -> LocalSignerBinding {
- self.binding
- }
-
- #[must_use]
- pub const fn expected_revision(self) -> SnapshotRevision {
- self.expected_revision
- }
-
- #[must_use]
- pub const fn session_generation(self) -> SessionGeneration {
- self.session_generation
- }
-}
-
-#[derive(Clone, Copy, Debug, Eq, PartialEq)]
-pub enum RuntimeLifecycle {
- Opening,
- CompatibilityChecking,
- AcquiringOwnership,
- Migrating,
- Recovering,
- Ready,
- Degraded(SafeError),
- Blocked(SafeError),
- ShuttingDown,
- Closed,
- Fatal(SafeError),
-}
-
-#[derive(Clone, Copy, Debug, Eq, PartialEq)]
-pub enum RuntimeCommandClass {
- Observe,
- MutateLocalState,
- UseCredential,
- UseRelay,
- RetryOpening,
- Shutdown,
-}
-
-#[derive(Clone, Copy, Debug, Eq, PartialEq)]
-pub struct LifecycleGate {
- lifecycle: RuntimeLifecycle,
-}
-
-impl Default for LifecycleGate {
- fn default() -> Self {
- Self::opening()
- }
-}
-
-impl LifecycleGate {
- #[must_use]
- pub const fn opening() -> Self {
- Self {
- lifecycle: RuntimeLifecycle::Opening,
- }
- }
-
- #[must_use]
- pub const fn lifecycle(self) -> RuntimeLifecycle {
- self.lifecycle
- }
-
- #[must_use]
- pub const fn allows(self, command: RuntimeCommandClass) -> bool {
- match self.lifecycle {
- RuntimeLifecycle::Opening
- | RuntimeLifecycle::CompatibilityChecking
- | RuntimeLifecycle::AcquiringOwnership
- | RuntimeLifecycle::Migrating
- | RuntimeLifecycle::Recovering => {
- matches!(
- command,
- RuntimeCommandClass::Observe | RuntimeCommandClass::Shutdown
- )
- }
- RuntimeLifecycle::Ready => !matches!(command, RuntimeCommandClass::RetryOpening),
- RuntimeLifecycle::Degraded(_) => !matches!(
- command,
- RuntimeCommandClass::UseRelay | RuntimeCommandClass::RetryOpening
- ),
- RuntimeLifecycle::Blocked(_) => matches!(
- command,
- RuntimeCommandClass::Observe
- | RuntimeCommandClass::RetryOpening
- | RuntimeCommandClass::Shutdown
- ),
- RuntimeLifecycle::ShuttingDown => matches!(command, RuntimeCommandClass::Observe),
- RuntimeLifecycle::Closed | RuntimeLifecycle::Fatal(_) => false,
- }
- }
-
- /// Advances the required open sequence to compatibility checking.
- ///
- /// # Errors
- ///
- /// Returns a safe lifecycle error when the stage is out of order.
- pub fn begin_compatibility_check(&mut self) -> Result<(), SafeError> {
- self.advance(
- RuntimeLifecycle::Opening,
- RuntimeLifecycle::CompatibilityChecking,
- )
- }
-
- /// Records compatibility acceptance and begins ownership acquisition.
- ///
- /// # Errors
- ///
- /// Returns a safe lifecycle error when the stage is out of order.
- pub fn compatibility_accepted(&mut self) -> Result<(), SafeError> {
- self.advance(
- RuntimeLifecycle::CompatibilityChecking,
- RuntimeLifecycle::AcquiringOwnership,
- )
- }
-
- /// Records exclusive ownership and begins migration.
- ///
- /// # Errors
- ///
- /// Returns a safe lifecycle error when the stage is out of order.
- pub fn ownership_acquired(&mut self) -> Result<(), SafeError> {
- self.advance(
- RuntimeLifecycle::AcquiringOwnership,
- RuntimeLifecycle::Migrating,
- )
- }
-
- /// Records migration completion and begins recovery.
- ///
- /// # Errors
- ///
- /// Returns a safe lifecycle error when the stage is out of order.
- pub fn migration_complete(&mut self) -> Result<(), SafeError> {
- self.advance(RuntimeLifecycle::Migrating, RuntimeLifecycle::Recovering)
- }
-
- /// Records recovery completion and admits normal commands.
- ///
- /// # Errors
- ///
- /// Returns a safe lifecycle error when the stage is out of order.
- pub fn recovery_complete(&mut self) -> Result<(), SafeError> {
- self.advance(RuntimeLifecycle::Recovering, RuntimeLifecycle::Ready)
- }
-
- pub fn block(&mut self, error: SafeError) {
- self.lifecycle = RuntimeLifecycle::Blocked(error);
- }
-
- pub fn fail(&mut self, error: SafeError) {
- self.lifecycle = RuntimeLifecycle::Fatal(error);
- }
-
- /// Moves a ready runtime into a nonfatal degraded state.
- ///
- /// # Errors
- ///
- /// Returns a safe lifecycle error when the runtime is not ready.
- pub fn degrade(&mut self, error: SafeError) -> Result<(), SafeError> {
- self.advance(RuntimeLifecycle::Ready, RuntimeLifecycle::Degraded(error))
- }
-
- /// Restores local and relay command availability after degradation.
- ///
- /// # Errors
- ///
- /// Returns a safe lifecycle error when the runtime is not degraded.
- pub fn restore_ready(&mut self) -> Result<(), SafeError> {
- if !matches!(self.lifecycle, RuntimeLifecycle::Degraded(_)) {
- return Err(invalid_lifecycle_transition());
- }
- self.lifecycle = RuntimeLifecycle::Ready;
- Ok(())
- }
-
- /// Begins actor-owned shutdown.
- ///
- /// # Errors
- ///
- /// Returns a safe lifecycle error after shutdown or close has begun.
- pub fn begin_shutdown(&mut self) -> Result<(), SafeError> {
- if matches!(
- self.lifecycle,
- RuntimeLifecycle::ShuttingDown | RuntimeLifecycle::Closed
- ) {
- return Err(invalid_lifecycle_transition());
- }
- self.lifecycle = RuntimeLifecycle::ShuttingDown;
- Ok(())
- }
-
- /// Completes actor-owned shutdown.
- ///
- /// # Errors
- ///
- /// Returns a safe lifecycle error unless shutdown already began.
- pub fn finish_shutdown(&mut self) -> Result<(), SafeError> {
- self.advance(RuntimeLifecycle::ShuttingDown, RuntimeLifecycle::Closed)
- }
-
- fn advance(
- &mut self,
- expected: RuntimeLifecycle,
- next: RuntimeLifecycle,
- ) -> Result<(), SafeError> {
- if self.lifecycle != expected {
- return Err(invalid_lifecycle_transition());
- }
- self.lifecycle = next;
- Ok(())
- }
-}
-
-const fn invalid_lifecycle_transition() -> SafeError {
- SafeError::new(
- radroots_studio_domain::SafeErrorCode::InvalidApplicationState,
- radroots_studio_domain::SafeMessage::new("The runtime lifecycle transition is invalid."),
- )
-}
-
-#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
-pub struct RequestId(NonZeroU64);
-
-impl RequestId {
- #[must_use]
- pub const fn new(value: u64) -> Option<Self> {
- match NonZeroU64::new(value) {
- Some(value) => Some(Self(value)),
- None => None,
- }
- }
-
- #[must_use]
- pub const fn get(self) -> u64 {
- self.0.get()
- }
-}
-
-#[derive(Clone, Copy, Debug, Eq, PartialEq)]
-pub struct CommandContext {
- request_id: RequestId,
- expected_revision: Option<SnapshotRevision>,
- deadline: Instant,
-}
-
-impl CommandContext {
- #[must_use]
- pub const fn new(
- request_id: RequestId,
- expected_revision: Option<SnapshotRevision>,
- deadline: Instant,
- ) -> Self {
- Self {
- request_id,
- expected_revision,
- deadline,
- }
- }
-
- #[must_use]
- pub const fn request_id(self) -> RequestId {
- self.request_id
- }
-
- #[must_use]
- pub const fn expected_revision(self) -> Option<SnapshotRevision> {
- self.expected_revision
- }
-
- #[must_use]
- pub const fn deadline(self) -> Instant {
- self.deadline
- }
-
- #[must_use]
- pub fn is_expired(self, now: Instant) -> bool {
- now >= self.deadline
- }
-}
-
-#[derive(Clone, Copy, Debug, Eq, PartialEq)]
-pub enum CommandRejection {
- MailboxSaturated,
-}
-
-#[derive(Clone, Debug, Eq, PartialEq)]
-pub enum CommandResult<T> {
- Completed(T),
- Rejected(CommandRejection),
- Conflicted { current_revision: SnapshotRevision },
- TimedOut,
- Closed,
- Failed(SafeError),
-}
-
-#[derive(Clone, Debug, Eq, PartialEq)]
-pub struct CommandReceipt<T> {
- request_id: RequestId,
- result: CommandResult<T>,
-}
-
-impl<T> CommandReceipt<T> {
- #[must_use]
- pub const fn new(request_id: RequestId, result: CommandResult<T>) -> Self {
- Self { request_id, result }
- }
-
- #[must_use]
- pub const fn request_id(&self) -> RequestId {
- self.request_id
- }
-
- #[must_use]
- pub const fn result(&self) -> &CommandResult<T> {
- &self.result
- }
-
- #[must_use]
- pub fn into_result(self) -> CommandResult<T> {
- self.result
- }
-}
-
-pub struct CommandTicket<T> {
- request_id: RequestId,
- receiver: oneshot::Receiver<CommandReceipt<T>>,
-}
-
-impl<T> CommandTicket<T> {
- #[must_use]
- pub const fn request_id(&self) -> RequestId {
- self.request_id
- }
-
- pub async fn receipt(self) -> CommandReceipt<T> {
- self.receiver
- .await
- .unwrap_or_else(|_| CommandReceipt::new(self.request_id, CommandResult::Closed))
- }
-}
-
-pub enum CommandSubmission<T> {
- Accepted(CommandTicket<T>),
- Rejected(CommandReceipt<T>),
-}
-
-impl<T> CommandSubmission<T> {
- #[must_use]
- pub const fn request_id(&self) -> RequestId {
- match self {
- Self::Accepted(ticket) => ticket.request_id(),
- Self::Rejected(receipt) => receipt.request_id(),
- }
- }
-}
-
-pub struct CommandEnvelope<C, R> {
- context: CommandContext,
- command: C,
- reply: oneshot::Sender<CommandReceipt<R>>,
-}
-
-impl<C, R> CommandEnvelope<C, R> {
- #[must_use]
- pub const fn context(&self) -> CommandContext {
- self.context
- }
-
- #[must_use]
- pub const fn command(&self) -> &C {
- &self.command
- }
-
- #[must_use]
- pub fn into_parts(self) -> (CommandContext, C, oneshot::Sender<CommandReceipt<R>>) {
- (self.context, self.command, self.reply)
- }
-}
-
-pub struct ActorMailbox<C, R> {
- sender: mpsc::Sender<CommandEnvelope<C, R>>,
-}
-
-impl<C, R> Clone for ActorMailbox<C, R> {
- fn clone(&self) -> Self {
- Self {
- sender: self.sender.clone(),
- }
- }
-}
-
-impl<C, R> ActorMailbox<C, R> {
- #[must_use]
- pub fn bounded(capacity: NonZeroUsize) -> (Self, mpsc::Receiver<CommandEnvelope<C, R>>) {
- let (sender, receiver) = mpsc::channel(capacity.get());
- (Self { sender }, receiver)
- }
-
- #[must_use]
- pub fn available_capacity(&self) -> usize {
- self.sender.capacity()
- }
-
- #[must_use]
- pub fn submit(&self, context: CommandContext, command: C) -> CommandSubmission<R> {
- let request_id = context.request_id();
- if context.is_expired(Instant::now()) {
- return CommandSubmission::Rejected(CommandReceipt::new(
- request_id,
- CommandResult::TimedOut,
- ));
- }
- let (reply, receiver) = oneshot::channel();
- let envelope = CommandEnvelope {
- context,
- command,
- reply,
- };
- match self.sender.try_send(envelope) {
- Ok(()) => CommandSubmission::Accepted(CommandTicket {
- request_id,
- receiver,
- }),
- Err(mpsc::error::TrySendError::Full(_)) => {
- CommandSubmission::Rejected(CommandReceipt::new(
- request_id,
- CommandResult::Rejected(CommandRejection::MailboxSaturated),
- ))
- }
- Err(mpsc::error::TrySendError::Closed(_)) => {
- CommandSubmission::Rejected(CommandReceipt::new(request_id, CommandResult::Closed))
- }
- }
- }
-}
-
-#[cfg(test)]
-mod tests {
- use std::num::NonZeroUsize;
- use std::time::{Duration, Instant};
-
- use radroots_studio_domain::{AccountIdentity, BindingAvailability, LocalSignerBinding};
-
- use crate::{
- ActorMailbox, CommandContext, CommandReceipt, CommandRejection, CommandResult,
- CommandSubmission, ForegroundSessionBinding, LifecycleGate, RequestId, RuntimeCommandClass,
- RuntimeLifecycle, SessionGeneration,
- };
-
- fn context(id: u64) -> CommandContext {
- CommandContext::new(
- RequestId::new(id).expect("nonzero request"),
- None,
- Instant::now() + Duration::from_secs(1),
- )
- }
-
- #[test]
- fn foreground_session_requires_matching_available_binding_and_generation() {
- let public_key = crate::test_support::valid_test_public_key(3).expect("valid public key");
- let identity = AccountIdentity::derive(public_key).expect("identity");
- let generation = SessionGeneration::from_value(4);
- let session = ForegroundSessionBinding::new(
- identity.clone(),
- LocalSignerBinding::new(public_key, BindingAvailability::Available),
- generation,
- )
- .expect("session");
- assert_eq!(session.identity(), &identity);
- assert_eq!(session.signer().account(), public_key);
- assert_eq!(session.generation(), generation);
-
- let correlation = super::TaskCorrelation::new(
- RequestId::new(8).expect("request"),
- public_key,
- session.signer(),
- crate::SnapshotRevision::from_value(9),
- generation,
- );
- assert_eq!(correlation.request_id().get(), 8);
- assert_eq!(correlation.account(), public_key);
- assert_eq!(correlation.binding(), session.signer());
- assert_eq!(correlation.expected_revision().value(), 9);
- assert_eq!(correlation.session_generation(), generation);
-
- assert!(
- ForegroundSessionBinding::new(
- identity.clone(),
- LocalSignerBinding::new(
- radroots_studio_domain::PublicKey::from_hex(
- "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af",
- )
- .expect("different valid public key"),
- BindingAvailability::Available,
- ),
- generation,
- )
- .is_err()
- );
- assert!(
- ForegroundSessionBinding::new(
- identity,
- LocalSignerBinding::new(public_key, BindingAvailability::CredentialMissing),
- generation,
- )
- .is_err()
- );
- }
-
- #[tokio::test]
- async fn bounded_mailbox_accepts_one_and_rejects_saturation() {
- let (mailbox, mut receiver) =
- ActorMailbox::<u8, u8>::bounded(NonZeroUsize::new(1).expect("capacity"));
- let CommandSubmission::Accepted(ticket) = mailbox.submit(context(1), 7) else {
- panic!("first command must be accepted");
- };
- let CommandSubmission::Rejected(rejected) = mailbox.submit(context(2), 8) else {
- panic!("second command must be rejected");
- };
- assert_eq!(
- rejected.into_result(),
- CommandResult::Rejected(CommandRejection::MailboxSaturated)
- );
-
- let envelope = receiver.recv().await.expect("command");
- assert_eq!(envelope.context().request_id().get(), 1);
- assert_eq!(*envelope.command(), 7);
- let (context, command, reply) = envelope.into_parts();
- reply
- .send(CommandReceipt::new(
- context.request_id(),
- CommandResult::Completed(command + 1),
- ))
- .expect("ticket remains open");
- assert_eq!(
- ticket.receipt().await.into_result(),
- CommandResult::Completed(8)
- );
- }
-
- #[test]
- fn expired_and_closed_mailboxes_reject_without_enqueuing() {
- let (mailbox, receiver) =
- ActorMailbox::<u8, u8>::bounded(NonZeroUsize::new(1).expect("capacity"));
- let expired =
- CommandContext::new(RequestId::new(1).expect("request"), None, Instant::now());
- let CommandSubmission::Rejected(receipt) = mailbox.submit(expired, 1) else {
- panic!("expired command must be rejected");
- };
- assert_eq!(receipt.into_result(), CommandResult::TimedOut);
-
- drop(receiver);
- let CommandSubmission::Rejected(receipt) = mailbox.submit(context(2), 2) else {
- panic!("closed mailbox must be rejected");
- };
- assert_eq!(receipt.into_result(), CommandResult::Closed);
- }
-
- #[tokio::test]
- async fn dropped_actor_reply_becomes_closed_receipt() {
- let (mailbox, mut receiver) =
- ActorMailbox::<u8, u8>::bounded(NonZeroUsize::new(1).expect("capacity"));
- let CommandSubmission::Accepted(ticket) = mailbox.submit(context(1), 1) else {
- panic!("command must be accepted");
- };
- drop(receiver.recv().await.expect("command"));
-
- assert_eq!(ticket.receipt().await.into_result(), CommandResult::Closed);
- }
-
- #[test]
- fn opening_sequence_gates_mutation_until_recovery_completes() {
- let mut lifecycle = LifecycleGate::opening();
- for expected in [
- RuntimeLifecycle::Opening,
- RuntimeLifecycle::CompatibilityChecking,
- RuntimeLifecycle::AcquiringOwnership,
- RuntimeLifecycle::Migrating,
- RuntimeLifecycle::Recovering,
- ] {
- assert_eq!(lifecycle.lifecycle(), expected);
- assert!(lifecycle.allows(RuntimeCommandClass::Observe));
- assert!(lifecycle.allows(RuntimeCommandClass::Shutdown));
- assert!(!lifecycle.allows(RuntimeCommandClass::MutateLocalState));
- match expected {
- RuntimeLifecycle::Opening => {
- lifecycle
- .begin_compatibility_check()
- .expect("compatibility");
- }
- RuntimeLifecycle::CompatibilityChecking => {
- lifecycle
- .compatibility_accepted()
- .expect("compatibility accepted");
- }
- RuntimeLifecycle::AcquiringOwnership => {
- lifecycle.ownership_acquired().expect("ownership");
- }
- RuntimeLifecycle::Migrating => {
- lifecycle.migration_complete().expect("migration");
- }
- RuntimeLifecycle::Recovering => {
- lifecycle.recovery_complete().expect("recovery");
- }
- _ => unreachable!("opening states only"),
- }
- }
- assert_eq!(lifecycle.lifecycle(), RuntimeLifecycle::Ready);
- assert!(lifecycle.allows(RuntimeCommandClass::MutateLocalState));
- assert!(lifecycle.allows(RuntimeCommandClass::UseCredential));
- assert!(lifecycle.allows(RuntimeCommandClass::UseRelay));
- }
-
- #[test]
- fn blocked_degraded_fatal_and_closed_states_fail_safe() {
- let problem = radroots_studio_domain::SafeError::new(
- radroots_studio_domain::SafeErrorCode::StorageUnavailable,
- radroots_studio_domain::SafeMessage::new("The runtime is unavailable."),
- );
- let mut blocked = LifecycleGate::opening();
- blocked.block(problem);
- assert!(blocked.allows(RuntimeCommandClass::RetryOpening));
- assert!(!blocked.allows(RuntimeCommandClass::MutateLocalState));
-
- let mut degraded = LifecycleGate::opening();
- degraded.begin_compatibility_check().expect("compatibility");
- degraded.compatibility_accepted().expect("accepted");
- degraded.ownership_acquired().expect("ownership");
- degraded.migration_complete().expect("migration");
- degraded.recovery_complete().expect("recovery");
- degraded.degrade(problem).expect("degraded");
- assert!(degraded.allows(RuntimeCommandClass::MutateLocalState));
- assert!(!degraded.allows(RuntimeCommandClass::UseRelay));
- degraded.restore_ready().expect("restored");
- assert!(LifecycleGate::opening().restore_ready().is_err());
-
- let mut fatal = LifecycleGate::opening();
- fatal.fail(problem);
- assert!(!fatal.allows(RuntimeCommandClass::Observe));
- fatal.begin_shutdown().expect("fatal can close");
- fatal.finish_shutdown().expect("closed");
- assert_eq!(fatal.lifecycle(), RuntimeLifecycle::Closed);
- assert!(!fatal.allows(RuntimeCommandClass::Shutdown));
- }
-
- #[test]
- fn opening_stages_reject_out_of_order_and_repeated_transitions() {
- let mut lifecycle = LifecycleGate::opening();
- assert!(lifecycle.migration_complete().is_err());
- lifecycle.begin_compatibility_check().expect("first stage");
- assert!(lifecycle.begin_compatibility_check().is_err());
- assert!(lifecycle.recovery_complete().is_err());
- }
-}
diff --git a/crates/studio_application/src/app_core.rs b/crates/studio_application/src/app_core.rs
@@ -1,358 +0,0 @@
-use std::collections::BTreeMap;
-use std::sync::{Arc, Mutex, MutexGuard};
-
-use radroots_studio_domain::{PublicKey, SafeError, SafeErrorCode, SafeMessage, UnixTimestamp};
-
-use crate::{
- AccountRepository, AppSnapshot, AppStateRepository, KeyMaterialProvider, RelayConfiguration,
- SnapshotRevision, StateMachine, StateTransition,
-};
-
-pub struct RemovalConfirmationToken {
- id: u64,
- public_key: PublicKey,
- revision: SnapshotRevision,
- expires_at: UnixTimestamp,
- impact: RemovalImpact,
-}
-
-#[derive(Clone, Copy, Debug, Eq, PartialEq)]
-pub struct RemovalImpact {
- deletes_local_credential: bool,
- signs_out: bool,
-}
-
-impl RemovalImpact {
- #[must_use]
- pub const fn deletes_local_credential(self) -> bool {
- self.deletes_local_credential
- }
- #[must_use]
- pub const fn signs_out(self) -> bool {
- self.signs_out
- }
-}
-
-impl RemovalConfirmationToken {
- #[must_use]
- pub const fn public_key(&self) -> PublicKey {
- self.public_key
- }
- #[must_use]
- pub const fn revision(&self) -> SnapshotRevision {
- self.revision
- }
- #[must_use]
- pub const fn expires_at(&self) -> UnixTimestamp {
- self.expires_at
- }
- #[must_use]
- pub const fn impact(&self) -> RemovalImpact {
- self.impact
- }
-}
-
-#[derive(Clone, Copy)]
-struct RemovalTokenState {
- public_key: PublicKey,
- revision: SnapshotRevision,
- expires_at: UnixTimestamp,
- impact: RemovalImpact,
-}
-
-struct CoreState {
- state_machine: StateMachine,
- removal_tokens: BTreeMap<u64, RemovalTokenState>,
- next_removal_token: u64,
-}
-
-pub struct AppCore {
- relay_configuration: RelayConfiguration,
- key_material: Arc<dyn KeyMaterialProvider>,
- state: Mutex<CoreState>,
-}
-
-impl AppCore {
- #[must_use]
- pub fn new(
- relay_configuration: RelayConfiguration,
- key_material: Arc<dyn KeyMaterialProvider>,
- ) -> Self {
- Self {
- relay_configuration,
- key_material,
- state: Mutex::new(CoreState {
- state_machine: StateMachine::booting(),
- removal_tokens: BTreeMap::new(),
- next_removal_token: 1,
- }),
- }
- }
-
- #[cfg(test)]
- #[must_use]
- pub fn in_memory(relay_configuration: RelayConfiguration) -> Self {
- Self::new(
- relay_configuration,
- Arc::new(crate::test_support::TestKeyMaterialProvider::default()),
- )
- }
-
- pub(crate) fn key_material(&self) -> &dyn KeyMaterialProvider {
- self.key_material.as_ref()
- }
-
- /// Moves the in-memory core from booting to an empty ready snapshot.
- ///
- /// # Errors
- ///
- /// Returns a safe application-state error if the ready snapshot invariant
- /// cannot be constructed.
- pub fn bootstrap(&self) -> Result<AppSnapshot, SafeError> {
- self.apply_transition(StateTransition::Bootstrap)
- }
-
- /// Loads the durable public registry and selection into a signed-out snapshot.
- ///
- /// # Errors
- ///
- /// Returns the safe persistence error after publishing a fatal snapshot when
- /// durable state cannot be read or violates application invariants.
- pub fn bootstrap_from(
- &self,
- accounts: &(impl AccountRepository + ?Sized),
- app_state: &(impl AppStateRepository + ?Sized),
- ) -> Result<AppSnapshot, SafeError> {
- let loaded = accounts.list_accounts().and_then(|accounts| {
- app_state
- .load_selected_account()
- .map(|selected| (accounts, selected))
- });
- match loaded {
- Ok((accounts, selected)) => {
- self.apply_transition(StateTransition::BootstrapRegistry { accounts, selected })
- }
- Err(error) => {
- self.apply_transition(StateTransition::Fatal(error))?;
- Err(error)
- }
- }
- }
-
- #[must_use]
- pub fn snapshot(&self) -> AppSnapshot {
- self.lock_state().state_machine.snapshot().clone()
- }
-
- pub(crate) fn apply_transition(
- &self,
- transition: StateTransition,
- ) -> Result<AppSnapshot, SafeError> {
- self.lock_state()
- .state_machine
- .apply(transition, &self.relay_configuration)
- }
-
- pub(crate) fn issue_removal_token(
- &self,
- public_key: PublicKey,
- now: UnixTimestamp,
- ) -> Result<RemovalConfirmationToken, SafeError> {
- let mut state = self.lock_state();
- let Some(account) = state
- .state_machine
- .snapshot()
- .accounts()
- .iter()
- .find(|account| account.public_key() == public_key)
- else {
- return Err(account_not_found());
- };
- let deletes_local_credential = account.signer().availability()
- != radroots_studio_domain::BindingAvailability::CredentialMissing;
- let id = state.next_removal_token;
- state.next_removal_token = id.checked_add(1).ok_or_else(invalid_application_state)?;
- let revision = state.state_machine.snapshot().revision();
- let expires_at = UnixTimestamp::from_seconds(
- now.as_seconds()
- .checked_add(300)
- .ok_or_else(invalid_application_state)?,
- )
- .ok_or_else(invalid_application_state)?;
- let impact = RemovalImpact {
- deletes_local_credential,
- signs_out: state
- .state_machine
- .snapshot()
- .active_account()
- .is_some_and(|active| active.account().public_key() == public_key),
- };
- state.removal_tokens.insert(
- id,
- RemovalTokenState {
- public_key,
- revision,
- expires_at,
- impact,
- },
- );
- Ok(RemovalConfirmationToken {
- id,
- public_key,
- revision,
- expires_at,
- impact,
- })
- }
-
- #[allow(clippy::needless_pass_by_value)]
- pub(crate) fn consume_removal_token(
- &self,
- token: RemovalConfirmationToken,
- now: UnixTimestamp,
- ) -> Result<PublicKey, SafeError> {
- let RemovalConfirmationToken {
- id,
- public_key,
- revision,
- expires_at,
- impact,
- } = token;
- let mut state = self.lock_state();
- let stored = state.removal_tokens.remove(&id);
- if stored.is_none_or(|stored| {
- stored.public_key != public_key
- || stored.revision != revision
- || stored.expires_at != expires_at
- || stored.impact != impact
- }) || state.state_machine.snapshot().revision() != revision
- || now.as_seconds() > expires_at.as_seconds()
- {
- return Err(invalid_application_state());
- }
- Ok(public_key)
- }
-
- #[allow(clippy::needless_pass_by_value)]
- pub(crate) fn cancel_removal_token(&self, token: RemovalConfirmationToken) -> bool {
- self.lock_state().removal_tokens.remove(&token.id).is_some()
- }
-
- fn lock_state(&self) -> MutexGuard<'_, CoreState> {
- self.state
- .lock()
- .unwrap_or_else(std::sync::PoisonError::into_inner)
- }
-}
-
-const fn invalid_application_state() -> SafeError {
- SafeError::new(
- SafeErrorCode::InvalidApplicationState,
- SafeMessage::new("The account removal confirmation is no longer valid."),
- )
-}
-
-const fn account_not_found() -> SafeError {
- SafeError::new(
- SafeErrorCode::AccountNotFound,
- SafeMessage::new("The account was not found."),
- )
-}
-
-#[cfg(test)]
-mod tests {
- use radroots_studio_domain::{
- AccountCreatedAt, AccountIdentity, AccountSummary, BindingAvailability, LocalSignerBinding,
- UnixTimestamp,
- };
-
- use crate::{AppCore, AppLifecycle, RelayConfiguration, StateTransition};
-
- #[test]
- fn bootstrap_is_idempotent_and_advances_only_once() {
- let core = AppCore::in_memory(RelayConfiguration::default());
- let ready = core.bootstrap().expect("bootstrap");
- let repeated = core.bootstrap().expect("idempotent bootstrap");
-
- assert_eq!(ready.lifecycle(), AppLifecycle::Ready);
- assert_eq!(ready.revision().value(), 1);
- assert_eq!(repeated, ready);
- }
-
- #[test]
- fn core_instances_never_share_state() {
- let first = AppCore::in_memory(RelayConfiguration::default());
- let second = AppCore::in_memory(RelayConfiguration::default());
-
- first.bootstrap().expect("first bootstrap");
-
- assert_eq!(first.snapshot().revision().value(), 1);
- assert_eq!(second.snapshot().revision().value(), 0);
- }
-
- #[test]
- fn removal_impact_matches_missing_local_binding() {
- let core = AppCore::in_memory(RelayConfiguration::default());
- let public_key = crate::test_support::valid_test_public_key(9).expect("valid public key");
- let account = AccountSummary::new(
- AccountIdentity::derive(public_key).expect("identity"),
- LocalSignerBinding::new(public_key, BindingAvailability::CredentialMissing),
- None,
- AccountCreatedAt::new(UnixTimestamp::from_seconds(1).expect("time")),
- None,
- )
- .expect("account");
- core.apply_transition(StateTransition::BootstrapRegistry {
- accounts: vec![account],
- selected: Some(public_key),
- })
- .expect("registry");
-
- let removal = core
- .issue_removal_token(public_key, UnixTimestamp::from_seconds(2).expect("time"))
- .expect("removal");
-
- assert!(!removal.impact().deletes_local_credential());
- assert!(!removal.impact().signs_out());
- }
-
- #[test]
- fn removal_confirmation_rejects_every_tampered_authority_field() {
- let core = AppCore::in_memory(RelayConfiguration::default());
- let public_key = crate::test_support::valid_test_public_key(7).expect("public key");
- let other_key = crate::test_support::valid_test_public_key(8).expect("other key");
- let account = AccountSummary::new(
- AccountIdentity::derive(public_key).expect("identity"),
- LocalSignerBinding::new(public_key, BindingAvailability::Available),
- None,
- AccountCreatedAt::new(UnixTimestamp::from_seconds(1).expect("time")),
- None,
- )
- .expect("account");
- core.apply_transition(StateTransition::BootstrapRegistry {
- accounts: vec![account],
- selected: Some(public_key),
- })
- .expect("registry");
-
- let now = UnixTimestamp::from_seconds(2).expect("now");
- let mut wrong_key = core.issue_removal_token(public_key, now).expect("token");
- wrong_key.public_key = other_key;
- assert!(core.consume_removal_token(wrong_key, now).is_err());
-
- let mut wrong_revision = core.issue_removal_token(public_key, now).expect("token");
- wrong_revision.revision = crate::SnapshotRevision::initial();
- assert!(core.consume_removal_token(wrong_revision, now).is_err());
-
- let mut wrong_expiry = core.issue_removal_token(public_key, now).expect("token");
- wrong_expiry.expires_at = UnixTimestamp::from_seconds(999).expect("expiry");
- assert!(core.consume_removal_token(wrong_expiry, now).is_err());
-
- let mut wrong_impact = core.issue_removal_token(public_key, now).expect("token");
- wrong_impact.impact = super::RemovalImpact {
- deletes_local_credential: false,
- signs_out: false,
- };
- assert!(core.consume_removal_token(wrong_impact, now).is_err());
- }
-}
diff --git a/crates/studio_application/src/change_stream.rs b/crates/studio_application/src/change_stream.rs
@@ -1,239 +0,0 @@
-use std::collections::BTreeMap;
-use std::num::{NonZeroU64, NonZeroUsize};
-
-use tokio::sync::mpsc;
-
-use crate::{AppSnapshot, SnapshotRevision};
-
-#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
-pub struct ChangeSubscriptionId(NonZeroU64);
-
-impl ChangeSubscriptionId {
- #[must_use]
- pub const fn value(self) -> u64 {
- self.0.get()
- }
-}
-
-#[derive(Clone, Debug, Eq, PartialEq)]
-pub struct SnapshotChange {
- snapshot: AppSnapshot,
- previous_revision: Option<SnapshotRevision>,
-}
-
-impl SnapshotChange {
- #[must_use]
- pub const fn revision(&self) -> SnapshotRevision {
- self.snapshot.revision()
- }
-
- #[must_use]
- pub const fn snapshot(&self) -> &AppSnapshot {
- &self.snapshot
- }
-
- #[must_use]
- pub fn into_snapshot(self) -> AppSnapshot {
- self.snapshot
- }
-
- #[must_use]
- pub const fn previous_revision(&self) -> Option<SnapshotRevision> {
- self.previous_revision
- }
-
- #[must_use]
- pub fn recovers_gap_after(&self, observed: SnapshotRevision) -> bool {
- self.previous_revision
- .is_some_and(|previous| previous != observed)
- }
-}
-
-pub struct SnapshotChangeReceiver {
- receiver: mpsc::Receiver<SnapshotChange>,
-}
-
-impl SnapshotChangeReceiver {
- pub async fn receive(&mut self) -> Option<SnapshotChange> {
- self.receiver.recv().await
- }
-}
-
-pub struct OrderedSnapshotChanges {
- latest: AppSnapshot,
- next_subscription: u64,
- subscribers: BTreeMap<ChangeSubscriptionId, mpsc::Sender<SnapshotChange>>,
- closed: bool,
-}
-
-impl OrderedSnapshotChanges {
- #[must_use]
- pub fn new(initial_snapshot: AppSnapshot) -> Self {
- Self {
- latest: initial_snapshot,
- next_subscription: 1,
- subscribers: BTreeMap::new(),
- closed: false,
- }
- }
-
- #[must_use]
- pub const fn last_revision(&self) -> SnapshotRevision {
- self.latest.revision()
- }
-
- /// Registers a bounded consumer for future changes.
- ///
- /// # Errors
- ///
- /// Returns `None` if the subscription identifier space is exhausted.
- pub fn subscribe(
- &mut self,
- capacity: NonZeroUsize,
- ) -> Option<(ChangeSubscriptionId, SnapshotChangeReceiver)> {
- if self.closed {
- return None;
- }
- let id = ChangeSubscriptionId(NonZeroU64::new(self.next_subscription)?);
- self.next_subscription = self.next_subscription.checked_add(1)?;
- let (sender, receiver) = mpsc::channel(capacity.get());
- sender
- .try_send(SnapshotChange {
- snapshot: self.latest.clone(),
- previous_revision: None,
- })
- .ok()?;
- self.subscribers.insert(id, sender);
- Some((id, SnapshotChangeReceiver { receiver }))
- }
-
- #[must_use]
- pub fn unsubscribe(&mut self, id: ChangeSubscriptionId) -> bool {
- self.subscribers.remove(&id).is_some()
- }
-
- pub fn publish(&mut self, snapshot: AppSnapshot) {
- if self.closed || snapshot.revision() <= self.latest.revision() {
- return;
- }
- let change = SnapshotChange {
- previous_revision: Some(self.latest.revision()),
- snapshot,
- };
- self.latest = change.snapshot.clone();
- self.subscribers
- .retain(|_, sender| match sender.try_send(change.clone()) {
- Ok(()) | Err(mpsc::error::TrySendError::Full(_)) => true,
- Err(mpsc::error::TrySendError::Closed(_)) => false,
- });
- }
-
- pub fn close(&mut self) {
- self.closed = true;
- self.subscribers.clear();
- }
-}
-
-#[cfg(test)]
-mod tests {
- use std::num::NonZeroUsize;
-
- use crate::{
- AppSnapshot, OrderedSnapshotChanges, RelayConfiguration, SessionState, SnapshotRevision,
- };
-
- #[tokio::test]
- async fn change_stream_publishes_monotonic_revisions_to_multiple_consumers() {
- let mut changes = OrderedSnapshotChanges::new(snapshot(0));
- let (_, mut first) = changes
- .subscribe(NonZeroUsize::new(4).expect("capacity"))
- .expect("first subscription");
- let (_, mut second) = changes
- .subscribe(NonZeroUsize::new(4).expect("capacity"))
- .expect("second subscription");
-
- assert_eq!(
- first.receive().await.expect("initial").revision(),
- revision(0)
- );
- assert_eq!(
- second.receive().await.expect("initial").revision(),
- revision(0)
- );
- changes.publish(snapshot(1));
- changes.publish(snapshot(1));
- changes.publish(snapshot(2));
-
- for receiver in [&mut first, &mut second] {
- assert_eq!(
- receiver.receive().await.expect("revision 1").revision(),
- revision(1)
- );
- assert_eq!(
- receiver.receive().await.expect("revision 2").revision(),
- revision(2)
- );
- }
- assert_eq!(changes.last_revision(), revision(2));
- }
-
- #[tokio::test]
- async fn slow_consumers_expose_a_revision_gap_without_blocking_publication() {
- let mut changes = OrderedSnapshotChanges::new(snapshot(0));
- let (_, mut receiver) = changes
- .subscribe(NonZeroUsize::new(1).expect("capacity"))
- .expect("subscription");
-
- assert_eq!(
- receiver.receive().await.expect("initial").revision(),
- revision(0)
- );
- changes.publish(snapshot(1));
- changes.publish(snapshot(2));
- let first = receiver.receive().await.expect("first");
- assert_eq!(first.revision(), revision(1));
- changes.publish(snapshot(3));
- let recovered = receiver.receive().await.expect("gap recovery");
- assert_eq!(recovered.revision(), revision(3));
- assert!(recovered.recovers_gap_after(first.revision()));
- }
-
- #[tokio::test]
- async fn close_terminates_consumers_and_rejects_later_subscriptions() {
- let mut changes = OrderedSnapshotChanges::new(snapshot(0));
- let (_, mut receiver) = changes
- .subscribe(NonZeroUsize::new(1).expect("capacity"))
- .expect("subscription");
- receiver.receive().await.expect("initial");
-
- changes.close();
- changes.publish(snapshot(1));
- assert!(receiver.receive().await.is_none());
- assert!(
- changes
- .subscribe(NonZeroUsize::new(1).expect("capacity"))
- .is_none()
- );
- }
-
- fn revision(value: u64) -> SnapshotRevision {
- SnapshotRevision::from_value(value)
- }
-
- fn snapshot(value: u64) -> AppSnapshot {
- if value == 0 {
- AppSnapshot::booting()
- } else {
- AppSnapshot::ready(
- revision(value),
- RelayConfiguration::default(),
- Vec::new(),
- None,
- SessionState::SignedOut,
- None,
- None,
- )
- .expect("snapshot")
- }
- }
-}
diff --git a/crates/studio_application/src/config.rs b/crates/studio_application/src/config.rs
@@ -1,107 +0,0 @@
-use radroots_studio_domain::{
- RelayDestinationPolicy, SafeError, SafeErrorCode, SafeMessage, normalize_relay_urls,
-};
-
-use crate::RelayConfiguration;
-
-pub const RELAY_ENVIRONMENT_VARIABLE: &str = "RADROOTS_NOSTR_RELAYS";
-const DEVELOPMENT_RELAY: &str = "ws://localhost:8080";
-
-#[derive(Clone, Copy, Debug, Eq, PartialEq)]
-pub enum RelayRuntimeMode {
- Development,
- Packaged,
-}
-
-/// Reads the process relay configuration once through the Rust-owned boundary.
-///
-/// # Errors
-///
-/// Returns a safe configuration error for missing Unicode or invalid relay data.
-pub fn relay_configuration_from_environment(
- mode: RelayRuntimeMode,
-) -> Result<RelayConfiguration, SafeError> {
- let value = match std::env::var(RELAY_ENVIRONMENT_VARIABLE) {
- Ok(value) => Some(value),
- Err(std::env::VarError::NotPresent) => None,
- Err(std::env::VarError::NotUnicode(_)) => return Err(invalid_configuration()),
- };
- relay_configuration_from_value(value.as_deref(), mode)
-}
-
-/// Parses an injected comma-separated relay list without mutating process state.
-///
-/// # Errors
-///
-/// Returns a safe configuration error when an entry is invalid or packaged mode
-/// has no configured relay.
-pub fn relay_configuration_from_value(
- value: Option<&str>,
- mode: RelayRuntimeMode,
-) -> Result<RelayConfiguration, SafeError> {
- let configured = value.unwrap_or_default().trim();
- let (source, policy) = if configured.is_empty() {
- match mode {
- RelayRuntimeMode::Development => (DEVELOPMENT_RELAY, RelayDestinationPolicy::Local),
- RelayRuntimeMode::Packaged => return Err(invalid_configuration()),
- }
- } else {
- (configured, RelayDestinationPolicy::Public)
- };
- let normalized = normalize_relay_urls(source.split(',').map(str::trim), policy)?;
- if normalized.is_empty() {
- return Err(invalid_configuration());
- }
- RelayConfiguration::new(normalized)
-}
-
-const fn invalid_configuration() -> SafeError {
- SafeError::new(
- SafeErrorCode::InvalidRelayConfiguration,
- SafeMessage::new("The Nostr relay configuration is invalid."),
- )
-}
-
-#[cfg(test)]
-mod tests {
- use radroots_studio_domain::SafeErrorCode;
-
- use super::{RelayRuntimeMode, relay_configuration_from_value};
-
- #[test]
- fn relay_config_uses_localhost_fallback_only_for_development() {
- for value in [None, Some(""), Some(" ")] {
- let development = relay_configuration_from_value(value, RelayRuntimeMode::Development)
- .expect("development fallback");
- assert_eq!(development.relays()[0].as_str(), "ws://localhost:8080/");
- let packaged = relay_configuration_from_value(value, RelayRuntimeMode::Packaged)
- .expect_err("packaged configuration required");
- assert_eq!(packaged.code(), SafeErrorCode::InvalidRelayConfiguration);
- }
- }
-
- #[test]
- fn relay_config_trims_deduplicates_and_preserves_order() {
- let configuration = relay_configuration_from_value(
- Some(" wss://relay.one ,wss://relay.two,wss://relay.one/ "),
- RelayRuntimeMode::Packaged,
- )
- .expect("configuration");
- let relays = configuration
- .relays()
- .iter()
- .map(radroots_studio_domain::RelayUrl::as_str)
- .collect::<Vec<_>>();
- assert_eq!(relays, ["wss://relay.one/", "wss://relay.two/"]);
- }
-
- #[test]
- fn relay_config_rejects_any_invalid_comma_separated_entry() {
- let error = relay_configuration_from_value(
- Some("wss://relay.one,https://not-a-relay.test"),
- RelayRuntimeMode::Packaged,
- )
- .expect_err("invalid entry");
- assert_eq!(error.code(), SafeErrorCode::InvalidRelayConfiguration);
- }
-}
diff --git a/crates/studio_application/src/custody.rs b/crates/studio_application/src/custody.rs
@@ -1,288 +0,0 @@
-use std::num::NonZeroU64;
-use std::sync::Mutex;
-use std::time::Duration;
-
-use crate::KeyMaterialProvider;
-use radroots_studio_domain::{
- AccountCreatedAt, AccountIdentity, AccountSummary, BindingAvailability, LocalSignerBinding,
- Nsec, SafeError, SafeErrorCode, SafeMessage, SecretKeyInput, UnixTimestamp,
-};
-pub const GENERATED_KEY_STAGE_TTL: Duration = Duration::from_mins(5);
-
-#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
-pub struct RecoveryStageId(NonZeroU64);
-
-impl RecoveryStageId {
- #[must_use]
- pub const fn new(value: NonZeroU64) -> Self {
- Self(value)
- }
-
- #[must_use]
- pub const fn value(self) -> u64 {
- self.0.get()
- }
-}
-
-#[derive(Clone, Debug, Eq, PartialEq)]
-pub struct GeneratedKeyStageView {
- account: AccountSummary,
- expires_at: UnixTimestamp,
-}
-
-impl GeneratedKeyStageView {
- #[must_use]
- pub const fn account(&self) -> &AccountSummary {
- &self.account
- }
-
- #[must_use]
- pub const fn expires_at(&self) -> UnixTimestamp {
- self.expires_at
- }
-}
-
-pub struct StagedGeneratedKey {
- id: RecoveryStageId,
- account: AccountSummary,
- secret: SecretKeyInput,
- expected_revision: u64,
- expires_at: UnixTimestamp,
-}
-
-impl StagedGeneratedKey {
- #[must_use]
- pub fn view(&self) -> GeneratedKeyStageView {
- GeneratedKeyStageView {
- account: self.account.clone(),
- expires_at: self.expires_at,
- }
- }
-
- #[must_use]
- pub const fn expected_revision(&self) -> u64 {
- self.expected_revision
- }
-
- #[must_use]
- pub const fn id(&self) -> RecoveryStageId {
- self.id
- }
-
- #[must_use]
- pub const fn account(&self) -> &AccountSummary {
- &self.account
- }
-
- #[must_use]
- pub fn into_commit_parts(self) -> (AccountSummary, SecretKeyInput) {
- (self.account, self.secret)
- }
-}
-
-pub struct GeneratedKeyRecoveryHandle {
- id: RecoveryStageId,
- view: GeneratedKeyStageView,
- recovery_nsec: Mutex<Option<Nsec>>,
-}
-
-impl GeneratedKeyRecoveryHandle {
- fn new(id: RecoveryStageId, view: GeneratedKeyStageView, recovery_nsec: Nsec) -> Self {
- Self {
- id,
- view,
- recovery_nsec: Mutex::new(Some(recovery_nsec)),
- }
- }
-
- #[must_use]
- pub const fn id(&self) -> RecoveryStageId {
- self.id
- }
-
- #[must_use]
- pub const fn view(&self) -> &GeneratedKeyStageView {
- &self.view
- }
-
- /// Returns the generated recovery value exactly once.
- ///
- /// # Errors
- ///
- /// Returns a safe unavailable error after the value was already consumed.
- pub fn take_recovery_nsec(&self) -> Result<Nsec, SafeError> {
- self.recovery_nsec
- .lock()
- .unwrap_or_else(std::sync::PoisonError::into_inner)
- .take()
- .ok_or_else(recovery_not_available)
- }
-}
-
-#[derive(Default)]
-pub struct GeneratedKeyStage {
- pending: Option<StagedGeneratedKey>,
-}
-
-impl GeneratedKeyStage {
- /// Replaces an expired stage or creates the only active generated-key stage.
- ///
- /// # Errors
- ///
- /// Returns a safe conflict while an unexpired recovery stage is active.
- pub fn begin(
- &mut self,
- key_material: &dyn KeyMaterialProvider,
- id: RecoveryStageId,
- expected_revision: u64,
- now: UnixTimestamp,
- ) -> Result<GeneratedKeyRecoveryHandle, SafeError> {
- self.expire(now);
- if self.pending.is_some() {
- return Err(recovery_in_progress());
- }
- let generated = key_material.generate()?;
- let (public_key, npub, secret, recovery_nsec) = generated.into_parts();
- let account = AccountSummary::new(
- AccountIdentity::verify(public_key, npub.as_str().to_owned())?,
- LocalSignerBinding::new(public_key, BindingAvailability::Available),
- None,
- AccountCreatedAt::new(now),
- None,
- )?;
- let ttl =
- i64::try_from(GENERATED_KEY_STAGE_TTL.as_secs()).map_err(|_| invalid_stage_expiry())?;
- let expires_at = now
- .as_seconds()
- .checked_add(ttl)
- .and_then(UnixTimestamp::from_seconds)
- .ok_or_else(invalid_stage_expiry)?;
- let pending = StagedGeneratedKey {
- id,
- account,
- secret,
- expected_revision,
- expires_at,
- };
- let view = pending.view();
- self.pending = Some(pending);
- Ok(GeneratedKeyRecoveryHandle::new(id, view, recovery_nsec))
- }
-
- pub fn cancel(&mut self) -> bool {
- self.pending.take().is_some()
- }
-
- pub fn expire(&mut self, now: UnixTimestamp) -> bool {
- if self
- .pending
- .as_ref()
- .is_some_and(|pending| now >= pending.expires_at)
- {
- self.pending = None;
- true
- } else {
- false
- }
- }
-
- #[must_use]
- pub const fn pending(&self) -> Option<&StagedGeneratedKey> {
- self.pending.as_ref()
- }
-
- /// Consumes the active, unexpired stage for its commit boundary.
- ///
- /// # Errors
- ///
- /// Returns a safe unavailable error when no live stage remains.
- pub fn take(
- &mut self,
- id: RecoveryStageId,
- now: UnixTimestamp,
- ) -> Result<StagedGeneratedKey, SafeError> {
- self.expire(now);
- if self.pending.as_ref().map(StagedGeneratedKey::id) != Some(id) {
- return Err(recovery_not_available());
- }
- self.pending.take().ok_or_else(recovery_not_available)
- }
-}
-
-const fn recovery_in_progress() -> SafeError {
- SafeError::new(
- SafeErrorCode::InvalidApplicationState,
- SafeMessage::new("A generated-key recovery step is already in progress."),
- )
-}
-
-const fn recovery_not_available() -> SafeError {
- SafeError::new(
- SafeErrorCode::InvalidApplicationState,
- SafeMessage::new("The generated-key recovery step is no longer available."),
- )
-}
-
-const fn invalid_stage_expiry() -> SafeError {
- SafeError::new(
- SafeErrorCode::InvalidApplicationState,
- SafeMessage::new("The generated-key recovery expiry is invalid."),
- )
-}
-
-#[cfg(test)]
-mod tests {
- use std::num::NonZeroU64;
-
- use radroots_studio_domain::UnixTimestamp;
-
- use super::{GENERATED_KEY_STAGE_TTL, GeneratedKeyStage, RecoveryStageId};
- use crate::test_support::TestKeyMaterialProvider;
-
- fn time(seconds: i64) -> UnixTimestamp {
- UnixTimestamp::from_seconds(seconds).expect("time")
- }
-
- fn id(value: u64) -> RecoveryStageId {
- RecoveryStageId::new(NonZeroU64::new(value).expect("id"))
- }
-
- #[test]
- fn stage_is_exclusive_cancelable_and_never_publishes_secret_debug() {
- let mut stage = GeneratedKeyStage::default();
- let key_material = TestKeyMaterialProvider::default();
- let handle = stage
- .begin(&key_material, id(1), 4, time(10))
- .expect("begin");
- let view = handle.view();
- assert_eq!(view.expires_at().as_seconds(), 310);
- assert_eq!(stage.pending().expect("pending").expected_revision(), 4);
- assert!(stage.begin(&key_material, id(2), 4, time(11)).is_err());
- let nsec = handle.take_recovery_nsec().expect("one-use recovery");
- assert_eq!(nsec.with_exposed_secret(str::len), 63);
- assert!(handle.take_recovery_nsec().is_err());
- assert!(stage.cancel());
- assert!(!stage.cancel());
- assert!(format!("{view:?}").contains(view.account().npub().as_str()));
- assert!(!format!("{view:?}").contains("nsec1"));
- }
-
- #[test]
- fn stage_expires_and_is_destroyed_on_owner_drop() {
- let mut stage = GeneratedKeyStage::default();
- let key_material = TestKeyMaterialProvider::default();
- stage
- .begin(&key_material, id(1), 0, time(20))
- .expect("begin");
- let expiry = 20 + i64::try_from(GENERATED_KEY_STAGE_TTL.as_secs()).expect("ttl");
- assert!(stage.expire(time(expiry)));
- assert!(stage.pending().is_none());
- assert!(stage.take(id(1), time(expiry)).is_err());
-
- let mut shutdown_stage = GeneratedKeyStage::default();
- shutdown_stage
- .begin(&key_material, id(2), 0, time(30))
- .expect("begin");
- drop(shutdown_stage);
- }
-}
diff --git a/crates/studio_application/src/lib.rs b/crates/studio_application/src/lib.rs
@@ -1,58 +0,0 @@
-#![doc = "Radroots Studio application runtime."]
-
-pub mod accounts;
-pub mod actor;
-pub mod app_core;
-mod change_stream;
-pub mod config;
-pub mod custody;
-pub mod ports;
-mod profile_refresh;
-pub mod recovery;
-pub mod secrets;
-pub mod session;
-pub mod snapshot;
-pub mod state_machine;
-
-#[cfg(test)]
-mod test_support;
-
-pub use accounts::{
- GenerateAccountReceipt, ImportAccountReceipt, InMemoryAccountRepository,
- InMemoryOperationJournal,
-};
-pub use actor::{
- ActorMailbox, CommandContext, CommandEnvelope, CommandReceipt, CommandRejection, CommandResult,
- CommandSubmission, CommandTicket, ForegroundSessionBinding, LifecycleGate, RequestId,
- RuntimeCommandClass, RuntimeLifecycle, SessionGeneration, TaskCorrelation,
-};
-pub use app_core::{AppCore, RemovalConfirmationToken, RemovalImpact};
-pub use change_stream::{
- ChangeSubscriptionId, OrderedSnapshotChanges, SnapshotChange, SnapshotChangeReceiver,
-};
-pub use config::{
- RelayRuntimeMode, relay_configuration_from_environment, relay_configuration_from_value,
-};
-pub use custody::{
- GENERATED_KEY_STAGE_TTL, GeneratedKeyRecoveryHandle, GeneratedKeyStage, GeneratedKeyStageView,
- RecoveryStageId, StagedGeneratedKey,
-};
-pub use ports::{
- AccountNamespaceRepository, AccountOperationKind, AccountOperationPhase, AccountPreferenceKey,
- AccountRepository, AppStateRepository, BoxFuture, CachedProfile, Clock,
- DurableAccountOperation, DurableOperationKind, DurableOperationPhase, DurableOperationReceipt,
- DurableOperationRepository, DurableOperationStart, DurableRequestId, DurableTerminalOutcome,
- GeneratedKeyMaterial, ImportedKeyMaterial, KeyMaterialProvider, NostrClient,
- OperationDiagnostic, OperationId, OperationJournal, OperationPriorState,
- PendingAccountOperation, ProfileFetchResult, ProfileRefreshStatus, ProfileRepository,
- RelayFetchCompleteness,
-};
-pub use profile_refresh::ProfileRefreshPlan;
-pub use secrets::{
- FailureSecretStore, InMemorySecretStore, SecretStore, SecretStoreCall, SecretStoreOperation,
-};
-pub use snapshot::{
- ActiveAccountSnapshot, AppLifecycle, AppSnapshot, MAX_CONFIGURED_RELAYS, ProfileLoadState,
- RelayConfiguration, RelayConnectionState, SessionState, SnapshotRevision,
-};
-pub use state_machine::{StateMachine, StateTransition};
diff --git a/crates/studio_application/src/ports.rs b/crates/studio_application/src/ports.rs
@@ -1,887 +0,0 @@
-use std::future::Future;
-use std::pin::Pin;
-use std::time::Instant;
-
-use radroots_studio_domain::{
- AccountSummary, BindingAvailability, Kind0ProfileCandidate, Npub, Nsec, PublicKey, RelayUrl,
- SafeError, SafeErrorCode, SafeMessage, SecretKeyInput, UnixTimestamp,
-};
-
-const MAX_DURABLE_REQUEST_ID_BYTES: usize = 128;
-
-#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
-pub struct DurableRequestId(String);
-
-impl DurableRequestId {
- /// Validates an opaque caller-generated idempotency key.
- ///
- /// # Errors
- ///
- /// Returns a safe validation error when the value is empty, oversized, or contains anything
- /// other than visible ASCII characters.
- pub fn parse(value: impl Into<String>) -> Result<Self, SafeError> {
- let value = value.into();
- if value.is_empty()
- || value.len() > MAX_DURABLE_REQUEST_ID_BYTES
- || !value.bytes().all(|byte| byte.is_ascii_graphic())
- {
- return Err(invalid_request_id());
- }
- Ok(Self(value))
- }
-
- #[must_use]
- pub fn as_str(&self) -> &str {
- &self.0
- }
-}
-
-#[derive(Clone, Copy, Debug, Eq, PartialEq)]
-pub enum DurableOperationKind {
- Create,
- Import,
- Repair,
- Remove,
-}
-
-#[derive(Clone, Copy, Debug, Eq, PartialEq)]
-pub enum DurableOperationPhase {
- IntentRecorded,
- CredentialWritten,
- MetadataCommitted,
- SelectionCommitted,
- CompensationPending,
- CredentialDeleted,
- MetadataDeleted,
- Finalized,
-}
-
-#[derive(Clone, Copy, Debug, Eq, PartialEq)]
-pub enum DurableTerminalOutcome {
- Completed,
- Cancelled,
- Failed,
-}
-
-#[derive(Clone, Copy, Debug, Eq, PartialEq)]
-pub struct OperationPriorState {
- selected_account: Option<PublicKey>,
- binding_availability: Option<BindingAvailability>,
-}
-
-impl OperationPriorState {
- #[must_use]
- pub const fn new(
- selected_account: Option<PublicKey>,
- binding_availability: Option<BindingAvailability>,
- ) -> Self {
- Self {
- selected_account,
- binding_availability,
- }
- }
-
- #[must_use]
- pub const fn selected_account(self) -> Option<PublicKey> {
- self.selected_account
- }
-
- #[must_use]
- pub const fn binding_availability(self) -> Option<BindingAvailability> {
- self.binding_availability
- }
-}
-
-#[derive(Clone, Debug, Eq, PartialEq)]
-pub struct DurableOperationReceipt {
- request_id: DurableRequestId,
- account: PublicKey,
- outcome: DurableTerminalOutcome,
- resulting_revision: Option<u64>,
-}
-
-impl DurableOperationReceipt {
- #[must_use]
- pub const fn new(
- request_id: DurableRequestId,
- account: PublicKey,
- outcome: DurableTerminalOutcome,
- resulting_revision: Option<u64>,
- ) -> Self {
- Self {
- request_id,
- account,
- outcome,
- resulting_revision,
- }
- }
-
- #[must_use]
- pub const fn request_id(&self) -> &DurableRequestId {
- &self.request_id
- }
-
- #[must_use]
- pub const fn account(&self) -> PublicKey {
- self.account
- }
-
- #[must_use]
- pub const fn outcome(&self) -> DurableTerminalOutcome {
- self.outcome
- }
-
- #[must_use]
- pub const fn resulting_revision(&self) -> Option<u64> {
- self.resulting_revision
- }
-}
-
-#[derive(Clone, Debug, Eq, PartialEq)]
-pub struct DurableAccountOperation {
- request_id: DurableRequestId,
- kind: DurableOperationKind,
- account: PublicKey,
- expected_revision: Option<u64>,
- phase: DurableOperationPhase,
- prior: OperationPriorState,
- updated_at: UnixTimestamp,
- diagnostic: Option<OperationDiagnostic>,
- terminal: Option<DurableOperationReceipt>,
-}
-
-impl DurableAccountOperation {
- #[allow(clippy::too_many_arguments)]
- #[must_use]
- pub const fn new(
- request_id: DurableRequestId,
- kind: DurableOperationKind,
- account: PublicKey,
- expected_revision: Option<u64>,
- phase: DurableOperationPhase,
- prior: OperationPriorState,
- updated_at: UnixTimestamp,
- diagnostic: Option<OperationDiagnostic>,
- terminal: Option<DurableOperationReceipt>,
- ) -> Self {
- Self {
- request_id,
- kind,
- account,
- expected_revision,
- phase,
- prior,
- updated_at,
- diagnostic,
- terminal,
- }
- }
-
- #[must_use]
- pub const fn request_id(&self) -> &DurableRequestId {
- &self.request_id
- }
- #[must_use]
- pub const fn kind(&self) -> DurableOperationKind {
- self.kind
- }
- #[must_use]
- pub const fn account(&self) -> PublicKey {
- self.account
- }
- #[must_use]
- pub const fn expected_revision(&self) -> Option<u64> {
- self.expected_revision
- }
- #[must_use]
- pub const fn phase(&self) -> DurableOperationPhase {
- self.phase
- }
- #[must_use]
- pub const fn prior(&self) -> OperationPriorState {
- self.prior
- }
- #[must_use]
- pub const fn updated_at(&self) -> UnixTimestamp {
- self.updated_at
- }
- #[must_use]
- pub const fn diagnostic(&self) -> Option<OperationDiagnostic> {
- self.diagnostic
- }
- #[must_use]
- pub const fn terminal(&self) -> Option<&DurableOperationReceipt> {
- self.terminal.as_ref()
- }
-}
-
-#[derive(Clone, Debug, Eq, PartialEq)]
-pub enum DurableOperationStart {
- Started(DurableAccountOperation),
- Existing(DurableAccountOperation),
-}
-
-const fn invalid_request_id() -> SafeError {
- SafeError::new(
- SafeErrorCode::InvalidApplicationState,
- SafeMessage::new("The request identifier is invalid."),
- )
-}
-
-pub type BoxFuture<'a, T> = Pin<Box<dyn Future<Output = T> + Send + 'a>>;
-
-#[derive(Clone, Copy, Debug, Eq, PartialEq)]
-pub enum ProfileRefreshStatus {
- Success,
- Offline,
- InvalidData,
-}
-
-#[derive(Clone, Copy, Debug, Eq, PartialEq)]
-pub enum RelayFetchCompleteness {
- Complete,
- Partial,
-}
-
-#[derive(Clone, Debug, Eq, PartialEq)]
-pub struct ProfileFetchResult {
- candidate: Option<Kind0ProfileCandidate>,
- completeness: RelayFetchCompleteness,
-}
-
-impl ProfileFetchResult {
- #[must_use]
- pub const fn complete(candidate: Option<Kind0ProfileCandidate>) -> Self {
- Self {
- candidate,
- completeness: RelayFetchCompleteness::Complete,
- }
- }
-
- #[must_use]
- pub const fn partial(candidate: Option<Kind0ProfileCandidate>) -> Self {
- Self {
- candidate,
- completeness: RelayFetchCompleteness::Partial,
- }
- }
-
- #[must_use]
- pub fn into_parts(self) -> (Option<Kind0ProfileCandidate>, RelayFetchCompleteness) {
- (self.candidate, self.completeness)
- }
-}
-
-#[derive(Clone, Debug, Eq, PartialEq)]
-pub struct CachedProfile {
- candidate: Kind0ProfileCandidate,
- refreshed_at: UnixTimestamp,
- refresh_status: ProfileRefreshStatus,
-}
-
-#[derive(Clone, Copy, Debug, Eq, PartialEq)]
-pub enum AccountPreferenceKey {
- NamespaceProbe,
-}
-
-#[derive(Clone, Copy, Debug, Eq, PartialEq)]
-pub enum AccountOperationKind {
- Add,
- Import,
- Remove,
-}
-
-#[derive(Clone, Copy, Debug, Eq, PartialEq)]
-pub enum AccountOperationPhase {
- IntentRecorded,
- CredentialWritten,
- MetadataCommitted,
- CompensationPending,
- CredentialDeleted,
- MetadataDeleted,
-}
-
-#[derive(Clone, Copy, Debug, Eq, PartialEq)]
-pub enum OperationDiagnostic {
- StorageUnavailable,
- KeyringUnavailable,
- CredentialMissing,
- CompensationFailed,
- Conflict,
- Expired,
-}
-
-#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
-pub struct OperationId(u64);
-
-impl OperationId {
- #[must_use]
- pub const fn from_raw(value: u64) -> Self {
- Self(value)
- }
-
- #[must_use]
- pub const fn as_raw(self) -> u64 {
- self.0
- }
-}
-
-#[derive(Clone, Debug, Eq, PartialEq)]
-pub struct PendingAccountOperation {
- id: OperationId,
- kind: AccountOperationKind,
- subject: PublicKey,
- phase: AccountOperationPhase,
- updated_at: UnixTimestamp,
- diagnostic: Option<OperationDiagnostic>,
-}
-
-impl PendingAccountOperation {
- #[must_use]
- pub const fn new(
- id: OperationId,
- kind: AccountOperationKind,
- subject: PublicKey,
- phase: AccountOperationPhase,
- updated_at: UnixTimestamp,
- diagnostic: Option<OperationDiagnostic>,
- ) -> Self {
- Self {
- id,
- kind,
- subject,
- phase,
- updated_at,
- diagnostic,
- }
- }
-
- #[must_use]
- pub const fn id(&self) -> OperationId {
- self.id
- }
- #[must_use]
- pub const fn kind(&self) -> AccountOperationKind {
- self.kind
- }
- #[must_use]
- pub const fn subject(&self) -> PublicKey {
- self.subject
- }
- #[must_use]
- pub const fn phase(&self) -> AccountOperationPhase {
- self.phase
- }
- #[must_use]
- pub const fn updated_at(&self) -> UnixTimestamp {
- self.updated_at
- }
- #[must_use]
- pub const fn diagnostic(&self) -> Option<OperationDiagnostic> {
- self.diagnostic
- }
-}
-
-impl CachedProfile {
- #[must_use]
- pub const fn new(
- candidate: Kind0ProfileCandidate,
- refreshed_at: UnixTimestamp,
- refresh_status: ProfileRefreshStatus,
- ) -> Self {
- Self {
- candidate,
- refreshed_at,
- refresh_status,
- }
- }
-
- #[must_use]
- pub const fn candidate(&self) -> &Kind0ProfileCandidate {
- &self.candidate
- }
-
- #[must_use]
- pub const fn refreshed_at(&self) -> UnixTimestamp {
- self.refreshed_at
- }
-
- #[must_use]
- pub const fn refresh_status(&self) -> ProfileRefreshStatus {
- self.refresh_status
- }
-}
-
-pub trait AccountRepository: Send + Sync {
- /// Lists saved public account records in deterministic order.
- ///
- /// # Errors
- ///
- /// Returns a safe storage error when records cannot be read.
- fn list_accounts(&self) -> Result<Vec<AccountSummary>, SafeError>;
- /// Finds one saved public account record.
- ///
- /// # Errors
- ///
- /// Returns a safe storage error when the lookup cannot complete.
- fn find_account(&self, public_key: PublicKey) -> Result<Option<AccountSummary>, SafeError>;
- /// Inserts one public account record.
- ///
- /// # Errors
- ///
- /// Returns a safe storage error when the durable write fails.
- fn insert_account(&self, account: &AccountSummary) -> Result<(), SafeError>;
- /// Updates one existing public account record.
- ///
- /// # Errors
- ///
- /// Returns a safe storage or account-not-found error when the durable
- /// update cannot complete.
- fn update_account(&self, account: &AccountSummary) -> Result<(), SafeError>;
- /// Removes one public account record.
- ///
- /// # Errors
- ///
- /// Returns a safe storage error when the durable delete fails.
- fn remove_account(&self, public_key: PublicKey) -> Result<(), SafeError>;
-}
-
-pub trait ProfileRepository: Send + Sync {
- /// Loads cached public profile metadata.
- ///
- /// # Errors
- ///
- /// Returns a safe storage error when the cache cannot be read.
- fn load_profile(&self, public_key: PublicKey) -> Result<Option<CachedProfile>, SafeError>;
- /// Saves a verified kind-0 profile candidate.
- ///
- /// # Errors
- ///
- /// Returns a safe storage error when the cache cannot be committed.
- fn save_profile(&self, profile: &CachedProfile) -> Result<(), SafeError>;
- /// Records the result of a profile refresh without replacing cached metadata.
- ///
- /// # Errors
- ///
- /// Returns a safe storage error when the cache cannot be committed.
- fn record_refresh_status(
- &self,
- public_key: PublicKey,
- refreshed_at: UnixTimestamp,
- status: ProfileRefreshStatus,
- ) -> Result<(), SafeError>;
- /// Removes cached profile metadata for an account.
- ///
- /// # Errors
- ///
- /// Returns a safe storage error when the cache cannot be deleted.
- fn remove_profile(&self, public_key: PublicKey) -> Result<(), SafeError>;
-}
-
-pub trait AccountNamespaceRepository: Send + Sync {
- /// Reads one internal non-secret account-scoped value.
- ///
- /// # Errors
- ///
- /// Returns a safe storage error when the value cannot be read.
- fn get_value(
- &self,
- owner: PublicKey,
- key: AccountPreferenceKey,
- ) -> Result<Option<String>, SafeError>;
- /// Writes one internal non-secret account-scoped value.
- ///
- /// # Errors
- ///
- /// Returns a safe storage error when the value cannot be committed.
- fn set_value(
- &self,
- owner: PublicKey,
- key: AccountPreferenceKey,
- value: &str,
- ) -> Result<(), SafeError>;
- /// Removes all internal values owned by an account.
- ///
- /// # Errors
- ///
- /// Returns a safe storage error when cleanup cannot be committed.
- fn clear_owner(&self, owner: PublicKey) -> Result<(), SafeError>;
-}
-
-pub trait AppStateRepository: Send + Sync {
- /// Loads the persisted selected account.
- ///
- /// # Errors
- ///
- /// Returns a safe storage error when application state cannot be read.
- fn load_selected_account(&self) -> Result<Option<PublicKey>, SafeError>;
- /// Persists the selected account or the empty selection.
- ///
- /// # Errors
- ///
- /// Returns a safe storage error when application state cannot be committed.
- fn save_selected_account(&self, public_key: Option<PublicKey>) -> Result<(), SafeError>;
-}
-
-pub trait OperationJournal: Send + Sync {
- /// Records one cross-resource account operation intent.
- ///
- /// # Errors
- ///
- /// Returns a safe storage error when the entry cannot be committed.
- fn begin_operation(
- &self,
- kind: AccountOperationKind,
- subject: PublicKey,
- updated_at: UnixTimestamp,
- ) -> Result<OperationId, SafeError>;
- /// Advances an operation to a durable recovery phase.
- ///
- /// # Errors
- ///
- /// Returns a safe storage error when the entry cannot be updated.
- fn update_operation(
- &self,
- id: OperationId,
- phase: AccountOperationPhase,
- updated_at: UnixTimestamp,
- diagnostic: Option<OperationDiagnostic>,
- ) -> Result<(), SafeError>;
- /// Loads all unfinished operations in deterministic order.
- ///
- /// # Errors
- ///
- /// Returns a safe storage error when entries cannot be read.
- fn list_pending_operations(&self) -> Result<Vec<PendingAccountOperation>, SafeError>;
- /// Deletes one fully reconciled operation entry.
- ///
- /// # Errors
- ///
- /// Returns a safe storage error when finalization cannot be committed.
- fn finalize_operation(&self, id: OperationId) -> Result<(), SafeError>;
-}
-
-pub trait DurableOperationRepository: Send + Sync {
- /// Records one idempotent durable operation or returns the existing matching request.
- ///
- /// # Errors
- ///
- /// Returns a safe conflict or storage error when the request cannot be recorded.
- #[allow(clippy::too_many_arguments)]
- fn begin_durable_operation(
- &self,
- request_id: &DurableRequestId,
- kind: DurableOperationKind,
- account: PublicKey,
- expected_revision: Option<u64>,
- prior: OperationPriorState,
- updated_at: UnixTimestamp,
- ) -> Result<DurableOperationStart, SafeError>;
- /// Loads one durable operation by its idempotency key.
- ///
- /// # Errors
- ///
- /// Returns a safe storage error when the lookup cannot complete.
- fn load_durable_operation(
- &self,
- request_id: &DurableRequestId,
- ) -> Result<Option<DurableAccountOperation>, SafeError>;
- /// Advances one operation only from the caller's expected phase.
- ///
- /// # Errors
- ///
- /// Returns a safe conflict or storage error when the transition cannot commit.
- fn advance_durable_operation(
- &self,
- request_id: &DurableRequestId,
- expected_phase: DurableOperationPhase,
- next_phase: DurableOperationPhase,
- updated_at: UnixTimestamp,
- diagnostic: Option<OperationDiagnostic>,
- ) -> Result<DurableAccountOperation, SafeError>;
- /// Finalizes one operation and durably retains its recoverable receipt.
- ///
- /// # Errors
- ///
- /// Returns a safe conflict or storage error when finalization cannot commit.
- fn finalize_durable_operation(
- &self,
- request_id: &DurableRequestId,
- expected_phase: DurableOperationPhase,
- outcome: DurableTerminalOutcome,
- resulting_revision: Option<u64>,
- updated_at: UnixTimestamp,
- ) -> Result<DurableOperationReceipt, SafeError>;
- /// Lists unfinished operations in deterministic request order.
- ///
- /// # Errors
- ///
- /// Returns a safe storage error when operations cannot be read.
- fn list_unfinished_durable_operations(&self)
- -> Result<Vec<DurableAccountOperation>, SafeError>;
-}
-
-pub trait NostrClient: Send + Sync {
- fn fetch_profile<'a>(
- &'a self,
- public_key: PublicKey,
- relays: &'a [RelayUrl],
- deadline: Instant,
- ) -> BoxFuture<'a, Result<ProfileFetchResult, SafeError>>;
-}
-
-pub struct GeneratedKeyMaterial {
- public_key: PublicKey,
- npub: Npub,
- secret: SecretKeyInput,
- nsec: Nsec,
-}
-
-impl GeneratedKeyMaterial {
- #[must_use]
- pub const fn new(
- public_key: PublicKey,
- npub: Npub,
- secret: SecretKeyInput,
- nsec: Nsec,
- ) -> Self {
- Self {
- public_key,
- npub,
- secret,
- nsec,
- }
- }
-
- #[must_use]
- pub fn into_parts(self) -> (PublicKey, Npub, SecretKeyInput, Nsec) {
- (self.public_key, self.npub, self.secret, self.nsec)
- }
-}
-
-pub struct ImportedKeyMaterial {
- public_key: PublicKey,
- npub: Npub,
- secret: SecretKeyInput,
-}
-
-impl ImportedKeyMaterial {
- #[must_use]
- pub const fn new(public_key: PublicKey, npub: Npub, secret: SecretKeyInput) -> Self {
- Self {
- public_key,
- npub,
- secret,
- }
- }
-
- #[must_use]
- pub fn into_parts(self) -> (PublicKey, Npub, SecretKeyInput) {
- (self.public_key, self.npub, self.secret)
- }
-}
-
-pub trait KeyMaterialProvider: Send + Sync {
- /// Generates one keypair from host-provided cryptographic entropy.
- ///
- /// # Errors
- ///
- /// Returns a redacted key or entropy error.
- fn generate(&self) -> Result<GeneratedKeyMaterial, SafeError>;
-
- /// Canonicalizes imported secret material and derives its public identity.
- ///
- /// # Errors
- ///
- /// Returns a redacted validation error.
- fn import(&self, input: SecretKeyInput) -> Result<ImportedKeyMaterial, SafeError>;
-}
-
-pub trait Clock: Send + Sync {
- fn now(&self) -> UnixTimestamp;
-}
-
-#[cfg(test)]
-mod tests {
- use std::time::Instant;
-
- use std::sync::Mutex;
-
- use radroots_studio_domain::{AccountSummary, PublicKey, RelayUrl, SafeError, UnixTimestamp};
-
- use super::{
- AccountNamespaceRepository, AccountOperationKind, AccountOperationPhase,
- AccountPreferenceKey, AccountRepository, AppStateRepository, BoxFuture, CachedProfile,
- Clock, DurableOperationReceipt, DurableRequestId, DurableTerminalOutcome, NostrClient,
- OperationDiagnostic, OperationId, OperationJournal, PendingAccountOperation,
- ProfileFetchResult, ProfileRefreshStatus, ProfileRepository,
- };
-
- #[test]
- fn durable_request_ids_and_terminal_receipts_are_bounded_and_public() {
- let request = DurableRequestId::parse("create:desktop:0001").expect("request id");
- let receipt = DurableOperationReceipt::new(
- request.clone(),
- PublicKey::from_bytes([7; 32]).expect("valid public key"),
- DurableTerminalOutcome::Completed,
- Some(42),
- );
- assert_eq!(receipt.request_id(), &request);
- assert_eq!(receipt.resulting_revision(), Some(42));
- for invalid in ["", "contains space", &"x".repeat(129)] {
- assert!(DurableRequestId::parse(invalid).is_err());
- }
- }
-
- #[derive(Default)]
- struct FakePorts {
- selected: Mutex<Option<PublicKey>>,
- }
-
- impl AccountRepository for FakePorts {
- fn list_accounts(&self) -> Result<Vec<AccountSummary>, SafeError> {
- Ok(Vec::new())
- }
-
- fn find_account(
- &self,
- _public_key: PublicKey,
- ) -> Result<Option<AccountSummary>, SafeError> {
- Ok(None)
- }
-
- fn insert_account(&self, _account: &AccountSummary) -> Result<(), SafeError> {
- Ok(())
- }
-
- fn update_account(&self, _account: &AccountSummary) -> Result<(), SafeError> {
- Ok(())
- }
-
- fn remove_account(&self, _public_key: PublicKey) -> Result<(), SafeError> {
- Ok(())
- }
- }
-
- impl ProfileRepository for FakePorts {
- fn load_profile(&self, _public_key: PublicKey) -> Result<Option<CachedProfile>, SafeError> {
- Ok(None)
- }
-
- fn save_profile(&self, _profile: &CachedProfile) -> Result<(), SafeError> {
- Ok(())
- }
-
- fn record_refresh_status(
- &self,
- _public_key: PublicKey,
- _refreshed_at: UnixTimestamp,
- _status: ProfileRefreshStatus,
- ) -> Result<(), SafeError> {
- Ok(())
- }
-
- fn remove_profile(&self, _public_key: PublicKey) -> Result<(), SafeError> {
- Ok(())
- }
- }
-
- impl AccountNamespaceRepository for FakePorts {
- fn get_value(
- &self,
- _owner: PublicKey,
- _key: AccountPreferenceKey,
- ) -> Result<Option<String>, SafeError> {
- Ok(None)
- }
-
- fn set_value(
- &self,
- _owner: PublicKey,
- _key: AccountPreferenceKey,
- _value: &str,
- ) -> Result<(), SafeError> {
- Ok(())
- }
-
- fn clear_owner(&self, _owner: PublicKey) -> Result<(), SafeError> {
- Ok(())
- }
- }
-
- impl AppStateRepository for FakePorts {
- fn load_selected_account(&self) -> Result<Option<PublicKey>, SafeError> {
- Ok(*self.selected.lock().expect("selected lock"))
- }
-
- fn save_selected_account(&self, public_key: Option<PublicKey>) -> Result<(), SafeError> {
- *self.selected.lock().expect("selected lock") = public_key;
- Ok(())
- }
- }
-
- impl OperationJournal for FakePorts {
- fn begin_operation(
- &self,
- _kind: AccountOperationKind,
- _subject: PublicKey,
- _updated_at: UnixTimestamp,
- ) -> Result<OperationId, SafeError> {
- Ok(OperationId::from_raw(1))
- }
-
- fn update_operation(
- &self,
- _id: OperationId,
- _phase: AccountOperationPhase,
- _updated_at: UnixTimestamp,
- _diagnostic: Option<OperationDiagnostic>,
- ) -> Result<(), SafeError> {
- Ok(())
- }
-
- fn list_pending_operations(&self) -> Result<Vec<PendingAccountOperation>, SafeError> {
- Ok(Vec::new())
- }
-
- fn finalize_operation(&self, _id: OperationId) -> Result<(), SafeError> {
- Ok(())
- }
- }
-
- impl NostrClient for FakePorts {
- fn fetch_profile<'a>(
- &'a self,
- _public_key: PublicKey,
- _relays: &'a [RelayUrl],
- _deadline: Instant,
- ) -> BoxFuture<'a, Result<ProfileFetchResult, SafeError>> {
- Box::pin(async { Ok(ProfileFetchResult::complete(None)) })
- }
- }
-
- impl Clock for FakePorts {
- fn now(&self) -> UnixTimestamp {
- UnixTimestamp::from_seconds(1).expect("valid fake time")
- }
- }
-
- fn assert_send_sync<T: Send + Sync>() {}
-
- #[test]
- fn ports_accept_send_sync_test_fakes() {
- assert_send_sync::<FakePorts>();
-
- let ports = FakePorts::default();
- ports
- .save_selected_account(Some(
- PublicKey::from_bytes([7_u8; 32]).expect("valid public key"),
- ))
- .expect("save selection");
- assert_eq!(
- ports.load_selected_account().expect("load selection"),
- Some(PublicKey::from_bytes([7_u8; 32]).expect("valid public key"))
- );
- assert_eq!(ports.now().as_seconds(), 1);
- }
-}
diff --git a/crates/studio_application/src/profile_refresh.rs b/crates/studio_application/src/profile_refresh.rs
@@ -1,659 +0,0 @@
-use radroots_studio_domain::{PublicKey, RelayUrl, SafeError, SafeErrorCode};
-use std::time::Instant;
-
-use crate::{
- ActiveAccountSnapshot, AppCore, AppSnapshot, CachedProfile, Clock, NostrClient,
- ProfileFetchResult, ProfileLoadState, ProfileRefreshStatus, ProfileRepository,
- RelayConnectionState, RelayFetchCompleteness, SnapshotRevision, StateTransition,
-};
-
-#[derive(Clone, Debug, Eq, PartialEq)]
-pub struct ProfileRefreshPlan {
- public_key: PublicKey,
- active_account: ActiveAccountSnapshot,
- relays: Vec<RelayUrl>,
- expected_revision: SnapshotRevision,
-}
-
-impl ProfileRefreshPlan {
- #[must_use]
- pub const fn public_key(&self) -> PublicKey {
- self.public_key
- }
-
- #[must_use]
- pub const fn active_account(&self) -> &ActiveAccountSnapshot {
- &self.active_account
- }
-
- #[must_use]
- pub fn relays(&self) -> &[RelayUrl] {
- &self.relays
- }
-
- #[must_use]
- pub const fn expected_revision(&self) -> SnapshotRevision {
- self.expected_revision
- }
-}
-
-impl AppCore {
- /// Manually refreshes the active account's Nostr kind-0 profile.
- ///
- /// Cached public metadata remains visible while the asynchronous request is
- /// running. Calling this command while signed out is an idempotent no-op.
- ///
- /// # Errors
- ///
- /// Returns a safe storage or application-state error. Relay and invalid-data
- /// failures are represented as nonfatal snapshot state.
- pub async fn refresh_active_profile(
- &self,
- profiles: &(impl ProfileRepository + ?Sized),
- client: &(impl NostrClient + ?Sized),
- clock: &(impl Clock + ?Sized),
- deadline: Instant,
- ) -> Result<AppSnapshot, SafeError> {
- self.refresh_profile_for_active_account(profiles, client, clock, deadline)
- .await
- }
-
- /// Refreshes the current active account while retaining any cached profile.
- ///
- /// Stale results are discarded when the account is replaced or signed out.
- ///
- /// # Errors
- ///
- /// Returns a safe storage or application-state error. Relay and invalid-data
- /// failures are represented as nonfatal snapshot state.
- async fn refresh_profile_for_active_account(
- &self,
- profiles: &(impl ProfileRepository + ?Sized),
- client: &(impl NostrClient + ?Sized),
- clock: &(impl Clock + ?Sized),
- deadline: Instant,
- ) -> Result<AppSnapshot, SafeError> {
- let Some(plan) = self.begin_profile_refresh()? else {
- return Ok(self.snapshot());
- };
- let result = client
- .fetch_profile(plan.public_key(), plan.relays(), deadline)
- .await;
- self.complete_profile_refresh(&plan, result, profiles, clock)
- }
-
- /// Begins a refresh on the actor and returns the immutable network plan.
- ///
- /// # Errors
- ///
- /// Returns a safe state error when the loading transition is invalid.
- pub fn begin_profile_refresh(&self) -> Result<Option<ProfileRefreshPlan>, SafeError> {
- let Some(active) = self.snapshot().active_account().cloned() else {
- return Ok(None);
- };
- let public_key = active.account().public_key();
- let loading = self.apply_transition(StateTransition::UpdateActiveAccount {
- expected: public_key,
- active_account: Box::new(ActiveAccountSnapshot::new(
- active.account().clone(),
- RelayConnectionState::Connecting,
- ProfileLoadState::Loading,
- active.profile().cloned(),
- )),
- problem: None,
- })?;
- Ok(Some(ProfileRefreshPlan {
- public_key,
- active_account: active,
- relays: loading.relay_configuration().relays().to_vec(),
- expected_revision: loading.revision(),
- }))
- }
-
- /// Applies a correlated refresh result on the actor.
- ///
- /// # Errors
- ///
- /// Returns a safe storage or application-state error. Stale results are
- /// discarded without persistence or publication.
- pub fn complete_profile_refresh(
- &self,
- plan: &ProfileRefreshPlan,
- result: Result<ProfileFetchResult, SafeError>,
- profiles: &(impl ProfileRepository + ?Sized),
- clock: &(impl Clock + ?Sized),
- ) -> Result<AppSnapshot, SafeError> {
- if !is_current_active(self, plan.public_key()) {
- return Ok(self.snapshot());
- }
-
- let current_active = self
- .snapshot()
- .active_account()
- .cloned()
- .ok_or_else(invalid_profile_completion)?;
-
- match result {
- Ok(fetched) => {
- let (candidate, completeness) = fetched.into_parts();
- self.complete_successful_profile_fetch(
- plan,
- current_active,
- candidate,
- completeness,
- profiles,
- clock,
- )
- }
- Err(error) => {
- let status = refresh_status(error);
- profiles.record_refresh_status(plan.public_key(), clock.now(), status)?;
- self.apply_transition(StateTransition::UpdateActiveAccount {
- expected: plan.public_key(),
- active_account: Box::new(ActiveAccountSnapshot::new(
- current_active.account().clone(),
- RelayConnectionState::Degraded,
- ProfileLoadState::Error(error),
- current_active.profile().cloned(),
- )),
- problem: Some(error),
- })
- }
- }
- }
-
- fn complete_successful_profile_fetch(
- &self,
- plan: &ProfileRefreshPlan,
- current_active: ActiveAccountSnapshot,
- candidate: Option<radroots_studio_domain::Kind0ProfileCandidate>,
- completeness: RelayFetchCompleteness,
- profiles: &(impl ProfileRepository + ?Sized),
- clock: &(impl Clock + ?Sized),
- ) -> Result<AppSnapshot, SafeError> {
- let relay_state = match completeness {
- RelayFetchCompleteness::Complete => RelayConnectionState::Connected,
- RelayFetchCompleteness::Partial => RelayConnectionState::Degraded,
- };
- let problem = match completeness {
- RelayFetchCompleteness::Complete => None,
- RelayFetchCompleteness::Partial => Some(partial_relay_result()),
- };
- match candidate {
- Some(candidate) => {
- let cached = CachedProfile::new(
- candidate.clone(),
- clock.now(),
- ProfileRefreshStatus::Success,
- );
- profiles.save_profile(&cached)?;
- let winning_profile = profiles.load_profile(plan.public_key())?.map_or_else(
- || candidate.metadata().clone(),
- |profile| profile.candidate().metadata().clone(),
- );
- self.apply_transition(StateTransition::UpdateActiveAccount {
- expected: plan.public_key(),
- active_account: Box::new(ActiveAccountSnapshot::new(
- current_active.account().clone(),
- relay_state,
- ProfileLoadState::Fresh,
- Some(winning_profile),
- )),
- problem,
- })
- }
- None => self.apply_transition(StateTransition::UpdateActiveAccount {
- expected: plan.public_key(),
- active_account: Box::new(ActiveAccountSnapshot::new(
- current_active.account().clone(),
- relay_state,
- if current_active.profile().is_some() {
- ProfileLoadState::Cached
- } else {
- ProfileLoadState::Empty
- },
- current_active.profile().cloned(),
- )),
- problem,
- }),
- }
- }
-}
-
-const fn partial_relay_result() -> SafeError {
- SafeError::new(
- SafeErrorCode::RelayConnectionFailed,
- radroots_studio_domain::SafeMessage::new("One or more Nostr relays did not complete."),
- )
-}
-
-const fn invalid_profile_completion() -> SafeError {
- SafeError::new(
- SafeErrorCode::InvalidApplicationState,
- radroots_studio_domain::SafeMessage::new("The active profile refresh is no longer valid."),
- )
-}
-
-fn is_current_active(core: &AppCore, public_key: PublicKey) -> bool {
- core.snapshot()
- .active_account()
- .is_some_and(|active| active.account().public_key() == public_key)
-}
-
-const fn refresh_status(error: SafeError) -> ProfileRefreshStatus {
- match error.code() {
- SafeErrorCode::InvalidProfileMetadata | SafeErrorCode::ProfileRefreshFailed => {
- ProfileRefreshStatus::InvalidData
- }
- _ => ProfileRefreshStatus::Offline,
- }
-}
-
-#[cfg(test)]
-mod tests {
- use std::sync::Mutex;
- use std::time::{Duration, Instant};
-
- use radroots_studio_domain::{
- EventId, Kind0ProfileCandidate, ProfileMetadata, PublicKey, RelayDestinationPolicy,
- RelayUrl, SafeError, SafeErrorCode, SafeMessage, SecretKeyInput, UnixTimestamp,
- select_latest_kind0,
- };
-
- use crate::{
- ActiveAccountSnapshot, AppCore, BoxFuture, CachedProfile, Clock, InMemoryAccountRepository,
- InMemoryOperationJournal, InMemorySecretStore, NostrClient, ProfileFetchResult,
- ProfileLoadState, ProfileRefreshStatus, ProfileRepository, RelayConfiguration,
- RelayConnectionState,
- };
-
- #[derive(Default)]
- struct MemoryProfiles(Mutex<Option<CachedProfile>>);
-
- impl ProfileRepository for MemoryProfiles {
- fn load_profile(&self, _public_key: PublicKey) -> Result<Option<CachedProfile>, SafeError> {
- Ok(self.0.lock().expect("profiles").clone())
- }
- fn save_profile(&self, profile: &CachedProfile) -> Result<(), SafeError> {
- let mut cached = self.0.lock().expect("profiles");
- let selected = cached.as_ref().map_or_else(
- || profile.clone(),
- |current| {
- let winner = select_latest_kind0([
- current.candidate().clone(),
- profile.candidate().clone(),
- ])
- .expect("two candidates");
- if &winner == current.candidate() {
- current.clone()
- } else {
- profile.clone()
- }
- },
- );
- *cached = Some(selected);
- Ok(())
- }
- fn record_refresh_status(
- &self,
- _public_key: PublicKey,
- refreshed_at: UnixTimestamp,
- status: ProfileRefreshStatus,
- ) -> Result<(), SafeError> {
- if let Some(profile) = self.0.lock().expect("profiles").as_mut() {
- *profile = CachedProfile::new(profile.candidate().clone(), refreshed_at, status);
- }
- Ok(())
- }
- fn remove_profile(&self, _public_key: PublicKey) -> Result<(), SafeError> {
- *self.0.lock().expect("profiles") = None;
- Ok(())
- }
- }
-
- struct FixedClock;
- impl Clock for FixedClock {
- fn now(&self) -> UnixTimestamp {
- UnixTimestamp::from_seconds(50).expect("time")
- }
- }
-
- struct FixedClient(Result<Option<Kind0ProfileCandidate>, SafeError>);
- impl NostrClient for FixedClient {
- fn fetch_profile<'a>(
- &'a self,
- _public_key: PublicKey,
- _relays: &'a [RelayUrl],
- _deadline: std::time::Instant,
- ) -> BoxFuture<'a, Result<ProfileFetchResult, SafeError>> {
- let result = self.0.clone();
- Box::pin(async move { result.map(ProfileFetchResult::complete) })
- }
- }
-
- struct BlockingClient {
- started: tokio::sync::Semaphore,
- release: tokio::sync::Semaphore,
- result: Result<Option<Kind0ProfileCandidate>, SafeError>,
- }
-
- impl BlockingClient {
- fn new(result: Result<Option<Kind0ProfileCandidate>, SafeError>) -> Self {
- Self {
- started: tokio::sync::Semaphore::new(0),
- release: tokio::sync::Semaphore::new(0),
- result,
- }
- }
- }
-
- impl NostrClient for BlockingClient {
- fn fetch_profile<'a>(
- &'a self,
- _public_key: PublicKey,
- _relays: &'a [RelayUrl],
- _deadline: std::time::Instant,
- ) -> BoxFuture<'a, Result<ProfileFetchResult, SafeError>> {
- Box::pin(async move {
- self.started.add_permits(1);
- let permit = self.release.acquire().await.expect("release open");
- permit.forget();
- self.result.clone().map(ProfileFetchResult::complete)
- })
- }
- }
-
- fn profile(public_key: PublicKey, name: &str, timestamp: i64) -> Kind0ProfileCandidate {
- Kind0ProfileCandidate::new(
- EventId::from_bytes([u8::try_from(timestamp).expect("small timestamp"); 32]),
- public_key,
- UnixTimestamp::from_seconds(timestamp).expect("time"),
- ProfileMetadata::new(Some(name.to_owned()), None, None, None, None).expect("profile"),
- )
- }
-
- fn active_core(profiles: &MemoryProfiles, cached_name: Option<&str>) -> (AppCore, PublicKey) {
- let relays = RelayConfiguration::new(vec![
- RelayUrl::parse("ws://localhost:8080", RelayDestinationPolicy::Local).expect("relay"),
- ])
- .expect("relay configuration");
- let core = AppCore::in_memory(relays);
- let accounts = InMemoryAccountRepository::default();
- let secrets = InMemorySecretStore::default();
- let journal = InMemoryOperationJournal::default();
- core.bootstrap().expect("bootstrap");
- let public_key = core
- .import_secret_key(
- SecretKeyInput::parse(
- "7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7".to_owned(),
- )
- .expect("secret"),
- &accounts,
- &accounts,
- &secrets,
- &journal,
- &FixedClock,
- )
- .expect("import")
- .account()
- .public_key();
- if let Some(name) = cached_name {
- profiles
- .save_profile(&CachedProfile::new(
- profile(public_key, name, 10),
- UnixTimestamp::from_seconds(11).expect("time"),
- ProfileRefreshStatus::Success,
- ))
- .expect("cache");
- }
- core.activate_account(
- public_key,
- &accounts,
- &accounts,
- profiles,
- &secrets,
- &FixedClock,
- )
- .expect("activate");
- (core, public_key)
- }
-
- #[tokio::test]
- async fn refresh_transitions_from_cache_through_loading_to_fresh_profile() {
- let profiles = MemoryProfiles::default();
- let (core, public_key) = active_core(&profiles, Some("Cached"));
- assert_eq!(
- core.snapshot()
- .active_account()
- .map(crate::ActiveAccountSnapshot::profile_state),
- Some(ProfileLoadState::Cached)
- );
- let plan = core
- .begin_profile_refresh()
- .expect("begin refresh")
- .expect("active refresh");
- let loading = core.snapshot();
- assert_eq!(
- loading
- .active_account()
- .map(crate::ActiveAccountSnapshot::profile_state),
- Some(ProfileLoadState::Loading)
- );
- assert_eq!(
- loading
- .active_account()
- .map(crate::ActiveAccountSnapshot::relay_state),
- Some(RelayConnectionState::Connecting)
- );
- let client = FixedClient(Ok(Some(profile(public_key, "Fresh", 20))));
- let result = client
- .fetch_profile(plan.public_key(), plan.relays(), deadline())
- .await;
- core.complete_profile_refresh(&plan, result, &profiles, &FixedClock)
- .expect("complete refresh");
- assert_eq!(
- core.snapshot()
- .active_account()
- .and_then(|active| active.profile())
- .and_then(ProfileMetadata::name),
- Some("Fresh")
- );
- }
-
- #[tokio::test]
- async fn refresh_failure_preserves_cached_profile_as_nonfatal_state() {
- let profiles = MemoryProfiles::default();
- let (core, public_key) = active_core(&profiles, Some("Cached"));
- let cached = profile(public_key, "Cached", 10);
- let error = SafeError::new(
- SafeErrorCode::RelayConnectionFailed,
- SafeMessage::new("The relay is offline."),
- );
-
- let snapshot = core
- .refresh_profile_for_active_account(
- &profiles,
- &FixedClient(Err(error)),
- &FixedClock,
- deadline(),
- )
- .await
- .expect("nonfatal refresh");
-
- assert_eq!(snapshot.recoverable_problem(), Some(error));
- assert_eq!(
- snapshot
- .active_account()
- .map(crate::ActiveAccountSnapshot::relay_state),
- Some(RelayConnectionState::Degraded)
- );
- assert_eq!(
- profiles
- .load_profile(public_key)
- .expect("load")
- .expect("cache")
- .candidate(),
- &cached
- );
- }
-
- #[tokio::test]
- async fn refresh_discards_stale_completion_after_sign_out() {
- let profiles = MemoryProfiles::default();
- let (core, public_key) = active_core(&profiles, Some("Cached"));
- let client = BlockingClient::new(Ok(Some(profile(public_key, "Stale", 20))));
-
- let refresh =
- core.refresh_profile_for_active_account(&profiles, &client, &FixedClock, deadline());
- let sign_out = async {
- let permit = client.started.acquire().await.expect("refresh starts");
- permit.forget();
- core.sign_out().expect("sign out");
- client.release.add_permits(1);
- };
- let (result, ()) = tokio::join!(refresh, sign_out);
-
- assert!(
- result
- .expect("stale result is harmless")
- .active_account()
- .is_none()
- );
- assert_eq!(
- profiles
- .load_profile(public_key)
- .expect("load")
- .expect("cached")
- .candidate()
- .metadata()
- .name(),
- Some("Cached")
- );
- }
-
- #[tokio::test]
- async fn manual_refresh_is_repeatable_and_signed_out_safe() {
- let profiles = MemoryProfiles::default();
- let (core, public_key) = active_core(&profiles, None);
- let first = core
- .refresh_active_profile(
- &profiles,
- &FixedClient(Ok(Some(profile(public_key, "First", 10)))),
- &FixedClock,
- deadline(),
- )
- .await
- .expect("first refresh");
- let second = core
- .refresh_active_profile(
- &profiles,
- &FixedClient(Ok(Some(profile(public_key, "Second", 20)))),
- &FixedClock,
- deadline(),
- )
- .await
- .expect("second refresh");
-
- assert!(second.revision() > first.revision());
- assert_eq!(
- second
- .active_account()
- .and_then(|active| active.profile())
- .and_then(ProfileMetadata::name),
- Some("Second")
- );
- let signed_out = core.sign_out().expect("sign out");
- let no_op = core
- .refresh_active_profile(&profiles, &FixedClient(Ok(None)), &FixedClock, deadline())
- .await
- .expect("signed-out no-op");
- assert_eq!(no_op, signed_out);
- }
-
- fn deadline() -> Instant {
- Instant::now() + Duration::from_secs(5)
- }
-
- #[test]
- fn partial_relay_success_retains_verified_data_and_marks_degraded_connectivity() {
- let profiles = MemoryProfiles::default();
- let (core, public_key) = active_core(&profiles, None);
- let plan = core
- .begin_profile_refresh()
- .expect("begin")
- .expect("active");
- let snapshot = core
- .complete_profile_refresh(
- &plan,
- Ok(ProfileFetchResult::partial(Some(profile(
- public_key, "Partial", 30,
- )))),
- &profiles,
- &FixedClock,
- )
- .expect("partial completion");
- let active = snapshot.active_account().expect("active account");
- assert_eq!(active.relay_state(), RelayConnectionState::Degraded);
- assert_eq!(active.profile_state(), ProfileLoadState::Fresh);
- assert_eq!(
- active.profile().and_then(ProfileMetadata::name),
- Some("Partial")
- );
- assert_eq!(
- snapshot.recoverable_problem().map(SafeError::code),
- Some(SafeErrorCode::RelayConnectionFailed)
- );
- }
-
- #[test]
- fn overlapping_refreshes_keep_the_newest_event_regardless_of_completion_order() {
- let profiles = MemoryProfiles::default();
- let (core, public_key) = active_core(&profiles, Some("Cached"));
- let first = core
- .begin_profile_refresh()
- .expect("first")
- .expect("active");
- let second = core
- .begin_profile_refresh()
- .expect("second")
- .expect("active");
-
- core.complete_profile_refresh(
- &second,
- Ok(ProfileFetchResult::complete(Some(profile(
- public_key, "Newest", 30,
- )))),
- &profiles,
- &FixedClock,
- )
- .expect("newest completes first");
- let final_snapshot = core
- .complete_profile_refresh(
- &first,
- Ok(ProfileFetchResult::complete(Some(profile(
- public_key, "Older", 20,
- )))),
- &profiles,
- &FixedClock,
- )
- .expect("older completes last");
-
- assert_eq!(
- final_snapshot
- .active_account()
- .and_then(ActiveAccountSnapshot::profile)
- .and_then(ProfileMetadata::name),
- Some("Newest")
- );
- assert_eq!(
- profiles
- .load_profile(public_key)
- .expect("cache")
- .expect("profile")
- .candidate()
- .metadata()
- .name(),
- Some("Newest")
- );
- }
-}
diff --git a/crates/studio_application/src/recovery.rs b/crates/studio_application/src/recovery.rs
@@ -1,788 +0,0 @@
-use radroots_studio_domain::{PublicKey, SafeError};
-
-use crate::{
- AccountOperationKind, AccountOperationPhase, AccountRepository, AppCore, AppStateRepository,
- Clock, DurableAccountOperation, DurableOperationKind, DurableOperationPhase,
- DurableOperationRepository, DurableTerminalOutcome, OperationJournal, SecretStore,
-};
-
-impl AppCore {
- /// Reconciles durable request operations before public state is restored.
- ///
- /// # Errors
- ///
- /// Returns a safe credential, persistence, or recovery error while retaining the operation
- /// at its last durable phase for a later retry.
- pub fn recover_durable_operations(
- &self,
- accounts: &(impl AccountRepository + ?Sized),
- app_state: &(impl AppStateRepository + ?Sized),
- secrets: &(impl SecretStore + ?Sized),
- operations: &(impl DurableOperationRepository + ?Sized),
- clock: &(impl Clock + ?Sized),
- ) -> Result<(), SafeError> {
- for operation in operations.list_unfinished_durable_operations()? {
- match operation.kind() {
- DurableOperationKind::Create
- | DurableOperationKind::Import
- | DurableOperationKind::Repair => recover_durable_addition(
- &operation, accounts, app_state, secrets, operations, clock,
- )?,
- DurableOperationKind::Remove => recover_durable_removal(
- &operation, accounts, app_state, secrets, operations, clock,
- )?,
- }
- }
- Ok(())
- }
-
- /// Reconciles non-secret cross-resource journal entries before bootstrap.
- ///
- /// An empty journal does not access the credential store.
- ///
- /// # Errors
- ///
- /// Returns a safe credential, persistence, or recovery error while retaining
- /// the unfinished journal entry for a later retry.
- pub fn recover_pending_operations(
- &self,
- accounts: &(impl AccountRepository + ?Sized),
- app_state: &(impl AppStateRepository + ?Sized),
- secrets: &(impl SecretStore + ?Sized),
- journal: &(impl OperationJournal + ?Sized),
- clock: &(impl Clock + ?Sized),
- ) -> Result<(), SafeError> {
- for operation in journal.list_pending_operations()? {
- match operation.kind() {
- AccountOperationKind::Remove => {
- recover_removal(&operation, accounts, app_state, secrets, journal, clock)?;
- }
- AccountOperationKind::Add | AccountOperationKind::Import => {
- recover_addition(&operation, accounts, secrets, journal, clock)?;
- }
- }
- }
- Ok(())
- }
-}
-
-fn recover_durable_removal(
- operation: &DurableAccountOperation,
- accounts: &(impl AccountRepository + ?Sized),
- app_state: &(impl AppStateRepository + ?Sized),
- secrets: &(impl SecretStore + ?Sized),
- operations: &(impl DurableOperationRepository + ?Sized),
- clock: &(impl Clock + ?Sized),
-) -> Result<(), SafeError> {
- let request = operation.request_id();
- let account = operation.account();
- let mut phase = operation.phase();
- if phase == DurableOperationPhase::IntentRecorded {
- if secrets.contains(account)? {
- secrets.delete(account)?;
- }
- operations.advance_durable_operation(
- request,
- phase,
- DurableOperationPhase::CredentialDeleted,
- clock.now(),
- None,
- )?;
- phase = DurableOperationPhase::CredentialDeleted;
- }
- if phase == DurableOperationPhase::CredentialDeleted {
- if accounts.find_account(account)?.is_some() {
- accounts.remove_account(account)?;
- }
- operations.advance_durable_operation(
- request,
- phase,
- DurableOperationPhase::MetadataDeleted,
- clock.now(),
- None,
- )?;
- phase = DurableOperationPhase::MetadataDeleted;
- }
- if phase == DurableOperationPhase::MetadataDeleted {
- app_state.save_selected_account(operation.prior().selected_account())?;
- operations.advance_durable_operation(
- request,
- phase,
- DurableOperationPhase::SelectionCommitted,
- clock.now(),
- None,
- )?;
- phase = DurableOperationPhase::SelectionCommitted;
- }
- if phase == DurableOperationPhase::SelectionCommitted {
- operations.finalize_durable_operation(
- request,
- phase,
- DurableTerminalOutcome::Completed,
- None,
- clock.now(),
- )?;
- }
- Ok(())
-}
-
-fn recover_durable_addition(
- operation: &DurableAccountOperation,
- accounts: &(impl AccountRepository + ?Sized),
- app_state: &(impl AppStateRepository + ?Sized),
- secrets: &(impl SecretStore + ?Sized),
- operations: &(impl DurableOperationRepository + ?Sized),
- clock: &(impl Clock + ?Sized),
-) -> Result<(), SafeError> {
- let request = operation.request_id();
- let account = operation.account();
- match operation.phase() {
- DurableOperationPhase::IntentRecorded => {
- if secrets.contains(account)? {
- secrets.delete(account)?;
- }
- operations.finalize_durable_operation(
- request,
- DurableOperationPhase::IntentRecorded,
- DurableTerminalOutcome::Failed,
- None,
- clock.now(),
- )?;
- }
- DurableOperationPhase::CredentialWritten => {
- let metadata = accounts.find_account(account)?;
- let committed = metadata.as_ref().is_some_and(|saved| {
- saved.signer().availability()
- == radroots_studio_domain::BindingAvailability::Available
- });
- if committed {
- operations.advance_durable_operation(
- request,
- DurableOperationPhase::CredentialWritten,
- DurableOperationPhase::MetadataCommitted,
- clock.now(),
- None,
- )?;
- finish_durable_selection(operation, app_state, operations, clock)?;
- } else {
- operations.advance_durable_operation(
- request,
- DurableOperationPhase::CredentialWritten,
- DurableOperationPhase::CompensationPending,
- clock.now(),
- None,
- )?;
- compensate_durable_addition(
- operation, accounts, app_state, secrets, operations, clock,
- )?;
- }
- }
- DurableOperationPhase::MetadataCommitted => {
- finish_durable_selection(operation, app_state, operations, clock)?;
- }
- DurableOperationPhase::SelectionCommitted => {
- operations.finalize_durable_operation(
- request,
- DurableOperationPhase::SelectionCommitted,
- DurableTerminalOutcome::Completed,
- None,
- clock.now(),
- )?;
- }
- DurableOperationPhase::CompensationPending => {
- compensate_durable_addition(
- operation, accounts, app_state, secrets, operations, clock,
- )?;
- }
- DurableOperationPhase::CredentialDeleted | DurableOperationPhase::MetadataDeleted => {
- operations.finalize_durable_operation(
- request,
- operation.phase(),
- DurableTerminalOutcome::Failed,
- None,
- clock.now(),
- )?;
- }
- DurableOperationPhase::Finalized => {}
- }
- Ok(())
-}
-
-fn finish_durable_selection(
- operation: &DurableAccountOperation,
- app_state: &(impl AppStateRepository + ?Sized),
- operations: &(impl DurableOperationRepository + ?Sized),
- clock: &(impl Clock + ?Sized),
-) -> Result<(), SafeError> {
- app_state.save_selected_account(Some(operation.account()))?;
- operations.advance_durable_operation(
- operation.request_id(),
- DurableOperationPhase::MetadataCommitted,
- DurableOperationPhase::SelectionCommitted,
- clock.now(),
- None,
- )?;
- operations.finalize_durable_operation(
- operation.request_id(),
- DurableOperationPhase::SelectionCommitted,
- DurableTerminalOutcome::Completed,
- None,
- clock.now(),
- )?;
- Ok(())
-}
-
-fn compensate_durable_addition(
- operation: &DurableAccountOperation,
- accounts: &(impl AccountRepository + ?Sized),
- app_state: &(impl AppStateRepository + ?Sized),
- secrets: &(impl SecretStore + ?Sized),
- operations: &(impl DurableOperationRepository + ?Sized),
- clock: &(impl Clock + ?Sized),
-) -> Result<(), SafeError> {
- if secrets.contains(operation.account())? {
- secrets.delete(operation.account())?;
- }
- if let Some(availability) = operation.prior().binding_availability() {
- if let Some(previous) = accounts.find_account(operation.account())? {
- accounts.update_account(&previous.with_binding_availability(availability))?;
- }
- } else if accounts.find_account(operation.account())?.is_some() {
- accounts.remove_account(operation.account())?;
- }
- app_state.save_selected_account(operation.prior().selected_account())?;
- operations.advance_durable_operation(
- operation.request_id(),
- DurableOperationPhase::CompensationPending,
- DurableOperationPhase::CredentialDeleted,
- clock.now(),
- None,
- )?;
- operations.finalize_durable_operation(
- operation.request_id(),
- DurableOperationPhase::CredentialDeleted,
- DurableTerminalOutcome::Failed,
- None,
- clock.now(),
- )?;
- Ok(())
-}
-
-fn recover_removal(
- operation: &crate::PendingAccountOperation,
- accounts: &(impl AccountRepository + ?Sized),
- app_state: &(impl AppStateRepository + ?Sized),
- secrets: &(impl SecretStore + ?Sized),
- journal: &(impl OperationJournal + ?Sized),
- clock: &(impl Clock + ?Sized),
-) -> Result<(), SafeError> {
- let public_key = operation.subject();
- if operation.phase() == AccountOperationPhase::IntentRecorded {
- match secrets.delete(public_key) {
- Ok(()) => {}
- Err(error)
- if error.code() == radroots_studio_domain::SafeErrorCode::CredentialMissing => {}
- Err(error) => return Err(error),
- }
- journal.update_operation(
- operation.id(),
- AccountOperationPhase::CredentialDeleted,
- clock.now(),
- None,
- )?;
- }
- if matches!(
- operation.phase(),
- AccountOperationPhase::IntentRecorded | AccountOperationPhase::CredentialDeleted
- ) {
- let registry = accounts.list_accounts()?;
- let selected = removal_fallback(®istry, app_state.load_selected_account()?, public_key);
- accounts.remove_account(public_key)?;
- app_state.save_selected_account(selected)?;
- journal.update_operation(
- operation.id(),
- AccountOperationPhase::MetadataDeleted,
- clock.now(),
- None,
- )?;
- }
- journal.finalize_operation(operation.id())
-}
-
-fn recover_addition(
- operation: &crate::PendingAccountOperation,
- accounts: &(impl AccountRepository + ?Sized),
- secrets: &(impl SecretStore + ?Sized),
- journal: &(impl OperationJournal + ?Sized),
- clock: &(impl Clock + ?Sized),
-) -> Result<(), SafeError> {
- let has_metadata = accounts.find_account(operation.subject())?.is_some();
- match operation.phase() {
- AccountOperationPhase::CredentialWritten | AccountOperationPhase::CompensationPending
- if !has_metadata =>
- {
- match secrets.delete(operation.subject()) {
- Ok(()) => {}
- Err(error)
- if error.code() == radroots_studio_domain::SafeErrorCode::CredentialMissing => {
- }
- Err(error) => return Err(error),
- }
- journal.update_operation(
- operation.id(),
- AccountOperationPhase::MetadataDeleted,
- clock.now(),
- None,
- )?;
- }
- _ => {}
- }
- journal.finalize_operation(operation.id())
-}
-
-fn removal_fallback(
- registry: &[radroots_studio_domain::AccountSummary],
- selected: Option<PublicKey>,
- removed: PublicKey,
-) -> Option<PublicKey> {
- if selected != Some(removed) {
- return selected;
- }
- let index = registry
- .iter()
- .position(|account| account.public_key() == removed)?;
- registry
- .get(index + 1)
- .or_else(|| index.checked_sub(1).and_then(|before| registry.get(before)))
- .map(radroots_studio_domain::AccountSummary::public_key)
-}
-
-#[cfg(test)]
-pub(crate) mod tests {
- use std::sync::{Mutex, MutexGuard};
-
- use radroots_studio_domain::{
- BindingAvailability, PublicKey, SafeError, SafeErrorCode, SafeMessage, SecretKeyInput,
- UnixTimestamp,
- };
-
- use super::*;
- use crate::{
- DurableOperationReceipt, DurableOperationStart, DurableRequestId, FailureSecretStore,
- InMemoryAccountRepository, InMemoryOperationJournal, InMemorySecretStore,
- RelayConfiguration, SecretStore, SecretStoreOperation,
- };
-
- struct FixedClock;
-
- impl Clock for FixedClock {
- fn now(&self) -> UnixTimestamp {
- UnixTimestamp::from_seconds(10).expect("time")
- }
- }
-
- pub(crate) struct TestDurableRepository {
- operation: Mutex<DurableAccountOperation>,
- return_existing: bool,
- }
-
- impl TestDurableRepository {
- pub(crate) fn new(operation: DurableAccountOperation) -> Self {
- Self {
- operation: Mutex::new(operation),
- return_existing: true,
- }
- }
-
- pub(crate) fn fresh(operation: DurableAccountOperation) -> Self {
- Self {
- operation: Mutex::new(operation),
- return_existing: false,
- }
- }
-
- pub(crate) fn operation(&self) -> MutexGuard<'_, DurableAccountOperation> {
- self.operation
- .lock()
- .unwrap_or_else(std::sync::PoisonError::into_inner)
- }
-
- fn replace(
- current: &DurableAccountOperation,
- phase: DurableOperationPhase,
- diagnostic: Option<crate::OperationDiagnostic>,
- terminal: Option<DurableOperationReceipt>,
- ) -> DurableAccountOperation {
- DurableAccountOperation::new(
- current.request_id().clone(),
- current.kind(),
- current.account(),
- current.expected_revision(),
- phase,
- current.prior(),
- current.updated_at(),
- diagnostic,
- terminal,
- )
- }
- }
-
- impl DurableOperationRepository for TestDurableRepository {
- fn begin_durable_operation(
- &self,
- _request_id: &DurableRequestId,
- _kind: DurableOperationKind,
- _account: PublicKey,
- _expected_revision: Option<u64>,
- _prior: crate::OperationPriorState,
- _updated_at: UnixTimestamp,
- ) -> Result<DurableOperationStart, SafeError> {
- let operation = self.operation().clone();
- Ok(if self.return_existing {
- DurableOperationStart::Existing(operation)
- } else {
- DurableOperationStart::Started(operation)
- })
- }
-
- fn load_durable_operation(
- &self,
- request_id: &DurableRequestId,
- ) -> Result<Option<DurableAccountOperation>, SafeError> {
- if !self.return_existing {
- return Ok(None);
- }
- let operation = self.operation();
- Ok((operation.request_id() == request_id).then(|| operation.clone()))
- }
-
- fn advance_durable_operation(
- &self,
- request_id: &DurableRequestId,
- expected_phase: DurableOperationPhase,
- next_phase: DurableOperationPhase,
- _updated_at: UnixTimestamp,
- diagnostic: Option<crate::OperationDiagnostic>,
- ) -> Result<DurableAccountOperation, SafeError> {
- let mut operation = self.operation();
- if operation.request_id() != request_id || operation.phase() != expected_phase {
- return Err(conflict());
- }
- *operation = Self::replace(&operation, next_phase, diagnostic, None);
- Ok(operation.clone())
- }
-
- fn finalize_durable_operation(
- &self,
- request_id: &DurableRequestId,
- expected_phase: DurableOperationPhase,
- outcome: DurableTerminalOutcome,
- resulting_revision: Option<u64>,
- _updated_at: UnixTimestamp,
- ) -> Result<DurableOperationReceipt, SafeError> {
- let mut operation = self.operation();
- if operation.request_id() != request_id || operation.phase() != expected_phase {
- return Err(conflict());
- }
- let receipt = DurableOperationReceipt::new(
- request_id.clone(),
- operation.account(),
- outcome,
- resulting_revision,
- );
- *operation = Self::replace(
- &operation,
- DurableOperationPhase::Finalized,
- operation.diagnostic(),
- Some(receipt.clone()),
- );
- Ok(receipt)
- }
-
- fn list_unfinished_durable_operations(
- &self,
- ) -> Result<Vec<DurableAccountOperation>, SafeError> {
- Ok(vec![self.operation().clone()])
- }
- }
-
- fn conflict() -> SafeError {
- SafeError::new(
- SafeErrorCode::InvalidApplicationState,
- SafeMessage::new("The test durable operation conflicted."),
- )
- }
-
- fn seeded() -> (
- AppCore,
- InMemoryAccountRepository,
- InMemorySecretStore,
- InMemoryOperationJournal,
- PublicKey,
- ) {
- let core = AppCore::in_memory(RelayConfiguration::default());
- let accounts = InMemoryAccountRepository::default();
- let secrets = InMemorySecretStore::default();
- let journal = InMemoryOperationJournal::default();
- core.bootstrap().expect("bootstrap");
- let receipt = core
- .generate_account(&accounts, &accounts, &secrets, &journal, &FixedClock)
- .expect("seed account");
- (
- core,
- accounts,
- secrets,
- journal,
- receipt.account().public_key(),
- )
- }
-
- pub(crate) fn operation(
- kind: DurableOperationKind,
- phase: DurableOperationPhase,
- account: PublicKey,
- prior_availability: Option<BindingAvailability>,
- ) -> DurableAccountOperation {
- DurableAccountOperation::new(
- DurableRequestId::parse(format!("{kind:?}-{phase:?}")).expect("durable request ID"),
- kind,
- account,
- Some(1),
- phase,
- crate::OperationPriorState::new(None, prior_availability),
- FixedClock.now(),
- None,
- None,
- )
- }
-
- fn run_durable(
- core: &AppCore,
- accounts: &InMemoryAccountRepository,
- secrets: &InMemorySecretStore,
- operation: DurableAccountOperation,
- ) -> DurableAccountOperation {
- let repository = TestDurableRepository::new(operation);
- core.recover_durable_operations(accounts, accounts, secrets, &repository, &FixedClock)
- .expect("durable recovery");
- repository.operation().clone()
- }
-
- #[test]
- fn durable_recovery_exercises_every_removal_phase_and_presence_branch() {
- for phase in [
- DurableOperationPhase::IntentRecorded,
- DurableOperationPhase::CredentialDeleted,
- DurableOperationPhase::MetadataDeleted,
- DurableOperationPhase::SelectionCommitted,
- DurableOperationPhase::Finalized,
- ] {
- let (core, accounts, secrets, _journal, public_key) = seeded();
- if phase != DurableOperationPhase::IntentRecorded {
- secrets.delete(public_key).expect("delete credential");
- }
- if matches!(
- phase,
- DurableOperationPhase::MetadataDeleted
- | DurableOperationPhase::SelectionCommitted
- | DurableOperationPhase::Finalized
- ) {
- accounts.remove_account(public_key).expect("remove account");
- }
- let recovered = run_durable(
- &core,
- &accounts,
- &secrets,
- operation(DurableOperationKind::Remove, phase, public_key, None),
- );
- assert_eq!(recovered.phase(), DurableOperationPhase::Finalized);
- }
-
- let (core, accounts, secrets, _journal, public_key) = seeded();
- secrets.delete(public_key).expect("delete credential");
- accounts.remove_account(public_key).expect("remove account");
- let recovered = run_durable(
- &core,
- &accounts,
- &secrets,
- operation(
- DurableOperationKind::Remove,
- DurableOperationPhase::IntentRecorded,
- public_key,
- None,
- ),
- );
- assert_eq!(recovered.phase(), DurableOperationPhase::Finalized);
- }
-
- #[test]
- fn durable_recovery_exercises_every_addition_phase_and_compensation_shape() {
- for phase in [
- DurableOperationPhase::IntentRecorded,
- DurableOperationPhase::CredentialWritten,
- DurableOperationPhase::MetadataCommitted,
- DurableOperationPhase::SelectionCommitted,
- DurableOperationPhase::CredentialDeleted,
- DurableOperationPhase::MetadataDeleted,
- DurableOperationPhase::Finalized,
- ] {
- let (core, accounts, secrets, _journal, public_key) = seeded();
- if phase == DurableOperationPhase::IntentRecorded {
- accounts
- .remove_account(public_key)
- .expect("remove metadata");
- }
- let recovered = run_durable(
- &core,
- &accounts,
- &secrets,
- operation(DurableOperationKind::Create, phase, public_key, None),
- );
- assert_eq!(recovered.phase(), DurableOperationPhase::Finalized);
- }
-
- for (prior, retain_metadata, retain_secret) in [
- (Some(BindingAvailability::CredentialMissing), true, true),
- (Some(BindingAvailability::CredentialMissing), false, true),
- (None, true, true),
- (None, false, false),
- ] {
- let (core, accounts, secrets, _journal, public_key) = seeded();
- if !retain_metadata {
- accounts
- .remove_account(public_key)
- .expect("remove metadata");
- }
- if !retain_secret {
- secrets.delete(public_key).expect("delete credential");
- }
- let recovered = run_durable(
- &core,
- &accounts,
- &secrets,
- operation(
- DurableOperationKind::Repair,
- DurableOperationPhase::CompensationPending,
- public_key,
- prior,
- ),
- );
- assert_eq!(recovered.phase(), DurableOperationPhase::Finalized);
- }
-
- let (core, accounts, secrets, _journal, public_key) = seeded();
- accounts
- .remove_account(public_key)
- .expect("remove metadata");
- let recovered = run_durable(
- &core,
- &accounts,
- &secrets,
- operation(
- DurableOperationKind::Import,
- DurableOperationPhase::CredentialWritten,
- public_key,
- None,
- ),
- );
- assert_eq!(recovered.phase(), DurableOperationPhase::Finalized);
-
- let (core, accounts, secrets, _journal, public_key) = seeded();
- accounts
- .remove_account(public_key)
- .expect("remove metadata");
- secrets.delete(public_key).expect("delete credential");
- let recovered = run_durable(
- &core,
- &accounts,
- &secrets,
- operation(
- DurableOperationKind::Create,
- DurableOperationPhase::IntentRecorded,
- public_key,
- None,
- ),
- );
- assert_eq!(recovered.phase(), DurableOperationPhase::Finalized);
- }
-
- #[test]
- fn pending_recovery_exercises_removal_and_addition_presence_branches() {
- for credential_present in [true, false] {
- let (core, accounts, secrets, journal, public_key) = seeded();
- if !credential_present {
- secrets.delete(public_key).expect("delete credential");
- accounts
- .save_selected_account(None)
- .expect("clear selection");
- }
- journal
- .begin_operation(AccountOperationKind::Remove, public_key, FixedClock.now())
- .expect("removal intent");
- core.recover_pending_operations(&accounts, &accounts, &secrets, &journal, &FixedClock)
- .expect("removal recovery");
- assert!(journal.list_pending_operations().unwrap().is_empty());
- }
-
- for (kind, metadata_present, credential_present) in [
- (AccountOperationKind::Add, false, true),
- (AccountOperationKind::Import, false, false),
- (AccountOperationKind::Add, true, true),
- ] {
- let (core, accounts, secrets, journal, public_key) = seeded();
- if !metadata_present {
- accounts
- .remove_account(public_key)
- .expect("remove metadata");
- }
- if !credential_present {
- secrets.delete(public_key).expect("delete credential");
- }
- let id = journal
- .begin_operation(kind, public_key, FixedClock.now())
- .expect("addition intent");
- journal
- .update_operation(
- id,
- AccountOperationPhase::CredentialWritten,
- FixedClock.now(),
- None,
- )
- .expect("credential phase");
- core.recover_pending_operations(&accounts, &accounts, &secrets, &journal, &FixedClock)
- .expect("addition recovery");
- assert!(journal.list_pending_operations().unwrap().is_empty());
- }
-
- let (core, accounts, _secrets, journal, public_key) = seeded();
- accounts
- .remove_account(public_key)
- .expect("remove metadata");
- let secrets = FailureSecretStore::default();
- secrets
- .put(
- public_key,
- SecretKeyInput::parse(
- "7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7".to_owned(),
- )
- .expect("secret"),
- )
- .expect("store credential");
- secrets.fail_next(SecretStoreOperation::Delete);
- let id = journal
- .begin_operation(AccountOperationKind::Add, public_key, FixedClock.now())
- .expect("addition intent");
- journal
- .update_operation(
- id,
- AccountOperationPhase::CredentialWritten,
- FixedClock.now(),
- None,
- )
- .expect("credential phase");
- assert!(
- core.recover_pending_operations(&accounts, &accounts, &secrets, &journal, &FixedClock,)
- .is_err()
- );
- }
-}
diff --git a/crates/studio_application/src/secrets.rs b/crates/studio_application/src/secrets.rs
@@ -1,308 +0,0 @@
-use std::collections::BTreeMap;
-use std::sync::{Mutex, MutexGuard};
-
-use radroots_studio_domain::{PublicKey, SafeError, SafeErrorCode, SafeMessage, SecretKeyInput};
-use secrecy::{ExposeSecret, SecretString};
-
-pub trait SecretStore: Send + Sync {
- /// Stores a credential under its canonical public key without overwriting.
- ///
- /// # Errors
- ///
- /// Returns a safe duplicate or keyring error without exposing the credential.
- fn put(&self, public_key: PublicKey, secret: SecretKeyInput) -> Result<(), SafeError>;
- /// Loads a credential into a non-cloneable redacted boundary value.
- ///
- /// # Errors
- ///
- /// Returns a safe missing-credential or keyring error.
- fn load(&self, public_key: PublicKey) -> Result<SecretKeyInput, SafeError>;
- /// Reports whether a credential exists without exposing it.
- ///
- /// # Errors
- ///
- /// Returns a safe keyring error when availability cannot be determined.
- fn contains(&self, public_key: PublicKey) -> Result<bool, SafeError>;
- /// Deletes a credential without affecting public account metadata.
- ///
- /// # Errors
- ///
- /// Returns a safe missing-credential or keyring error.
- fn delete(&self, public_key: PublicKey) -> Result<(), SafeError>;
-}
-
-#[derive(Default)]
-pub struct InMemorySecretStore {
- credentials: Mutex<BTreeMap<PublicKey, SecretString>>,
-}
-
-#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
-pub enum SecretStoreOperation {
- Put,
- Load,
- Contains,
- Delete,
-}
-
-#[derive(Clone, Copy, Debug, Eq, PartialEq)]
-pub struct SecretStoreCall {
- operation: SecretStoreOperation,
- public_key: PublicKey,
-}
-
-impl SecretStoreCall {
- #[must_use]
- pub const fn operation(self) -> SecretStoreOperation {
- self.operation
- }
-
- #[must_use]
- pub const fn public_key(self) -> PublicKey {
- self.public_key
- }
-}
-
-#[derive(Default)]
-pub struct FailureSecretStore {
- inner: InMemorySecretStore,
- remaining_failures: Mutex<BTreeMap<SecretStoreOperation, usize>>,
- calls: Mutex<Vec<SecretStoreCall>>,
-}
-
-impl FailureSecretStore {
- pub fn fail_next(&self, operation: SecretStoreOperation) {
- *self
- .remaining_failures
- .lock()
- .unwrap_or_else(std::sync::PoisonError::into_inner)
- .entry(operation)
- .or_default() += 1;
- }
-
- #[must_use]
- pub fn calls(&self) -> Vec<SecretStoreCall> {
- self.calls
- .lock()
- .unwrap_or_else(std::sync::PoisonError::into_inner)
- .clone()
- }
-
- fn record_and_should_fail(
- &self,
- operation: SecretStoreOperation,
- public_key: PublicKey,
- ) -> bool {
- self.calls
- .lock()
- .unwrap_or_else(std::sync::PoisonError::into_inner)
- .push(SecretStoreCall {
- operation,
- public_key,
- });
- let mut failures = self
- .remaining_failures
- .lock()
- .unwrap_or_else(std::sync::PoisonError::into_inner);
- let remaining = failures.entry(operation).or_default();
- let should_fail = *remaining > 0;
- *remaining = remaining.saturating_sub(1);
- should_fail
- }
-}
-
-impl SecretStore for FailureSecretStore {
- fn put(&self, public_key: PublicKey, secret: SecretKeyInput) -> Result<(), SafeError> {
- if self.record_and_should_fail(SecretStoreOperation::Put, public_key) {
- return Err(keyring_unavailable());
- }
- self.inner.put(public_key, secret)
- }
-
- fn load(&self, public_key: PublicKey) -> Result<SecretKeyInput, SafeError> {
- if self.record_and_should_fail(SecretStoreOperation::Load, public_key) {
- return Err(keyring_unavailable());
- }
- self.inner.load(public_key)
- }
-
- fn contains(&self, public_key: PublicKey) -> Result<bool, SafeError> {
- if self.record_and_should_fail(SecretStoreOperation::Contains, public_key) {
- return Err(keyring_unavailable());
- }
- self.inner.contains(public_key)
- }
-
- fn delete(&self, public_key: PublicKey) -> Result<(), SafeError> {
- if self.record_and_should_fail(SecretStoreOperation::Delete, public_key) {
- return Err(keyring_unavailable());
- }
- self.inner.delete(public_key)
- }
-}
-
-impl InMemorySecretStore {
- fn credentials(&self) -> MutexGuard<'_, BTreeMap<PublicKey, SecretString>> {
- self.credentials
- .lock()
- .unwrap_or_else(std::sync::PoisonError::into_inner)
- }
-}
-
-impl SecretStore for InMemorySecretStore {
- fn put(&self, public_key: PublicKey, secret: SecretKeyInput) -> Result<(), SafeError> {
- let mut credentials = self.credentials();
- if credentials.contains_key(&public_key) {
- return Err(credential_exists());
- }
- let value = secret.with_exposed_secret(ToOwned::to_owned);
- credentials.insert(public_key, SecretString::from(value));
- Ok(())
- }
-
- fn load(&self, public_key: PublicKey) -> Result<SecretKeyInput, SafeError> {
- let credentials = self.credentials();
- let secret = credentials
- .get(&public_key)
- .ok_or_else(credential_missing)?;
- SecretKeyInput::parse(secret.expose_secret().to_owned()).map_err(|_| credential_missing())
- }
-
- fn contains(&self, public_key: PublicKey) -> Result<bool, SafeError> {
- Ok(self.credentials().contains_key(&public_key))
- }
-
- fn delete(&self, public_key: PublicKey) -> Result<(), SafeError> {
- self.credentials()
- .remove(&public_key)
- .map(|_| ())
- .ok_or_else(credential_missing)
- }
-}
-
-const fn credential_exists() -> SafeError {
- SafeError::new(
- SafeErrorCode::AccountAlreadyExists,
- SafeMessage::new("The Nostr account credential already exists."),
- )
-}
-
-const fn credential_missing() -> SafeError {
- SafeError::new(
- SafeErrorCode::CredentialMissing,
- SafeMessage::new("The Nostr account credential is missing."),
- )
-}
-
-const fn keyring_unavailable() -> SafeError {
- SafeError::new(
- SafeErrorCode::KeyringUnavailable,
- SafeMessage::new("The operating system credential store is unavailable."),
- )
-}
-
-#[cfg(test)]
-mod tests {
- use radroots_studio_domain::{PublicKey, SafeErrorCode, SecretKeyInput};
-
- use super::{FailureSecretStore, InMemorySecretStore, SecretStore, SecretStoreOperation};
-
- const SECRET: &str = "7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7";
-
- #[test]
- fn secret_store_puts_loads_checks_and_deletes_redacted_credentials() {
- let store = InMemorySecretStore::default();
- let public_key = PublicKey::from_bytes([7; 32]).expect("valid public key");
- assert!(!store.contains(public_key).expect("contains"));
- store
- .put(
- public_key,
- SecretKeyInput::parse(SECRET.to_owned()).expect("secret"),
- )
- .expect("put");
- assert!(store.contains(public_key).expect("contains"));
- let loaded = store.load(public_key).expect("load");
- assert_eq!(loaded.with_exposed_secret(str::len), 64);
- store.delete(public_key).expect("delete");
- assert!(!store.contains(public_key).expect("contains"));
- }
-
- #[test]
- fn secret_store_rejects_duplicates_and_reports_missing_credentials() {
- let store = InMemorySecretStore::default();
- let public_key = PublicKey::from_bytes([7; 32]).expect("valid public key");
- let Err(missing) = store.load(public_key) else {
- panic!("missing credential was returned");
- };
- assert_eq!(missing.code(), SafeErrorCode::CredentialMissing);
- store
- .put(
- public_key,
- SecretKeyInput::parse(SECRET.to_owned()).expect("secret"),
- )
- .expect("put");
- let duplicate = store
- .put(
- public_key,
- SecretKeyInput::parse(SECRET.to_owned()).expect("secret"),
- )
- .expect_err("duplicate");
- assert_eq!(duplicate.code(), SafeErrorCode::AccountAlreadyExists);
- store.delete(public_key).expect("delete");
- let missing = store.delete(public_key).expect_err("missing delete");
- assert_eq!(missing.code(), SafeErrorCode::CredentialMissing);
- }
-
- #[test]
- fn failure_secret_store_injects_each_boundary_without_mutating_state() {
- let store = FailureSecretStore::default();
- let public_key = PublicKey::from_bytes([7; 32]).expect("valid public key");
- store.fail_next(SecretStoreOperation::Put);
- let error = store
- .put(
- public_key,
- SecretKeyInput::parse(SECRET.to_owned()).expect("secret"),
- )
- .expect_err("put failure");
- assert_eq!(error.code(), SafeErrorCode::KeyringUnavailable);
- assert!(!store.contains(public_key).expect("not written"));
-
- store
- .put(
- public_key,
- SecretKeyInput::parse(SECRET.to_owned()).expect("secret"),
- )
- .expect("put");
- for operation in [
- SecretStoreOperation::Load,
- SecretStoreOperation::Contains,
- SecretStoreOperation::Delete,
- ] {
- store.fail_next(operation);
- let error = match operation {
- SecretStoreOperation::Load => store.load(public_key).map(|_| ()),
- SecretStoreOperation::Contains => store.contains(public_key).map(|_| ()),
- SecretStoreOperation::Delete => store.delete(public_key),
- SecretStoreOperation::Put => unreachable!("put tested separately"),
- }
- .expect_err("injected failure");
- assert_eq!(error.code(), SafeErrorCode::KeyringUnavailable);
- }
- assert!(store.contains(public_key).expect("credential retained"));
- }
-
- #[test]
- fn failure_secret_store_call_log_contains_only_public_identity() {
- let store = FailureSecretStore::default();
- let public_key = PublicKey::from_bytes([7; 32]).expect("valid public key");
- store
- .put(
- public_key,
- SecretKeyInput::parse(SECRET.to_owned()).expect("secret"),
- )
- .expect("put");
- let calls = store.calls();
- assert_eq!(calls[0].operation(), SecretStoreOperation::Put);
- assert_eq!(calls[0].public_key(), public_key);
- assert!(!format!("{calls:?}").contains(SECRET));
- }
-}
diff --git a/crates/studio_application/src/session.rs b/crates/studio_application/src/session.rs
@@ -1,268 +0,0 @@
-use crate::{
- AccountRepository, ActiveAccountSnapshot, AppCore, AppSnapshot, AppStateRepository, Clock,
- ProfileLoadState, ProfileRepository, RelayConnectionState, SecretStore, StateTransition,
-};
-use radroots_studio_domain::{PublicKey, SafeError, SafeErrorCode, SafeMessage};
-
-impl AppCore {
- /// Drops the active session while retaining accounts, selection, and credentials.
- ///
- /// # Errors
- ///
- /// Returns a safe application-state error if the transition cannot be applied.
- pub fn sign_out(&self) -> Result<AppSnapshot, SafeError> {
- if matches!(self.snapshot().session(), crate::SessionState::SignedOut) {
- return Ok(self.snapshot());
- }
- self.apply_transition(StateTransition::SignOut)
- }
-
- /// Validates and prepares a saved local account before replacing the active session.
- ///
- /// # Errors
- ///
- /// Returns a safe account, credential, profile-cache, persistence, or state
- /// error while preserving any previously active session.
- pub fn activate_account(
- &self,
- public_key: PublicKey,
- accounts: &(impl AccountRepository + ?Sized),
- app_state: &(impl AppStateRepository + ?Sized),
- profiles: &(impl ProfileRepository + ?Sized),
- secrets: &(impl SecretStore + ?Sized),
- clock: &(impl Clock + ?Sized),
- ) -> Result<AppSnapshot, SafeError> {
- let account = accounts
- .find_account(public_key)?
- .ok_or_else(account_not_found)?;
- self.apply_transition(StateTransition::BeginActivation(public_key))?;
- let prepared = (|| {
- let credential = secrets.load(public_key)?;
- let imported = self.key_material().import(credential)?;
- let (derived_public_key, _npub, canonical_secret) = imported.into_parts();
- drop(canonical_secret);
- if derived_public_key != public_key {
- return Err(invalid_credential());
- }
- let cached = profiles.load_profile(public_key)?;
- let active = ActiveAccountSnapshot::new(
- account.with_last_used_at(clock.now()),
- RelayConnectionState::Disconnected,
- if cached.is_some() {
- ProfileLoadState::Cached
- } else {
- ProfileLoadState::Empty
- },
- cached.map(|profile| profile.candidate().metadata().clone()),
- );
- accounts.update_account(active.account())?;
- app_state.save_selected_account(Some(public_key))?;
- Ok(active)
- })();
- match prepared {
- Ok(active) => {
- self.apply_transition(StateTransition::ActivationSucceeded(Box::new(active)))
- }
- Err(error) => {
- self.apply_transition(StateTransition::ActivationFailed(error))?;
- Err(error)
- }
- }
- }
-}
-
-const fn account_not_found() -> SafeError {
- SafeError::new(
- SafeErrorCode::AccountNotFound,
- SafeMessage::new("The account was not found."),
- )
-}
-
-const fn invalid_credential() -> SafeError {
- SafeError::new(
- SafeErrorCode::InvalidSecretKey,
- SafeMessage::new("The Nostr account credential is invalid."),
- )
-}
-
-#[cfg(test)]
-mod tests {
- use radroots_studio_domain::{PublicKey, SafeError, SecretKeyInput, UnixTimestamp};
-
- use crate::{
- AppCore, CachedProfile, Clock, InMemoryAccountRepository, InMemoryOperationJournal,
- InMemorySecretStore, ProfileRefreshStatus, ProfileRepository, RelayConfiguration,
- SecretStore, SessionState,
- };
-
- #[derive(Default)]
- struct EmptyProfiles;
-
- impl ProfileRepository for EmptyProfiles {
- fn load_profile(&self, _public_key: PublicKey) -> Result<Option<CachedProfile>, SafeError> {
- Ok(None)
- }
-
- fn save_profile(&self, _profile: &CachedProfile) -> Result<(), SafeError> {
- Ok(())
- }
-
- fn record_refresh_status(
- &self,
- _public_key: PublicKey,
- _refreshed_at: UnixTimestamp,
- _status: ProfileRefreshStatus,
- ) -> Result<(), SafeError> {
- Ok(())
- }
-
- fn remove_profile(&self, _public_key: PublicKey) -> Result<(), SafeError> {
- Ok(())
- }
- }
-
- struct FixedClock;
-
- impl Clock for FixedClock {
- fn now(&self) -> UnixTimestamp {
- UnixTimestamp::from_seconds(30).expect("time")
- }
- }
-
- fn input(value: &str) -> SecretKeyInput {
- SecretKeyInput::parse(value.to_owned()).expect("input")
- }
-
- #[test]
- fn activate_account_switches_only_after_candidate_is_ready() {
- let core = AppCore::in_memory(RelayConfiguration::default());
- let accounts = InMemoryAccountRepository::default();
- let secrets = InMemorySecretStore::default();
- let journal = InMemoryOperationJournal::default();
- let profiles = EmptyProfiles;
- core.bootstrap().expect("bootstrap");
- let first = core
- .import_secret_key(
- input("7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7"),
- &accounts,
- &accounts,
- &secrets,
- &journal,
- &FixedClock,
- )
- .expect("first")
- .account()
- .public_key();
- let second = core
- .import_secret_key(
- input("1111111111111111111111111111111111111111111111111111111111111111"),
- &accounts,
- &accounts,
- &secrets,
- &journal,
- &FixedClock,
- )
- .expect("second")
- .account()
- .public_key();
- core.activate_account(
- first,
- &accounts,
- &accounts,
- &profiles,
- &secrets,
- &FixedClock,
- )
- .expect("activate first");
- assert_eq!(core.snapshot().session(), SessionState::Active);
- assert_eq!(
- core.snapshot()
- .active_account()
- .map(|active| active.account().public_key()),
- Some(first)
- );
-
- secrets.delete(second).expect("remove second credential");
- let error = core
- .activate_account(
- second,
- &accounts,
- &accounts,
- &profiles,
- &secrets,
- &FixedClock,
- )
- .expect_err("missing credential");
- assert_eq!(
- error.code(),
- radroots_studio_domain::SafeErrorCode::CredentialMissing
- );
- secrets
- .put(
- second,
- input("7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7"),
- )
- .expect("mismatched credential");
- let invalid = core
- .activate_account(
- second,
- &accounts,
- &accounts,
- &profiles,
- &secrets,
- &FixedClock,
- )
- .expect_err("mismatched credential");
- assert_eq!(
- invalid.code(),
- radroots_studio_domain::SafeErrorCode::InvalidSecretKey
- );
- assert_eq!(core.snapshot().session(), SessionState::Active);
- assert_eq!(
- core.snapshot()
- .active_account()
- .map(|active| active.account().public_key()),
- Some(first)
- );
- }
-
- #[test]
- fn sign_out_retains_saved_account_selection_and_credential() {
- let core = AppCore::in_memory(RelayConfiguration::default());
- let accounts = InMemoryAccountRepository::default();
- let secrets = InMemorySecretStore::default();
- let journal = InMemoryOperationJournal::default();
- let profiles = EmptyProfiles;
- core.bootstrap().expect("bootstrap");
- let public_key = core
- .import_secret_key(
- input("7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7"),
- &accounts,
- &accounts,
- &secrets,
- &journal,
- &FixedClock,
- )
- .expect("import")
- .account()
- .public_key();
- core.activate_account(
- public_key,
- &accounts,
- &accounts,
- &profiles,
- &secrets,
- &FixedClock,
- )
- .expect("activate");
-
- let signed_out = core.sign_out().expect("sign out");
- let repeated = core.sign_out().expect("idempotent sign out");
- assert_eq!(signed_out, repeated);
- assert_eq!(signed_out.session(), SessionState::SignedOut);
- assert!(signed_out.active_account().is_none());
- assert_eq!(signed_out.accounts().len(), 1);
- assert_eq!(signed_out.selected_account(), Some(public_key));
- assert!(secrets.contains(public_key).expect("credential retained"));
- }
-}
diff --git a/crates/studio_application/src/snapshot.rs b/crates/studio_application/src/snapshot.rs
@@ -1,479 +0,0 @@
-use std::collections::HashSet;
-
-use radroots_studio_domain::{
- AccountSummary, ProfileMetadata, PublicKey, RelayUrl, SafeError, SafeErrorCode, SafeMessage,
-};
-
-pub const MAX_CONFIGURED_RELAYS: usize = 16;
-
-#[derive(Clone, Copy, Debug, Default, Eq, Ord, PartialEq, PartialOrd)]
-pub struct SnapshotRevision(u64);
-
-impl SnapshotRevision {
- #[must_use]
- pub const fn initial() -> Self {
- Self(0)
- }
-
- #[must_use]
- pub const fn from_value(value: u64) -> Self {
- Self(value)
- }
-
- #[must_use]
- pub const fn value(self) -> u64 {
- self.0
- }
-
- #[must_use]
- pub const fn next(self) -> Option<Self> {
- match self.0.checked_add(1) {
- Some(value) => Some(Self(value)),
- None => None,
- }
- }
-}
-
-#[derive(Clone, Copy, Debug, Eq, PartialEq)]
-pub enum AppLifecycle {
- Booting,
- Ready,
- Fatal(SafeError),
-}
-
-#[derive(Clone, Copy, Debug, Eq, PartialEq)]
-pub enum SessionState {
- SignedOut,
- Activating(PublicKey),
- Active,
- SigningOut,
- Failed(SafeError),
-}
-
-#[derive(Clone, Copy, Debug, Eq, PartialEq)]
-pub enum RelayConnectionState {
- Disconnected,
- Connecting,
- Connected,
- Degraded,
- Error(SafeError),
-}
-
-#[derive(Clone, Copy, Debug, Eq, PartialEq)]
-pub enum ProfileLoadState {
- Empty,
- Loading,
- Cached,
- Fresh,
- Error(SafeError),
-}
-
-#[derive(Clone, Debug, Default, Eq, PartialEq)]
-pub struct RelayConfiguration(Vec<RelayUrl>);
-
-impl RelayConfiguration {
- /// Creates a bounded, explicitly classified relay configuration.
- ///
- /// # Errors
- ///
- /// Returns a safe configuration error before runtime or network work when
- /// the relay count exceeds the Studio policy.
- pub fn new(relays: Vec<RelayUrl>) -> Result<Self, SafeError> {
- if relays.len() > MAX_CONFIGURED_RELAYS {
- return Err(relay_limit_exceeded());
- }
- Ok(Self(relays))
- }
-
- #[must_use]
- pub fn relays(&self) -> &[RelayUrl] {
- &self.0
- }
-}
-
-const fn relay_limit_exceeded() -> SafeError {
- SafeError::new(
- SafeErrorCode::InvalidRelayConfiguration,
- SafeMessage::new("The Nostr relay configuration exceeds its limit."),
- )
-}
-
-#[derive(Clone, Debug, Eq, PartialEq)]
-pub struct ActiveAccountSnapshot {
- account: AccountSummary,
- relay_state: RelayConnectionState,
- profile_state: ProfileLoadState,
- profile: Option<ProfileMetadata>,
-}
-
-impl ActiveAccountSnapshot {
- #[must_use]
- pub const fn new(
- account: AccountSummary,
- relay_state: RelayConnectionState,
- profile_state: ProfileLoadState,
- profile: Option<ProfileMetadata>,
- ) -> Self {
- Self {
- account,
- relay_state,
- profile_state,
- profile,
- }
- }
-
- #[must_use]
- pub const fn account(&self) -> &AccountSummary {
- &self.account
- }
-
- #[must_use]
- pub const fn relay_state(&self) -> RelayConnectionState {
- self.relay_state
- }
-
- #[must_use]
- pub const fn profile_state(&self) -> ProfileLoadState {
- self.profile_state
- }
-
- #[must_use]
- pub const fn profile(&self) -> Option<&ProfileMetadata> {
- self.profile.as_ref()
- }
-}
-
-#[derive(Clone, Debug, Eq, PartialEq)]
-pub struct AppSnapshot {
- revision: SnapshotRevision,
- lifecycle: AppLifecycle,
- relay_configuration: RelayConfiguration,
- accounts: Vec<AccountSummary>,
- selected_account: Option<PublicKey>,
- session: SessionState,
- active_account: Option<ActiveAccountSnapshot>,
- recoverable_problem: Option<SafeError>,
-}
-
-impl AppSnapshot {
- #[must_use]
- pub fn booting() -> Self {
- Self {
- revision: SnapshotRevision::initial(),
- lifecycle: AppLifecycle::Booting,
- relay_configuration: RelayConfiguration::default(),
- accounts: Vec::new(),
- selected_account: None,
- session: SessionState::SignedOut,
- active_account: None,
- recoverable_problem: None,
- }
- }
-
- #[must_use]
- pub fn fatal(
- revision: SnapshotRevision,
- relay_configuration: RelayConfiguration,
- error: SafeError,
- ) -> Self {
- Self {
- revision,
- lifecycle: AppLifecycle::Fatal(error),
- relay_configuration,
- accounts: Vec::new(),
- selected_account: None,
- session: SessionState::SignedOut,
- active_account: None,
- recoverable_problem: None,
- }
- }
-
- /// Constructs a ready immutable snapshot after validating state invariants.
- ///
- /// # Errors
- ///
- /// Returns a safe invalid-state error for duplicate accounts, invalid
- /// selection, or inconsistent active-session state.
- pub fn ready(
- revision: SnapshotRevision,
- relay_configuration: RelayConfiguration,
- accounts: Vec<AccountSummary>,
- selected_account: Option<PublicKey>,
- session: SessionState,
- active_account: Option<ActiveAccountSnapshot>,
- recoverable_problem: Option<SafeError>,
- ) -> Result<Self, SafeError> {
- validate_snapshot(
- &accounts,
- selected_account,
- session,
- active_account.as_ref(),
- )?;
- Ok(Self {
- revision,
- lifecycle: AppLifecycle::Ready,
- relay_configuration,
- accounts,
- selected_account,
- session,
- active_account,
- recoverable_problem,
- })
- }
-
- #[must_use]
- pub const fn revision(&self) -> SnapshotRevision {
- self.revision
- }
-
- #[must_use]
- pub const fn lifecycle(&self) -> AppLifecycle {
- self.lifecycle
- }
-
- #[must_use]
- pub const fn relay_configuration(&self) -> &RelayConfiguration {
- &self.relay_configuration
- }
-
- #[must_use]
- pub fn accounts(&self) -> &[AccountSummary] {
- &self.accounts
- }
-
- #[must_use]
- pub const fn selected_account(&self) -> Option<PublicKey> {
- self.selected_account
- }
-
- #[must_use]
- pub const fn session(&self) -> SessionState {
- self.session
- }
-
- #[must_use]
- pub const fn active_account(&self) -> Option<&ActiveAccountSnapshot> {
- self.active_account.as_ref()
- }
-
- #[must_use]
- pub const fn recoverable_problem(&self) -> Option<SafeError> {
- self.recoverable_problem
- }
-}
-
-fn validate_snapshot(
- accounts: &[AccountSummary],
- selected_account: Option<PublicKey>,
- session: SessionState,
- active_account: Option<&ActiveAccountSnapshot>,
-) -> Result<(), SafeError> {
- let unique_accounts = accounts
- .iter()
- .map(AccountSummary::public_key)
- .collect::<HashSet<_>>();
- if unique_accounts.len() != accounts.len()
- || (accounts.is_empty() != selected_account.is_none())
- || selected_account.is_some_and(|key| !unique_accounts.contains(&key))
- || active_account
- .is_some_and(|active| !unique_accounts.contains(&active.account().public_key()))
- || (matches!(session, SessionState::Active) && active_account.is_none())
- || (matches!(session, SessionState::SignedOut) && active_account.is_some())
- {
- return Err(invalid_snapshot());
- }
- Ok(())
-}
-
-const fn invalid_snapshot() -> SafeError {
- SafeError::new(
- SafeErrorCode::InvalidApplicationState,
- SafeMessage::new("The application state is invalid."),
- )
-}
-
-#[cfg(test)]
-mod tests {
- use radroots_studio_domain::{
- AccountCreatedAt, AccountIdentity, AccountSummary, BindingAvailability, LocalSignerBinding,
- RelayDestinationPolicy, RelayUrl, SafeErrorCode, UnixTimestamp,
- };
-
- use super::{
- ActiveAccountSnapshot, AppLifecycle, AppSnapshot, ProfileLoadState, RelayConfiguration,
- RelayConnectionState, SessionState, SnapshotRevision,
- };
-
- fn account(key_byte: u8) -> AccountSummary {
- let public_key =
- crate::test_support::valid_test_public_key(key_byte).expect("valid public key");
- AccountSummary::new(
- AccountIdentity::derive(public_key).expect("identity"),
- LocalSignerBinding::new(public_key, BindingAvailability::Available),
- None,
- AccountCreatedAt::new(UnixTimestamp::from_seconds(1).expect("valid time")),
- None,
- )
- .expect("account")
- }
-
- #[test]
- fn snapshot_boots_empty_and_secret_free() {
- let snapshot = AppSnapshot::booting();
- let debug = format!("{snapshot:?}");
-
- assert_eq!(snapshot.revision(), SnapshotRevision::initial());
- assert_eq!(snapshot.lifecycle(), AppLifecycle::Booting);
- assert_eq!(snapshot.session(), SessionState::SignedOut);
- assert!(snapshot.accounts().is_empty());
- assert!(snapshot.selected_account().is_none());
- assert!(snapshot.active_account().is_none());
- assert!(snapshot.relay_configuration().relays().is_empty());
- assert!(snapshot.recoverable_problem().is_none());
- assert!(!debug.contains("nsec1"));
- assert!(!debug.contains(&"11".repeat(32)));
- }
-
- #[test]
- fn relay_configuration_rejects_excess_targets_before_runtime_work() {
- let relays = (0..=super::MAX_CONFIGURED_RELAYS)
- .map(|index| {
- RelayUrl::parse(
- format!("wss://relay-{index}.example").as_str(),
- RelayDestinationPolicy::Public,
- )
- .expect("relay")
- })
- .collect();
- let error = RelayConfiguration::new(relays).expect_err("relay limit");
- assert_eq!(error.code(), SafeErrorCode::InvalidRelayConfiguration);
- }
-
- #[test]
- fn revision_helper_is_monotonic_and_checked() {
- assert_eq!(
- SnapshotRevision::initial()
- .next()
- .map(SnapshotRevision::value),
- Some(1)
- );
- assert_eq!(SnapshotRevision::from_value(u64::MAX).next(), None);
- }
-
- #[test]
- fn ready_snapshot_requires_valid_selection_and_active_session() {
- let first = account(1);
- let second = account(2);
- let active = ActiveAccountSnapshot::new(
- second.clone(),
- RelayConnectionState::Disconnected,
- ProfileLoadState::Empty,
- None,
- );
- let valid = AppSnapshot::ready(
- SnapshotRevision::from_value(1),
- RelayConfiguration::default(),
- vec![first.clone(), second.clone()],
- Some(first.public_key()),
- SessionState::Active,
- Some(active),
- None,
- )
- .expect("valid ready snapshot");
-
- assert_eq!(valid.lifecycle(), AppLifecycle::Ready);
- assert_eq!(valid.selected_account(), Some(first.public_key()));
- assert_eq!(
- valid
- .active_account()
- .map(|value| value.account().public_key()),
- Some(second.public_key())
- );
-
- assert!(
- AppSnapshot::ready(
- SnapshotRevision::initial(),
- RelayConfiguration::default(),
- vec![first.clone(), first],
- Some(second.public_key()),
- SessionState::SignedOut,
- None,
- None,
- )
- .is_err()
- );
- assert!(
- AppSnapshot::ready(
- SnapshotRevision::initial(),
- RelayConfiguration::default(),
- vec![second.clone()],
- Some(second.public_key()),
- SessionState::Active,
- None,
- None,
- )
- .is_err()
- );
-
- let missing = account(3);
- for result in [
- AppSnapshot::ready(
- SnapshotRevision::initial(),
- RelayConfiguration::default(),
- Vec::new(),
- Some(missing.public_key()),
- SessionState::SignedOut,
- None,
- None,
- ),
- AppSnapshot::ready(
- SnapshotRevision::initial(),
- RelayConfiguration::default(),
- vec![second.clone()],
- None,
- SessionState::SignedOut,
- None,
- None,
- ),
- AppSnapshot::ready(
- SnapshotRevision::initial(),
- RelayConfiguration::default(),
- vec![second.clone()],
- Some(missing.public_key()),
- SessionState::SignedOut,
- None,
- None,
- ),
- AppSnapshot::ready(
- SnapshotRevision::initial(),
- RelayConfiguration::default(),
- vec![second.clone()],
- Some(second.public_key()),
- SessionState::SignedOut,
- Some(ActiveAccountSnapshot::new(
- missing,
- RelayConnectionState::Disconnected,
- ProfileLoadState::Empty,
- None,
- )),
- None,
- ),
- AppSnapshot::ready(
- SnapshotRevision::initial(),
- RelayConfiguration::default(),
- vec![second.clone()],
- Some(second.public_key()),
- SessionState::SignedOut,
- Some(ActiveAccountSnapshot::new(
- second,
- RelayConnectionState::Disconnected,
- ProfileLoadState::Empty,
- None,
- )),
- None,
- ),
- ] {
- assert!(result.is_err());
- }
- }
-}
diff --git a/crates/studio_application/src/state_machine.rs b/crates/studio_application/src/state_machine.rs
@@ -1,616 +0,0 @@
-use radroots_studio_domain::{AccountSummary, PublicKey, SafeError, SafeErrorCode, SafeMessage};
-
-use crate::{ActiveAccountSnapshot, AppLifecycle, AppSnapshot, RelayConfiguration, SessionState};
-
-#[derive(Clone, Debug, Eq, PartialEq)]
-pub enum StateTransition {
- Bootstrap,
- BootstrapRegistry {
- accounts: Vec<AccountSummary>,
- selected: Option<PublicKey>,
- },
- Fatal(SafeError),
- ReplaceRegistry {
- accounts: Vec<AccountSummary>,
- selected: Option<PublicKey>,
- },
- ReplaceRegistryPreservingSession {
- accounts: Vec<AccountSummary>,
- selected: Option<PublicKey>,
- },
- Select(PublicKey),
- BeginActivation(PublicKey),
- ActivationSucceeded(Box<ActiveAccountSnapshot>),
- ActivationFailed(SafeError),
- UpdateActiveAccount {
- expected: PublicKey,
- active_account: Box<ActiveAccountSnapshot>,
- problem: Option<SafeError>,
- },
- SignOut,
- SetProblem(Option<SafeError>),
-}
-
-#[derive(Clone)]
-struct PreviousSession {
- session: SessionState,
- active_account: Option<ActiveAccountSnapshot>,
-}
-
-pub struct StateMachine {
- snapshot: AppSnapshot,
- pending_activation: Option<(PublicKey, PreviousSession)>,
-}
-
-impl StateMachine {
- #[must_use]
- pub fn booting() -> Self {
- Self {
- snapshot: AppSnapshot::booting(),
- pending_activation: None,
- }
- }
-
- #[must_use]
- pub const fn snapshot(&self) -> &AppSnapshot {
- &self.snapshot
- }
-
- /// Applies one deterministic state transition and returns the new snapshot.
- ///
- /// # Errors
- ///
- /// Returns a safe application error when the transition violates account,
- /// revision, activation, or snapshot invariants.
- pub fn apply(
- &mut self,
- transition: StateTransition,
- relay_configuration: &RelayConfiguration,
- ) -> Result<AppSnapshot, SafeError> {
- let next_revision = self
- .snapshot
- .revision()
- .next()
- .ok_or_else(invalid_application_state)?;
-
- let next = match transition {
- StateTransition::Bootstrap => self.bootstrap(next_revision, relay_configuration)?,
- StateTransition::BootstrapRegistry { accounts, selected } => {
- self.bootstrap_registry(next_revision, relay_configuration, accounts, selected)?
- }
- StateTransition::Fatal(error) => {
- AppSnapshot::fatal(next_revision, relay_configuration.clone(), error)
- }
- StateTransition::ReplaceRegistry { accounts, selected } => {
- self.replace_registry(next_revision, accounts, selected)?
- }
- StateTransition::ReplaceRegistryPreservingSession { accounts, selected } => {
- self.replace_registry_preserving_session(next_revision, accounts, selected)?
- }
- StateTransition::Select(public_key) => self.select(next_revision, public_key)?,
- StateTransition::BeginActivation(public_key) => {
- self.begin_activation(next_revision, public_key)?
- }
- StateTransition::ActivationSucceeded(active_account) => {
- self.activation_succeeded(next_revision, *active_account)?
- }
- StateTransition::ActivationFailed(problem) => {
- self.activation_failed(next_revision, problem)?
- }
- StateTransition::UpdateActiveAccount {
- expected,
- active_account,
- problem,
- } => self.update_active_account(next_revision, expected, *active_account, problem)?,
- StateTransition::SignOut => self.sign_out(next_revision)?,
- StateTransition::SetProblem(problem) => self.copy_ready(
- next_revision,
- self.snapshot.selected_account(),
- self.snapshot.session(),
- self.snapshot.active_account().cloned(),
- problem,
- )?,
- };
- self.snapshot = next.clone();
- Ok(next)
- }
-
- fn bootstrap(
- &self,
- revision: crate::SnapshotRevision,
- relay_configuration: &RelayConfiguration,
- ) -> Result<AppSnapshot, SafeError> {
- if !matches!(self.snapshot.lifecycle(), AppLifecycle::Booting) {
- return Ok(self.snapshot.clone());
- }
- AppSnapshot::ready(
- revision,
- relay_configuration.clone(),
- Vec::new(),
- None,
- SessionState::SignedOut,
- None,
- None,
- )
- }
-
- fn bootstrap_registry(
- &self,
- revision: crate::SnapshotRevision,
- relay_configuration: &RelayConfiguration,
- accounts: Vec<AccountSummary>,
- selected: Option<PublicKey>,
- ) -> Result<AppSnapshot, SafeError> {
- if !matches!(self.snapshot.lifecycle(), AppLifecycle::Booting) {
- return Ok(self.snapshot.clone());
- }
- AppSnapshot::ready(
- revision,
- relay_configuration.clone(),
- accounts,
- selected,
- SessionState::SignedOut,
- None,
- None,
- )
- }
-
- fn replace_registry(
- &mut self,
- revision: crate::SnapshotRevision,
- accounts: Vec<AccountSummary>,
- selected: Option<PublicKey>,
- ) -> Result<AppSnapshot, SafeError> {
- self.pending_activation = None;
- AppSnapshot::ready(
- revision,
- self.snapshot.relay_configuration().clone(),
- accounts,
- selected,
- SessionState::SignedOut,
- None,
- None,
- )
- }
-
- fn replace_registry_preserving_session(
- &mut self,
- revision: crate::SnapshotRevision,
- accounts: Vec<AccountSummary>,
- selected: Option<PublicKey>,
- ) -> Result<AppSnapshot, SafeError> {
- self.pending_activation = None;
- AppSnapshot::ready(
- revision,
- self.snapshot.relay_configuration().clone(),
- accounts,
- selected,
- self.snapshot.session(),
- self.snapshot.active_account().cloned(),
- None,
- )
- }
-
- fn select(
- &self,
- revision: crate::SnapshotRevision,
- public_key: PublicKey,
- ) -> Result<AppSnapshot, SafeError> {
- self.require_account(public_key)?;
- self.copy_ready(
- revision,
- Some(public_key),
- self.snapshot.session(),
- self.snapshot.active_account().cloned(),
- None,
- )
- }
-
- fn begin_activation(
- &mut self,
- revision: crate::SnapshotRevision,
- public_key: PublicKey,
- ) -> Result<AppSnapshot, SafeError> {
- self.require_account(public_key)?;
- if self.pending_activation.is_some() {
- return Err(invalid_application_state());
- }
- self.pending_activation = Some((
- public_key,
- PreviousSession {
- session: self.snapshot.session(),
- active_account: self.snapshot.active_account().cloned(),
- },
- ));
- self.copy_ready(
- revision,
- self.snapshot.selected_account(),
- SessionState::Activating(public_key),
- self.snapshot.active_account().cloned(),
- None,
- )
- }
-
- fn activation_succeeded(
- &mut self,
- revision: crate::SnapshotRevision,
- active_account: ActiveAccountSnapshot,
- ) -> Result<AppSnapshot, SafeError> {
- let Some((target, _previous)) = self.pending_activation.as_ref() else {
- return Err(invalid_application_state());
- };
- if active_account.account().public_key() != *target {
- return Err(invalid_application_state());
- }
- let target = *target;
- self.pending_activation = None;
- self.copy_ready(
- revision,
- Some(target),
- SessionState::Active,
- Some(active_account),
- None,
- )
- }
-
- fn activation_failed(
- &mut self,
- revision: crate::SnapshotRevision,
- problem: SafeError,
- ) -> Result<AppSnapshot, SafeError> {
- let Some((_target, previous)) = self.pending_activation.take() else {
- return Err(invalid_application_state());
- };
- self.copy_ready(
- revision,
- self.snapshot.selected_account(),
- previous.session,
- previous.active_account,
- Some(problem),
- )
- }
-
- fn sign_out(&mut self, revision: crate::SnapshotRevision) -> Result<AppSnapshot, SafeError> {
- self.pending_activation = None;
- self.copy_ready(
- revision,
- self.snapshot.selected_account(),
- SessionState::SignedOut,
- None,
- None,
- )
- }
-
- fn update_active_account(
- &self,
- revision: crate::SnapshotRevision,
- expected: PublicKey,
- active_account: ActiveAccountSnapshot,
- problem: Option<SafeError>,
- ) -> Result<AppSnapshot, SafeError> {
- if !matches!(self.snapshot.session(), SessionState::Active)
- || self
- .snapshot
- .active_account()
- .map(|active| active.account().public_key())
- != Some(expected)
- || active_account.account().public_key() != expected
- {
- return Err(invalid_application_state());
- }
- self.copy_ready(
- revision,
- self.snapshot.selected_account(),
- SessionState::Active,
- Some(active_account),
- problem,
- )
- }
-
- fn require_account(&self, public_key: PublicKey) -> Result<(), SafeError> {
- if self
- .snapshot
- .accounts()
- .iter()
- .any(|account| account.public_key() == public_key)
- {
- Ok(())
- } else {
- Err(account_not_found())
- }
- }
-
- fn copy_ready(
- &self,
- revision: crate::SnapshotRevision,
- selected_account: Option<PublicKey>,
- session: SessionState,
- active_account: Option<ActiveAccountSnapshot>,
- recoverable_problem: Option<SafeError>,
- ) -> Result<AppSnapshot, SafeError> {
- AppSnapshot::ready(
- revision,
- self.snapshot.relay_configuration().clone(),
- self.snapshot.accounts().to_vec(),
- selected_account,
- session,
- active_account,
- recoverable_problem,
- )
- }
-}
-
-const fn invalid_application_state() -> SafeError {
- SafeError::new(
- SafeErrorCode::InvalidApplicationState,
- SafeMessage::new("The application state is invalid."),
- )
-}
-
-const fn account_not_found() -> SafeError {
- SafeError::new(
- SafeErrorCode::AccountNotFound,
- SafeMessage::new("The account was not found."),
- )
-}
-
-#[cfg(test)]
-mod tests {
- use radroots_studio_domain::{
- AccountCreatedAt, AccountIdentity, AccountSummary, BindingAvailability, LocalSignerBinding,
- SafeError, SafeErrorCode, SafeMessage, UnixTimestamp,
- };
-
- use crate::{
- ActiveAccountSnapshot, ProfileLoadState, RelayConfiguration, RelayConnectionState,
- SessionState, StateMachine, StateTransition,
- };
-
- fn account(key_byte: u8) -> AccountSummary {
- let public_key =
- crate::test_support::valid_test_public_key(key_byte).expect("valid public key");
- AccountSummary::new(
- AccountIdentity::derive(public_key).expect("identity"),
- LocalSignerBinding::new(public_key, BindingAvailability::Available),
- None,
- AccountCreatedAt::new(UnixTimestamp::from_seconds(1).expect("valid time")),
- None,
- )
- .expect("account")
- }
-
- fn active(account: AccountSummary) -> ActiveAccountSnapshot {
- ActiveAccountSnapshot::new(
- account,
- RelayConnectionState::Disconnected,
- ProfileLoadState::Empty,
- None,
- )
- }
-
- #[test]
- fn state_machine_command_trace_preserves_working_session_on_failed_replacement() {
- let first = account(1);
- let second = account(2);
- let mut machine = StateMachine::booting();
- let relays = RelayConfiguration::default();
- let problem = SafeError::new(
- SafeErrorCode::CredentialMissing,
- SafeMessage::new("The account credential is missing."),
- );
-
- machine
- .apply(StateTransition::Bootstrap, &relays)
- .expect("bootstrap");
- machine
- .apply(
- StateTransition::ReplaceRegistry {
- accounts: vec![first.clone(), second.clone()],
- selected: Some(first.public_key()),
- },
- &relays,
- )
- .expect("load registry");
- machine
- .apply(
- StateTransition::BeginActivation(first.public_key()),
- &relays,
- )
- .expect("begin first activation");
- machine
- .apply(
- StateTransition::ActivationSucceeded(Box::new(active(first.clone()))),
- &relays,
- )
- .expect("activate first");
- machine
- .apply(StateTransition::Select(second.public_key()), &relays)
- .expect("select second");
- let pending = machine
- .apply(
- StateTransition::BeginActivation(second.public_key()),
- &relays,
- )
- .expect("begin replacement");
- let restored = machine
- .apply(StateTransition::ActivationFailed(problem), &relays)
- .expect("fail replacement");
-
- assert_eq!(
- pending.session(),
- SessionState::Activating(second.public_key())
- );
- assert_eq!(
- pending
- .active_account()
- .map(|value| value.account().public_key()),
- Some(first.public_key())
- );
- assert_eq!(restored.session(), SessionState::Active);
- assert_eq!(restored.selected_account(), Some(second.public_key()));
- assert_eq!(
- restored
- .active_account()
- .map(|value| value.account().public_key()),
- Some(first.public_key())
- );
- assert_eq!(restored.recoverable_problem(), Some(problem));
- assert_eq!(restored.revision().value(), 7);
- }
-
- #[test]
- fn state_machine_rejects_missing_targets_and_signs_out_without_deleting() {
- let account = account(1);
- let mut machine = StateMachine::booting();
- let relays = RelayConfiguration::default();
- machine
- .apply(StateTransition::Bootstrap, &relays)
- .expect("bootstrap");
- machine
- .apply(
- StateTransition::ReplaceRegistry {
- accounts: vec![account.clone()],
- selected: Some(account.public_key()),
- },
- &relays,
- )
- .expect("load registry");
-
- let error = machine
- .apply(
- StateTransition::Select(
- crate::test_support::valid_test_public_key(9).expect("valid public key"),
- ),
- &relays,
- )
- .expect_err("missing account");
- assert_eq!(error.code(), SafeErrorCode::AccountNotFound);
-
- machine
- .apply(
- StateTransition::BeginActivation(account.public_key()),
- &relays,
- )
- .expect("begin activation");
- machine
- .apply(
- StateTransition::ActivationSucceeded(Box::new(active(account.clone()))),
- &relays,
- )
- .expect("activate");
- let signed_out = machine
- .apply(StateTransition::SignOut, &relays)
- .expect("sign out");
-
- assert_eq!(signed_out.accounts(), &[account]);
- assert_eq!(signed_out.session(), SessionState::SignedOut);
- assert!(signed_out.active_account().is_none());
- }
-
- #[test]
- fn activation_state_policy_rejects_every_stale_or_mismatched_transition() {
- let first = account(1);
- let second = account(2);
- let relays = RelayConfiguration::default();
- let problem = SafeError::new(
- SafeErrorCode::CredentialMissing,
- SafeMessage::new("The account credential is missing."),
- );
- let mut machine = StateMachine::booting();
- machine
- .apply(
- StateTransition::BootstrapRegistry {
- accounts: vec![first.clone(), second.clone()],
- selected: Some(first.public_key()),
- },
- &relays,
- )
- .expect("registry");
- let unchanged = machine
- .apply(
- StateTransition::BootstrapRegistry {
- accounts: Vec::new(),
- selected: None,
- },
- &relays,
- )
- .expect("repeated bootstrap is idempotent");
- assert_eq!(unchanged.accounts().len(), 2);
-
- assert!(
- machine
- .apply(
- StateTransition::ActivationSucceeded(Box::new(active(first.clone()))),
- &relays,
- )
- .is_err()
- );
- assert!(
- machine
- .apply(StateTransition::ActivationFailed(problem), &relays)
- .is_err()
- );
- machine
- .apply(
- StateTransition::BeginActivation(first.public_key()),
- &relays,
- )
- .expect("begin activation");
- assert!(
- machine
- .apply(
- StateTransition::BeginActivation(second.public_key()),
- &relays,
- )
- .is_err()
- );
- assert!(
- machine
- .apply(
- StateTransition::ActivationSucceeded(Box::new(active(second.clone()))),
- &relays,
- )
- .is_err()
- );
- machine
- .apply(
- StateTransition::ActivationSucceeded(Box::new(active(first.clone()))),
- &relays,
- )
- .expect("activate first");
-
- for (expected, candidate) in [
- (second.public_key(), first.clone()),
- (first.public_key(), second.clone()),
- ] {
- assert!(
- machine
- .apply(
- StateTransition::UpdateActiveAccount {
- expected,
- active_account: Box::new(active(candidate)),
- problem: None,
- },
- &relays,
- )
- .is_err()
- );
- }
-
- machine
- .apply(StateTransition::SignOut, &relays)
- .expect("sign out");
- assert!(
- machine
- .apply(
- StateTransition::UpdateActiveAccount {
- expected: first.public_key(),
- active_account: Box::new(active(first)),
- problem: None,
- },
- &relays,
- )
- .is_err()
- );
- }
-}
diff --git a/crates/studio_application/src/test_support.rs b/crates/studio_application/src/test_support.rs
@@ -1,58 +0,0 @@
-use std::sync::atomic::{AtomicU8, Ordering};
-
-use radroots_studio_domain::{
- Npub, Nsec, PublicKey, SafeError, SafeErrorCode, SafeMessage, SecretKeyInput,
-};
-
-use crate::{GeneratedKeyMaterial, ImportedKeyMaterial, KeyMaterialProvider};
-
-#[derive(Default)]
-pub(crate) struct TestKeyMaterialProvider {
- next: AtomicU8,
-}
-
-impl KeyMaterialProvider for TestKeyMaterialProvider {
- fn generate(&self) -> Result<GeneratedKeyMaterial, SafeError> {
- let public_key = (0..=u8::MAX)
- .find_map(|_| {
- let candidate = self.next.fetch_add(1, Ordering::Relaxed).wrapping_add(9);
- PublicKey::from_bytes([candidate; 32]).ok()
- })
- .ok_or_else(invalid_secret_key)?;
- let secret_byte = public_key.as_bytes()[0];
- Ok(GeneratedKeyMaterial::new(
- public_key,
- Npub::derive(public_key)?,
- SecretKeyInput::parse(format!("{secret_byte:02x}").repeat(32))?,
- Nsec::from_encoded(
- "nsec1vl029mgpspedva04g90vltkh6fvh240zqtv9k0t9af8935ke9laqsnlfe5".to_owned(),
- )?,
- ))
- }
-
- fn import(&self, input: SecretKeyInput) -> Result<ImportedKeyMaterial, SafeError> {
- let discriminator = input.with_exposed_secret(|value| value.as_bytes()[0]);
- if input.with_exposed_secret(|value| value.starts_with("nsec1qq")) {
- return Err(invalid_secret_key());
- }
- let public_key = valid_test_public_key(discriminator)?;
- Ok(ImportedKeyMaterial::new(
- public_key,
- Npub::derive(public_key)?,
- input,
- ))
- }
-}
-
-pub(crate) fn valid_test_public_key(discriminator: u8) -> Result<PublicKey, SafeError> {
- (0..=u8::MAX)
- .find_map(|offset| PublicKey::from_bytes([discriminator.wrapping_add(offset); 32]).ok())
- .ok_or_else(invalid_secret_key)
-}
-
-const fn invalid_secret_key() -> SafeError {
- SafeError::new(
- SafeErrorCode::InvalidSecretKey,
- SafeMessage::new("The Nostr secret key is invalid."),
- )
-}
diff --git a/crates/studio_application/tests/redaction.rs b/crates/studio_application/tests/redaction.rs
@@ -1,48 +0,0 @@
-use radroots_studio_application::{
- AppSnapshot, RelayConfiguration, SessionState, SnapshotRevision,
-};
-use radroots_studio_domain::{
- AccountCreatedAt, AccountIdentity, AccountSummary, BindingAvailability, LocalSignerBinding,
- PublicKey, SafeError, SafeErrorCode, SafeMessage, UnixTimestamp,
-};
-
-const SECRET_HEX: &str = "1111111111111111111111111111111111111111111111111111111111111111";
-const SECRET_NSEC: &str = "nsec1vl029mgpspedva04g90vltkh6fvh240zqtv9k0t9af8935ke9laqsnlfe5";
-fn assert_redacted(text: &str) {
- assert!(!text.contains(SECRET_HEX));
- assert!(!text.contains(SECRET_NSEC));
- assert!(!text.contains("nsec1"));
-}
-
-#[test]
-fn redaction_guards_public_snapshot_and_safe_error_debug() {
- let account = AccountSummary::new(
- AccountIdentity::derive(PublicKey::from_bytes([7; 32]).expect("valid public key"))
- .expect("identity"),
- LocalSignerBinding::new(
- PublicKey::from_bytes([7; 32]).expect("valid public key"),
- BindingAvailability::Available,
- ),
- None,
- AccountCreatedAt::new(UnixTimestamp::from_seconds(1).expect("time")),
- None,
- )
- .expect("account");
- let snapshot = AppSnapshot::ready(
- SnapshotRevision::from_value(1),
- RelayConfiguration::default(),
- vec![account.clone()],
- Some(account.public_key()),
- SessionState::SignedOut,
- None,
- None,
- )
- .expect("snapshot");
- let error = SafeError::new(
- SafeErrorCode::KeyringUnavailable,
- SafeMessage::new("The operating system credential store is unavailable."),
- );
-
- assert_redacted(&format!("{snapshot:?}"));
- assert_redacted(&format!("{error:?} {error}"));
-}
diff --git a/crates/studio_domain/Cargo.toml b/crates/studio_domain/Cargo.toml
@@ -1,22 +0,0 @@
-[package]
-name = "radroots_studio_domain"
-description = "Private domain model for Radroots Studio"
-version = "0.1.0-alpha"
-edition.workspace = true
-authors.workspace = true
-rust-version.workspace = true
-license = "GPL-3.0-only"
-repository.workspace = true
-homepage.workspace = true
-publish = false
-include = ["src/**", "Cargo.toml"]
-
-[dependencies]
-bech32 = "=0.11.1"
-radroots_identity.workspace = true
-secrecy = "=0.10.3"
-url = "=2.5.8"
-zeroize = "=1.9.0"
-
-[lints]
-workspace = true
diff --git a/crates/studio_domain/src/account.rs b/crates/studio_domain/src/account.rs
@@ -1,430 +0,0 @@
-//! Public account metadata and lifecycle values.
-
-use crate::time::UnixTimestamp;
-use crate::{Npub, PublicKey, SafeError, SafeErrorCode, SafeMessage};
-
-const MAX_ACCOUNT_LABEL_CHARS: usize = 80;
-
-#[derive(Clone, Debug, Eq, PartialEq)]
-pub struct AccountIdentity {
- public_key: PublicKey,
- npub: Npub,
-}
-
-impl AccountIdentity {
- /// Constructs one canonical Nostr account identity and derives its npub.
- ///
- /// # Errors
- ///
- /// Returns a safe public-key error if canonical NIP-19 encoding fails.
- pub fn derive(public_key: PublicKey) -> Result<Self, SafeError> {
- Ok(Self {
- public_key,
- npub: Npub::derive(public_key)?,
- })
- }
-
- /// Reconstitutes persisted identity only when its public forms agree.
- ///
- /// # Errors
- ///
- /// Returns a safe public-key error for a mismatched or malformed npub.
- pub fn verify(public_key: PublicKey, npub: String) -> Result<Self, SafeError> {
- Ok(Self {
- public_key,
- npub: Npub::verify(public_key, npub)?,
- })
- }
-
- #[must_use]
- pub const fn public_key(&self) -> PublicKey {
- self.public_key
- }
-
- #[must_use]
- pub const fn npub(&self) -> &Npub {
- &self.npub
- }
-}
-
-#[derive(Clone, Copy, Debug, Eq, PartialEq)]
-pub struct LocalSignerBinding {
- account: PublicKey,
- availability: BindingAvailability,
-}
-
-impl LocalSignerBinding {
- #[must_use]
- pub const fn new(account: PublicKey, availability: BindingAvailability) -> Self {
- Self {
- account,
- availability,
- }
- }
-
- #[must_use]
- pub const fn account(self) -> PublicKey {
- self.account
- }
-
- #[must_use]
- pub const fn availability(self) -> BindingAvailability {
- self.availability
- }
-
- #[must_use]
- pub const fn repair_action(self) -> Option<BindingRepairAction> {
- match self.availability {
- BindingAvailability::Available => None,
- BindingAvailability::CredentialMissing => Some(BindingRepairAction::ImportCredential),
- BindingAvailability::StoreUnavailable => {
- Some(BindingRepairAction::RetryCredentialStore)
- }
- }
- }
-
- /// Records a missing credential after a successful store lookup.
- ///
- /// # Errors
- ///
- /// Returns a safe state error unless the binding was previously available.
- pub fn mark_credential_missing(&mut self) -> Result<(), SafeError> {
- self.transition(
- BindingAvailability::Available,
- BindingAvailability::CredentialMissing,
- )
- }
-
- pub fn mark_store_unavailable(&mut self) {
- self.availability = BindingAvailability::StoreUnavailable;
- }
-
- /// Completes an explicit credential repair.
- ///
- /// # Errors
- ///
- /// Returns a safe state error unless a credential was missing.
- pub fn repair_credential(&mut self) -> Result<(), SafeError> {
- self.transition(
- BindingAvailability::CredentialMissing,
- BindingAvailability::Available,
- )
- }
-
- /// Resolves a recovered store lookup to its observed credential state.
- ///
- /// # Errors
- ///
- /// Returns a safe state error unless the credential store was unavailable.
- pub fn resolve_store_recovery(&mut self, credential_present: bool) -> Result<(), SafeError> {
- if self.availability != BindingAvailability::StoreUnavailable {
- return Err(invalid_account_metadata());
- }
- self.availability = if credential_present {
- BindingAvailability::Available
- } else {
- BindingAvailability::CredentialMissing
- };
- Ok(())
- }
-
- fn transition(
- &mut self,
- expected: BindingAvailability,
- next: BindingAvailability,
- ) -> Result<(), SafeError> {
- if self.availability != expected {
- return Err(invalid_account_metadata());
- }
- self.availability = next;
- Ok(())
- }
-}
-
-#[derive(Clone, Copy, Debug, Eq, PartialEq)]
-pub enum BindingAvailability {
- Available,
- CredentialMissing,
- StoreUnavailable,
-}
-
-#[derive(Clone, Copy, Debug, Eq, PartialEq)]
-pub enum BindingRepairAction {
- ImportCredential,
- RetryCredentialStore,
-}
-
-#[derive(Clone, Debug, Eq, PartialEq)]
-pub struct AccountLabel(String);
-
-impl AccountLabel {
- /// Trims and validates an optional human-assigned account label value.
- ///
- /// # Errors
- ///
- /// Returns a safe metadata error when the resulting label is empty, too
- /// long, or contains a control character.
- pub fn parse(value: &str) -> Result<Self, SafeError> {
- let normalized = value.trim();
- if normalized.is_empty()
- || normalized.chars().count() > MAX_ACCOUNT_LABEL_CHARS
- || normalized.chars().any(char::is_control)
- {
- return Err(invalid_account_metadata());
- }
- Ok(Self(normalized.to_owned()))
- }
-
- #[must_use]
- pub fn as_str(&self) -> &str {
- &self.0
- }
-}
-
-#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
-pub struct AccountCreatedAt(UnixTimestamp);
-
-impl AccountCreatedAt {
- #[must_use]
- pub const fn new(timestamp: UnixTimestamp) -> Self {
- Self(timestamp)
- }
-
- #[must_use]
- pub const fn timestamp(self) -> UnixTimestamp {
- self.0
- }
-}
-
-#[derive(Clone, Debug, Eq, PartialEq)]
-pub struct AccountSummary {
- identity: AccountIdentity,
- signer: LocalSignerBinding,
- label: Option<AccountLabel>,
- created_at: AccountCreatedAt,
- last_used_at: Option<UnixTimestamp>,
-}
-
-impl AccountSummary {
- /// Creates an account summary whose identity and signer binding refer to the same account.
- ///
- /// # Errors
- ///
- /// Returns an invalid-account-metadata error when the signer binding belongs to a different
- /// public key.
- pub fn new(
- identity: AccountIdentity,
- signer: LocalSignerBinding,
- label: Option<AccountLabel>,
- created_at: AccountCreatedAt,
- last_used_at: Option<UnixTimestamp>,
- ) -> Result<Self, SafeError> {
- if identity.public_key() != signer.account() {
- return Err(invalid_account_metadata());
- }
- Ok(Self {
- identity,
- signer,
- label,
- created_at,
- last_used_at,
- })
- }
-
- #[must_use]
- pub const fn public_key(&self) -> PublicKey {
- self.identity.public_key()
- }
-
- #[must_use]
- pub fn npub(&self) -> &Npub {
- self.identity.npub()
- }
-
- #[must_use]
- pub const fn signer(&self) -> LocalSignerBinding {
- self.signer
- }
-
- #[must_use]
- pub fn label(&self) -> Option<&AccountLabel> {
- self.label.as_ref()
- }
-
- #[must_use]
- pub const fn created_at(&self) -> AccountCreatedAt {
- self.created_at
- }
-
- #[must_use]
- pub const fn last_used_at(&self) -> Option<UnixTimestamp> {
- self.last_used_at
- }
-
- #[must_use]
- pub fn with_binding_availability(&self, availability: BindingAvailability) -> Self {
- Self {
- identity: self.identity.clone(),
- signer: LocalSignerBinding::new(self.public_key(), availability),
- label: self.label.clone(),
- created_at: self.created_at,
- last_used_at: self.last_used_at,
- }
- }
-
- #[must_use]
- pub fn with_last_used_at(&self, last_used_at: UnixTimestamp) -> Self {
- Self {
- identity: self.identity.clone(),
- signer: self.signer,
- label: self.label.clone(),
- created_at: self.created_at,
- last_used_at: Some(last_used_at),
- }
- }
-
- #[must_use]
- pub fn display_label(&self) -> String {
- self.label
- .as_ref()
- .map_or_else(|| self.npub().short(), |label| label.as_str().to_owned())
- }
-}
-
-const fn invalid_account_metadata() -> SafeError {
- SafeError::new(
- SafeErrorCode::InvalidAccountMetadata,
- SafeMessage::new("The account metadata is invalid."),
- )
-}
-
-#[cfg(test)]
-mod tests {
- use crate::PublicKey;
- use crate::time::UnixTimestamp;
-
- use super::{
- AccountCreatedAt, AccountIdentity, AccountLabel, AccountSummary, BindingAvailability,
- BindingRepairAction, LocalSignerBinding,
- };
-
- const DERIVED_NPUB: &str = "npub1qurswpc8qurswpc8qurswpc8qurswpc8qurswpc8qurswpc8qursnvjvl7";
- const MISMATCHED_NPUB: &str = "npub10elfcs4fr0l0r8af98jlmgdh9c8tcxjvz9qkw038js35mp4dma8qzvjptg";
-
- fn public_key() -> PublicKey {
- PublicKey::from_bytes([7_u8; 32]).expect("valid public key")
- }
-
- fn account(label: Option<AccountLabel>) -> AccountSummary {
- let public_key = public_key();
- AccountSummary::new(
- AccountIdentity::derive(public_key).expect("identity"),
- LocalSignerBinding::new(public_key, BindingAvailability::Available),
- label,
- AccountCreatedAt::new(UnixTimestamp::from_seconds(10).expect("valid time")),
- None,
- )
- .expect("account")
- }
-
- #[test]
- fn account_label_is_trimmed_bounded_and_control_free() {
- let label = AccountLabel::parse(" Farm account ").expect("valid label");
- assert_eq!(label.as_str(), "Farm account");
-
- for invalid in ["", " ", "line\nbreak", &"x".repeat(81)] {
- assert!(AccountLabel::parse(invalid).is_err());
- }
- }
-
- #[test]
- fn account_display_prefers_label_then_shortened_npub() {
- let labelled = account(Some(AccountLabel::parse("Farm").expect("valid label")));
- let unlabelled = account(None);
-
- assert_eq!(labelled.display_label(), "Farm");
- assert_eq!(unlabelled.display_label(), "npub1qurswpc…rsnvjvl7");
- }
-
- #[test]
- fn local_account_summary_contains_public_metadata_only() {
- let account = account(None);
- let debug = format!("{account:?}");
-
- assert_eq!(
- account.signer().availability(),
- BindingAvailability::Available
- );
- assert!(account.label().is_none());
- assert!(account.last_used_at().is_none());
- assert_eq!(account.created_at().timestamp().as_seconds(), 10);
- assert_eq!(account.public_key(), public_key());
- assert_eq!(account.npub().as_str(), DERIVED_NPUB);
- assert!(!debug.contains("nsec1"));
- assert!(!debug.contains(&"11".repeat(32)));
- }
-
- #[test]
- fn account_identity_derives_npub_and_rejects_mismatched_persisted_forms() {
- let public_key = public_key();
- let identity = AccountIdentity::derive(public_key).expect("identity");
- assert_eq!(identity.public_key(), public_key);
- assert_eq!(identity.npub().as_str(), DERIVED_NPUB);
- assert_eq!(
- AccountIdentity::verify(public_key, DERIVED_NPUB.to_owned()).expect("verified"),
- identity
- );
- assert!(AccountIdentity::verify(public_key, MISMATCHED_NPUB.to_owned()).is_err());
- assert!(
- AccountIdentity::verify(
- PublicKey::from_hex(
- "7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7",
- )
- .expect("second public key"),
- MISMATCHED_NPUB.to_owned()
- )
- .is_err()
- );
- }
-
- #[test]
- fn local_signer_binding_carries_only_canonical_account_identity() {
- let public_key = public_key();
- let identity = AccountIdentity::derive(public_key).expect("identity");
- let binding = LocalSignerBinding::new(public_key, BindingAvailability::Available);
-
- assert_eq!(binding.account(), identity.public_key());
- assert!(!format!("{binding:?}").contains("nsec1"));
- }
-
- #[test]
- fn local_binding_repair_transitions_are_typed_and_fail_closed() {
- let public_key = public_key();
- let mut binding = LocalSignerBinding::new(public_key, BindingAvailability::Available);
- assert_eq!(binding.repair_action(), None);
- assert!(binding.repair_credential().is_err());
-
- binding
- .mark_credential_missing()
- .expect("missing credential");
- assert_eq!(
- binding.repair_action(),
- Some(BindingRepairAction::ImportCredential)
- );
- binding.repair_credential().expect("repair");
-
- binding.mark_store_unavailable();
- assert_eq!(
- binding.repair_action(),
- Some(BindingRepairAction::RetryCredentialStore)
- );
- binding
- .resolve_store_recovery(false)
- .expect("store recovery");
- assert_eq!(
- binding.availability(),
- BindingAvailability::CredentialMissing
- );
- assert!(binding.resolve_store_recovery(true).is_err());
- }
-}
diff --git a/crates/studio_domain/src/error.rs b/crates/studio_domain/src/error.rs
@@ -1,113 +0,0 @@
-use std::error::Error;
-use std::fmt::{self, Debug, Display, Formatter};
-
-#[derive(Clone, Copy, Debug, Eq, PartialEq)]
-pub enum SafeErrorCode {
- InvalidPublicKey,
- InvalidSecretKey,
- InvalidAccountMetadata,
- InvalidProfileMetadata,
- InvalidApplicationState,
- AccountAlreadyExists,
- AccountNotFound,
- KeyringUnavailable,
- CredentialMissing,
- StorageUnavailable,
- StorageCorrupt,
- StorageQuarantined,
- StorageBackupInvalid,
- UnsupportedSchemaVersion,
- RepairUnauthorized,
- PendingOperationRecoveryRequired,
- InvalidRelayConfiguration,
- RelayConnectionFailed,
- ProfileRefreshFailed,
- ObserverRegistrationFailed,
- NativeLibraryLoadFailed,
-}
-
-#[derive(Clone, Copy, Eq, PartialEq)]
-pub struct SafeMessage(&'static str);
-
-impl SafeMessage {
- #[must_use]
- pub const fn new(message: &'static str) -> Self {
- Self(message)
- }
-
- #[must_use]
- pub const fn as_str(self) -> &'static str {
- self.0
- }
-}
-
-impl Debug for SafeMessage {
- fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result {
- formatter.debug_tuple("SafeMessage").field(&self.0).finish()
- }
-}
-
-impl Display for SafeMessage {
- fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result {
- formatter.write_str(self.0)
- }
-}
-
-#[derive(Clone, Copy, Eq, PartialEq)]
-pub struct SafeError {
- code: SafeErrorCode,
- message: SafeMessage,
-}
-
-impl SafeError {
- #[must_use]
- pub const fn new(code: SafeErrorCode, message: SafeMessage) -> Self {
- Self { code, message }
- }
-
- #[must_use]
- pub const fn code(self) -> SafeErrorCode {
- self.code
- }
-
- #[must_use]
- pub const fn message(self) -> SafeMessage {
- self.message
- }
-}
-
-impl Debug for SafeError {
- fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result {
- formatter
- .debug_struct("SafeError")
- .field("code", &self.code)
- .field("message", &self.message)
- .finish()
- }
-}
-
-impl Display for SafeError {
- fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result {
- Display::fmt(&self.message, formatter)
- }
-}
-
-impl Error for SafeError {}
-
-#[cfg(test)]
-mod tests {
- use super::{SafeError, SafeErrorCode, SafeMessage};
-
- #[test]
- fn safe_error_formats_only_a_static_public_message() {
- let error = SafeError::new(
- SafeErrorCode::InvalidSecretKey,
- SafeMessage::new("The secret key is invalid."),
- );
-
- assert_eq!(error.to_string(), "The secret key is invalid.");
- assert_eq!(error.code(), SafeErrorCode::InvalidSecretKey);
- assert_eq!(error.message().as_str(), "The secret key is invalid.");
- assert!(!format!("{error:?}").contains("nsec1unsafe-test-value"));
- }
-}
diff --git a/crates/studio_domain/src/key.rs b/crates/studio_domain/src/key.rs
@@ -1,451 +0,0 @@
-//! Validated Nostr public and secret-key boundary values.
-
-use std::fmt::{self, Display, Formatter};
-use std::str::FromStr;
-
-use secrecy::{ExposeSecret, SecretString};
-use zeroize::Zeroizing;
-
-use crate::{SafeError, SafeErrorCode, SafeMessage};
-
-pub const PUBLIC_KEY_BYTE_LENGTH: usize = 32;
-pub const PUBLIC_KEY_HEX_LENGTH: usize = PUBLIC_KEY_BYTE_LENGTH * 2;
-pub const MAX_SECRET_KEY_INPUT_BYTES: usize = 128;
-const NIP19_KEY_LENGTH: usize = 63;
-const BECH32_DATA_CHARSET: &[u8] = b"qpzry9x8gf2tvdw0s3jn54khce6mua7l";
-
-#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
-pub struct Npub(String);
-
-impl Npub {
- /// Constructs a human-facing npub after structural validation.
- ///
- /// Cryptographic conversion and checksum validation are performed by the
- /// selected Nostr adapter before this domain value is created in runtime
- /// flows.
- ///
- /// # Errors
- ///
- /// Returns a safe invalid-public-key error for a malformed npub shape.
- pub fn from_encoded(value: String) -> Result<Self, SafeError> {
- if !is_nip19_key_shape(&value, "npub1") {
- return Err(invalid_public_key());
- }
- Ok(Self(value))
- }
-
- /// Derives the canonical NIP-19 display identity from a public key.
- ///
- /// # Errors
- ///
- /// Returns a safe public-key error if canonical encoding fails.
- pub fn derive(public_key: PublicKey) -> Result<Self, SafeError> {
- let hrp = bech32::Hrp::parse("npub").map_err(|_| invalid_public_key())?;
- bech32::encode::<bech32::Bech32>(hrp, public_key.as_bytes())
- .map_err(|_| invalid_public_key())
- .and_then(Self::from_encoded)
- }
-
- /// Validates that encoded display identity belongs to the canonical key.
- ///
- /// # Errors
- ///
- /// Returns a safe public-key error when the values do not match.
- pub fn verify(public_key: PublicKey, encoded: String) -> Result<Self, SafeError> {
- let candidate = Self::from_encoded(encoded)?;
- if candidate != Self::derive(public_key)? {
- return Err(invalid_public_key());
- }
- Ok(candidate)
- }
-
- #[must_use]
- pub fn as_str(&self) -> &str {
- &self.0
- }
-
- #[must_use]
- pub fn short(&self) -> String {
- format!("{}…{}", &self.0[..12], &self.0[self.0.len() - 8..])
- }
-}
-
-impl Display for Npub {
- fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result {
- formatter.write_str(&self.0)
- }
-}
-
-pub struct Nsec(SecretString);
-
-impl Nsec {
- /// Constructs a secret nsec display value after structural validation.
- ///
- /// # Errors
- ///
- /// Returns a safe invalid-secret-key error for a malformed nsec shape.
- pub fn from_encoded(value: String) -> Result<Self, SafeError> {
- if !is_nip19_key_shape(&value, "nsec1") {
- return Err(invalid_secret_key());
- }
- Ok(Self(SecretString::from(value)))
- }
-
- pub fn with_exposed_secret<T>(&self, operation: impl FnOnce(&str) -> T) -> T {
- operation(self.0.expose_secret())
- }
-}
-
-#[derive(Clone, Copy, Debug, Eq, PartialEq)]
-pub enum SecretKeyInputKind {
- Nsec,
- Hex,
-}
-
-pub struct SecretKeyInput {
- value: SecretString,
- kind: SecretKeyInputKind,
-}
-
-impl SecretKeyInput {
- /// Moves bounded transport bytes into the zeroizing secret boundary.
- ///
- /// The source byte allocation is cleared on every return path.
- ///
- /// # Errors
- ///
- /// Returns a safe invalid-secret-key error for oversized, non-UTF-8, or
- /// structurally invalid input.
- pub fn parse_bytes(value: Vec<u8>) -> Result<Self, SafeError> {
- let value = Zeroizing::new(value);
- if value.len() > MAX_SECRET_KEY_INPUT_BYTES {
- return Err(invalid_secret_key());
- }
- let encoded = std::str::from_utf8(&value).map_err(|_| invalid_secret_key())?;
- Self::parse(encoded.to_owned())
- }
-
- /// Moves one secret input string into a zeroizing boundary.
- ///
- /// Nsec inputs receive complete NIP-19 validation in the Nostr adapter.
- /// Hex input is structurally validated here to prevent ambiguous fallback.
- ///
- /// # Errors
- ///
- /// Returns a safe invalid-secret-key error when the input is neither an
- /// nsec-looking value nor exactly 64 lowercase hexadecimal characters.
- pub fn parse(value: String) -> Result<Self, SafeError> {
- let mut value = Zeroizing::new(value);
- let kind = if value.len() == PUBLIC_KEY_HEX_LENGTH
- && value
- .bytes()
- .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
- {
- SecretKeyInputKind::Hex
- } else if is_nip19_key_shape(&value, "nsec1") {
- SecretKeyInputKind::Nsec
- } else {
- return Err(invalid_secret_key());
- };
-
- Ok(Self {
- value: SecretString::from(std::mem::take(&mut *value)),
- kind,
- })
- }
-
- #[must_use]
- pub const fn kind(&self) -> SecretKeyInputKind {
- self.kind
- }
-
- pub fn with_exposed_secret<T>(&self, operation: impl FnOnce(&str) -> T) -> T {
- operation(self.value.expose_secret())
- }
-}
-
-#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
-pub struct PublicKey(radroots_identity::PublicKey);
-
-#[derive(Clone, Copy, Debug, Eq, PartialEq)]
-pub enum PersistedPublicKeyClassification {
- Canonical(PublicKey),
- NonCanonicalEncoding,
- InvalidCurvePoint,
- MalformedEncoding,
-}
-
-impl PublicKey {
- /// Validates canonical x-only secp256k1 public-key bytes.
- ///
- /// # Errors
- ///
- /// Returns a safe invalid-public-key error when the bytes are not a valid
- /// x-only secp256k1 point.
- pub fn from_bytes(bytes: [u8; PUBLIC_KEY_BYTE_LENGTH]) -> Result<Self, SafeError> {
- radroots_identity::PublicKey::from_bytes(bytes)
- .map(Self)
- .map_err(|_| invalid_public_key())
- }
-
- /// Parses a canonical lowercase hexadecimal Nostr public key.
- ///
- /// # Errors
- ///
- /// Returns a safe invalid-public-key error when the value is not exactly
- /// 64 lowercase hexadecimal characters.
- pub fn from_hex(value: &str) -> Result<Self, SafeError> {
- if value.len() != PUBLIC_KEY_HEX_LENGTH
- || !value
- .bytes()
- .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
- {
- return Err(invalid_public_key());
- }
-
- radroots_identity::PublicKey::from_hex(value)
- .map(Self)
- .map_err(|_| invalid_public_key())
- }
-
- #[must_use]
- pub const fn as_bytes(&self) -> &[u8; PUBLIC_KEY_BYTE_LENGTH] {
- self.0.as_bytes()
- }
-
- #[must_use]
- pub const fn canonical(self) -> radroots_identity::PublicKey {
- self.0
- }
-
- #[must_use]
- pub const fn from_canonical(public_key: radroots_identity::PublicKey) -> Self {
- Self(public_key)
- }
-
- #[must_use]
- pub fn to_hex(self) -> String {
- self.0.to_hex()
- }
-
- #[must_use]
- pub fn short_hex(self) -> String {
- let hex = self.to_hex();
- format!("{}…{}", &hex[..8], &hex[PUBLIC_KEY_HEX_LENGTH - 8..])
- }
-}
-
-impl Display for PublicKey {
- fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result {
- formatter.write_str(&self.to_hex())
- }
-}
-
-impl From<radroots_identity::PublicKey> for PublicKey {
- fn from(value: radroots_identity::PublicKey) -> Self {
- Self::from_canonical(value)
- }
-}
-
-impl From<PublicKey> for radroots_identity::PublicKey {
- fn from(value: PublicKey) -> Self {
- value.canonical()
- }
-}
-
-impl FromStr for PublicKey {
- type Err = SafeError;
-
- fn from_str(value: &str) -> Result<Self, Self::Err> {
- Self::from_hex(value)
- }
-}
-
-const fn invalid_public_key() -> SafeError {
- SafeError::new(
- SafeErrorCode::InvalidPublicKey,
- SafeMessage::new("The Nostr public key is invalid."),
- )
-}
-
-const fn invalid_secret_key() -> SafeError {
- SafeError::new(
- SafeErrorCode::InvalidSecretKey,
- SafeMessage::new("The Nostr secret key is invalid."),
- )
-}
-
-#[must_use]
-pub fn classify_persisted_public_key(value: &str) -> PersistedPublicKeyClassification {
- if value.len() != PUBLIC_KEY_HEX_LENGTH || !value.bytes().all(|byte| byte.is_ascii_hexdigit()) {
- return PersistedPublicKeyClassification::MalformedEncoding;
- }
- if !value
- .bytes()
- .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
- {
- return PersistedPublicKeyClassification::NonCanonicalEncoding;
- }
- match PublicKey::from_hex(value) {
- Ok(public_key) => PersistedPublicKeyClassification::Canonical(public_key),
- Err(_) => PersistedPublicKeyClassification::InvalidCurvePoint,
- }
-}
-
-fn is_nip19_key_shape(value: &str, prefix: &str) -> bool {
- value.len() == NIP19_KEY_LENGTH
- && value.starts_with(prefix)
- && value[prefix.len()..]
- .bytes()
- .all(|byte| BECH32_DATA_CHARSET.contains(&byte))
-}
-
-#[cfg(test)]
-mod tests {
- use std::str::FromStr;
-
- use super::{
- MAX_SECRET_KEY_INPUT_BYTES, Npub, Nsec, PUBLIC_KEY_BYTE_LENGTH,
- PersistedPublicKeyClassification, PublicKey, SecretKeyInput, SecretKeyInputKind,
- classify_persisted_public_key,
- };
- use crate::SafeErrorCode;
-
- const HEX: &str = "7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7";
- const NPUB: &str = "npub10elfcs4fr0l0r8af98jlmgdh9c8tcxjvz9qkw038js35mp4dma8qzvjptg";
- const NSEC: &str = "nsec1vl029mgpspedva04g90vltkh6fvh240zqtv9k0t9af8935ke9laqsnlfe5";
-
- #[test]
- fn public_key_round_trips_canonical_hex_and_bytes() {
- let key = PublicKey::from_str(HEX).expect("valid public key");
-
- assert_eq!(key.to_hex(), HEX);
- assert_eq!(key.to_string(), HEX);
- assert_eq!(key.short_hex(), "7e7e9c42…2107f6d7");
- assert_eq!(
- PublicKey::from_bytes(*key.as_bytes()).expect("valid bytes"),
- key
- );
- assert_eq!(key.as_bytes().len(), PUBLIC_KEY_BYTE_LENGTH);
- }
-
- #[test]
- fn public_key_rejects_noncanonical_or_malformed_hex() {
- for value in [
- "",
- "00",
- "7E7E9C42A91BFEF19FA7EA99D52D8AFDB67D893A8FEFBA1F5CB9793F2107F6D7",
- "ze7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7",
- " 7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7",
- "00e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7",
- ] {
- let error = PublicKey::from_hex(value).expect_err("invalid public key");
- assert_eq!(error.code(), SafeErrorCode::InvalidPublicKey);
- }
- }
-
- #[test]
- fn public_keys_are_ordered_by_canonical_bytes() {
- let low =
- PublicKey::from_hex("585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df")
- .expect("low key");
- let high =
- PublicKey::from_hex("e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af")
- .expect("high key");
-
- assert!(low < high);
- }
-
- #[test]
- fn persisted_public_key_classification_is_explicit_and_fail_closed() {
- assert!(matches!(
- classify_persisted_public_key(HEX),
- PersistedPublicKeyClassification::Canonical(_)
- ));
- assert_eq!(
- classify_persisted_public_key(HEX.to_ascii_uppercase().as_str()),
- PersistedPublicKeyClassification::NonCanonicalEncoding
- );
- assert_eq!(
- classify_persisted_public_key(&"00".repeat(PUBLIC_KEY_BYTE_LENGTH)),
- PersistedPublicKeyClassification::InvalidCurvePoint
- );
- assert_eq!(
- classify_persisted_public_key("not-a-public-key"),
- PersistedPublicKeyClassification::MalformedEncoding
- );
- }
-
- #[test]
- fn secret_input_is_redacted_and_exposed_only_to_a_scoped_operation() {
- let secret = "11".repeat(PUBLIC_KEY_BYTE_LENGTH);
- let input = SecretKeyInput::parse(secret.clone()).expect("valid secret hex");
-
- assert_eq!(input.kind(), SecretKeyInputKind::Hex);
- assert_eq!(input.with_exposed_secret(str::len), secret.len());
- assert_eq!(input.with_exposed_secret(str::len), 64);
- }
-
- #[test]
- fn secret_input_accepts_nsec_shape_without_exposing_it() {
- let secret = NSEC.to_owned();
- let input = SecretKeyInput::parse(secret.clone()).expect("nsec-shaped input");
-
- assert_eq!(input.kind(), SecretKeyInputKind::Nsec);
- assert_eq!(input.with_exposed_secret(str::len), secret.len());
- }
-
- #[test]
- fn secret_input_rejects_invalid_hex_and_arbitrary_text() {
- for value in [
- "",
- "very-sensitive-input",
- &"GG".repeat(PUBLIC_KEY_BYTE_LENGTH),
- ] {
- let Err(error) = SecretKeyInput::parse(value.to_owned()) else {
- panic!("invalid secret accepted");
- };
- assert_eq!(error.code(), SafeErrorCode::InvalidSecretKey);
- if !value.is_empty() {
- assert!(!format!("{error:?}").contains(value));
- }
- }
- }
-
- #[test]
- fn secret_byte_transport_is_bounded_and_validated() {
- let parsed = SecretKeyInput::parse_bytes(HEX.as_bytes().to_vec()).expect("bytes");
- assert_eq!(parsed.with_exposed_secret(str::len), 64);
- assert!(SecretKeyInput::parse_bytes(vec![0xff]).is_err());
- assert!(SecretKeyInput::parse_bytes(vec![b'a'; MAX_SECRET_KEY_INPUT_BYTES + 1]).is_err());
- }
-
- #[test]
- fn npub_is_public_display_data_but_not_canonical_identity() {
- let npub = Npub::from_encoded(NPUB.to_owned()).expect("valid npub shape");
-
- assert_eq!(npub.as_str(), NPUB);
- assert_eq!(npub.to_string(), NPUB);
- }
-
- #[test]
- fn nsec_is_redacted_and_exposed_only_to_a_scoped_operation() {
- let nsec = Nsec::from_encoded(NSEC.to_owned()).expect("valid nsec shape");
-
- assert_eq!(nsec.with_exposed_secret(str::len), NSEC.len());
- assert_eq!(nsec.with_exposed_secret(str::len), NSEC.len());
- }
-
- #[test]
- fn nip19_display_types_reject_wrong_prefix_length_and_charset() {
- for invalid in [
- "",
- "npub1short",
- "nsec1short",
- "npub10elfcs4fr0l0r8af98jlmgdh9c8tcxjvz9qkw038js35mp4dma8qzvjp!g",
- ] {
- assert!(Npub::from_encoded(invalid.to_owned()).is_err());
- assert!(Nsec::from_encoded(invalid.to_owned()).is_err());
- }
- }
-}
diff --git a/crates/studio_domain/src/lib.rs b/crates/studio_domain/src/lib.rs
@@ -1,21 +0,0 @@
-#![doc = "Radroots Studio Nostr account domain types."]
-
-pub mod account;
-pub mod error;
-pub mod key;
-pub mod profile;
-pub mod relay;
-pub mod time;
-
-pub use account::{
- AccountCreatedAt, AccountIdentity, AccountLabel, AccountSummary, BindingAvailability,
- BindingRepairAction, LocalSignerBinding,
-};
-pub use error::{SafeError, SafeErrorCode, SafeMessage};
-pub use key::{
- MAX_SECRET_KEY_INPUT_BYTES, Npub, Nsec, PersistedPublicKeyClassification, PublicKey,
- SecretKeyInput, SecretKeyInputKind, classify_persisted_public_key,
-};
-pub use profile::{EventId, Kind0ProfileCandidate, ProfileMetadata, select_latest_kind0};
-pub use relay::{RelayDestinationPolicy, RelayUrl, normalize_relay_urls};
-pub use time::UnixTimestamp;
diff --git a/crates/studio_domain/src/profile.rs b/crates/studio_domain/src/profile.rs
@@ -1,298 +0,0 @@
-//! Public Nostr profile metadata values.
-
-use crate::{PublicKey, SafeError, SafeErrorCode, SafeMessage, UnixTimestamp};
-
-const EVENT_ID_BYTES: usize = 32;
-const EVENT_ID_HEX: usize = EVENT_ID_BYTES * 2;
-const MAX_NAME_CHARS: usize = 128;
-const MAX_NIP05_CHARS: usize = 320;
-const MAX_ABOUT_CHARS: usize = 4_096;
-const MAX_PICTURE_CHARS: usize = 2_048;
-
-#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
-pub struct EventId([u8; EVENT_ID_BYTES]);
-
-impl EventId {
- /// Parses a canonical lowercase hexadecimal Nostr event ID.
- ///
- /// # Errors
- ///
- /// Returns a safe profile error for malformed input.
- pub fn from_hex(value: &str) -> Result<Self, SafeError> {
- if value.len() != EVENT_ID_HEX
- || !value
- .bytes()
- .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
- {
- return Err(invalid_profile_metadata());
- }
-
- let mut bytes = [0_u8; EVENT_ID_BYTES];
- for (index, pair) in value.as_bytes().chunks_exact(2).enumerate() {
- let high = decode_hex(pair[0]).ok_or_else(invalid_profile_metadata)?;
- let low = decode_hex(pair[1]).ok_or_else(invalid_profile_metadata)?;
- bytes[index] = (high << 4) | low;
- }
- Ok(Self(bytes))
- }
-
- #[must_use]
- pub const fn from_bytes(bytes: [u8; EVENT_ID_BYTES]) -> Self {
- Self(bytes)
- }
-
- #[must_use]
- pub const fn as_bytes(self) -> [u8; EVENT_ID_BYTES] {
- self.0
- }
-
- #[must_use]
- pub fn to_hex(self) -> String {
- const HEX: &[u8; 16] = b"0123456789abcdef";
- let mut output = String::with_capacity(EVENT_ID_HEX);
- for byte in self.0 {
- output.push(char::from(HEX[usize::from(byte >> 4)]));
- output.push(char::from(HEX[usize::from(byte & 0x0f)]));
- }
- output
- }
-}
-
-#[derive(Clone, Debug, Default, Eq, PartialEq)]
-pub struct ProfileMetadata {
- name: Option<String>,
- display_name: Option<String>,
- nip05: Option<String>,
- about: Option<String>,
- picture: Option<String>,
-}
-
-impl ProfileMetadata {
- /// Normalizes and bounds public kind-0 profile fields.
- ///
- /// # Errors
- ///
- /// Returns a safe profile error when a field exceeds its limit or contains
- /// a forbidden control character.
- pub fn new(
- name: Option<String>,
- display_name: Option<String>,
- nip05: Option<String>,
- about: Option<String>,
- picture: Option<String>,
- ) -> Result<Self, SafeError> {
- Ok(Self {
- name: normalize_field(name, MAX_NAME_CHARS, false)?,
- display_name: normalize_field(display_name, MAX_NAME_CHARS, false)?,
- nip05: normalize_field(nip05, MAX_NIP05_CHARS, false)?,
- about: normalize_field(about, MAX_ABOUT_CHARS, true)?,
- picture: normalize_field(picture, MAX_PICTURE_CHARS, false)?,
- })
- }
-
- #[must_use]
- pub fn name(&self) -> Option<&str> {
- self.name.as_deref()
- }
-
- #[must_use]
- pub fn display_name(&self) -> Option<&str> {
- self.display_name.as_deref()
- }
-
- #[must_use]
- pub fn nip05(&self) -> Option<&str> {
- self.nip05.as_deref()
- }
-
- #[must_use]
- pub fn about(&self) -> Option<&str> {
- self.about.as_deref()
- }
-
- #[must_use]
- pub fn picture(&self) -> Option<&str> {
- self.picture.as_deref()
- }
-
- #[must_use]
- pub fn preferred_name(&self) -> Option<&str> {
- self.display_name().or_else(|| self.name())
- }
-}
-
-#[derive(Clone, Debug, Eq, PartialEq)]
-pub struct Kind0ProfileCandidate {
- event_id: EventId,
- author: PublicKey,
- created_at: UnixTimestamp,
- metadata: ProfileMetadata,
-}
-
-impl Kind0ProfileCandidate {
- #[must_use]
- pub const fn new(
- event_id: EventId,
- author: PublicKey,
- created_at: UnixTimestamp,
- metadata: ProfileMetadata,
- ) -> Self {
- Self {
- event_id,
- author,
- created_at,
- metadata,
- }
- }
-
- #[must_use]
- pub const fn event_id(&self) -> EventId {
- self.event_id
- }
-
- #[must_use]
- pub const fn author(&self) -> PublicKey {
- self.author
- }
-
- #[must_use]
- pub const fn created_at(&self) -> UnixTimestamp {
- self.created_at
- }
-
- #[must_use]
- pub const fn metadata(&self) -> &ProfileMetadata {
- &self.metadata
- }
-}
-
-#[must_use]
-pub fn select_latest_kind0(
- candidates: impl IntoIterator<Item = Kind0ProfileCandidate>,
-) -> Option<Kind0ProfileCandidate> {
- candidates.into_iter().reduce(|selected, candidate| {
- if candidate.created_at > selected.created_at
- || (candidate.created_at == selected.created_at
- && candidate.event_id < selected.event_id)
- {
- candidate
- } else {
- selected
- }
- })
-}
-
-fn normalize_field(
- value: Option<String>,
- max_chars: usize,
- allow_layout_controls: bool,
-) -> Result<Option<String>, SafeError> {
- let Some(value) = value else {
- return Ok(None);
- };
- let normalized = value.trim();
- if normalized.is_empty() {
- return Ok(None);
- }
- if normalized.chars().count() > max_chars
- || normalized.chars().any(|character| {
- character.is_control()
- && !(allow_layout_controls && matches!(character, '\n' | '\r' | '\t'))
- })
- {
- return Err(invalid_profile_metadata());
- }
- Ok(Some(normalized.to_owned()))
-}
-
-const fn invalid_profile_metadata() -> SafeError {
- SafeError::new(
- SafeErrorCode::InvalidProfileMetadata,
- SafeMessage::new("The Nostr profile metadata is invalid."),
- )
-}
-
-const fn decode_hex(byte: u8) -> Option<u8> {
- match byte {
- b'0'..=b'9' => Some(byte - b'0'),
- b'a'..=b'f' => Some(byte - b'a' + 10),
- _ => None,
- }
-}
-
-#[cfg(test)]
-mod tests {
- use crate::{PublicKey, UnixTimestamp};
-
- use super::{EventId, Kind0ProfileCandidate, ProfileMetadata, select_latest_kind0};
-
- fn profile(name: &str) -> ProfileMetadata {
- ProfileMetadata::new(Some(name.to_owned()), None, None, None, None).expect("valid profile")
- }
-
- fn candidate(id_byte: u8, created_at: i64, name: &str) -> Kind0ProfileCandidate {
- Kind0ProfileCandidate::new(
- EventId::from_bytes([id_byte; 32]),
- PublicKey::from_bytes([7_u8; 32]).expect("valid public key"),
- UnixTimestamp::from_seconds(created_at).expect("valid timestamp"),
- profile(name),
- )
- }
-
- #[test]
- fn profile_fields_are_trimmed_bounded_and_public() {
- let metadata = ProfileMetadata::new(
- Some(" farmer ".to_owned()),
- Some(" Farm Account ".to_owned()),
- Some("farmer@example.test".to_owned()),
- Some("First line\nSecond line".to_owned()),
- Some("https://images.example.test/profile.png".to_owned()),
- )
- .expect("valid profile");
-
- assert_eq!(metadata.name(), Some("farmer"));
- assert_eq!(metadata.display_name(), Some("Farm Account"));
- assert_eq!(metadata.preferred_name(), Some("Farm Account"));
- assert_eq!(metadata.nip05(), Some("farmer@example.test"));
- assert_eq!(metadata.about(), Some("First line\nSecond line"));
- assert_eq!(
- metadata.picture(),
- Some("https://images.example.test/profile.png")
- );
- }
-
- #[test]
- fn profile_fields_reject_forbidden_controls_and_oversize_values() {
- assert!(
- ProfileMetadata::new(Some("bad\0name".to_owned()), None, None, None, None).is_err()
- );
- assert!(ProfileMetadata::new(Some("x".repeat(129)), None, None, None, None).is_err());
- }
-
- #[test]
- fn latest_kind0_uses_timestamp_then_lowest_event_id() {
- let older = candidate(0, 10, "older");
- let equal_high_id = candidate(9, 20, "high-id");
- let equal_low_id = candidate(1, 20, "low-id");
-
- let selected =
- select_latest_kind0([older, equal_high_id, equal_low_id]).expect("selected profile");
-
- assert_eq!(selected.metadata().name(), Some("low-id"));
- assert_eq!(selected.event_id().as_bytes(), [1_u8; 32]);
- assert_eq!(
- selected.author(),
- PublicKey::from_bytes([7_u8; 32]).expect("valid public key")
- );
- assert_eq!(selected.created_at().as_seconds(), 20);
- }
-
- #[test]
- fn event_id_rejects_noncanonical_hex_and_round_trips() {
- let hex = "12".repeat(32);
- let event_id = EventId::from_hex(&hex).expect("valid event id");
-
- assert_eq!(event_id.to_hex(), hex);
- assert!(EventId::from_hex(&"GG".repeat(32)).is_err());
- }
-}
diff --git a/crates/studio_domain/src/relay.rs b/crates/studio_domain/src/relay.rs
@@ -1,198 +0,0 @@
-//! Validated Nostr relay values.
-
-use std::collections::HashSet;
-use std::fmt::{self, Display, Formatter};
-
-use url::{Host, Url};
-
-use crate::{SafeError, SafeErrorCode, SafeMessage};
-
-#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
-pub enum RelayDestinationPolicy {
- Public,
- Local,
- PrivateNetwork,
-}
-
-#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
-pub struct RelayUrl {
- value: String,
- policy: RelayDestinationPolicy,
-}
-
-impl RelayUrl {
- /// Parses and normalizes an allowed WebSocket relay URL.
- ///
- /// # Errors
- ///
- /// Returns a safe configuration error for empty or malformed input,
- /// forbidden schemes, credentials, fragments, or non-loopback `ws://`.
- pub fn parse(value: &str, policy: RelayDestinationPolicy) -> Result<Self, SafeError> {
- let trimmed = value.trim();
- if trimmed.is_empty() || trimmed.chars().any(char::is_control) {
- return Err(invalid_relay());
- }
-
- let parsed = Url::parse(trimmed).map_err(|_| invalid_relay())?;
- if !parsed.username().is_empty()
- || parsed.password().is_some()
- || parsed.fragment().is_some()
- {
- return Err(invalid_relay());
- }
-
- match (policy, parsed.scheme()) {
- (RelayDestinationPolicy::Public | RelayDestinationPolicy::PrivateNetwork, "wss") => {}
- (RelayDestinationPolicy::Local, "ws" | "wss") if is_loopback(&parsed) => {}
- _ => return Err(invalid_relay()),
- }
-
- if parsed.host().is_none() {
- return Err(invalid_relay());
- }
-
- Ok(Self {
- value: parsed.to_string(),
- policy,
- })
- }
-
- #[must_use]
- pub fn as_str(&self) -> &str {
- &self.value
- }
-
- #[must_use]
- pub const fn policy(&self) -> RelayDestinationPolicy {
- self.policy
- }
-}
-
-impl Display for RelayUrl {
- fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result {
- formatter.write_str(&self.value)
- }
-}
-
-/// Parses relay values and removes duplicates without changing first-seen order.
-///
-/// # Errors
-///
-/// Returns the first safe relay validation error.
-pub fn normalize_relay_urls<I, S>(
- values: I,
- policy: RelayDestinationPolicy,
-) -> Result<Vec<RelayUrl>, SafeError>
-where
- I: IntoIterator<Item = S>,
- S: AsRef<str>,
-{
- let mut seen = HashSet::new();
- let mut relays = Vec::new();
- for value in values {
- let relay = RelayUrl::parse(value.as_ref(), policy)?;
- if seen.insert(relay.clone()) {
- relays.push(relay);
- }
- }
- Ok(relays)
-}
-
-fn is_loopback(url: &Url) -> bool {
- match url.host() {
- Some(Host::Domain(domain)) => domain == "localhost",
- Some(Host::Ipv4(address)) => address.octets()[0] == 127,
- Some(Host::Ipv6(address)) => address.is_loopback(),
- None => false,
- }
-}
-
-const fn invalid_relay() -> SafeError {
- SafeError::new(
- SafeErrorCode::InvalidRelayConfiguration,
- SafeMessage::new("The Nostr relay URL is invalid."),
- )
-}
-
-#[cfg(test)]
-mod tests {
- use super::{RelayDestinationPolicy, RelayUrl, normalize_relay_urls};
- use crate::SafeErrorCode;
-
- #[test]
- fn relay_accepts_secure_remote_and_loopback_development_urls() {
- for (input, policy, expected) in [
- (
- " wss://Relay.Example/path ",
- RelayDestinationPolicy::Public,
- "wss://relay.example/path",
- ),
- (
- "ws://localhost:8080",
- RelayDestinationPolicy::Local,
- "ws://localhost:8080/",
- ),
- (
- "ws://127.42.1.9:8080",
- RelayDestinationPolicy::Local,
- "ws://127.42.1.9:8080/",
- ),
- (
- "ws://[::1]:8080",
- RelayDestinationPolicy::Local,
- "ws://[::1]:8080/",
- ),
- ] {
- let relay = RelayUrl::parse(input, policy).expect("allowed relay");
- assert_eq!(relay.as_str(), expected);
- assert_eq!(relay.to_string(), expected);
- }
- }
-
- #[test]
- fn relay_rejects_non_websocket_credentials_fragments_and_remote_plaintext() {
- for input in [
- "",
- "https://relay.example",
- "http://localhost:8080",
- "wss://user:password@relay.example",
- "wss://relay.example/#fragment",
- "ws://relay.example",
- "ws://192.168.1.2:8080",
- "ws://localhost.evil.example:8080",
- "wss://relay.example/\nunsafe",
- ] {
- let error = RelayUrl::parse(input, RelayDestinationPolicy::Public)
- .expect_err("forbidden relay");
- assert_eq!(error.code(), SafeErrorCode::InvalidRelayConfiguration);
- }
- }
-
- #[test]
- fn relay_deduplication_preserves_normalized_first_seen_order() {
- let relays = normalize_relay_urls(
- [
- "wss://relay.example",
- " wss://second.example/path ",
- "wss://RELAY.example/",
- "wss://second.example/path",
- ],
- RelayDestinationPolicy::Public,
- )
- .expect("valid relays");
-
- assert_eq!(
- relays.iter().map(RelayUrl::as_str).collect::<Vec<_>>(),
- vec!["wss://relay.example/", "wss://second.example/path"]
- );
- }
-
- #[test]
- fn relay_destination_policy_is_explicit_and_fail_closed() {
- assert!(RelayUrl::parse("ws://localhost:8080", RelayDestinationPolicy::Public).is_err());
- assert!(RelayUrl::parse("wss://relay.example", RelayDestinationPolicy::Local).is_err());
- let private = RelayUrl::parse("wss://10.0.0.4", RelayDestinationPolicy::PrivateNetwork)
- .expect("explicit private network");
- assert_eq!(private.policy(), RelayDestinationPolicy::PrivateNetwork);
- }
-}
diff --git a/crates/studio_domain/src/time.rs b/crates/studio_domain/src/time.rs
@@ -1,36 +0,0 @@
-//! Time values shared by account and profile records.
-
-#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
-pub struct UnixTimestamp(i64);
-
-impl UnixTimestamp {
- pub const UNIX_EPOCH: Self = Self(0);
-
- #[must_use]
- pub const fn from_seconds(seconds: i64) -> Option<Self> {
- if seconds < 0 {
- None
- } else {
- Some(Self(seconds))
- }
- }
-
- #[must_use]
- pub const fn as_seconds(self) -> i64 {
- self.0
- }
-}
-
-#[cfg(test)]
-mod tests {
- use super::UnixTimestamp;
-
- #[test]
- fn timestamp_rejects_negative_seconds() {
- assert_eq!(UnixTimestamp::from_seconds(-1), None);
- assert_eq!(
- UnixTimestamp::from_seconds(0).map(UnixTimestamp::as_seconds),
- Some(0)
- );
- }
-}
diff --git a/crates/studio_ffi/Cargo.toml b/crates/studio_ffi/Cargo.toml
@@ -1,45 +0,0 @@
-[package]
-name = "radroots_studio_ffi"
-description = "Private native FFI boundary for Radroots Studio"
-version = "0.1.0-alpha"
-edition.workspace = true
-authors.workspace = true
-rust-version.workspace = true
-license = "GPL-3.0-only"
-repository.workspace = true
-homepage.workspace = true
-publish = false
-build = "build.rs"
-include = ["build.rs", "src/**", "uniffi.toml", "Cargo.toml"]
-
-[lib]
-crate-type = ["cdylib", "rlib"]
-
-[dependencies]
-directories = "=6.0.0"
-radroots_studio_application.workspace = true
-radroots_studio_domain.workspace = true
-radroots_studio_nostr.workspace = true
-radroots_studio_runtime.workspace = true
-radroots_studio_storage.workspace = true
-tokio = { version = "=1.47.1", features = [
- "macros",
- "rt-multi-thread",
- "sync",
- "time",
-] }
-uniffi = "=0.32.0"
-
-[build-dependencies]
-quote.workspace = true
-sha2.workspace = true
-syn.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" }
-tempfile = "=3.23.0"
-
-[lints]
-workspace = true
diff --git a/crates/studio_ffi/build.rs b/crates/studio_ffi/build.rs
@@ -1,95 +0,0 @@
-use std::fs;
-use std::path::{Path, PathBuf};
-
-use quote::ToTokens;
-use sha2::{Digest, Sha256};
-use syn::{ImplItem, Item, Visibility};
-
-const CONTRACT_SOURCES: &[&str] = &[
- "src/commands.rs",
- "src/contract.rs",
- "src/dto.rs",
- "src/lib.rs",
- "src/observer.rs",
-];
-
-fn main() {
- for source in CONTRACT_SOURCES {
- println!("cargo:rerun-if-changed={source}");
- }
- println!("cargo:rerun-if-changed=../studio_storage/migrations");
-
- let mut metadata = Vec::new();
- for source in CONTRACT_SOURCES {
- collect_public_metadata(Path::new(source), &mut metadata);
- }
- let mut migrations = fs::read_dir("../studio_storage/migrations")
- .expect("read Studio migration catalog")
- .map(|entry| entry.expect("read migration entry").path())
- .filter(|path| path.extension().is_some_and(|extension| extension == "sql"))
- .collect::<Vec<_>>();
- migrations.sort();
- for migration in migrations {
- metadata.push(format!(
- "migration:{}:{}",
- migration
- .file_name()
- .expect("migration filename")
- .to_string_lossy(),
- hex_digest(&fs::read(&migration).expect("read migration"))
- ));
- }
- metadata.sort();
- metadata.dedup();
- let normalized = metadata.join("\n");
- println!(
- "cargo:rustc-env=RADROOTS_STUDIO_FFI_CONTRACT_DIGEST={}",
- hex_digest(normalized.as_bytes())
- );
- fs::write(
- PathBuf::from(std::env::var_os("OUT_DIR").expect("OUT_DIR"))
- .join("ffi_contract_metadata.txt"),
- normalized,
- )
- .expect("write normalized FFI metadata");
-}
-
-fn collect_public_metadata(path: &Path, output: &mut Vec<String>) {
- let source = fs::read_to_string(path).expect("read FFI source");
- let file = syn::parse_file(&source).expect("parse FFI source");
- for item in file.items {
- match item {
- Item::Const(item) if is_public(&item.vis) => push_tokens("const", item, output),
- Item::Enum(item) if is_public(&item.vis) => push_tokens("enum", item, output),
- Item::Fn(item) if is_public(&item.vis) => push_tokens("fn", item.sig, output),
- Item::Struct(item) if is_public(&item.vis) => push_tokens("struct", item, output),
- Item::Trait(item) if is_public(&item.vis) => push_tokens("trait", item, output),
- Item::Impl(item) => {
- let owner = item.self_ty.to_token_stream().to_string();
- for member in item.items {
- if let ImplItem::Fn(function) = member
- && is_public(&function.vis)
- {
- output.push(format!("method:{owner}:{}", function.sig.to_token_stream()));
- }
- }
- }
- _ => {}
- }
- }
-}
-
-fn push_tokens(kind: &str, value: impl ToTokens, output: &mut Vec<String>) {
- output.push(format!("{kind}:{}", value.to_token_stream()));
-}
-
-const fn is_public(visibility: &Visibility) -> bool {
- matches!(visibility, Visibility::Public(_))
-}
-
-fn hex_digest(bytes: &[u8]) -> String {
- Sha256::digest(bytes)
- .iter()
- .map(|byte| format!("{byte:02x}"))
- .collect()
-}
diff --git a/crates/studio_ffi/src/commands.rs b/crates/studio_ffi/src/commands.rs
@@ -1,1122 +0,0 @@
-use std::collections::BTreeMap;
-use std::fmt::{self, Display, Formatter};
-use std::num::NonZeroUsize;
-use std::path::{Path, PathBuf};
-use std::sync::atomic::{AtomicBool, Ordering};
-use std::sync::{Arc, Mutex, OnceLock};
-use std::time::{Duration, SystemTime, UNIX_EPOCH};
-
-use directories::ProjectDirs;
-use radroots_studio_application::{
- Clock, DurableRequestId, GeneratedKeyRecoveryHandle, RelayConfiguration, RelayRuntimeMode,
- RemovalConfirmationToken, relay_configuration_from_environment,
-};
-use radroots_studio_domain::{PublicKey, SafeError, SecretKeyInput, UnixTimestamp};
-use radroots_studio_nostr::SdkNostrClient;
-use radroots_studio_runtime::{
- RuntimeActorHandle, RuntimeDependencies, UuidInstallationIdentitySource,
-};
-use radroots_studio_storage::OsKeyringSecretStore;
-
-use crate::{
- AccountDto, AppSnapshotDto, WireErrorCategory, WireErrorCode, WireRecoveryAction,
- contract::{
- FFI_CONTRACT_HASH, FFI_CONTRACT_MAJOR, FFI_CONTRACT_MINOR, MINIMUM_SCHEMA_VERSION,
- PRODUCT_VERSION,
- },
- dto::error_policy,
-};
-
-const DATABASE_QUALIFIER: &str = "org";
-const DATABASE_ORGANIZATION: &str = "radroots";
-const DATABASE_APPLICATION: &str = "studio";
-const DATABASE_FILENAME: &str = "studio.sqlite3";
-const DEVELOPMENT_DATA_DIR_ENVIRONMENT: &str = "RADROOTS_STUDIO_DEVELOPMENT_DATA_DIR";
-pub(crate) const ACTOR_MAILBOX_CAPACITY: usize = 64;
-const MAX_COMMAND_DEADLINE_MILLIS: u64 = 30_000;
-
-#[derive(Clone, Debug, Eq, PartialEq)]
-#[cfg_attr(not(coverage_nightly), derive(uniffi::Record))]
-pub struct RequestContextDto {
- pub request_id: String,
- pub expected_revision: u64,
- pub deadline_millis: u64,
-}
-
-#[derive(Clone, Debug, Eq, PartialEq)]
-#[cfg_attr(not(coverage_nightly), derive(uniffi::Record))]
-pub struct AccountCommandReceiptDto {
- pub request_id: String,
- pub committed_revision: u64,
- pub snapshot: AppSnapshotDto,
-}
-
-#[derive(Clone, Debug, Eq, PartialEq)]
-#[cfg_attr(not(coverage_nightly), derive(uniffi::Record))]
-pub struct CompatibilityDescriptor {
- pub product_version: String,
- pub cargo_package_version: String,
- pub contract_major: u16,
- pub contract_minor: u16,
- pub contract_hash: String,
- pub minimum_schema_version: u32,
- pub current_schema_version: u32,
-}
-
-#[derive(Clone, Debug, Eq, PartialEq)]
-#[cfg_attr(not(coverage_nightly), derive(uniffi::Record))]
-pub struct CompatibilityExpectation {
- pub contract_major: u16,
- pub minimum_contract_minor: u16,
- pub contract_hash: String,
- pub minimum_schema_version: u32,
- pub maximum_schema_version: u32,
-}
-
-#[cfg_attr(not(coverage_nightly), uniffi::export)]
-pub fn compatibility_descriptor() -> CompatibilityDescriptor {
- CompatibilityDescriptor {
- product_version: PRODUCT_VERSION.to_owned(),
- cargo_package_version: env!("CARGO_PKG_VERSION").to_owned(),
- contract_major: FFI_CONTRACT_MAJOR,
- contract_minor: FFI_CONTRACT_MINOR,
- contract_hash: FFI_CONTRACT_HASH.to_owned(),
- minimum_schema_version: MINIMUM_SCHEMA_VERSION,
- current_schema_version: radroots_studio_storage::CURRENT_SCHEMA_VERSION,
- }
-}
-
-#[derive(Debug)]
-#[cfg_attr(not(coverage_nightly), derive(uniffi::Error))]
-pub enum StudioError {
- Failure {
- code: WireErrorCode,
- category: WireErrorCategory,
- retryable: bool,
- recovery_action: WireRecoveryAction,
- correlation_id: Option<String>,
- safe_message: String,
- },
-}
-
-impl Display for StudioError {
- fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result {
- match self {
- Self::Failure { safe_message, .. } => formatter.write_str(safe_message),
- }
- }
-}
-
-impl std::error::Error for StudioError {}
-
-impl From<SafeError> for StudioError {
- fn from(error: SafeError) -> Self {
- let (category, retryable, recovery_action) = error_policy(error.code());
- Self::Failure {
- code: error.code().into(),
- category,
- retryable,
- recovery_action,
- correlation_id: None,
- safe_message: error.message().as_str().to_owned(),
- }
- }
-}
-
-impl StudioError {
- fn correlated(error: SafeError, correlation_id: &str) -> Self {
- let (category, retryable, recovery_action) = error_policy(error.code());
- Self::Failure {
- code: error.code().into(),
- category,
- retryable,
- recovery_action,
- correlation_id: Some(correlation_id.to_owned()),
- safe_message: error.message().as_str().to_owned(),
- }
- }
-}
-
-#[cfg_attr(not(coverage_nightly), derive(uniffi::Object))]
-pub struct GeneratedRecoveryRequest {
- handle: GeneratedKeyRecoveryHandle,
- resolved: AtomicBool,
-}
-
-#[cfg_attr(not(coverage_nightly), uniffi::export)]
-impl GeneratedRecoveryRequest {
- pub fn account(&self) -> AccountDto {
- self.handle.view().account().into()
- }
-
- pub fn expires_at_seconds(&self) -> i64 {
- self.handle.view().expires_at().as_seconds()
- }
-
- /// Returns the recovery secret exactly once.
- ///
- /// # Errors
- ///
- /// Returns a safe unavailable error after the first read.
- pub fn take_recovery_nsec(&self) -> Result<String, StudioError> {
- self.handle
- .take_recovery_nsec()
- .map(|nsec| nsec.with_exposed_secret(str::to_owned))
- .map_err(StudioError::from)
- }
-}
-
-#[cfg_attr(not(coverage_nightly), derive(uniffi::Object))]
-pub struct RemovalRequest {
- public_key_hex: String,
- deletes_local_credential: bool,
- signs_out: bool,
- expires_at_seconds: i64,
- token: Mutex<Option<RemovalConfirmationToken>>,
-}
-
-#[cfg_attr(not(coverage_nightly), uniffi::export)]
-impl RemovalRequest {
- pub fn public_key_hex(&self) -> String {
- self.public_key_hex.clone()
- }
-
- pub fn deletes_local_credential(&self) -> bool {
- self.deletes_local_credential
- }
-
- pub fn signs_out(&self) -> bool {
- self.signs_out
- }
-
- pub fn expires_at_seconds(&self) -> i64 {
- self.expires_at_seconds
- }
-}
-
-pub(crate) struct RuntimeCore {
- pub(crate) actor: RuntimeActorHandle,
- pub(crate) observers: Mutex<
- BTreeMap<
- radroots_studio_application::ChangeSubscriptionId,
- Option<tokio::task::JoinHandle<()>>,
- >,
- >,
- pub(crate) closed: AtomicBool,
- pub(crate) startup_relay_problem: Option<SafeError>,
-}
-
-impl RuntimeCore {
- pub(crate) fn snapshot_dto(&self) -> AppSnapshotDto {
- AppSnapshotDto::from_runtime(&self.actor.snapshot(), self.effective_lifecycle())
- }
-
- pub(crate) fn dto_for(
- &self,
- snapshot: &radroots_studio_application::AppSnapshot,
- ) -> AppSnapshotDto {
- AppSnapshotDto::from_runtime(snapshot, self.effective_lifecycle())
- }
-
- pub(crate) fn effective_lifecycle(&self) -> radroots_studio_application::RuntimeLifecycle {
- let lifecycle = self.actor.lifecycle();
- match (lifecycle, self.startup_relay_problem) {
- (radroots_studio_application::RuntimeLifecycle::Ready, Some(problem)) => {
- radroots_studio_application::RuntimeLifecycle::Degraded(problem)
- }
- _ => lifecycle,
- }
- }
-}
-
-#[cfg_attr(not(coverage_nightly), derive(uniffi::Object))]
-pub struct StudioAppCore {
- pub(crate) inner: Arc<RuntimeCore>,
-}
-
-#[cfg_attr(not(coverage_nightly), uniffi::export)]
-impl StudioAppCore {
- /// Verifies the static contract before touching the application data path.
- ///
- /// # Errors
- ///
- /// Returns a safe compatibility error without opening or migrating storage.
- #[cfg_attr(not(coverage_nightly), uniffi::constructor)]
- #[allow(clippy::needless_pass_by_value)]
- pub fn open_compatible(
- expectation: CompatibilityExpectation,
- development_mode: bool,
- ) -> Result<Arc<Self>, StudioError> {
- let path = application_database_path(development_mode)?;
- Self::open_path_compatible(&path, &expectation, development_mode)
- }
-
- /// Restores durable public application state.
- ///
- /// # Errors
- ///
- /// Returns a safe storage, recovery, or application-state error.
- pub async fn bootstrap(&self) -> Result<AppSnapshotDto, StudioError> {
- self.inner
- .actor
- .bootstrap()
- .await
- .map(|snapshot| self.inner.dto_for(&snapshot))
- .map_err(StudioError::from)
- }
-
- #[must_use]
- pub fn snapshot(&self) -> AppSnapshotDto {
- self.inner.snapshot_dto()
- }
-
- /// Begins the exclusive generated-account recovery flow without persistence.
- ///
- /// # Errors
- ///
- /// Returns a safe key-generation, conflict, timeout, or lifecycle error.
- pub async fn begin_generated_account_v2(
- &self,
- ) -> Result<Arc<GeneratedRecoveryRequest>, StudioError> {
- self.inner
- .actor
- .begin_generated_key_stage()
- .await
- .map(|handle| {
- Arc::new(GeneratedRecoveryRequest {
- handle,
- resolved: AtomicBool::new(false),
- })
- })
- .map_err(StudioError::from)
- }
-
- /// Acknowledges recovery and commits the generated account once.
- ///
- /// # Errors
- ///
- /// Returns a terminal safe recovery, credential, persistence, timeout, or lifecycle error.
- /// A failed commit must be recovered by importing the already-saved recovery key.
- pub async fn acknowledge_generated_account_v2(
- &self,
- context: RequestContextDto,
- request: Arc<GeneratedRecoveryRequest>,
- ) -> Result<AppSnapshotDto, StudioError> {
- if request.resolved.swap(true, Ordering::AcqRel) {
- return Err(generated_recovery_expired());
- }
- let request_id = DurableRequestId::parse(context.request_id.clone())
- .map_err(|error| StudioError::correlated(error, &context.request_id))?;
- let timeout = command_timeout(context.deadline_millis, &context.request_id)?;
- self.inner
- .actor
- .acknowledge_generated_key_stage(
- request.handle.id(),
- request_id,
- radroots_studio_application::SnapshotRevision::from_value(
- context.expected_revision,
- ),
- timeout,
- )
- .await
- .map(|snapshot| self.inner.dto_for(&snapshot))
- .map_err(generated_commit_failed)
- }
-
- /// Cancels the exclusive generated-account recovery flow.
- ///
- /// # Errors
- ///
- /// Returns a safe timeout or lifecycle error.
- pub async fn cancel_generated_account_v2(
- &self,
- request: Arc<GeneratedRecoveryRequest>,
- ) -> Result<bool, StudioError> {
- if request.resolved.swap(true, Ordering::AcqRel) {
- return Ok(false);
- }
- self.inner
- .actor
- .cancel_generated_key_stage()
- .await
- .map_err(StudioError::from)
- }
-
- /// Imports or repairs an account using a caller-owned idempotency key.
- ///
- /// # Errors
- ///
- /// Returns a correlated validation, conflict, timeout, credential, or storage error.
- pub async fn import_account_v2(
- &self,
- context: RequestContextDto,
- secret_key: Vec<u8>,
- ) -> Result<AccountCommandReceiptDto, StudioError> {
- let request_id = DurableRequestId::parse(context.request_id.clone())
- .map_err(|error| StudioError::correlated(error, &context.request_id))?;
- let timeout = command_timeout(context.deadline_millis, &context.request_id)?;
- let input = SecretKeyInput::parse_bytes(secret_key)
- .map_err(|error| StudioError::correlated(error, &context.request_id))?;
- self.inner
- .actor
- .import_secret_key(
- request_id,
- radroots_studio_application::SnapshotRevision::from_value(
- context.expected_revision,
- ),
- input,
- timeout,
- )
- .await
- .map(|_| {
- let snapshot = self.inner.snapshot_dto();
- AccountCommandReceiptDto {
- request_id: context.request_id.clone(),
- committed_revision: snapshot.revision,
- snapshot,
- }
- })
- .map_err(|error| StudioError::correlated(error, &context.request_id))
- }
-
- /// Selects one saved account without activating it.
- ///
- /// # Errors
- ///
- /// Returns a safe public-key, account, or storage error.
- pub async fn select_account(
- &self,
- public_key_hex: String,
- ) -> Result<AppSnapshotDto, StudioError> {
- let public_key = parse_public_key(&public_key_hex)?;
- self.inner
- .actor
- .select_account(public_key)
- .await
- .map(|snapshot| self.inner.dto_for(&snapshot))
- .map_err(StudioError::from)
- }
-
- /// Activates one saved account after validating its credential.
- ///
- /// # Errors
- ///
- /// Returns a safe public-key, credential, account, or storage error.
- pub async fn activate_account(
- &self,
- public_key_hex: String,
- ) -> Result<AppSnapshotDto, StudioError> {
- let public_key = parse_public_key(&public_key_hex)?;
- self.inner
- .actor
- .activate_account(public_key)
- .await
- .map(|snapshot| self.inner.dto_for(&snapshot))
- .map_err(StudioError::from)
- }
-
- /// Signs out while retaining accounts and credentials.
- ///
- /// # Errors
- ///
- /// Returns a safe application-state error.
- pub async fn sign_out(&self) -> Result<AppSnapshotDto, StudioError> {
- self.inner
- .actor
- .sign_out()
- .await
- .map(|snapshot| self.inner.dto_for(&snapshot))
- .map_err(StudioError::from)
- }
-
- /// Refreshes the active Nostr profile from configured relays.
- ///
- /// # Errors
- ///
- /// Returns a safe storage or application-state error.
- pub async fn refresh_active_profile(&self) -> Result<AppSnapshotDto, StudioError> {
- self.inner
- .actor
- .refresh_active_profile()
- .await
- .map(|snapshot| self.inner.dto_for(&snapshot))
- .map_err(StudioError::from)
- }
-
- /// Issues a revision-bound removal confirmation object.
- ///
- /// # Errors
- ///
- /// Returns a safe public-key or account error.
- pub async fn request_account_removal(
- &self,
- public_key_hex: String,
- ) -> Result<Arc<RemovalRequest>, StudioError> {
- let public_key = parse_public_key(&public_key_hex)?;
- self.inner
- .actor
- .request_account_removal(public_key)
- .await
- .map(|token| {
- let impact = token.impact();
- Arc::new(RemovalRequest {
- public_key_hex,
- deletes_local_credential: impact.deletes_local_credential(),
- signs_out: impact.signs_out(),
- expires_at_seconds: token.expires_at().as_seconds(),
- token: Mutex::new(Some(token)),
- })
- })
- .map_err(StudioError::from)
- }
-
- /// Permanently removes the account represented by a one-time request.
- ///
- /// # Errors
- ///
- /// Returns a safe confirmation, credential, recovery, or storage error.
- pub async fn confirm_account_removal(
- &self,
- context: RequestContextDto,
- request: Arc<RemovalRequest>,
- ) -> Result<AppSnapshotDto, StudioError> {
- let token = request
- .token
- .lock()
- .unwrap_or_else(std::sync::PoisonError::into_inner)
- .take()
- .ok_or_else(confirmation_expired)?;
- let request_id = DurableRequestId::parse(context.request_id.clone())
- .map_err(|error| StudioError::correlated(error, &context.request_id))?;
- let timeout = command_timeout(context.deadline_millis, &context.request_id)?;
- self.inner
- .actor
- .confirm_account_removal(
- token,
- request_id,
- radroots_studio_application::SnapshotRevision::from_value(
- context.expected_revision,
- ),
- timeout,
- )
- .await
- .map(|snapshot| self.inner.dto_for(&snapshot))
- .map_err(StudioError::from)
- }
-}
-
-fn verify_compatibility(expectation: &CompatibilityExpectation) -> Result<(), StudioError> {
- let actual = compatibility_descriptor();
- if expectation.contract_major != actual.contract_major
- || expectation.minimum_contract_minor > actual.contract_minor
- || expectation.contract_hash != actual.contract_hash
- || expectation.minimum_schema_version > actual.current_schema_version
- || expectation.maximum_schema_version < actual.minimum_schema_version
- {
- return Err(compatibility_mismatch());
- }
- Ok(())
-}
-
-impl StudioAppCore {
- fn open_path_compatible(
- path: &Path,
- expectation: &CompatibilityExpectation,
- development_mode: bool,
- ) -> Result<Arc<Self>, StudioError> {
- verify_compatibility(expectation)?;
- std::fs::create_dir_all(path.parent().ok_or_else(path_unavailable)?)
- .map_err(|_| path_unavailable())?;
- Self::open_path(path, development_mode)
- }
-
- // The concrete product opener binds operating-system paths, keyrings, and
- // SQLite ownership. Platform installation lanes exercise this adapter;
- // deterministic coverage owns the compatibility and runtime policies.
- #[cfg_attr(coverage_nightly, coverage(off))]
- fn open_path(path: &Path, development_mode: bool) -> Result<Arc<Self>, StudioError> {
- let mode = if development_mode {
- RelayRuntimeMode::Development
- } else {
- RelayRuntimeMode::Packaged
- };
- let (relays, startup_relay_problem) =
- local_first_relay_configuration(relay_configuration_from_environment(mode));
- let runtime = runtime()?;
- let actor = runtime.block_on(RuntimeActorHandle::open(
- path,
- relays,
- RuntimeDependencies::new(
- Arc::new(OsKeyringSecretStore::default()),
- Arc::new(SystemClock),
- Arc::new(SdkNostrClient::new(Duration::from_secs(5))),
- Arc::new(UuidInstallationIdentitySource),
- ),
- actor_mailbox_capacity()?,
- runtime.handle(),
- ))?;
- Ok(Arc::new(Self {
- inner: Arc::new(RuntimeCore {
- actor,
- observers: Mutex::new(BTreeMap::new()),
- closed: AtomicBool::new(false),
- startup_relay_problem,
- }),
- }))
- }
-}
-
-fn local_first_relay_configuration(
- configured: Result<RelayConfiguration, SafeError>,
-) -> (RelayConfiguration, Option<SafeError>) {
- match configured {
- Ok(relays) => (relays, None),
- Err(problem) => (RelayConfiguration::default(), Some(problem)),
- }
-}
-
-#[derive(Clone, Copy)]
-pub(crate) struct SystemClock;
-
-impl Clock for SystemClock {
- fn now(&self) -> UnixTimestamp {
- let seconds = SystemTime::now()
- .duration_since(UNIX_EPOCH)
- .map_or(0, |duration| {
- i64::try_from(duration.as_secs()).unwrap_or(i64::MAX)
- });
- UnixTimestamp::from_seconds(seconds).unwrap_or(UnixTimestamp::UNIX_EPOCH)
- }
-}
-
-// ProjectDirs and the process environment are host integration boundaries.
-#[cfg_attr(coverage_nightly, coverage(off))]
-fn application_database_path(development_mode: bool) -> Result<PathBuf, StudioError> {
- if development_mode && let Some(directory) = std::env::var_os(DEVELOPMENT_DATA_DIR_ENVIRONMENT)
- {
- return Ok(PathBuf::from(directory).join(DATABASE_FILENAME));
- }
- ProjectDirs::from(
- DATABASE_QUALIFIER,
- DATABASE_ORGANIZATION,
- DATABASE_APPLICATION,
- )
- .map(|project| project.data_dir().join(DATABASE_FILENAME))
- .ok_or_else(path_unavailable)
-}
-
-fn parse_public_key(value: &str) -> Result<PublicKey, StudioError> {
- PublicKey::from_hex(value).map_err(StudioError::from)
-}
-
-fn command_timeout(millis: u64, correlation_id: &str) -> Result<Duration, StudioError> {
- if millis == 0 || millis > MAX_COMMAND_DEADLINE_MILLIS {
- return Err(StudioError::Failure {
- code: WireErrorCode::InvalidApplicationState,
- category: WireErrorCategory::Input,
- retryable: false,
- recovery_action: WireRecoveryAction::None,
- correlation_id: Some(correlation_id.to_owned()),
- safe_message: "The command deadline is invalid.".to_owned(),
- });
- }
- Ok(Duration::from_millis(millis))
-}
-
-pub(crate) fn runtime() -> Result<&'static tokio::runtime::Runtime, StudioError> {
- static RUNTIME: OnceLock<Result<tokio::runtime::Runtime, ()>> = OnceLock::new();
- RUNTIME
- .get_or_init(|| {
- tokio::runtime::Builder::new_multi_thread()
- .enable_all()
- .thread_name("radroots-studio-core")
- .build()
- .map_err(|_| ())
- })
- .as_ref()
- .map_err(|()| runtime_unavailable())
-}
-
-fn actor_mailbox_capacity() -> Result<NonZeroUsize, StudioError> {
- NonZeroUsize::new(ACTOR_MAILBOX_CAPACITY).ok_or_else(runtime_unavailable)
-}
-
-fn runtime_unavailable() -> StudioError {
- StudioError::Failure {
- code: WireErrorCode::InvalidApplicationState,
- category: WireErrorCategory::Lifecycle,
- retryable: true,
- recovery_action: WireRecoveryAction::RestartApplication,
- correlation_id: None,
- safe_message: "The application runtime is unavailable.".to_owned(),
- }
-}
-
-fn path_unavailable() -> StudioError {
- StudioError::Failure {
- code: WireErrorCode::StorageUnavailable,
- category: WireErrorCategory::Storage,
- retryable: true,
- recovery_action: WireRecoveryAction::RestartApplication,
- correlation_id: None,
- safe_message: "The application data directory is unavailable.".to_owned(),
- }
-}
-
-fn confirmation_expired() -> StudioError {
- StudioError::Failure {
- code: WireErrorCode::InvalidApplicationState,
- category: WireErrorCategory::Lifecycle,
- retryable: false,
- recovery_action: WireRecoveryAction::None,
- correlation_id: None,
- safe_message: "The account removal confirmation is no longer valid.".to_owned(),
- }
-}
-
-fn generated_recovery_expired() -> StudioError {
- StudioError::Failure {
- code: WireErrorCode::InvalidApplicationState,
- category: WireErrorCategory::Lifecycle,
- retryable: false,
- recovery_action: WireRecoveryAction::None,
- correlation_id: None,
- safe_message: "The generated-key recovery step is no longer valid.".to_owned(),
- }
-}
-
-fn generated_commit_failed(error: SafeError) -> StudioError {
- let (category, _, _) = error_policy(error.code());
- StudioError::Failure {
- code: error.code().into(),
- category,
- retryable: false,
- recovery_action: WireRecoveryAction::None,
- correlation_id: None,
- safe_message:
- "The generated account could not be saved. Import the recovery key you saved to try again."
- .to_owned(),
- }
-}
-
-fn compatibility_mismatch() -> StudioError {
- StudioError::Failure {
- code: WireErrorCode::CompatibilityMismatch,
- category: WireErrorCategory::Compatibility,
- retryable: false,
- recovery_action: WireRecoveryAction::UpdateApplication,
- correlation_id: None,
- safe_message: "The application and native runtime are incompatible.".to_owned(),
- }
-}
-
-#[cfg(test)]
-#[cfg_attr(coverage_nightly, coverage(off))]
-mod tests {
- use std::num::NonZeroUsize;
- use std::sync::Arc;
-
- use radroots_studio_application::{InMemorySecretStore, RelayConfiguration};
- use radroots_studio_domain::SafeError;
- use radroots_studio_nostr::SdkNostrClient;
- use radroots_studio_runtime::{
- RuntimeActorHandle, RuntimeDependencies, UuidInstallationIdentitySource,
- };
-
- use radroots_studio_storage::{CREDENTIAL_SERVICE, CURRENT_SCHEMA_VERSION};
-
- use super::{
- ACTOR_MAILBOX_CAPACITY, CompatibilityExpectation, DATABASE_APPLICATION, DATABASE_FILENAME,
- DATABASE_ORGANIZATION, DATABASE_QUALIFIER, FFI_CONTRACT_HASH, FFI_CONTRACT_MAJOR,
- FFI_CONTRACT_MINOR, RequestContextDto, RuntimeCore, StudioAppCore, StudioError,
- SystemClock, WireErrorCategory, WireErrorCode, WireRecoveryAction, actor_mailbox_capacity,
- compatibility_descriptor, confirmation_expired, generated_commit_failed,
- local_first_relay_configuration, path_unavailable, runtime, runtime_unavailable,
- verify_compatibility,
- };
-
- async fn in_memory_core() -> Arc<StudioAppCore> {
- let actor = RuntimeActorHandle::in_memory(
- RelayConfiguration::default(),
- RuntimeDependencies::new(
- Arc::new(InMemorySecretStore::default()),
- Arc::new(SystemClock),
- Arc::new(SdkNostrClient::new(std::time::Duration::from_millis(10))),
- Arc::new(UuidInstallationIdentitySource),
- ),
- NonZeroUsize::new(ACTOR_MAILBOX_CAPACITY).expect("capacity"),
- runtime().expect("runtime").handle(),
- )
- .await
- .expect("in-memory actor");
- Arc::new(StudioAppCore {
- inner: Arc::new(RuntimeCore {
- actor,
- observers: std::sync::Mutex::new(std::collections::BTreeMap::new()),
- closed: std::sync::atomic::AtomicBool::new(false),
- startup_relay_problem: None,
- }),
- })
- }
-
- #[tokio::test]
- async fn exported_bootstrap_and_snapshot_are_revisioned() {
- let core = in_memory_core().await;
- let bootstrapped = core.bootstrap().await.expect("bootstrap");
- let current = core.snapshot();
-
- assert_eq!(bootstrapped, current);
- assert_eq!(current.revision, 1);
- }
-
- #[tokio::test]
- async fn request_context_import_replays_one_committed_receipt() {
- let core = in_memory_core().await;
- let initial = core.snapshot();
- let context = RequestContextDto {
- request_id: "ffi-test-import-1".to_owned(),
- expected_revision: initial.revision,
- deadline_millis: 5_000,
- };
- let secret = b"7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7";
- let first = core
- .import_account_v2(context.clone(), secret.to_vec())
- .await
- .expect("first import");
- let replay = core
- .import_account_v2(context, secret.to_vec())
- .await
- .expect("replayed import");
-
- assert_eq!(first, replay);
- assert_eq!(first.snapshot.accounts.len(), 1);
- assert_eq!(first.request_id, "ffi-test-import-1");
- }
-
- #[tokio::test]
- async fn generated_recovery_handle_is_one_use_and_acknowledgement_gated() {
- let core = in_memory_core().await;
- let initial = core.snapshot();
- let recovery = core
- .begin_generated_account_v2()
- .await
- .expect("begin recovery");
-
- assert_eq!(core.snapshot(), initial);
- let nsec = recovery.take_recovery_nsec().expect("one-use nsec");
- assert!(nsec.starts_with("nsec1"));
- assert!(recovery.take_recovery_nsec().is_err());
- let context = RequestContextDto {
- request_id: "ffi-test-generate-1".to_owned(),
- expected_revision: initial.revision,
- deadline_millis: 5_000,
- };
- let committed = core
- .acknowledge_generated_account_v2(context.clone(), Arc::clone(&recovery))
- .await
- .expect("acknowledge");
- assert_eq!(committed.accounts.len(), 1);
- let repeated = core
- .acknowledge_generated_account_v2(context, recovery)
- .await
- .expect_err("repeated acknowledgement");
- assert!(matches!(
- repeated,
- StudioError::Failure { safe_message, .. }
- if safe_message == "The generated-key recovery step is no longer valid."
- ));
- }
-
- #[tokio::test]
- async fn account_lifecycle_and_one_use_removal_are_exercised_through_the_ffi_boundary() {
- let core = in_memory_core().await;
- let initial = core.bootstrap().await.expect("bootstrap");
- let imported = core
- .import_account_v2(
- RequestContextDto {
- request_id: "ffi-lifecycle-import".to_owned(),
- expected_revision: initial.revision,
- deadline_millis: 5_000,
- },
- b"7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7".to_vec(),
- )
- .await
- .expect("import account");
- let public_key = imported.snapshot.accounts[0].public_key_hex.clone();
-
- let selected = core
- .select_account(public_key.clone())
- .await
- .expect("select account");
- let active = core
- .activate_account(public_key.clone())
- .await
- .expect("activate account");
- assert!(active.revision > selected.revision);
- let signed_out = core.sign_out().await.expect("sign out");
- assert!(signed_out.revision > active.revision);
- let refreshed = core
- .refresh_active_profile()
- .await
- .expect("signed-out refresh is a stable no-op");
- assert_eq!(refreshed.revision, signed_out.revision);
-
- let removal = core
- .request_account_removal(public_key.clone())
- .await
- .expect("request removal");
- assert_eq!(removal.public_key_hex(), public_key);
- assert!(removal.deletes_local_credential());
- assert!(!removal.signs_out());
- assert!(removal.expires_at_seconds() > 0);
- let removed = core
- .confirm_account_removal(
- RequestContextDto {
- request_id: "ffi-lifecycle-remove".to_owned(),
- expected_revision: signed_out.revision,
- deadline_millis: 5_000,
- },
- Arc::clone(&removal),
- )
- .await
- .expect("confirm removal");
- assert!(removed.accounts.is_empty());
- assert!(
- core.confirm_account_removal(
- RequestContextDto {
- request_id: "ffi-lifecycle-remove-repeated".to_owned(),
- expected_revision: removed.revision,
- deadline_millis: 5_000,
- },
- removal,
- )
- .await
- .is_err()
- );
- assert!(
- core.select_account("not-a-public-key".to_owned())
- .await
- .is_err()
- );
- }
-
- #[tokio::test]
- async fn generated_recovery_cancellation_and_request_validation_fail_closed() {
- let core = in_memory_core().await;
- let recovery = core
- .begin_generated_account_v2()
- .await
- .expect("begin generated account");
- assert_eq!(recovery.account().public_key_hex.len(), 64);
- assert!(recovery.expires_at_seconds() > 0);
- assert!(
- core.cancel_generated_account_v2(Arc::clone(&recovery))
- .await
- .expect("first cancellation")
- );
- assert!(
- !core
- .cancel_generated_account_v2(recovery)
- .await
- .expect("second cancellation")
- );
-
- for context in [
- RequestContextDto {
- request_id: String::new(),
- expected_revision: 0,
- deadline_millis: 5_000,
- },
- RequestContextDto {
- request_id: "ffi-zero-deadline".to_owned(),
- expected_revision: 0,
- deadline_millis: 0,
- },
- RequestContextDto {
- request_id: "ffi-long-deadline".to_owned(),
- expected_revision: 0,
- deadline_millis: 30_001,
- },
- ] {
- assert!(core.import_account_v2(context, vec![0; 32]).await.is_err());
- }
- assert!(
- core.import_account_v2(
- RequestContextDto {
- request_id: "ffi-invalid-secret".to_owned(),
- expected_revision: 0,
- deadline_millis: 5_000,
- },
- vec![0; 31],
- )
- .await
- .is_err()
- );
- }
-
- #[test]
- fn boundary_failures_remain_typed_and_secret_safe() {
- assert_eq!(actor_mailbox_capacity().expect("capacity").get(), 64);
- for (error, code, category, retryable, recovery, message) in [
- (
- runtime_unavailable(),
- WireErrorCode::InvalidApplicationState,
- WireErrorCategory::Lifecycle,
- true,
- WireRecoveryAction::RestartApplication,
- "The application runtime is unavailable.",
- ),
- (
- path_unavailable(),
- WireErrorCode::StorageUnavailable,
- WireErrorCategory::Storage,
- true,
- WireRecoveryAction::RestartApplication,
- "The application data directory is unavailable.",
- ),
- (
- confirmation_expired(),
- WireErrorCode::InvalidApplicationState,
- WireErrorCategory::Lifecycle,
- false,
- WireRecoveryAction::None,
- "The account removal confirmation is no longer valid.",
- ),
- (
- generated_commit_failed(SafeError::new(
- radroots_studio_domain::SafeErrorCode::StorageUnavailable,
- radroots_studio_domain::SafeMessage::new("internal detail"),
- )),
- WireErrorCode::StorageUnavailable,
- WireErrorCategory::Storage,
- false,
- WireRecoveryAction::None,
- "The generated account could not be saved. Import the recovery key you saved to try again.",
- ),
- ] {
- assert_eq!(error.to_string(), message);
- assert!(matches!(
- error,
- StudioError::Failure {
- code: actual_code,
- category: actual_category,
- retryable: actual_retryable,
- recovery_action: actual_recovery,
- correlation_id: None,
- safe_message,
- } if actual_code == code
- && actual_category == category
- && actual_retryable == retryable
- && actual_recovery == recovery
- && safe_message == message
- ));
- }
- }
-
- #[test]
- fn compatibility_matrix_rejects_before_storage_mutation() {
- let actual = compatibility_descriptor();
- let compatible = CompatibilityExpectation {
- contract_major: FFI_CONTRACT_MAJOR,
- minimum_contract_minor: FFI_CONTRACT_MINOR,
- contract_hash: FFI_CONTRACT_HASH.to_owned(),
- minimum_schema_version: 5,
- maximum_schema_version: CURRENT_SCHEMA_VERSION,
- };
- verify_compatibility(&compatible).expect("compatible");
-
- for incompatible in [
- CompatibilityExpectation {
- contract_major: FFI_CONTRACT_MAJOR + 1,
- ..compatible.clone()
- },
- CompatibilityExpectation {
- minimum_contract_minor: FFI_CONTRACT_MINOR + 1,
- ..compatible.clone()
- },
- CompatibilityExpectation {
- contract_hash: "wrong-contract".to_owned(),
- ..compatible.clone()
- },
- CompatibilityExpectation {
- minimum_schema_version: actual.current_schema_version + 1,
- ..compatible.clone()
- },
- CompatibilityExpectation {
- maximum_schema_version: actual.minimum_schema_version - 1,
- ..compatible.clone()
- },
- ] {
- assert!(verify_compatibility(&incompatible).is_err());
- }
-
- let directory = tempfile::tempdir().expect("directory");
- let rejected = directory.path().join("rejected").join("studio.sqlite3");
- let incompatible = CompatibilityExpectation {
- contract_major: FFI_CONTRACT_MAJOR + 1,
- ..compatible
- };
- assert!(StudioAppCore::open_path_compatible(&rejected, &incompatible, true).is_err());
- assert!(!rejected.parent().expect("parent").exists());
- }
-
- #[test]
- fn v5_compatibility_fixture_preserves_external_coordinates() {
- let fixture =
- include_str!("../../../contracts/compatibility/studio/v5-baseline.properties");
- let property = |key: &str| {
- fixture.lines().find_map(|line| {
- line.split_once('=')
- .filter(|(candidate, _)| *candidate == key)
- .map(|(_, value)| value)
- })
- };
-
- assert_eq!(property("baseline.id"), Some("studio-runtime-v5"));
- assert_eq!(property("schema.version"), Some("5"));
- assert_eq!(CURRENT_SCHEMA_VERSION, 10);
- assert_eq!(property("ffi.contract"), Some("legacy-unversioned-v1"));
- assert_eq!(property("ffi.snapshot.schema"), Some("1"));
- assert_eq!(property("ffi.runtime.version"), Some("0.1.0-alpha"));
- assert_eq!(property("database.qualifier"), Some(DATABASE_QUALIFIER));
- assert_eq!(
- property("database.organization"),
- Some(DATABASE_ORGANIZATION)
- );
- assert_eq!(property("database.application"), Some(DATABASE_APPLICATION));
- assert_eq!(property("database.filename"), Some(DATABASE_FILENAME));
- assert_eq!(property("keyring.service"), Some(CREDENTIAL_SERVICE));
- assert_eq!(
- property("keyring.account"),
- Some("canonical-lowercase-public-key-hex")
- );
- }
-
- #[test]
- fn superseded_v1_ffi_commands_are_absent() {
- let commands = include_str!("commands.rs");
- let observer = include_str!("observer.rs");
- for forbidden in [
- format!("pub async fn {}_account(", "generate"),
- format!("pub async fn {}_secret_key(", "import"),
- format!("pub fn {}(development_mode", "open"),
- format!("pub async fn {}(", "subscribe"),
- format!("pub fn {}(&self)", "shutdown"),
- ] {
- assert!(!commands.contains(&forbidden));
- assert!(!observer.contains(&forbidden));
- }
- }
-
- #[test]
- fn invalid_relay_configuration_preserves_local_startup_as_degraded() {
- let problem = SafeError::new(
- radroots_studio_domain::SafeErrorCode::InvalidRelayConfiguration,
- radroots_studio_domain::SafeMessage::new("The Nostr relay configuration is invalid."),
- );
- let (relays, degraded) = local_first_relay_configuration(Err(problem));
-
- assert!(relays.relays().is_empty());
- assert_eq!(degraded, Some(problem));
- }
-}
diff --git a/crates/studio_ffi/src/contract.rs b/crates/studio_ffi/src/contract.rs
@@ -1,9 +0,0 @@
-pub const PRODUCT_VERSION: &str = "0.1.0-alpha";
-pub const FFI_CONTRACT_MAJOR: u16 = 3;
-pub const FFI_CONTRACT_MINOR: u16 = 0;
-pub const MINIMUM_SCHEMA_VERSION: u32 = 5;
-pub const FFI_CONTRACT_HASH: &str = env!("RADROOTS_STUDIO_FFI_CONTRACT_DIGEST");
-
-#[cfg(test)]
-pub(crate) const NORMALIZED_CONTRACT_METADATA: &str =
- include_str!(concat!(env!("OUT_DIR"), "/ffi_contract_metadata.txt"));
diff --git a/crates/studio_ffi/src/dto.rs b/crates/studio_ffi/src/dto.rs
@@ -1,729 +0,0 @@
-use radroots_studio_application::{
- ActiveAccountSnapshot, AppLifecycle, AppSnapshot, ProfileLoadState, RelayConnectionState,
- RuntimeLifecycle, SessionState,
-};
-use radroots_studio_domain::{
- AccountSummary, BindingAvailability, ProfileMetadata, SafeError, SafeErrorCode,
-};
-
-#[derive(Clone, Copy, Debug, Eq, PartialEq)]
-#[cfg_attr(not(coverage_nightly), derive(uniffi::Enum))]
-pub enum WireErrorCode {
- InvalidPublicKey,
- InvalidSecretKey,
- InvalidAccountMetadata,
- InvalidProfileMetadata,
- InvalidApplicationState,
- AccountAlreadyExists,
- AccountNotFound,
- KeyringUnavailable,
- CredentialMissing,
- StorageUnavailable,
- StorageCorrupt,
- StorageQuarantined,
- StorageBackupInvalid,
- UnsupportedSchemaVersion,
- RepairUnauthorized,
- PendingOperationRecoveryRequired,
- InvalidRelayConfiguration,
- RelayConnectionFailed,
- ProfileRefreshFailed,
- ObserverRegistrationFailed,
- NativeLibraryLoadFailed,
- CompatibilityMismatch,
- Internal,
-}
-
-#[derive(Clone, Copy, Debug, Eq, PartialEq)]
-#[cfg_attr(not(coverage_nightly), derive(uniffi::Enum))]
-pub enum WireErrorCategory {
- Input,
- Conflict,
- Credential,
- Storage,
- Network,
- Lifecycle,
- Compatibility,
- Internal,
-}
-
-#[derive(Clone, Copy, Debug, Eq, PartialEq)]
-#[cfg_attr(not(coverage_nightly), derive(uniffi::Enum))]
-pub enum WireRecoveryAction {
- None,
- Retry,
- RepairCredential,
- Authenticate,
- RepairStorage,
- RestoreBackup,
- CheckConfiguration,
- RestartApplication,
- UpdateApplication,
-}
-
-#[derive(Clone, Debug, Eq, PartialEq)]
-#[cfg_attr(not(coverage_nightly), derive(uniffi::Record))]
-pub struct SafeErrorDto {
- pub code: WireErrorCode,
- pub category: WireErrorCategory,
- pub retryable: bool,
- pub recovery_action: WireRecoveryAction,
- pub message: String,
-}
-
-#[derive(Clone, Copy, Debug, Eq, PartialEq)]
-#[cfg_attr(not(coverage_nightly), derive(uniffi::Enum))]
-pub enum AppLifecycleDto {
- Opening,
- CompatibilityChecking,
- AcquiringOwnership,
- Migrating,
- Recovering,
- Ready,
- Degraded,
- Blocked,
- ShuttingDown,
- Closed,
- Fatal,
-}
-
-#[derive(Clone, Copy, Debug, Eq, PartialEq)]
-#[cfg_attr(not(coverage_nightly), derive(uniffi::Enum))]
-pub enum SessionStateDto {
- SignedOut,
- Activating,
- Active,
- SigningOut,
- Failed,
-}
-
-#[derive(Clone, Copy, Debug, Eq, PartialEq)]
-#[cfg_attr(not(coverage_nightly), derive(uniffi::Enum))]
-pub enum RelayConnectionStateDto {
- Disconnected,
- Connecting,
- Connected,
- Degraded,
- Error,
-}
-
-#[derive(Clone, Copy, Debug, Eq, PartialEq)]
-#[cfg_attr(not(coverage_nightly), derive(uniffi::Enum))]
-pub enum ProfileLoadStateDto {
- Empty,
- Loading,
- Cached,
- Fresh,
- Error,
-}
-
-#[derive(Clone, Copy, Debug, Eq, PartialEq)]
-#[cfg_attr(not(coverage_nightly), derive(uniffi::Enum))]
-pub enum SignerKindDto {
- LocalSecret,
- WatchOnly,
- RemoteNip46,
-}
-
-#[derive(Clone, Copy, Debug, Eq, PartialEq)]
-#[cfg_attr(not(coverage_nightly), derive(uniffi::Enum))]
-pub enum KeyAvailabilityDto {
- Available,
- CredentialMissing,
- StoreUnavailable,
- NotRequired,
-}
-
-#[derive(Clone, Debug, Eq, PartialEq)]
-#[cfg_attr(not(coverage_nightly), derive(uniffi::Record))]
-pub struct ProfileDto {
- pub name: Option<String>,
- pub display_name: Option<String>,
- pub nip05: Option<String>,
- pub about: Option<String>,
- pub picture: Option<String>,
-}
-
-#[derive(Clone, Debug, Eq, PartialEq)]
-#[cfg_attr(not(coverage_nightly), derive(uniffi::Record))]
-pub struct AccountDto {
- pub public_key_hex: String,
- pub npub: String,
- pub display_label: String,
- pub signer_kind: SignerKindDto,
- pub key_availability: KeyAvailabilityDto,
- pub created_at_seconds: i64,
- pub last_used_at_seconds: Option<i64>,
-}
-
-#[derive(Clone, Debug, Eq, PartialEq)]
-#[cfg_attr(not(coverage_nightly), derive(uniffi::Record))]
-pub struct ActiveAccountDto {
- pub account: AccountDto,
- pub relay_state: RelayConnectionStateDto,
- pub profile_state: ProfileLoadStateDto,
- pub profile: Option<ProfileDto>,
-}
-
-#[derive(Clone, Debug, Eq, PartialEq)]
-#[cfg_attr(not(coverage_nightly), derive(uniffi::Record))]
-pub struct AppSnapshotDto {
- pub revision: u64,
- pub lifecycle: AppLifecycleDto,
- pub lifecycle_error: Option<SafeErrorDto>,
- pub configured_relays: Vec<String>,
- pub accounts: Vec<AccountDto>,
- pub selected_public_key_hex: Option<String>,
- pub session: SessionStateDto,
- pub session_subject_public_key_hex: Option<String>,
- pub session_error: Option<SafeErrorDto>,
- pub active_account: Option<ActiveAccountDto>,
- pub recoverable_problem: Option<SafeErrorDto>,
-}
-
-impl From<&AppSnapshot> for AppSnapshotDto {
- fn from(snapshot: &AppSnapshot) -> Self {
- let (lifecycle, lifecycle_error) = match snapshot.lifecycle() {
- AppLifecycle::Booting => (AppLifecycleDto::Opening, None),
- AppLifecycle::Ready => (AppLifecycleDto::Ready, None),
- AppLifecycle::Fatal(error) => (AppLifecycleDto::Fatal, Some(error.into())),
- };
- let (session, session_subject_public_key_hex, session_error) = match snapshot.session() {
- SessionState::SignedOut => (SessionStateDto::SignedOut, None, None),
- SessionState::Activating(public_key) => {
- (SessionStateDto::Activating, Some(public_key.to_hex()), None)
- }
- SessionState::Active => (SessionStateDto::Active, None, None),
- SessionState::SigningOut => (SessionStateDto::SigningOut, None, None),
- SessionState::Failed(error) => (SessionStateDto::Failed, None, Some(error.into())),
- };
- Self {
- revision: snapshot.revision().value(),
- lifecycle,
- lifecycle_error,
- configured_relays: snapshot
- .relay_configuration()
- .relays()
- .iter()
- .map(|relay| relay.as_str().to_owned())
- .collect(),
- accounts: snapshot.accounts().iter().map(AccountDto::from).collect(),
- selected_public_key_hex: snapshot
- .selected_account()
- .map(radroots_studio_domain::PublicKey::to_hex),
- session,
- session_subject_public_key_hex,
- session_error,
- active_account: snapshot.active_account().map(ActiveAccountDto::from),
- recoverable_problem: snapshot.recoverable_problem().map(SafeErrorDto::from),
- }
- }
-}
-
-impl AppSnapshotDto {
- pub(crate) fn from_runtime(snapshot: &AppSnapshot, runtime: RuntimeLifecycle) -> Self {
- let mut dto = Self::from(snapshot);
- let (lifecycle, problem) = match runtime {
- RuntimeLifecycle::Opening => (AppLifecycleDto::Opening, None),
- RuntimeLifecycle::CompatibilityChecking => {
- (AppLifecycleDto::CompatibilityChecking, None)
- }
- RuntimeLifecycle::AcquiringOwnership => (AppLifecycleDto::AcquiringOwnership, None),
- RuntimeLifecycle::Migrating => (AppLifecycleDto::Migrating, None),
- RuntimeLifecycle::Recovering => (AppLifecycleDto::Recovering, None),
- RuntimeLifecycle::Ready => (AppLifecycleDto::Ready, None),
- RuntimeLifecycle::Degraded(error) => {
- (AppLifecycleDto::Degraded, Some(SafeErrorDto::from(error)))
- }
- RuntimeLifecycle::Blocked(error) => {
- (AppLifecycleDto::Blocked, Some(SafeErrorDto::from(error)))
- }
- RuntimeLifecycle::ShuttingDown => (AppLifecycleDto::ShuttingDown, None),
- RuntimeLifecycle::Closed => (AppLifecycleDto::Closed, None),
- RuntimeLifecycle::Fatal(error) => {
- (AppLifecycleDto::Fatal, Some(SafeErrorDto::from(error)))
- }
- };
- dto.lifecycle = lifecycle;
- dto.lifecycle_error = problem;
- dto
- }
-}
-
-impl From<&AccountSummary> for AccountDto {
- fn from(account: &AccountSummary) -> Self {
- Self {
- public_key_hex: account.public_key().to_hex(),
- npub: account.npub().as_str().to_owned(),
- display_label: account.display_label(),
- signer_kind: SignerKindDto::LocalSecret,
- key_availability: account.signer().availability().into(),
- created_at_seconds: account.created_at().timestamp().as_seconds(),
- last_used_at_seconds: account
- .last_used_at()
- .map(radroots_studio_domain::UnixTimestamp::as_seconds),
- }
- }
-}
-
-impl From<&ActiveAccountSnapshot> for ActiveAccountDto {
- fn from(active: &ActiveAccountSnapshot) -> Self {
- Self {
- account: active.account().into(),
- relay_state: active.relay_state().into(),
- profile_state: active.profile_state().into(),
- profile: active.profile().map(ProfileDto::from),
- }
- }
-}
-
-impl From<&ProfileMetadata> for ProfileDto {
- fn from(profile: &ProfileMetadata) -> Self {
- Self {
- name: profile.name().map(str::to_owned),
- display_name: profile.display_name().map(str::to_owned),
- nip05: profile.nip05().map(str::to_owned),
- about: profile.about().map(str::to_owned),
- picture: profile.picture().map(str::to_owned),
- }
- }
-}
-
-impl From<SafeError> for SafeErrorDto {
- fn from(error: SafeError) -> Self {
- let (category, retryable, recovery_action) = error_policy(error.code());
- Self {
- code: error.code().into(),
- category,
- retryable,
- recovery_action,
- message: error.message().as_str().to_owned(),
- }
- }
-}
-
-impl From<SafeErrorCode> for WireErrorCode {
- fn from(code: SafeErrorCode) -> Self {
- match code {
- SafeErrorCode::InvalidPublicKey => Self::InvalidPublicKey,
- SafeErrorCode::InvalidSecretKey => Self::InvalidSecretKey,
- SafeErrorCode::InvalidAccountMetadata => Self::InvalidAccountMetadata,
- SafeErrorCode::InvalidProfileMetadata => Self::InvalidProfileMetadata,
- SafeErrorCode::InvalidApplicationState => Self::InvalidApplicationState,
- SafeErrorCode::AccountAlreadyExists => Self::AccountAlreadyExists,
- SafeErrorCode::AccountNotFound => Self::AccountNotFound,
- SafeErrorCode::KeyringUnavailable => Self::KeyringUnavailable,
- SafeErrorCode::CredentialMissing => Self::CredentialMissing,
- SafeErrorCode::StorageUnavailable => Self::StorageUnavailable,
- SafeErrorCode::StorageCorrupt => Self::StorageCorrupt,
- SafeErrorCode::StorageQuarantined => Self::StorageQuarantined,
- SafeErrorCode::StorageBackupInvalid => Self::StorageBackupInvalid,
- SafeErrorCode::UnsupportedSchemaVersion => Self::UnsupportedSchemaVersion,
- SafeErrorCode::RepairUnauthorized => Self::RepairUnauthorized,
- SafeErrorCode::PendingOperationRecoveryRequired => {
- Self::PendingOperationRecoveryRequired
- }
- SafeErrorCode::InvalidRelayConfiguration => Self::InvalidRelayConfiguration,
- SafeErrorCode::RelayConnectionFailed => Self::RelayConnectionFailed,
- SafeErrorCode::ProfileRefreshFailed => Self::ProfileRefreshFailed,
- SafeErrorCode::ObserverRegistrationFailed => Self::ObserverRegistrationFailed,
- SafeErrorCode::NativeLibraryLoadFailed => Self::NativeLibraryLoadFailed,
- }
- }
-}
-
-pub(crate) const fn error_policy(
- code: SafeErrorCode,
-) -> (WireErrorCategory, bool, WireRecoveryAction) {
- match code {
- SafeErrorCode::InvalidPublicKey
- | SafeErrorCode::InvalidSecretKey
- | SafeErrorCode::InvalidAccountMetadata
- | SafeErrorCode::InvalidProfileMetadata => {
- (WireErrorCategory::Input, false, WireRecoveryAction::None)
- }
- SafeErrorCode::AccountAlreadyExists | SafeErrorCode::AccountNotFound => {
- (WireErrorCategory::Conflict, false, WireRecoveryAction::None)
- }
- SafeErrorCode::KeyringUnavailable => (
- WireErrorCategory::Credential,
- true,
- WireRecoveryAction::Retry,
- ),
- SafeErrorCode::CredentialMissing => (
- WireErrorCategory::Credential,
- false,
- WireRecoveryAction::RepairCredential,
- ),
- SafeErrorCode::StorageUnavailable => (
- WireErrorCategory::Storage,
- true,
- WireRecoveryAction::RestartApplication,
- ),
- SafeErrorCode::StorageCorrupt | SafeErrorCode::PendingOperationRecoveryRequired => (
- WireErrorCategory::Storage,
- false,
- WireRecoveryAction::RestartApplication,
- ),
- SafeErrorCode::StorageQuarantined => (
- WireErrorCategory::Storage,
- false,
- WireRecoveryAction::RepairStorage,
- ),
- SafeErrorCode::StorageBackupInvalid => (
- WireErrorCategory::Storage,
- false,
- WireRecoveryAction::RestoreBackup,
- ),
- SafeErrorCode::UnsupportedSchemaVersion => (
- WireErrorCategory::Compatibility,
- false,
- WireRecoveryAction::UpdateApplication,
- ),
- SafeErrorCode::RepairUnauthorized => (
- WireErrorCategory::Credential,
- false,
- WireRecoveryAction::Authenticate,
- ),
- SafeErrorCode::InvalidRelayConfiguration => (
- WireErrorCategory::Network,
- false,
- WireRecoveryAction::CheckConfiguration,
- ),
- SafeErrorCode::RelayConnectionFailed | SafeErrorCode::ProfileRefreshFailed => {
- (WireErrorCategory::Network, true, WireRecoveryAction::Retry)
- }
- SafeErrorCode::InvalidApplicationState | SafeErrorCode::ObserverRegistrationFailed => (
- WireErrorCategory::Lifecycle,
- true,
- WireRecoveryAction::Retry,
- ),
- SafeErrorCode::NativeLibraryLoadFailed => (
- WireErrorCategory::Internal,
- false,
- WireRecoveryAction::RestartApplication,
- ),
- }
-}
-
-impl From<BindingAvailability> for KeyAvailabilityDto {
- fn from(value: BindingAvailability) -> Self {
- match value {
- BindingAvailability::Available => Self::Available,
- BindingAvailability::CredentialMissing => Self::CredentialMissing,
- BindingAvailability::StoreUnavailable => Self::StoreUnavailable,
- }
- }
-}
-
-impl From<RelayConnectionState> for RelayConnectionStateDto {
- fn from(value: RelayConnectionState) -> Self {
- match value {
- RelayConnectionState::Disconnected => Self::Disconnected,
- RelayConnectionState::Connecting => Self::Connecting,
- RelayConnectionState::Connected => Self::Connected,
- RelayConnectionState::Degraded => Self::Degraded,
- RelayConnectionState::Error(_) => Self::Error,
- }
- }
-}
-
-impl From<ProfileLoadState> for ProfileLoadStateDto {
- fn from(value: ProfileLoadState) -> Self {
- match value {
- ProfileLoadState::Empty => Self::Empty,
- ProfileLoadState::Loading => Self::Loading,
- ProfileLoadState::Cached => Self::Cached,
- ProfileLoadState::Fresh => Self::Fresh,
- ProfileLoadState::Error(_) => Self::Error,
- }
- }
-}
-
-#[cfg(test)]
-#[cfg_attr(coverage_nightly, coverage(off))]
-mod tests {
- use std::sync::Arc;
-
- use radroots_studio_application::{
- AppCore, ProfileLoadState, RelayConfiguration, RelayConnectionState, RuntimeLifecycle,
- };
- use radroots_studio_nostr::NostrKeyMaterialProvider;
-
- use radroots_studio_domain::{BindingAvailability, SafeError, SafeErrorCode, SafeMessage};
-
- use super::{
- AppLifecycleDto, AppSnapshotDto, KeyAvailabilityDto, ProfileLoadStateDto,
- RelayConnectionStateDto, SafeErrorDto, WireErrorCategory, WireErrorCode,
- WireRecoveryAction, error_policy,
- };
-
- fn safe_error(code: SafeErrorCode) -> SafeError {
- SafeError::new(code, SafeMessage::new("Safe compatibility failure."))
- }
-
- #[test]
- fn snapshot_dto_is_revisioned_public_and_secret_free() {
- let core = AppCore::new(
- RelayConfiguration::default(),
- Arc::new(NostrKeyMaterialProvider),
- );
- let snapshot = core.bootstrap().expect("bootstrap");
- let dto = AppSnapshotDto::from(&snapshot);
- let debug = format!("{dto:?}");
-
- assert_eq!(dto.revision, 1);
- assert!(dto.accounts.is_empty());
- assert!(!debug.contains("nsec"));
- assert!(!debug.contains("secret_key"));
- assert!(!debug.contains("server_url"));
- }
-
- #[test]
- fn security_errors_have_explicit_stable_wire_mappings() {
- for (code, expected_code, expected_recovery) in [
- (
- SafeErrorCode::StorageQuarantined,
- WireErrorCode::StorageQuarantined,
- WireRecoveryAction::RepairStorage,
- ),
- (
- SafeErrorCode::StorageBackupInvalid,
- WireErrorCode::StorageBackupInvalid,
- WireRecoveryAction::RestoreBackup,
- ),
- (
- SafeErrorCode::UnsupportedSchemaVersion,
- WireErrorCode::UnsupportedSchemaVersion,
- WireRecoveryAction::UpdateApplication,
- ),
- (
- SafeErrorCode::RepairUnauthorized,
- WireErrorCode::RepairUnauthorized,
- WireRecoveryAction::Authenticate,
- ),
- ] {
- let dto = SafeErrorDto::from(radroots_studio_domain::SafeError::new(
- code,
- SafeMessage::new("Safe compatibility failure."),
- ));
- assert_eq!(dto.code, expected_code);
- assert_eq!(dto.recovery_action, expected_recovery);
- assert!(!dto.retryable);
- }
- }
-
- #[test]
- fn every_safe_error_has_an_explicit_wire_code_and_policy() {
- let cases = [
- (
- SafeErrorCode::InvalidPublicKey,
- WireErrorCode::InvalidPublicKey,
- ),
- (
- SafeErrorCode::InvalidSecretKey,
- WireErrorCode::InvalidSecretKey,
- ),
- (
- SafeErrorCode::InvalidAccountMetadata,
- WireErrorCode::InvalidAccountMetadata,
- ),
- (
- SafeErrorCode::InvalidProfileMetadata,
- WireErrorCode::InvalidProfileMetadata,
- ),
- (
- SafeErrorCode::InvalidApplicationState,
- WireErrorCode::InvalidApplicationState,
- ),
- (
- SafeErrorCode::AccountAlreadyExists,
- WireErrorCode::AccountAlreadyExists,
- ),
- (
- SafeErrorCode::AccountNotFound,
- WireErrorCode::AccountNotFound,
- ),
- (
- SafeErrorCode::KeyringUnavailable,
- WireErrorCode::KeyringUnavailable,
- ),
- (
- SafeErrorCode::CredentialMissing,
- WireErrorCode::CredentialMissing,
- ),
- (
- SafeErrorCode::StorageUnavailable,
- WireErrorCode::StorageUnavailable,
- ),
- (SafeErrorCode::StorageCorrupt, WireErrorCode::StorageCorrupt),
- (
- SafeErrorCode::StorageQuarantined,
- WireErrorCode::StorageQuarantined,
- ),
- (
- SafeErrorCode::StorageBackupInvalid,
- WireErrorCode::StorageBackupInvalid,
- ),
- (
- SafeErrorCode::UnsupportedSchemaVersion,
- WireErrorCode::UnsupportedSchemaVersion,
- ),
- (
- SafeErrorCode::RepairUnauthorized,
- WireErrorCode::RepairUnauthorized,
- ),
- (
- SafeErrorCode::PendingOperationRecoveryRequired,
- WireErrorCode::PendingOperationRecoveryRequired,
- ),
- (
- SafeErrorCode::InvalidRelayConfiguration,
- WireErrorCode::InvalidRelayConfiguration,
- ),
- (
- SafeErrorCode::RelayConnectionFailed,
- WireErrorCode::RelayConnectionFailed,
- ),
- (
- SafeErrorCode::ProfileRefreshFailed,
- WireErrorCode::ProfileRefreshFailed,
- ),
- (
- SafeErrorCode::ObserverRegistrationFailed,
- WireErrorCode::ObserverRegistrationFailed,
- ),
- (
- SafeErrorCode::NativeLibraryLoadFailed,
- WireErrorCode::NativeLibraryLoadFailed,
- ),
- ];
- for (code, expected_wire_code) in cases {
- let dto = SafeErrorDto::from(safe_error(code));
- assert_eq!(dto.code, expected_wire_code);
- assert_eq!(
- (dto.category, dto.retryable, dto.recovery_action),
- error_policy(code)
- );
- }
- assert_eq!(
- error_policy(SafeErrorCode::KeyringUnavailable),
- (
- WireErrorCategory::Credential,
- true,
- WireRecoveryAction::Retry,
- )
- );
- assert_eq!(
- error_policy(SafeErrorCode::NativeLibraryLoadFailed),
- (
- WireErrorCategory::Internal,
- false,
- WireRecoveryAction::RestartApplication,
- )
- );
- }
-
- #[test]
- fn runtime_and_connection_states_map_exhaustively_to_wire_states() {
- let core = AppCore::new(
- RelayConfiguration::default(),
- Arc::new(NostrKeyMaterialProvider),
- );
- let snapshot = core.bootstrap().expect("bootstrap");
- for (runtime, expected) in [
- (RuntimeLifecycle::Opening, AppLifecycleDto::Opening),
- (
- RuntimeLifecycle::CompatibilityChecking,
- AppLifecycleDto::CompatibilityChecking,
- ),
- (
- RuntimeLifecycle::AcquiringOwnership,
- AppLifecycleDto::AcquiringOwnership,
- ),
- (RuntimeLifecycle::Migrating, AppLifecycleDto::Migrating),
- (RuntimeLifecycle::Recovering, AppLifecycleDto::Recovering),
- (RuntimeLifecycle::Ready, AppLifecycleDto::Ready),
- (
- RuntimeLifecycle::Degraded(safe_error(SafeErrorCode::RelayConnectionFailed)),
- AppLifecycleDto::Degraded,
- ),
- (
- RuntimeLifecycle::Blocked(safe_error(SafeErrorCode::StorageUnavailable)),
- AppLifecycleDto::Blocked,
- ),
- (
- RuntimeLifecycle::ShuttingDown,
- AppLifecycleDto::ShuttingDown,
- ),
- (RuntimeLifecycle::Closed, AppLifecycleDto::Closed),
- (
- RuntimeLifecycle::Fatal(safe_error(SafeErrorCode::StorageCorrupt)),
- AppLifecycleDto::Fatal,
- ),
- ] {
- let dto = AppSnapshotDto::from_runtime(&snapshot, runtime);
- assert_eq!(dto.lifecycle, expected);
- assert_eq!(
- dto.lifecycle_error.is_some(),
- matches!(
- expected,
- AppLifecycleDto::Degraded | AppLifecycleDto::Blocked | AppLifecycleDto::Fatal
- )
- );
- }
-
- for (source, expected) in [
- (
- BindingAvailability::Available,
- KeyAvailabilityDto::Available,
- ),
- (
- BindingAvailability::CredentialMissing,
- KeyAvailabilityDto::CredentialMissing,
- ),
- (
- BindingAvailability::StoreUnavailable,
- KeyAvailabilityDto::StoreUnavailable,
- ),
- ] {
- assert_eq!(KeyAvailabilityDto::from(source), expected);
- }
- for (source, expected) in [
- (
- RelayConnectionState::Disconnected,
- RelayConnectionStateDto::Disconnected,
- ),
- (
- RelayConnectionState::Connecting,
- RelayConnectionStateDto::Connecting,
- ),
- (
- RelayConnectionState::Connected,
- RelayConnectionStateDto::Connected,
- ),
- (
- RelayConnectionState::Degraded,
- RelayConnectionStateDto::Degraded,
- ),
- (
- RelayConnectionState::Error(safe_error(SafeErrorCode::RelayConnectionFailed)),
- RelayConnectionStateDto::Error,
- ),
- ] {
- assert_eq!(RelayConnectionStateDto::from(source), expected);
- }
- for (source, expected) in [
- (ProfileLoadState::Empty, ProfileLoadStateDto::Empty),
- (ProfileLoadState::Loading, ProfileLoadStateDto::Loading),
- (ProfileLoadState::Cached, ProfileLoadStateDto::Cached),
- (ProfileLoadState::Fresh, ProfileLoadStateDto::Fresh),
- (
- ProfileLoadState::Error(safe_error(SafeErrorCode::ProfileRefreshFailed)),
- ProfileLoadStateDto::Error,
- ),
- ] {
- assert_eq!(ProfileLoadStateDto::from(source), expected);
- }
- }
-}
diff --git a/crates/studio_ffi/src/lib.rs b/crates/studio_ffi/src/lib.rs
@@ -1,46 +0,0 @@
-#![doc = "Radroots Studio `UniFFI` boundary."]
-#![cfg_attr(coverage_nightly, feature(coverage_attribute))]
-
-mod commands;
-mod contract;
-mod dto;
-mod observer;
-
-pub use commands::{
- AccountCommandReceiptDto, GeneratedRecoveryRequest, RemovalRequest, RequestContextDto,
- StudioAppCore, StudioError,
-};
-pub use contract::{
- FFI_CONTRACT_HASH, FFI_CONTRACT_MAJOR, FFI_CONTRACT_MINOR, MINIMUM_SCHEMA_VERSION,
- PRODUCT_VERSION,
-};
-pub use dto::{
- AccountDto, ActiveAccountDto, AppLifecycleDto, AppSnapshotDto, KeyAvailabilityDto, ProfileDto,
- ProfileLoadStateDto, RelayConnectionStateDto, SafeErrorDto, SessionStateDto, SignerKindDto,
- WireErrorCategory, WireErrorCode, WireRecoveryAction,
-};
-pub use observer::{
- ObserverSubscription, ShutdownReceiptDto, SnapshotChangeDto, StudioChangeObserver,
-};
-
-uniffi::setup_scaffolding!();
-
-#[cfg_attr(not(coverage_nightly), uniffi::export)]
-#[must_use]
-pub fn native_runtime_version() -> String {
- PRODUCT_VERSION.to_owned()
-}
-
-#[cfg(test)]
-#[cfg_attr(coverage_nightly, coverage(off))]
-mod tests {
- #[test]
- fn native_runtime_reports_the_product_version_independently() {
- assert_eq!(super::native_runtime_version(), "0.1.0-alpha");
- assert_eq!(super::PRODUCT_VERSION, "0.1.0-alpha");
- assert_eq!(env!("CARGO_PKG_VERSION"), "0.1.0-alpha");
- assert_eq!(super::FFI_CONTRACT_MAJOR, 3);
- assert_eq!(super::FFI_CONTRACT_HASH.len(), 64);
- assert!(!super::contract::NORMALIZED_CONTRACT_METADATA.is_empty());
- }
-}
diff --git a/crates/studio_ffi/src/observer.rs b/crates/studio_ffi/src/observer.rs
@@ -1,512 +0,0 @@
-use std::num::NonZeroUsize;
-use std::panic::{AssertUnwindSafe, catch_unwind};
-use std::sync::atomic::Ordering;
-use std::sync::{Arc, Mutex, Weak};
-
-use radroots_studio_application::ChangeSubscriptionId;
-
-use crate::commands::RuntimeCore;
-use crate::{AppSnapshotDto, StudioAppCore, StudioError};
-
-const OBSERVER_CHANGE_CAPACITY: NonZeroUsize = NonZeroUsize::MIN.saturating_add(63);
-const MAX_OBSERVERS: usize = 32;
-
-#[derive(Clone, Debug, Eq, PartialEq)]
-#[cfg_attr(not(coverage_nightly), derive(uniffi::Record))]
-pub struct SnapshotChangeDto {
- pub snapshot: AppSnapshotDto,
- pub previous_revision: Option<u64>,
-}
-
-#[derive(Clone, Copy, Debug, Eq, PartialEq)]
-#[cfg_attr(not(coverage_nightly), derive(uniffi::Record))]
-pub struct ShutdownReceiptDto {
- pub final_revision: u64,
- pub closed: bool,
-}
-
-#[cfg_attr(not(coverage_nightly), uniffi::export(callback_interface))]
-pub trait StudioChangeObserver: Send + Sync {
- fn on_change(&self, change: SnapshotChangeDto);
-}
-
-#[cfg_attr(not(coverage_nightly), derive(uniffi::Object))]
-pub struct ObserverSubscription {
- core: Weak<RuntimeCore>,
- id: Mutex<Option<ChangeSubscriptionId>>,
-}
-
-#[cfg_attr(not(coverage_nightly), uniffi::export)]
-impl ObserverSubscription {
- pub async fn unsubscribe(&self) {
- let id = self
- .id
- .lock()
- .unwrap_or_else(std::sync::PoisonError::into_inner)
- .take();
- let (Some(core), Some(id)) = (self.core.upgrade(), id) else {
- return;
- };
- let task = {
- core.observers
- .lock()
- .unwrap_or_else(std::sync::PoisonError::into_inner)
- .remove(&id)
- };
- if let Some(Some(task)) = task {
- task.abort();
- let _ = task.await;
- }
- let _ = core.actor.unsubscribe_changes(id).await;
- }
-}
-
-#[cfg_attr(not(coverage_nightly), uniffi::export)]
-impl StudioAppCore {
- /// Subscribes to ordered revision changes including predecessor metadata.
- ///
- /// # Errors
- ///
- /// Returns a safe observer or lifecycle error.
- pub async fn subscribe_changes_v2(
- &self,
- observer: Box<dyn StudioChangeObserver>,
- ) -> Result<Arc<ObserverSubscription>, StudioError> {
- if self.inner.closed.load(Ordering::Acquire) {
- return Err(closed_error());
- }
- let mut subscription = self
- .inner
- .actor
- .subscribe_changes(OBSERVER_CHANGE_CAPACITY)
- .await
- .map_err(StudioError::from)?;
- let id = subscription.id();
- let observer: Arc<dyn StudioChangeObserver> = Arc::from(observer);
- let runtime_core = Arc::downgrade(&self.inner);
- let admitted = {
- let mut observers = self
- .inner
- .observers
- .lock()
- .unwrap_or_else(std::sync::PoisonError::into_inner);
- if self.inner.closed.load(Ordering::Acquire) || observers.len() >= MAX_OBSERVERS {
- false
- } else {
- observers.insert(id, None);
- true
- }
- };
- if !admitted {
- self.inner
- .actor
- .unsubscribe_changes(id)
- .await
- .map_err(StudioError::from)?;
- return Err(observer_registration_error());
- }
- let task = crate::commands::runtime()?.spawn(async move {
- while let Some(change) = subscription.receive().await {
- let Some(runtime_core) = runtime_core.upgrade() else {
- break;
- };
- let delivery = SnapshotChangeDto {
- snapshot: AppSnapshotDto::from_runtime(
- change.snapshot(),
- runtime_core.effective_lifecycle(),
- ),
- previous_revision: change
- .previous_revision()
- .map(radroots_studio_application::SnapshotRevision::value),
- };
- if catch_unwind(AssertUnwindSafe(|| observer.on_change(delivery))).is_err() {
- break;
- }
- }
- if let Some(runtime_core) = runtime_core.upgrade() {
- let _ = runtime_core.actor.unsubscribe_changes(id).await;
- runtime_core
- .observers
- .lock()
- .unwrap_or_else(std::sync::PoisonError::into_inner)
- .remove(&id);
- }
- });
- let retained = {
- let mut observers = self
- .inner
- .observers
- .lock()
- .unwrap_or_else(std::sync::PoisonError::into_inner);
- if let Some(slot) = observers.get_mut(&id) {
- *slot = Some(task);
- true
- } else {
- task.abort();
- false
- }
- };
- if !retained {
- let _ = self.inner.actor.unsubscribe_changes(id).await;
- return Err(closed_error());
- }
- Ok(Arc::new(ObserverSubscription {
- core: Arc::downgrade(&self.inner),
- id: Mutex::new(Some(id)),
- }))
- }
-
- /// Stops observer delivery and waits for actor-owned shutdown.
- ///
- /// # Errors
- ///
- /// Returns a safe closed or timeout error when shutdown cannot complete.
- pub async fn shutdown_v2(&self) -> Result<ShutdownReceiptDto, StudioError> {
- if self.inner.closed.swap(true, Ordering::AcqRel) {
- return Err(closed_error());
- }
- let handles = std::mem::take(
- &mut *self
- .inner
- .observers
- .lock()
- .unwrap_or_else(std::sync::PoisonError::into_inner),
- );
- for (_, task) in handles {
- if let Some(task) = task {
- task.abort();
- let _ = task.await;
- }
- }
- self.inner.actor.close().await.map_err(StudioError::from)?;
- Ok(ShutdownReceiptDto {
- final_revision: self.inner.actor.snapshot().revision().value(),
- closed: true,
- })
- }
-}
-
-fn closed_error() -> StudioError {
- StudioError::Failure {
- code: crate::WireErrorCode::InvalidApplicationState,
- category: crate::WireErrorCategory::Lifecycle,
- retryable: false,
- recovery_action: crate::WireRecoveryAction::None,
- correlation_id: None,
- safe_message: "The application runtime is closed.".to_owned(),
- }
-}
-
-fn observer_registration_error() -> StudioError {
- StudioError::Failure {
- code: crate::WireErrorCode::ObserverRegistrationFailed,
- category: crate::WireErrorCategory::Lifecycle,
- retryable: true,
- recovery_action: crate::WireRecoveryAction::Retry,
- correlation_id: None,
- safe_message: "The change observer could not be registered.".to_owned(),
- }
-}
-
-#[cfg(test)]
-#[cfg_attr(coverage_nightly, coverage(off))]
-mod tests {
- use std::num::NonZeroUsize;
- use std::sync::{Arc, Mutex};
- use std::time::Duration;
-
- use nostr::{EventBuilder, Keys, Metadata};
- use nostr_relay_builder::MockRelay;
- use nostr_sdk::Client;
- use radroots_studio_application::{InMemorySecretStore, RelayConfiguration};
- use radroots_studio_domain::{RelayDestinationPolicy, RelayUrl};
- use radroots_studio_nostr::SdkNostrClient;
- use radroots_studio_runtime::{
- RuntimeActorHandle, RuntimeDependencies, UuidInstallationIdentitySource,
- };
-
- use crate::commands::{ACTOR_MAILBOX_CAPACITY, RuntimeCore, SystemClock, runtime};
- use crate::{
- AppSnapshotDto, ProfileLoadStateDto, SnapshotChangeDto, StudioAppCore, StudioChangeObserver,
- };
-
- const SECRET_HEX: &str = "7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7";
-
- #[derive(Default)]
- struct RecordingObserver {
- snapshots: Mutex<Vec<AppSnapshotDto>>,
- core: Mutex<Option<Arc<StudioAppCore>>>,
- }
-
- struct PanickingObserver;
-
- impl StudioChangeObserver for PanickingObserver {
- fn on_change(&self, _change: SnapshotChangeDto) {
- panic!("injected host callback failure");
- }
- }
-
- impl StudioChangeObserver for RecordingObserver {
- fn on_change(&self, change: SnapshotChangeDto) {
- let snapshot = change.snapshot;
- if let Some(core) = self.core.lock().expect("core").as_ref() {
- assert_eq!(core.snapshot().revision, snapshot.revision);
- }
- self.snapshots.lock().expect("snapshots").push(snapshot);
- }
- }
-
- async fn core() -> Arc<StudioAppCore> {
- core_with_relays(RelayConfiguration::default()).await
- }
-
- async fn core_with_relays(relays: RelayConfiguration) -> Arc<StudioAppCore> {
- let actor = RuntimeActorHandle::in_memory(
- relays,
- RuntimeDependencies::new(
- Arc::new(InMemorySecretStore::default()),
- Arc::new(SystemClock),
- Arc::new(SdkNostrClient::new(std::time::Duration::from_millis(10))),
- Arc::new(UuidInstallationIdentitySource),
- ),
- NonZeroUsize::new(ACTOR_MAILBOX_CAPACITY).expect("capacity"),
- runtime().expect("runtime").handle(),
- )
- .await
- .expect("actor");
- Arc::new(StudioAppCore {
- inner: Arc::new(RuntimeCore {
- actor,
- observers: Mutex::new(std::collections::BTreeMap::new()),
- closed: std::sync::atomic::AtomicBool::new(false),
- startup_relay_problem: None,
- }),
- })
- }
-
- #[test]
- fn callbacks_allow_reentry_and_stop_after_subscription_close() {
- runtime().expect("runtime").block_on(async {
- let core = core().await;
- let observer = Arc::new(RecordingObserver::default());
- *observer.core.lock().expect("core") = Some(Arc::clone(&core));
- let subscription = core
- .subscribe_changes_v2(Box::new(ArcObserver(observer.clone())))
- .await
- .expect("subscribe");
- wait_for_snapshot_count(&observer, 1).await;
- core.inner
- .actor
- .bootstrap()
- .await
- .expect("idempotent bootstrap");
- assert_eq!(observer.snapshots.lock().expect("snapshots").len(), 1);
- subscription.unsubscribe().await;
- subscription.unsubscribe().await;
- core.inner.actor.sign_out().await.expect("sign out");
- assert_eq!(observer.snapshots.lock().expect("snapshots").len(), 1);
- });
- }
-
- #[test]
- fn core_close_deregisters_all_observers_and_rejects_new_subscriptions() {
- runtime().expect("runtime").block_on(async {
- let core = core().await;
- let observer = Arc::new(RecordingObserver::default());
- let subscription = core
- .subscribe_changes_v2(Box::new(ArcObserver(observer.clone())))
- .await
- .expect("subscribe");
- let _active_subscription = core
- .subscribe_changes_v2(Box::new(ArcObserver(observer.clone())))
- .await
- .expect("second subscription");
- let id = subscription
- .id
- .lock()
- .expect("subscription id")
- .expect("active subscription id");
- let handle = core
- .inner
- .observers
- .lock()
- .expect("observers")
- .get_mut(&id)
- .expect("registered observer")
- .take()
- .expect("observer task");
- handle.abort();
-
- core.shutdown_v2().await.expect("shutdown");
- assert!(core.shutdown_v2().await.is_err());
-
- assert!(
- core.subscribe_changes_v2(Box::new(ArcObserver(observer)))
- .await
- .is_err()
- );
- assert!(core.inner.observers.lock().expect("observers").is_empty());
- });
- }
-
- #[test]
- fn subscription_unsubscribe_tolerates_a_dropped_runtime_core() {
- runtime().expect("runtime").block_on(async {
- let core = core().await;
- let observer = Arc::new(RecordingObserver::default());
- let subscription = core
- .subscribe_changes_v2(Box::new(ArcObserver(observer.clone())))
- .await
- .expect("subscribe");
- wait_for_snapshot_count(&observer, 1).await;
-
- drop(core);
- subscription.unsubscribe().await;
- });
- }
-
- #[test]
- fn observer_registration_is_bounded_and_callback_panics_are_contained() {
- runtime().expect("runtime").block_on(async {
- let core = core().await;
- let panic_subscription = core
- .subscribe_changes_v2(Box::new(PanickingObserver))
- .await
- .expect("panic observer registration");
- tokio::time::sleep(Duration::from_millis(10)).await;
- assert!(core.inner.observers.lock().expect("observers").is_empty());
- panic_subscription.unsubscribe().await;
-
- let observer = Arc::new(RecordingObserver::default());
- let mut subscriptions = Vec::new();
- for _ in 0..super::MAX_OBSERVERS {
- subscriptions.push(
- core.subscribe_changes_v2(Box::new(ArcObserver(observer.clone())))
- .await
- .expect("bounded observer registration"),
- );
- }
- assert!(
- core.subscribe_changes_v2(Box::new(ArcObserver(observer)))
- .await
- .is_err()
- );
- for subscription in subscriptions {
- subscription.unsubscribe().await;
- }
- assert!(core.inner.observers.lock().expect("observers").is_empty());
- });
- }
-
- #[tokio::test]
- async fn ffi_callback_receives_async_profile_refresh_and_stops_after_unsubscribe() {
- let local_relay = MockRelay::run().await.expect("local relay");
- let relay_url = local_relay.url().await;
- let publisher = Client::new(Keys::parse(SECRET_HEX).expect("known key"));
- publisher
- .add_relay(relay_url.clone())
- .await
- .expect("publisher relay");
- publisher.connect().await;
- publisher.wait_for_connection(Duration::from_secs(2)).await;
- publisher
- .send_event_builder(EventBuilder::metadata(
- &Metadata::new().display_name("FFI Profile"),
- ))
- .await
- .expect("publish profile");
-
- let core = core_with_relays(
- RelayConfiguration::new(vec![
- RelayUrl::parse(relay_url.as_str(), RelayDestinationPolicy::Local)
- .expect("relay URL"),
- ])
- .expect("relay configuration"),
- )
- .await;
- core.bootstrap().await.expect("bootstrap");
- let observer = Arc::new(RecordingObserver::default());
- *observer.core.lock().expect("core") = Some(Arc::clone(&core));
- let subscription = core
- .subscribe_changes_v2(Box::new(ArcObserver(observer.clone())))
- .await
- .expect("subscribe");
- let imported = core
- .import_account_v2(
- crate::RequestContextDto {
- request_id: "observer-import".to_owned(),
- expected_revision: core.snapshot().revision,
- deadline_millis: 5_000,
- },
- SECRET_HEX.as_bytes().to_vec(),
- )
- .await
- .expect("import")
- .snapshot;
- let public_key = imported.selected_public_key_hex.expect("selection");
- core.activate_account(public_key).await.expect("activate");
- core.refresh_active_profile().await.expect("refresh");
-
- wait_for_fresh_profile(&observer).await;
- let snapshots = observer.snapshots.lock().expect("snapshots").clone();
- assert!(snapshots.iter().any(|snapshot| {
- snapshot.active_account.as_ref().is_some_and(|active| {
- active.profile_state == ProfileLoadStateDto::Fresh
- && active
- .profile
- .as_ref()
- .and_then(|profile| profile.display_name.as_deref())
- == Some("FFI Profile")
- })
- }));
- subscription.unsubscribe().await;
- let count = observer.snapshots.lock().expect("snapshots").len();
- core.sign_out().await.expect("sign out");
- assert_eq!(observer.snapshots.lock().expect("snapshots").len(), count);
-
- core.shutdown_v2().await.expect("shutdown");
- publisher.shutdown().await;
- local_relay.shutdown();
- }
-
- struct ArcObserver(Arc<RecordingObserver>);
-
- impl StudioChangeObserver for ArcObserver {
- fn on_change(&self, change: SnapshotChangeDto) {
- self.0.on_change(change);
- }
- }
-
- async fn wait_for_snapshot_count(observer: &RecordingObserver, minimum: usize) {
- tokio::time::timeout(Duration::from_secs(1), async {
- while observer.snapshots.lock().expect("snapshots").len() < minimum {
- tokio::task::yield_now().await;
- }
- })
- .await
- .expect("snapshot delivery");
- }
-
- async fn wait_for_fresh_profile(observer: &RecordingObserver) {
- tokio::time::timeout(Duration::from_secs(1), async {
- loop {
- let fresh = observer
- .snapshots
- .lock()
- .expect("snapshots")
- .iter()
- .any(|snapshot| {
- snapshot.active_account.as_ref().is_some_and(|active| {
- active.profile_state == ProfileLoadStateDto::Fresh
- })
- });
- if fresh {
- break;
- }
- tokio::task::yield_now().await;
- }
- })
- .await
- .expect("fresh profile delivery");
- }
-}
diff --git a/crates/studio_ffi/uniffi.toml b/crates/studio_ffi/uniffi.toml
@@ -1,3 +0,0 @@
-[crates.radroots_studio_ffi.bindings.kotlin]
-package_name = "org.radroots.studio.ffi"
-cdylib_name = "radroots_studio_ffi"
diff --git a/crates/studio_nostr/Cargo.toml b/crates/studio_nostr/Cargo.toml
@@ -1,34 +0,0 @@
-[package]
-name = "radroots_studio_nostr"
-description = "Private Nostr adapter for Radroots Studio"
-version = "0.1.0-alpha"
-edition.workspace = true
-authors.workspace = true
-rust-version.workspace = true
-license = "GPL-3.0-only"
-repository.workspace = true
-homepage.workspace = true
-publish = false
-include = ["src/**", "Cargo.toml"]
-
-[dependencies]
-nostr = { git = "https://github.com/rust-nostr/nostr.git", rev = "5bba5163eb77107f82c4a8262cf29d7f33a73219", package = "nostr" }
-nostr-sdk = { git = "https://github.com/rust-nostr/nostr.git", rev = "5bba5163eb77107f82c4a8262cf29d7f33a73219", package = "nostr-sdk" }
-radroots_studio_application.workspace = true
-radroots_studio_domain.workspace = true
-radroots_identity.workspace = true
-radroots_transport.workspace = true
-radroots_transport_nostr.workspace = true
-tokio = { version = "=1.47.1", features = ["sync", "time"] }
-
-[dev-dependencies]
-nostr-relay-builder = { git = "https://github.com/rust-nostr/nostr.git", rev = "5bba5163eb77107f82c4a8262cf29d7f33a73219", package = "nostr-relay-builder" }
-tokio = { version = "=1.47.1", features = [
- "macros",
- "rt-multi-thread",
- "sync",
- "time",
-] }
-
-[lints]
-workspace = true
diff --git a/crates/studio_nostr/src/client.rs b/crates/studio_nostr/src/client.rs
@@ -1,353 +0,0 @@
-use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
-
-use radroots_studio_domain::{
- PublicKey, RelayDestinationPolicy, RelayUrl, SafeError, SafeErrorCode, SafeMessage,
- select_latest_kind0,
-};
-use radroots_transport::{
- EventSource, FetchRequest, Target, TargetSet,
- outcome::FetchTargetState,
- source::{FetchBounds, FetchSelector},
-};
-use radroots_transport_nostr::{Config, NostrTransport, RelayProfile};
-
-use radroots_studio_application::{
- BoxFuture, MAX_CONFIGURED_RELAYS, NostrClient, ProfileFetchResult,
-};
-
-pub struct SdkNostrClient {
- timeout: Duration,
-}
-
-const MAX_PROFILE_EVENTS_PER_RELAY: usize = 64;
-
-impl SdkNostrClient {
- #[must_use]
- pub const fn new(timeout: Duration) -> Self {
- Self { timeout }
- }
-}
-
-impl NostrClient for SdkNostrClient {
- fn fetch_profile<'a>(
- &'a self,
- public_key: PublicKey,
- relays: &'a [RelayUrl],
- deadline: Instant,
- ) -> BoxFuture<'a, Result<ProfileFetchResult, SafeError>> {
- Box::pin(async move {
- if relays.is_empty() {
- return Err(invalid_relay_configuration());
- }
- if relays.len() > MAX_CONFIGURED_RELAYS {
- return Err(invalid_relay_configuration());
- }
-
- let author = public_key.canonical();
- let deadline = deadline.min(Instant::now() + self.timeout);
- let mut candidates = Vec::new();
- let mut successful_relays = 0usize;
- for policy in [
- RelayDestinationPolicy::Public,
- RelayDestinationPolicy::Local,
- RelayDestinationPolicy::PrivateNetwork,
- ] {
- let policy_relays = relays
- .iter()
- .filter(|relay| relay.policy() == policy)
- .collect::<Vec<_>>();
- if policy_relays.is_empty() {
- continue;
- }
- let profile =
- relay_profile(policy, policy_relays.iter().map(|relay| relay.as_str()))
- .map_err(|_| invalid_relay_configuration())?;
- let config = Config::from_profile(profile);
- let timeout_ms = timeout_millis(deadline.saturating_duration_since(Instant::now()));
- let config = config
- .with_timeouts(timeout_ms, timeout_ms, timeout_ms)
- .map_err(|_| invalid_relay_configuration())?;
- let targets = policy_relays
- .iter()
- .map(|relay| Target::nostr_relay(relay.as_str()))
- .collect::<Result<Vec<_>, _>>()
- .map_err(|_| invalid_relay_configuration())?;
- let request = FetchRequest::new(
- format!("studio-profile-{policy:?}"),
- TargetSet::new(targets).map_err(|_| invalid_relay_configuration())?,
- FetchBounds::new(
- MAX_PROFILE_EVENTS_PER_RELAY as u16,
- unix_deadline(deadline.saturating_duration_since(Instant::now()))?,
- )
- .map_err(|_| invalid_relay_configuration())?,
- )
- .map_err(|_| invalid_relay_configuration())?
- .with_selector(
- FetchSelector::all()
- .with_kinds(vec![0])
- .and_then(|selector| selector.with_authors(vec![author]))
- .map_err(|_| invalid_relay_configuration())?,
- );
- let page = tokio::time::timeout_at(
- deadline.into(),
- NostrTransport::new(config).fetch(request),
- )
- .await
- .map_err(|_| relay_connection_failed())?
- .map_err(|_| relay_connection_failed())?;
- successful_relays += page
- .target_outcomes()
- .iter()
- .filter(|outcome| {
- matches!(
- outcome.state(),
- FetchTargetState::Complete | FetchTargetState::Partial
- )
- })
- .count();
- for observed in page.events() {
- candidates.push(crate::parse_verified_kind0(
- observed.event().raw_json(),
- public_key,
- )?);
- }
- }
- if successful_relays == 0 {
- return Err(relay_connection_failed());
- }
- let candidate = select_latest_kind0(candidates);
- if successful_relays == relays.len() {
- Ok(ProfileFetchResult::complete(candidate))
- } else {
- Ok(ProfileFetchResult::partial(candidate))
- }
- })
- }
-}
-
-fn unix_deadline(timeout: Duration) -> Result<u64, SafeError> {
- let now = SystemTime::now()
- .duration_since(UNIX_EPOCH)
- .map_err(|_| relay_connection_failed())?;
- let deadline = now
- .checked_add(timeout)
- .ok_or_else(relay_connection_failed)?;
- u64::try_from(deadline.as_millis())
- .ok()
- .filter(|deadline| *deadline > 0)
- .ok_or_else(relay_connection_failed)
-}
-
-fn timeout_millis(timeout: Duration) -> u64 {
- u64::try_from(timeout.as_millis())
- .unwrap_or(u64::MAX)
- .clamp(1, 120_000)
-}
-
-fn relay_profile<I, S>(
- policy: RelayDestinationPolicy,
- relays: I,
-) -> Result<RelayProfile, radroots_transport_nostr::Error>
-where
- I: IntoIterator<Item = S>,
- S: AsRef<str>,
-{
- match policy {
- RelayDestinationPolicy::Public => RelayProfile::public(relays),
- RelayDestinationPolicy::Local => RelayProfile::simulator(relays),
- RelayDestinationPolicy::PrivateNetwork => RelayProfile::device(relays),
- }
-}
-
-const fn invalid_relay_configuration() -> SafeError {
- SafeError::new(
- SafeErrorCode::InvalidRelayConfiguration,
- SafeMessage::new("No Nostr relay is configured."),
- )
-}
-
-const fn relay_connection_failed() -> SafeError {
- SafeError::new(
- SafeErrorCode::RelayConnectionFailed,
- SafeMessage::new("The Nostr relays could not be reached."),
- )
-}
-
-#[cfg(test)]
-mod tests {
- use std::time::Duration;
-
- use nostr::{EventBuilder, Keys, Metadata};
- use nostr_relay_builder::MockRelay;
- use nostr_sdk::Client;
- use radroots_studio_domain::{PublicKey, RelayDestinationPolicy, RelayUrl, SafeErrorCode};
-
- use radroots_studio_application::NostrClient;
-
- use crate::SdkNostrClient;
-
- #[tokio::test]
- async fn sdk_client_fetches_verified_profile_from_ephemeral_local_relay() {
- let relay = MockRelay::run().await.expect("local relay");
- let relay_url = relay.url().await;
- let keys = Keys::generate();
- let publisher = Client::new(keys.clone());
- publisher
- .add_relay(relay_url.clone())
- .await
- .expect("add relay");
- publisher.connect().await;
- publisher.wait_for_connection(Duration::from_secs(2)).await;
- publisher
- .send_event_builder(EventBuilder::metadata(
- &Metadata::new().name("Farmer").display_name("Farm Account"),
- ))
- .await
- .expect("publish metadata");
-
- let adapter = SdkNostrClient::new(Duration::from_secs(2));
- let domain_relay = RelayUrl::parse(relay_url.as_str(), RelayDestinationPolicy::Local)
- .expect("domain relay URL");
- let public_key =
- PublicKey::from_bytes(keys.public_key().to_bytes()).expect("valid public key");
- let fetched = adapter
- .fetch_profile(
- public_key,
- &[domain_relay],
- std::time::Instant::now() + Duration::from_secs(2),
- )
- .await
- .expect("fetch profile");
- let (profile, completeness) = fetched.into_parts();
- let profile = profile.expect("published profile");
-
- assert_eq!(profile.author(), public_key);
- assert_eq!(profile.metadata().preferred_name(), Some("Farm Account"));
- assert_eq!(
- completeness,
- radroots_studio_application::RelayFetchCompleteness::Complete
- );
- publisher.shutdown().await;
- relay.shutdown();
- }
-
- #[tokio::test]
- async fn sdk_client_rejects_empty_configuration_without_network_access() {
- let error = SdkNostrClient::new(Duration::from_millis(10))
- .fetch_profile(
- PublicKey::from_bytes([7; 32]).expect("valid public key"),
- &[],
- std::time::Instant::now() + Duration::from_millis(10),
- )
- .await
- .expect_err("empty relay list");
-
- assert_eq!(error.code(), SafeErrorCode::InvalidRelayConfiguration);
-
- let relay = RelayUrl::parse("wss://relay.example.test", RelayDestinationPolicy::Public)
- .expect("relay URL");
- let too_many = vec![relay; radroots_studio_application::MAX_CONFIGURED_RELAYS + 1];
- let error = SdkNostrClient::new(Duration::from_millis(10))
- .fetch_profile(
- PublicKey::from_bytes([7; 32]).expect("valid public key"),
- &too_many,
- std::time::Instant::now() + Duration::from_millis(10),
- )
- .await
- .expect_err("oversized relay list");
- assert_eq!(error.code(), SafeErrorCode::InvalidRelayConfiguration);
- }
-
- #[tokio::test]
- async fn sdk_client_fails_when_no_configured_relay_completes() {
- let relay = RelayUrl::parse("ws://127.0.0.1:1", RelayDestinationPolicy::Local)
- .expect("unavailable relay");
- let error = SdkNostrClient::new(Duration::from_millis(25))
- .fetch_profile(
- PublicKey::from_bytes([7; 32]).expect("valid public key"),
- &[relay],
- std::time::Instant::now() + Duration::from_millis(50),
- )
- .await
- .expect_err("all relays unavailable");
- assert_eq!(error.code(), SafeErrorCode::RelayConnectionFailed);
- }
-
- #[tokio::test]
- async fn sdk_client_reports_partial_when_one_configured_relay_is_unavailable() {
- let relay = MockRelay::run().await.expect("local relay");
- let relay_url = relay.url().await;
- let keys = Keys::generate();
- let publisher = Client::new(keys.clone());
- publisher
- .add_relay(relay_url.clone())
- .await
- .expect("add relay");
- publisher.connect().await;
- publisher
- .send_event_builder(EventBuilder::metadata(&Metadata::new().name("Partial")))
- .await
- .expect("publish metadata");
-
- let configured = [
- RelayUrl::parse(relay_url.as_str(), RelayDestinationPolicy::Local).expect("live relay"),
- RelayUrl::parse("ws://127.0.0.1:1", RelayDestinationPolicy::Local)
- .expect("unavailable relay"),
- ];
- let fetched = SdkNostrClient::new(Duration::from_millis(250))
- .fetch_profile(
- PublicKey::from_bytes(keys.public_key().to_bytes()).expect("valid public key"),
- &configured,
- std::time::Instant::now() + Duration::from_secs(1),
- )
- .await
- .expect("partial fetch");
- let (candidate, completeness) = fetched.into_parts();
- assert!(candidate.is_some());
- assert_eq!(
- completeness,
- radroots_studio_application::RelayFetchCompleteness::Partial
- );
- publisher.shutdown().await;
- relay.shutdown();
- }
-
- #[test]
- fn studio_policy_maps_exactly_to_the_canonical_transport_profile() {
- let public = super::relay_profile(RelayDestinationPolicy::Public, ["wss://public.example"])
- .expect("public profile");
- let local = super::relay_profile(RelayDestinationPolicy::Local, ["ws://127.0.0.1:8080"])
- .expect("local profile");
- let device = super::relay_profile(
- RelayDestinationPolicy::PrivateNetwork,
- ["wss://10.0.0.5:7447"],
- )
- .expect("device profile");
- assert_eq!(
- public.kind(),
- radroots_transport_nostr::RelayProfileKind::Public
- );
- assert_eq!(
- local.kind(),
- radroots_transport_nostr::RelayProfileKind::Simulator
- );
- assert_eq!(
- device.kind(),
- radroots_transport_nostr::RelayProfileKind::Device
- );
- assert_eq!(super::timeout_millis(Duration::ZERO), 1);
- assert_eq!(
- super::timeout_millis(Duration::from_secs(1_000_000)),
- 120_000
- );
- assert!(super::unix_deadline(Duration::from_secs(1)).is_ok());
- assert_eq!(
- super::invalid_relay_configuration().code(),
- SafeErrorCode::InvalidRelayConfiguration
- );
- assert_eq!(
- super::relay_connection_failed().code(),
- SafeErrorCode::RelayConnectionFailed
- );
- }
-}
diff --git a/crates/studio_nostr/src/keys.rs b/crates/studio_nostr/src/keys.rs
@@ -1,125 +0,0 @@
-use nostr::{Keys, ToBech32};
-use radroots_studio_application::{GeneratedKeyMaterial, ImportedKeyMaterial, KeyMaterialProvider};
-use radroots_studio_domain::{
- Npub, Nsec, PublicKey, SafeError, SafeErrorCode, SafeMessage, SecretKeyInput,
-};
-
-#[derive(Clone, Copy, Debug, Default)]
-pub struct NostrKeyMaterialProvider;
-
-/// Generates one cryptographically random local Nostr keypair.
-///
-/// # Errors
-///
-/// Returns a safe key error if an upstream encoding cannot be represented by
-/// the stricter Radroots domain boundary.
-impl KeyMaterialProvider for NostrKeyMaterialProvider {
- fn generate(&self) -> Result<GeneratedKeyMaterial, SafeError> {
- let keys = Keys::generate();
- let (public_key, npub, secret, nsec) = encode_keys(&keys)?;
- Ok(GeneratedKeyMaterial::new(public_key, npub, secret, nsec))
- }
-
- fn import(&self, input: SecretKeyInput) -> Result<ImportedKeyMaterial, SafeError> {
- let keys = input
- .with_exposed_secret(Keys::parse)
- .map_err(|_| invalid_secret_key())?;
- drop(input);
- let public_key = PublicKey::from_bytes(keys.public_key().to_bytes())?;
- let npub = keys
- .public_key()
- .to_bech32()
- .map_err(|_| invalid_public_key())
- .and_then(Npub::from_encoded)?;
- let secret = SecretKeyInput::parse(keys.secret_key().to_secret_hex())?;
- Ok(ImportedKeyMaterial::new(public_key, npub, secret))
- }
-}
-
-fn encode_keys(keys: &Keys) -> Result<(PublicKey, Npub, SecretKeyInput, Nsec), SafeError> {
- let public_key = PublicKey::from_bytes(keys.public_key().to_bytes())?;
- let npub = keys
- .public_key()
- .to_bech32()
- .map_err(|_| invalid_public_key())
- .and_then(Npub::from_encoded)?;
- let secret = SecretKeyInput::parse(keys.secret_key().to_secret_hex())?;
- let nsec = keys
- .secret_key()
- .to_bech32()
- .map_err(|_| invalid_secret_key())
- .and_then(Nsec::from_encoded)?;
- Ok((public_key, npub, secret, nsec))
-}
-
-const fn invalid_secret_key() -> SafeError {
- SafeError::new(
- SafeErrorCode::InvalidSecretKey,
- SafeMessage::new("The Nostr secret key is invalid."),
- )
-}
-
-const fn invalid_public_key() -> SafeError {
- SafeError::new(
- SafeErrorCode::InvalidPublicKey,
- SafeMessage::new("The Nostr public key is invalid."),
- )
-}
-
-#[cfg(test)]
-mod tests {
- use radroots_studio_domain::{SafeErrorCode, SecretKeyInput};
-
- use radroots_studio_application::KeyMaterialProvider;
-
- use super::{NostrKeyMaterialProvider, invalid_public_key, invalid_secret_key};
-
- const SECRET_HEX: &str = "7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7";
- const NSEC: &str = "nsec1vl029mgpspedva04g90vltkh6fvh240zqtv9k0t9af8935ke9laqsnlfe5";
- const NSEC_PUBLIC_HEX: &str =
- "7e7e9c42a91bfef19fa929e5fda1b72e0ebc1a4c1141673e2794234d86addf4e";
- const HEX_PUBLIC_HEX: &str = "0cfda0afa91cc2fbbd6050c285802fe95c7a1755e0f68323999e13760501dc40";
-
- #[test]
- fn keys_generate_valid_redacted_material() {
- let generated = NostrKeyMaterialProvider.generate().expect("generated");
- let (public_key, npub, secret, nsec) = generated.into_parts();
- assert_eq!(public_key.to_hex().len(), 64);
- assert!(npub.as_str().starts_with("npub1"));
- assert_eq!(secret.with_exposed_secret(str::len), 64);
- assert_eq!(nsec.with_exposed_secret(str::len), 63);
- assert_eq!(secret.with_exposed_secret(str::len), 64);
- assert_eq!(nsec.with_exposed_secret(str::len), 63);
- }
-
- #[test]
- fn keys_import_known_nsec_and_hex_vectors() {
- let from_nsec = NostrKeyMaterialProvider
- .import(SecretKeyInput::parse(NSEC.to_owned()).expect("nsec"))
- .expect("import nsec");
- let from_hex = NostrKeyMaterialProvider
- .import(SecretKeyInput::parse(SECRET_HEX.to_owned()).expect("hex"))
- .expect("import hex");
- let (nsec_public, nsec_npub, _) = from_nsec.into_parts();
- let (hex_public, hex_npub, _) = from_hex.into_parts();
- assert_eq!(nsec_public.to_hex(), NSEC_PUBLIC_HEX);
- assert_eq!(hex_public.to_hex(), HEX_PUBLIC_HEX);
- assert!(nsec_npub.as_str().starts_with("npub1"));
- assert!(hex_npub.as_str().starts_with("npub1"));
- }
-
- #[test]
- fn keys_reject_structurally_plausible_nsec_with_invalid_checksum() {
- let input = SecretKeyInput::parse(
- "nsec1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq".to_owned(),
- )
- .expect("domain shape");
- let error = NostrKeyMaterialProvider
- .import(input)
- .err()
- .expect("invalid checksum");
- assert_eq!(error.code(), SafeErrorCode::InvalidSecretKey);
- assert_eq!(invalid_secret_key().code(), SafeErrorCode::InvalidSecretKey);
- assert_eq!(invalid_public_key().code(), SafeErrorCode::InvalidPublicKey);
- }
-}
diff --git a/crates/studio_nostr/src/lib.rs b/crates/studio_nostr/src/lib.rs
@@ -1,9 +0,0 @@
-#![doc = "Radroots Studio Nostr protocol adapters."]
-
-pub mod client;
-pub mod keys;
-pub mod profile;
-
-pub use client::SdkNostrClient;
-pub use keys::NostrKeyMaterialProvider;
-pub use profile::parse_verified_kind0;
diff --git a/crates/studio_nostr/src/profile.rs b/crates/studio_nostr/src/profile.rs
@@ -1,182 +0,0 @@
-use nostr::{Event, JsonUtil, Kind, Metadata};
-use radroots_studio_domain::{
- EventId, Kind0ProfileCandidate, ProfileMetadata, PublicKey, SafeError, SafeErrorCode,
- SafeMessage, UnixTimestamp,
-};
-
-const MAX_EVENT_JSON_BYTES: usize = 64 * 1_024;
-const MAX_PROFILE_CONTENT_BYTES: usize = 16 * 1_024;
-
-/// Verifies and converts one serialized Nostr kind-0 event.
-///
-/// # Errors
-///
-/// Returns a safe profile-refresh error when the event is oversized,
-/// malformed, invalidly signed, authored by another key, or not kind 0.
-pub fn parse_verified_kind0(
- event_json: &str,
- expected_author: PublicKey,
-) -> Result<Kind0ProfileCandidate, SafeError> {
- if event_json.len() > MAX_EVENT_JSON_BYTES {
- return Err(invalid_event());
- }
-
- let event = Event::from_json(event_json).map_err(|_| invalid_event())?;
- event.verify().map_err(|_| invalid_event())?;
- if event.kind != Kind::Metadata
- || event.pubkey.to_bytes() != *expected_author.as_bytes()
- || event.content.len() > MAX_PROFILE_CONTENT_BYTES
- {
- return Err(invalid_event());
- }
-
- let metadata = Metadata::from_json(&event.content).map_err(|_| invalid_metadata())?;
- let profile = ProfileMetadata::new(
- metadata.name,
- metadata.display_name,
- metadata.nip05,
- metadata.about,
- metadata.picture,
- )?;
- let created_at = i64::try_from(event.created_at.as_secs())
- .ok()
- .and_then(UnixTimestamp::from_seconds)
- .ok_or_else(invalid_event)?;
-
- Ok(Kind0ProfileCandidate::new(
- EventId::from_bytes(event.id.to_bytes()),
- expected_author,
- created_at,
- profile,
- ))
-}
-
-const fn invalid_event() -> SafeError {
- SafeError::new(
- SafeErrorCode::ProfileRefreshFailed,
- SafeMessage::new("The Nostr profile event is invalid."),
- )
-}
-
-const fn invalid_metadata() -> SafeError {
- SafeError::new(
- SafeErrorCode::InvalidProfileMetadata,
- SafeMessage::new("The Nostr profile metadata is invalid."),
- )
-}
-
-#[cfg(test)]
-mod tests {
- use nostr::{EventBuilder, JsonUtil, Keys, Metadata, Url};
- use radroots_studio_domain::{PublicKey, SafeErrorCode};
-
- use super::parse_verified_kind0;
-
- fn signed_profile() -> (Keys, String) {
- let keys = Keys::generate();
- let event = EventBuilder::metadata(
- &Metadata::new()
- .name(" farmer ")
- .display_name(" Farm Account ")
- .nip05("farmer@example.test")
- .about("Local grower")
- .picture(
- Url::parse("https://images.example.test/farmer.png")
- .expect("valid picture URL"),
- ),
- )
- .sign_with_keys(&keys)
- .expect("signed metadata event");
- (keys, event.as_json())
- }
-
- #[test]
- fn profile_event_verifies_signature_author_kind_and_metadata() {
- let (keys, json) = signed_profile();
- let expected_author =
- PublicKey::from_bytes(keys.public_key().to_bytes()).expect("valid public key");
-
- let candidate = parse_verified_kind0(&json, expected_author).expect("verified profile");
-
- assert_eq!(candidate.author(), expected_author);
- assert_eq!(candidate.metadata().name(), Some("farmer"));
- assert_eq!(candidate.metadata().display_name(), Some("Farm Account"));
- assert_eq!(candidate.metadata().nip05(), Some("farmer@example.test"));
- assert_eq!(candidate.metadata().about(), Some("Local grower"));
- assert_eq!(
- candidate.metadata().picture(),
- Some("https://images.example.test/farmer.png")
- );
- }
-
- #[test]
- fn profile_event_rejects_tampering_wrong_author_kind_and_oversize_content() {
- let (keys, json) = signed_profile();
- let expected_author =
- PublicKey::from_bytes(keys.public_key().to_bytes()).expect("valid public key");
- let wrong_author = PublicKey::from_bytes(Keys::generate().public_key().to_bytes())
- .expect("valid public key");
- let tampered = json.replace("Local grower", "Remote grower");
- let note = EventBuilder::text_note("not metadata")
- .sign_with_keys(&keys)
- .expect("signed note")
- .as_json();
- let oversized = EventBuilder::metadata(&Metadata::new().about("x".repeat(16 * 1_024 + 1)))
- .sign_with_keys(&keys)
- .expect("signed oversized profile")
- .as_json();
-
- for rejected in [
- parse_verified_kind0(&tampered, expected_author),
- parse_verified_kind0(&json, wrong_author),
- parse_verified_kind0(¬e, expected_author),
- parse_verified_kind0(&oversized, expected_author),
- ] {
- assert_eq!(
- rejected.expect_err("invalid event").code(),
- SafeErrorCode::ProfileRefreshFailed
- );
- }
- }
-
- #[test]
- fn profile_event_rejects_malformed_and_bounded_invalid_metadata() {
- let keys = Keys::generate();
- let malformed = EventBuilder::new(nostr::Kind::Metadata, "not json")
- .sign_with_keys(&keys)
- .expect("signed malformed metadata")
- .as_json();
- let invalid = EventBuilder::metadata(&Metadata::new().name("x".repeat(129)))
- .sign_with_keys(&keys)
- .expect("signed invalid metadata")
- .as_json();
- let author = PublicKey::from_bytes(keys.public_key().to_bytes()).expect("valid public key");
-
- assert_eq!(
- parse_verified_kind0(&malformed, author)
- .expect_err("malformed metadata")
- .code(),
- SafeErrorCode::InvalidProfileMetadata
- );
- assert_eq!(
- parse_verified_kind0(&invalid, author)
- .expect_err("bounded metadata")
- .code(),
- SafeErrorCode::InvalidProfileMetadata
- );
- assert_eq!(
- parse_verified_kind0(&"x".repeat(64 * 1_024 + 1), author)
- .expect_err("oversized event")
- .code(),
- SafeErrorCode::ProfileRefreshFailed
- );
- assert_eq!(
- super::invalid_event().code(),
- SafeErrorCode::ProfileRefreshFailed
- );
- assert_eq!(
- super::invalid_metadata().code(),
- SafeErrorCode::InvalidProfileMetadata
- );
- }
-}
diff --git a/crates/studio_preferences/Cargo.toml b/crates/studio_preferences/Cargo.toml
@@ -1,18 +0,0 @@
-[package]
-name = "radroots_studio_preferences"
-description = "Private preference model for Radroots Studio"
-version = "0.1.0-alpha"
-edition.workspace = true
-authors.workspace = true
-rust-version.workspace = true
-license = "MPL-2.0"
-repository.workspace = true
-homepage.workspace = true
-publish = false
-include = ["src/**", "Cargo.toml"]
-
-[dependencies]
-url = "=2.5.8"
-
-[lints]
-workspace = true
diff --git a/crates/studio_preferences/src/lib.rs b/crates/studio_preferences/src/lib.rs
@@ -1,328 +0,0 @@
-// SPDX-License-Identifier: MPL-2.0
-//! UI-neutral Studio preference state.
-//!
-//! This module carries forward the uniquely required preference behavior from
-//! source commit `6074a4745be361f21bb47d4778c74a14b2d57954`. It intentionally
-//! excludes that source's process-global state, sample account, and FFI layer.
-
-use url::Url;
-
-pub const PREFERENCES_SCHEMA_VERSION: u32 = 1;
-const MAX_SUMMARY_BYTES: usize = 256;
-const MAX_SERVER_URL_BYTES: usize = 2_048;
-
-#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
-pub enum UpdateChannel {
- #[default]
- Stable,
- Preview,
-}
-
-#[derive(Clone, Debug, PartialEq, Eq)]
-pub struct StudioPreferences {
- pub allow_incoming_connections: bool,
- pub use_radroots_dns: bool,
- pub use_radroots_subnets: bool,
- pub launch_at_login: bool,
- pub hide_dock_icon: bool,
- pub vpn_on_demand_enabled: bool,
- pub run_as_exit_node: bool,
- pub allow_local_network_access: bool,
- pub automatically_check_for_updates: bool,
- pub update_channel: UpdateChannel,
- pub last_update_check_summary: String,
- pub alternate_server_url: String,
-}
-
-impl Default for StudioPreferences {
- fn default() -> Self {
- Self {
- allow_incoming_connections: true,
- use_radroots_dns: true,
- use_radroots_subnets: true,
- launch_at_login: true,
- hide_dock_icon: false,
- vpn_on_demand_enabled: false,
- run_as_exit_node: false,
- allow_local_network_access: false,
- automatically_check_for_updates: true,
- update_channel: UpdateChannel::Stable,
- last_update_check_summary: String::new(),
- alternate_server_url: String::new(),
- }
- }
-}
-
-#[derive(Clone, Debug, PartialEq, Eq)]
-pub struct PreferencesState {
- schema_version: u32,
- revision: u64,
- preferences: StudioPreferences,
-}
-
-impl Default for PreferencesState {
- fn default() -> Self {
- Self {
- schema_version: PREFERENCES_SCHEMA_VERSION,
- revision: 1,
- preferences: StudioPreferences::default(),
- }
- }
-}
-
-impl PreferencesState {
- #[must_use]
- pub const fn schema_version(&self) -> u32 {
- self.schema_version
- }
-
- #[must_use]
- pub const fn revision(&self) -> u64 {
- self.revision
- }
-
- #[must_use]
- pub const fn preferences(&self) -> &StudioPreferences {
- &self.preferences
- }
-
- pub fn apply(&mut self, change: PreferenceChange) -> Result<bool, PreferencesError> {
- let mut candidate = self.preferences.clone();
- change.apply_to(&mut candidate)?;
- if candidate == self.preferences {
- return Ok(false);
- }
- self.revision = self
- .revision
- .checked_add(1)
- .ok_or(PreferencesError::RevisionExhausted)?;
- self.preferences = candidate;
- Ok(true)
- }
-}
-
-#[derive(Clone, Debug, PartialEq, Eq)]
-pub enum PreferenceChange {
- AllowIncomingConnections(bool),
- UseRadrootsDns(bool),
- UseRadrootsSubnets(bool),
- LaunchAtLogin(bool),
- HideDockIcon(bool),
- VpnOnDemandEnabled(bool),
- RunAsExitNode(bool),
- AllowLocalNetworkAccess(bool),
- AutomaticallyCheckForUpdates(bool),
- UpdateChannel(UpdateChannel),
- LastUpdateCheckSummary(String),
- AlternateServerUrl(String),
-}
-
-impl PreferenceChange {
- fn apply_to(self, preferences: &mut StudioPreferences) -> Result<(), PreferencesError> {
- match self {
- Self::AllowIncomingConnections(value) => {
- preferences.allow_incoming_connections = value;
- }
- Self::UseRadrootsDns(value) => preferences.use_radroots_dns = value,
- Self::UseRadrootsSubnets(value) => preferences.use_radroots_subnets = value,
- Self::LaunchAtLogin(value) => preferences.launch_at_login = value,
- Self::HideDockIcon(value) => preferences.hide_dock_icon = value,
- Self::VpnOnDemandEnabled(value) => preferences.vpn_on_demand_enabled = value,
- Self::RunAsExitNode(value) => preferences.run_as_exit_node = value,
- Self::AllowLocalNetworkAccess(value) => {
- preferences.allow_local_network_access = value;
- }
- Self::AutomaticallyCheckForUpdates(value) => {
- preferences.automatically_check_for_updates = value;
- }
- Self::UpdateChannel(value) => preferences.update_channel = value,
- Self::LastUpdateCheckSummary(value) => {
- preferences.last_update_check_summary = validated_summary(value)?;
- }
- Self::AlternateServerUrl(value) => {
- preferences.alternate_server_url = validated_server_url(value)?;
- }
- }
- Ok(())
- }
-}
-
-#[derive(Clone, Copy, Debug, PartialEq, Eq)]
-pub enum PreferencesError {
- InvalidSummary,
- InvalidAlternateServerUrl,
- RevisionExhausted,
-}
-
-fn validated_summary(value: String) -> Result<String, PreferencesError> {
- let value = value.trim();
- if value.len() > MAX_SUMMARY_BYTES || value.chars().any(char::is_control) {
- return Err(PreferencesError::InvalidSummary);
- }
- Ok(value.to_owned())
-}
-
-fn validated_server_url(value: String) -> Result<String, PreferencesError> {
- let value = value.trim();
- if value.is_empty() {
- return Ok(String::new());
- }
- if value.len() > MAX_SERVER_URL_BYTES || value.chars().any(char::is_control) {
- return Err(PreferencesError::InvalidAlternateServerUrl);
- }
- let url = Url::parse(value).map_err(|_| PreferencesError::InvalidAlternateServerUrl)?;
- if url.scheme() != "https"
- || url.host_str().is_none()
- || !url.username().is_empty()
- || url.password().is_some()
- || url.fragment().is_some()
- {
- return Err(PreferencesError::InvalidAlternateServerUrl);
- }
- Ok(url.to_string())
-}
-
-#[cfg(test)]
-mod tests {
- use super::*;
-
- #[test]
- fn defaults_preserve_the_reviewed_boolean_policy_without_sample_identity() {
- let state = PreferencesState::default();
- assert_eq!(state.schema_version(), PREFERENCES_SCHEMA_VERSION);
- assert_eq!(state.revision(), 1);
- assert!(state.preferences().allow_incoming_connections);
- assert!(state.preferences().use_radroots_dns);
- assert!(state.preferences().use_radroots_subnets);
- assert!(state.preferences().launch_at_login);
- assert!(state.preferences().automatically_check_for_updates);
- assert_eq!(state.preferences().update_channel, UpdateChannel::Stable);
- assert!(state.preferences().last_update_check_summary.is_empty());
- assert!(state.preferences().alternate_server_url.is_empty());
- }
-
- #[test]
- fn revisions_advance_only_when_a_valid_canonical_value_changes() {
- let mut state = PreferencesState::default();
- assert!(
- !state
- .apply(PreferenceChange::HideDockIcon(false))
- .expect("unchanged value")
- );
- assert_eq!(state.revision(), 1);
- assert!(
- state
- .apply(PreferenceChange::HideDockIcon(true))
- .expect("changed value")
- );
- assert_eq!(state.revision(), 2);
- assert!(state.preferences().hide_dock_icon);
- }
-
- #[test]
- fn every_boolean_and_channel_change_updates_exactly_one_revision() {
- let mut state = PreferencesState::default();
- let changes = [
- PreferenceChange::AllowIncomingConnections(false),
- PreferenceChange::UseRadrootsDns(false),
- PreferenceChange::UseRadrootsSubnets(false),
- PreferenceChange::LaunchAtLogin(false),
- PreferenceChange::VpnOnDemandEnabled(true),
- PreferenceChange::RunAsExitNode(true),
- PreferenceChange::AllowLocalNetworkAccess(true),
- PreferenceChange::AutomaticallyCheckForUpdates(false),
- PreferenceChange::UpdateChannel(UpdateChannel::Preview),
- ];
- for (index, change) in changes.into_iter().enumerate() {
- assert!(state.apply(change).expect("valid preference change"));
- assert_eq!(state.revision(), index as u64 + 2);
- }
- let preferences = state.preferences();
- assert!(!preferences.allow_incoming_connections);
- assert!(!preferences.use_radroots_dns);
- assert!(!preferences.use_radroots_subnets);
- assert!(!preferences.launch_at_login);
- assert!(preferences.vpn_on_demand_enabled);
- assert!(preferences.run_as_exit_node);
- assert!(preferences.allow_local_network_access);
- assert!(!preferences.automatically_check_for_updates);
- assert_eq!(preferences.update_channel, UpdateChannel::Preview);
- }
-
- #[test]
- fn revision_overflow_is_rejected_without_mutation() {
- let mut state = PreferencesState {
- revision: u64::MAX,
- ..PreferencesState::default()
- };
- assert_eq!(
- state.apply(PreferenceChange::HideDockIcon(true)),
- Err(PreferencesError::RevisionExhausted)
- );
- assert!(!state.preferences().hide_dock_icon);
- }
-
- #[test]
- fn alternate_server_is_trimmed_canonical_and_credential_free() {
- let mut state = PreferencesState::default();
- state
- .apply(PreferenceChange::AlternateServerUrl(
- " https://example.com/api ".to_owned(),
- ))
- .expect("valid URL");
- assert_eq!(
- state.preferences().alternate_server_url,
- "https://example.com/api"
- );
- for invalid in [
- "http://example.com",
- "https://user@example.com",
- "https://user:password@example.com",
- "https://example.com/#fragment",
- "not a URL",
- "https://example.com/a\nb",
- ] {
- assert_eq!(
- state.apply(PreferenceChange::AlternateServerUrl(invalid.to_owned())),
- Err(PreferencesError::InvalidAlternateServerUrl)
- );
- }
- state
- .apply(PreferenceChange::AlternateServerUrl(" ".to_owned()))
- .expect("empty URL resets the override");
- assert!(state.preferences().alternate_server_url.is_empty());
- assert_eq!(
- state.apply(PreferenceChange::AlternateServerUrl(format!(
- "https://example.com/{}",
- "x".repeat(MAX_SERVER_URL_BYTES)
- ))),
- Err(PreferencesError::InvalidAlternateServerUrl)
- );
- }
-
- #[test]
- fn summary_is_bounded_trimmed_and_control_free() {
- let mut state = PreferencesState::default();
- state
- .apply(PreferenceChange::LastUpdateCheckSummary(
- " Checked today ".to_owned(),
- ))
- .expect("valid summary");
- assert_eq!(
- state.preferences().last_update_check_summary,
- "Checked today"
- );
- assert_eq!(
- state.apply(PreferenceChange::LastUpdateCheckSummary(
- "bad\nvalue".to_owned()
- )),
- Err(PreferencesError::InvalidSummary)
- );
- assert_eq!(
- state.apply(PreferenceChange::LastUpdateCheckSummary(
- "x".repeat(MAX_SUMMARY_BYTES + 1)
- )),
- Err(PreferencesError::InvalidSummary)
- );
- }
-}
diff --git a/crates/studio_runtime/Cargo.toml b/crates/studio_runtime/Cargo.toml
@@ -1,34 +0,0 @@
-[package]
-name = "radroots_studio_runtime"
-description = "Private supervised composition runtime for Radroots Studio"
-version = "0.1.0-alpha"
-edition.workspace = true
-authors.workspace = true
-rust-version.workspace = true
-license = "GPL-3.0-only"
-repository.workspace = true
-homepage.workspace = true
-publish = false
-include = ["src/**", "tests/**", "Cargo.toml"]
-
-[dependencies]
-radroots_studio_application.workspace = true
-radroots_studio_domain.workspace = true
-radroots_studio_nostr.workspace = true
-radroots_studio_storage.workspace = true
-tokio = { version = "=1.47.1", features = [
- "macros",
- "rt-multi-thread",
- "sync",
- "time",
-] }
-uuid.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" }
-tempfile = "=3.23.0"
-
-[lints]
-workspace = true
diff --git a/crates/studio_runtime/src/blocking.rs b/crates/studio_runtime/src/blocking.rs
@@ -1,114 +0,0 @@
-use std::sync::Arc;
-use std::time::Instant;
-
-use tokio::runtime::Handle;
-use tokio::sync::Semaphore;
-
-#[derive(Clone, Copy, Debug, Eq, PartialEq)]
-pub(crate) enum BlockingExecutionError {
- DeadlineElapsed,
- Saturated,
- TaskFailed,
-}
-
-#[derive(Clone)]
-pub(crate) struct BoundedBlockingExecutor {
- permits: Arc<Semaphore>,
- runtime: Handle,
-}
-
-impl BoundedBlockingExecutor {
- pub(crate) fn new(capacity: usize, runtime: &Handle) -> Self {
- Self {
- permits: Arc::new(Semaphore::new(capacity)),
- runtime: runtime.clone(),
- }
- }
-
- pub(crate) async fn execute<T, F>(
- &self,
- deadline: Instant,
- operation: F,
- ) -> Result<T, BlockingExecutionError>
- where
- T: Send + 'static,
- F: FnOnce() -> T + Send + 'static,
- {
- if Instant::now() >= deadline {
- return Err(BlockingExecutionError::DeadlineElapsed);
- }
- let permit = self
- .permits
- .clone()
- .try_acquire_owned()
- .map_err(|_| BlockingExecutionError::Saturated)?;
- self.runtime
- .spawn_blocking(move || {
- let _permit = permit;
- operation()
- })
- .await
- .map_err(|_| BlockingExecutionError::TaskFailed)
- }
-}
-
-#[cfg(test)]
-mod tests {
- use std::sync::{Arc, Condvar, Mutex};
- use std::time::{Duration, Instant};
-
- use tokio::sync::oneshot;
-
- use super::{BlockingExecutionError, BoundedBlockingExecutor};
-
- #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
- async fn executor_rejects_saturation_without_starting_excess_work() {
- let executor = BoundedBlockingExecutor::new(1, &tokio::runtime::Handle::current());
- let release = Arc::new((Mutex::new(false), Condvar::new()));
- let first_release = Arc::clone(&release);
- let (started, started_rx) = oneshot::channel();
- let first_executor = executor.clone();
- let first = tokio::spawn(async move {
- first_executor
- .execute(Instant::now() + Duration::from_secs(5), move || {
- let _ = started.send(());
- let (lock, ready) = &*first_release;
- let mut released = lock.lock().expect("release lock");
- while !*released {
- released = ready.wait(released).expect("release wait");
- }
- 7
- })
- .await
- });
- started_rx.await.expect("first work starts");
-
- let second = executor
- .execute(Instant::now() + Duration::from_secs(5), || 9)
- .await;
- assert_eq!(second, Err(BlockingExecutionError::Saturated));
-
- let (lock, ready) = &*release;
- *lock.lock().expect("release lock") = true;
- ready.notify_all();
- assert_eq!(first.await.expect("first join"), Ok(7));
- }
-
- #[tokio::test]
- async fn executor_rejects_expired_work_before_spawn() {
- let executor = BoundedBlockingExecutor::new(1, &tokio::runtime::Handle::current());
- let result = executor.execute(Instant::now(), || 1).await;
- assert_eq!(result, Err(BlockingExecutionError::DeadlineElapsed));
- }
-
- #[tokio::test]
- async fn executor_classifies_panicked_work_without_panicking_the_actor() {
- let executor = BoundedBlockingExecutor::new(1, &tokio::runtime::Handle::current());
- let result = executor
- .execute::<(), _>(Instant::now() + Duration::from_secs(1), || {
- panic!("test-only blocking task failure");
- })
- .await;
- assert_eq!(result, Err(BlockingExecutionError::TaskFailed));
- }
-}
diff --git a/crates/studio_runtime/src/installation.rs b/crates/studio_runtime/src/installation.rs
@@ -1,58 +0,0 @@
-use radroots_studio_domain::{SafeError, SafeErrorCode, SafeMessage};
-
-#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
-pub struct InstallationIdentity(String);
-
-impl InstallationIdentity {
- pub fn parse(value: impl Into<String>) -> Result<Self, SafeError> {
- let value = value.into();
- if value.len() != 32
- || !value
- .bytes()
- .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
- {
- return Err(invalid_installation_identity());
- }
- Ok(Self(value))
- }
-
- #[must_use]
- pub fn as_str(&self) -> &str {
- self.0.as_str()
- }
-}
-
-pub trait InstallationIdentitySource: Send + Sync {
- fn generate(&self) -> Result<InstallationIdentity, SafeError>;
-}
-
-#[derive(Clone, Copy, Debug, Default)]
-pub struct UuidInstallationIdentitySource;
-
-impl InstallationIdentitySource for UuidInstallationIdentitySource {
- fn generate(&self) -> Result<InstallationIdentity, SafeError> {
- InstallationIdentity::parse(uuid::Uuid::new_v4().simple().to_string())
- }
-}
-
-const fn invalid_installation_identity() -> SafeError {
- SafeError::new(
- SafeErrorCode::InvalidApplicationState,
- SafeMessage::new("The installation identity is invalid."),
- )
-}
-
-#[cfg(test)]
-mod tests {
- use super::{InstallationIdentity, InstallationIdentitySource, UuidInstallationIdentitySource};
-
- #[test]
- fn installation_identity_is_fixed_width_lowercase_hex() {
- let identity = UuidInstallationIdentitySource.generate().expect("identity");
- assert_eq!(identity.as_str().len(), 32);
- assert!(InstallationIdentity::parse(identity.as_str()).is_ok());
- for denied in ["", "AAaabbccddeeff001122334455667788", "not-an-identity"] {
- assert!(InstallationIdentity::parse(denied).is_err());
- }
- }
-}
diff --git a/crates/studio_runtime/src/lib.rs b/crates/studio_runtime/src/lib.rs
@@ -1,12 +0,0 @@
-#![doc = "Radroots Studio supervised runtime composition."]
-
-mod blocking;
-mod installation;
-mod persistence;
-mod runtime_actor;
-
-pub use installation::{
- InstallationIdentity, InstallationIdentitySource, UuidInstallationIdentitySource,
-};
-pub use persistence::PersistentAppCore;
-pub use runtime_actor::{RuntimeActorHandle, RuntimeChangeSubscription, RuntimeDependencies};
diff --git a/crates/studio_runtime/src/persistence.rs b/crates/studio_runtime/src/persistence.rs
@@ -1,746 +0,0 @@
-use std::path::Path;
-use std::sync::Arc;
-
-use radroots_studio_application::{
- AppCore, AppSnapshot, Clock, DurableRequestId, GenerateAccountReceipt, ImportAccountReceipt,
- KeyMaterialProvider, RelayConfiguration, RemovalConfirmationToken, SecretStore,
- StagedGeneratedKey,
-};
-use radroots_studio_domain::{PublicKey, SafeError, SecretKeyInput};
-use radroots_studio_nostr::NostrKeyMaterialProvider;
-
-use radroots_studio_storage::Database;
-
-use crate::{InstallationIdentity, InstallationIdentitySource};
-
-pub struct PersistentAppCore {
- core: AppCore,
- database: Database,
- key_material: Arc<dyn KeyMaterialProvider>,
-}
-
-impl PersistentAppCore {
- pub(crate) fn initialize_installation_identity(
- &self,
- source: &dyn InstallationIdentitySource,
- ) -> Result<InstallationIdentity, SafeError> {
- if let Some(existing) = self.database.load_installation_id()? {
- return InstallationIdentity::parse(existing);
- }
- let candidate = source.generate()?;
- InstallationIdentity::parse(
- self.database
- .initialize_installation_id(candidate.as_str())?,
- )
- }
-
- /// Commits an acknowledged generated-key stage through the durable coordinator.
- ///
- /// # Errors
- ///
- /// Returns a safe conflict, credential, storage, or recovery error.
- pub fn commit_staged_generated_key(
- &self,
- request_id: &DurableRequestId,
- staged: StagedGeneratedKey,
- secrets: &(impl SecretStore + ?Sized),
- clock: &(impl Clock + ?Sized),
- ) -> Result<ImportAccountReceipt, SafeError> {
- self.core.commit_staged_generated_key(
- request_id,
- staged,
- &self.database,
- &self.database,
- secrets,
- &self.database,
- clock,
- )
- }
-
- /// Opens the application database without accessing credentials or relays.
- ///
- /// # Errors
- ///
- /// Returns a safe storage error when the database cannot be opened or migrated.
- pub fn open(path: &Path, relay_configuration: RelayConfiguration) -> Result<Self, SafeError> {
- let key_material: Arc<dyn KeyMaterialProvider> = Arc::new(NostrKeyMaterialProvider);
- Ok(Self {
- core: AppCore::new(relay_configuration, Arc::clone(&key_material)),
- database: Database::open(path)?,
- key_material,
- })
- }
-
- /// Creates an isolated persistent-core adapter for tests.
- ///
- /// # Errors
- ///
- /// Returns a safe storage error when the database cannot be initialized.
- pub fn in_memory(relay_configuration: RelayConfiguration) -> Result<Self, SafeError> {
- let key_material: Arc<dyn KeyMaterialProvider> = Arc::new(NostrKeyMaterialProvider);
- Ok(Self {
- core: AppCore::new(relay_configuration, Arc::clone(&key_material)),
- database: Database::in_memory()?,
- key_material,
- })
- }
-
- pub(crate) fn key_material(&self) -> &dyn KeyMaterialProvider {
- self.key_material.as_ref()
- }
-
- /// Restores public accounts and selection while keeping the session signed out.
- ///
- /// # Errors
- ///
- /// Returns a safe storage or application-state error after publishing a fatal
- /// snapshot when durable state cannot be restored.
- pub fn bootstrap(
- &self,
- secrets: &(impl SecretStore + ?Sized),
- clock: &(impl Clock + ?Sized),
- ) -> Result<AppSnapshot, SafeError> {
- self.core.recover_durable_operations(
- &self.database,
- &self.database,
- secrets,
- &self.database,
- clock,
- )?;
- self.core.recover_pending_operations(
- &self.database,
- &self.database,
- secrets,
- &self.database,
- clock,
- )?;
- self.core.bootstrap_from(&self.database, &self.database)
- }
-
- /// Generates and durably persists one selected, signed-out local account.
- ///
- /// # Errors
- ///
- /// Returns a safe credential, storage, key, or application-state error.
- pub fn generate_account(
- &self,
- secrets: &(impl SecretStore + ?Sized),
- clock: &(impl Clock + ?Sized),
- ) -> Result<GenerateAccountReceipt, SafeError> {
- self.core.generate_account(
- &self.database,
- &self.database,
- secrets,
- &self.database,
- clock,
- )
- }
-
- /// Imports and durably persists one selected, signed-out local account.
- ///
- /// # Errors
- ///
- /// Returns a safe credential, storage, key, or application-state error.
- pub fn import_secret_key(
- &self,
- input: SecretKeyInput,
- secrets: &(impl SecretStore + ?Sized),
- clock: &(impl Clock + ?Sized),
- ) -> Result<ImportAccountReceipt, SafeError> {
- self.core.import_secret_key(
- input,
- &self.database,
- &self.database,
- secrets,
- &self.database,
- clock,
- )
- }
-
- /// Generates an account through the durable request coordinator.
- ///
- /// # Errors
- ///
- /// Returns a safe conflict, credential, storage, or application-state error.
- pub fn generate_account_durable(
- &self,
- request_id: &DurableRequestId,
- expected_revision: u64,
- secrets: &(impl SecretStore + ?Sized),
- clock: &(impl Clock + ?Sized),
- ) -> Result<GenerateAccountReceipt, SafeError> {
- self.core.generate_account_durable(
- request_id,
- expected_revision,
- &self.database,
- &self.database,
- secrets,
- &self.database,
- clock,
- )
- }
-
- /// Imports or repairs an account through the durable request coordinator.
- ///
- /// # Errors
- ///
- /// Returns a safe conflict, validation, credential, storage, or state error.
- pub fn import_secret_key_durable(
- &self,
- request_id: &DurableRequestId,
- expected_revision: u64,
- input: SecretKeyInput,
- secrets: &(impl SecretStore + ?Sized),
- clock: &(impl Clock + ?Sized),
- ) -> Result<ImportAccountReceipt, SafeError> {
- self.core.import_secret_key_durable(
- request_id,
- expected_revision,
- input,
- &self.database,
- &self.database,
- secrets,
- &self.database,
- clock,
- )
- }
-
- /// Persists and publishes one saved-account selection without activation.
- ///
- /// # Errors
- ///
- /// Returns a safe account, storage, or application-state error.
- pub fn select_account(&self, public_key: PublicKey) -> Result<AppSnapshot, SafeError> {
- self.core
- .select_account(public_key, &self.database, &self.database)
- }
-
- /// Activates a saved account after validating its credential and cached profile.
- ///
- /// # Errors
- ///
- /// Returns a safe account, credential, storage, or application-state error.
- pub fn activate_account(
- &self,
- public_key: PublicKey,
- secrets: &(impl SecretStore + ?Sized),
- clock: &(impl Clock + ?Sized),
- ) -> Result<AppSnapshot, SafeError> {
- self.core.activate_account(
- public_key,
- &self.database,
- &self.database,
- &self.database,
- secrets,
- clock,
- )
- }
-
- /// Signs out while retaining durable account data and credentials.
- ///
- /// # Errors
- ///
- /// Returns a safe application-state error if sign out cannot complete.
- pub fn sign_out(&self) -> Result<AppSnapshot, SafeError> {
- self.core.sign_out()
- }
-
- /// Issues a revision-bound, single-use account-removal confirmation.
- ///
- /// # Errors
- ///
- /// Returns a safe error when the target account is not saved.
- pub fn request_account_removal(
- &self,
- public_key: PublicKey,
- clock: &(impl Clock + ?Sized),
- ) -> Result<RemovalConfirmationToken, SafeError> {
- self.core.request_account_removal(public_key, clock)
- }
-
- /// Permanently removes one confirmed account and its credential.
- ///
- /// # Errors
- ///
- /// Returns a safe confirmation, credential, storage, recovery, or state error.
- pub fn confirm_account_removal(
- &self,
- token: RemovalConfirmationToken,
- secrets: &(impl SecretStore + ?Sized),
- clock: &(impl Clock + ?Sized),
- ) -> Result<AppSnapshot, SafeError> {
- self.core.confirm_account_removal(
- token,
- &self.database,
- &self.database,
- secrets,
- &self.database,
- clock,
- )
- }
-
- /// Executes a confirmed removal through the durable request coordinator.
- ///
- /// # Errors
- ///
- /// Returns a safe expiry, conflict, credential, storage, recovery, or state error.
- pub fn confirm_account_removal_durable(
- &self,
- request_id: &DurableRequestId,
- token: RemovalConfirmationToken,
- secrets: &(impl SecretStore + ?Sized),
- clock: &(impl Clock + ?Sized),
- ) -> Result<AppSnapshot, SafeError> {
- self.core.confirm_account_removal_durable(
- request_id,
- token,
- &self.database,
- &self.database,
- secrets,
- &self.database,
- clock,
- )
- }
-
- #[must_use]
- pub const fn core(&self) -> &AppCore {
- &self.core
- }
-
- #[must_use]
- pub const fn database(&self) -> &Database {
- &self.database
- }
-}
-
-#[cfg(test)]
-mod tests {
- use std::fs;
-
- use radroots_studio_application::{
- AccountOperationKind, AccountOperationPhase, AccountRepository, AppLifecycle,
- AppStateRepository, Clock, DurableOperationKind, DurableOperationPhase,
- DurableOperationRepository, DurableRequestId, DurableTerminalOutcome, FailureSecretStore,
- InMemorySecretStore, OperationJournal, OperationPriorState, RelayConfiguration,
- SecretStore, SecretStoreOperation, SessionState,
- };
- use radroots_studio_domain::{
- AccountCreatedAt, AccountIdentity, AccountSummary, BindingAvailability, LocalSignerBinding,
- PublicKey, SafeErrorCode, SecretKeyInput, UnixTimestamp,
- };
- use tempfile::tempdir;
-
- use super::PersistentAppCore;
-
- fn account() -> AccountSummary {
- let public_key = PublicKey::from_bytes([7; 32]).expect("valid public key");
- AccountSummary::new(
- AccountIdentity::derive(public_key).expect("identity"),
- LocalSignerBinding::new(public_key, BindingAvailability::Available),
- None,
- AccountCreatedAt::new(UnixTimestamp::from_seconds(1).expect("time")),
- None,
- )
- .expect("account")
- }
-
- struct FixedClock;
-
- impl Clock for FixedClock {
- fn now(&self) -> UnixTimestamp {
- UnixTimestamp::from_seconds(25).expect("time")
- }
- }
-
- #[test]
- fn persistent_bootstrap_handles_fresh_and_existing_signed_out_state() {
- let directory = tempdir().expect("directory");
- let path = directory.path().join("studio.sqlite3");
- let public_key = account().public_key();
- let secrets = InMemorySecretStore::default();
- {
- let adapter = PersistentAppCore::open(&path, RelayConfiguration::default())
- .expect("open adapter");
- let fresh = adapter
- .bootstrap(&secrets, &FixedClock)
- .expect("fresh bootstrap");
- assert!(fresh.accounts().is_empty());
- adapter
- .database()
- .insert_account(&account())
- .expect("account");
- adapter
- .database()
- .save_selected_account(Some(public_key))
- .expect("selection");
- }
-
- let adapter =
- PersistentAppCore::open(&path, RelayConfiguration::default()).expect("reopen adapter");
- let restored = adapter.bootstrap(&secrets, &FixedClock).expect("restore");
- assert_eq!(restored.lifecycle(), AppLifecycle::Ready);
- assert_eq!(restored.accounts().len(), 1);
- assert_eq!(restored.selected_account(), Some(public_key));
- assert_eq!(restored.session(), SessionState::SignedOut);
- assert!(restored.active_account().is_none());
- }
-
- #[test]
- fn corrupt_database_fails_safely_without_recreation() {
- let directory = tempdir().expect("directory");
- let path = directory.path().join("studio.sqlite3");
- fs::write(&path, b"not a sqlite database").expect("corrupt file");
-
- let error = PersistentAppCore::open(&path, RelayConfiguration::default())
- .err()
- .expect("safe failure");
- assert_eq!(error.code(), SafeErrorCode::StorageCorrupt);
- assert_eq!(
- fs::read(&path).expect("unchanged file"),
- b"not a sqlite database"
- );
- }
-
- #[test]
- fn persisted_generate_and_import_survive_restart_without_secret_bytes() {
- let directory = tempdir().expect("directory");
- let path = directory.path().join("studio.sqlite3");
- let secrets = InMemorySecretStore::default();
- let selected;
- {
- let adapter =
- PersistentAppCore::open(&path, RelayConfiguration::default()).expect("adapter");
- adapter.bootstrap(&secrets, &FixedClock).expect("bootstrap");
- let generated = adapter
- .generate_account(&secrets, &FixedClock)
- .expect("generate");
- assert!(
- secrets
- .contains(generated.account().public_key())
- .expect("generated credential")
- );
- let imported = adapter
- .import_secret_key(
- SecretKeyInput::parse(
- "7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7"
- .to_owned(),
- )
- .expect("secret"),
- &secrets,
- &FixedClock,
- )
- .expect("import");
- selected = imported.account().public_key();
- assert_eq!(adapter.core().snapshot().accounts().len(), 2);
- }
-
- let bytes = fs::read(&path).expect("database bytes");
- assert!(!bytes.windows(5).any(|value| value == b"nsec1"));
- assert!(!bytes.windows(64).any(|value| {
- value == b"7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7"
- }));
- let reopened =
- PersistentAppCore::open(&path, RelayConfiguration::default()).expect("reopen");
- let restored = reopened.bootstrap(&secrets, &FixedClock).expect("restore");
- assert_eq!(restored.accounts().len(), 2);
- assert_eq!(restored.selected_account(), Some(selected));
- assert_eq!(restored.session(), SessionState::SignedOut);
- }
-
- #[test]
- fn durable_import_commits_each_phase_and_recovers_the_terminal_receipt() {
- let adapter = PersistentAppCore::in_memory(RelayConfiguration::default()).expect("adapter");
- let secrets = InMemorySecretStore::default();
- let snapshot = adapter.bootstrap(&secrets, &FixedClock).expect("bootstrap");
- let request = DurableRequestId::parse("import:adapter:1").expect("request");
- let imported = adapter
- .import_secret_key_durable(
- &request,
- snapshot.revision().value(),
- SecretKeyInput::parse(
- "7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7".to_owned(),
- )
- .expect("secret"),
- &secrets,
- &FixedClock,
- )
- .expect("durable import");
- let operation = adapter
- .database()
- .load_durable_operation(&request)
- .expect("operation")
- .expect("durable record");
- let receipt = operation.terminal().expect("terminal receipt");
- assert_eq!(receipt.account(), imported.account().public_key());
- assert_eq!(
- receipt.resulting_revision(),
- Some(adapter.core().snapshot().revision().value())
- );
- }
-
- #[test]
- fn durable_recovery_preserves_repair_metadata_and_deletes_orphan_credentials() {
- let adapter = PersistentAppCore::in_memory(RelayConfiguration::default()).expect("adapter");
- let secrets = InMemorySecretStore::default();
- let missing = account().with_binding_availability(BindingAvailability::CredentialMissing);
- adapter
- .database()
- .insert_account(&missing)
- .expect("account");
- adapter
- .database()
- .save_selected_account(Some(missing.public_key()))
- .expect("selection");
- let request = DurableRequestId::parse("repair:recovery:1").expect("request");
- adapter
- .database()
- .begin_durable_operation(
- &request,
- DurableOperationKind::Repair,
- missing.public_key(),
- Some(0),
- OperationPriorState::new(
- Some(missing.public_key()),
- Some(BindingAvailability::CredentialMissing),
- ),
- FixedClock.now(),
- )
- .expect("intent");
- secrets
- .put(
- missing.public_key(),
- SecretKeyInput::parse(
- "7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7".to_owned(),
- )
- .expect("secret"),
- )
- .expect("credential");
- adapter
- .database()
- .advance_durable_operation(
- &request,
- DurableOperationPhase::IntentRecorded,
- DurableOperationPhase::CredentialWritten,
- FixedClock.now(),
- None,
- )
- .expect("credential phase");
-
- adapter.bootstrap(&secrets, &FixedClock).expect("recovery");
- let repaired = adapter
- .database()
- .find_account(missing.public_key())
- .expect("lookup")
- .expect("preserved account");
- assert_eq!(
- repaired.signer().availability(),
- BindingAvailability::CredentialMissing
- );
- assert!(!secrets.contains(missing.public_key()).expect("credential"));
- assert_eq!(
- adapter
- .database()
- .load_durable_operation(&request)
- .expect("operation")
- .expect("record")
- .terminal()
- .expect("receipt")
- .outcome(),
- DurableTerminalOutcome::Failed
- );
- }
-
- #[test]
- fn durable_recovery_covers_response_loss_and_irreversible_removal_windows() {
- let secrets = InMemorySecretStore::default();
- let adapter = PersistentAppCore::in_memory(RelayConfiguration::default()).expect("adapter");
- let saved = account();
- adapter.database().insert_account(&saved).expect("account");
- let import = DurableRequestId::parse("import:response-loss:1").expect("request");
- adapter
- .database()
- .begin_durable_operation(
- &import,
- DurableOperationKind::Import,
- saved.public_key(),
- Some(0),
- OperationPriorState::new(None, None),
- FixedClock.now(),
- )
- .expect("intent");
- adapter
- .database()
- .advance_durable_operation(
- &import,
- DurableOperationPhase::IntentRecorded,
- DurableOperationPhase::CredentialWritten,
- FixedClock.now(),
- None,
- )
- .expect("credential");
- adapter
- .database()
- .advance_durable_operation(
- &import,
- DurableOperationPhase::CredentialWritten,
- DurableOperationPhase::MetadataCommitted,
- FixedClock.now(),
- None,
- )
- .expect("metadata");
- let restored = adapter
- .bootstrap(&secrets, &FixedClock)
- .expect("response recovery");
- assert_eq!(restored.selected_account(), Some(saved.public_key()));
- assert_eq!(
- adapter
- .database()
- .load_durable_operation(&import)
- .expect("operation")
- .expect("record")
- .terminal()
- .expect("receipt")
- .outcome(),
- DurableTerminalOutcome::Completed
- );
-
- let removal_adapter =
- PersistentAppCore::in_memory(RelayConfiguration::default()).expect("remove adapter");
- removal_adapter
- .database()
- .insert_account(&saved)
- .expect("remove account");
- removal_adapter
- .database()
- .save_selected_account(Some(saved.public_key()))
- .expect("remove selection");
- let removal = DurableRequestId::parse("remove:response-loss:1").expect("request");
- removal_adapter
- .database()
- .begin_durable_operation(
- &removal,
- DurableOperationKind::Remove,
- saved.public_key(),
- Some(0),
- OperationPriorState::new(None, Some(BindingAvailability::Available)),
- FixedClock.now(),
- )
- .expect("remove intent");
- removal_adapter
- .database()
- .advance_durable_operation(
- &removal,
- DurableOperationPhase::IntentRecorded,
- DurableOperationPhase::CredentialDeleted,
- FixedClock.now(),
- None,
- )
- .expect("credential deleted");
- let removed = removal_adapter
- .bootstrap(&secrets, &FixedClock)
- .expect("removal recovery");
- assert!(removed.accounts().is_empty());
- assert_eq!(removed.selected_account(), None);
- }
-
- #[test]
- fn bootstrap_recovery_completes_credential_deleted_removal_and_fallback() {
- let directory = tempdir().expect("directory");
- let path = directory.path().join("studio.sqlite3");
- let secrets = InMemorySecretStore::default();
- let first;
- let removed;
- {
- let adapter =
- PersistentAppCore::open(&path, RelayConfiguration::default()).expect("adapter");
- adapter.bootstrap(&secrets, &FixedClock).expect("bootstrap");
- first = adapter
- .generate_account(&secrets, &FixedClock)
- .expect("first")
- .account()
- .public_key();
- removed = adapter
- .generate_account(&secrets, &FixedClock)
- .expect("removed")
- .account()
- .public_key();
- let operation = adapter
- .database()
- .begin_operation(AccountOperationKind::Remove, removed, FixedClock.now())
- .expect("intent");
- secrets.delete(removed).expect("credential deletion");
- adapter
- .database()
- .update_operation(
- operation,
- AccountOperationPhase::CredentialDeleted,
- FixedClock.now(),
- None,
- )
- .expect("phase");
- }
-
- let reopened =
- PersistentAppCore::open(&path, RelayConfiguration::default()).expect("reopen");
- let restored = reopened
- .bootstrap(&secrets, &FixedClock)
- .expect("recover and bootstrap");
- assert_eq!(restored.accounts().len(), 1);
- assert_eq!(restored.selected_account(), Some(first));
- assert_eq!(restored.session(), SessionState::SignedOut);
- assert!(
- reopened
- .database()
- .list_pending_operations()
- .expect("journal")
- .is_empty()
- );
- assert!(
- reopened
- .database()
- .find_account(removed)
- .expect("removed")
- .is_none()
- );
- }
-
- #[test]
- fn bootstrap_skips_keyring_when_journal_empty_and_retains_failed_intent() {
- let empty = PersistentAppCore::in_memory(RelayConfiguration::default()).expect("empty");
- let unavailable = FailureSecretStore::default();
- unavailable.fail_next(SecretStoreOperation::Delete);
- empty
- .bootstrap(&unavailable, &FixedClock)
- .expect("empty journal does not access keyring");
-
- let adapter = PersistentAppCore::in_memory(RelayConfiguration::default()).expect("adapter");
- adapter
- .database()
- .insert_account(&account())
- .expect("account");
- adapter
- .database()
- .save_selected_account(Some(account().public_key()))
- .expect("selection");
- adapter
- .database()
- .begin_operation(
- AccountOperationKind::Remove,
- account().public_key(),
- FixedClock.now(),
- )
- .expect("intent");
- let failing = FailureSecretStore::default();
- failing.fail_next(SecretStoreOperation::Delete);
- let error = adapter
- .bootstrap(&failing, &FixedClock)
- .expect_err("keyring unavailable");
- assert_eq!(error.code(), SafeErrorCode::KeyringUnavailable);
- let pending = adapter
- .database()
- .list_pending_operations()
- .expect("pending");
- assert_eq!(pending.len(), 1);
- assert_eq!(pending[0].phase(), AccountOperationPhase::IntentRecorded);
- }
-}
diff --git a/crates/studio_runtime/src/runtime_actor.rs b/crates/studio_runtime/src/runtime_actor.rs
@@ -1,2276 +0,0 @@
-use std::collections::BTreeMap;
-use std::future::Future;
-use std::num::{NonZeroU64, NonZeroUsize};
-use std::path::Path;
-use std::sync::atomic::{AtomicU64, Ordering};
-use std::sync::{Arc, Mutex};
-use std::time::{Duration, Instant};
-
-use radroots_studio_application::{
- ActorMailbox, AppSnapshot, ChangeSubscriptionId, Clock, CommandContext, CommandEnvelope,
- CommandReceipt, CommandResult, CommandSubmission, DurableRequestId, ForegroundSessionBinding,
- GenerateAccountReceipt, GeneratedKeyRecoveryHandle, GeneratedKeyStage, ImportAccountReceipt,
- LifecycleGate, NostrClient, OrderedSnapshotChanges, ProfileFetchResult, ProfileRefreshPlan,
- RecoveryStageId, RelayConfiguration, RemovalConfirmationToken, RequestId, RuntimeCommandClass,
- RuntimeLifecycle, SecretStore, SessionGeneration, SnapshotChange, SnapshotChangeReceiver,
- SnapshotRevision, StagedGeneratedKey, TaskCorrelation,
-};
-use radroots_studio_domain::{
- AccountIdentity, BindingAvailability, LocalSignerBinding, PublicKey, SafeError, SafeErrorCode,
- SafeMessage, SecretKeyInput,
-};
-use tokio::runtime::Handle;
-use tokio::sync::{mpsc, oneshot, watch};
-
-use crate::blocking::{BlockingExecutionError, BoundedBlockingExecutor};
-use crate::{InstallationIdentity, InstallationIdentitySource, PersistentAppCore};
-
-const DEFAULT_COMMAND_TIMEOUT: Duration = Duration::from_secs(30);
-const DEFAULT_TASK_CAPACITY: usize = 64;
-const DEFAULT_BLOCKING_CAPACITY: usize = 4;
-
-enum RuntimeCommand {
- Snapshot,
- GenerateAccount {
- durable_request: DurableRequestId,
- expected_revision: u64,
- },
- BeginGeneratedKeyStage,
- AcknowledgeGeneratedKeyStage {
- id: RecoveryStageId,
- durable_request: DurableRequestId,
- },
- CancelGeneratedKeyStage,
- ImportSecretKey {
- input: SecretKeyInput,
- durable_request: DurableRequestId,
- expected_revision: u64,
- },
- SelectAccount(PublicKey),
- ActivateAccount(PublicKey),
- SignOut,
- RefreshActiveProfile,
- RequestAccountRemoval(PublicKey),
- ConfirmAccountRemoval {
- token: RemovalConfirmationToken,
- durable_request: DurableRequestId,
- },
- SubscribeChanges(NonZeroUsize),
- UnsubscribeChanges(ChangeSubscriptionId),
- Close,
-}
-
-enum RuntimeCommandValue {
- Snapshot(Box<AppSnapshot>),
- Generated(GenerateAccountReceipt),
- GeneratedKeyStage(GeneratedKeyRecoveryHandle),
- GeneratedKeyStageCancelled(bool),
- Imported(ImportAccountReceipt),
- RemovalRequest(RemovalConfirmationToken),
- Subscription(RuntimeChangeSubscription),
- Unsubscribed(bool),
- Closed,
-}
-
-impl RuntimeCommand {
- const fn class(&self) -> RuntimeCommandClass {
- match self {
- Self::Snapshot | Self::SubscribeChanges(_) | Self::UnsubscribeChanges(_) => {
- RuntimeCommandClass::Observe
- }
- Self::GenerateAccount { .. }
- | Self::BeginGeneratedKeyStage
- | Self::AcknowledgeGeneratedKeyStage { .. }
- | Self::ImportSecretKey { .. }
- | Self::ActivateAccount(_)
- | Self::ConfirmAccountRemoval { .. } => RuntimeCommandClass::UseCredential,
- Self::SelectAccount(_)
- | Self::SignOut
- | Self::RequestAccountRemoval(_)
- | Self::CancelGeneratedKeyStage => RuntimeCommandClass::MutateLocalState,
- Self::RefreshActiveProfile => RuntimeCommandClass::UseRelay,
- Self::Close => RuntimeCommandClass::Shutdown,
- }
- }
-
- const fn resolves_revision_through_durable_replay(&self) -> bool {
- matches!(
- self,
- Self::GenerateAccount { .. } | Self::ImportSecretKey { .. }
- )
- }
-}
-
-struct RuntimeActor {
- adapter: Arc<PersistentAppCore>,
- secrets: Arc<dyn SecretStore>,
- clock: Arc<dyn Clock>,
- nostr: Arc<dyn NostrClient>,
- lifecycle: Arc<Mutex<LifecycleGate>>,
- runtime: Handle,
- blocking: BoundedBlockingExecutor,
- session_generation: SessionGeneration,
- published_session_generation: Arc<AtomicU64>,
- profile_tasks: BTreeMap<RequestId, PendingProfileTask>,
- changes: OrderedSnapshotChanges,
- published_foreground_session: Arc<Mutex<Option<ForegroundSessionBinding>>>,
- generated_key_stage: GeneratedKeyStage,
-}
-
-struct PendingProfileTask {
- correlation: TaskCorrelation,
- plan: ProfileRefreshPlan,
- deadline: Instant,
- reply: oneshot::Sender<CommandReceipt<RuntimeCommandValue>>,
- handle: tokio::task::JoinHandle<()>,
-}
-
-struct ProfileCompletion {
- request_id: RequestId,
- result: Result<ProfileFetchResult, SafeError>,
-}
-
-#[derive(Clone)]
-pub struct RuntimeActorHandle {
- mailbox: ActorMailbox<RuntimeCommand, RuntimeCommandValue>,
- adapter: Arc<PersistentAppCore>,
- lifecycle: Arc<Mutex<LifecycleGate>>,
- next_request: Arc<AtomicU64>,
- session_generation: Arc<AtomicU64>,
- foreground_session: Arc<Mutex<Option<ForegroundSessionBinding>>>,
- installation_identity: InstallationIdentity,
- runtime: Handle,
- actor_task: Arc<Mutex<Option<tokio::task::JoinHandle<()>>>>,
- actor_exit: watch::Receiver<bool>,
-}
-
-#[derive(Clone)]
-pub struct RuntimeDependencies {
- secrets: Arc<dyn SecretStore>,
- clock: Arc<dyn Clock>,
- nostr: Arc<dyn NostrClient>,
- installation_source: Arc<dyn InstallationIdentitySource>,
-}
-
-impl RuntimeDependencies {
- #[must_use]
- pub fn new(
- secrets: Arc<dyn SecretStore>,
- clock: Arc<dyn Clock>,
- nostr: Arc<dyn NostrClient>,
- installation_source: Arc<dyn InstallationIdentitySource>,
- ) -> Self {
- Self {
- secrets,
- clock,
- nostr,
- installation_source,
- }
- }
-}
-
-pub struct RuntimeChangeSubscription {
- id: ChangeSubscriptionId,
- receiver: SnapshotChangeReceiver,
-}
-
-impl RuntimeChangeSubscription {
- #[must_use]
- pub const fn id(&self) -> ChangeSubscriptionId {
- self.id
- }
-
- pub async fn receive(&mut self) -> Option<SnapshotChange> {
- self.receiver.receive().await
- }
-}
-
-impl RuntimeActorHandle {
- /// Opens, migrates, recovers, and starts one actor-owned file-backed runtime.
- ///
- /// # Errors
- ///
- /// Returns a safe storage, recovery, or lifecycle error before the actor is
- /// published when opening cannot reach ready state.
- pub async fn open(
- path: &Path,
- relay_configuration: RelayConfiguration,
- dependencies: RuntimeDependencies,
- capacity: NonZeroUsize,
- runtime: &Handle,
- ) -> Result<Self, SafeError> {
- let blocking = BoundedBlockingExecutor::new(DEFAULT_BLOCKING_CAPACITY, runtime);
- let path = path.to_path_buf();
- let adapter = blocking
- .execute(Instant::now() + DEFAULT_COMMAND_TIMEOUT, move || {
- PersistentAppCore::open(&path, relay_configuration)
- })
- .await
- .map_err(blocking_execution_failed)??;
- Self::start(adapter, dependencies, capacity, runtime, blocking).await
- }
-
- /// Starts one isolated actor-owned in-memory runtime for tests.
- ///
- /// # Errors
- ///
- /// Returns a safe storage, recovery, or lifecycle error before publication.
- pub async fn in_memory(
- relay_configuration: RelayConfiguration,
- dependencies: RuntimeDependencies,
- capacity: NonZeroUsize,
- runtime: &Handle,
- ) -> Result<Self, SafeError> {
- let blocking = BoundedBlockingExecutor::new(DEFAULT_BLOCKING_CAPACITY, runtime);
- let adapter = blocking
- .execute(Instant::now() + DEFAULT_COMMAND_TIMEOUT, move || {
- PersistentAppCore::in_memory(relay_configuration)
- })
- .await
- .map_err(blocking_execution_failed)??;
- Self::start(adapter, dependencies, capacity, runtime, blocking).await
- }
-
- async fn start(
- adapter: PersistentAppCore,
- dependencies: RuntimeDependencies,
- capacity: NonZeroUsize,
- runtime: &Handle,
- blocking: BoundedBlockingExecutor,
- ) -> Result<Self, SafeError> {
- let mut gate = LifecycleGate::opening();
- gate.begin_compatibility_check()?;
- gate.compatibility_accepted()?;
- gate.ownership_acquired()?;
- gate.migration_complete()?;
- let RuntimeDependencies {
- secrets,
- clock,
- nostr,
- installation_source,
- } = dependencies;
- let adapter = Arc::new(adapter);
- let bootstrap_adapter = Arc::clone(&adapter);
- let bootstrap_secrets = Arc::clone(&secrets);
- let bootstrap_clock = Arc::clone(&clock);
- let installation_identity = blocking
- .execute(Instant::now() + DEFAULT_COMMAND_TIMEOUT, move || {
- bootstrap_adapter
- .bootstrap(bootstrap_secrets.as_ref(), bootstrap_clock.as_ref())?;
- bootstrap_adapter.initialize_installation_identity(installation_source.as_ref())
- })
- .await
- .map_err(blocking_execution_failed)??;
- gate.recovery_complete()?;
-
- let lifecycle = Arc::new(Mutex::new(gate));
- let (mailbox, receiver) = ActorMailbox::bounded(capacity);
- let session_generation = Arc::new(AtomicU64::new(SessionGeneration::initial().value()));
- let foreground_session = Arc::new(Mutex::new(None));
- let changes = OrderedSnapshotChanges::new(adapter.core().snapshot());
- let actor = RuntimeActor {
- adapter: Arc::clone(&adapter),
- secrets,
- clock,
- nostr,
- lifecycle: Arc::clone(&lifecycle),
- runtime: runtime.clone(),
- blocking,
- session_generation: SessionGeneration::initial(),
- published_session_generation: Arc::clone(&session_generation),
- profile_tasks: BTreeMap::new(),
- changes,
- published_foreground_session: Arc::clone(&foreground_session),
- generated_key_stage: GeneratedKeyStage::default(),
- };
- let (actor_exit_sender, actor_exit) = watch::channel(false);
- let actor_task = runtime.spawn(async move {
- actor.run(receiver).await;
- let _ = actor_exit_sender.send(true);
- });
- Ok(Self {
- mailbox,
- adapter,
- lifecycle,
- next_request: Arc::new(AtomicU64::new(1)),
- session_generation,
- foreground_session,
- installation_identity,
- runtime: runtime.clone(),
- actor_task: Arc::new(Mutex::new(Some(actor_task))),
- actor_exit,
- })
- }
-
- #[must_use]
- pub fn lifecycle(&self) -> RuntimeLifecycle {
- self.lifecycle
- .lock()
- .unwrap_or_else(std::sync::PoisonError::into_inner)
- .lifecycle()
- }
-
- #[must_use]
- pub fn session_generation(&self) -> SessionGeneration {
- SessionGeneration::from_value(self.session_generation.load(Ordering::Acquire))
- }
-
- #[must_use]
- pub fn foreground_session(&self) -> Option<ForegroundSessionBinding> {
- self.foreground_session
- .lock()
- .unwrap_or_else(std::sync::PoisonError::into_inner)
- .clone()
- }
-
- #[must_use]
- pub fn installation_identity(&self) -> &InstallationIdentity {
- &self.installation_identity
- }
-
- #[must_use]
- pub fn snapshot(&self) -> AppSnapshot {
- self.adapter.core().snapshot()
- }
-
- /// Returns the ready snapshot through the actor command boundary.
- ///
- /// # Errors
- ///
- /// Returns a typed safe actor error.
- pub async fn bootstrap(&self) -> Result<AppSnapshot, SafeError> {
- Self::expect_snapshot(self.dispatch(RuntimeCommand::Snapshot, None).await?)
- }
-
- /// Generates one account through the serialized actor boundary.
- ///
- /// # Errors
- ///
- /// Returns a safe account, storage, keyring, timeout, or actor error.
- pub async fn generate_account(
- &self,
- request: DurableRequestId,
- expected_revision: SnapshotRevision,
- timeout: Duration,
- ) -> Result<GenerateAccountReceipt, SafeError> {
- match self
- .dispatch_durable(
- RuntimeCommand::GenerateAccount {
- durable_request: request,
- expected_revision: expected_revision.value(),
- },
- expected_revision,
- timeout,
- )
- .await?
- {
- RuntimeCommandValue::Generated(receipt) => Ok(receipt),
- _ => Err(invalid_actor_response()),
- }
- }
-
- /// Begins the only actor-owned generated-key recovery stage.
- ///
- /// # Errors
- ///
- /// Returns a safe conflict, timeout, key-generation, or actor error.
- pub async fn begin_generated_key_stage(&self) -> Result<GeneratedKeyRecoveryHandle, SafeError> {
- match self
- .dispatch(RuntimeCommand::BeginGeneratedKeyStage, None)
- .await?
- {
- RuntimeCommandValue::GeneratedKeyStage(view) => Ok(view),
- _ => Err(invalid_actor_response()),
- }
- }
-
- /// Acknowledges recovery and commits the staged account and credential once.
- ///
- /// # Errors
- ///
- /// Returns a safe unavailable, conflict, keyring, storage, timeout, or actor error.
- pub async fn acknowledge_generated_key_stage(
- &self,
- id: RecoveryStageId,
- request: DurableRequestId,
- expected_revision: SnapshotRevision,
- timeout: Duration,
- ) -> Result<AppSnapshot, SafeError> {
- let value = self
- .dispatch_durable(
- RuntimeCommand::AcknowledgeGeneratedKeyStage {
- id,
- durable_request: request,
- },
- expected_revision,
- timeout,
- )
- .await?;
- Self::expect_snapshot(value)
- }
-
- /// Cancels and zeroizes the active generated-key stage, if present.
- ///
- /// # Errors
- ///
- /// Returns a safe timeout or actor error.
- pub async fn cancel_generated_key_stage(&self) -> Result<bool, SafeError> {
- match self
- .dispatch(RuntimeCommand::CancelGeneratedKeyStage, None)
- .await?
- {
- RuntimeCommandValue::GeneratedKeyStageCancelled(cancelled) => Ok(cancelled),
- _ => Err(invalid_actor_response()),
- }
- }
-
- /// Imports one account through the serialized actor boundary.
- ///
- /// # Errors
- ///
- /// Returns a safe account, storage, keyring, timeout, or actor error.
- pub async fn import_secret_key(
- &self,
- request: DurableRequestId,
- expected_revision: SnapshotRevision,
- input: SecretKeyInput,
- timeout: Duration,
- ) -> Result<ImportAccountReceipt, SafeError> {
- match self
- .dispatch_durable(
- RuntimeCommand::ImportSecretKey {
- input,
- durable_request: request,
- expected_revision: expected_revision.value(),
- },
- expected_revision,
- timeout,
- )
- .await?
- {
- RuntimeCommandValue::Imported(receipt) => Ok(receipt),
- _ => Err(invalid_actor_response()),
- }
- }
-
- /// Selects one account through the serialized actor boundary.
- ///
- /// # Errors
- ///
- /// Returns a safe account, storage, timeout, or actor error.
- pub async fn select_account(&self, public_key: PublicKey) -> Result<AppSnapshot, SafeError> {
- let value = self
- .dispatch(RuntimeCommand::SelectAccount(public_key), None)
- .await?;
- Self::expect_snapshot(value)
- }
-
- /// Activates one account through the serialized actor boundary.
- ///
- /// # Errors
- ///
- /// Returns a safe account, credential, storage, timeout, or actor error.
- pub async fn activate_account(&self, public_key: PublicKey) -> Result<AppSnapshot, SafeError> {
- let value = self
- .dispatch(RuntimeCommand::ActivateAccount(public_key), None)
- .await?;
- Self::expect_snapshot(value)
- }
-
- /// Signs out through the serialized actor boundary.
- ///
- /// # Errors
- ///
- /// Returns a safe timeout or actor error.
- pub async fn sign_out(&self) -> Result<AppSnapshot, SafeError> {
- let value = self.dispatch(RuntimeCommand::SignOut, None).await?;
- Self::expect_snapshot(value)
- }
-
- /// Refreshes the active profile through the serialized actor boundary.
- ///
- /// # Errors
- ///
- /// Returns a safe relay, storage, timeout, or actor error.
- pub async fn refresh_active_profile(&self) -> Result<AppSnapshot, SafeError> {
- let value = self
- .dispatch(RuntimeCommand::RefreshActiveProfile, None)
- .await?;
- Self::expect_snapshot(value)
- }
-
- /// Creates one removal request through the serialized actor boundary.
- ///
- /// # Errors
- ///
- /// Returns a safe account, timeout, or actor error.
- pub async fn request_account_removal(
- &self,
- public_key: PublicKey,
- ) -> Result<RemovalConfirmationToken, SafeError> {
- match self
- .dispatch(RuntimeCommand::RequestAccountRemoval(public_key), None)
- .await?
- {
- RuntimeCommandValue::RemovalRequest(token) => Ok(token),
- _ => Err(invalid_actor_response()),
- }
- }
-
- /// Confirms one removal through the serialized actor boundary.
- ///
- /// # Errors
- ///
- /// Returns a safe account, credential, storage, timeout, or actor error.
- pub async fn confirm_account_removal(
- &self,
- token: RemovalConfirmationToken,
- request: DurableRequestId,
- expected_revision: SnapshotRevision,
- timeout: Duration,
- ) -> Result<AppSnapshot, SafeError> {
- let value = self
- .dispatch_durable(
- RuntimeCommand::ConfirmAccountRemoval {
- token,
- durable_request: request,
- },
- expected_revision,
- timeout,
- )
- .await?;
- Self::expect_snapshot(value)
- }
-
- /// Closes command admission and cancels supervised work.
- ///
- /// # Errors
- ///
- /// Returns a safe timeout or actor error. Repeated calls return closed.
- pub async fn close(&self) -> Result<(), SafeError> {
- self.close_with_timeout(DEFAULT_COMMAND_TIMEOUT).await
- }
-
- /// Closes the runtime within the supplied command deadline.
- ///
- /// # Errors
- ///
- /// Returns a safe timeout or actor error. An expired queued close cannot
- /// later change runtime state.
- pub async fn close_with_timeout(&self, timeout: Duration) -> Result<(), SafeError> {
- let deadline = Instant::now() + timeout;
- if matches!(self.lifecycle(), RuntimeLifecycle::Closed) {
- return self.await_actor_exit(deadline).await;
- }
- let raw_request = self.next_request.fetch_add(1, Ordering::Relaxed);
- let request_id = RequestId::new(raw_request).ok_or_else(request_space_exhausted)?;
- let command_result = match self
- .dispatch_with_deadline(RuntimeCommand::Close, None, request_id, deadline)
- .await
- {
- Ok(RuntimeCommandValue::Closed) => Ok(()),
- Ok(_) => Err(invalid_actor_response()),
- Err(error) => Err(error),
- };
- let exit_result = self.await_actor_exit(deadline).await;
- if matches!(self.lifecycle(), RuntimeLifecycle::Closed) {
- exit_result
- } else {
- command_result.and(exit_result)
- }
- }
-
- async fn await_actor_exit(&self, deadline: Instant) -> Result<(), SafeError> {
- let mut actor_exit = self.actor_exit.clone();
- if !*actor_exit.borrow() {
- let remaining = deadline.saturating_duration_since(Instant::now());
- self.timeout(remaining, actor_exit.wait_for(|exited| *exited))
- .await
- .map_err(|_| command_timed_out())?
- .map_err(|_| runtime_closed())?;
- }
- let actor_task = self
- .actor_task
- .lock()
- .unwrap_or_else(std::sync::PoisonError::into_inner)
- .take();
- if let Some(actor_task) = actor_task {
- let remaining = deadline.saturating_duration_since(Instant::now());
- self.timeout(remaining, actor_task)
- .await
- .map_err(|_| command_timed_out())?
- .map_err(|_| runtime_closed())?;
- }
- Ok(())
- }
-
- /// Atomically registers a bounded ordered change consumer with its initial snapshot.
- ///
- /// # Errors
- ///
- /// Returns a safe actor or subscription error.
- pub async fn subscribe_changes(
- &self,
- capacity: NonZeroUsize,
- ) -> Result<RuntimeChangeSubscription, SafeError> {
- match self
- .dispatch(RuntimeCommand::SubscribeChanges(capacity), None)
- .await?
- {
- RuntimeCommandValue::Subscription(subscription) => Ok(subscription),
- _ => Err(invalid_actor_response()),
- }
- }
-
- /// Removes a change consumer through the serialized actor boundary.
- ///
- /// # Errors
- ///
- /// Returns a safe actor error.
- pub async fn unsubscribe_changes(&self, id: ChangeSubscriptionId) -> Result<bool, SafeError> {
- match self
- .dispatch(RuntimeCommand::UnsubscribeChanges(id), None)
- .await?
- {
- RuntimeCommandValue::Unsubscribed(removed) => Ok(removed),
- _ => Err(invalid_actor_response()),
- }
- }
-
- async fn dispatch(
- &self,
- command: RuntimeCommand,
- expected_revision: Option<SnapshotRevision>,
- ) -> Result<RuntimeCommandValue, SafeError> {
- let raw_request = self.next_request.fetch_add(1, Ordering::Relaxed);
- let request_id = RequestId::new(raw_request).ok_or_else(request_space_exhausted)?;
- self.dispatch_with_deadline(
- command,
- expected_revision,
- request_id,
- Instant::now() + DEFAULT_COMMAND_TIMEOUT,
- )
- .await
- }
-
- async fn dispatch_durable(
- &self,
- command: RuntimeCommand,
- expected_revision: SnapshotRevision,
- timeout: Duration,
- ) -> Result<RuntimeCommandValue, SafeError> {
- let raw_request = self.next_request.fetch_add(1, Ordering::Relaxed);
- let request_id = RequestId::new(raw_request).ok_or_else(request_space_exhausted)?;
- self.dispatch_with_deadline(
- command,
- Some(expected_revision),
- request_id,
- Instant::now() + timeout,
- )
- .await
- }
-
- async fn dispatch_with_deadline(
- &self,
- command: RuntimeCommand,
- expected_revision: Option<SnapshotRevision>,
- request_id: RequestId,
- deadline: Instant,
- ) -> Result<RuntimeCommandValue, SafeError> {
- let context = CommandContext::new(request_id, expected_revision, deadline);
- let receipt = match self.mailbox.submit(context, command) {
- CommandSubmission::Accepted(ticket) => {
- let remaining = deadline.saturating_duration_since(Instant::now());
- match self.timeout(remaining, ticket.receipt()).await {
- Ok(receipt) => receipt,
- Err(_) => CommandReceipt::new(request_id, CommandResult::TimedOut),
- }
- }
- CommandSubmission::Rejected(receipt) => receipt,
- };
- match receipt.into_result() {
- CommandResult::Completed(value) => Ok(value),
- CommandResult::Conflicted { .. } => Err(command_conflicted()),
- CommandResult::Rejected(_) => Err(command_rejected()),
- CommandResult::TimedOut => Err(command_timed_out()),
- CommandResult::Closed => Err(runtime_closed()),
- CommandResult::Failed(error) => Err(error),
- }
- }
-
- fn timeout<F>(&self, duration: Duration, future: F) -> tokio::time::Timeout<F>
- where
- F: Future,
- {
- let _guard = self.runtime.enter();
- tokio::time::timeout(duration, future)
- }
-
- #[cfg(test)]
- async fn import_secret_key_test(
- &self,
- input: SecretKeyInput,
- ) -> Result<ImportAccountReceipt, SafeError> {
- let request_number = self.next_request.fetch_add(1, Ordering::Relaxed);
- self.import_secret_key(
- DurableRequestId::parse(format!("test:import:{request_number}"))?,
- self.snapshot().revision(),
- input,
- DEFAULT_COMMAND_TIMEOUT,
- )
- .await
- }
-
- #[cfg(test)]
- async fn acknowledge_generated_key_stage_test(
- &self,
- id: RecoveryStageId,
- ) -> Result<AppSnapshot, SafeError> {
- let request_number = self.next_request.fetch_add(1, Ordering::Relaxed);
- self.acknowledge_generated_key_stage(
- id,
- DurableRequestId::parse(format!("test:generate:{request_number}"))?,
- self.snapshot().revision(),
- DEFAULT_COMMAND_TIMEOUT,
- )
- .await
- }
-
- #[cfg(test)]
- async fn confirm_account_removal_test(
- &self,
- token: RemovalConfirmationToken,
- ) -> Result<AppSnapshot, SafeError> {
- let request_number = self.next_request.fetch_add(1, Ordering::Relaxed);
- self.confirm_account_removal(
- token,
- DurableRequestId::parse(format!("test:remove:{request_number}"))?,
- self.snapshot().revision(),
- DEFAULT_COMMAND_TIMEOUT,
- )
- .await
- }
-
- #[cfg(test)]
- async fn import_secret_key_with_timeout(
- &self,
- input: SecretKeyInput,
- timeout: Duration,
- ) -> Result<ImportAccountReceipt, SafeError> {
- let raw_request = self.next_request.fetch_add(1, Ordering::Relaxed);
- let request_id = RequestId::new(raw_request).ok_or_else(request_space_exhausted)?;
- let expected_revision = self.adapter.core().snapshot().revision();
- let durable_request = DurableRequestId::parse(format!("test:timeout:{raw_request}"))?;
- match self
- .dispatch_with_deadline(
- RuntimeCommand::ImportSecretKey {
- input,
- durable_request,
- expected_revision: expected_revision.value(),
- },
- Some(expected_revision),
- request_id,
- Instant::now() + timeout,
- )
- .await?
- {
- RuntimeCommandValue::Imported(receipt) => Ok(receipt),
- _ => Err(invalid_actor_response()),
- }
- }
-
- fn expect_snapshot(value: RuntimeCommandValue) -> Result<AppSnapshot, SafeError> {
- match value {
- RuntimeCommandValue::Snapshot(snapshot) => Ok(*snapshot),
- _ => Err(invalid_actor_response()),
- }
- }
-}
-
-impl RuntimeActor {
- async fn run(
- mut self,
- mut receiver: mpsc::Receiver<CommandEnvelope<RuntimeCommand, RuntimeCommandValue>>,
- ) {
- let (completion_sender, mut completions) = mpsc::channel(DEFAULT_TASK_CAPACITY);
- loop {
- tokio::select! {
- envelope = receiver.recv() => {
- let Some(envelope) = envelope else {
- break;
- };
- if !self.handle_command(envelope, &completion_sender).await {
- break;
- }
- }
- completion = completions.recv(), if !self.profile_tasks.is_empty() => {
- if let Some(completion) = completion {
- self.complete_profile_task(completion).await;
- }
- }
- }
- }
- self.cancel_profile_tasks(None).await;
- }
-
- async fn handle_command(
- &mut self,
- envelope: CommandEnvelope<RuntimeCommand, RuntimeCommandValue>,
- completion_sender: &mpsc::Sender<ProfileCompletion>,
- ) -> bool {
- let (context, command, reply) = envelope.into_parts();
- if let Some(result) = self.preflight(context, &command) {
- let _ = reply.send(CommandReceipt::new(context.request_id(), result));
- return true;
- }
- if matches!(command, RuntimeCommand::RefreshActiveProfile) {
- self.start_profile_task(context, reply, completion_sender.clone())
- .await;
- return true;
- }
- if matches!(command, RuntimeCommand::Close) {
- let result = self.close_actor().await;
- let closed = matches!(result, CommandResult::Completed(_));
- let _ = reply.send(CommandReceipt::new(context.request_id(), result));
- return !closed;
- }
- let changes_session = matches!(
- command,
- RuntimeCommand::ActivateAccount(_)
- | RuntimeCommand::SignOut
- | RuntimeCommand::ConfirmAccountRemoval { .. }
- );
- let begins_generated_recovery = matches!(&command, RuntimeCommand::BeginGeneratedKeyStage);
- let result = self.execute_command(context, command).await;
- if begins_generated_recovery && matches!(&result, CommandResult::Completed(_)) {
- let snapshot = self.adapter.core().snapshot();
- self.cancel_profile_tasks(Some(&snapshot)).await;
- }
- if changes_session && matches!(result, CommandResult::Completed(_)) {
- self.advance_session_generation().await;
- self.synchronize_foreground_session();
- }
- if matches!(result, CommandResult::Completed(_)) {
- self.changes.publish(self.adapter.core().snapshot());
- }
- let _ = reply.send(CommandReceipt::new(context.request_id(), result));
- true
- }
-
- fn preflight(
- &self,
- context: CommandContext,
- command: &RuntimeCommand,
- ) -> Option<CommandResult<RuntimeCommandValue>> {
- if context.is_expired(Instant::now()) {
- return Some(CommandResult::TimedOut);
- }
- let lifecycle = self
- .lifecycle
- .lock()
- .unwrap_or_else(std::sync::PoisonError::into_inner)
- .to_owned();
- if matches!(lifecycle.lifecycle(), RuntimeLifecycle::Closed) {
- return Some(CommandResult::Closed);
- }
- if !lifecycle.allows(command.class()) {
- return Some(CommandResult::Failed(command_unavailable()));
- }
- if self.generated_key_stage.pending().is_some()
- && !matches!(
- command,
- RuntimeCommand::Snapshot
- | RuntimeCommand::AcknowledgeGeneratedKeyStage { .. }
- | RuntimeCommand::CancelGeneratedKeyStage
- | RuntimeCommand::SubscribeChanges(_)
- | RuntimeCommand::UnsubscribeChanges(_)
- | RuntimeCommand::Close
- )
- {
- return Some(CommandResult::Failed(generated_recovery_route_active()));
- }
- let current_revision = self.adapter.core().snapshot().revision();
- if !command.resolves_revision_through_durable_replay()
- && context
- .expected_revision()
- .is_some_and(|expected| expected != current_revision)
- {
- return Some(CommandResult::Conflicted { current_revision });
- }
- None
- }
-
- async fn execute_command(
- &mut self,
- context: CommandContext,
- command: RuntimeCommand,
- ) -> CommandResult<RuntimeCommandValue> {
- let result = match command {
- RuntimeCommand::Snapshot => Ok(RuntimeCommandValue::Snapshot(Box::new(
- self.adapter.core().snapshot(),
- ))),
- RuntimeCommand::GenerateAccount {
- durable_request,
- expected_revision,
- } => {
- self.run_blocking(context.deadline(), move |adapter, secrets, clock| {
- adapter
- .generate_account_durable(
- &durable_request,
- expected_revision,
- secrets.as_ref(),
- clock.as_ref(),
- )
- .map(RuntimeCommandValue::Generated)
- })
- .await
- }
- RuntimeCommand::BeginGeneratedKeyStage => {
- match NonZeroU64::new(context.request_id().get()).map(RecoveryStageId::new) {
- Some(stage_id) => self
- .generated_key_stage
- .begin(
- self.adapter.key_material(),
- stage_id,
- self.adapter.core().snapshot().revision().value(),
- self.clock.now(),
- )
- .map(RuntimeCommandValue::GeneratedKeyStage),
- None => Err(request_space_exhausted()),
- }
- }
- RuntimeCommand::AcknowledgeGeneratedKeyStage {
- id,
- durable_request,
- } => match self.generated_key_stage.take(id, self.clock.now()) {
- Ok(staged) => {
- self.run_blocking(context.deadline(), move |adapter, secrets, clock| {
- commit_generated_key_stage(
- adapter.as_ref(),
- secrets.as_ref(),
- clock.as_ref(),
- &durable_request,
- staged,
- )
- })
- .await
- }
- Err(error) => Err(error),
- },
- RuntimeCommand::CancelGeneratedKeyStage => Ok(
- RuntimeCommandValue::GeneratedKeyStageCancelled(self.generated_key_stage.cancel()),
- ),
- RuntimeCommand::ImportSecretKey {
- input,
- durable_request,
- expected_revision,
- } => {
- self.run_blocking(context.deadline(), move |adapter, secrets, clock| {
- adapter
- .import_secret_key_durable(
- &durable_request,
- expected_revision,
- input,
- secrets.as_ref(),
- clock.as_ref(),
- )
- .map(RuntimeCommandValue::Imported)
- })
- .await
- }
- RuntimeCommand::SelectAccount(public_key) => {
- self.run_blocking(context.deadline(), move |adapter, _, _| {
- adapter
- .select_account(public_key)
- .map(Box::new)
- .map(RuntimeCommandValue::Snapshot)
- })
- .await
- }
- RuntimeCommand::ActivateAccount(public_key) => {
- self.run_blocking(context.deadline(), move |adapter, secrets, clock| {
- adapter
- .activate_account(public_key, secrets.as_ref(), clock.as_ref())
- .map(Box::new)
- .map(RuntimeCommandValue::Snapshot)
- })
- .await
- }
- RuntimeCommand::SignOut => self
- .adapter
- .sign_out()
- .map(Box::new)
- .map(RuntimeCommandValue::Snapshot),
- RuntimeCommand::RequestAccountRemoval(public_key) => self
- .adapter
- .request_account_removal(public_key, self.clock.as_ref())
- .map(RuntimeCommandValue::RemovalRequest),
- RuntimeCommand::ConfirmAccountRemoval {
- token,
- durable_request,
- } => {
- self.run_blocking(context.deadline(), move |adapter, secrets, clock| {
- adapter
- .confirm_account_removal_durable(
- &durable_request,
- token,
- secrets.as_ref(),
- clock.as_ref(),
- )
- .map(Box::new)
- .map(RuntimeCommandValue::Snapshot)
- })
- .await
- }
- RuntimeCommand::SubscribeChanges(capacity) => self
- .changes
- .subscribe(capacity)
- .map(|(id, receiver)| {
- RuntimeCommandValue::Subscription(RuntimeChangeSubscription { id, receiver })
- })
- .ok_or_else(observer_registration_failed),
- RuntimeCommand::UnsubscribeChanges(id) => Ok(RuntimeCommandValue::Unsubscribed(
- self.changes.unsubscribe(id),
- )),
- RuntimeCommand::Close | RuntimeCommand::RefreshActiveProfile => {
- Err(invalid_actor_response())
- }
- };
- result.map_or_else(CommandResult::Failed, CommandResult::Completed)
- }
-
- async fn run_blocking<F>(
- &self,
- deadline: Instant,
- operation: F,
- ) -> Result<RuntimeCommandValue, SafeError>
- where
- F: FnOnce(
- Arc<PersistentAppCore>,
- Arc<dyn SecretStore>,
- Arc<dyn Clock>,
- ) -> Result<RuntimeCommandValue, SafeError>
- + Send
- + 'static,
- {
- let adapter = Arc::clone(&self.adapter);
- let secrets = Arc::clone(&self.secrets);
- let clock = Arc::clone(&self.clock);
- self.blocking
- .execute(deadline, move || operation(adapter, secrets, clock))
- .await
- .map_err(blocking_execution_failed)?
- }
-
- async fn start_profile_task(
- &mut self,
- context: CommandContext,
- reply: oneshot::Sender<CommandReceipt<RuntimeCommandValue>>,
- completion_sender: mpsc::Sender<ProfileCompletion>,
- ) {
- let foreground = self
- .published_foreground_session
- .lock()
- .unwrap_or_else(std::sync::PoisonError::into_inner)
- .clone();
- let plan = match self.adapter.core().begin_profile_refresh() {
- Ok(Some(plan)) => plan,
- Ok(None) => {
- let _ = reply.send(CommandReceipt::new(
- context.request_id(),
- CommandResult::Completed(RuntimeCommandValue::Snapshot(Box::new(
- self.adapter.core().snapshot(),
- ))),
- ));
- return;
- }
- Err(error) => {
- let _ = reply.send(CommandReceipt::new(
- context.request_id(),
- CommandResult::Failed(error),
- ));
- return;
- }
- };
- let Some(foreground) = foreground.filter(|binding| {
- binding.identity().public_key() == plan.public_key()
- && binding.generation() == self.session_generation
- }) else {
- let _ = reply.send(CommandReceipt::new(
- context.request_id(),
- CommandResult::Failed(stale_profile_binding()),
- ));
- return;
- };
- let correlation = TaskCorrelation::new(
- context.request_id(),
- plan.public_key(),
- foreground.signer(),
- plan.expected_revision(),
- self.session_generation,
- );
- let client = Arc::clone(&self.nostr);
- let relays = plan.relays().to_vec();
- let request_id = context.request_id();
- let handle = self.runtime.spawn(async move {
- let result = client
- .fetch_profile(correlation.account(), &relays, context.deadline())
- .await;
- let _ = completion_sender
- .send(ProfileCompletion { request_id, result })
- .await;
- });
- let previous = self.profile_tasks.insert(
- request_id,
- PendingProfileTask {
- correlation,
- plan,
- deadline: context.deadline(),
- reply,
- handle,
- },
- );
- if let Some(previous) = previous {
- self.lifecycle
- .lock()
- .unwrap_or_else(std::sync::PoisonError::into_inner)
- .fail(request_space_exhausted());
- previous.handle.abort();
- let _ = previous.handle.await;
- let _ = previous.reply.send(CommandReceipt::new(
- previous.correlation.request_id(),
- CommandResult::Failed(request_space_exhausted()),
- ));
- self.cancel_profile_tasks(None).await;
- }
- }
-
- async fn close_actor(&mut self) -> CommandResult<RuntimeCommandValue> {
- let transition = (|| {
- let mut lifecycle = self
- .lifecycle
- .lock()
- .unwrap_or_else(std::sync::PoisonError::into_inner);
- lifecycle.begin_shutdown()?;
- lifecycle.finish_shutdown()
- })();
- match transition {
- Ok(()) => {
- self.generated_key_stage.cancel();
- self.cancel_profile_tasks(None).await;
- self.changes.close();
- *self
- .published_foreground_session
- .lock()
- .unwrap_or_else(std::sync::PoisonError::into_inner) = None;
- CommandResult::Completed(RuntimeCommandValue::Closed)
- }
- Err(error) => CommandResult::Failed(error),
- }
- }
-
- async fn complete_profile_task(&mut self, completion: ProfileCompletion) {
- let Some(task) = self.profile_tasks.remove(&completion.request_id) else {
- return;
- };
- let _ = task.handle.await;
- let current = self.adapter.core().snapshot();
- let foreground = self
- .published_foreground_session
- .lock()
- .unwrap_or_else(std::sync::PoisonError::into_inner)
- .clone();
- let correlated = task.correlation.session_generation() == self.session_generation
- && foreground.is_some_and(|binding| {
- binding.generation() == task.correlation.session_generation()
- && binding.identity().public_key() == task.correlation.account()
- && binding.signer() == task.correlation.binding()
- })
- && current
- .active_account()
- .is_some_and(|active| active.account().public_key() == task.correlation.account());
- let result = if correlated {
- let plan = task.plan.clone();
- let completed = self
- .run_blocking(task.deadline, move |adapter, _, clock| {
- adapter
- .core()
- .complete_profile_refresh(
- &plan,
- completion.result,
- adapter.database(),
- clock.as_ref(),
- )
- .map(Box::new)
- .map(RuntimeCommandValue::Snapshot)
- })
- .await;
- completed.map_or_else(CommandResult::Failed, CommandResult::Completed)
- } else {
- CommandResult::Completed(RuntimeCommandValue::Snapshot(Box::new(current)))
- };
- if matches!(result, CommandResult::Completed(_)) {
- self.changes.publish(self.adapter.core().snapshot());
- }
- let _ = task
- .reply
- .send(CommandReceipt::new(task.correlation.request_id(), result));
- }
-
- async fn advance_session_generation(&mut self) {
- let Some(next) = self.session_generation.next() else {
- self.lifecycle
- .lock()
- .unwrap_or_else(std::sync::PoisonError::into_inner)
- .fail(request_space_exhausted());
- self.cancel_profile_tasks(None).await;
- return;
- };
- self.session_generation = next;
- self.published_session_generation
- .store(next.value(), Ordering::Release);
- let snapshot = self.adapter.core().snapshot();
- self.cancel_profile_tasks(Some(&snapshot)).await;
- }
-
- fn synchronize_foreground_session(&mut self) {
- let session = self
- .adapter
- .core()
- .snapshot()
- .active_account()
- .map(|active| {
- let public_key = active.account().public_key();
- ForegroundSessionBinding::new(
- AccountIdentity::derive(public_key)?,
- LocalSignerBinding::new(public_key, BindingAvailability::Available),
- self.session_generation,
- )
- });
- let session = match session.transpose() {
- Ok(session) => session,
- Err(error) => {
- self.lifecycle
- .lock()
- .unwrap_or_else(std::sync::PoisonError::into_inner)
- .fail(error);
- None
- }
- };
- *self
- .published_foreground_session
- .lock()
- .unwrap_or_else(std::sync::PoisonError::into_inner) = session;
- }
-
- async fn cancel_profile_tasks(&mut self, snapshot: Option<&AppSnapshot>) {
- let tasks = std::mem::take(&mut self.profile_tasks);
- for (_, task) in tasks {
- task.handle.abort();
- let _ = task.handle.await;
- let receipt_result = snapshot.map_or(CommandResult::Closed, |snapshot| {
- CommandResult::Completed(RuntimeCommandValue::Snapshot(Box::new(snapshot.clone())))
- });
- let _ = task.reply.send(CommandReceipt::new(
- task.correlation.request_id(),
- receipt_result,
- ));
- }
- }
-}
-
-fn commit_generated_key_stage(
- adapter: &PersistentAppCore,
- secrets: &dyn SecretStore,
- clock: &dyn Clock,
- request: &DurableRequestId,
- staged: StagedGeneratedKey,
-) -> Result<RuntimeCommandValue, SafeError> {
- adapter.commit_staged_generated_key(request, staged, secrets, clock)?;
- Ok(RuntimeCommandValue::Snapshot(Box::new(
- adapter.core().snapshot(),
- )))
-}
-
-const fn blocking_execution_failed(error: BlockingExecutionError) -> SafeError {
- match error {
- BlockingExecutionError::DeadlineElapsed => command_timed_out(),
- BlockingExecutionError::Saturated => command_rejected(),
- BlockingExecutionError::TaskFailed => SafeError::new(
- SafeErrorCode::InvalidApplicationState,
- SafeMessage::new("The runtime blocking worker failed."),
- ),
- }
-}
-
-const fn request_space_exhausted() -> SafeError {
- SafeError::new(
- SafeErrorCode::InvalidApplicationState,
- SafeMessage::new("The runtime request identifier space is exhausted."),
- )
-}
-
-const fn stale_profile_binding() -> SafeError {
- SafeError::new(
- SafeErrorCode::InvalidApplicationState,
- SafeMessage::new("The active account binding changed before profile refresh."),
- )
-}
-
-const fn command_conflicted() -> SafeError {
- SafeError::new(
- SafeErrorCode::InvalidApplicationState,
- SafeMessage::new("The command conflicts with newer application state."),
- )
-}
-
-const fn command_rejected() -> SafeError {
- SafeError::new(
- SafeErrorCode::InvalidApplicationState,
- SafeMessage::new("The runtime is busy. Try again."),
- )
-}
-
-const fn command_timed_out() -> SafeError {
- SafeError::new(
- SafeErrorCode::InvalidApplicationState,
- SafeMessage::new("The runtime command timed out."),
- )
-}
-
-const fn runtime_closed() -> SafeError {
- SafeError::new(
- SafeErrorCode::InvalidApplicationState,
- SafeMessage::new("The application runtime is closed."),
- )
-}
-
-const fn command_unavailable() -> SafeError {
- SafeError::new(
- SafeErrorCode::InvalidApplicationState,
- SafeMessage::new("The command is unavailable in the current runtime state."),
- )
-}
-
-const fn generated_recovery_route_active() -> SafeError {
- SafeError::new(
- SafeErrorCode::InvalidApplicationState,
- SafeMessage::new("Complete or cancel generated-key recovery before another action."),
- )
-}
-
-const fn invalid_actor_response() -> SafeError {
- SafeError::new(
- SafeErrorCode::InvalidApplicationState,
- SafeMessage::new("The runtime returned an invalid command response."),
- )
-}
-
-const fn observer_registration_failed() -> SafeError {
- SafeError::new(
- SafeErrorCode::ObserverRegistrationFailed,
- SafeMessage::new("The application change subscription could not be registered."),
- )
-}
-
-#[cfg(test)]
-mod tests {
- use std::future::Future;
- use std::num::NonZeroUsize;
- use std::sync::atomic::{AtomicBool, Ordering};
- use std::sync::{Arc, Condvar, Mutex};
- use std::task::{Context, Poll, Wake, Waker};
- use std::thread::{self, Thread};
- use std::time::{Duration, Instant};
-
- use radroots_studio_application::{
- BoxFuture, Clock, DurableRequestId, FailureSecretStore, ForegroundSessionBinding,
- InMemorySecretStore, NostrClient, ProfileFetchResult, RelayConfiguration, RuntimeLifecycle,
- SecretStore, SecretStoreOperation, SessionGeneration, SessionState, SnapshotRevision,
- };
- use radroots_studio_domain::{
- AccountIdentity, BindingAvailability, LocalSignerBinding, PublicKey,
- RelayDestinationPolicy, RelayUrl, SafeError, SafeErrorCode, SecretKeyInput, UnixTimestamp,
- };
-
- use super::{
- DEFAULT_COMMAND_TIMEOUT, RuntimeActorHandle, RuntimeDependencies, command_unavailable,
- };
- use crate::{InstallationIdentity, InstallationIdentitySource, UuidInstallationIdentitySource};
-
- struct FixedInstallationIdentity(&'static str);
-
- impl InstallationIdentitySource for FixedInstallationIdentity {
- fn generate(&self) -> Result<InstallationIdentity, SafeError> {
- InstallationIdentity::parse(self.0)
- }
- }
-
- fn dependencies(
- secrets: Arc<dyn SecretStore>,
- nostr: Arc<dyn NostrClient>,
- ) -> RuntimeDependencies {
- RuntimeDependencies::new(
- secrets,
- Arc::new(FixedClock),
- nostr,
- Arc::new(UuidInstallationIdentitySource),
- )
- }
-
- struct FixedClock;
-
- impl Clock for FixedClock {
- fn now(&self) -> UnixTimestamp {
- UnixTimestamp::from_seconds(50).expect("time")
- }
- }
-
- struct OfflineNostr;
-
- impl NostrClient for OfflineNostr {
- fn fetch_profile<'a>(
- &'a self,
- _public_key: PublicKey,
- _relays: &'a [RelayUrl],
- _deadline: Instant,
- ) -> BoxFuture<'a, Result<ProfileFetchResult, SafeError>> {
- Box::pin(async { Ok(ProfileFetchResult::complete(None)) })
- }
- }
-
- struct BlockingNostr {
- started: tokio::sync::Semaphore,
- release: tokio::sync::Semaphore,
- }
-
- impl BlockingNostr {
- fn new() -> Self {
- Self {
- started: tokio::sync::Semaphore::new(0),
- release: tokio::sync::Semaphore::new(0),
- }
- }
- }
-
- impl NostrClient for BlockingNostr {
- fn fetch_profile<'a>(
- &'a self,
- _public_key: PublicKey,
- _relays: &'a [RelayUrl],
- _deadline: Instant,
- ) -> BoxFuture<'a, Result<ProfileFetchResult, SafeError>> {
- Box::pin(async move {
- self.started.add_permits(1);
- let permit = self.release.acquire().await.expect("release");
- permit.forget();
- Ok(ProfileFetchResult::complete(None))
- })
- }
- }
-
- struct BlockingSecretStore {
- inner: InMemorySecretStore,
- block_next_put: AtomicBool,
- put_started: AtomicBool,
- released: Mutex<bool>,
- release_signal: Condvar,
- }
-
- impl BlockingSecretStore {
- fn new() -> Self {
- Self {
- inner: InMemorySecretStore::default(),
- block_next_put: AtomicBool::new(true),
- put_started: AtomicBool::new(false),
- released: Mutex::new(false),
- release_signal: Condvar::new(),
- }
- }
-
- async fn wait_until_put_started(&self) {
- while !self.put_started.load(Ordering::Acquire) {
- tokio::task::yield_now().await;
- }
- }
-
- fn release(&self) {
- *self
- .released
- .lock()
- .unwrap_or_else(std::sync::PoisonError::into_inner) = true;
- self.release_signal.notify_all();
- }
- }
-
- impl SecretStore for BlockingSecretStore {
- fn put(&self, public_key: PublicKey, secret: SecretKeyInput) -> Result<(), SafeError> {
- if self.block_next_put.swap(false, Ordering::AcqRel) {
- self.put_started.store(true, Ordering::Release);
- let released = self
- .released
- .lock()
- .unwrap_or_else(std::sync::PoisonError::into_inner);
- drop(
- self.release_signal
- .wait_while(released, |released| !*released)
- .unwrap_or_else(std::sync::PoisonError::into_inner),
- );
- }
- self.inner.put(public_key, secret)
- }
-
- fn load(&self, public_key: PublicKey) -> Result<SecretKeyInput, SafeError> {
- self.inner.load(public_key)
- }
-
- fn contains(&self, public_key: PublicKey) -> Result<bool, SafeError> {
- self.inner.contains(public_key)
- }
-
- fn delete(&self, public_key: PublicKey) -> Result<(), SafeError> {
- self.inner.delete(public_key)
- }
- }
-
- async fn actor() -> (RuntimeActorHandle, Arc<InMemorySecretStore>) {
- let secrets = Arc::new(InMemorySecretStore::default());
- let secret_port: Arc<dyn SecretStore> = secrets.clone();
- let actor = RuntimeActorHandle::in_memory(
- RelayConfiguration::default(),
- dependencies(secret_port, Arc::new(OfflineNostr)),
- NonZeroUsize::new(8).expect("capacity"),
- &tokio::runtime::Handle::current(),
- )
- .await
- .expect("actor");
- (actor, secrets)
- }
-
- struct ThreadWake(Thread);
-
- impl Wake for ThreadWake {
- fn wake(self: Arc<Self>) {
- self.0.unpark();
- }
-
- fn wake_by_ref(self: &Arc<Self>) {
- self.0.unpark();
- }
- }
-
- fn block_on_without_runtime<F: Future>(future: F) -> F::Output {
- let waker = Waker::from(Arc::new(ThreadWake(thread::current())));
- let mut context = Context::from_waker(&waker);
- let mut future = std::pin::pin!(future);
- loop {
- match future.as_mut().poll(&mut context) {
- Poll::Ready(output) => return output,
- Poll::Pending => thread::park(),
- }
- }
- }
-
- #[test]
- fn actor_operations_support_foreign_executor_polling() {
- let runtime = tokio::runtime::Runtime::new().expect("runtime");
- let (actor, _) = runtime.block_on(actor());
-
- let snapshot = block_on_without_runtime(actor.bootstrap()).expect("bootstrap");
- assert_eq!(snapshot, actor.snapshot());
- block_on_without_runtime(actor.close()).expect("close");
- }
-
- #[tokio::test(flavor = "multi_thread")]
- async fn installation_identity_survives_file_backed_runtime_restart() {
- let directory = tempfile::tempdir().expect("temporary directory");
- let path = directory.path().join("studio.sqlite3");
- let first = RuntimeActorHandle::open(
- &path,
- RelayConfiguration::default(),
- RuntimeDependencies::new(
- Arc::new(InMemorySecretStore::default()),
- Arc::new(FixedClock),
- Arc::new(OfflineNostr),
- Arc::new(FixedInstallationIdentity(
- "11aabbccddeeff001122334455667788",
- )),
- ),
- NonZeroUsize::new(8).expect("capacity"),
- &tokio::runtime::Handle::current(),
- )
- .await
- .expect("first runtime");
- assert_eq!(
- first.installation_identity().as_str(),
- "11aabbccddeeff001122334455667788"
- );
- first.close().await.expect("first close");
- drop(first);
-
- let second = RuntimeActorHandle::open(
- &path,
- RelayConfiguration::default(),
- RuntimeDependencies::new(
- Arc::new(InMemorySecretStore::default()),
- Arc::new(FixedClock),
- Arc::new(OfflineNostr),
- Arc::new(FixedInstallationIdentity(
- "22aabbccddeeff001122334455667788",
- )),
- ),
- NonZeroUsize::new(8).expect("capacity"),
- &tokio::runtime::Handle::current(),
- )
- .await
- .expect("second runtime");
- assert_eq!(
- second.installation_identity().as_str(),
- "11aabbccddeeff001122334455667788"
- );
- second.close().await.expect("second close");
- }
-
- #[tokio::test(flavor = "multi_thread")]
- async fn account_mutations_run_serially_through_one_ready_actor() {
- let (actor, secrets) = actor().await;
- assert_eq!(actor.lifecycle(), RuntimeLifecycle::Ready);
-
- let imported = actor
- .import_secret_key_test(
- SecretKeyInput::parse(
- "7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7".to_owned(),
- )
- .expect("input"),
- )
- .await
- .expect("import");
- let public_key = imported.account().public_key();
- let activated = actor.activate_account(public_key).await.expect("activate");
- assert_eq!(activated.session(), SessionState::Active);
- let foreground = actor.foreground_session().expect("foreground session");
- assert_eq!(foreground.identity().public_key(), public_key);
- assert_eq!(foreground.signer().account(), public_key);
- assert_eq!(foreground.generation(), actor.session_generation());
- assert!(secrets.contains(public_key).expect("credential"));
-
- let signed_out = actor.sign_out().await.expect("sign out");
- assert_eq!(signed_out.session(), SessionState::SignedOut);
- assert!(actor.foreground_session().is_none());
- let removal = actor
- .request_account_removal(public_key)
- .await
- .expect("removal request");
- let removed = actor
- .confirm_account_removal_test(removal)
- .await
- .expect("remove");
- assert!(removed.accounts().is_empty());
- assert!(!secrets.contains(public_key).expect("credential removed"));
- }
-
- #[tokio::test(flavor = "multi_thread")]
- async fn public_actor_commands_cover_generation_selection_and_empty_profile_refresh() {
- let (actor, _) = actor().await;
- let unchanged = actor
- .refresh_active_profile()
- .await
- .expect("refresh without an active account");
- assert!(unchanged.active_account().is_none());
-
- let generated = actor
- .generate_account(
- DurableRequestId::parse("test:generate:public-surface").expect("request"),
- actor.snapshot().revision(),
- DEFAULT_COMMAND_TIMEOUT,
- )
- .await
- .expect("generate account");
- let selected = actor
- .select_account(generated.account().public_key())
- .await
- .expect("select generated account");
- assert_eq!(
- selected.selected_account(),
- Some(generated.account().public_key())
- );
-
- let missing =
- PublicKey::from_hex("79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798")
- .expect("public key");
- let error = match actor.request_account_removal(missing).await {
- Ok(_) => panic!("unknown account removal must fail"),
- Err(error) => error,
- };
- assert_eq!(error.code(), SafeErrorCode::AccountNotFound);
- }
-
- #[tokio::test(flavor = "multi_thread")]
- async fn staged_recovery_rejects_a_stale_expected_revision_before_commit() {
- let (actor, _) = actor().await;
- let handle = actor
- .begin_generated_key_stage()
- .await
- .expect("generated key stage");
- let stale = SnapshotRevision::from_value(actor.snapshot().revision().value() + 1);
- let error = actor
- .acknowledge_generated_key_stage(
- handle.id(),
- DurableRequestId::parse("test:generate:stale-revision").expect("request"),
- stale,
- DEFAULT_COMMAND_TIMEOUT,
- )
- .await
- .expect_err("stale revision must conflict");
- assert_eq!(
- error.message().as_str(),
- "The command conflicts with newer application state."
- );
- assert!(actor.cancel_generated_key_stage().await.expect("cancel"));
- }
-
- #[tokio::test(flavor = "multi_thread")]
- async fn fatal_lifecycle_rejects_commands_before_execution() {
- let (actor, _) = actor().await;
- actor
- .lifecycle
- .lock()
- .unwrap_or_else(std::sync::PoisonError::into_inner)
- .fail(command_unavailable());
-
- let error = actor
- .bootstrap()
- .await
- .expect_err("fatal lifecycle must reject command admission");
- assert_eq!(
- error.message().as_str(),
- "The command is unavailable in the current runtime state."
- );
- assert!(matches!(actor.lifecycle(), RuntimeLifecycle::Fatal(_)));
- }
-
- #[tokio::test(flavor = "multi_thread")]
- async fn generated_key_stage_is_exclusive_cancelable_and_snapshot_free() {
- let (actor, secrets) = actor().await;
- let initial = actor.snapshot();
- let stage = actor
- .begin_generated_key_stage()
- .await
- .expect("generated key stage");
-
- assert!(actor.begin_generated_key_stage().await.is_err());
- assert_eq!(actor.snapshot(), initial);
- assert!(
- !secrets
- .contains(stage.view().account().public_key())
- .expect("keyring")
- );
- assert!(actor.sign_out().await.is_err());
- assert_eq!(actor.snapshot(), initial);
- assert!(actor.cancel_generated_key_stage().await.expect("cancel"));
- assert!(
- !actor
- .cancel_generated_key_stage()
- .await
- .expect("cancel empty")
- );
- assert_eq!(actor.snapshot(), initial);
-
- actor
- .begin_generated_key_stage()
- .await
- .expect("replacement stage");
- actor.close().await.expect("close clears stage");
- assert_eq!(actor.lifecycle(), RuntimeLifecycle::Closed);
- }
-
- #[tokio::test(flavor = "multi_thread")]
- async fn recovery_handle_is_one_use_and_acknowledgement_commits_once() {
- let (actor, secrets) = actor().await;
- let initial = actor.snapshot();
- let handle = actor
- .begin_generated_key_stage()
- .await
- .expect("generated key stage");
- let public_key = handle.view().account().public_key();
- let recovery = handle.take_recovery_nsec().expect("recovery material");
- assert_eq!(recovery.with_exposed_secret(str::len), 63);
- assert!(handle.take_recovery_nsec().is_err());
- assert_eq!(actor.snapshot(), initial);
- assert!(!secrets.contains(public_key).expect("not committed"));
-
- let committed = actor
- .acknowledge_generated_key_stage_test(handle.id())
- .await
- .expect("acknowledge");
- assert_eq!(committed.accounts().len(), 1);
- assert_eq!(committed.selected_account(), Some(public_key));
- assert!(secrets.contains(public_key).expect("credential committed"));
- assert!(
- actor
- .acknowledge_generated_key_stage_test(handle.id())
- .await
- .is_err()
- );
- }
-
- #[tokio::test(flavor = "multi_thread")]
- async fn failed_generated_commit_consumes_the_stage_without_poisoning_the_actor() {
- let secrets = Arc::new(FailureSecretStore::default());
- secrets.fail_next(SecretStoreOperation::Put);
- let secret_port: Arc<dyn SecretStore> = secrets.clone();
- let actor = RuntimeActorHandle::in_memory(
- RelayConfiguration::default(),
- dependencies(secret_port, Arc::new(OfflineNostr)),
- NonZeroUsize::new(8).expect("capacity"),
- &tokio::runtime::Handle::current(),
- )
- .await
- .expect("actor");
- let handle = actor
- .begin_generated_key_stage()
- .await
- .expect("generated key stage");
-
- let error = actor
- .acknowledge_generated_key_stage_test(handle.id())
- .await
- .expect_err("injected keyring failure");
-
- assert_eq!(error.code(), SafeErrorCode::KeyringUnavailable);
- assert!(actor.snapshot().accounts().is_empty());
- actor
- .begin_generated_key_stage()
- .await
- .expect("fresh recovery after terminal failure");
- assert!(actor.cancel_generated_key_stage().await.expect("cancel"));
- }
-
- #[tokio::test(flavor = "multi_thread")]
- async fn session_generation_cancels_correlated_profile_work_on_sign_out() {
- let client = Arc::new(BlockingNostr::new());
- let actor = RuntimeActorHandle::in_memory(
- RelayConfiguration::new(vec![
- RelayUrl::parse("ws://localhost:8080", RelayDestinationPolicy::Local)
- .expect("relay"),
- ])
- .expect("relay configuration"),
- dependencies(Arc::new(InMemorySecretStore::default()), client.clone()),
- NonZeroUsize::new(8).expect("capacity"),
- &tokio::runtime::Handle::current(),
- )
- .await
- .expect("actor");
- let imported = actor
- .import_secret_key_test(
- SecretKeyInput::parse(
- "7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7".to_owned(),
- )
- .expect("input"),
- )
- .await
- .expect("import");
- actor
- .activate_account(imported.account().public_key())
- .await
- .expect("activate");
- assert_eq!(actor.session_generation().value(), 1);
-
- let refresh_actor = actor.clone();
- let refresh = tokio::spawn(async move { refresh_actor.refresh_active_profile().await });
- let started = client.started.acquire().await.expect("refresh started");
- started.forget();
- let signed_out = actor.sign_out().await.expect("sign out");
- let cancelled = refresh
- .await
- .expect("refresh task")
- .expect("safe cancellation");
-
- assert_eq!(actor.session_generation().value(), 2);
- assert_eq!(signed_out.session(), SessionState::SignedOut);
- assert_eq!(cancelled.session(), SessionState::SignedOut);
- assert!(cancelled.active_account().is_none());
- }
-
- #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
- async fn profile_refresh_rejects_stale_bindings_and_discards_stale_completions() {
- let client = Arc::new(BlockingNostr::new());
- let actor = RuntimeActorHandle::in_memory(
- RelayConfiguration::new(vec![
- RelayUrl::parse("ws://localhost:8080", RelayDestinationPolicy::Local)
- .expect("relay"),
- ])
- .expect("relay configuration"),
- dependencies(Arc::new(InMemorySecretStore::default()), client.clone()),
- NonZeroUsize::new(8).expect("capacity"),
- &tokio::runtime::Handle::current(),
- )
- .await
- .expect("actor");
- let imported = actor
- .import_secret_key_test(secret(
- "7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7",
- ))
- .await
- .expect("import");
- let public_key = imported.account().public_key();
- actor.activate_account(public_key).await.expect("activate");
- let binding = actor.foreground_session().expect("foreground binding");
- let stale_binding = ForegroundSessionBinding::new(
- AccountIdentity::derive(public_key).expect("identity"),
- LocalSignerBinding::new(public_key, BindingAvailability::Available),
- SessionGeneration::from_value(binding.generation().value() + 1),
- )
- .expect("stale binding fixture");
- let other_public_key =
- PublicKey::from_hex("c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee5")
- .expect("other public key");
- let other_binding = ForegroundSessionBinding::new(
- AccountIdentity::derive(other_public_key).expect("other identity"),
- LocalSignerBinding::new(other_public_key, BindingAvailability::Available),
- binding.generation(),
- )
- .expect("other binding fixture");
-
- *actor
- .foreground_session
- .lock()
- .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(stale_binding.clone());
- let error = actor
- .refresh_active_profile()
- .await
- .expect_err("stale generation must reject before relay work");
- assert_eq!(
- error.message().as_str(),
- "The active account binding changed before profile refresh."
- );
-
- *actor
- .foreground_session
- .lock()
- .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(other_binding.clone());
- let error = actor
- .refresh_active_profile()
- .await
- .expect_err("different account binding must reject before relay work");
- assert_eq!(
- error.message().as_str(),
- "The active account binding changed before profile refresh."
- );
-
- *actor
- .foreground_session
- .lock()
- .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(binding.clone());
- let refresh_actor = actor.clone();
- let refresh = tokio::spawn(async move { refresh_actor.refresh_active_profile().await });
- let started = client.started.acquire().await.expect("refresh started");
- started.forget();
- *actor
- .foreground_session
- .lock()
- .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(stale_binding);
- client.release.add_permits(1);
- let unchanged = refresh
- .await
- .expect("refresh task")
- .expect("stale completion returns current snapshot");
- assert_eq!(unchanged.revision(), actor.snapshot().revision());
-
- *actor
- .foreground_session
- .lock()
- .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(binding.clone());
- let refresh_actor = actor.clone();
- let refresh = tokio::spawn(async move { refresh_actor.refresh_active_profile().await });
- let started = client
- .started
- .acquire()
- .await
- .expect("second refresh started");
- started.forget();
- *actor
- .foreground_session
- .lock()
- .unwrap_or_else(std::sync::PoisonError::into_inner) = None;
- client.release.add_permits(1);
- let unchanged = refresh
- .await
- .expect("refresh task")
- .expect("missing binding returns current snapshot");
- assert_eq!(unchanged.revision(), actor.snapshot().revision());
-
- *actor
- .foreground_session
- .lock()
- .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(binding.clone());
- let refresh_actor = actor.clone();
- let refresh = tokio::spawn(async move { refresh_actor.refresh_active_profile().await });
- let started = client
- .started
- .acquire()
- .await
- .expect("third refresh started");
- started.forget();
- *actor
- .foreground_session
- .lock()
- .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(other_binding);
- client.release.add_permits(1);
- let unchanged = refresh
- .await
- .expect("refresh task")
- .expect("different account binding returns current snapshot");
- assert_eq!(unchanged.revision(), actor.snapshot().revision());
-
- *actor
- .foreground_session
- .lock()
- .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(binding);
- }
-
- #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
- async fn bounded_runtime_rejects_saturation_without_dropping_accepted_commands() {
- let secrets = Arc::new(BlockingSecretStore::new());
- let actor = RuntimeActorHandle::in_memory(
- RelayConfiguration::default(),
- dependencies(secrets.clone(), Arc::new(OfflineNostr)),
- NonZeroUsize::new(1).expect("capacity"),
- &tokio::runtime::Handle::current(),
- )
- .await
- .expect("actor");
-
- let first_actor = actor.clone();
- let first = tokio::spawn(async move {
- first_actor
- .import_secret_key_test(secret(
- "7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7",
- ))
- .await
- });
- secrets.wait_until_put_started().await;
-
- let second_actor = actor.clone();
- let second = tokio::spawn(async move {
- second_actor
- .import_secret_key_test(secret(
- "0000000000000000000000000000000000000000000000000000000000000001",
- ))
- .await
- });
- while actor.mailbox.available_capacity() != 0 {
- assert!(
- !second.is_finished(),
- "second command must enter the mailbox"
- );
- tokio::task::yield_now().await;
- }
- let rejected = actor
- .import_secret_key_test(secret(
- "0000000000000000000000000000000000000000000000000000000000000002",
- ))
- .await
- .expect_err("full mailbox must reject");
- assert_eq!(
- rejected.message().as_str(),
- "The runtime is busy. Try again."
- );
-
- secrets.release();
- first.await.expect("first task").expect("first command");
- let second = second
- .await
- .expect("second task")
- .expect_err("accepted stale revision conflicts explicitly");
- assert_eq!(
- second.message().as_str(),
- "The account operation conflicts with the current application state."
- );
- assert_eq!(
- actor.bootstrap().await.expect("snapshot").accounts().len(),
- 1
- );
- }
-
- #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
- async fn queued_command_expiry_returns_timeout_and_prevents_late_mutation() {
- let secrets = Arc::new(BlockingSecretStore::new());
- let actor = RuntimeActorHandle::in_memory(
- RelayConfiguration::default(),
- dependencies(secrets.clone(), Arc::new(OfflineNostr)),
- NonZeroUsize::new(1).expect("capacity"),
- &tokio::runtime::Handle::current(),
- )
- .await
- .expect("actor");
-
- let first_actor = actor.clone();
- let first = tokio::spawn(async move {
- first_actor
- .import_secret_key_test(secret(
- "7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7",
- ))
- .await
- });
- secrets.wait_until_put_started().await;
-
- let expired = actor
- .import_secret_key_with_timeout(
- secret("0000000000000000000000000000000000000000000000000000000000000001"),
- Duration::from_millis(10),
- )
- .await
- .expect_err("queued command must time out");
- assert_eq!(expired.message().as_str(), "The runtime command timed out.");
-
- secrets.release();
- first.await.expect("first task").expect("first command");
- assert_eq!(
- actor.bootstrap().await.expect("snapshot").accounts().len(),
- 1
- );
- }
-
- #[tokio::test(flavor = "multi_thread")]
- async fn close_is_terminal_and_every_later_command_is_rejected_as_closed() {
- let (actor, _) = actor().await;
- actor.close().await.expect("close");
- assert_eq!(actor.lifecycle(), RuntimeLifecycle::Closed);
-
- let error = actor.bootstrap().await.expect_err("bootstrap after close");
- assert_eq!(
- error.message().as_str(),
- "The application runtime is closed."
- );
- actor.close().await.expect("repeated close is idempotent");
- }
-
- #[tokio::test(flavor = "multi_thread")]
- async fn actor_subscription_atomically_delivers_initial_then_ordered_changes() {
- let (actor, _) = actor().await;
- let mut subscription = actor
- .subscribe_changes(NonZeroUsize::new(4).expect("capacity"))
- .await
- .expect("subscribe");
- let initial = subscription.receive().await.expect("initial snapshot");
- assert_eq!(initial.revision(), actor.snapshot().revision());
- assert!(initial.previous_revision().is_none());
-
- actor
- .import_secret_key_test(secret(
- "7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7",
- ))
- .await
- .expect("import");
- let changed = subscription.receive().await.expect("change");
- assert!(changed.revision() > initial.revision());
- assert_eq!(changed.previous_revision(), Some(initial.revision()));
- assert!(
- actor
- .unsubscribe_changes(subscription.id())
- .await
- .expect("unsubscribe")
- );
- assert!(
- !actor
- .unsubscribe_changes(subscription.id())
- .await
- .expect("second unsubscribe")
- );
- }
-
- #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
- async fn expired_queued_shutdown_does_not_close_runtime_later() {
- let secrets = Arc::new(BlockingSecretStore::new());
- let actor = RuntimeActorHandle::in_memory(
- RelayConfiguration::default(),
- dependencies(secrets.clone(), Arc::new(OfflineNostr)),
- NonZeroUsize::new(1).expect("capacity"),
- &tokio::runtime::Handle::current(),
- )
- .await
- .expect("actor");
- let import_actor = actor.clone();
- let import = tokio::spawn(async move {
- import_actor
- .import_secret_key_test(secret(
- "7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7",
- ))
- .await
- });
- secrets.wait_until_put_started().await;
-
- let timeout = actor
- .close_with_timeout(Duration::from_millis(10))
- .await
- .expect_err("queued shutdown must expire");
- assert_eq!(timeout.message().as_str(), "The runtime command timed out.");
- secrets.release();
- import.await.expect("import task").expect("import");
- assert_eq!(actor.lifecycle(), RuntimeLifecycle::Ready);
- assert_eq!(
- actor
- .bootstrap()
- .await
- .expect("still open")
- .accounts()
- .len(),
- 1
- );
- actor.close().await.expect("later close");
- }
-
- #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
- async fn shutdown_cancels_in_flight_work_and_terminates_publication() {
- let client = Arc::new(BlockingNostr::new());
- let actor = RuntimeActorHandle::in_memory(
- RelayConfiguration::new(vec![
- RelayUrl::parse("ws://localhost:8080", RelayDestinationPolicy::Local)
- .expect("relay"),
- ])
- .expect("relay configuration"),
- dependencies(Arc::new(InMemorySecretStore::default()), client.clone()),
- NonZeroUsize::new(8).expect("capacity"),
- &tokio::runtime::Handle::current(),
- )
- .await
- .expect("actor");
- let imported = actor
- .import_secret_key_test(secret(
- "7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7",
- ))
- .await
- .expect("import");
- actor
- .activate_account(imported.account().public_key())
- .await
- .expect("activate");
- let mut changes = actor
- .subscribe_changes(NonZeroUsize::new(4).expect("capacity"))
- .await
- .expect("subscribe");
- changes.receive().await.expect("initial");
-
- let refresh_actor = actor.clone();
- let refresh = tokio::spawn(async move { refresh_actor.refresh_active_profile().await });
- let started = client.started.acquire().await.expect("refresh started");
- started.forget();
- actor.close().await.expect("close");
-
- let cancelled = refresh
- .await
- .expect("refresh task")
- .expect_err("refresh closes");
- assert_eq!(
- cancelled.message().as_str(),
- "The application runtime is closed."
- );
- assert!(changes.receive().await.is_none());
- assert_eq!(actor.lifecycle(), RuntimeLifecycle::Closed);
- }
-
- fn secret(value: &str) -> SecretKeyInput {
- SecretKeyInput::parse(value.to_owned()).expect("valid test secret")
- }
-}
diff --git a/crates/studio_runtime/tests/local_relay_e2e.rs b/crates/studio_runtime/tests/local_relay_e2e.rs
@@ -1,100 +0,0 @@
-use std::time::Duration;
-
-use nostr::{EventBuilder, Keys, Metadata};
-use nostr_relay_builder::MockRelay;
-use nostr_sdk::Client;
-use radroots_studio_application::{
- Clock, InMemorySecretStore, ProfileLoadState, ProfileRepository, RelayConfiguration,
- RelayConnectionState, SecretStore, SessionState,
-};
-use radroots_studio_domain::{RelayDestinationPolicy, RelayUrl, SecretKeyInput, UnixTimestamp};
-use radroots_studio_nostr::SdkNostrClient;
-use radroots_studio_runtime::PersistentAppCore;
-
-const SECRET_HEX: &str = "7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7";
-
-struct FixedClock;
-
-impl Clock for FixedClock {
- fn now(&self) -> UnixTimestamp {
- UnixTimestamp::from_seconds(100).expect("fixed timestamp")
- }
-}
-
-#[tokio::test]
-async fn local_relay_e2e_imports_activates_refreshes_and_caches_profile() {
- let local_relay = MockRelay::run().await.expect("local relay");
- let relay_url = local_relay.url().await;
- let keys = Keys::parse(SECRET_HEX).expect("known secret key");
- let publisher = Client::new(keys);
- publisher
- .add_relay(relay_url.clone())
- .await
- .expect("publisher relay");
- publisher.connect().await;
- publisher.wait_for_connection(Duration::from_secs(2)).await;
- publisher
- .send_event_builder(EventBuilder::metadata(
- &Metadata::new()
- .name("farmer")
- .display_name("Farm Account")
- .about("Local food profile"),
- ))
- .await
- .expect("publish profile");
-
- let relay =
- RelayUrl::parse(relay_url.as_str(), RelayDestinationPolicy::Local).expect("relay URL");
- let adapter = PersistentAppCore::in_memory(
- RelayConfiguration::new(vec![relay]).expect("relay configuration"),
- )
- .expect("persistent adapter");
- let secrets = InMemorySecretStore::default();
- adapter.bootstrap(&secrets, &FixedClock).expect("bootstrap");
- let imported = adapter
- .import_secret_key(
- SecretKeyInput::parse(SECRET_HEX.to_owned()).expect("secret input"),
- &secrets,
- &FixedClock,
- )
- .expect("import account");
- let public_key = imported.account().public_key();
- assert!(secrets.contains(public_key).expect("credential exists"));
- adapter
- .activate_account(public_key, &secrets, &FixedClock)
- .expect("activate account");
-
- let refreshed = adapter
- .core()
- .refresh_active_profile(
- adapter.database(),
- &SdkNostrClient::new(Duration::from_secs(2)),
- &FixedClock,
- std::time::Instant::now() + Duration::from_secs(2),
- )
- .await
- .expect("refresh profile");
-
- assert_eq!(refreshed.session(), SessionState::Active);
- let active = refreshed.active_account().expect("active account");
- assert_eq!(active.relay_state(), RelayConnectionState::Connected);
- assert_eq!(active.profile_state(), ProfileLoadState::Fresh);
- assert_eq!(
- active.profile().and_then(|profile| profile.display_name()),
- Some("Farm Account")
- );
- let cached = adapter
- .database()
- .load_profile(public_key)
- .expect("load cache")
- .expect("cached profile");
- assert_eq!(
- cached.candidate().metadata().preferred_name(),
- Some("Farm Account")
- );
- let public_debug = format!("{refreshed:?}");
- assert!(!public_debug.contains(SECRET_HEX));
- assert!(!public_debug.contains("nsec1"));
- publisher.shutdown().await;
- local_relay.shutdown();
-}
diff --git a/crates/studio_runtime/tests/restart_isolation.rs b/crates/studio_runtime/tests/restart_isolation.rs
@@ -1,95 +0,0 @@
-use std::fs;
-
-use radroots_studio_application::{
- AccountNamespaceRepository, AccountPreferenceKey, Clock, InMemorySecretStore,
- RelayConfiguration, SessionState,
-};
-use radroots_studio_domain::{SecretKeyInput, UnixTimestamp};
-use radroots_studio_runtime::PersistentAppCore;
-use tempfile::tempdir;
-
-const SECRET_A: &str = "7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7";
-const SECRET_B: &str = "0101010101010101010101010101010101010101010101010101010101010101";
-
-struct FixedClock;
-
-impl Clock for FixedClock {
- fn now(&self) -> UnixTimestamp {
- UnixTimestamp::from_seconds(200).expect("fixed timestamp")
- }
-}
-
-#[test]
-fn restart_restores_selection_and_keeps_account_namespaces_isolated() {
- let directory = tempdir().expect("temporary directory");
- let path = directory.path().join("studio.sqlite3");
- let secrets = InMemorySecretStore::default();
- let (owner_a, owner_b);
-
- {
- let adapter = PersistentAppCore::open(&path, RelayConfiguration::default())
- .expect("persistent adapter");
- adapter.bootstrap(&secrets, &FixedClock).expect("bootstrap");
- owner_a = adapter
- .import_secret_key(
- SecretKeyInput::parse(SECRET_A.to_owned()).expect("secret A"),
- &secrets,
- &FixedClock,
- )
- .expect("account A")
- .account()
- .public_key();
- owner_b = adapter
- .import_secret_key(
- SecretKeyInput::parse(SECRET_B.to_owned()).expect("secret B"),
- &secrets,
- &FixedClock,
- )
- .expect("account B")
- .account()
- .public_key();
- adapter
- .database()
- .set_value(owner_a, AccountPreferenceKey::NamespaceProbe, "account-a")
- .expect("namespace A");
- adapter
- .database()
- .set_value(owner_b, AccountPreferenceKey::NamespaceProbe, "account-b")
- .expect("namespace B");
- adapter.select_account(owner_b).expect("select B");
- }
-
- let reopened =
- PersistentAppCore::open(&path, RelayConfiguration::default()).expect("reopen adapter");
- let restored = reopened.bootstrap(&secrets, &FixedClock).expect("restore");
- assert_eq!(restored.accounts().len(), 2);
- assert_eq!(restored.selected_account(), Some(owner_b));
- assert_eq!(restored.session(), SessionState::SignedOut);
- assert_eq!(
- reopened
- .database()
- .get_value(owner_a, AccountPreferenceKey::NamespaceProbe)
- .expect("read A"),
- Some("account-a".to_owned())
- );
- assert_eq!(
- reopened
- .database()
- .get_value(owner_b, AccountPreferenceKey::NamespaceProbe)
- .expect("read B"),
- Some("account-b".to_owned())
- );
-
- let database = fs::read(path).expect("database bytes");
- assert!(
- !database
- .windows(SECRET_A.len())
- .any(|bytes| bytes == SECRET_A.as_bytes())
- );
- assert!(
- !database
- .windows(SECRET_B.len())
- .any(|bytes| bytes == SECRET_B.as_bytes())
- );
- assert!(!database.windows(5).any(|bytes| bytes == b"nsec1"));
-}
diff --git a/crates/studio_storage/Cargo.toml b/crates/studio_storage/Cargo.toml
@@ -1,35 +0,0 @@
-[package]
-name = "radroots_studio_storage"
-description = "Private persistence and keyring adapters for Radroots Studio"
-version = "0.1.0-alpha"
-edition.workspace = true
-authors.workspace = true
-rust-version.workspace = true
-license = "GPL-3.0-only"
-repository.workspace = true
-homepage.workspace = true
-publish = false
-include = ["src/**", "tests/**", "migrations/**", "Cargo.toml"]
-
-[dependencies]
-fs2 = "=0.4.3"
-keyring = "=4.1.6"
-radroots_studio_application.workspace = true
-radroots_studio_domain.workspace = true
-refinery = { version = "=0.9.2", default-features = false, features = [
- "rusqlite",
-] }
-getrandom.workspace = true
-hmac.workspace = true
-rusqlite = { version = "=0.39.0", features = ["backup", "bundled"] }
-sha2.workspace = true
-zeroize = "=1.9.0"
-
-[target.'cfg(unix)'.dependencies]
-rustix.workspace = true
-
-[dev-dependencies]
-tempfile = "=3.23.0"
-
-[lints]
-workspace = true
diff --git a/crates/studio_storage/migrations/V10__installation_identity.sql b/crates/studio_storage/migrations/V10__installation_identity.sql
@@ -1,7 +0,0 @@
-CREATE TABLE installation_identity (
- singleton INTEGER PRIMARY KEY CHECK (singleton = 1),
- installation_id TEXT NOT NULL CHECK (
- length(installation_id) = 32
- AND installation_id NOT GLOB '*[^0-9a-f]*'
- )
-) STRICT;
diff --git a/crates/studio_storage/migrations/V1__initialize.sql b/crates/studio_storage/migrations/V1__initialize.sql
@@ -1,6 +0,0 @@
-CREATE TABLE application_schema (
- singleton INTEGER PRIMARY KEY CHECK (singleton = 1),
- schema_version INTEGER NOT NULL CHECK (schema_version >= 1)
-);
-
-INSERT INTO application_schema (singleton, schema_version) VALUES (1, 1);
diff --git a/crates/studio_storage/migrations/V2__accounts.sql b/crates/studio_storage/migrations/V2__accounts.sql
@@ -1,28 +0,0 @@
-CREATE TABLE accounts (
- pubkey TEXT PRIMARY KEY NOT NULL CHECK (
- length(pubkey) = 64 AND pubkey = lower(pubkey)
- ),
- npub TEXT NOT NULL CHECK (length(npub) = 63),
- signer_kind TEXT NOT NULL CHECK (
- signer_kind IN ('local_secret', 'watch_only', 'remote_nip46')
- ),
- key_availability TEXT NOT NULL CHECK (
- key_availability IN (
- 'available',
- 'credential_missing',
- 'store_unavailable',
- 'not_required'
- )
- ),
- label TEXT,
- created_at INTEGER NOT NULL CHECK (created_at >= 0),
- last_used_at INTEGER CHECK (last_used_at >= 0)
-);
-
-CREATE TABLE app_state (
- singleton INTEGER PRIMARY KEY CHECK (singleton = 1),
- selected_pubkey TEXT REFERENCES accounts(pubkey) ON DELETE SET NULL
-);
-
-INSERT INTO app_state (singleton, selected_pubkey) VALUES (1, NULL);
-UPDATE application_schema SET schema_version = 2 WHERE singleton = 1;
diff --git a/crates/studio_storage/migrations/V3__profile_cache.sql b/crates/studio_storage/migrations/V3__profile_cache.sql
@@ -1,12 +0,0 @@
-CREATE TABLE profile_cache (
- subject_pubkey TEXT PRIMARY KEY NOT NULL REFERENCES accounts(pubkey) ON DELETE CASCADE,
- event_id TEXT NOT NULL,
- event_created_at INTEGER NOT NULL,
- name TEXT,
- display_name TEXT,
- nip05 TEXT,
- about TEXT,
- picture TEXT,
- refreshed_at INTEGER NOT NULL,
- refresh_status TEXT NOT NULL CHECK (refresh_status IN ('success', 'offline', 'invalid_data'))
-) STRICT;
diff --git a/crates/studio_storage/migrations/V4__account_namespace.sql b/crates/studio_storage/migrations/V4__account_namespace.sql
@@ -1,6 +0,0 @@
-CREATE TABLE account_namespace (
- owner_pubkey TEXT NOT NULL REFERENCES accounts(pubkey) ON DELETE CASCADE,
- preference_key TEXT NOT NULL CHECK (preference_key IN ('namespace_probe')),
- preference_value TEXT NOT NULL CHECK (length(preference_value) <= 4096),
- PRIMARY KEY (owner_pubkey, preference_key)
-) STRICT;
diff --git a/crates/studio_storage/migrations/V5__operation_journal.sql b/crates/studio_storage/migrations/V5__operation_journal.sql
@@ -1,8 +0,0 @@
-CREATE TABLE operation_journal (
- operation_id INTEGER PRIMARY KEY AUTOINCREMENT,
- operation_kind TEXT NOT NULL CHECK (operation_kind IN ('add', 'import', 'remove')),
- subject_pubkey TEXT NOT NULL,
- phase TEXT NOT NULL CHECK (phase IN ('intent_recorded', 'credential_written', 'metadata_committed', 'compensation_pending', 'credential_deleted', 'metadata_deleted')),
- updated_at INTEGER NOT NULL,
- diagnostic_code TEXT CHECK (diagnostic_code IN ('storage_unavailable', 'keyring_unavailable', 'credential_missing', 'compensation_failed'))
-) STRICT;
diff --git a/crates/studio_storage/migrations/V6__normalized_runtime_schema.sql b/crates/studio_storage/migrations/V6__normalized_runtime_schema.sql
@@ -1,100 +0,0 @@
-CREATE TABLE account_identities (
- public_key TEXT PRIMARY KEY NOT NULL CHECK (
- length(public_key) = 64 AND public_key = lower(public_key)
- ),
- npub TEXT NOT NULL UNIQUE CHECK (length(npub) = 63),
- label TEXT CHECK (label IS NULL OR length(label) BETWEEN 1 AND 80),
- created_at INTEGER NOT NULL CHECK (created_at >= 0),
- last_used_at INTEGER CHECK (last_used_at IS NULL OR last_used_at >= 0)
-) STRICT;
-
-CREATE TABLE local_signer_bindings (
- account_public_key TEXT NOT NULL,
- binding_public_key TEXT NOT NULL,
- binding_kind TEXT NOT NULL CHECK (binding_kind = 'local_secret'),
- availability TEXT NOT NULL CHECK (
- availability IN ('available', 'credential_missing', 'store_unavailable')
- ),
- PRIMARY KEY (account_public_key, binding_public_key),
- UNIQUE (account_public_key, binding_kind),
- FOREIGN KEY (account_public_key) REFERENCES account_identities(public_key) ON DELETE CASCADE,
- CHECK (account_public_key = binding_public_key)
-) STRICT;
-
-CREATE TABLE runtime_state (
- singleton INTEGER PRIMARY KEY CHECK (singleton = 1),
- selected_public_key TEXT REFERENCES account_identities(public_key) ON DELETE SET NULL,
- active_account_public_key TEXT,
- active_binding_public_key TEXT,
- session_generation INTEGER NOT NULL DEFAULT 0 CHECK (session_generation >= 0),
- FOREIGN KEY (active_account_public_key, active_binding_public_key)
- REFERENCES local_signer_bindings(account_public_key, binding_public_key)
- ON DELETE SET NULL,
- CHECK (
- (active_account_public_key IS NULL AND active_binding_public_key IS NULL)
- OR
- (active_account_public_key IS NOT NULL AND active_binding_public_key IS NOT NULL)
- )
-) STRICT;
-
-INSERT INTO runtime_state (singleton) VALUES (1);
-
-CREATE TABLE profile_cache_v6 (
- subject_public_key TEXT PRIMARY KEY NOT NULL
- REFERENCES account_identities(public_key) ON DELETE CASCADE,
- event_id TEXT NOT NULL CHECK (length(event_id) = 64 AND event_id = lower(event_id)),
- event_created_at INTEGER NOT NULL CHECK (event_created_at >= 0),
- name TEXT,
- display_name TEXT,
- nip05 TEXT,
- about TEXT,
- picture TEXT,
- refreshed_at INTEGER NOT NULL CHECK (refreshed_at >= 0),
- refresh_status TEXT NOT NULL CHECK (
- refresh_status IN ('success', 'offline', 'invalid_data')
- )
-) STRICT;
-
-CREATE TABLE durable_operations (
- request_id TEXT PRIMARY KEY NOT NULL CHECK (length(request_id) BETWEEN 1 AND 128),
- operation_kind TEXT NOT NULL CHECK (
- operation_kind IN ('create', 'import', 'repair', 'remove')
- ),
- account_public_key TEXT NOT NULL CHECK (
- length(account_public_key) = 64 AND account_public_key = lower(account_public_key)
- ),
- binding_public_key TEXT NOT NULL CHECK (binding_public_key = account_public_key),
- expected_revision INTEGER CHECK (expected_revision IS NULL OR expected_revision >= 0),
- phase TEXT NOT NULL CHECK (
- phase IN (
- 'intent_recorded',
- 'credential_written',
- 'metadata_committed',
- 'selection_committed',
- 'compensation_pending',
- 'credential_deleted',
- 'metadata_deleted',
- 'finalized'
- )
- ),
- terminal_outcome TEXT CHECK (
- terminal_outcome IS NULL OR terminal_outcome IN ('completed', 'cancelled', 'failed')
- ),
- prior_selected_public_key TEXT,
- updated_at INTEGER NOT NULL CHECK (updated_at >= 0),
- diagnostic_code TEXT CHECK (
- diagnostic_code IS NULL OR diagnostic_code IN (
- 'storage_unavailable',
- 'keyring_unavailable',
- 'credential_missing',
- 'compensation_failed',
- 'conflict',
- 'expired'
- )
- ),
- CHECK (
- (phase = 'finalized' AND terminal_outcome IS NOT NULL)
- OR
- (phase <> 'finalized' AND terminal_outcome IS NULL)
- )
-) STRICT;
diff --git a/crates/studio_storage/migrations/V7__migrate_v5_runtime_data.sql b/crates/studio_storage/migrations/V7__migrate_v5_runtime_data.sql
@@ -1,68 +0,0 @@
-INSERT INTO account_identities (
- public_key,
- npub,
- label,
- created_at,
- last_used_at
-)
-SELECT pubkey, npub, label, created_at, last_used_at
-FROM accounts;
-
-INSERT INTO local_signer_bindings (
- account_public_key,
- binding_public_key,
- binding_kind,
- availability
-)
-SELECT pubkey, pubkey, 'local_secret', key_availability
-FROM accounts;
-
-UPDATE runtime_state
-SET selected_public_key = (
- SELECT selected_pubkey FROM app_state WHERE singleton = 1
-)
-WHERE singleton = 1;
-
-INSERT INTO profile_cache_v6 (
- subject_public_key,
- event_id,
- event_created_at,
- name,
- display_name,
- nip05,
- about,
- picture,
- refreshed_at,
- refresh_status
-)
-SELECT
- subject_pubkey,
- event_id,
- event_created_at,
- name,
- display_name,
- nip05,
- about,
- picture,
- refreshed_at,
- refresh_status
-FROM profile_cache;
-
-INSERT INTO durable_operations (
- request_id,
- operation_kind,
- account_public_key,
- binding_public_key,
- phase,
- updated_at,
- diagnostic_code
-)
-SELECT
- 'legacy-v5-' || operation_id,
- CASE operation_kind WHEN 'add' THEN 'create' ELSE operation_kind END,
- subject_pubkey,
- subject_pubkey,
- phase,
- updated_at,
- diagnostic_code
-FROM operation_journal;
diff --git a/crates/studio_storage/migrations/V8__normalized_account_preferences.sql b/crates/studio_storage/migrations/V8__normalized_account_preferences.sql
@@ -1,10 +0,0 @@
-CREATE TABLE account_preferences (
- owner_public_key TEXT NOT NULL REFERENCES account_identities(public_key) ON DELETE CASCADE,
- preference_key TEXT NOT NULL CHECK (preference_key = 'namespace_probe'),
- preference_value TEXT NOT NULL CHECK (length(preference_value) <= 4096),
- PRIMARY KEY (owner_public_key, preference_key)
-) STRICT;
-
-INSERT INTO account_preferences (owner_public_key, preference_key, preference_value)
-SELECT owner_pubkey, preference_key, preference_value
-FROM account_namespace;
diff --git a/crates/studio_storage/migrations/V9__durable_operation_receipts.sql b/crates/studio_storage/migrations/V9__durable_operation_receipts.sql
@@ -1,11 +0,0 @@
-ALTER TABLE durable_operations ADD COLUMN prior_binding_availability TEXT CHECK (
- prior_binding_availability IS NULL OR prior_binding_availability IN (
- 'available',
- 'credential_missing',
- 'store_unavailable'
- )
-);
-
-ALTER TABLE durable_operations ADD COLUMN resulting_revision INTEGER CHECK (
- resulting_revision IS NULL OR resulting_revision >= 0
-);
diff --git a/crates/studio_storage/src/account_namespace.rs b/crates/studio_storage/src/account_namespace.rs
@@ -1,189 +0,0 @@
-use radroots_studio_application::{AccountNamespaceRepository, AccountPreferenceKey};
-use radroots_studio_domain::{PublicKey, SafeError, SafeErrorCode, SafeMessage};
-use rusqlite::{OptionalExtension, params};
-
-use crate::Database;
-
-const MAX_VALUE_CHARS: usize = 4_096;
-
-impl AccountNamespaceRepository for Database {
- fn get_value(
- &self,
- owner: PublicKey,
- key: AccountPreferenceKey,
- ) -> Result<Option<String>, SafeError> {
- self.connection()
- .query_row(
- "SELECT preference_value FROM account_preferences \
- WHERE owner_public_key = ?1 AND preference_key = ?2",
- params![owner.to_hex(), encode_key(key)],
- |row| row.get(0),
- )
- .optional()
- .map_err(|_| storage_error())
- }
-
- fn set_value(
- &self,
- owner: PublicKey,
- key: AccountPreferenceKey,
- value: &str,
- ) -> Result<(), SafeError> {
- if value.chars().count() > MAX_VALUE_CHARS || value.chars().any(char::is_control) {
- return Err(invalid_preference());
- }
- self.connection()
- .execute(
- "INSERT INTO account_preferences (owner_public_key, preference_key, preference_value) \
- VALUES (?1, ?2, ?3) ON CONFLICT(owner_public_key, preference_key) DO UPDATE SET \
- preference_value = excluded.preference_value",
- params![owner.to_hex(), encode_key(key), value],
- )
- .map(|_| ())
- .map_err(|_| storage_error())
- }
-
- fn clear_owner(&self, owner: PublicKey) -> Result<(), SafeError> {
- self.connection()
- .execute(
- "DELETE FROM account_preferences WHERE owner_public_key = ?1",
- [owner.to_hex()],
- )
- .map(|_| ())
- .map_err(|_| storage_error())
- }
-}
-
-const fn encode_key(key: AccountPreferenceKey) -> &'static str {
- match key {
- AccountPreferenceKey::NamespaceProbe => "namespace_probe",
- }
-}
-
-const fn storage_error() -> SafeError {
- SafeError::new(
- SafeErrorCode::StorageUnavailable,
- SafeMessage::new("The account preference is unavailable."),
- )
-}
-
-const fn invalid_preference() -> SafeError {
- SafeError::new(
- SafeErrorCode::InvalidAccountMetadata,
- SafeMessage::new("The account preference is invalid."),
- )
-}
-
-#[cfg(test)]
-mod tests {
- use radroots_studio_application::{
- AccountNamespaceRepository, AccountPreferenceKey, AccountRepository, AppStateRepository,
- };
- use radroots_studio_domain::{
- AccountCreatedAt, AccountIdentity, AccountSummary, BindingAvailability, LocalSignerBinding,
- PublicKey, UnixTimestamp,
- };
-
- use crate::Database;
-
- fn public_key(byte: u8) -> PublicKey {
- let value = match byte {
- 1 => "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df",
- 2 => "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af",
- _ => "7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7",
- };
- PublicKey::from_hex(value).expect("valid public key")
- }
-
- fn account(byte: u8) -> AccountSummary {
- let public_key = public_key(byte);
- AccountSummary::new(
- AccountIdentity::derive(public_key).expect("identity"),
- LocalSignerBinding::new(public_key, BindingAvailability::Available),
- None,
- AccountCreatedAt::new(UnixTimestamp::from_seconds(i64::from(byte)).expect("time")),
- None,
- )
- .expect("account")
- }
-
- #[test]
- fn namespace_partitions_same_typed_key_by_owner_and_selection() {
- let database = Database::in_memory().expect("database");
- let owner_a = public_key(1);
- let owner_b = public_key(2);
- database.insert_account(&account(1)).expect("account a");
- database.insert_account(&account(2)).expect("account b");
- database
- .set_value(owner_a, AccountPreferenceKey::NamespaceProbe, "A")
- .expect("set a");
- database
- .set_value(owner_b, AccountPreferenceKey::NamespaceProbe, "B")
- .expect("set b");
-
- database
- .save_selected_account(Some(owner_b))
- .expect("select b");
- let selected = database
- .load_selected_account()
- .expect("selection")
- .expect("selected owner");
- assert_eq!(
- database
- .get_value(selected, AccountPreferenceKey::NamespaceProbe)
- .expect("selected value"),
- Some("B".to_owned())
- );
- assert_eq!(
- database
- .get_value(owner_a, AccountPreferenceKey::NamespaceProbe)
- .expect("owner a value"),
- Some("A".to_owned())
- );
- }
-
- #[test]
- fn namespace_updates_and_cascades_with_owner_removal() {
- let database = Database::in_memory().expect("database");
- let owner = public_key(3);
- database.insert_account(&account(3)).expect("account");
- database
- .set_value(owner, AccountPreferenceKey::NamespaceProbe, "before")
- .expect("set");
- database
- .set_value(owner, AccountPreferenceKey::NamespaceProbe, "after")
- .expect("update");
- assert_eq!(
- database
- .get_value(owner, AccountPreferenceKey::NamespaceProbe)
- .expect("value"),
- Some("after".to_owned())
- );
-
- database.remove_account(owner).expect("remove");
- assert_eq!(
- database
- .get_value(owner, AccountPreferenceKey::NamespaceProbe)
- .expect("deleted value"),
- None
- );
- }
-
- #[test]
- fn namespace_rejects_oversized_and_control_character_values() {
- let database = Database::in_memory().expect("database");
- let owner = public_key(3);
- database.insert_account(&account(3)).expect("account");
- let oversized = "a".repeat(super::MAX_VALUE_CHARS + 1);
- assert!(
- database
- .set_value(owner, AccountPreferenceKey::NamespaceProbe, &oversized)
- .is_err()
- );
- assert!(
- database
- .set_value(owner, AccountPreferenceKey::NamespaceProbe, "line\nbreak")
- .is_err()
- );
- }
-}
diff --git a/crates/studio_storage/src/accounts.rs b/crates/studio_storage/src/accounts.rs
@@ -1,516 +0,0 @@
-use radroots_studio_application::{AccountRepository, AppStateRepository};
-use radroots_studio_domain::{
- AccountCreatedAt, AccountIdentity, AccountLabel, AccountSummary, BindingAvailability,
- LocalSignerBinding, PublicKey, SafeError, SafeErrorCode, SafeMessage, UnixTimestamp,
-};
-use rusqlite::{OptionalExtension, Row, params};
-
-use crate::Database;
-
-impl AccountRepository for Database {
- fn list_accounts(&self) -> Result<Vec<AccountSummary>, SafeError> {
- let connection = self.connection();
- let mut statement = connection
- .prepare(
- "SELECT identity.public_key, identity.npub, binding.binding_kind, \
- binding.availability, identity.label, identity.created_at, identity.last_used_at \
- FROM account_identities AS identity \
- JOIN local_signer_bindings AS binding \
- ON binding.account_public_key = identity.public_key \
- ORDER BY identity.created_at ASC, identity.public_key ASC",
- )
- .map_err(|_| storage_error())?;
- let rows = statement
- .query_map([], decode_account)
- .map_err(|_| storage_error())?;
- rows.map(|row| row.map_err(|_| corrupt_storage_error()))
- .collect()
- }
-
- fn find_account(&self, public_key: PublicKey) -> Result<Option<AccountSummary>, SafeError> {
- self.connection()
- .query_row(
- "SELECT identity.public_key, identity.npub, binding.binding_kind, \
- binding.availability, identity.label, identity.created_at, identity.last_used_at \
- FROM account_identities AS identity \
- JOIN local_signer_bindings AS binding \
- ON binding.account_public_key = identity.public_key \
- WHERE identity.public_key = ?1",
- [public_key.to_hex()],
- decode_account,
- )
- .optional()
- .map_err(|_| storage_error())
- }
-
- fn insert_account(&self, account: &AccountSummary) -> Result<(), SafeError> {
- let encoded = EncodedAccount::from(account);
- let mut connection = self.connection();
- let transaction = connection.transaction().map_err(|_| storage_error())?;
- let result = transaction.execute(
- "INSERT INTO account_identities (public_key, npub, label, created_at, last_used_at) \
- VALUES (?1, ?2, ?3, ?4, ?5)",
- params![
- encoded.public_key,
- encoded.npub,
- encoded.label,
- encoded.created_at,
- encoded.last_used_at
- ],
- );
- match result {
- Ok(1) => {}
- Err(error) if is_constraint_violation(&error) => return Err(account_exists()),
- Ok(_) | Err(_) => return Err(storage_error()),
- }
- if transaction
- .execute(
- "INSERT INTO local_signer_bindings (account_public_key, binding_public_key, \
- binding_kind, availability) VALUES (?1, ?1, ?2, ?3)",
- params![
- encoded.public_key,
- encoded.signer_kind,
- encoded.key_availability
- ],
- )
- .map_err(|_| storage_error())?
- != 1
- {
- return Err(storage_error());
- }
- transaction.commit().map_err(|_| storage_error())
- }
-
- fn update_account(&self, account: &AccountSummary) -> Result<(), SafeError> {
- let encoded = EncodedAccount::from(account);
- let mut connection = self.connection();
- let transaction = connection.transaction().map_err(|_| storage_error())?;
- let identity_rows = transaction
- .execute(
- "UPDATE account_identities SET npub = ?2, label = ?5, created_at = ?6, \
- last_used_at = ?7 WHERE public_key = ?1",
- params![
- encoded.public_key,
- encoded.npub,
- encoded.signer_kind,
- encoded.key_availability,
- encoded.label,
- encoded.created_at,
- encoded.last_used_at,
- ],
- )
- .map_err(|_| storage_error())?;
- if identity_rows == 0 {
- return Err(account_not_found());
- }
- if identity_rows != 1 {
- return Err(storage_error());
- }
- let binding_rows = transaction
- .execute(
- "UPDATE local_signer_bindings SET binding_kind = ?2, availability = ?3 \
- WHERE account_public_key = ?1 AND binding_public_key = ?1",
- params![
- encoded.public_key,
- encoded.signer_kind,
- encoded.key_availability
- ],
- )
- .map_err(|_| storage_error())?;
- if binding_rows != 1 {
- return Err(corrupt_storage_error());
- }
- transaction.commit().map_err(|_| storage_error())
- }
-
- fn remove_account(&self, public_key: PublicKey) -> Result<(), SafeError> {
- match self.connection().execute(
- "DELETE FROM account_identities WHERE public_key = ?1",
- [public_key.to_hex()],
- ) {
- Ok(1) => Ok(()),
- Ok(0) => Err(account_not_found()),
- Ok(_) | Err(_) => Err(storage_error()),
- }
- }
-}
-
-impl AppStateRepository for Database {
- fn load_selected_account(&self) -> Result<Option<PublicKey>, SafeError> {
- let value = self
- .connection()
- .query_row(
- "SELECT selected_public_key FROM runtime_state WHERE singleton = 1",
- [],
- |row| row.get::<_, Option<String>>(0),
- )
- .map_err(|_| corrupt_storage_error())?;
- value
- .map(|hex| PublicKey::from_hex(&hex).map_err(|_| corrupt_storage_error()))
- .transpose()
- }
-
- fn save_selected_account(&self, public_key: Option<PublicKey>) -> Result<(), SafeError> {
- let mut connection = self.connection();
- let transaction = connection.transaction().map_err(|_| storage_error())?;
- if let Some(public_key) = public_key {
- let exists = transaction
- .query_row(
- "SELECT EXISTS(SELECT 1 FROM account_identities WHERE public_key = ?1)",
- [public_key.to_hex()],
- |row| row.get::<_, bool>(0),
- )
- .map_err(|_| storage_error())?;
- if !exists {
- return Err(account_not_found());
- }
- }
- let rows = transaction
- .execute(
- "UPDATE runtime_state SET selected_public_key = ?1 WHERE singleton = 1",
- [public_key.map(PublicKey::to_hex)],
- )
- .map_err(|_| storage_error())?;
- if rows != 1 {
- return Err(corrupt_storage_error());
- }
- transaction.commit().map_err(|_| storage_error())
- }
-}
-
-struct EncodedAccount {
- public_key: String,
- npub: String,
- signer_kind: &'static str,
- key_availability: &'static str,
- label: Option<String>,
- created_at: i64,
- last_used_at: Option<i64>,
-}
-
-impl From<&AccountSummary> for EncodedAccount {
- fn from(account: &AccountSummary) -> Self {
- Self {
- public_key: account.public_key().to_hex(),
- npub: account.npub().as_str().to_owned(),
- signer_kind: "local_secret",
- key_availability: encode_key_availability(account.signer().availability()),
- label: account.label().map(|label| label.as_str().to_owned()),
- created_at: account.created_at().timestamp().as_seconds(),
- last_used_at: account.last_used_at().map(UnixTimestamp::as_seconds),
- }
- }
-}
-
-fn decode_account(row: &Row<'_>) -> rusqlite::Result<AccountSummary> {
- let public_key =
- PublicKey::from_hex(row.get::<_, String>(0)?.as_str()).map_err(|_| invalid_column(0))?;
- let npub: String = row.get(1)?;
- if row.get::<_, String>(2)?.as_str() != "local_secret" {
- return Err(invalid_column(2));
- }
- let key_availability = decode_key_availability(row.get::<_, String>(3)?.as_str())?;
- let label = row
- .get::<_, Option<String>>(4)?
- .map(|value| AccountLabel::parse(&value).map_err(|_| invalid_column(4)))
- .transpose()?;
- let created_at = UnixTimestamp::from_seconds(row.get(5)?).ok_or_else(|| invalid_column(5))?;
- let last_used_at = row
- .get::<_, Option<i64>>(6)?
- .map(|value| UnixTimestamp::from_seconds(value).ok_or_else(|| invalid_column(6)))
- .transpose()?;
-
- AccountSummary::new(
- AccountIdentity::verify(public_key, npub).map_err(|_| invalid_column(1))?,
- LocalSignerBinding::new(public_key, key_availability),
- label,
- AccountCreatedAt::new(created_at),
- last_used_at,
- )
- .map_err(|_| invalid_column(0))
-}
-
-const fn encode_key_availability(value: BindingAvailability) -> &'static str {
- match value {
- BindingAvailability::Available => "available",
- BindingAvailability::CredentialMissing => "credential_missing",
- BindingAvailability::StoreUnavailable => "store_unavailable",
- }
-}
-
-fn decode_key_availability(value: &str) -> rusqlite::Result<BindingAvailability> {
- match value {
- "available" => Ok(BindingAvailability::Available),
- "credential_missing" => Ok(BindingAvailability::CredentialMissing),
- "store_unavailable" => Ok(BindingAvailability::StoreUnavailable),
- _ => Err(invalid_column(3)),
- }
-}
-
-fn invalid_column(index: usize) -> rusqlite::Error {
- rusqlite::Error::InvalidColumnType(
- index,
- "public account metadata".to_owned(),
- rusqlite::types::Type::Text,
- )
-}
-
-fn is_constraint_violation(error: &rusqlite::Error) -> bool {
- matches!(
- error,
- rusqlite::Error::SqliteFailure(
- rusqlite::ffi::Error {
- code: rusqlite::ErrorCode::ConstraintViolation,
- ..
- },
- _
- )
- )
-}
-
-const fn storage_error() -> SafeError {
- SafeError::new(
- SafeErrorCode::StorageUnavailable,
- SafeMessage::new("The application database is unavailable."),
- )
-}
-
-const fn corrupt_storage_error() -> SafeError {
- SafeError::new(
- SafeErrorCode::StorageCorrupt,
- SafeMessage::new("The application database could not be read."),
- )
-}
-
-const fn account_exists() -> SafeError {
- SafeError::new(
- SafeErrorCode::AccountAlreadyExists,
- SafeMessage::new("The Nostr account is already saved."),
- )
-}
-
-const fn account_not_found() -> SafeError {
- SafeError::new(
- SafeErrorCode::AccountNotFound,
- SafeMessage::new("The account was not found."),
- )
-}
-
-#[cfg(test)]
-mod tests {
- use std::fs;
-
- use radroots_studio_application::{AccountRepository, AppStateRepository};
- use radroots_studio_domain::{
- AccountCreatedAt, AccountIdentity, AccountLabel, AccountSummary, BindingAvailability,
- LocalSignerBinding, PublicKey, SafeErrorCode, UnixTimestamp,
- };
- use tempfile::tempdir;
-
- use crate::Database;
-
- fn public_key(key_byte: u8) -> PublicKey {
- let value = match key_byte {
- 1 => "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df",
- 2 => "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af",
- _ => "7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7",
- };
- PublicKey::from_hex(value).expect("valid public key")
- }
-
- fn account(key_byte: u8, created_at: i64) -> AccountSummary {
- let public_key = public_key(key_byte);
- AccountSummary::new(
- AccountIdentity::derive(public_key).expect("identity"),
- LocalSignerBinding::new(public_key, BindingAvailability::Available),
- Some(AccountLabel::parse("Farm account").expect("valid label")),
- AccountCreatedAt::new(
- UnixTimestamp::from_seconds(created_at).expect("valid timestamp"),
- ),
- None,
- )
- .expect("account")
- }
-
- #[test]
- fn accounts_insert_list_update_and_reject_duplicates() {
- let database = Database::in_memory().expect("database");
- let first = account(1, 20);
- let second = account(2, 10);
-
- database.insert_account(&first).expect("insert first");
- database.insert_account(&second).expect("insert second");
- let duplicate = database.insert_account(&first).expect_err("duplicate");
-
- assert_eq!(duplicate.code(), SafeErrorCode::AccountAlreadyExists);
- assert_eq!(
- database.list_accounts().expect("list"),
- vec![second, first.clone()]
- );
- assert_eq!(
- database.find_account(first.public_key()).expect("find"),
- Some(first)
- );
- }
-
- #[test]
- fn accounts_and_selection_survive_restart_without_secret_text() {
- let directory = tempdir().expect("temporary directory");
- let path = directory.path().join("studio.sqlite3");
- let account = account(3, 30);
-
- {
- let database = Database::open(&path).expect("database");
- database.insert_account(&account).expect("insert");
- database
- .save_selected_account(Some(account.public_key()))
- .expect("select");
- }
- let reopened = Database::open(&path).expect("reopen");
-
- assert_eq!(
- reopened.list_accounts().expect("list"),
- vec![account.clone()]
- );
- assert_eq!(
- reopened.load_selected_account().expect("selection"),
- Some(account.public_key())
- );
- let bytes = fs::read(path).expect("database bytes");
- assert!(!String::from_utf8_lossy(&bytes).contains("nsec1known-test-secret"));
- }
-
- #[test]
- fn selection_requires_an_existing_account_and_clears_on_delete() {
- let database = Database::in_memory().expect("database");
- let account = account(4, 40);
-
- let missing = database
- .save_selected_account(Some(account.public_key()))
- .expect_err("missing account");
- assert_eq!(missing.code(), SafeErrorCode::AccountNotFound);
-
- database.insert_account(&account).expect("insert");
- database
- .save_selected_account(Some(account.public_key()))
- .expect("select");
- database
- .remove_account(account.public_key())
- .expect("remove");
-
- assert_eq!(database.load_selected_account().expect("selection"), None);
- }
-
- #[test]
- fn account_mutations_reject_missing_and_corrupt_rows() {
- let database = Database::in_memory().expect("database");
- let missing = account(3, 30);
- assert_eq!(
- database
- .update_account(&missing)
- .expect_err("missing update")
- .code(),
- SafeErrorCode::AccountNotFound
- );
- assert_eq!(
- database
- .remove_account(missing.public_key())
- .expect_err("missing removal")
- .code(),
- SafeErrorCode::AccountNotFound
- );
- assert_eq!(
- database.find_account(missing.public_key()).expect("find"),
- None
- );
-
- database.insert_account(&missing).expect("insert");
- database.update_account(&missing).expect("update");
- database
- .connection()
- .execute(
- "DELETE FROM local_signer_bindings WHERE account_public_key = ?1",
- [missing.public_key().to_hex()],
- )
- .expect("delete binding");
- assert_eq!(
- database
- .update_account(&missing)
- .expect_err("missing binding must fail")
- .code(),
- SafeErrorCode::StorageCorrupt
- );
- database
- .connection()
- .execute(
- "INSERT INTO local_signer_bindings (account_public_key, binding_public_key, binding_kind, availability) VALUES (?1, ?1, 'local_secret', 'available')",
- [missing.public_key().to_hex()],
- )
- .expect("restore binding");
- database
- .connection()
- .pragma_update(None, "ignore_check_constraints", "ON")
- .expect("disable check constraints for corruption fixture");
- database
- .connection()
- .execute(
- "UPDATE local_signer_bindings SET binding_kind = 'remote' WHERE account_public_key = ?1",
- [missing.public_key().to_hex()],
- )
- .expect("corrupt binding kind");
- assert_eq!(
- database
- .list_accounts()
- .expect_err("corrupt binding must fail")
- .code(),
- SafeErrorCode::StorageCorrupt
- );
-
- let database = Database::in_memory().expect("database");
- database.insert_account(&missing).expect("insert");
- database
- .connection()
- .pragma_update(None, "ignore_check_constraints", "ON")
- .expect("disable check constraints for corruption fixture");
- database
- .connection()
- .execute(
- "UPDATE local_signer_bindings SET availability = 'invalid' WHERE account_public_key = ?1",
- [missing.public_key().to_hex()],
- )
- .expect("corrupt availability");
- assert_eq!(
- database
- .find_account(missing.public_key())
- .expect_err("corrupt availability must fail")
- .code(),
- SafeErrorCode::StorageUnavailable
- );
-
- let database = Database::in_memory().expect("database");
- database
- .connection()
- .execute("DELETE FROM runtime_state", [])
- .expect("delete runtime singleton");
- assert_eq!(
- database
- .save_selected_account(None)
- .expect_err("missing runtime singleton must fail")
- .code(),
- SafeErrorCode::StorageCorrupt
- );
-
- let read_only = Database::in_memory().expect("read-only database");
- read_only
- .connection()
- .pragma_update(None, "query_only", "ON")
- .expect("enable query-only mode");
- assert_eq!(
- read_only
- .insert_account(&missing)
- .expect_err("non-constraint insertion failure must fail closed")
- .code(),
- SafeErrorCode::StorageUnavailable
- );
- }
-}
diff --git a/crates/studio_storage/src/compatibility.rs b/crates/studio_storage/src/compatibility.rs
@@ -1,474 +0,0 @@
-use std::path::Path;
-
-use radroots_studio_domain::{
- AccountIdentity, PersistedPublicKeyClassification, SafeError, SafeErrorCode, SafeMessage,
- classify_persisted_public_key,
-};
-use rusqlite::{Connection, OpenFlags};
-use sha2::{Digest, Sha256};
-
-use crate::CURRENT_SCHEMA_VERSION;
-
-const KNOWN_TABLES: &[(&str, u32)] = &[
- ("application_schema", 1),
- ("accounts", 2),
- ("app_state", 2),
- ("profile_cache", 3),
- ("account_namespace", 4),
- ("operation_journal", 5),
- ("account_identities", 6),
- ("local_signer_bindings", 6),
- ("runtime_state", 6),
- ("profile_cache_v6", 6),
- ("durable_operations", 6),
- ("account_preferences", 8),
- ("installation_identity", 10),
-];
-
-const PUBLIC_KEY_COLUMNS: &[(&str, &str)] = &[
- ("accounts", "pubkey"),
- ("app_state", "selected_pubkey"),
- ("profile_cache", "subject_pubkey"),
- ("account_namespace", "owner_pubkey"),
- ("operation_journal", "subject_pubkey"),
- ("account_identities", "public_key"),
- ("local_signer_bindings", "account_public_key"),
- ("local_signer_bindings", "binding_public_key"),
- ("runtime_state", "selected_public_key"),
- ("runtime_state", "active_account_public_key"),
- ("runtime_state", "active_binding_public_key"),
- ("profile_cache_v6", "subject_public_key"),
- ("durable_operations", "account_public_key"),
- ("durable_operations", "binding_public_key"),
- ("durable_operations", "prior_selected_public_key"),
- ("account_preferences", "owner_public_key"),
-];
-
-#[derive(Clone, Debug, Eq, PartialEq)]
-pub enum DatabasePreflight {
- Fresh,
- Ready {
- schema_version: u32,
- },
- Quarantined {
- schema_version: u32,
- issues: Vec<PersistedIdentityIssue>,
- },
-}
-
-#[derive(Clone, Copy, Debug, Eq, PartialEq)]
-pub enum PersistedIdentityIssueKind {
- MalformedEncoding,
- NonCanonicalEncoding,
- InvalidCurvePoint,
- DisplayIdentityMismatch,
-}
-
-#[derive(Clone, Debug, Eq, PartialEq)]
-pub struct PersistedIdentityIssue {
- table: &'static str,
- column: &'static str,
- row_id: i64,
- kind: PersistedIdentityIssueKind,
- fingerprint: [u8; 32],
-}
-
-impl PersistedIdentityIssue {
- #[must_use]
- pub const fn table(&self) -> &'static str {
- self.table
- }
-
- #[must_use]
- pub const fn column(&self) -> &'static str {
- self.column
- }
-
- #[must_use]
- pub const fn row_id(&self) -> i64 {
- self.row_id
- }
-
- #[must_use]
- pub const fn kind(&self) -> PersistedIdentityIssueKind {
- self.kind
- }
-
- #[must_use]
- pub const fn fingerprint(&self) -> &[u8; 32] {
- &self.fingerprint
- }
-}
-
-pub(crate) fn preflight(path: &Path) -> Result<DatabasePreflight, SafeError> {
- match std::fs::symlink_metadata(path) {
- Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_file() => {
- return Err(corrupt_storage_error());
- }
- Ok(_) => {}
- Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
- return Ok(DatabasePreflight::Fresh);
- }
- Err(_) => return Err(corrupt_storage_error()),
- }
- let flags = OpenFlags::SQLITE_OPEN_READ_ONLY
- | OpenFlags::SQLITE_OPEN_NO_MUTEX
- | OpenFlags::SQLITE_OPEN_NOFOLLOW;
- let connection =
- Connection::open_with_flags(path, flags).map_err(|_| corrupt_storage_error())?;
- connection
- .pragma_update(None, "trusted_schema", "OFF")
- .map_err(|_| corrupt_storage_error())?;
- let integrity: String = connection
- .pragma_query_value(None, "quick_check", |row| row.get(0))
- .map_err(|_| corrupt_storage_error())?;
- if integrity != "ok" {
- return Err(corrupt_storage_error());
- }
- let schema_version = schema_version(&connection)?;
- if schema_version == 0 || schema_version > CURRENT_SCHEMA_VERSION {
- return Err(unsupported_schema_error());
- }
- validate_schema_inventory(&connection, schema_version)?;
-
- let mut issues = Vec::new();
- for &(table, column) in PUBLIC_KEY_COLUMNS {
- if column_exists(&connection, table, column)? {
- scan_public_key_column(&connection, table, column, &mut issues)?;
- }
- }
- scan_display_identities(&connection, "accounts", "pubkey", "npub", &mut issues)?;
- scan_display_identities(
- &connection,
- "account_identities",
- "public_key",
- "npub",
- &mut issues,
- )?;
- issues.sort_by_key(|issue| (issue.table, issue.column, issue.row_id));
- if issues.is_empty() {
- Ok(DatabasePreflight::Ready { schema_version })
- } else {
- Ok(DatabasePreflight::Quarantined {
- schema_version,
- issues,
- })
- }
-}
-
-fn schema_version(connection: &Connection) -> Result<u32, SafeError> {
- if !table_exists(connection, "refinery_schema_history")? {
- return Err(unsupported_schema_error());
- }
- connection
- .query_row(
- "SELECT COALESCE(MAX(version), 0) FROM refinery_schema_history",
- [],
- |row| row.get(0),
- )
- .map_err(|_| corrupt_storage_error())
-}
-
-fn validate_schema_inventory(connection: &Connection, version: u32) -> Result<(), SafeError> {
- for &(table, introduced) in KNOWN_TABLES {
- let present = table_exists(connection, table)?;
- if present != (version >= introduced) {
- return Err(corrupt_storage_error());
- }
- }
- let mut statement = connection
- .prepare(
- "SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' AND name <> 'refinery_schema_history'",
- )
- .map_err(|_| corrupt_storage_error())?;
- let names = statement
- .query_map([], |row| row.get::<_, String>(0))
- .map_err(|_| corrupt_storage_error())?;
- for name in names {
- let name = name.map_err(|_| corrupt_storage_error())?;
- if !KNOWN_TABLES.iter().any(|(known, _)| *known == name) {
- return Err(corrupt_storage_error());
- }
- }
- Ok(())
-}
-
-fn scan_public_key_column(
- connection: &Connection,
- table: &'static str,
- column: &'static str,
- issues: &mut Vec<PersistedIdentityIssue>,
-) -> Result<(), SafeError> {
- let sql = format!("SELECT rowid, {column} FROM {table} WHERE {column} IS NOT NULL");
- let mut statement = connection
- .prepare(&sql)
- .map_err(|_| corrupt_storage_error())?;
- let rows = statement
- .query_map([], |row| {
- Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?))
- })
- .map_err(|_| corrupt_storage_error())?;
- for row in rows {
- let (row_id, value) = row.map_err(|_| corrupt_storage_error())?;
- let kind = match classify_persisted_public_key(&value) {
- PersistedPublicKeyClassification::Canonical(_) => continue,
- PersistedPublicKeyClassification::MalformedEncoding => {
- PersistedIdentityIssueKind::MalformedEncoding
- }
- PersistedPublicKeyClassification::NonCanonicalEncoding => {
- PersistedIdentityIssueKind::NonCanonicalEncoding
- }
- PersistedPublicKeyClassification::InvalidCurvePoint => {
- PersistedIdentityIssueKind::InvalidCurvePoint
- }
- };
- issues.push(issue(table, column, row_id, kind, &value));
- }
- Ok(())
-}
-
-fn scan_display_identities(
- connection: &Connection,
- table: &'static str,
- key_column: &'static str,
- npub_column: &'static str,
- issues: &mut Vec<PersistedIdentityIssue>,
-) -> Result<(), SafeError> {
- if !column_exists(connection, table, key_column)?
- || !column_exists(connection, table, npub_column)?
- {
- return Ok(());
- }
- let sql = format!("SELECT rowid, {key_column}, {npub_column} FROM {table}");
- let mut statement = connection
- .prepare(&sql)
- .map_err(|_| corrupt_storage_error())?;
- let rows = statement
- .query_map([], |row| {
- Ok((
- row.get::<_, i64>(0)?,
- row.get::<_, String>(1)?,
- row.get::<_, String>(2)?,
- ))
- })
- .map_err(|_| corrupt_storage_error())?;
- for row in rows {
- let (row_id, key, npub) = row.map_err(|_| corrupt_storage_error())?;
- let PersistedPublicKeyClassification::Canonical(public_key) =
- classify_persisted_public_key(&key)
- else {
- continue;
- };
- if AccountIdentity::verify(public_key, npub.clone()).is_err() {
- issues.push(issue(
- table,
- npub_column,
- row_id,
- PersistedIdentityIssueKind::DisplayIdentityMismatch,
- &npub,
- ));
- }
- }
- Ok(())
-}
-
-fn issue(
- table: &'static str,
- column: &'static str,
- row_id: i64,
- kind: PersistedIdentityIssueKind,
- value: &str,
-) -> PersistedIdentityIssue {
- PersistedIdentityIssue {
- table,
- column,
- row_id,
- kind,
- fingerprint: Sha256::digest(value.as_bytes()).into(),
- }
-}
-
-fn table_exists(connection: &Connection, table: &str) -> Result<bool, SafeError> {
- connection
- .query_row(
- "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?1)",
- [table],
- |row| row.get(0),
- )
- .map_err(|_| corrupt_storage_error())
-}
-
-fn column_exists(connection: &Connection, table: &str, column: &str) -> Result<bool, SafeError> {
- if !table_exists(connection, table)? {
- return Ok(false);
- }
- let sql = format!("SELECT EXISTS(SELECT 1 FROM pragma_table_info('{table}') WHERE name = ?1)");
- connection
- .query_row(&sql, [column], |row| row.get(0))
- .map_err(|_| corrupt_storage_error())
-}
-
-const fn corrupt_storage_error() -> SafeError {
- SafeError::new(
- SafeErrorCode::StorageCorrupt,
- SafeMessage::new("The application database could not be read."),
- )
-}
-
-const fn unsupported_schema_error() -> SafeError {
- SafeError::new(
- SafeErrorCode::UnsupportedSchemaVersion,
- SafeMessage::new("The application database schema is not supported."),
- )
-}
-
-pub(crate) const fn quarantined_storage_error() -> SafeError {
- SafeError::new(
- SafeErrorCode::StorageQuarantined,
- SafeMessage::new("The application database requires authenticated repair."),
- )
-}
-
-#[cfg(test)]
-mod tests {
- use rusqlite::{Connection, params};
- use tempfile::tempdir;
-
- use super::{
- DatabasePreflight, PersistedIdentityIssueKind, column_exists, preflight,
- scan_display_identities, scan_public_key_column,
- };
- use crate::Database;
- use radroots_studio_domain::{AccountIdentity, PublicKey, SafeErrorCode};
-
- #[test]
- fn preflight_rejects_non_files_missing_schema_zero_version_and_unknown_tables() {
- let directory = tempdir().expect("temporary directory");
- let missing = directory.path().join("missing.sqlite3");
- assert_eq!(
- preflight(&missing).expect("fresh preflight"),
- DatabasePreflight::Fresh
- );
- assert_eq!(
- preflight(directory.path())
- .expect_err("directory must fail")
- .code(),
- SafeErrorCode::StorageCorrupt
- );
- let regular_parent = directory.path().join("regular-parent");
- std::fs::write(®ular_parent, b"not a directory").expect("write regular parent");
- assert_eq!(
- preflight(®ular_parent.join("nested.sqlite3"))
- .expect_err("non-directory parent must fail")
- .code(),
- SafeErrorCode::StorageCorrupt
- );
-
- let no_schema = directory.path().join("no-schema.sqlite3");
- drop(Connection::open(&no_schema).expect("blank sqlite database"));
- assert_eq!(
- preflight(&no_schema)
- .expect_err("missing schema history")
- .code(),
- SafeErrorCode::UnsupportedSchemaVersion
- );
-
- let zero_schema = directory.path().join("zero-schema.sqlite3");
- let connection = Connection::open(&zero_schema).expect("zero schema database");
- connection
- .execute(
- "CREATE TABLE refinery_schema_history (version INTEGER NOT NULL)",
- [],
- )
- .expect("schema history");
- connection
- .execute(
- "INSERT INTO refinery_schema_history (version) VALUES (0)",
- [],
- )
- .expect("zero version");
- drop(connection);
- assert_eq!(
- preflight(&zero_schema)
- .expect_err("zero schema version")
- .code(),
- SafeErrorCode::UnsupportedSchemaVersion
- );
-
- let unknown = directory.path().join("unknown-table.sqlite3");
- drop(Database::open(&unknown).expect("current database"));
- let connection = Connection::open(&unknown).expect("open current database");
- connection
- .execute("CREATE TABLE ungoverned_table (value INTEGER)", [])
- .expect("unknown table");
- drop(connection);
- assert_eq!(
- preflight(&unknown)
- .expect_err("unknown table must fail")
- .code(),
- SafeErrorCode::StorageCorrupt
- );
- }
-
- #[test]
- fn identity_scans_classify_all_persisted_key_and_display_failures() {
- let connection = Connection::open_in_memory().expect("database");
- connection
- .execute("CREATE TABLE identities (public_key TEXT, npub TEXT)", [])
- .expect("identity table");
- let canonical =
- PublicKey::from_hex("585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df")
- .expect("canonical key");
- let npub = AccountIdentity::derive(canonical)
- .expect("identity")
- .npub()
- .as_str()
- .to_owned();
- let values = [
- (canonical.to_hex(), npub),
- (canonical.to_hex().to_uppercase(), "invalid-npub".to_owned()),
- ("bad".to_owned(), "invalid-npub".to_owned()),
- ("00".repeat(32), "invalid-npub".to_owned()),
- (canonical.to_hex(), "invalid-npub".to_owned()),
- ];
- for (public_key, npub) in values {
- connection
- .execute(
- "INSERT INTO identities (public_key, npub) VALUES (?1, ?2)",
- params![public_key, npub],
- )
- .expect("identity row");
- }
-
- assert!(column_exists(&connection, "identities", "public_key").expect("column"));
- assert!(!column_exists(&connection, "missing", "public_key").expect("missing table"));
- assert!(!column_exists(&connection, "identities", "missing").expect("missing column"));
- let mut issues = Vec::new();
- scan_public_key_column(&connection, "identities", "public_key", &mut issues)
- .expect("scan public keys");
- scan_display_identities(&connection, "identities", "public_key", "npub", &mut issues)
- .expect("scan display identities");
- scan_display_identities(&connection, "missing", "public_key", "npub", &mut issues)
- .expect("skip missing table");
- connection
- .execute("CREATE TABLE key_only (public_key TEXT)", [])
- .expect("key-only table");
- scan_display_identities(&connection, "key_only", "public_key", "npub", &mut issues)
- .expect("skip missing display column");
-
- for kind in [
- PersistedIdentityIssueKind::MalformedEncoding,
- PersistedIdentityIssueKind::NonCanonicalEncoding,
- PersistedIdentityIssueKind::InvalidCurvePoint,
- PersistedIdentityIssueKind::DisplayIdentityMismatch,
- ] {
- assert!(issues.iter().any(|issue| issue.kind() == kind));
- }
- for issue in &issues {
- assert_eq!(issue.table(), "identities");
- assert!(matches!(issue.column(), "public_key" | "npub"));
- assert!(issue.row_id() > 0);
- assert_ne!(issue.fingerprint(), &[0_u8; 32]);
- }
- }
-}
diff --git a/crates/studio_storage/src/db.rs b/crates/studio_storage/src/db.rs
@@ -1,907 +0,0 @@
-use std::fs::{self, File, OpenOptions};
-use std::ops::{Deref, DerefMut};
-use std::path::{Path, PathBuf};
-use std::sync::{Mutex, MutexGuard};
-use std::time::Duration;
-
-use fs2::FileExt;
-use radroots_studio_domain::{SafeError, SafeErrorCode, SafeMessage};
-use refinery::embed_migrations;
-use rusqlite::{Connection, OpenFlags};
-
-use crate::compatibility::{DatabasePreflight, preflight, quarantined_storage_error};
-use crate::recovery::MigrationRecovery;
-use crate::repair::{
- QuarantineExportReceipt, RepairAuthorization, RepairCandidate, authenticate_candidate,
- export_quarantined, install_candidate,
-};
-
-pub const CURRENT_SCHEMA_VERSION: u32 = 10;
-
-mod migrations {
- use super::embed_migrations;
-
- embed_migrations!("migrations");
-}
-
-pub struct Database {
- connection: Mutex<Connection>,
- path: Option<PathBuf>,
- _ownership: Option<WritableOwnership>,
-}
-
-pub(crate) struct DatabaseConnection<'a> {
- connection: MutexGuard<'a, Connection>,
- path: Option<&'a Path>,
-}
-
-struct WritableOwnership {
- _file: File,
-}
-
-impl Database {
- /// Opens, configures, and migrates a file-backed `SQLite` database.
- ///
- /// # Errors
- ///
- /// Returns a safe storage error when the file, connection configuration,
- /// permission update, or migration cannot complete.
- pub fn open(path: &Path) -> Result<Self, SafeError> {
- let preflight = preflight(path)?;
- if matches!(&preflight, DatabasePreflight::Quarantined { .. }) {
- return Err(quarantined_storage_error());
- }
- let parent = path.parent().ok_or_else(storage_error)?;
- create_secure_directory(parent)?;
- restrict_sqlite_sidecars(path)?;
- let ownership = WritableOwnership::acquire(path)?;
- let recovery_source_schema = match &preflight {
- DatabasePreflight::Ready { schema_version }
- if *schema_version < CURRENT_SCHEMA_VERSION =>
- {
- Some(*schema_version)
- }
- _ => None,
- };
- let recovery = match preflight {
- DatabasePreflight::Ready { schema_version }
- if schema_version < CURRENT_SCHEMA_VERSION =>
- {
- Some(MigrationRecovery::prepare(
- path,
- schema_version,
- CURRENT_SCHEMA_VERSION,
- )?)
- }
- DatabasePreflight::Fresh | DatabasePreflight::Ready { .. } => None,
- DatabasePreflight::Quarantined { .. } => unreachable!("handled above"),
- };
- let flags = OpenFlags::SQLITE_OPEN_READ_WRITE
- | OpenFlags::SQLITE_OPEN_CREATE
- | OpenFlags::SQLITE_OPEN_NO_MUTEX
- | OpenFlags::SQLITE_OPEN_NOFOLLOW;
- let mut connection =
- Connection::open_with_flags(path, flags).map_err(|_| storage_error())?;
- configure(&connection).map_err(|_| corrupt_storage_error())?;
- if migrations::migrations::runner()
- .run(&mut connection)
- .is_err()
- {
- drop(connection);
- if let Some(source_schema) = recovery_source_schema {
- MigrationRecovery::restore(path, source_schema, CURRENT_SCHEMA_VERSION)?;
- }
- return Err(corrupt_storage_error());
- }
- let schema_version = connection
- .query_row(
- "SELECT COALESCE(MAX(version), 0) FROM refinery_schema_history",
- [],
- |row| row.get::<_, u32>(0),
- )
- .map_err(|_| corrupt_storage_error())?;
- if schema_version != CURRENT_SCHEMA_VERSION {
- return Err(corrupt_storage_error());
- }
- restrict_file_permissions(path)?;
- restrict_sqlite_sidecars(path)?;
- if let Some(recovery) = recovery {
- recovery.finish(schema_version)?;
- }
- Ok(Self {
- connection: Mutex::new(connection),
- path: Some(path.to_path_buf()),
- _ownership: Some(ownership),
- })
- }
-
- /// Opens and migrates an isolated in-memory `SQLite` database.
- ///
- /// # Errors
- ///
- /// Returns a safe storage error when configuration or migration fails.
- pub fn in_memory() -> Result<Self, SafeError> {
- let mut connection = Connection::open_in_memory().map_err(|_| storage_error())?;
- configure(&connection)?;
- migrations::migrations::runner()
- .run(&mut connection)
- .map_err(|_| corrupt_storage_error())?;
- Ok(Self {
- connection: Mutex::new(connection),
- path: None,
- _ownership: None,
- })
- }
-
- /// Inspects schema and persisted identities without mutating the database.
- ///
- /// # Errors
- ///
- /// Returns a safe corrupt or unsupported-schema error when the database
- /// cannot be classified.
- pub fn preflight(path: &Path) -> Result<DatabasePreflight, SafeError> {
- preflight(path)
- }
-
- /// Verifies the authenticated, immutable backup retained for a migration.
- ///
- /// # Errors
- ///
- /// Returns a safe backup error when any manifest, digest, authentication
- /// tag, schema identity, or SQLite integrity check fails.
- pub fn verify_migration_backup(path: &Path, source_schema: u32) -> Result<(), SafeError> {
- MigrationRecovery::verify_evidence(path, source_schema, CURRENT_SCHEMA_VERSION)
- }
-
- /// Restores an authenticated pre-migration backup while retaining the
- /// displaced database as recovery evidence.
- ///
- /// # Errors
- ///
- /// Returns a safe storage or backup error without replacing the database
- /// when authentication or the atomic replacement fails.
- pub fn restore_migration_backup(path: &Path, source_schema: u32) -> Result<(), SafeError> {
- let _ownership = WritableOwnership::acquire(path)?;
- MigrationRecovery::restore(path, source_schema, CURRENT_SCHEMA_VERSION)
- }
-
- /// Exports a quarantined database without mutating it and authenticates
- /// the resulting SQLite artifact with a caller-owned repair capability.
- ///
- /// # Errors
- ///
- /// Returns a safe state, authorization, or storage error.
- pub fn export_quarantined(
- path: &Path,
- destination: &Path,
- authorization: &RepairAuthorization,
- ) -> Result<QuarantineExportReceipt, SafeError> {
- export_quarantined(path, destination, authorization)
- }
-
- /// Validates and authenticates a canonical repaired database candidate.
- ///
- /// # Errors
- ///
- /// Returns a safe compatibility or storage error for an invalid candidate.
- pub fn authenticate_repair_candidate(
- path: &Path,
- authorization: &RepairAuthorization,
- ) -> Result<RepairCandidate, SafeError> {
- authenticate_candidate(path, authorization)
- }
-
- /// Atomically installs an authenticated candidate over a quarantined
- /// database while retaining the original as immutable evidence.
- ///
- /// # Errors
- ///
- /// Returns a safe authorization, ownership, or storage error without
- /// replacing the target when any gate fails.
- pub fn install_repair_candidate(
- path: &Path,
- candidate: &RepairCandidate,
- authorization: &RepairAuthorization,
- ) -> Result<(), SafeError> {
- let _ownership = WritableOwnership::acquire(path)?;
- install_candidate(path, candidate, authorization)
- }
-
- /// Returns the highest successfully applied migration version.
- ///
- /// # Errors
- ///
- /// Returns a safe storage error when migration history cannot be read.
- pub fn schema_version(&self) -> Result<u32, SafeError> {
- self.connection()
- .query_row(
- "SELECT COALESCE(MAX(version), 0) FROM refinery_schema_history",
- [],
- |row| row.get(0),
- )
- .map_err(|_| corrupt_storage_error())
- }
-
- pub(crate) fn connection(&self) -> DatabaseConnection<'_> {
- DatabaseConnection {
- connection: self
- .connection
- .lock()
- .unwrap_or_else(std::sync::PoisonError::into_inner),
- path: self.path.as_deref(),
- }
- }
-}
-
-impl Deref for DatabaseConnection<'_> {
- type Target = Connection;
-
- fn deref(&self) -> &Self::Target {
- &self.connection
- }
-}
-
-impl DerefMut for DatabaseConnection<'_> {
- fn deref_mut(&mut self) -> &mut Self::Target {
- &mut self.connection
- }
-}
-
-impl Drop for DatabaseConnection<'_> {
- fn drop(&mut self) {
- if let Some(path) = self.path {
- let _ = restrict_sqlite_sidecars(path);
- }
- }
-}
-
-impl WritableOwnership {
- fn acquire(database_path: &Path) -> Result<Self, SafeError> {
- let lock_path = database_path.with_extension("sqlite3.lock");
- let mut options = OpenOptions::new();
- options.read(true).write(true).create(true).truncate(false);
- #[cfg(unix)]
- {
- use std::os::unix::fs::OpenOptionsExt;
- options.custom_flags(
- (rustix::fs::OFlags::NOFOLLOW | rustix::fs::OFlags::CLOEXEC).bits() as i32,
- );
- }
- let file = options.open(&lock_path).map_err(|_| storage_error())?;
- restrict_file_permissions(&lock_path)?;
- file.try_lock_exclusive().map_err(|_| ownership_error())?;
- Ok(Self { _file: file })
- }
-}
-
-fn create_secure_directory(path: &Path) -> Result<(), SafeError> {
- let mut existing = path;
- loop {
- match fs::symlink_metadata(existing) {
- Ok(metadata) => {
- if metadata.file_type().is_symlink() || !metadata.is_dir() {
- return Err(storage_error());
- }
- break;
- }
- Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
- existing = existing.parent().ok_or_else(storage_error)?;
- }
- Err(_) => return Err(storage_error()),
- }
- }
- fs::create_dir_all(path).map_err(|_| storage_error())?;
- let metadata = fs::symlink_metadata(path).map_err(|_| storage_error())?;
- if metadata.file_type().is_symlink() || !metadata.is_dir() {
- return Err(storage_error());
- }
- restrict_directory_permissions(path)
-}
-
-fn configure(connection: &Connection) -> Result<(), SafeError> {
- connection
- .pragma_update(None, "foreign_keys", "ON")
- .and_then(|()| connection.pragma_update(None, "trusted_schema", "OFF"))
- .and_then(|()| connection.pragma_update(None, "journal_mode", "WAL"))
- .and_then(|()| connection.pragma_update(None, "synchronous", "FULL"))
- .and_then(|()| connection.pragma_update(None, "secure_delete", "ON"))
- .and_then(|()| connection.pragma_update(None, "wal_autocheckpoint", 1_000))
- .and_then(|()| connection.busy_timeout(Duration::from_secs(5)))
- .map_err(|_| storage_error())
-}
-
-fn restrict_sqlite_sidecars(path: &Path) -> Result<(), SafeError> {
- for suffix in ["-wal", "-shm"] {
- let sidecar = PathBuf::from(format!("{}{suffix}", path.display()));
- match fs::symlink_metadata(&sidecar) {
- Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_file() => {
- return Err(storage_error());
- }
- Ok(_) => restrict_file_permissions(&sidecar)?,
- Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
- Err(_) => return Err(storage_error()),
- }
- }
- Ok(())
-}
-
-#[cfg(unix)]
-pub(crate) fn restrict_file_permissions(path: &Path) -> Result<(), SafeError> {
- use std::os::unix::fs::PermissionsExt;
-
- fs::set_permissions(path, fs::Permissions::from_mode(0o600)).map_err(|_| storage_error())
-}
-
-#[cfg(unix)]
-pub(crate) fn restrict_directory_permissions(path: &Path) -> Result<(), SafeError> {
- use std::os::unix::fs::PermissionsExt;
-
- fs::set_permissions(path, fs::Permissions::from_mode(0o700)).map_err(|_| storage_error())
-}
-
-#[cfg(not(unix))]
-pub(crate) fn restrict_file_permissions(_path: &Path) -> Result<(), SafeError> {
- Ok(())
-}
-
-#[cfg(not(unix))]
-pub(crate) fn restrict_directory_permissions(_path: &Path) -> Result<(), SafeError> {
- Ok(())
-}
-
-const fn storage_error() -> SafeError {
- SafeError::new(
- SafeErrorCode::StorageUnavailable,
- SafeMessage::new("The application database is unavailable."),
- )
-}
-
-const fn corrupt_storage_error() -> SafeError {
- SafeError::new(
- SafeErrorCode::StorageCorrupt,
- SafeMessage::new("The application database could not be read."),
- )
-}
-
-const fn ownership_error() -> SafeError {
- SafeError::new(
- SafeErrorCode::StorageUnavailable,
- SafeMessage::new("The application database is already in use."),
- )
-}
-
-#[cfg(test)]
-mod tests {
- use std::fs;
- use std::io::Write;
- use std::path::Path;
- use std::process::Command;
-
- use tempfile::tempdir;
-
- use radroots_studio_application::{AccountRepository, AppStateRepository};
- use radroots_studio_domain::{PublicKey, SafeErrorCode};
- use refinery::Target;
- use rusqlite::Connection;
-
- use super::{
- CURRENT_SCHEMA_VERSION, Database, configure, create_secure_directory, migrations,
- restrict_sqlite_sidecars,
- };
- use crate::{DatabasePreflight, PersistedIdentityIssueKind, RepairAuthorization};
-
- #[test]
- fn migration_opens_fresh_memory_database_once() {
- let database = Database::in_memory().expect("open memory database");
-
- assert_eq!(
- database.schema_version().expect("schema version"),
- CURRENT_SCHEMA_VERSION
- );
- assert_eq!(
- database.schema_version().expect("repeat schema version"),
- CURRENT_SCHEMA_VERSION
- );
- }
-
- #[test]
- fn database_path_guards_reject_files_as_directories_and_sidecars() {
- let directory = tempdir().expect("temporary directory");
- let regular = directory.path().join("regular");
- fs::write(®ular, b"file").expect("write regular file");
- assert!(create_secure_directory(®ular).is_err());
-
- let database = directory.path().join("studio.sqlite3");
- fs::write(&database, b"database").expect("write database file");
- fs::create_dir(directory.path().join("studio.sqlite3-wal"))
- .expect("create invalid WAL sidecar");
- assert!(restrict_sqlite_sidecars(&database).is_err());
- }
-
- #[test]
- fn sqlite_connection_enforces_trust_durability_and_busy_policy() {
- let database = Database::in_memory().expect("open memory database");
- let connection = database.connection();
-
- assert_eq!(
- connection
- .pragma_query_value(None, "foreign_keys", |row| row.get::<_, u8>(0))
- .expect("foreign keys"),
- 1
- );
- assert_eq!(
- connection
- .pragma_query_value(None, "trusted_schema", |row| row.get::<_, u8>(0))
- .expect("trusted schema"),
- 0
- );
- assert_eq!(
- connection
- .pragma_query_value(None, "synchronous", |row| row.get::<_, u8>(0))
- .expect("synchronous"),
- 2
- );
- assert_eq!(
- connection
- .pragma_query_value(None, "busy_timeout", |row| row.get::<_, i64>(0))
- .expect("busy timeout"),
- 5_000
- );
- }
-
- #[test]
- fn normalized_schema_is_strict_and_enforces_same_account_bindings() {
- let database = Database::in_memory().expect("open memory database");
- let connection = database.connection();
- let strict_tables: i64 = connection
- .query_row(
- "SELECT COUNT(*) FROM pragma_table_list WHERE name IN ('account_identities', 'local_signer_bindings', 'runtime_state', 'profile_cache_v6', 'durable_operations') AND strict = 1",
- [],
- |row| row.get(0),
- )
- .expect("strict table inventory");
- assert_eq!(strict_tables, 5);
-
- connection
- .execute(
- "INSERT INTO account_identities (public_key, npub, created_at) VALUES (?1, ?2, 1)",
- [
- "07".repeat(32),
- "npub1qurswpc8qurswpc8qurswpc8qurswpc8qurswpc8qurswpc8qursnvjvl7".to_owned(),
- ],
- )
- .expect("identity");
- assert!(
- connection
- .execute(
- "INSERT INTO local_signer_bindings (account_public_key, binding_public_key, binding_kind, availability) VALUES (?1, ?2, 'local_secret', 'available')",
- ["07".repeat(32), "08".repeat(32)],
- )
- .is_err()
- );
- }
-
- #[test]
- fn v5_data_migrates_append_only_with_identity_profile_and_selection() {
- let directory = tempdir().expect("temporary directory");
- let path = directory.path().join("studio.sqlite3");
- let public_key = "07".repeat(32);
- {
- let mut connection = Connection::open(&path).expect("legacy database");
- configure(&connection).expect("configuration");
- migrations::migrations::runner()
- .set_target(Target::Version(5))
- .run(&mut connection)
- .expect("V5 schema");
- connection
- .execute(
- "INSERT INTO accounts (pubkey, npub, signer_kind, key_availability, created_at) VALUES (?1, ?2, 'local_secret', 'available', 10)",
- [&public_key, "npub1qurswpc8qurswpc8qurswpc8qurswpc8qurswpc8qurswpc8qursnvjvl7"],
- )
- .expect("legacy account");
- connection
- .execute(
- "UPDATE app_state SET selected_pubkey = ?1 WHERE singleton = 1",
- [&public_key],
- )
- .expect("legacy selection");
- connection
- .execute(
- "INSERT INTO profile_cache (subject_pubkey, event_id, event_created_at, name, refreshed_at, refresh_status) VALUES (?1, ?2, 11, 'Farm', 12, 'success')",
- [&public_key, &"01".repeat(32)],
- )
- .expect("legacy profile");
- }
-
- let database = Database::open(&path).expect("migrated database");
- assert_eq!(database.schema_version().expect("version"), 10);
- assert_eq!(database.list_accounts().expect("accounts").len(), 1);
- assert_eq!(
- database.load_selected_account().expect("selection"),
- Some(PublicKey::from_bytes([7; 32]).expect("valid public key"))
- );
- let connection = database.connection();
- let migrated: (i64, i64, i64) = connection
- .query_row(
- "SELECT (SELECT COUNT(*) FROM account_identities), (SELECT COUNT(*) FROM local_signer_bindings), (SELECT COUNT(*) FROM profile_cache_v6)",
- [],
- |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
- )
- .expect("migrated inventory");
- assert_eq!(migrated, (1, 1, 1));
- drop(connection);
- drop(database);
-
- Database::verify_migration_backup(&path, 5).expect("authenticated backup");
- Database::restore_migration_backup(&path, 5).expect("authenticated restore");
- assert_eq!(
- Database::preflight(&path).expect("restored preflight"),
- DatabasePreflight::Ready { schema_version: 5 }
- );
- let retried = Database::open(&path).expect("idempotent migration retry");
- assert_eq!(retried.schema_version().expect("retried version"), 10);
- drop(retried);
-
- let backup = directory
- .path()
- .join("studio.sqlite3.recovery/migration-v5-to-v10.sqlite3");
- fs::OpenOptions::new()
- .append(true)
- .open(backup)
- .expect("open backup")
- .write_all(b"tamper")
- .expect("tamper backup");
- let error =
- Database::verify_migration_backup(&path, 5).expect_err("tampered backup must fail");
- assert_eq!(error.code(), SafeErrorCode::StorageBackupInvalid);
- }
-
- #[test]
- fn corrupt_v5_identity_fails_before_migration_without_recreation() {
- let directory = tempdir().expect("temporary directory");
- let path = directory.path().join("studio.sqlite3");
- {
- let mut connection = Connection::open(&path).expect("legacy database");
- configure(&connection).expect("configuration");
- migrations::migrations::runner()
- .set_target(Target::Version(5))
- .run(&mut connection)
- .expect("V5 schema");
- connection
- .execute(
- "INSERT INTO accounts (pubkey, npub, signer_kind, key_availability, created_at) VALUES (?1, ?2, 'local_secret', 'available', 10)",
- ["07".repeat(32), "npub10elfcs4fr0l0r8af98jlmgdh9c8tcxjvz9qkw038js35mp4dma8qzvjptg".to_owned()],
- )
- .expect("mismatched legacy account");
- }
-
- assert!(Database::open(&path).is_err());
- let connection = Connection::open(&path).expect("inspect legacy database");
- let version: u32 = connection
- .query_row(
- "SELECT MAX(version) FROM refinery_schema_history",
- [],
- |row| row.get(0),
- )
- .expect("legacy version");
- let accounts: i64 = connection
- .query_row("SELECT COUNT(*) FROM accounts", [], |row| row.get(0))
- .expect("legacy accounts");
- assert_eq!((version, accounts), (5, 1));
- }
-
- #[test]
- fn invalid_curve_identity_is_quarantined_without_mutation() {
- let directory = tempdir().expect("temporary directory");
- let path = directory.path().join("studio.sqlite3");
- {
- let mut connection = Connection::open(&path).expect("legacy database");
- configure(&connection).expect("configuration");
- migrations::migrations::runner()
- .set_target(Target::Version(5))
- .run(&mut connection)
- .expect("V5 schema");
- connection
- .execute(
- "INSERT INTO accounts (pubkey, npub, signer_kind, key_availability, created_at) VALUES (?1, ?2, 'local_secret', 'available', 10)",
- ["00".repeat(32), "npub1qurswpc8qurswpc8qurswpc8qurswpc8qurswpc8qurswpc8qursnvjvl7".to_owned()],
- )
- .expect("invalid-curve fixture");
- connection
- .execute_batch("PRAGMA wal_checkpoint(TRUNCATE)")
- .expect("checkpoint");
- }
- let before = fs::read(&path).expect("before bytes");
-
- let DatabasePreflight::Quarantined {
- schema_version,
- issues,
- } = Database::preflight(&path).expect("classified preflight")
- else {
- panic!("invalid identity was not quarantined");
- };
- assert_eq!(schema_version, 5);
- assert!(issues.iter().any(|issue| {
- issue.table() == "accounts"
- && issue.column() == "pubkey"
- && issue.kind() == PersistedIdentityIssueKind::InvalidCurvePoint
- }));
- let error = Database::open(&path)
- .err()
- .expect("quarantined open must fail");
- assert_eq!(error.code(), SafeErrorCode::StorageQuarantined);
- assert_eq!(fs::read(&path).expect("after bytes"), before);
- assert!(!path.with_extension("sqlite3.lock").exists());
-
- let authorization = RepairAuthorization::from_bytes(vec![0x41; 32])
- .unwrap_or_else(|_| panic!("repair authorization"));
- let export_path = directory.path().join("quarantine-export.sqlite3");
- let export = Database::export_quarantined(&path, &export_path, &authorization)
- .expect("authenticated quarantine export");
- assert_eq!(export.path(), export_path);
- assert_eq!(export.sha256().len(), 64);
- assert_eq!(export.authentication_tag().len(), 64);
- assert_eq!(fs::read(&path).expect("post-export bytes"), before);
-
- let candidate_path = directory.path().join("repaired.sqlite3");
- drop(Database::open(&candidate_path).expect("canonical repair candidate"));
- let candidate = Database::authenticate_repair_candidate(&candidate_path, &authorization)
- .expect("authenticate candidate");
- let wrong_authorization = RepairAuthorization::from_bytes(vec![0x42; 32])
- .unwrap_or_else(|_| panic!("wrong authorization shape"));
- let error = Database::install_repair_candidate(&path, &candidate, &wrong_authorization)
- .expect_err("wrong repair authorization");
- assert_eq!(error.code(), SafeErrorCode::RepairUnauthorized);
- assert_eq!(fs::read(&path).expect("unauthorized bytes"), before);
-
- Database::install_repair_candidate(&path, &candidate, &authorization)
- .expect("authenticated repair install");
- assert!(matches!(
- Database::preflight(&path).expect("repaired preflight"),
- DatabasePreflight::Ready {
- schema_version: CURRENT_SCHEMA_VERSION
- }
- ));
- assert!(
- directory
- .path()
- .join("studio.sqlite3.quarantined-evidence")
- .is_file()
- );
- }
-
- #[test]
- fn newer_and_mixed_schema_inventory_fail_before_mutation() {
- let directory = tempdir().expect("temporary directory");
- let newer_path = directory.path().join("newer.sqlite3");
- {
- let database = Database::open(&newer_path).expect("current database");
- database
- .connection()
- .execute(
- "UPDATE refinery_schema_history SET version = ?1 WHERE version = ?2",
- [CURRENT_SCHEMA_VERSION + 1, CURRENT_SCHEMA_VERSION],
- )
- .expect("future schema row");
- }
- let newer_before = fs::read(&newer_path).expect("newer bytes");
- let error = Database::preflight(&newer_path).expect_err("newer schema");
- assert_eq!(error.code(), SafeErrorCode::UnsupportedSchemaVersion);
- assert_eq!(fs::read(&newer_path).expect("newer after"), newer_before);
-
- let mixed_path = directory.path().join("mixed.sqlite3");
- {
- let mut connection = Connection::open(&mixed_path).expect("legacy database");
- configure(&connection).expect("configuration");
- migrations::migrations::runner()
- .set_target(Target::Version(5))
- .run(&mut connection)
- .expect("V5 schema");
- connection
- .execute("CREATE TABLE installation_identity (singleton INTEGER)", [])
- .expect("mixed table");
- }
- let mixed_before = fs::read(&mixed_path).expect("mixed bytes");
- let error = Database::preflight(&mixed_path).expect_err("mixed schema");
- assert_eq!(error.code(), SafeErrorCode::StorageCorrupt);
- assert_eq!(fs::read(&mixed_path).expect("mixed after"), mixed_before);
- }
-
- #[test]
- fn failed_v5_copy_rolls_back_the_active_migration() {
- let directory = tempdir().expect("temporary directory");
- let path = directory.path().join("studio.sqlite3");
- let public_key = "07".repeat(32);
- {
- let mut connection = Connection::open(&path).expect("legacy database");
- configure(&connection).expect("configuration");
- migrations::migrations::runner()
- .set_target(Target::Version(5))
- .run(&mut connection)
- .expect("V5 schema");
- connection
- .execute(
- "INSERT INTO accounts (pubkey, npub, signer_kind, key_availability, created_at) VALUES (?1, ?2, 'local_secret', 'available', 10)",
- [&public_key, "npub1qurswpc8qurswpc8qurswpc8qurswpc8qurswpc8qurswpc8qursnvjvl7"],
- )
- .expect("legacy account");
- connection
- .execute(
- "INSERT INTO profile_cache (subject_pubkey, event_id, event_created_at, refreshed_at, refresh_status) VALUES (?1, 'invalid', 11, 12, 'success')",
- [&public_key],
- )
- .expect("legacy corrupt profile");
- }
-
- assert!(Database::open(&path).is_err());
- let connection = Connection::open(&path).expect("inspect interrupted migration");
- let version: u32 = connection
- .query_row(
- "SELECT MAX(version) FROM refinery_schema_history",
- [],
- |row| row.get(0),
- )
- .expect("migration version");
- assert_eq!(version, 5);
- assert!(!connection
- .query_row(
- "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'account_identities')",
- [],
- |row| row.get::<_, bool>(0),
- )
- .expect("normalized table inventory"));
- }
-
- #[test]
- fn foreign_keys_reject_orphan_normalized_records() {
- let database = Database::in_memory().expect("database");
- let connection = database.connection();
- assert!(
- connection
- .execute(
- "INSERT INTO local_signer_bindings (account_public_key, binding_public_key, binding_kind, availability) VALUES (?1, ?1, 'local_secret', 'available')",
- ["09".repeat(32)],
- )
- .is_err()
- );
- }
-
- #[test]
- fn second_process_cannot_acquire_writable_ownership() {
- let directory = tempdir().expect("temporary directory");
- let path = directory.path().join("studio.sqlite3");
- let _owner = Database::open(&path).expect("parent owner");
- let status = Command::new(std::env::current_exe().expect("test executable"))
- .arg("--exact")
- .arg("db::tests::writable_ownership_child_probe")
- .arg("--nocapture")
- .env("RADROOTS_STUDIO_LOCK_PROBE_PATH", &path)
- .status()
- .expect("child process");
- assert!(status.success());
- }
-
- #[test]
- fn writable_ownership_child_probe() {
- let Ok(path) = std::env::var("RADROOTS_STUDIO_LOCK_PROBE_PATH") else {
- return;
- };
- assert!(Database::open(Path::new(&path)).is_err());
- }
-
- #[test]
- fn migration_persists_schema_version_across_file_reopen() {
- let directory = tempdir().expect("temporary directory");
- let path = directory.path().join("studio.sqlite3");
-
- {
- let database = Database::open(&path).expect("open file database");
- assert_eq!(
- database.schema_version().expect("schema version"),
- CURRENT_SCHEMA_VERSION
- );
- }
- let reopened = Database::open(&path).expect("reopen file database");
- assert_eq!(
- reopened.schema_version().expect("schema version"),
- CURRENT_SCHEMA_VERSION
- );
- assert!(fs::metadata(path).expect("database metadata").len() > 0);
- }
-
- #[test]
- fn writable_ownership_rejects_a_second_runtime_and_releases_on_drop() {
- let directory = tempdir().expect("temporary directory");
- let path = directory.path().join("studio.sqlite3");
- let first = Database::open(&path).expect("first owner");
- let Err(error) = Database::open(&path) else {
- panic!("second owner must fail");
- };
- assert_eq!(
- error.message().as_str(),
- "The application database is already in use."
- );
- drop(first);
- Database::open(&path).expect("ownership released");
- }
-
- #[cfg(unix)]
- #[test]
- fn migration_attempts_owner_only_database_permissions() {
- use std::os::unix::fs::PermissionsExt;
-
- let directory = tempdir().expect("temporary directory");
- let path = directory.path().join("studio.sqlite3");
- let database = Database::open(&path).expect("open file database");
- let mode = fs::metadata(&path)
- .expect("database metadata")
- .permissions()
- .mode()
- & 0o777;
-
- assert_eq!(mode, 0o600);
- let directory_mode = fs::metadata(directory.path())
- .expect("directory metadata")
- .permissions()
- .mode()
- & 0o777;
- assert_eq!(directory_mode, 0o700);
-
- let connection = database.connection();
- connection
- .execute_batch("CREATE TABLE sidecar_probe (value INTEGER) STRICT; INSERT INTO sidecar_probe VALUES (1);")
- .expect("write through WAL");
- drop(connection);
- for suffix in ["-wal", "-shm"] {
- let sidecar = std::path::PathBuf::from(format!("{}{suffix}", path.display()));
- let sidecar_mode = fs::metadata(sidecar)
- .expect("sidecar metadata")
- .permissions()
- .mode()
- & 0o777;
- assert_eq!(sidecar_mode, 0o600);
- }
- }
-
- #[cfg(unix)]
- #[test]
- fn database_lock_sidecar_and_recovery_symlinks_fail_closed() {
- use std::os::unix::fs::symlink;
-
- let directory = tempdir().expect("temporary directory");
- let victim = directory.path().join("victim");
- fs::write(&victim, b"unchanged").expect("victim");
-
- let database_link = directory.path().join("database-link.sqlite3");
- symlink(&victim, &database_link).expect("database symlink");
- assert!(Database::open(&database_link).is_err());
- assert_eq!(fs::read(&victim).expect("victim bytes"), b"unchanged");
-
- let lock_path = directory.path().join("locked.sqlite3");
- symlink(&victim, lock_path.with_extension("sqlite3.lock")).expect("lock symlink");
- assert!(Database::open(&lock_path).is_err());
- assert_eq!(fs::read(&victim).expect("victim bytes"), b"unchanged");
-
- let sidecar_path = directory.path().join("sidecar.sqlite3");
- let wal = std::path::PathBuf::from(format!("{}-wal", sidecar_path.display()));
- symlink(&victim, wal).expect("WAL symlink");
- assert!(Database::open(&sidecar_path).is_err());
- assert_eq!(fs::read(&victim).expect("victim bytes"), b"unchanged");
-
- let legacy_path = directory.path().join("legacy.sqlite3");
- {
- let mut connection = Connection::open(&legacy_path).expect("legacy database");
- configure(&connection).expect("configuration");
- migrations::migrations::runner()
- .set_target(Target::Version(5))
- .run(&mut connection)
- .expect("V5 schema");
- }
- symlink(
- directory.path().join("not-present"),
- directory.path().join("legacy.sqlite3.recovery"),
- )
- .expect("recovery symlink");
- assert!(Database::open(&legacy_path).is_err());
- }
-}
diff --git a/crates/studio_storage/src/installation.rs b/crates/studio_storage/src/installation.rs
@@ -1,71 +0,0 @@
-use radroots_studio_domain::{SafeError, SafeErrorCode, SafeMessage};
-use rusqlite::OptionalExtension;
-
-use crate::Database;
-
-impl Database {
- pub fn load_installation_id(&self) -> Result<Option<String>, SafeError> {
- self.connection()
- .query_row(
- "SELECT installation_id FROM installation_identity WHERE singleton = 1",
- [],
- |row| row.get(0),
- )
- .optional()
- .map_err(|_| installation_storage_error())
- }
-
- pub fn initialize_installation_id(&self, candidate: &str) -> Result<String, SafeError> {
- let connection = self.connection();
- connection
- .execute(
- "INSERT INTO installation_identity (singleton, installation_id) VALUES (1, ?1) ON CONFLICT(singleton) DO NOTHING",
- [candidate],
- )
- .map_err(|_| installation_storage_error())?;
- connection
- .query_row(
- "SELECT installation_id FROM installation_identity WHERE singleton = 1",
- [],
- |row| row.get(0),
- )
- .map_err(|_| installation_storage_error())
- }
-}
-
-const fn installation_storage_error() -> SafeError {
- SafeError::new(
- SafeErrorCode::StorageUnavailable,
- SafeMessage::new("The installation identity is unavailable."),
- )
-}
-
-#[cfg(test)]
-mod tests {
- use crate::Database;
-
- #[test]
- fn installation_identity_is_insert_once_and_stable() {
- let database = Database::in_memory().expect("database");
- assert_eq!(database.load_installation_id().expect("empty"), None);
- let first = database
- .initialize_installation_id("11aabbccddeeff001122334455667788")
- .expect("first identity");
- let second = database
- .initialize_installation_id("22aabbccddeeff001122334455667788")
- .expect("existing identity");
- assert_eq!(first, "11aabbccddeeff001122334455667788");
- assert_eq!(second, first);
- assert_eq!(database.load_installation_id().expect("load"), Some(first));
- }
-
- #[test]
- fn installation_identity_rejects_invalid_values() {
- let database = Database::in_memory().expect("database");
- assert!(
- database
- .initialize_installation_id("not-an-identity")
- .is_err()
- );
- }
-}
diff --git a/crates/studio_storage/src/journal.rs b/crates/studio_storage/src/journal.rs
@@ -1,774 +0,0 @@
-use radroots_studio_application::{
- AccountOperationKind, AccountOperationPhase, DurableAccountOperation, DurableOperationKind,
- DurableOperationPhase, DurableOperationReceipt, DurableOperationRepository,
- DurableOperationStart, DurableRequestId, DurableTerminalOutcome, OperationDiagnostic,
- OperationId, OperationJournal, OperationPriorState, PendingAccountOperation,
-};
-use radroots_studio_domain::{
- BindingAvailability, PublicKey, SafeError, SafeErrorCode, SafeMessage, UnixTimestamp,
-};
-use rusqlite::{OptionalExtension, Row, params};
-
-use crate::Database;
-
-impl DurableOperationRepository for Database {
- fn begin_durable_operation(
- &self,
- request_id: &DurableRequestId,
- kind: DurableOperationKind,
- account: PublicKey,
- expected_revision: Option<u64>,
- prior: OperationPriorState,
- updated_at: UnixTimestamp,
- ) -> Result<DurableOperationStart, SafeError> {
- let encoded_expected_revision = expected_revision
- .map(i64::try_from)
- .transpose()
- .map_err(|_| operation_conflict())?;
- let mut connection = self.connection();
- let transaction = connection.transaction().map_err(|_| storage_error())?;
- let inserted = transaction
- .execute(
- "INSERT OR IGNORE INTO durable_operations (request_id, operation_kind, \
- account_public_key, binding_public_key, expected_revision, phase, \
- prior_selected_public_key, updated_at, prior_binding_availability) \
- VALUES (?1, ?2, ?3, ?3, ?4, 'intent_recorded', ?5, ?6, ?7)",
- params![
- request_id.as_str(),
- encode_durable_kind(kind),
- account.to_hex(),
- encoded_expected_revision,
- prior.selected_account().map(PublicKey::to_hex),
- updated_at.as_seconds(),
- prior
- .binding_availability()
- .map(encode_binding_availability),
- ],
- )
- .map_err(|_| storage_error())?;
- let operation =
- query_durable_operation(&transaction, request_id)?.ok_or_else(corrupt_storage_error)?;
- if operation.kind() != kind
- || operation.account() != account
- || operation.expected_revision() != expected_revision
- || operation.prior() != prior
- {
- return Err(operation_conflict());
- }
- transaction.commit().map_err(|_| storage_error())?;
- Ok(if inserted == 1 {
- DurableOperationStart::Started(operation)
- } else {
- DurableOperationStart::Existing(operation)
- })
- }
-
- fn load_durable_operation(
- &self,
- request_id: &DurableRequestId,
- ) -> Result<Option<DurableAccountOperation>, SafeError> {
- query_durable_operation(&self.connection(), request_id)
- }
-
- fn advance_durable_operation(
- &self,
- request_id: &DurableRequestId,
- expected_phase: DurableOperationPhase,
- next_phase: DurableOperationPhase,
- updated_at: UnixTimestamp,
- diagnostic: Option<OperationDiagnostic>,
- ) -> Result<DurableAccountOperation, SafeError> {
- let mut connection = self.connection();
- let transaction = connection.transaction().map_err(|_| storage_error())?;
- let rows = transaction
- .execute(
- "UPDATE durable_operations SET phase = ?3, updated_at = ?4, diagnostic_code = ?5 \
- WHERE request_id = ?1 AND phase = ?2 AND terminal_outcome IS NULL",
- params![
- request_id.as_str(),
- encode_durable_phase(expected_phase),
- encode_durable_phase(next_phase),
- updated_at.as_seconds(),
- diagnostic.map(encode_diagnostic),
- ],
- )
- .map_err(|_| storage_error())?;
- if rows != 1 {
- return Err(operation_conflict());
- }
- let operation =
- query_durable_operation(&transaction, request_id)?.ok_or_else(corrupt_storage_error)?;
- transaction.commit().map_err(|_| storage_error())?;
- Ok(operation)
- }
-
- fn finalize_durable_operation(
- &self,
- request_id: &DurableRequestId,
- expected_phase: DurableOperationPhase,
- outcome: DurableTerminalOutcome,
- resulting_revision: Option<u64>,
- updated_at: UnixTimestamp,
- ) -> Result<DurableOperationReceipt, SafeError> {
- if let Some(existing) = self.load_durable_operation(request_id)?
- && let Some(receipt) = existing.terminal()
- {
- return if receipt.outcome() == outcome
- && receipt.resulting_revision() == resulting_revision
- {
- Ok(receipt.clone())
- } else {
- Err(operation_conflict())
- };
- }
- let resulting_revision = resulting_revision
- .map(i64::try_from)
- .transpose()
- .map_err(|_| operation_conflict())?;
- let rows = self
- .connection()
- .execute(
- "UPDATE durable_operations SET phase = 'finalized', terminal_outcome = ?3, \
- resulting_revision = ?4, updated_at = ?5 \
- WHERE request_id = ?1 AND phase = ?2 AND terminal_outcome IS NULL",
- params![
- request_id.as_str(),
- encode_durable_phase(expected_phase),
- encode_terminal_outcome(outcome),
- resulting_revision,
- updated_at.as_seconds(),
- ],
- )
- .map_err(|_| storage_error())?;
- if rows != 1 {
- return Err(operation_conflict());
- }
- self.load_durable_operation(request_id)?
- .and_then(|operation| operation.terminal().cloned())
- .ok_or_else(corrupt_storage_error)
- }
-
- fn list_unfinished_durable_operations(
- &self,
- ) -> Result<Vec<DurableAccountOperation>, SafeError> {
- let connection = self.connection();
- let mut statement = connection
- .prepare(&format!(
- "{DURABLE_OPERATION_SELECT} WHERE terminal_outcome IS NULL ORDER BY request_id ASC"
- ))
- .map_err(|_| storage_error())?;
- let rows = statement
- .query_map([], decode_durable_operation)
- .map_err(|_| storage_error())?;
- rows.map(|row| row.map_err(|_| corrupt_storage_error()))
- .collect()
- }
-}
-
-const DURABLE_OPERATION_SELECT: &str = "SELECT request_id, operation_kind, account_public_key, \
- expected_revision, phase, prior_selected_public_key, updated_at, diagnostic_code, \
- terminal_outcome, prior_binding_availability, resulting_revision FROM durable_operations";
-
-fn query_durable_operation(
- connection: &rusqlite::Connection,
- request_id: &DurableRequestId,
-) -> Result<Option<DurableAccountOperation>, SafeError> {
- connection
- .query_row(
- &format!("{DURABLE_OPERATION_SELECT} WHERE request_id = ?1"),
- [request_id.as_str()],
- decode_durable_operation,
- )
- .optional()
- .map_err(|_| corrupt_storage_error())
-}
-
-fn decode_durable_operation(row: &Row<'_>) -> rusqlite::Result<DurableAccountOperation> {
- let request_id =
- DurableRequestId::parse(row.get::<_, String>(0)?).map_err(|_| invalid_column(0))?;
- let kind = decode_durable_kind(row.get::<_, String>(1)?.as_str())?;
- let account =
- PublicKey::from_hex(row.get::<_, String>(2)?.as_str()).map_err(|_| invalid_column(2))?;
- let expected_revision = row
- .get::<_, Option<i64>>(3)?
- .map(|value| u64::try_from(value).map_err(|_| invalid_column(3)))
- .transpose()?;
- let phase = decode_durable_phase(row.get::<_, String>(4)?.as_str())?;
- let prior_selected = row
- .get::<_, Option<String>>(5)?
- .map(|value| PublicKey::from_hex(&value).map_err(|_| invalid_column(5)))
- .transpose()?;
- let updated_at = UnixTimestamp::from_seconds(row.get(6)?).ok_or_else(|| invalid_column(6))?;
- let diagnostic = row
- .get::<_, Option<String>>(7)?
- .map(|value| decode_diagnostic(&value))
- .transpose()?;
- let outcome = row
- .get::<_, Option<String>>(8)?
- .map(|value| decode_terminal_outcome(&value))
- .transpose()?;
- let prior_availability = row
- .get::<_, Option<String>>(9)?
- .map(|value| decode_binding_availability(&value))
- .transpose()?;
- let resulting_revision = row
- .get::<_, Option<i64>>(10)?
- .map(|value| u64::try_from(value).map_err(|_| invalid_column(10)))
- .transpose()?;
- let terminal = outcome.map(|outcome| {
- DurableOperationReceipt::new(request_id.clone(), account, outcome, resulting_revision)
- });
- Ok(DurableAccountOperation::new(
- request_id,
- kind,
- account,
- expected_revision,
- phase,
- OperationPriorState::new(prior_selected, prior_availability),
- updated_at,
- diagnostic,
- terminal,
- ))
-}
-
-impl OperationJournal for Database {
- fn begin_operation(
- &self,
- kind: AccountOperationKind,
- subject: PublicKey,
- updated_at: UnixTimestamp,
- ) -> Result<OperationId, SafeError> {
- let connection = self.connection();
- connection
- .execute(
- "INSERT INTO operation_journal (operation_kind, subject_pubkey, phase, \
- updated_at) VALUES (?1, ?2, 'intent_recorded', ?3)",
- params![encode_kind(kind), subject.to_hex(), updated_at.as_seconds()],
- )
- .map_err(|_| storage_error())?;
- let id =
- u64::try_from(connection.last_insert_rowid()).map_err(|_| corrupt_storage_error())?;
- Ok(OperationId::from_raw(id))
- }
-
- fn update_operation(
- &self,
- id: OperationId,
- phase: AccountOperationPhase,
- updated_at: UnixTimestamp,
- diagnostic: Option<OperationDiagnostic>,
- ) -> Result<(), SafeError> {
- let encoded_id = i64::try_from(id.as_raw()).map_err(|_| corrupt_storage_error())?;
- match self.connection().execute(
- "UPDATE operation_journal SET phase = ?2, updated_at = ?3, diagnostic_code = ?4 \
- WHERE operation_id = ?1",
- params![
- encoded_id,
- encode_phase(phase),
- updated_at.as_seconds(),
- diagnostic.map(encode_diagnostic)
- ],
- ) {
- Ok(1) => Ok(()),
- Ok(0) => Err(operation_not_found()),
- Ok(_) | Err(_) => Err(storage_error()),
- }
- }
-
- fn list_pending_operations(&self) -> Result<Vec<PendingAccountOperation>, SafeError> {
- let connection = self.connection();
- let mut statement = connection
- .prepare(
- "SELECT operation_id, operation_kind, subject_pubkey, phase, updated_at, \
- diagnostic_code FROM operation_journal ORDER BY operation_id ASC",
- )
- .map_err(|_| storage_error())?;
- let rows = statement
- .query_map([], decode_operation)
- .map_err(|_| storage_error())?;
- rows.map(|row| row.map_err(|_| corrupt_storage_error()))
- .collect()
- }
-
- fn finalize_operation(&self, id: OperationId) -> Result<(), SafeError> {
- let encoded_id = i64::try_from(id.as_raw()).map_err(|_| corrupt_storage_error())?;
- self.connection()
- .execute(
- "DELETE FROM operation_journal WHERE operation_id = ?1",
- [encoded_id],
- )
- .map(|_| ())
- .map_err(|_| storage_error())
- }
-}
-
-fn decode_operation(row: &Row<'_>) -> rusqlite::Result<PendingAccountOperation> {
- let id = u64::try_from(row.get::<_, i64>(0)?).map_err(|_| invalid_column(0))?;
- let kind = decode_kind(row.get::<_, String>(1)?.as_str())?;
- let subject =
- PublicKey::from_hex(row.get::<_, String>(2)?.as_str()).map_err(|_| invalid_column(2))?;
- let phase = decode_phase(row.get::<_, String>(3)?.as_str())?;
- let updated_at = UnixTimestamp::from_seconds(row.get(4)?).ok_or_else(|| invalid_column(4))?;
- let diagnostic = row
- .get::<_, Option<String>>(5)?
- .map(|value| decode_diagnostic(&value))
- .transpose()?;
- Ok(PendingAccountOperation::new(
- OperationId::from_raw(id),
- kind,
- subject,
- phase,
- updated_at,
- diagnostic,
- ))
-}
-
-const fn encode_durable_kind(value: DurableOperationKind) -> &'static str {
- match value {
- DurableOperationKind::Create => "create",
- DurableOperationKind::Import => "import",
- DurableOperationKind::Repair => "repair",
- DurableOperationKind::Remove => "remove",
- }
-}
-
-fn decode_durable_kind(value: &str) -> rusqlite::Result<DurableOperationKind> {
- match value {
- "create" => Ok(DurableOperationKind::Create),
- "import" => Ok(DurableOperationKind::Import),
- "repair" => Ok(DurableOperationKind::Repair),
- "remove" => Ok(DurableOperationKind::Remove),
- _ => Err(invalid_column(1)),
- }
-}
-
-const fn encode_durable_phase(value: DurableOperationPhase) -> &'static str {
- match value {
- DurableOperationPhase::IntentRecorded => "intent_recorded",
- DurableOperationPhase::CredentialWritten => "credential_written",
- DurableOperationPhase::MetadataCommitted => "metadata_committed",
- DurableOperationPhase::SelectionCommitted => "selection_committed",
- DurableOperationPhase::CompensationPending => "compensation_pending",
- DurableOperationPhase::CredentialDeleted => "credential_deleted",
- DurableOperationPhase::MetadataDeleted => "metadata_deleted",
- DurableOperationPhase::Finalized => "finalized",
- }
-}
-
-fn decode_durable_phase(value: &str) -> rusqlite::Result<DurableOperationPhase> {
- match value {
- "intent_recorded" => Ok(DurableOperationPhase::IntentRecorded),
- "credential_written" => Ok(DurableOperationPhase::CredentialWritten),
- "metadata_committed" => Ok(DurableOperationPhase::MetadataCommitted),
- "selection_committed" => Ok(DurableOperationPhase::SelectionCommitted),
- "compensation_pending" => Ok(DurableOperationPhase::CompensationPending),
- "credential_deleted" => Ok(DurableOperationPhase::CredentialDeleted),
- "metadata_deleted" => Ok(DurableOperationPhase::MetadataDeleted),
- "finalized" => Ok(DurableOperationPhase::Finalized),
- _ => Err(invalid_column(4)),
- }
-}
-
-const fn encode_terminal_outcome(value: DurableTerminalOutcome) -> &'static str {
- match value {
- DurableTerminalOutcome::Completed => "completed",
- DurableTerminalOutcome::Cancelled => "cancelled",
- DurableTerminalOutcome::Failed => "failed",
- }
-}
-
-fn decode_terminal_outcome(value: &str) -> rusqlite::Result<DurableTerminalOutcome> {
- match value {
- "completed" => Ok(DurableTerminalOutcome::Completed),
- "cancelled" => Ok(DurableTerminalOutcome::Cancelled),
- "failed" => Ok(DurableTerminalOutcome::Failed),
- _ => Err(invalid_column(8)),
- }
-}
-
-const fn encode_binding_availability(value: BindingAvailability) -> &'static str {
- match value {
- BindingAvailability::Available => "available",
- BindingAvailability::CredentialMissing => "credential_missing",
- BindingAvailability::StoreUnavailable => "store_unavailable",
- }
-}
-
-fn decode_binding_availability(value: &str) -> rusqlite::Result<BindingAvailability> {
- match value {
- "available" => Ok(BindingAvailability::Available),
- "credential_missing" => Ok(BindingAvailability::CredentialMissing),
- "store_unavailable" => Ok(BindingAvailability::StoreUnavailable),
- _ => Err(invalid_column(9)),
- }
-}
-
-const fn encode_kind(value: AccountOperationKind) -> &'static str {
- match value {
- AccountOperationKind::Add => "add",
- AccountOperationKind::Import => "import",
- AccountOperationKind::Remove => "remove",
- }
-}
-
-fn decode_kind(value: &str) -> rusqlite::Result<AccountOperationKind> {
- match value {
- "add" => Ok(AccountOperationKind::Add),
- "import" => Ok(AccountOperationKind::Import),
- "remove" => Ok(AccountOperationKind::Remove),
- _ => Err(invalid_column(1)),
- }
-}
-
-const fn encode_phase(value: AccountOperationPhase) -> &'static str {
- match value {
- AccountOperationPhase::IntentRecorded => "intent_recorded",
- AccountOperationPhase::CredentialWritten => "credential_written",
- AccountOperationPhase::MetadataCommitted => "metadata_committed",
- AccountOperationPhase::CompensationPending => "compensation_pending",
- AccountOperationPhase::CredentialDeleted => "credential_deleted",
- AccountOperationPhase::MetadataDeleted => "metadata_deleted",
- }
-}
-
-fn decode_phase(value: &str) -> rusqlite::Result<AccountOperationPhase> {
- match value {
- "intent_recorded" => Ok(AccountOperationPhase::IntentRecorded),
- "credential_written" => Ok(AccountOperationPhase::CredentialWritten),
- "metadata_committed" => Ok(AccountOperationPhase::MetadataCommitted),
- "compensation_pending" => Ok(AccountOperationPhase::CompensationPending),
- "credential_deleted" => Ok(AccountOperationPhase::CredentialDeleted),
- "metadata_deleted" => Ok(AccountOperationPhase::MetadataDeleted),
- _ => Err(invalid_column(3)),
- }
-}
-
-const fn encode_diagnostic(value: OperationDiagnostic) -> &'static str {
- match value {
- OperationDiagnostic::StorageUnavailable => "storage_unavailable",
- OperationDiagnostic::KeyringUnavailable => "keyring_unavailable",
- OperationDiagnostic::CredentialMissing => "credential_missing",
- OperationDiagnostic::CompensationFailed => "compensation_failed",
- OperationDiagnostic::Conflict => "conflict",
- OperationDiagnostic::Expired => "expired",
- }
-}
-
-fn decode_diagnostic(value: &str) -> rusqlite::Result<OperationDiagnostic> {
- match value {
- "storage_unavailable" => Ok(OperationDiagnostic::StorageUnavailable),
- "keyring_unavailable" => Ok(OperationDiagnostic::KeyringUnavailable),
- "credential_missing" => Ok(OperationDiagnostic::CredentialMissing),
- "compensation_failed" => Ok(OperationDiagnostic::CompensationFailed),
- "conflict" => Ok(OperationDiagnostic::Conflict),
- "expired" => Ok(OperationDiagnostic::Expired),
- _ => Err(invalid_column(5)),
- }
-}
-
-fn invalid_column(index: usize) -> rusqlite::Error {
- rusqlite::Error::InvalidColumnType(
- index,
- "account operation journal".to_owned(),
- rusqlite::types::Type::Text,
- )
-}
-
-const fn storage_error() -> SafeError {
- SafeError::new(
- SafeErrorCode::StorageUnavailable,
- SafeMessage::new("The account recovery journal is unavailable."),
- )
-}
-
-const fn corrupt_storage_error() -> SafeError {
- SafeError::new(
- SafeErrorCode::StorageCorrupt,
- SafeMessage::new("The account recovery journal could not be read."),
- )
-}
-
-const fn operation_not_found() -> SafeError {
- SafeError::new(
- SafeErrorCode::PendingOperationRecoveryRequired,
- SafeMessage::new("The account recovery operation was not found."),
- )
-}
-
-const fn operation_conflict() -> SafeError {
- SafeError::new(
- SafeErrorCode::InvalidApplicationState,
- SafeMessage::new("The durable account operation conflicts with existing state."),
- )
-}
-
-#[cfg(test)]
-mod tests {
- use radroots_studio_application::{
- AccountOperationKind, AccountOperationPhase, DurableOperationKind, DurableOperationPhase,
- DurableOperationRepository, DurableOperationStart, DurableRequestId,
- DurableTerminalOutcome, OperationDiagnostic, OperationJournal, OperationPriorState,
- };
- use radroots_studio_domain::{BindingAvailability, PublicKey, UnixTimestamp};
-
- use crate::Database;
-
- fn public_key(discriminator: u8) -> PublicKey {
- let value = match discriminator {
- 7 => "0707070707070707070707070707070707070707070707070707070707070707",
- 8 => "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df",
- _ => "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af",
- };
- PublicKey::from_hex(value).expect("valid public key")
- }
-
- #[test]
- fn journal_creates_advances_loads_and_finalizes_pending_operations() {
- let database = Database::in_memory().expect("database");
- let subject = public_key(7);
- let id = database
- .begin_operation(
- AccountOperationKind::Import,
- subject,
- UnixTimestamp::from_seconds(10).expect("time"),
- )
- .expect("begin");
- database
- .update_operation(
- id,
- AccountOperationPhase::CompensationPending,
- UnixTimestamp::from_seconds(11).expect("time"),
- Some(OperationDiagnostic::KeyringUnavailable),
- )
- .expect("advance");
-
- let pending = database.list_pending_operations().expect("pending");
- assert_eq!(pending.len(), 1);
- assert_eq!(pending[0].subject(), subject);
- assert_eq!(pending[0].kind(), AccountOperationKind::Import);
- assert_eq!(
- pending[0].phase(),
- AccountOperationPhase::CompensationPending
- );
- assert_eq!(
- pending[0].diagnostic(),
- Some(OperationDiagnostic::KeyringUnavailable)
- );
-
- database.finalize_operation(id).expect("finalize");
- assert!(
- database
- .list_pending_operations()
- .expect("pending")
- .is_empty()
- );
- }
-
- #[test]
- fn journal_schema_and_rows_exclude_secret_payload_columns() {
- let database = Database::in_memory().expect("database");
- database
- .begin_operation(
- AccountOperationKind::Remove,
- public_key(8),
- UnixTimestamp::from_seconds(12).expect("time"),
- )
- .expect("begin");
- let connection = database.connection();
- let schema: String = connection
- .query_row(
- "SELECT sql FROM sqlite_master WHERE name = 'operation_journal'",
- [],
- |row| row.get(0),
- )
- .expect("schema");
- assert!(!schema.contains("secret"));
- assert!(!schema.contains("payload"));
- }
-
- #[test]
- fn durable_repository_replays_matching_requests_and_retains_terminal_receipts() {
- let database = Database::in_memory().expect("database");
- let request = DurableRequestId::parse("import:test:1").expect("request");
- let account = public_key(9);
- let prior = OperationPriorState::new(
- Some(public_key(8)),
- Some(BindingAvailability::CredentialMissing),
- );
- let started = database
- .begin_durable_operation(
- &request,
- DurableOperationKind::Repair,
- account,
- Some(4),
- prior,
- UnixTimestamp::from_seconds(10).expect("time"),
- )
- .expect("begin");
- assert!(matches!(started, DurableOperationStart::Started(_)));
- let replay = database
- .begin_durable_operation(
- &request,
- DurableOperationKind::Repair,
- account,
- Some(4),
- prior,
- UnixTimestamp::from_seconds(11).expect("time"),
- )
- .expect("replay");
- assert!(matches!(replay, DurableOperationStart::Existing(_)));
- assert!(
- database
- .begin_durable_operation(
- &request,
- DurableOperationKind::Remove,
- account,
- Some(4),
- prior,
- UnixTimestamp::from_seconds(11).expect("time"),
- )
- .is_err()
- );
- let missing_request = DurableRequestId::parse("import:test:missing").expect("request");
- assert!(
- database
- .finalize_durable_operation(
- &missing_request,
- DurableOperationPhase::IntentRecorded,
- DurableTerminalOutcome::Completed,
- None,
- UnixTimestamp::from_seconds(17).expect("time"),
- )
- .is_err()
- );
- assert!(
- database
- .begin_durable_operation(
- &request,
- DurableOperationKind::Repair,
- public_key(8),
- Some(4),
- prior,
- UnixTimestamp::from_seconds(11).expect("time"),
- )
- .is_err()
- );
- assert!(
- database
- .begin_durable_operation(
- &request,
- DurableOperationKind::Repair,
- account,
- Some(5),
- prior,
- UnixTimestamp::from_seconds(11).expect("time"),
- )
- .is_err()
- );
- assert!(
- database
- .begin_durable_operation(
- &request,
- DurableOperationKind::Repair,
- account,
- Some(4),
- OperationPriorState::new(None, None),
- UnixTimestamp::from_seconds(11).expect("time"),
- )
- .is_err()
- );
- assert!(
- database
- .advance_durable_operation(
- &request,
- DurableOperationPhase::CredentialDeleted,
- DurableOperationPhase::Finalized,
- UnixTimestamp::from_seconds(11).expect("time"),
- None,
- )
- .is_err()
- );
- database
- .advance_durable_operation(
- &request,
- DurableOperationPhase::IntentRecorded,
- DurableOperationPhase::CredentialWritten,
- UnixTimestamp::from_seconds(12).expect("time"),
- None,
- )
- .expect("advance");
- let receipt = database
- .finalize_durable_operation(
- &request,
- DurableOperationPhase::CredentialWritten,
- DurableTerminalOutcome::Completed,
- Some(5),
- UnixTimestamp::from_seconds(13).expect("time"),
- )
- .expect("finalize");
- assert_eq!(receipt.resulting_revision(), Some(5));
- assert_eq!(
- database
- .finalize_durable_operation(
- &request,
- DurableOperationPhase::CredentialWritten,
- DurableTerminalOutcome::Completed,
- Some(5),
- UnixTimestamp::from_seconds(14).expect("time"),
- )
- .expect("receipt replay"),
- receipt
- );
- assert!(
- database
- .finalize_durable_operation(
- &request,
- DurableOperationPhase::CredentialWritten,
- DurableTerminalOutcome::Cancelled,
- Some(5),
- UnixTimestamp::from_seconds(14).expect("time"),
- )
- .is_err()
- );
- assert!(
- database
- .finalize_durable_operation(
- &request,
- DurableOperationPhase::CredentialWritten,
- DurableTerminalOutcome::Completed,
- Some(6),
- UnixTimestamp::from_seconds(14).expect("time"),
- )
- .is_err()
- );
- let overflow_request = DurableRequestId::parse("import:test:overflow").expect("request");
- database
- .begin_durable_operation(
- &overflow_request,
- DurableOperationKind::Import,
- account,
- None,
- OperationPriorState::new(None, None),
- UnixTimestamp::from_seconds(15).expect("time"),
- )
- .expect("begin overflow operation");
- assert!(
- database
- .finalize_durable_operation(
- &overflow_request,
- DurableOperationPhase::IntentRecorded,
- DurableTerminalOutcome::Completed,
- Some(u64::MAX),
- UnixTimestamp::from_seconds(16).expect("time"),
- )
- .is_err()
- );
- assert!(
- database
- .list_unfinished_durable_operations()
- .expect("unfinished")
- .iter()
- .any(|operation| operation.request_id() == &overflow_request)
- );
- }
-}
diff --git a/crates/studio_storage/src/lib.rs b/crates/studio_storage/src/lib.rs
@@ -1,20 +0,0 @@
-#![doc = "Radroots Studio persistence adapters."]
-#![cfg_attr(coverage_nightly, feature(coverage_attribute))]
-
-pub mod account_namespace;
-pub mod accounts;
-mod compatibility;
-pub mod db;
-mod installation;
-pub mod journal;
-// The operating-system credential store requires an explicit, ignored host smoke test.
-#[cfg_attr(coverage_nightly, coverage(off))]
-pub mod os_keyring;
-pub mod profiles;
-mod recovery;
-mod repair;
-
-pub use compatibility::{DatabasePreflight, PersistedIdentityIssue, PersistedIdentityIssueKind};
-pub use db::{CURRENT_SCHEMA_VERSION, Database};
-pub use os_keyring::{CREDENTIAL_SERVICE, OsKeyringSecretStore};
-pub use repair::{QuarantineExportReceipt, RepairAuthorization, RepairCandidate};
diff --git a/crates/studio_storage/src/os_keyring.rs b/crates/studio_storage/src/os_keyring.rs
@@ -1,138 +0,0 @@
-use std::sync::{Mutex, MutexGuard};
-
-use keyring::{Entry, Error as KeyringError};
-use radroots_studio_application::SecretStore;
-use radroots_studio_domain::{PublicKey, SafeError, SafeErrorCode, SafeMessage, SecretKeyInput};
-use zeroize::Zeroizing;
-
-pub const CREDENTIAL_SERVICE: &str = "org.radroots.studio.nostr";
-
-#[derive(Default)]
-pub struct OsKeyringSecretStore {
- operation_lock: Mutex<()>,
-}
-
-impl OsKeyringSecretStore {
- fn entry(public_key: PublicKey) -> Result<Entry, SafeError> {
- Entry::new(CREDENTIAL_SERVICE, &public_key.to_hex()).map_err(|_| keyring_unavailable())
- }
-
- fn operation(&self) -> MutexGuard<'_, ()> {
- self.operation_lock
- .lock()
- .unwrap_or_else(std::sync::PoisonError::into_inner)
- }
-}
-
-impl SecretStore for OsKeyringSecretStore {
- fn put(&self, public_key: PublicKey, secret: SecretKeyInput) -> Result<(), SafeError> {
- let _operation = self.operation();
- let entry = Self::entry(public_key)?;
- match entry.get_password() {
- Ok(password) => {
- drop(Zeroizing::new(password));
- return Err(credential_exists());
- }
- Err(KeyringError::NoEntry) => {}
- Err(_) => return Err(keyring_unavailable()),
- }
- secret
- .with_exposed_secret(|value| entry.set_password(value))
- .map_err(|_| keyring_unavailable())
- }
-
- fn load(&self, public_key: PublicKey) -> Result<SecretKeyInput, SafeError> {
- let _operation = self.operation();
- let password = Self::entry(public_key)?
- .get_password()
- .map_err(|error| map_read_error(&error))?;
- SecretKeyInput::parse(password)
- }
-
- fn contains(&self, public_key: PublicKey) -> Result<bool, SafeError> {
- let _operation = self.operation();
- match Self::entry(public_key)?.get_password() {
- Ok(password) => {
- drop(Zeroizing::new(password));
- Ok(true)
- }
- Err(KeyringError::NoEntry) => Ok(false),
- Err(_) => Err(keyring_unavailable()),
- }
- }
-
- fn delete(&self, public_key: PublicKey) -> Result<(), SafeError> {
- let _operation = self.operation();
- Self::entry(public_key)?
- .delete_credential()
- .map_err(|error| map_read_error(&error))
- }
-}
-
-const fn map_read_error(error: &KeyringError) -> SafeError {
- match error {
- KeyringError::NoEntry => credential_missing(),
- _ => keyring_unavailable(),
- }
-}
-
-const fn credential_exists() -> SafeError {
- SafeError::new(
- SafeErrorCode::AccountAlreadyExists,
- SafeMessage::new("The Nostr account credential already exists."),
- )
-}
-
-const fn credential_missing() -> SafeError {
- SafeError::new(
- SafeErrorCode::CredentialMissing,
- SafeMessage::new("The Nostr account credential is missing."),
- )
-}
-
-const fn keyring_unavailable() -> SafeError {
- SafeError::new(
- SafeErrorCode::KeyringUnavailable,
- SafeMessage::new("The operating system credential store is unavailable."),
- )
-}
-
-#[cfg(test)]
-mod tests {
- use radroots_studio_application::SecretStore;
- use radroots_studio_domain::{PublicKey, SecretKeyInput};
-
- use super::{CREDENTIAL_SERVICE, OsKeyringSecretStore};
-
- #[test]
- fn keyring_coordinates_are_stable_and_public() {
- let public_key =
- PublicKey::from_hex("7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7")
- .expect("valid public key");
- assert_eq!(CREDENTIAL_SERVICE, "org.radroots.studio.nostr");
- assert_eq!(
- public_key.to_hex(),
- "7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7"
- );
- }
-
- #[test]
- #[ignore = "mutates the current user's operating-system credential store"]
- fn real_keyring_smoke_round_trips_and_deletes() {
- let store = OsKeyringSecretStore::default();
- let public_key =
- PublicKey::from_hex("7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7")
- .expect("valid public key");
- let _ = store.delete(public_key);
- store
- .put(
- public_key,
- SecretKeyInput::parse("11".repeat(32)).expect("secret"),
- )
- .expect("keyring put");
- assert!(store.contains(public_key).expect("keyring contains"));
- let loaded = store.load(public_key).expect("keyring load");
- assert_eq!(loaded.with_exposed_secret(str::len), 64);
- store.delete(public_key).expect("keyring delete");
- }
-}
diff --git a/crates/studio_storage/src/profiles.rs b/crates/studio_storage/src/profiles.rs
@@ -1,254 +0,0 @@
-use radroots_studio_application::{CachedProfile, ProfileRefreshStatus, ProfileRepository};
-use radroots_studio_domain::{
- EventId, Kind0ProfileCandidate, ProfileMetadata, PublicKey, SafeError, SafeErrorCode,
- SafeMessage, UnixTimestamp,
-};
-use rusqlite::{OptionalExtension, Row, params};
-
-use crate::Database;
-
-impl ProfileRepository for Database {
- fn load_profile(&self, public_key: PublicKey) -> Result<Option<CachedProfile>, SafeError> {
- self.connection()
- .query_row(
- "SELECT event_id, event_created_at, name, display_name, nip05, about, picture, \
- refreshed_at, refresh_status FROM profile_cache_v6 WHERE subject_public_key = ?1",
- [public_key.to_hex()],
- |row| decode_profile(row, public_key),
- )
- .optional()
- .map_err(|_| corrupt_storage_error())
- }
-
- fn save_profile(&self, profile: &CachedProfile) -> Result<(), SafeError> {
- let candidate = profile.candidate();
- let metadata = candidate.metadata();
- self.connection()
- .execute(
- "INSERT INTO profile_cache_v6 (subject_public_key, event_id, event_created_at, name, \
- display_name, nip05, about, picture, refreshed_at, refresh_status) \
- VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10) \
- ON CONFLICT(subject_public_key) DO UPDATE SET \
- event_id = excluded.event_id, event_created_at = excluded.event_created_at, \
- name = excluded.name, display_name = excluded.display_name, nip05 = excluded.nip05, \
- about = excluded.about, picture = excluded.picture, \
- refreshed_at = excluded.refreshed_at, refresh_status = excluded.refresh_status \
- WHERE excluded.event_created_at > profile_cache_v6.event_created_at \
- OR (excluded.event_created_at = profile_cache_v6.event_created_at \
- AND excluded.event_id < profile_cache_v6.event_id)",
- params![
- candidate.author().to_hex(),
- candidate.event_id().to_hex(),
- candidate.created_at().as_seconds(),
- metadata.name(),
- metadata.display_name(),
- metadata.nip05(),
- metadata.about(),
- metadata.picture(),
- profile.refreshed_at().as_seconds(),
- encode_refresh_status(profile.refresh_status()),
- ],
- )
- .map(|_| ())
- .map_err(|_| storage_error())
- }
-
- fn record_refresh_status(
- &self,
- public_key: PublicKey,
- refreshed_at: UnixTimestamp,
- status: ProfileRefreshStatus,
- ) -> Result<(), SafeError> {
- self.connection()
- .execute(
- "UPDATE profile_cache_v6 SET refreshed_at = ?2, refresh_status = ?3 \
- WHERE subject_public_key = ?1",
- params![
- public_key.to_hex(),
- refreshed_at.as_seconds(),
- encode_refresh_status(status)
- ],
- )
- .map(|_| ())
- .map_err(|_| storage_error())
- }
-
- fn remove_profile(&self, public_key: PublicKey) -> Result<(), SafeError> {
- self.connection()
- .execute(
- "DELETE FROM profile_cache_v6 WHERE subject_public_key = ?1",
- [public_key.to_hex()],
- )
- .map(|_| ())
- .map_err(|_| storage_error())
- }
-}
-
-fn decode_profile(row: &Row<'_>, author: PublicKey) -> rusqlite::Result<CachedProfile> {
- let event_id =
- EventId::from_hex(row.get::<_, String>(0)?.as_str()).map_err(|_| invalid_column(0))?;
- let created_at = UnixTimestamp::from_seconds(row.get(1)?).ok_or_else(|| invalid_column(1))?;
- let metadata = ProfileMetadata::new(
- row.get(2)?,
- row.get(3)?,
- row.get(4)?,
- row.get(5)?,
- row.get(6)?,
- )
- .map_err(|_| invalid_column(2))?;
- let refreshed_at = UnixTimestamp::from_seconds(row.get(7)?).ok_or_else(|| invalid_column(7))?;
- let refresh_status = decode_refresh_status(row.get::<_, String>(8)?.as_str())?;
- Ok(CachedProfile::new(
- Kind0ProfileCandidate::new(event_id, author, created_at, metadata),
- refreshed_at,
- refresh_status,
- ))
-}
-
-const fn encode_refresh_status(status: ProfileRefreshStatus) -> &'static str {
- match status {
- ProfileRefreshStatus::Success => "success",
- ProfileRefreshStatus::Offline => "offline",
- ProfileRefreshStatus::InvalidData => "invalid_data",
- }
-}
-
-fn decode_refresh_status(value: &str) -> rusqlite::Result<ProfileRefreshStatus> {
- match value {
- "success" => Ok(ProfileRefreshStatus::Success),
- "offline" => Ok(ProfileRefreshStatus::Offline),
- "invalid_data" => Ok(ProfileRefreshStatus::InvalidData),
- _ => Err(invalid_column(8)),
- }
-}
-
-fn invalid_column(index: usize) -> rusqlite::Error {
- rusqlite::Error::InvalidColumnType(
- index,
- "cached Nostr profile".to_owned(),
- rusqlite::types::Type::Text,
- )
-}
-
-const fn storage_error() -> SafeError {
- SafeError::new(
- SafeErrorCode::StorageUnavailable,
- SafeMessage::new("The profile cache is unavailable."),
- )
-}
-
-const fn corrupt_storage_error() -> SafeError {
- SafeError::new(
- SafeErrorCode::StorageCorrupt,
- SafeMessage::new("The profile cache could not be read."),
- )
-}
-
-#[cfg(test)]
-mod tests {
- use radroots_studio_application::{
- AccountRepository, CachedProfile, ProfileRefreshStatus, ProfileRepository,
- };
- use radroots_studio_domain::{
- AccountCreatedAt, AccountIdentity, AccountSummary, BindingAvailability, EventId,
- Kind0ProfileCandidate, LocalSignerBinding, ProfileMetadata, PublicKey, UnixTimestamp,
- };
-
- use crate::Database;
-
- fn public_key() -> PublicKey {
- PublicKey::from_bytes([7; 32]).expect("valid public key")
- }
-
- fn account(public_key: PublicKey) -> AccountSummary {
- AccountSummary::new(
- AccountIdentity::derive(public_key).expect("identity"),
- LocalSignerBinding::new(public_key, BindingAvailability::Available),
- None,
- AccountCreatedAt::new(UnixTimestamp::from_seconds(1).expect("time")),
- None,
- )
- .expect("account")
- }
-
- fn profile(public_key: PublicKey, id: u8, created_at: i64, name: &str) -> CachedProfile {
- CachedProfile::new(
- Kind0ProfileCandidate::new(
- EventId::from_bytes([id; 32]),
- public_key,
- UnixTimestamp::from_seconds(created_at).expect("time"),
- ProfileMetadata::new(Some(name.to_owned()), None, None, None, None)
- .expect("metadata"),
- ),
- UnixTimestamp::from_seconds(created_at + 1).expect("refresh time"),
- ProfileRefreshStatus::Success,
- )
- }
-
- #[test]
- fn profile_cache_round_trips_and_records_refresh_status() {
- let database = Database::in_memory().expect("database");
- let public_key = public_key();
- database
- .insert_account(&account(public_key))
- .expect("account");
- database
- .save_profile(&profile(public_key, 1, 10, "Farm"))
- .expect("save profile");
- database
- .record_refresh_status(
- public_key,
- UnixTimestamp::from_seconds(20).expect("time"),
- ProfileRefreshStatus::Offline,
- )
- .expect("record status");
-
- let loaded = database
- .load_profile(public_key)
- .expect("load profile")
- .expect("cached profile");
- assert_eq!(loaded.candidate().metadata().name(), Some("Farm"));
- assert_eq!(loaded.refreshed_at().as_seconds(), 20);
- assert_eq!(loaded.refresh_status(), ProfileRefreshStatus::Offline);
- }
-
- #[test]
- fn profile_cache_keeps_newest_then_lowest_event_id() {
- let database = Database::in_memory().expect("database");
- let public_key = public_key();
- database
- .insert_account(&account(public_key))
- .expect("account");
- database
- .save_profile(&profile(public_key, 9, 20, "High ID"))
- .expect("initial");
- database
- .save_profile(&profile(public_key, 1, 20, "Low ID"))
- .expect("equal newer candidate");
- database
- .save_profile(&profile(public_key, 0, 10, "Older"))
- .expect("older candidate");
-
- let loaded = database
- .load_profile(public_key)
- .expect("load")
- .expect("profile");
- assert_eq!(loaded.candidate().metadata().name(), Some("Low ID"));
- assert_eq!(loaded.candidate().event_id(), EventId::from_bytes([1; 32]));
- }
-
- #[test]
- fn profile_cache_cascades_with_account_removal() {
- let database = Database::in_memory().expect("database");
- let public_key = public_key();
- database
- .insert_account(&account(public_key))
- .expect("account");
- database
- .save_profile(&profile(public_key, 1, 10, "Farm"))
- .expect("profile");
- database.remove_account(public_key).expect("remove account");
-
- assert_eq!(database.load_profile(public_key).expect("load"), None);
- }
-}
diff --git a/crates/studio_storage/src/recovery.rs b/crates/studio_storage/src/recovery.rs
@@ -1,692 +0,0 @@
-use std::fs::{self, File, OpenOptions};
-use std::io::{Read, Write};
-use std::path::{Path, PathBuf};
-
-use hmac::{Hmac, Mac};
-use radroots_studio_domain::{SafeError, SafeErrorCode, SafeMessage};
-use rusqlite::{Connection, MAIN_DB, OpenFlags};
-use sha2::{Digest, Sha256};
-use zeroize::Zeroizing;
-
-use crate::db::{restrict_directory_permissions, restrict_file_permissions};
-
-type HmacSha256 = Hmac<Sha256>;
-
-const RECOVERY_DIRECTORY_SUFFIX: &str = "recovery";
-const AUTHENTICATION_KEY_FILENAME: &str = "authentication-key-v1";
-const MANIFEST_FORMAT: &str = "radroots-studio-migration-recovery-v1";
-
-pub(crate) struct MigrationRecovery {
- directory: PathBuf,
- backup: PathBuf,
- marker: PathBuf,
- source_schema: u32,
- target_schema: u32,
- digest: String,
- tag: String,
- state: String,
-}
-
-impl MigrationRecovery {
- pub(crate) fn prepare(
- database_path: &Path,
- source_schema: u32,
- target_schema: u32,
- ) -> Result<Self, SafeError> {
- let directory = recovery_directory(database_path)?;
- create_recovery_directory(&directory)?;
- let key = load_or_create_authentication_key(&directory)?;
- let stem = format!("migration-v{source_schema}-to-v{target_schema}");
- let backup = directory.join(format!("{stem}.sqlite3"));
- let marker = directory.join(format!("{stem}.marker"));
-
- if marker.try_exists().map_err(|_| storage_error())? {
- let mut recovery = Self::load_existing(
- directory,
- backup,
- marker,
- source_schema,
- target_schema,
- &key,
- )?;
- if recovery.state == "complete" {
- recovery.tag = authentication_tag(
- &key,
- source_schema,
- target_schema,
- &recovery.digest,
- "prepared",
- )?;
- recovery.state = "prepared".to_owned();
- recovery.write_marker("prepared", &key)?;
- }
- return Ok(recovery);
- }
- if backup.try_exists().map_err(|_| storage_error())? {
- return Err(backup_invalid());
- }
-
- create_verified_backup(database_path, &backup)?;
- let digest = file_digest(&backup)?;
- let tag = authentication_tag(&key, source_schema, target_schema, &digest, "prepared")?;
- let recovery = Self {
- directory,
- backup,
- marker,
- source_schema,
- target_schema,
- digest,
- tag,
- state: "prepared".to_owned(),
- };
- recovery.write_marker("prepared", &key)?;
- recovery.verify_backup(&key, "prepared")?;
- Ok(recovery)
- }
-
- pub(crate) fn finish(self, current_schema: u32) -> Result<(), SafeError> {
- if current_schema != self.target_schema {
- return Err(backup_invalid());
- }
- let key = load_authentication_key(&self.directory)?;
- self.verify_backup(&key, "prepared")?;
- self.write_marker("complete", &key)
- }
-
- pub(crate) fn verify_evidence(
- database_path: &Path,
- source_schema: u32,
- target_schema: u32,
- ) -> Result<(), SafeError> {
- let directory = recovery_directory(database_path)?;
- let key = load_authentication_key(&directory)?;
- let stem = format!("migration-v{source_schema}-to-v{target_schema}");
- Self::load_existing(
- directory.clone(),
- directory.join(format!("{stem}.sqlite3")),
- directory.join(format!("{stem}.marker")),
- source_schema,
- target_schema,
- &key,
- )
- .map(|_| ())
- }
-
- pub(crate) fn restore(
- database_path: &Path,
- source_schema: u32,
- target_schema: u32,
- ) -> Result<(), SafeError> {
- let directory = recovery_directory(database_path)?;
- let key = load_authentication_key(&directory)?;
- let stem = format!("migration-v{source_schema}-to-v{target_schema}");
- let recovery = Self::load_existing(
- directory.clone(),
- directory.join(format!("{stem}.sqlite3")),
- directory.join(format!("{stem}.marker")),
- source_schema,
- target_schema,
- &key,
- )?;
- recovery.verify_backup(&key, &recovery.state)?;
- replace_with_backup(database_path, &recovery.backup)
- }
-
- fn load_existing(
- directory: PathBuf,
- backup: PathBuf,
- marker: PathBuf,
- source_schema: u32,
- target_schema: u32,
- key: &[u8],
- ) -> Result<Self, SafeError> {
- let manifest = read_bounded_file(&marker, 4_096)?;
- let manifest = std::str::from_utf8(&manifest).map_err(|_| backup_invalid())?;
- let mut lines = manifest.lines();
- if lines.next() != Some(MANIFEST_FORMAT)
- || parse_field(&mut lines, "source_schema")? != source_schema.to_string()
- || parse_field(&mut lines, "target_schema")? != target_schema.to_string()
- || parse_field(&mut lines, "backup")?
- != backup
- .file_name()
- .ok_or_else(backup_invalid)?
- .to_string_lossy()
- || lines.clone().count() != 3
- {
- return Err(backup_invalid());
- }
- let digest = parse_field(&mut lines, "sha256")?;
- let state = parse_field(&mut lines, "state")?;
- let tag = parse_field(&mut lines, "hmac_sha256")?;
- if !matches!(state.as_str(), "prepared" | "complete") {
- return Err(backup_invalid());
- }
- let recovery = Self {
- directory,
- backup,
- marker,
- source_schema,
- target_schema,
- digest,
- tag,
- state,
- };
- recovery.verify_backup(key, &recovery.state)?;
- Ok(recovery)
- }
-
- fn verify_backup(&self, key: &[u8], state: &str) -> Result<(), SafeError> {
- if file_digest(&self.backup)? != self.digest {
- return Err(backup_invalid());
- }
- let expected = authentication_tag(
- key,
- self.source_schema,
- self.target_schema,
- &self.digest,
- state,
- )?;
- let expected = decode_hex_32(&expected)?;
- let actual = decode_hex_32(&self.tag)?;
- if !constant_time_eq(&expected, &actual) {
- return Err(backup_invalid());
- }
- let flags = OpenFlags::SQLITE_OPEN_READ_ONLY
- | OpenFlags::SQLITE_OPEN_NO_MUTEX
- | OpenFlags::SQLITE_OPEN_NOFOLLOW;
- let connection =
- Connection::open_with_flags(&self.backup, flags).map_err(|_| backup_invalid())?;
- let integrity: String = connection
- .pragma_query_value(None, "quick_check", |row| row.get(0))
- .map_err(|_| backup_invalid())?;
- if integrity != "ok" {
- return Err(backup_invalid());
- }
- Ok(())
- }
-
- fn write_marker(&self, state: &str, key: &[u8]) -> Result<(), SafeError> {
- let tag = authentication_tag(
- key,
- self.source_schema,
- self.target_schema,
- &self.digest,
- state,
- )?;
- let content = format!(
- "{MANIFEST_FORMAT}\nsource_schema={}\ntarget_schema={}\nbackup={}\nsha256={}\nstate={state}\nhmac_sha256={tag}\n",
- self.source_schema,
- self.target_schema,
- self.backup
- .file_name()
- .ok_or_else(backup_invalid)?
- .to_string_lossy(),
- self.digest,
- );
- atomic_secure_write(&self.marker, content.as_bytes())
- }
-}
-
-fn recovery_directory(database_path: &Path) -> Result<PathBuf, SafeError> {
- let filename = database_path
- .file_name()
- .ok_or_else(storage_error)?
- .to_string_lossy();
- Ok(database_path.with_file_name(format!("{filename}.{RECOVERY_DIRECTORY_SUFFIX}")))
-}
-
-fn create_recovery_directory(directory: &Path) -> Result<(), SafeError> {
- match fs::symlink_metadata(directory) {
- Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_dir() => {
- return Err(storage_error());
- }
- Ok(_) => {}
- Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
- fs::create_dir(directory).map_err(|_| storage_error())?;
- }
- Err(_) => return Err(storage_error()),
- }
- restrict_directory_permissions(directory)
-}
-
-fn load_or_create_authentication_key(directory: &Path) -> Result<Zeroizing<Vec<u8>>, SafeError> {
- let path = directory.join(AUTHENTICATION_KEY_FILENAME);
- if path.try_exists().map_err(|_| storage_error())? {
- return load_authentication_key(directory);
- }
- let mut key = Zeroizing::new(vec![0_u8; 32]);
- getrandom::getrandom(&mut key).map_err(|_| storage_error())?;
- let mut options = OpenOptions::new();
- options.write(true).create_new(true);
- #[cfg(unix)]
- {
- use std::os::unix::fs::OpenOptionsExt;
- options.mode(0o600).custom_flags(
- i32::try_from((rustix::fs::OFlags::NOFOLLOW | rustix::fs::OFlags::CLOEXEC).bits())
- .map_err(|_| storage_error())?,
- );
- }
- match options.open(&path) {
- Ok(mut file) => {
- file.write_all(&key).map_err(|_| storage_error())?;
- file.sync_all().map_err(|_| storage_error())?;
- restrict_file_permissions(&path)?;
- Ok(key)
- }
- Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {
- load_authentication_key(directory)
- }
- Err(_) => Err(storage_error()),
- }
-}
-
-fn load_authentication_key(directory: &Path) -> Result<Zeroizing<Vec<u8>>, SafeError> {
- let path = directory.join(AUTHENTICATION_KEY_FILENAME);
- let key = read_bounded_file(&path, 32)?;
- if key.len() != 32 {
- return Err(backup_invalid());
- }
- Ok(Zeroizing::new(key))
-}
-
-fn create_verified_backup(source: &Path, destination: &Path) -> Result<(), SafeError> {
- let flags = OpenFlags::SQLITE_OPEN_READ_ONLY
- | OpenFlags::SQLITE_OPEN_NO_MUTEX
- | OpenFlags::SQLITE_OPEN_NOFOLLOW;
- let connection = Connection::open_with_flags(source, flags).map_err(|_| backup_invalid())?;
- connection
- .backup(MAIN_DB, destination, None)
- .map_err(|_| backup_invalid())?;
- restrict_file_permissions(destination)?;
- File::open(destination)
- .and_then(|file| file.sync_all())
- .map_err(|_| backup_invalid())
-}
-
-fn replace_with_backup(database_path: &Path, backup: &Path) -> Result<(), SafeError> {
- let parent = database_path.parent().ok_or_else(storage_error)?;
- let mut suffix = [0_u8; 8];
- getrandom::getrandom(&mut suffix).map_err(|_| storage_error())?;
- let replacement = parent.join(format!(".database-restore-{}.tmp", hex(&suffix)));
- let displaced = parent.join(format!(".database-displaced-{}.sqlite3", hex(&suffix)));
- let mut source = secure_read(backup)?;
- let mut options = OpenOptions::new();
- options.write(true).create_new(true);
- #[cfg(unix)]
- {
- use std::os::unix::fs::OpenOptionsExt;
- options.mode(0o600).custom_flags(
- i32::try_from((rustix::fs::OFlags::NOFOLLOW | rustix::fs::OFlags::CLOEXEC).bits())
- .map_err(|_| storage_error())?,
- );
- }
- let result = (|| {
- let mut destination = options.open(&replacement).map_err(|_| storage_error())?;
- std::io::copy(&mut source, &mut destination).map_err(|_| storage_error())?;
- destination.sync_all().map_err(|_| storage_error())?;
- restrict_file_permissions(&replacement)?;
- fs::rename(database_path, &displaced).map_err(|_| storage_error())?;
- if fs::rename(&replacement, database_path).is_err() {
- let _ = fs::rename(&displaced, database_path);
- return Err(storage_error());
- }
- File::open(parent)
- .and_then(|directory| directory.sync_all())
- .map_err(|_| storage_error())
- })();
- if result.is_err() {
- let _ = fs::remove_file(&replacement);
- }
- result
-}
-
-fn secure_read(path: &Path) -> Result<File, SafeError> {
- let metadata = fs::symlink_metadata(path).map_err(|_| backup_invalid())?;
- if metadata.file_type().is_symlink() || !metadata.is_file() {
- return Err(backup_invalid());
- }
- let mut options = OpenOptions::new();
- options.read(true);
- #[cfg(unix)]
- {
- use std::os::unix::fs::OpenOptionsExt;
- options.custom_flags(
- i32::try_from((rustix::fs::OFlags::NOFOLLOW | rustix::fs::OFlags::CLOEXEC).bits())
- .map_err(|_| backup_invalid())?,
- );
- }
- options.open(path).map_err(|_| backup_invalid())
-}
-
-fn file_digest(path: &Path) -> Result<String, SafeError> {
- let metadata = fs::symlink_metadata(path).map_err(|_| backup_invalid())?;
- if metadata.file_type().is_symlink() || !metadata.is_file() {
- return Err(backup_invalid());
- }
- let mut file = secure_read(path)?;
- let mut digest = Sha256::new();
- let mut buffer = [0_u8; 64 * 1024];
- loop {
- let read = file.read(&mut buffer).map_err(|_| backup_invalid())?;
- if read == 0 {
- break;
- }
- digest.update(&buffer[..read]);
- }
- Ok(hex(&digest.finalize()))
-}
-
-fn read_bounded_file(path: &Path, limit: usize) -> Result<Vec<u8>, SafeError> {
- let metadata = fs::symlink_metadata(path).map_err(|_| backup_invalid())?;
- if metadata.file_type().is_symlink()
- || !metadata.is_file()
- || usize::try_from(metadata.len()).map_err(|_| backup_invalid())? > limit
- {
- return Err(backup_invalid());
- }
- let mut options = OpenOptions::new();
- options.read(true);
- #[cfg(unix)]
- {
- use std::os::unix::fs::OpenOptionsExt;
- options.custom_flags(
- i32::try_from((rustix::fs::OFlags::NOFOLLOW | rustix::fs::OFlags::CLOEXEC).bits())
- .map_err(|_| backup_invalid())?,
- );
- }
- let file = options.open(path).map_err(|_| backup_invalid())?;
- let mut bytes = Vec::with_capacity(usize::try_from(metadata.len()).unwrap_or(0));
- file.take(u64::try_from(limit).map_err(|_| backup_invalid())? + 1)
- .read_to_end(&mut bytes)
- .map_err(|_| backup_invalid())?;
- if bytes.len() > limit {
- return Err(backup_invalid());
- }
- Ok(bytes)
-}
-
-fn atomic_secure_write(path: &Path, bytes: &[u8]) -> Result<(), SafeError> {
- let parent = path.parent().ok_or_else(storage_error)?;
- let mut suffix = [0_u8; 8];
- getrandom::getrandom(&mut suffix).map_err(|_| storage_error())?;
- let temporary = parent.join(format!(".marker-{}.tmp", hex(&suffix)));
- let mut options = OpenOptions::new();
- options.write(true).create_new(true);
- #[cfg(unix)]
- {
- use std::os::unix::fs::OpenOptionsExt;
- options.mode(0o600).custom_flags(
- i32::try_from((rustix::fs::OFlags::NOFOLLOW | rustix::fs::OFlags::CLOEXEC).bits())
- .map_err(|_| storage_error())?,
- );
- }
- let result = (|| {
- let mut file = options.open(&temporary).map_err(|_| storage_error())?;
- file.write_all(bytes).map_err(|_| storage_error())?;
- file.sync_all().map_err(|_| storage_error())?;
- restrict_file_permissions(&temporary)?;
- fs::rename(&temporary, path).map_err(|_| storage_error())?;
- File::open(parent)
- .and_then(|directory| directory.sync_all())
- .map_err(|_| storage_error())
- })();
- if result.is_err() {
- let _ = fs::remove_file(&temporary);
- }
- result
-}
-
-fn authentication_tag(
- key: &[u8],
- source_schema: u32,
- target_schema: u32,
- digest: &str,
- state: &str,
-) -> Result<String, SafeError> {
- let mut mac = HmacSha256::new_from_slice(key).map_err(|_| backup_invalid())?;
- mac.update(MANIFEST_FORMAT.as_bytes());
- mac.update(&source_schema.to_be_bytes());
- mac.update(&target_schema.to_be_bytes());
- mac.update(digest.as_bytes());
- mac.update(state.as_bytes());
- Ok(hex(&mac.finalize().into_bytes()))
-}
-
-fn parse_field<'a>(
- lines: &mut impl Iterator<Item = &'a str>,
- name: &str,
-) -> Result<String, SafeError> {
- lines
- .next()
- .and_then(|line| line.strip_prefix(name))
- .and_then(|value| value.strip_prefix('='))
- .map(str::to_owned)
- .ok_or_else(backup_invalid)
-}
-
-fn decode_hex_32(value: &str) -> Result<[u8; 32], SafeError> {
- if value.len() != 64 {
- return Err(backup_invalid());
- }
- let mut bytes = [0_u8; 32];
- for (index, pair) in value.as_bytes().chunks_exact(2).enumerate() {
- let high = hex_nibble(pair[0]).ok_or_else(backup_invalid)?;
- let low = hex_nibble(pair[1]).ok_or_else(backup_invalid)?;
- bytes[index] = (high << 4) | low;
- }
- Ok(bytes)
-}
-
-const fn hex_nibble(byte: u8) -> Option<u8> {
- match byte {
- b'0'..=b'9' => Some(byte - b'0'),
- b'a'..=b'f' => Some(byte - b'a' + 10),
- _ => None,
- }
-}
-
-fn constant_time_eq(left: &[u8; 32], right: &[u8; 32]) -> bool {
- left.iter()
- .zip(right)
- .fold(0_u8, |difference, (left, right)| {
- difference | (left ^ right)
- })
- == 0
-}
-
-fn hex(bytes: &[u8]) -> String {
- bytes.iter().map(|byte| format!("{byte:02x}")).collect()
-}
-
-const fn storage_error() -> SafeError {
- SafeError::new(
- SafeErrorCode::StorageUnavailable,
- SafeMessage::new("The application database recovery path is unavailable."),
- )
-}
-
-const fn backup_invalid() -> SafeError {
- SafeError::new(
- SafeErrorCode::StorageBackupInvalid,
- SafeMessage::new("The application database recovery backup is invalid."),
- )
-}
-
-#[cfg(test)]
-mod tests {
- use std::fs;
- use std::path::Path;
-
- use rusqlite::Connection;
- use tempfile::tempdir;
-
- use super::{
- AUTHENTICATION_KEY_FILENAME, MANIFEST_FORMAT, MigrationRecovery, atomic_secure_write,
- constant_time_eq, create_recovery_directory, decode_hex_32, file_digest, hex, hex_nibble,
- load_authentication_key, load_or_create_authentication_key, parse_field, read_bounded_file,
- recovery_directory, replace_with_backup, secure_read,
- };
-
- fn sqlite_database(path: &Path) {
- let connection = Connection::open(path).expect("open sqlite database");
- connection
- .execute("CREATE TABLE durable_probe (value INTEGER NOT NULL)", [])
- .expect("create probe table");
- connection
- .execute("INSERT INTO durable_probe (value) VALUES (7)", [])
- .expect("insert probe row");
- }
-
- #[test]
- fn migration_recovery_authenticates_finishes_reopens_and_restores() {
- let directory = tempdir().expect("temporary directory");
- let database = directory.path().join("studio.sqlite3");
- sqlite_database(&database);
-
- let recovery = MigrationRecovery::prepare(&database, 5, 10).expect("prepare recovery");
- MigrationRecovery::verify_evidence(&database, 5, 10).expect("prepared evidence");
- assert!(recovery.finish(9).is_err());
-
- MigrationRecovery::prepare(&database, 5, 10)
- .expect("reopen prepared recovery")
- .finish(10)
- .expect("finish recovery");
- MigrationRecovery::verify_evidence(&database, 5, 10).expect("complete evidence");
-
- MigrationRecovery::prepare(&database, 5, 10)
- .expect("reopen complete recovery")
- .finish(10)
- .expect("finish reopened recovery");
- fs::write(&database, b"not sqlite").expect("corrupt active database");
- MigrationRecovery::restore(&database, 5, 10).expect("restore authenticated backup");
- let connection = Connection::open(&database).expect("open restored database");
- let value: i64 = connection
- .query_row("SELECT value FROM durable_probe", [], |row| row.get(0))
- .expect("restored row");
- assert_eq!(value, 7);
- }
-
- #[test]
- fn recovery_manifest_rejects_every_tampered_authority_field() {
- let directory = tempdir().expect("temporary directory");
- let database = directory.path().join("studio.sqlite3");
- sqlite_database(&database);
- let recovery = MigrationRecovery::prepare(&database, 5, 10).expect("prepare recovery");
- let original = fs::read_to_string(&recovery.marker).expect("read marker");
- let backup_name = recovery
- .backup
- .file_name()
- .expect("backup name")
- .to_string_lossy();
- let cases = [
- original.replacen(MANIFEST_FORMAT, "wrong-format", 1),
- original.replacen("source_schema=5", "source_schema=4", 1),
- original.replacen("target_schema=10", "target_schema=11", 1),
- original.replacen(&format!("backup={backup_name}"), "backup=other.sqlite3", 1),
- original.replacen("sha256=", "unexpected=value\nsha256=", 1),
- original.replacen("state=prepared", "state=invalid", 1),
- original.replacen("sha256=", "sha256=00", 1),
- original.replacen("hmac_sha256=", "hmac_sha256=gg", 1),
- {
- let mut lines = original.lines().map(str::to_owned).collect::<Vec<_>>();
- let tag = lines
- .iter_mut()
- .find(|line| line.starts_with("hmac_sha256="))
- .expect("tag field");
- let replacement = if tag.ends_with('0') { '1' } else { '0' };
- tag.pop();
- tag.push(replacement);
- format!("{}\n", lines.join("\n"))
- },
- ];
- for tampered in cases {
- fs::write(&recovery.marker, tampered).expect("write tampered marker");
- assert!(MigrationRecovery::verify_evidence(&database, 5, 10).is_err());
- }
- fs::write(&recovery.marker, original).expect("restore marker");
- MigrationRecovery::verify_evidence(&database, 5, 10).expect("restored evidence");
- }
-
- #[test]
- fn recovery_helpers_reject_invalid_paths_sizes_and_encodings() {
- let directory = tempdir().expect("temporary directory");
- let regular = directory.path().join("regular");
- fs::write(®ular, b"abc").expect("write regular file");
- let child = directory.path().join("child");
- fs::create_dir(&child).expect("create child directory");
-
- assert!(recovery_directory(Path::new("/")).is_err());
- assert!(create_recovery_directory(®ular).is_err());
- assert!(create_recovery_directory(®ular.join("nested")).is_err());
- assert!(secure_read(&child).is_err());
- assert!(file_digest(&child).is_err());
- assert!(read_bounded_file(&child, 4).is_err());
- assert!(read_bounded_file(®ular, 2).is_err());
- assert_eq!(
- read_bounded_file(®ular, 3).expect("bounded read"),
- b"abc"
- );
-
- let missing_key_dir = directory.path().join("missing-key");
- fs::create_dir(&missing_key_dir).expect("create missing key directory");
- assert!(load_authentication_key(&missing_key_dir).is_err());
- fs::write(
- missing_key_dir.join(AUTHENTICATION_KEY_FILENAME),
- [0_u8; 31],
- )
- .expect("write short key");
- assert!(load_authentication_key(&missing_key_dir).is_err());
-
- assert!(decode_hex_32("00").is_err());
- assert!(decode_hex_32(&format!("g0{}", "00".repeat(31))).is_err());
- assert!(decode_hex_32(&format!("0g{}", "00".repeat(31))).is_err());
- let zeros = decode_hex_32(&"00".repeat(32)).expect("decode zeros");
- assert!(constant_time_eq(&zeros, &[0_u8; 32]));
- assert!(!constant_time_eq(&zeros, &[1_u8; 32]));
- assert_eq!(hex(&[0, 15, 255]), "000fff");
- assert_eq!(hex_nibble(b'9'), Some(9));
- assert_eq!(hex_nibble(b'f'), Some(15));
- assert_eq!(hex_nibble(b'G'), None);
-
- let mut valid = ["field=value"].into_iter();
- assert_eq!(parse_field(&mut valid, "field").expect("field"), "value");
- let mut invalid = ["other=value"].into_iter();
- assert!(parse_field(&mut invalid, "field").is_err());
- let mut missing = std::iter::empty();
- assert!(parse_field(&mut missing, "field").is_err());
-
- let absent_parent = directory.path().join("absent").join("marker");
- assert!(atomic_secure_write(&absent_parent, b"marker").is_err());
-
- let orphan_database = directory.path().join("orphan.sqlite3");
- sqlite_database(&orphan_database);
- let orphan_directory = recovery_directory(&orphan_database).expect("recovery directory");
- create_recovery_directory(&orphan_directory).expect("create recovery directory");
- load_or_create_authentication_key(&orphan_directory).expect("authentication key");
- fs::write(
- orphan_directory.join("migration-v5-to-v10.sqlite3"),
- b"orphan backup",
- )
- .expect("orphan backup");
- assert!(MigrationRecovery::prepare(&orphan_database, 5, 10).is_err());
-
- let missing_database = directory.path().join("missing-database.sqlite3");
- assert!(replace_with_backup(&missing_database, ®ular).is_err());
- }
-
- #[cfg(unix)]
- #[test]
- fn recovery_helpers_reject_symlink_inputs() {
- use std::os::unix::fs::symlink;
-
- let directory = tempdir().expect("temporary directory");
- let regular = directory.path().join("regular");
- fs::write(®ular, b"abc").expect("write regular file");
- let link = directory.path().join("link");
- symlink(®ular, &link).expect("create symlink");
- assert!(secure_read(&link).is_err());
- assert!(file_digest(&link).is_err());
- assert!(read_bounded_file(&link, 3).is_err());
- assert!(create_recovery_directory(&link).is_err());
- }
-}
diff --git a/crates/studio_storage/src/repair.rs b/crates/studio_storage/src/repair.rs
@@ -1,403 +0,0 @@
-use std::fs::{self, File, OpenOptions};
-use std::io::Read;
-use std::path::{Path, PathBuf};
-
-use hmac::{Hmac, Mac};
-use radroots_studio_domain::{SafeError, SafeErrorCode, SafeMessage};
-use rusqlite::{Connection, MAIN_DB, OpenFlags};
-use sha2::{Digest, Sha256};
-use zeroize::Zeroizing;
-
-use crate::compatibility::{DatabasePreflight, preflight};
-use crate::db::{CURRENT_SCHEMA_VERSION, restrict_file_permissions};
-
-type HmacSha256 = Hmac<Sha256>;
-const EXPORT_DOMAIN: &[u8] = b"radroots-studio-quarantine-export-v1";
-const REPAIR_DOMAIN: &[u8] = b"radroots-studio-repair-candidate-v1";
-
-pub struct RepairAuthorization(Zeroizing<[u8; 32]>);
-
-impl RepairAuthorization {
- /// Moves an exact 256-bit caller authorization secret into zeroizing storage.
- ///
- /// # Errors
- ///
- /// Returns a safe authorization error for every other input length.
- pub fn from_bytes(bytes: Vec<u8>) -> Result<Self, SafeError> {
- let bytes = Zeroizing::new(bytes);
- let value = <[u8; 32]>::try_from(bytes.as_slice()).map_err(|_| unauthorized())?;
- Ok(Self(Zeroizing::new(value)))
- }
-
- fn expose(&self) -> &[u8; 32] {
- &self.0
- }
-}
-
-pub struct QuarantineExportReceipt {
- path: PathBuf,
- sha256: String,
- authentication_tag: String,
-}
-
-impl QuarantineExportReceipt {
- #[must_use]
- pub fn path(&self) -> &Path {
- &self.path
- }
-
- #[must_use]
- pub fn sha256(&self) -> &str {
- &self.sha256
- }
-
- #[must_use]
- pub fn authentication_tag(&self) -> &str {
- &self.authentication_tag
- }
-}
-
-pub struct RepairCandidate {
- path: PathBuf,
- sha256: String,
- authentication_tag: String,
-}
-
-impl RepairCandidate {
- #[must_use]
- pub fn path(&self) -> &Path {
- &self.path
- }
-}
-
-pub(crate) fn export_quarantined(
- source: &Path,
- destination: &Path,
- authorization: &RepairAuthorization,
-) -> Result<QuarantineExportReceipt, SafeError> {
- if !matches!(preflight(source)?, DatabasePreflight::Quarantined { .. }) {
- return Err(not_quarantined());
- }
- ensure_new_destination(destination)?;
- let flags = OpenFlags::SQLITE_OPEN_READ_ONLY
- | OpenFlags::SQLITE_OPEN_NO_MUTEX
- | OpenFlags::SQLITE_OPEN_NOFOLLOW;
- let connection = Connection::open_with_flags(source, flags).map_err(|_| storage_error())?;
- if connection.backup(MAIN_DB, destination, None).is_err() {
- let _ = fs::remove_file(destination);
- return Err(storage_error());
- }
- restrict_file_permissions(destination)?;
- File::open(destination)
- .and_then(|file| file.sync_all())
- .map_err(|_| storage_error())?;
- let sha256 = digest_file(destination)?;
- let authentication_tag = authenticate(authorization, EXPORT_DOMAIN, &sha256)?;
- Ok(QuarantineExportReceipt {
- path: destination.to_path_buf(),
- sha256,
- authentication_tag,
- })
-}
-
-pub(crate) fn authenticate_candidate(
- path: &Path,
- authorization: &RepairAuthorization,
-) -> Result<RepairCandidate, SafeError> {
- if !matches!(
- preflight(path)?,
- DatabasePreflight::Ready { schema_version } if schema_version <= CURRENT_SCHEMA_VERSION
- ) {
- return Err(storage_error());
- }
- let sha256 = digest_file(path)?;
- let authentication_tag = authenticate(authorization, REPAIR_DOMAIN, &sha256)?;
- Ok(RepairCandidate {
- path: path.to_path_buf(),
- sha256,
- authentication_tag,
- })
-}
-
-pub(crate) fn install_candidate(
- target: &Path,
- candidate: &RepairCandidate,
- authorization: &RepairAuthorization,
-) -> Result<(), SafeError> {
- if !matches!(preflight(target)?, DatabasePreflight::Quarantined { .. }) {
- return Err(not_quarantined());
- }
- let digest = digest_file(&candidate.path)?;
- if digest != candidate.sha256
- || authenticate(authorization, REPAIR_DOMAIN, &digest)? != candidate.authentication_tag
- {
- return Err(unauthorized());
- }
- if !matches!(preflight(&candidate.path)?, DatabasePreflight::Ready { .. }) {
- return Err(storage_error());
- }
- let parent = target.parent().ok_or_else(storage_error)?;
- let replacement = parent.join(".authenticated-repair.tmp");
- if replacement.try_exists().map_err(|_| storage_error())? {
- return Err(storage_error());
- }
- copy_secure(&candidate.path, &replacement)?;
- let retained = parent.join("studio.sqlite3.quarantined-evidence");
- if retained.try_exists().map_err(|_| storage_error())? {
- let _ = fs::remove_file(&replacement);
- return Err(storage_error());
- }
- fs::rename(target, &retained).map_err(|_| storage_error())?;
- if fs::rename(&replacement, target).is_err() {
- let _ = fs::rename(&retained, target);
- let _ = fs::remove_file(&replacement);
- return Err(storage_error());
- }
- File::open(parent)
- .and_then(|directory| directory.sync_all())
- .map_err(|_| storage_error())
-}
-
-fn ensure_new_destination(path: &Path) -> Result<(), SafeError> {
- if path.try_exists().map_err(|_| storage_error())? {
- return Err(storage_error());
- }
- let parent = path.parent().ok_or_else(storage_error)?;
- let metadata = fs::symlink_metadata(parent).map_err(|_| storage_error())?;
- if metadata.file_type().is_symlink() || !metadata.is_dir() {
- return Err(storage_error());
- }
- Ok(())
-}
-
-fn copy_secure(source: &Path, destination_path: &Path) -> Result<(), SafeError> {
- let mut source = secure_read(source)?;
- let mut options = OpenOptions::new();
- options.write(true).create_new(true);
- #[cfg(unix)]
- {
- use std::os::unix::fs::OpenOptionsExt;
- options.mode(0o600).custom_flags(
- i32::try_from((rustix::fs::OFlags::NOFOLLOW | rustix::fs::OFlags::CLOEXEC).bits())
- .map_err(|_| storage_error())?,
- );
- }
- let mut destination = options
- .open(destination_path)
- .map_err(|_| storage_error())?;
- std::io::copy(&mut source, &mut destination).map_err(|_| storage_error())?;
- destination.sync_all().map_err(|_| storage_error())?;
- restrict_file_permissions(destination_path)
-}
-
-fn secure_read(path: &Path) -> Result<File, SafeError> {
- let metadata = fs::symlink_metadata(path).map_err(|_| storage_error())?;
- if metadata.file_type().is_symlink() || !metadata.is_file() {
- return Err(storage_error());
- }
- let mut options = OpenOptions::new();
- options.read(true);
- #[cfg(unix)]
- {
- use std::os::unix::fs::OpenOptionsExt;
- options.custom_flags(
- i32::try_from((rustix::fs::OFlags::NOFOLLOW | rustix::fs::OFlags::CLOEXEC).bits())
- .map_err(|_| storage_error())?,
- );
- }
- options.open(path).map_err(|_| storage_error())
-}
-
-fn digest_file(path: &Path) -> Result<String, SafeError> {
- let mut file = secure_read(path)?;
- let mut digest = Sha256::new();
- let mut buffer = [0_u8; 64 * 1024];
- loop {
- let read = file.read(&mut buffer).map_err(|_| storage_error())?;
- if read == 0 {
- break;
- }
- digest.update(&buffer[..read]);
- }
- Ok(hex(&digest.finalize()))
-}
-
-fn authenticate(
- authorization: &RepairAuthorization,
- domain: &[u8],
- digest: &str,
-) -> Result<String, SafeError> {
- let mut hmac =
- HmacSha256::new_from_slice(authorization.expose()).map_err(|_| unauthorized())?;
- hmac.update(domain);
- hmac.update(digest.as_bytes());
- Ok(hex(&hmac.finalize().into_bytes()))
-}
-
-fn hex(bytes: &[u8]) -> String {
- bytes.iter().map(|byte| format!("{byte:02x}")).collect()
-}
-
-const fn unauthorized() -> SafeError {
- SafeError::new(
- SafeErrorCode::RepairUnauthorized,
- SafeMessage::new("The database repair authorization is invalid."),
- )
-}
-
-const fn not_quarantined() -> SafeError {
- SafeError::new(
- SafeErrorCode::InvalidApplicationState,
- SafeMessage::new("The database is not in quarantine."),
- )
-}
-
-const fn storage_error() -> SafeError {
- SafeError::new(
- SafeErrorCode::StorageUnavailable,
- SafeMessage::new("The database repair operation could not be completed."),
- )
-}
-
-#[cfg(test)]
-mod tests {
- use std::fs;
- use std::io::Write;
-
- use rusqlite::Connection;
- use tempfile::tempdir;
-
- use super::{
- REPAIR_DOMAIN, RepairAuthorization, RepairCandidate, authenticate, authenticate_candidate,
- copy_secure, digest_file, ensure_new_destination, export_quarantined, hex,
- install_candidate, secure_read,
- };
- use crate::Database;
-
- fn quarantined_database(path: &std::path::Path) {
- drop(Database::open(path).expect("current database"));
- let connection = Connection::open(path).expect("open database");
- connection
- .execute(
- "INSERT INTO account_identities (public_key, npub, created_at) VALUES (?1, ?2, 1)",
- [
- "00".repeat(32),
- "npub1qurswpc8qurswpc8qurswpc8qurswpc8qurswpc8qurswpc8qursnvjvl7".to_owned(),
- ],
- )
- .expect("invalid identity fixture");
- connection
- .execute(
- "INSERT INTO local_signer_bindings (account_public_key, binding_public_key, binding_kind, availability) VALUES (?1, ?1, 'local_secret', 'available')",
- ["00".repeat(32)],
- )
- .expect("binding fixture");
- }
-
- #[test]
- fn repair_authority_and_candidate_reject_invalid_states() {
- assert!(RepairAuthorization::from_bytes(vec![0_u8; 31]).is_err());
- assert!(RepairAuthorization::from_bytes(vec![0_u8; 33]).is_err());
- let authorization = RepairAuthorization::from_bytes(vec![0x41; 32]).expect("authorization");
- assert_eq!(
- authenticate(&authorization, b"domain", "digest")
- .expect("authentication tag")
- .len(),
- 64
- );
- assert_eq!(hex(&[0, 15, 255]), "000fff");
-
- let directory = tempdir().expect("temporary directory");
- let ready = directory.path().join("ready.sqlite3");
- drop(Database::open(&ready).expect("ready database"));
- let candidate = authenticate_candidate(&ready, &authorization).expect("candidate");
- assert_eq!(candidate.path(), ready);
-
- let missing = directory.path().join("missing.sqlite3");
- assert!(authenticate_candidate(&missing, &authorization).is_err());
- let export = directory.path().join("export.sqlite3");
- assert!(export_quarantined(&ready, &export, &authorization).is_err());
- assert!(install_candidate(&ready, &candidate, &authorization).is_err());
- }
-
- #[test]
- fn repair_file_boundaries_reject_existing_non_file_and_missing_parent_paths() {
- let directory = tempdir().expect("temporary directory");
- let regular = directory.path().join("regular");
- fs::write(®ular, b"repair material").expect("write regular file");
- let child = directory.path().join("child");
- fs::create_dir(&child).expect("create child directory");
-
- assert!(ensure_new_destination(®ular).is_err());
- assert!(ensure_new_destination(®ular.join("nested")).is_err());
- assert!(secure_read(&child).is_err());
- assert_eq!(digest_file(®ular).expect("digest").len(), 64);
-
- let copied = directory.path().join("copied");
- copy_secure(®ular, &copied).expect("secure copy");
- assert_eq!(fs::read(&copied).expect("copied bytes"), b"repair material");
- assert!(copy_secure(®ular, &copied).is_err());
- assert!(copy_secure(&child, &directory.path().join("invalid-copy")).is_err());
- }
-
- #[test]
- fn repair_installation_rejects_tampering_quarantined_candidates_and_staging_collisions() {
- let directory = tempdir().expect("temporary directory");
- let authorization = RepairAuthorization::from_bytes(vec![0x41; 32]).expect("authorization");
- let target = directory.path().join("studio.sqlite3");
- quarantined_database(&target);
- let candidate_path = directory.path().join("candidate.sqlite3");
- drop(Database::open(&candidate_path).expect("candidate database"));
- let candidate = authenticate_candidate(&candidate_path, &authorization).expect("candidate");
-
- fs::OpenOptions::new()
- .append(true)
- .open(&candidate_path)
- .expect("open candidate")
- .write_all(b"tamper")
- .expect("tamper candidate");
- assert!(install_candidate(&target, &candidate, &authorization).is_err());
-
- let quarantined_candidate_path = directory.path().join("quarantined-candidate.sqlite3");
- quarantined_database(&quarantined_candidate_path);
- let digest = digest_file(&quarantined_candidate_path).expect("candidate digest");
- let quarantined_candidate = RepairCandidate {
- path: quarantined_candidate_path,
- sha256: digest.clone(),
- authentication_tag: authenticate(&authorization, REPAIR_DOMAIN, &digest)
- .expect("candidate tag"),
- };
- assert!(install_candidate(&target, &quarantined_candidate, &authorization).is_err());
-
- let candidate_path = directory.path().join("candidate-two.sqlite3");
- drop(Database::open(&candidate_path).expect("candidate database"));
- let candidate = authenticate_candidate(&candidate_path, &authorization).expect("candidate");
- let replacement = directory.path().join(".authenticated-repair.tmp");
- fs::write(&replacement, b"occupied").expect("occupied replacement");
- assert!(install_candidate(&target, &candidate, &authorization).is_err());
- fs::remove_file(&replacement).expect("remove occupied replacement");
-
- let retained = directory.path().join("studio.sqlite3.quarantined-evidence");
- fs::write(&retained, b"occupied").expect("occupied retained evidence");
- assert!(install_candidate(&target, &candidate, &authorization).is_err());
- assert!(!replacement.exists());
- }
-
- #[cfg(unix)]
- #[test]
- fn repair_file_boundaries_reject_symlinks() {
- use std::os::unix::fs::symlink;
-
- let directory = tempdir().expect("temporary directory");
- let regular = directory.path().join("regular");
- fs::write(®ular, b"repair material").expect("write regular file");
- let link = directory.path().join("link");
- symlink(®ular, &link).expect("create file symlink");
- assert!(secure_read(&link).is_err());
- assert!(digest_file(&link).is_err());
-
- let directory_link = directory.path().join("directory-link");
- symlink(directory.path(), &directory_link).expect("create directory symlink");
- assert!(ensure_new_destination(&directory_link.join("export")).is_err());
- }
-}
diff --git a/crates/studio_storage/tests/redaction.rs b/crates/studio_storage/tests/redaction.rs
@@ -1,52 +0,0 @@
-use std::fs;
-
-use radroots_studio_application::{AccountOperationKind, AccountRepository, OperationJournal};
-use radroots_studio_domain::{
- AccountCreatedAt, AccountIdentity, AccountSummary, BindingAvailability, LocalSignerBinding,
- PublicKey, UnixTimestamp,
-};
-use radroots_studio_storage::Database;
-use tempfile::tempdir;
-
-const SECRET_HEX: &str = "1111111111111111111111111111111111111111111111111111111111111111";
-const SECRET_NSEC: &str = "nsec1vl029mgpspedva04g90vltkh6fvh240zqtv9k0t9af8935ke9laqsnlfe5";
-fn assert_redacted(bytes: &[u8]) {
- assert!(
- !bytes
- .windows(SECRET_HEX.len())
- .any(|value| value == SECRET_HEX.as_bytes())
- );
- assert!(
- !bytes
- .windows(SECRET_NSEC.len())
- .any(|value| value == SECRET_NSEC.as_bytes())
- );
- assert!(!bytes.windows(5).any(|value| value == b"nsec1"));
-}
-
-#[test]
-fn redaction_guards_sqlite_schema_and_non_secret_records() {
- let directory = tempdir().expect("directory");
- let path = directory.path().join("studio.sqlite3");
- {
- let database = Database::open(&path).expect("database");
- let public_key = PublicKey::from_bytes([7; 32]).expect("valid public key");
- let account = AccountSummary::new(
- AccountIdentity::derive(public_key).expect("identity"),
- LocalSignerBinding::new(public_key, BindingAvailability::Available),
- None,
- AccountCreatedAt::new(UnixTimestamp::from_seconds(1).expect("time")),
- None,
- )
- .expect("account");
- database.insert_account(&account).expect("account");
- database
- .begin_operation(
- AccountOperationKind::Add,
- account.public_key(),
- UnixTimestamp::from_seconds(2).expect("time"),
- )
- .expect("journal");
- }
- assert_redacted(&fs::read(path).expect("database bytes"));
-}
diff --git a/crates/studio_uniffi_bindgen/Cargo.toml b/crates/studio_uniffi_bindgen/Cargo.toml
@@ -1,18 +0,0 @@
-[package]
-name = "radroots_studio_uniffi_bindgen"
-description = "Private UniFFI binding generator for Radroots Studio"
-version = "0.1.0-alpha"
-edition.workspace = true
-authors.workspace = true
-rust-version.workspace = true
-license = "GPL-3.0-only"
-repository.workspace = true
-homepage.workspace = true
-publish = false
-include = ["src/**", "Cargo.toml"]
-
-[dependencies]
-uniffi = { version = "=0.32.0", features = ["cli"] }
-
-[lints]
-workspace = true
diff --git a/crates/studio_uniffi_bindgen/src/main.rs b/crates/studio_uniffi_bindgen/src/main.rs
@@ -1,21 +0,0 @@
-#![doc = "Pinned `UniFFI` binding generator entry point."]
-
-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/supply-chain/config.toml b/supply-chain/config.toml
@@ -32,15 +32,6 @@ sha2 = "crypto-reviewed"
subtle = "crypto-reviewed"
zeroize = "secret-handling-reviewed"
-[policy.radroots_studio_ffi]
-dependency-criteria = { quote = "build-execution-reviewed", syn = "build-execution-reviewed" }
-
-[policy.radroots_studio_storage.dependency-criteria]
-hmac = "crypto-reviewed"
-keyring = "secret-handling-reviewed"
-sha2 = "crypto-reviewed"
-zeroize = "secret-handling-reviewed"
-
[policy.radroots_transport_nostr.dependency-criteria]
async-wsocket = "network-parser-reviewed"
nostr-relay-pool = "network-parser-reviewed"
diff --git a/tools/xtask/src/build_control.rs b/tools/xtask/src/build_control.rs
@@ -93,11 +93,8 @@ impl ConsumerRoot {
.map_err(|error| format!("consumer marker is not UTF-8: {error}"))?
.trim()
.to_owned();
- if !matches!(
- product.as_str(),
- "sdk" | "mobile" | "studio" | "myc" | "rhi"
- ) {
- return Err("consumer marker must contain sdk, mobile, studio, myc, or rhi".to_owned());
+ if !matches!(product.as_str(), "sdk" | "mobile" | "myc" | "rhi") {
+ return Err("consumer marker must contain sdk, mobile, myc, or rhi".to_owned());
}
let source_lock_path = canonical.join(SOURCE_LOCK_NAME);
let source_lock = parse_source_lock(&source_lock_path)?;
@@ -540,7 +537,6 @@ pub fn artifact(
let external_names = match product {
"sdk" => vec!["radroots", "radroots_sdk"],
"mobile" => vec!["RadrootsFFI", "RadrootsKitBindings"],
- "studio" => vec!["org.radroots.studio.ffi", "radroots_studio_ffi"],
_ => return Err("unsupported artifact product".to_owned()),
};
let manifest = ArtifactManifest {
@@ -613,7 +609,6 @@ fn validate_artifact_route(product: &str, target: &str, language: &str) -> Resul
(target, language),
("ios", "swift") | ("android", "kotlin") | ("wasm", "javascript")
),
- "studio" => matches!(target, "linux" | "macos" | "windows") && language == "kotlin",
_ => false,
};
if valid {
@@ -1177,7 +1172,7 @@ mod tests {
#[test]
fn source_lock_supports_a_contained_nested_lockfile() {
- let mut fixture = Fixture::new("studio");
+ let mut fixture = Fixture::new("sdk");
let core = fixture.consumer.join("core");
fs::create_dir(&core).expect("create nested capsule");
fs::rename(fixture.consumer.join("Cargo.lock"), core.join("Cargo.lock"))
diff --git a/tools/xtask/src/catalog.rs b/tools/xtask/src/catalog.rs
@@ -407,7 +407,6 @@ fn validate_catalog(catalog: &Catalog) -> Result<(), String> {
"preview",
"public_native",
"sdk",
- "studio",
"tools",
"wasm",
]);
@@ -1246,6 +1245,14 @@ fn expected_retired_packages() -> BTreeSet<&'static str> {
"radroots-studio-nostr",
"radroots-studio-storage",
"radroots-studio-uniffi-bindgen",
+ "radroots_studio_application",
+ "radroots_studio_domain",
+ "radroots_studio_ffi",
+ "radroots_studio_nostr",
+ "radroots_studio_preferences",
+ "radroots_studio_runtime",
+ "radroots_studio_storage",
+ "radroots_studio_uniffi_bindgen",
"radroots_app_bindgen",
"radroots_app_core",
"radroots_app_ffi",
diff --git a/tools/xtask/src/coverage.rs b/tools/xtask/src/coverage.rs
@@ -4094,22 +4094,31 @@ test_threads = 4
#[test]
fn coverage_profiles_resolve_validated_downstream_test_packages() {
- let root = workspace_root();
- let runtime = read_coverage_profile(&root, "radroots_studio_runtime")
- .expect("runtime coverage profile");
- assert_eq!(
- runtime.test_packages,
- vec!["radroots_studio_ffi".to_string()]
+ let root = temp_dir_path("profile_downstream_packages");
+ write_file(
+ &root.join("Cargo.toml"),
+ "[workspace]\nmembers = [\"crates/target\", \"crates/downstream\"]\n",
);
- let storage = read_coverage_profile(&root, "radroots_studio_storage")
- .expect("storage coverage profile");
+ write_file(
+ &root.join("crates/target/Cargo.toml"),
+ "[package]\nname = \"radroots_target\"\nversion = \"0.1.0-alpha\"\n",
+ );
+ write_file(
+ &root.join("crates/downstream/Cargo.toml"),
+ "[package]\nname = \"radroots_downstream\"\nversion = \"0.1.0-alpha\"\n",
+ );
+ write_file(
+ &root.join("contracts/coverage-profiles.toml"),
+ "[profiles.crates.\"radroots_target\"]\ntest_packages = [\"radroots_downstream\"]\n",
+ );
+
+ let profile =
+ read_coverage_profile(&root, "radroots_target").expect("target coverage profile");
assert_eq!(
- storage.test_packages,
- vec![
- "radroots_studio_runtime".to_string(),
- "radroots_studio_ffi".to_string()
- ]
+ profile.test_packages,
+ vec!["radroots_downstream".to_string()]
);
+ fs::remove_dir_all(root).expect("remove root");
}
#[test]
@@ -4625,10 +4634,27 @@ test_threads = 0
#[test]
fn run_crate_credits_declared_downstream_tests_only_to_the_target_report() {
+ let root = temp_dir_path("run_crate_downstream_tests");
+ write_file(
+ &root.join("Cargo.toml"),
+ "[workspace]\nmembers = [\"crates/target\", \"crates/downstream\"]\n",
+ );
+ write_file(
+ &root.join("crates/target/Cargo.toml"),
+ "[package]\nname = \"radroots_target\"\nversion = \"0.1.0-alpha\"\n",
+ );
+ write_file(
+ &root.join("crates/downstream/Cargo.toml"),
+ "[package]\nname = \"radroots_downstream\"\nversion = \"0.1.0-alpha\"\n",
+ );
+ write_file(
+ &root.join("contracts/coverage-profiles.toml"),
+ "[profiles.crates.\"radroots_target\"]\ntest_packages = [\"radroots_downstream\"]\n",
+ );
let out = temp_dir_path("run_crate_downstream_tests");
let args = vec![
"--crate".to_string(),
- "radroots_studio_runtime".to_string(),
+ "radroots_target".to_string(),
"--out".to_string(),
out.display().to_string(),
];
@@ -4642,21 +4668,23 @@ test_threads = 0
);
Ok(())
};
- run_crate_with_runner(&args, &mut runner).expect("run crate with downstream tests");
+ run_crate_with_runner_at_root(&args, &root, &mut runner)
+ .expect("run crate with downstream tests");
let test_command = rendered_commands
.iter()
.find(|command| command.contains("--no-report"))
.expect("coverage test command");
- assert!(test_command.contains("-p radroots_studio_runtime"));
- assert!(test_command.contains("-p radroots_studio_ffi"));
+ assert!(test_command.contains("-p radroots_target"));
+ assert!(test_command.contains("-p radroots_downstream"));
for report_command in rendered_commands
.iter()
.filter(|command| command.starts_with("report "))
{
- assert!(report_command.contains("-p radroots_studio_runtime"));
- assert!(!report_command.contains("-p radroots_studio_ffi"));
+ assert!(report_command.contains("-p radroots_target"));
+ assert!(!report_command.contains("-p radroots_downstream"));
}
fs::remove_dir_all(out).expect("remove downstream test output dir");
+ fs::remove_dir_all(root).expect("remove root");
}
#[test]
diff --git a/tools/xtask/src/main.rs b/tools/xtask/src/main.rs
@@ -172,7 +172,6 @@ enum SourceMode {
enum ArtifactProduct {
Sdk,
Mobile,
- Studio,
}
impl ArtifactProduct {
@@ -180,7 +179,6 @@ impl ArtifactProduct {
match self {
Self::Sdk => "sdk",
Self::Mobile => "mobile",
- Self::Studio => "studio",
}
}
}
@@ -311,7 +309,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|studio> --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|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>"
);
}
@@ -652,9 +650,8 @@ mod tests {
[
ArtifactProduct::Sdk.as_str(),
ArtifactProduct::Mobile.as_str(),
- ArtifactProduct::Studio.as_str(),
],
- ["sdk", "mobile", "studio"]
+ ["sdk", "mobile"]
);
assert_eq!(
[