app

Local-first trade for farms and co-ops
git clone https://radroots.dev/git/app.git
Log | Files | Refs | README | LICENSE

commit 4aaee761afcf03c633ba7a6a071c1126bf4b1b62
parent ebe68af3e052d3e42bbb264a464275b75d44bee1
Author: triesap <tyson@radroots.org>
Date:   Mon,  3 Aug 2026 22:11:06 +0000

runtime: bind foreground signer session

- require matching canonical identity and available local binding
- associate every active foreground session with its generation
- publish the actor-owned binding and clear it on sign-out or close
- cover mismatch, unavailable signer, activation, and teardown paths

Diffstat:
Mcore/crates/application/src/actor.rs | 102+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Mcore/crates/application/src/lib.rs | 4++--
Mcore/crates/storage/src/runtime_actor.rs | 65++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----
3 files changed, 162 insertions(+), 9 deletions(-)

diff --git a/core/crates/application/src/actor.rs b/core/crates/application/src/actor.rs @@ -1,7 +1,10 @@ use std::num::{NonZeroU64, NonZeroUsize}; use std::time::Instant; -use radroots_studio_domain::{PublicKey, SafeError}; +use radroots_studio_domain::{ + AccountIdentity, BindingAvailability, LocalSignerBinding, PublicKey, SafeError, SafeErrorCode, + SafeMessage, +}; use tokio::sync::{mpsc, oneshot}; use crate::SnapshotRevision; @@ -34,6 +37,60 @@ impl SessionGeneration { } } +#[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, @@ -507,9 +564,14 @@ mod tests { use std::num::NonZeroUsize; use std::time::{Duration, Instant}; + use radroots_studio_domain::{ + AccountIdentity, BindingAvailability, LocalSignerBinding, PublicKey, + }; + use crate::{ ActorMailbox, CommandContext, CommandReceipt, CommandRejection, CommandResult, - CommandSubmission, LifecycleGate, RequestId, RuntimeCommandClass, RuntimeLifecycle, + CommandSubmission, ForegroundSessionBinding, LifecycleGate, RequestId, RuntimeCommandClass, + RuntimeLifecycle, SessionGeneration, }; fn context(id: u64) -> CommandContext { @@ -520,6 +582,42 @@ mod tests { ) } + #[test] + fn foreground_session_requires_matching_available_binding_and_generation() { + let public_key = PublicKey::from_bytes([3_u8; 32]); + 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); + + assert!( + ForegroundSessionBinding::new( + identity.clone(), + LocalSignerBinding::new( + PublicKey::from_bytes([4_u8; 32]), + 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) = diff --git a/core/crates/application/src/lib.rs b/core/crates/application/src/lib.rs @@ -20,8 +20,8 @@ pub use accounts::{ }; pub use actor::{ ActorMailbox, CommandContext, CommandEnvelope, CommandReceipt, CommandRejection, CommandResult, - CommandSubmission, CommandTicket, LifecycleGate, RequestId, RuntimeCommandClass, - RuntimeLifecycle, SessionGeneration, TaskCorrelation, + CommandSubmission, CommandTicket, ForegroundSessionBinding, LifecycleGate, RequestId, + RuntimeCommandClass, RuntimeLifecycle, SessionGeneration, TaskCorrelation, }; pub use app_core::{AppCore, RemovalConfirmationToken}; pub use change_stream::{ diff --git a/core/crates/storage/src/runtime_actor.rs b/core/crates/storage/src/runtime_actor.rs @@ -7,13 +7,15 @@ use std::time::{Duration, Instant}; use radroots_studio_application::{ ActorMailbox, AppSnapshot, ChangeSubscriptionId, Clock, CommandContext, CommandEnvelope, - CommandReceipt, CommandResult, CommandSubmission, GenerateAccountReceipt, ImportAccountReceipt, - LifecycleGate, NostrClient, OrderedSnapshotChanges, ProfileRefreshPlan, RelayConfiguration, - RemovalConfirmationToken, RequestId, RuntimeCommandClass, RuntimeLifecycle, SecretStore, - SessionGeneration, SnapshotChange, SnapshotChangeReceiver, SnapshotRevision, TaskCorrelation, + CommandReceipt, CommandResult, CommandSubmission, ForegroundSessionBinding, + GenerateAccountReceipt, ImportAccountReceipt, LifecycleGate, NostrClient, + OrderedSnapshotChanges, ProfileRefreshPlan, RelayConfiguration, RemovalConfirmationToken, + RequestId, RuntimeCommandClass, RuntimeLifecycle, SecretStore, SessionGeneration, + SnapshotChange, SnapshotChangeReceiver, SnapshotRevision, TaskCorrelation, }; use radroots_studio_domain::{ - Kind0ProfileCandidate, PublicKey, SafeError, SafeErrorCode, SafeMessage, SecretKeyInput, + AccountIdentity, BindingAvailability, Kind0ProfileCandidate, LocalSignerBinding, PublicKey, + SafeError, SafeErrorCode, SafeMessage, SecretKeyInput, }; use tokio::runtime::Handle; use tokio::sync::{mpsc, oneshot}; @@ -78,6 +80,7 @@ struct RuntimeActor { published_session_generation: Arc<AtomicU64>, profile_tasks: BTreeMap<RequestId, PendingProfileTask>, changes: OrderedSnapshotChanges, + published_foreground_session: Arc<Mutex<Option<ForegroundSessionBinding>>>, } struct PendingProfileTask { @@ -99,6 +102,7 @@ pub struct RuntimeActorHandle { lifecycle: Arc<Mutex<LifecycleGate>>, next_request: Arc<AtomicU64>, session_generation: Arc<AtomicU64>, + foreground_session: Arc<Mutex<Option<ForegroundSessionBinding>>>, } pub struct RuntimeChangeSubscription { @@ -186,6 +190,7 @@ impl RuntimeActorHandle { 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), @@ -198,6 +203,7 @@ impl RuntimeActorHandle { published_session_generation: Arc::clone(&session_generation), profile_tasks: BTreeMap::new(), changes, + published_foreground_session: Arc::clone(&foreground_session), }; drop(runtime.spawn(actor.run(receiver))); Ok(Self { @@ -206,6 +212,7 @@ impl RuntimeActorHandle { lifecycle, next_request: Arc::new(AtomicU64::new(1)), session_generation, + foreground_session, }) } @@ -223,6 +230,14 @@ impl RuntimeActorHandle { } #[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 snapshot(&self) -> AppSnapshot { self.adapter.core().snapshot() } @@ -539,6 +554,7 @@ impl RuntimeActor { let result = self.execute_sync(command); if changes_session && matches!(result, CommandResult::Completed(_)) { self.advance_session_generation(); + self.synchronize_foreground_session(); } if matches!(result, CommandResult::Completed(_)) { self.changes.publish(self.adapter.core().snapshot()); @@ -695,6 +711,10 @@ impl RuntimeActor { Ok(()) => { self.cancel_profile_tasks(None); 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), @@ -750,6 +770,36 @@ impl RuntimeActor { self.cancel_profile_tasks(Some(&snapshot)); } + 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; + } + fn cancel_profile_tasks(&mut self, snapshot: Option<&AppSnapshot>) { let tasks = std::mem::take(&mut self.profile_tasks); for (_, task) in tasks { @@ -983,10 +1033,15 @@ mod tests { 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