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:
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