commit 9888b891ad9901962f83678c8d8aa9261d0d8a13
parent cb8a62b2ae4f08bfe3be701d75cbfa4d6f2f4d13
Author: triesap <tyson@radroots.org>
Date: Thu, 11 Jun 2026 21:37:04 -0700
field: add auth session ffi
- Adds typed field auth/session records and runtime snapshots.
- Wires the auth API HTTP client through reqwest using configured base URLs.
- Stores access and refresh tokens in runtime state with explicit secure-store export.
- Covers config, redaction, login, verify, and restore flows with Rust tests.
Diffstat:
6 files changed, 1355 insertions(+), 1 deletion(-)
diff --git a/Cargo.lock b/Cargo.lock
@@ -174,6 +174,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ef49f5882e4b6afaac09ad239a4f8c70a24b8f2b0897edb1f706008efd109cf4"
[[package]]
+name = "atomic-waker"
+version = "1.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
+
+[[package]]
name = "autocfg"
version = "1.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -337,6 +343,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
+name = "cfg_aliases"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
+
+[[package]]
name = "chacha20"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -770,9 +782,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
dependencies = [
"cfg-if",
+ "js-sys",
"libc",
"r-efi 5.3.0",
"wasip2",
+ "wasm-bindgen",
]
[[package]]
@@ -892,12 +906,94 @@ dependencies = [
]
[[package]]
+name = "http-body"
+version = "1.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184"
+dependencies = [
+ "bytes",
+ "http",
+]
+
+[[package]]
+name = "http-body-util"
+version = "0.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a"
+dependencies = [
+ "bytes",
+ "futures-core",
+ "http",
+ "http-body",
+ "pin-project-lite",
+]
+
+[[package]]
name = "httparse"
version = "1.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
[[package]]
+name = "hyper"
+version = "1.10.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498"
+dependencies = [
+ "atomic-waker",
+ "bytes",
+ "futures-channel",
+ "futures-core",
+ "http",
+ "http-body",
+ "httparse",
+ "itoa",
+ "pin-project-lite",
+ "smallvec",
+ "tokio",
+ "want",
+]
+
+[[package]]
+name = "hyper-rustls"
+version = "0.27.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f"
+dependencies = [
+ "http",
+ "hyper",
+ "hyper-util",
+ "rustls",
+ "tokio",
+ "tokio-rustls",
+ "tower-service",
+ "webpki-roots 1.0.7",
+]
+
+[[package]]
+name = "hyper-util"
+version = "0.1.20"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0"
+dependencies = [
+ "base64 0.22.1",
+ "bytes",
+ "futures-channel",
+ "futures-util",
+ "http",
+ "http-body",
+ "hyper",
+ "ipnet",
+ "libc",
+ "percent-encoding",
+ "pin-project-lite",
+ "socket2",
+ "tokio",
+ "tower-service",
+ "tracing",
+]
+
+[[package]]
name = "iana-time-zone"
version = "0.1.65"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1065,6 +1161,12 @@ dependencies = [
]
[[package]]
+name = "ipnet"
+version = "2.12.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2"
+
+[[package]]
name = "itoa"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1144,6 +1246,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f66e8d5d03f609abc3a39e6f08e4164ebf1447a732906d39eb9b99b7919ef39"
[[package]]
+name = "lru-slab"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
+
+[[package]]
name = "matchers"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1464,6 +1572,61 @@ dependencies = [
]
[[package]]
+name = "quinn"
+version = "0.11.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20"
+dependencies = [
+ "bytes",
+ "cfg_aliases",
+ "pin-project-lite",
+ "quinn-proto",
+ "quinn-udp",
+ "rustc-hash",
+ "rustls",
+ "socket2",
+ "thiserror 2.0.18",
+ "tokio",
+ "tracing",
+ "web-time",
+]
+
+[[package]]
+name = "quinn-proto"
+version = "0.11.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098"
+dependencies = [
+ "bytes",
+ "getrandom 0.3.4",
+ "lru-slab",
+ "rand 0.9.4",
+ "ring",
+ "rustc-hash",
+ "rustls",
+ "rustls-pki-types",
+ "slab",
+ "thiserror 2.0.18",
+ "tinyvec",
+ "tracing",
+ "web-time",
+]
+
+[[package]]
+name = "quinn-udp"
+version = "0.5.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd"
+dependencies = [
+ "cfg_aliases",
+ "libc",
+ "once_cell",
+ "socket2",
+ "tracing",
+ "windows-sys 0.52.0",
+]
+
+[[package]]
name = "quote"
version = "1.0.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1525,6 +1688,7 @@ dependencies = [
"radroots_net",
"radroots_nostr",
"radroots_trade",
+ "reqwest",
"serde",
"serde_json",
"thiserror 1.0.69",
@@ -1808,6 +1972,46 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
[[package]]
+name = "reqwest"
+version = "0.12.28"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
+dependencies = [
+ "base64 0.22.1",
+ "bytes",
+ "futures-channel",
+ "futures-core",
+ "futures-util",
+ "http",
+ "http-body",
+ "http-body-util",
+ "hyper",
+ "hyper-rustls",
+ "hyper-util",
+ "js-sys",
+ "log",
+ "percent-encoding",
+ "pin-project-lite",
+ "quinn",
+ "rustls",
+ "rustls-pki-types",
+ "serde",
+ "serde_json",
+ "serde_urlencoded",
+ "sync_wrapper",
+ "tokio",
+ "tokio-rustls",
+ "tower",
+ "tower-http",
+ "tower-service",
+ "url",
+ "wasm-bindgen",
+ "wasm-bindgen-futures",
+ "web-sys",
+ "webpki-roots 1.0.7",
+]
+
+[[package]]
name = "ring"
version = "0.17.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1904,6 +2108,7 @@ version = "1.14.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9"
dependencies = [
+ "web-time",
"zeroize",
]
@@ -1925,6 +2130,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
[[package]]
+name = "ryu"
+version = "1.0.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
+
+[[package]]
name = "salsa20"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2057,6 +2268,18 @@ dependencies = [
]
[[package]]
+name = "serde_urlencoded"
+version = "0.7.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd"
+dependencies = [
+ "form_urlencoded",
+ "itoa",
+ "ryu",
+ "serde",
+]
+
+[[package]]
name = "sha1"
version = "0.10.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2179,6 +2402,15 @@ dependencies = [
]
[[package]]
+name = "sync_wrapper"
+version = "1.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263"
+dependencies = [
+ "futures-core",
+]
+
+[[package]]
name = "synstructure"
version = "0.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2441,6 +2673,51 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801"
[[package]]
+name = "tower"
+version = "0.5.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4"
+dependencies = [
+ "futures-core",
+ "futures-util",
+ "pin-project-lite",
+ "sync_wrapper",
+ "tokio",
+ "tower-layer",
+ "tower-service",
+]
+
+[[package]]
+name = "tower-http"
+version = "0.6.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840"
+dependencies = [
+ "bitflags",
+ "bytes",
+ "futures-util",
+ "http",
+ "http-body",
+ "pin-project-lite",
+ "tower",
+ "tower-layer",
+ "tower-service",
+ "url",
+]
+
+[[package]]
+name = "tower-layer"
+version = "0.3.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e"
+
+[[package]]
+name = "tower-service"
+version = "0.3.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3"
+
+[[package]]
name = "tracing"
version = "0.1.44"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2515,6 +2792,12 @@ dependencies = [
]
[[package]]
+name = "try-lock"
+version = "0.2.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
+
+[[package]]
name = "tungstenite"
version = "0.26.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2769,6 +3052,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]]
+name = "want"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e"
+dependencies = [
+ "try-lock",
+]
+
+[[package]]
name = "wasi"
version = "0.11.1+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2893,6 +3185,16 @@ dependencies = [
]
[[package]]
+name = "web-time"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb"
+dependencies = [
+ "js-sys",
+ "wasm-bindgen",
+]
+
+[[package]]
name = "webpki-roots"
version = "0.26.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
diff --git a/Cargo.toml b/Cargo.toml
@@ -37,4 +37,5 @@ tracing = { version = "0.1" }
tracing-subscriber = { version = "0.3" }
uniffi = { version = "0.29.4" }
uniffi_build = { version = "0.29.4" }
+reqwest = { version = "0.12", default-features = false }
wasm-bindgen = { version = "0.2" }
diff --git a/crates/field_core/Cargo.toml b/crates/field_core/Cargo.toml
@@ -47,6 +47,7 @@ thiserror = { workspace = true }
tracing = { workspace = true }
uniffi = { workspace = true }
tokio = { workspace = true }
+reqwest = { workspace = true, features = ["blocking", "json", "rustls-tls"] }
[dev-dependencies]
tracing-subscriber = { workspace = true }
diff --git a/crates/field_core/src/runtime/auth_session.rs b/crates/field_core/src/runtime/auth_session.rs
@@ -0,0 +1,1044 @@
+use std::time::Duration;
+
+use reqwest::{Url, blocking::Client};
+use serde::{Deserialize, Serialize, de::DeserializeOwned};
+
+use super::{
+ RadrootsRuntime,
+ nostr::{NostrConnectionStatus, NostrLight},
+};
+use crate::RadrootsAppError;
+
+const AUTH_HTTP_TIMEOUT_SECONDS: u64 = 15;
+
+#[derive(uniffi::Enum, Debug, Clone, Copy, PartialEq, Eq)]
+pub enum FieldSessionPhase {
+ SignedOut,
+ ChallengePending,
+ Authenticated,
+ Expired,
+ Revoked,
+}
+
+impl Default for FieldSessionPhase {
+ fn default() -> Self {
+ Self::SignedOut
+ }
+}
+
+#[derive(uniffi::Record, Debug, Clone, PartialEq, Eq, Default)]
+pub struct FieldAuthConfig {
+ pub auth_api_base_url: Option<String>,
+ pub accounts_api_base_url: Option<String>,
+}
+
+impl FieldAuthConfig {
+ fn from_inputs(
+ auth_api_base_url: Option<String>,
+ accounts_api_base_url: Option<String>,
+ ) -> Result<Self, RadrootsAppError> {
+ Ok(Self {
+ auth_api_base_url: normalize_http_base_url(auth_api_base_url, "auth_api_base_url")?,
+ accounts_api_base_url: normalize_http_base_url(
+ accounts_api_base_url,
+ "accounts_api_base_url",
+ )?,
+ })
+ }
+}
+
+#[derive(uniffi::Record, Debug, Clone, PartialEq, Eq)]
+pub struct FieldLoginChallenge {
+ pub id: String,
+ pub challenge_kind: String,
+ pub login_username: Option<String>,
+ pub masked_email: String,
+ pub delivery_state: String,
+ pub max_attempts: i32,
+ pub attempt_count: i32,
+ pub expires_at_unix_seconds: i64,
+ pub delivered_at_unix_seconds: Option<i64>,
+}
+
+#[derive(uniffi::Record, Debug, Clone, PartialEq, Eq)]
+pub struct FieldSessionAccount {
+ pub id: String,
+ pub username: String,
+ pub display_name: String,
+ pub status: String,
+}
+
+#[derive(uniffi::Record, Debug, Clone, PartialEq, Eq)]
+pub struct FieldSessionProfile {
+ pub id: String,
+ pub account_id: String,
+ pub display_name: String,
+ pub email: Option<String>,
+ pub status: String,
+}
+
+#[derive(uniffi::Record, Debug, Clone, PartialEq, Eq)]
+pub struct FieldSessionCredential {
+ pub id: String,
+ pub account_id: String,
+ pub profile_id: String,
+ pub email: String,
+ pub status: String,
+ pub is_primary: bool,
+}
+
+#[derive(uniffi::Record, Debug, Clone, PartialEq, Eq)]
+pub struct FieldSession {
+ pub id: String,
+ pub account_id: String,
+ pub profile_id: String,
+ pub credential_id: String,
+ pub session_id: String,
+ pub status: String,
+ pub expires_at_unix_seconds: i64,
+ pub revoked_at_unix_seconds: Option<i64>,
+}
+
+#[derive(uniffi::Record, Debug, Clone, PartialEq, Eq)]
+pub struct FieldSessionSnapshot {
+ pub phase: FieldSessionPhase,
+ pub pending_challenge: Option<FieldLoginChallenge>,
+ pub account: Option<FieldSessionAccount>,
+ pub profile: Option<FieldSessionProfile>,
+ pub credential: Option<FieldSessionCredential>,
+ pub session: Option<FieldSession>,
+ pub access_token_present: bool,
+ pub refresh_token_present: bool,
+ pub selected_npub: Option<String>,
+ pub nostr_light: NostrLight,
+ pub nostr_connected: u32,
+ pub nostr_connecting: u32,
+ pub nostr_last_error: Option<String>,
+}
+
+#[derive(uniffi::Record, Debug, Clone, PartialEq, Eq)]
+pub struct FieldSessionTokenBundle {
+ pub access_token: String,
+ pub refresh_token: String,
+}
+
+#[derive(Debug, Clone, Default)]
+pub(crate) struct FieldSessionState {
+ phase: FieldSessionPhase,
+ pending_challenge: Option<FieldLoginChallenge>,
+ account: Option<FieldSessionAccount>,
+ profile: Option<FieldSessionProfile>,
+ credential: Option<FieldSessionCredential>,
+ session: Option<FieldSession>,
+ access_token: Option<String>,
+ refresh_token: Option<String>,
+}
+
+impl FieldSessionState {
+ fn challenge_pending(challenge: FieldLoginChallenge) -> Self {
+ Self {
+ phase: FieldSessionPhase::ChallengePending,
+ pending_challenge: Some(challenge),
+ ..Self::default()
+ }
+ }
+
+ fn authenticated(bundle: SessionBundleResponseDto) -> Self {
+ Self {
+ phase: FieldSessionPhase::Authenticated,
+ pending_challenge: None,
+ account: Some(bundle.account.into()),
+ profile: Some(bundle.profile.into()),
+ credential: Some(bundle.credential.into()),
+ session: Some(bundle.session.into()),
+ access_token: Some(bundle.access_token),
+ refresh_token: Some(bundle.refresh_token),
+ }
+ }
+
+ fn restored(
+ view: SessionResponseDto,
+ access_token: String,
+ refresh_token: Option<String>,
+ ) -> Self {
+ Self {
+ phase: FieldSessionPhase::Authenticated,
+ pending_challenge: None,
+ account: Some(view.account.into()),
+ profile: Some(view.profile.into()),
+ credential: Some(view.credential.into()),
+ session: Some(view.session.into()),
+ access_token: Some(access_token),
+ refresh_token,
+ }
+ }
+
+ fn revoked(view: SessionResponseDto) -> Self {
+ Self {
+ phase: FieldSessionPhase::Revoked,
+ pending_challenge: None,
+ account: Some(view.account.into()),
+ profile: Some(view.profile.into()),
+ credential: Some(view.credential.into()),
+ session: Some(view.session.into()),
+ access_token: None,
+ refresh_token: None,
+ }
+ }
+
+ fn snapshot(
+ &self,
+ selected_npub: Option<String>,
+ nostr: NostrConnectionStatus,
+ ) -> FieldSessionSnapshot {
+ FieldSessionSnapshot {
+ phase: self.phase,
+ pending_challenge: self.pending_challenge.clone(),
+ account: self.account.clone(),
+ profile: self.profile.clone(),
+ credential: self.credential.clone(),
+ session: self.session.clone(),
+ access_token_present: self.access_token.is_some(),
+ refresh_token_present: self.refresh_token.is_some(),
+ selected_npub,
+ nostr_light: nostr.light,
+ nostr_connected: nostr.connected,
+ nostr_connecting: nostr.connecting,
+ nostr_last_error: nostr.last_error,
+ }
+ }
+
+ fn token_bundle(&self) -> Option<FieldSessionTokenBundle> {
+ Some(FieldSessionTokenBundle {
+ access_token: self.access_token.clone()?,
+ refresh_token: self.refresh_token.clone()?,
+ })
+ }
+}
+
+#[cfg_attr(not(coverage_nightly), uniffi::export)]
+impl RadrootsRuntime {
+ pub fn field_configure_auth(
+ &self,
+ auth_api_base_url: Option<String>,
+ accounts_api_base_url: Option<String>,
+ ) -> Result<(), RadrootsAppError> {
+ let config = FieldAuthConfig::from_inputs(auth_api_base_url, accounts_api_base_url)?;
+ let mut guard = self
+ .auth_config
+ .write()
+ .map_err(|err| RadrootsAppError::Msg(format!("{err}")))?;
+ *guard = config;
+ Ok(())
+ }
+
+ pub fn field_auth_config(&self) -> FieldAuthConfig {
+ self.auth_config
+ .read()
+ .map(|guard| guard.clone())
+ .unwrap_or_default()
+ }
+
+ pub fn field_session_snapshot(&self) -> FieldSessionSnapshot {
+ self.snapshot_from_state()
+ }
+
+ pub fn field_session_token_bundle(
+ &self,
+ ) -> Result<Option<FieldSessionTokenBundle>, RadrootsAppError> {
+ let guard = self
+ .session
+ .read()
+ .map_err(|err| RadrootsAppError::Msg(format!("{err}")))?;
+ Ok(guard.token_bundle())
+ }
+
+ pub fn field_start_login(
+ &self,
+ username: String,
+ ) -> Result<FieldLoginChallenge, RadrootsAppError> {
+ let username = non_empty(username, "username")?;
+ let response: ChallengeResponseDto =
+ self.auth_post_public("/v1/auth/login", &StartLoginRequest { username })?;
+ let challenge: FieldLoginChallenge = response.challenge.into();
+ self.replace_session(FieldSessionState::challenge_pending(challenge.clone()))?;
+ Ok(challenge)
+ }
+
+ pub fn field_get_login_challenge(
+ &self,
+ challenge_id: String,
+ ) -> Result<FieldLoginChallenge, RadrootsAppError> {
+ let challenge_id = path_id(challenge_id, "challenge_id")?;
+ let response: ChallengeResponseDto =
+ self.auth_get_public(format!("/v1/auth/challenges/{challenge_id}").as_str())?;
+ let challenge: FieldLoginChallenge = response.challenge.into();
+ self.replace_session(FieldSessionState::challenge_pending(challenge.clone()))?;
+ Ok(challenge)
+ }
+
+ pub fn field_resend_login_challenge(
+ &self,
+ challenge_id: String,
+ ) -> Result<FieldLoginChallenge, RadrootsAppError> {
+ let challenge_id = path_id(challenge_id, "challenge_id")?;
+ let response: ChallengeResponseDto = self.auth_post_public(
+ format!("/v1/auth/challenges/{challenge_id}/resend").as_str(),
+ &EmptyRequest {},
+ )?;
+ let challenge: FieldLoginChallenge = response.challenge.into();
+ self.replace_session(FieldSessionState::challenge_pending(challenge.clone()))?;
+ Ok(challenge)
+ }
+
+ pub fn field_verify_login_challenge(
+ &self,
+ challenge_id: String,
+ code: String,
+ ) -> Result<FieldSessionSnapshot, RadrootsAppError> {
+ let challenge_id = path_id(challenge_id, "challenge_id")?;
+ let code = non_empty(code, "code")?;
+ let response: SessionBundleResponseDto = self.auth_post_public(
+ format!("/v1/auth/challenges/{challenge_id}/verify").as_str(),
+ &VerifyChallengeRequest { code },
+ )?;
+ self.replace_session(FieldSessionState::authenticated(response))?;
+ Ok(self.snapshot_from_state())
+ }
+
+ pub fn field_refresh_session(
+ &self,
+ request_id: String,
+ ) -> Result<FieldSessionSnapshot, RadrootsAppError> {
+ let request_id = non_empty(request_id, "request_id")?;
+ let refresh_token = self.require_refresh_token()?;
+ let response: SessionBundleResponseDto = self.auth_post_public(
+ "/v1/auth/session/refresh",
+ &RefreshSessionRequest {
+ request_id,
+ refresh_token,
+ },
+ )?;
+ self.replace_session(FieldSessionState::authenticated(response))?;
+ Ok(self.snapshot_from_state())
+ }
+
+ pub fn field_restore_session(
+ &self,
+ access_token: String,
+ refresh_token: Option<String>,
+ ) -> Result<FieldSessionSnapshot, RadrootsAppError> {
+ let access_token = non_empty(access_token, "access_token")?;
+ let refresh_token = optional_non_empty(refresh_token, "refresh_token")?;
+ let response: SessionResponseDto =
+ self.auth_get_bearer("/v1/auth/session", &access_token)?;
+ self.replace_session(FieldSessionState::restored(
+ response,
+ access_token,
+ refresh_token,
+ ))?;
+ Ok(self.snapshot_from_state())
+ }
+
+ pub fn field_current_session(&self) -> Result<FieldSessionSnapshot, RadrootsAppError> {
+ let access_token = self.require_access_token()?;
+ let response: SessionResponseDto =
+ self.auth_get_bearer("/v1/auth/session", &access_token)?;
+ let refresh_token = self.optional_refresh_token()?;
+ self.replace_session(FieldSessionState::restored(
+ response,
+ access_token,
+ refresh_token,
+ ))?;
+ Ok(self.snapshot_from_state())
+ }
+
+ pub fn field_revoke_session(&self) -> Result<FieldSessionSnapshot, RadrootsAppError> {
+ let access_token = self.require_access_token()?;
+ let session_id = self.require_session_id()?;
+ let response: SessionResponseDto = self.auth_post_bearer(
+ "/v1/auth/session/revoke",
+ &access_token,
+ &RevokeSessionRequest { session_id },
+ )?;
+ self.replace_session(FieldSessionState::revoked(response))?;
+ Ok(self.snapshot_from_state())
+ }
+
+ pub fn field_clear_session(&self) -> FieldSessionSnapshot {
+ let _ = self.replace_session(FieldSessionState::default());
+ self.snapshot_from_state()
+ }
+
+ pub fn field_prepare_authenticated_nostr(
+ &self,
+ relays: Vec<String>,
+ ) -> Result<FieldSessionSnapshot, RadrootsAppError> {
+ self.require_authenticated_session()?;
+ if relays.is_empty() {
+ return Err(RadrootsAppError::Msg(
+ "at least one relay is required".into(),
+ ));
+ }
+ if !self.accounts_has_selected_signing_identity() {
+ let label = self
+ .session
+ .read()
+ .ok()
+ .and_then(|guard| {
+ guard
+ .account
+ .as_ref()
+ .map(|account| account.username.clone())
+ })
+ .unwrap_or_else(|| "Radroots Field".to_owned());
+ self.accounts_generate(Some(label), true)?;
+ }
+ self.nostr_set_default_relays(relays)?;
+ self.nostr_connect_if_key_present()?;
+ Ok(self.snapshot_from_state())
+ }
+}
+
+impl RadrootsRuntime {
+ fn snapshot_from_state(&self) -> FieldSessionSnapshot {
+ let selected_npub = self.accounts_selected_npub();
+ let nostr = self.nostr_connection_status();
+ self.session
+ .read()
+ .map(|guard| guard.snapshot(selected_npub, nostr))
+ .unwrap_or_else(|_| {
+ FieldSessionState::default().snapshot(
+ self.accounts_selected_npub(),
+ NostrConnectionStatus {
+ light: NostrLight::Red,
+ connected: 0,
+ connecting: 0,
+ last_error: None,
+ },
+ )
+ })
+ }
+
+ fn replace_session(&self, session: FieldSessionState) -> Result<(), RadrootsAppError> {
+ let mut guard = self
+ .session
+ .write()
+ .map_err(|err| RadrootsAppError::Msg(format!("{err}")))?;
+ *guard = session;
+ Ok(())
+ }
+
+ fn require_authenticated_session(&self) -> Result<(), RadrootsAppError> {
+ let guard = self
+ .session
+ .read()
+ .map_err(|err| RadrootsAppError::Msg(format!("{err}")))?;
+ if guard.phase == FieldSessionPhase::Authenticated && guard.access_token.is_some() {
+ Ok(())
+ } else {
+ Err(RadrootsAppError::Msg(
+ "authenticated field session is required".into(),
+ ))
+ }
+ }
+
+ fn require_access_token(&self) -> Result<String, RadrootsAppError> {
+ let guard = self
+ .session
+ .read()
+ .map_err(|err| RadrootsAppError::Msg(format!("{err}")))?;
+ guard
+ .access_token
+ .clone()
+ .ok_or_else(|| RadrootsAppError::Msg("access token is not configured".into()))
+ }
+
+ fn optional_refresh_token(&self) -> Result<Option<String>, RadrootsAppError> {
+ let guard = self
+ .session
+ .read()
+ .map_err(|err| RadrootsAppError::Msg(format!("{err}")))?;
+ Ok(guard.refresh_token.clone())
+ }
+
+ fn require_refresh_token(&self) -> Result<String, RadrootsAppError> {
+ let guard = self
+ .session
+ .read()
+ .map_err(|err| RadrootsAppError::Msg(format!("{err}")))?;
+ guard
+ .refresh_token
+ .clone()
+ .ok_or_else(|| RadrootsAppError::Msg("refresh token is not configured".into()))
+ }
+
+ fn require_session_id(&self) -> Result<String, RadrootsAppError> {
+ let guard = self
+ .session
+ .read()
+ .map_err(|err| RadrootsAppError::Msg(format!("{err}")))?;
+ guard
+ .session
+ .as_ref()
+ .map(|session| session.session_id.clone())
+ .ok_or_else(|| RadrootsAppError::Msg("session id is not configured".into()))
+ }
+
+ fn auth_base_url(&self) -> Result<String, RadrootsAppError> {
+ let guard = self
+ .auth_config
+ .read()
+ .map_err(|err| RadrootsAppError::Msg(format!("{err}")))?;
+ guard
+ .auth_api_base_url
+ .clone()
+ .ok_or_else(|| RadrootsAppError::Msg("auth API base URL is not configured".into()))
+ }
+
+ fn auth_url(&self, path: &str) -> Result<Url, RadrootsAppError> {
+ if !path.starts_with('/') {
+ return Err(RadrootsAppError::Msg(
+ "auth API path must start with /".into(),
+ ));
+ }
+ Url::parse(format!("{}{}", self.auth_base_url()?, path).as_str())
+ .map_err(|err| RadrootsAppError::Msg(format!("auth API URL is invalid: {err}")))
+ }
+
+ fn auth_get_public<T>(&self, path: &str) -> Result<T, RadrootsAppError>
+ where
+ T: DeserializeOwned,
+ {
+ self.auth_get(path, None)
+ }
+
+ fn auth_get_bearer<T>(&self, path: &str, access_token: &str) -> Result<T, RadrootsAppError>
+ where
+ T: DeserializeOwned,
+ {
+ self.auth_get(path, Some(access_token))
+ }
+
+ fn auth_get<T>(&self, path: &str, access_token: Option<&str>) -> Result<T, RadrootsAppError>
+ where
+ T: DeserializeOwned,
+ {
+ let client = auth_client()?;
+ let mut request = client.get(self.auth_url(path)?);
+ if let Some(token) = access_token {
+ request = request.bearer_auth(token);
+ }
+ decode_response("GET", path, request.send())
+ }
+
+ fn auth_post_public<B, T>(&self, path: &str, body: &B) -> Result<T, RadrootsAppError>
+ where
+ B: Serialize,
+ T: DeserializeOwned,
+ {
+ self.auth_post(path, None, body)
+ }
+
+ fn auth_post_bearer<B, T>(
+ &self,
+ path: &str,
+ access_token: &str,
+ body: &B,
+ ) -> Result<T, RadrootsAppError>
+ where
+ B: Serialize,
+ T: DeserializeOwned,
+ {
+ self.auth_post(path, Some(access_token), body)
+ }
+
+ fn auth_post<B, T>(
+ &self,
+ path: &str,
+ access_token: Option<&str>,
+ body: &B,
+ ) -> Result<T, RadrootsAppError>
+ where
+ B: Serialize,
+ T: DeserializeOwned,
+ {
+ let client = auth_client()?;
+ let mut request = client.post(self.auth_url(path)?).json(body);
+ if let Some(token) = access_token {
+ request = request.bearer_auth(token);
+ }
+ decode_response("POST", path, request.send())
+ }
+}
+
+#[derive(Debug, Serialize)]
+struct EmptyRequest {}
+
+#[derive(Debug, Serialize)]
+struct StartLoginRequest {
+ username: String,
+}
+
+#[derive(Debug, Serialize)]
+struct VerifyChallengeRequest {
+ code: String,
+}
+
+#[derive(Debug, Serialize)]
+struct RefreshSessionRequest {
+ request_id: String,
+ refresh_token: String,
+}
+
+#[derive(Debug, Serialize)]
+struct RevokeSessionRequest {
+ session_id: String,
+}
+
+#[derive(Debug, Deserialize)]
+struct ChallengeResponseDto {
+ challenge: ChallengeBodyDto,
+}
+
+#[derive(Debug, Deserialize)]
+struct ChallengeBodyDto {
+ id: String,
+ challenge_kind: String,
+ login_username: Option<String>,
+ masked_email: String,
+ delivery_state: String,
+ max_attempts: i32,
+ attempt_count: i32,
+ expires_at_unix_seconds: i64,
+ delivered_at_unix_seconds: Option<i64>,
+}
+
+impl From<ChallengeBodyDto> for FieldLoginChallenge {
+ fn from(value: ChallengeBodyDto) -> Self {
+ Self {
+ id: value.id,
+ challenge_kind: value.challenge_kind,
+ login_username: value.login_username,
+ masked_email: value.masked_email,
+ delivery_state: value.delivery_state,
+ max_attempts: value.max_attempts,
+ attempt_count: value.attempt_count,
+ expires_at_unix_seconds: value.expires_at_unix_seconds,
+ delivered_at_unix_seconds: value.delivered_at_unix_seconds,
+ }
+ }
+}
+
+#[derive(Debug, Deserialize)]
+struct SessionBundleResponseDto {
+ account: AccountBodyDto,
+ profile: ProfileBodyDto,
+ credential: CredentialBodyDto,
+ session: SessionBodyDto,
+ access_token: String,
+ refresh_token: String,
+}
+
+#[derive(Debug, Deserialize)]
+struct SessionResponseDto {
+ account: AccountBodyDto,
+ profile: ProfileBodyDto,
+ credential: CredentialBodyDto,
+ session: SessionBodyDto,
+}
+
+#[derive(Debug, Deserialize)]
+struct AccountBodyDto {
+ id: String,
+ username: String,
+ display_name: String,
+ status: String,
+}
+
+impl From<AccountBodyDto> for FieldSessionAccount {
+ fn from(value: AccountBodyDto) -> Self {
+ Self {
+ id: value.id,
+ username: value.username,
+ display_name: value.display_name,
+ status: value.status,
+ }
+ }
+}
+
+#[derive(Debug, Deserialize)]
+struct ProfileBodyDto {
+ id: String,
+ account_id: String,
+ display_name: String,
+ email: Option<String>,
+ status: String,
+}
+
+impl From<ProfileBodyDto> for FieldSessionProfile {
+ fn from(value: ProfileBodyDto) -> Self {
+ Self {
+ id: value.id,
+ account_id: value.account_id,
+ display_name: value.display_name,
+ email: value.email,
+ status: value.status,
+ }
+ }
+}
+
+#[derive(Debug, Deserialize)]
+struct CredentialBodyDto {
+ id: String,
+ account_id: String,
+ profile_id: String,
+ email: String,
+ status: String,
+ is_primary: bool,
+}
+
+impl From<CredentialBodyDto> for FieldSessionCredential {
+ fn from(value: CredentialBodyDto) -> Self {
+ Self {
+ id: value.id,
+ account_id: value.account_id,
+ profile_id: value.profile_id,
+ email: value.email,
+ status: value.status,
+ is_primary: value.is_primary,
+ }
+ }
+}
+
+#[derive(Debug, Deserialize)]
+struct SessionBodyDto {
+ id: String,
+ account_id: String,
+ profile_id: String,
+ credential_id: String,
+ session_id: String,
+ status: String,
+ expires_at_unix_seconds: i64,
+ revoked_at_unix_seconds: Option<i64>,
+}
+
+impl From<SessionBodyDto> for FieldSession {
+ fn from(value: SessionBodyDto) -> Self {
+ Self {
+ id: value.id,
+ account_id: value.account_id,
+ profile_id: value.profile_id,
+ credential_id: value.credential_id,
+ session_id: value.session_id,
+ status: value.status,
+ expires_at_unix_seconds: value.expires_at_unix_seconds,
+ revoked_at_unix_seconds: value.revoked_at_unix_seconds,
+ }
+ }
+}
+
+#[derive(Debug, Deserialize)]
+struct ApiErrorBodyDto {
+ code: String,
+ message: String,
+}
+
+fn auth_client() -> Result<Client, RadrootsAppError> {
+ Client::builder()
+ .timeout(Duration::from_secs(AUTH_HTTP_TIMEOUT_SECONDS))
+ .build()
+ .map_err(|err| RadrootsAppError::Msg(format!("auth API client build failed: {err}")))
+}
+
+fn decode_response<T>(
+ method: &str,
+ path: &str,
+ response: Result<reqwest::blocking::Response, reqwest::Error>,
+) -> Result<T, RadrootsAppError>
+where
+ T: DeserializeOwned,
+{
+ let response =
+ response.map_err(|err| RadrootsAppError::Msg(format!("{method} {path} failed: {err}")))?;
+ let status = response.status();
+ if status.is_success() {
+ return response.json::<T>().map_err(|err| {
+ RadrootsAppError::Msg(format!("{method} {path} response decode failed: {err}"))
+ });
+ }
+ let body = response
+ .text()
+ .unwrap_or_else(|err| format!("failed to read error response: {err}"));
+ let message = serde_json::from_str::<ApiErrorBodyDto>(body.as_str())
+ .map(|body| format!("{} {}", body.code, body.message))
+ .unwrap_or(body);
+ Err(RadrootsAppError::Msg(format!(
+ "{method} {path} failed with HTTP {status}: {message}"
+ )))
+}
+
+fn normalize_http_base_url(
+ value: Option<String>,
+ field: &str,
+) -> Result<Option<String>, RadrootsAppError> {
+ let Some(value) = value else {
+ return Ok(None);
+ };
+ let value = value.trim().trim_end_matches('/').to_owned();
+ if value.is_empty() {
+ return Ok(None);
+ }
+ let parsed = Url::parse(value.as_str())
+ .map_err(|err| RadrootsAppError::Msg(format!("{field} is invalid: {err}")))?;
+ match parsed.scheme() {
+ "http" | "https" => Ok(Some(value)),
+ _ => Err(RadrootsAppError::Msg(format!(
+ "{field} must use http or https"
+ ))),
+ }
+}
+
+fn optional_non_empty(
+ value: Option<String>,
+ field: &str,
+) -> Result<Option<String>, RadrootsAppError> {
+ let Some(value) = value else {
+ return Ok(None);
+ };
+ Ok(Some(non_empty(value, field)?))
+}
+
+fn non_empty(value: String, field: &str) -> Result<String, RadrootsAppError> {
+ let value = value.trim().to_owned();
+ if value.is_empty() {
+ return Err(RadrootsAppError::Msg(format!("{field} is required")));
+ }
+ Ok(value)
+}
+
+fn path_id(value: String, field: &str) -> Result<String, RadrootsAppError> {
+ let value = non_empty(value, field)?;
+ if value.contains('/') || value.contains('?') || value.contains('#') {
+ return Err(RadrootsAppError::Msg(format!(
+ "{field} contains invalid path characters"
+ )));
+ }
+ Ok(value)
+}
+
+#[cfg(test)]
+mod tests {
+ use std::{
+ io::{Read, Write},
+ net::TcpListener,
+ thread,
+ };
+
+ use super::{FieldAuthConfig, FieldSessionPhase};
+ use crate::runtime::RadrootsRuntime;
+
+ #[test]
+ fn auth_config_normalizes_and_validates_http_urls() {
+ let config = FieldAuthConfig::from_inputs(
+ Some(" http://127.0.0.1:8081/ ".to_owned()),
+ Some("https://accounts.example.test/api/".to_owned()),
+ )
+ .expect("config");
+
+ assert_eq!(
+ config.auth_api_base_url.as_deref(),
+ Some("http://127.0.0.1:8081")
+ );
+ assert_eq!(
+ config.accounts_api_base_url.as_deref(),
+ Some("https://accounts.example.test/api")
+ );
+ let error = FieldAuthConfig::from_inputs(Some("wss://relay.example.test".to_owned()), None)
+ .expect_err("reject non-http URL");
+ assert!(error.to_string().contains("auth_api_base_url"));
+ }
+
+ #[test]
+ fn session_snapshot_redacts_tokens() {
+ let runtime = RadrootsRuntime::new().expect("runtime");
+ let snapshot = runtime
+ .field_restore_session("access-token".to_owned(), Some("refresh-token".to_owned()))
+ .expect_err("auth URL missing");
+ assert!(snapshot.to_string().contains("auth API base URL"));
+
+ runtime
+ .replace_session(super::FieldSessionState {
+ phase: FieldSessionPhase::Authenticated,
+ access_token: Some("access-token".to_owned()),
+ refresh_token: Some("refresh-token".to_owned()),
+ ..Default::default()
+ })
+ .expect("session");
+
+ let snapshot = runtime.field_session_snapshot();
+ assert!(snapshot.access_token_present);
+ assert!(snapshot.refresh_token_present);
+ assert!(!format!("{snapshot:?}").contains("access-token"));
+ assert!(!format!("{snapshot:?}").contains("refresh-token"));
+ let tokens = runtime
+ .field_session_token_bundle()
+ .expect("token bundle")
+ .expect("tokens");
+ assert_eq!(tokens.access_token, "access-token");
+ runtime.stop();
+ }
+
+ #[test]
+ fn start_login_posts_to_auth_api_and_stores_pending_challenge() {
+ let (base_url, handle) = spawn_response(
+ "POST /v1/auth/login HTTP/1.1",
+ Some("\"username\":\"field@radroots.test\""),
+ r#"{"challenge":{"id":"challenge-1","challenge_kind":"login","login_username":"field@radroots.test","masked_email":"f***@radroots.test","delivery_state":"sent","max_attempts":6,"attempt_count":0,"expires_at_unix_seconds":1893456000,"delivered_at_unix_seconds":1893455900}}"#,
+ );
+ let runtime = RadrootsRuntime::new().expect("runtime");
+ runtime
+ .field_configure_auth(Some(base_url), None)
+ .expect("configure");
+
+ let challenge = runtime
+ .field_start_login("field@radroots.test".to_owned())
+ .expect("login");
+ assert_eq!(challenge.id, "challenge-1");
+ assert_eq!(
+ runtime.field_session_snapshot().phase,
+ FieldSessionPhase::ChallengePending
+ );
+ handle.join().expect("server");
+ runtime.stop();
+ }
+
+ #[test]
+ fn verify_login_stores_authenticated_session_and_redacted_snapshot() {
+ let body = sample_session_bundle_json("access-token", "refresh-token");
+ let (base_url, handle) = spawn_response(
+ "POST /v1/auth/challenges/challenge-1/verify HTTP/1.1",
+ Some("\"code\":\"123456\""),
+ body.as_str(),
+ );
+ let runtime = RadrootsRuntime::new().expect("runtime");
+ runtime
+ .field_configure_auth(Some(base_url), None)
+ .expect("configure");
+
+ let snapshot = runtime
+ .field_verify_login_challenge("challenge-1".to_owned(), "123456".to_owned())
+ .expect("verify");
+ assert_eq!(snapshot.phase, FieldSessionPhase::Authenticated);
+ assert_eq!(
+ snapshot
+ .account
+ .as_ref()
+ .map(|account| account.username.as_str()),
+ Some("field")
+ );
+ assert!(snapshot.access_token_present);
+ assert!(snapshot.refresh_token_present);
+ assert!(!format!("{snapshot:?}").contains("access-token"));
+ let tokens = runtime
+ .field_session_token_bundle()
+ .expect("tokens")
+ .expect("token bundle");
+ assert_eq!(tokens.refresh_token, "refresh-token");
+ handle.join().expect("server");
+ runtime.stop();
+ }
+
+ #[test]
+ fn restore_session_fetches_current_session_with_bearer_token() {
+ let body = sample_session_json();
+ let (base_url, handle) = spawn_response(
+ "GET /v1/auth/session HTTP/1.1",
+ Some("authorization: Bearer access-token"),
+ body.as_str(),
+ );
+ let runtime = RadrootsRuntime::new().expect("runtime");
+ runtime
+ .field_configure_auth(Some(base_url), None)
+ .expect("configure");
+
+ let snapshot = runtime
+ .field_restore_session("access-token".to_owned(), Some("refresh-token".to_owned()))
+ .expect("restore");
+ assert_eq!(snapshot.phase, FieldSessionPhase::Authenticated);
+ assert_eq!(
+ snapshot
+ .session
+ .as_ref()
+ .map(|session| session.session_id.as_str()),
+ Some("session-public-id")
+ );
+ handle.join().expect("server");
+ runtime.stop();
+ }
+
+ fn spawn_response(
+ expected_start: &'static str,
+ expected_contains: Option<&'static str>,
+ body: &str,
+ ) -> (String, thread::JoinHandle<()>) {
+ let listener = TcpListener::bind("127.0.0.1:0").expect("bind");
+ let addr = listener.local_addr().expect("addr");
+ let body = body.to_owned();
+ let handle = thread::spawn(move || {
+ let (mut stream, _) = listener.accept().expect("accept");
+ let mut request = vec![0; 8192];
+ let read = stream.read(&mut request).expect("read");
+ let request = String::from_utf8_lossy(&request[..read]);
+ assert!(request.starts_with(expected_start), "{request}");
+ if let Some(expected) = expected_contains {
+ assert!(request.contains(expected), "{request}");
+ }
+ let response = format!(
+ "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\n\r\n{}",
+ body.len(),
+ body
+ );
+ stream.write_all(response.as_bytes()).expect("write");
+ });
+ (format!("http://{addr}"), handle)
+ }
+
+ fn sample_session_bundle_json(access_token: &str, refresh_token: &str) -> String {
+ format!(
+ r#"{{"account":{},"profile":{},"credential":{},"session":{},"access_token":"{}","refresh_token":"{}"}}"#,
+ sample_account_json(),
+ sample_profile_json(),
+ sample_credential_json(),
+ sample_session_body_json(),
+ access_token,
+ refresh_token
+ )
+ }
+
+ fn sample_session_json() -> String {
+ format!(
+ r#"{{"account":{},"profile":{},"credential":{},"session":{}}}"#,
+ sample_account_json(),
+ sample_profile_json(),
+ sample_credential_json(),
+ sample_session_body_json()
+ )
+ }
+
+ fn sample_account_json() -> &'static str {
+ r#"{"id":"account-1","username":"field","display_name":"Field Operator","status":"active"}"#
+ }
+
+ fn sample_profile_json() -> &'static str {
+ r#"{"id":"profile-1","account_id":"account-1","display_name":"Field Operator","email":"field@radroots.test","status":"active"}"#
+ }
+
+ fn sample_credential_json() -> &'static str {
+ r#"{"id":"credential-1","account_id":"account-1","profile_id":"profile-1","email":"field@radroots.test","status":"active","is_primary":true}"#
+ }
+
+ fn sample_session_body_json() -> &'static str {
+ r#"{"id":"session-row-1","account_id":"account-1","profile_id":"profile-1","credential_id":"credential-1","session_id":"session-public-id","status":"active","expires_at_unix_seconds":1893456000,"revoked_at_unix_seconds":null}"#
+ }
+}
diff --git a/crates/field_core/src/runtime/mod.rs b/crates/field_core/src/runtime/mod.rs
@@ -1,4 +1,5 @@
pub mod app_info;
+pub mod auth_session;
pub mod builder;
pub mod info;
pub mod key_management;
@@ -19,6 +20,7 @@ use tokio::sync::broadcast::Receiver;
use self::{
app_info::AppInfoPlatform,
+ auth_session::{FieldAuthConfig, FieldSessionState},
info::{RuntimeInfo, gather_runtime_info},
};
use crate::RadrootsAppError;
@@ -29,6 +31,8 @@ pub struct RadrootsRuntime {
pub(crate) started_unix_ms: i64,
pub(crate) shutting_down: AtomicBool,
pub(crate) platform_app: RwLock<Option<AppInfoPlatform>>,
+ pub(crate) auth_config: RwLock<FieldAuthConfig>,
+ pub(crate) session: RwLock<FieldSessionState>,
#[cfg(feature = "nostr-client")]
pub(crate) post_events_rx: Mutex<
Option<
@@ -63,6 +67,8 @@ impl RadrootsRuntime {
started_unix_ms: Utc::now().timestamp_millis(),
shutting_down: AtomicBool::new(false),
platform_app: RwLock::new(None),
+ auth_config: RwLock::new(FieldAuthConfig::default()),
+ session: RwLock::new(FieldSessionState::default()),
#[cfg(feature = "nostr-client")]
post_events_rx: Mutex::new(None),
})
diff --git a/crates/field_core/src/runtime/nostr.rs b/crates/field_core/src/runtime/nostr.rs
@@ -3,7 +3,7 @@ use crate::RadrootsAppError;
#[cfg(feature = "nostr-client")]
use tokio::sync::broadcast::error::TryRecvError;
-#[derive(uniffi::Enum, Debug, Clone, Copy)]
+#[derive(uniffi::Enum, Debug, Clone, Copy, PartialEq, Eq)]
pub enum NostrLight {
Red,
Yellow,