//! Each hive's sender account, `@hive-:`, on the swarm's homeserver: //! created here with the **swarm's** appservice token and stored at //! `swarm/hives//matrix/sender-token`, where hive-c0re's matrix sweep //! reads it under the hive's own store identity. //! //! Runs for every hive in the directory, local or remote, and decides the //! same way [`super::agent_token`] does: a stored token that `whoami` //! confirms as `@hive-:` is kept, so this writes only when the path is //! empty or its token is dead. //! //! This is the only writer of that path: hive-c0re never mints, it takes //! whatever the store holds. use std::sync::Arc; use anyhow::{Context, Result}; use swarm_matrix_client as homeserver; use swarm_secret_client::matrix; use super::agent_token::{Decision, Observed, appservice_token, classify, mint_at, plan, probe_at}; /// How often [`spawn`] re-checks every hive's sender token. const RECONCILE_INTERVAL: std::time::Duration = std::time::Duration::from_mins(5); /// Decide what to do about `hive`'s sender token from what the stored one is. fn classify_hive(hive: &str, probe: &super::agent_token::Probe) -> Decision { classify(&matrix::hive_localpart(hive), probe) } /// Make sure `hive`'s sender account holds a live token in the store, /// creating the account or logging in to it when it does not. The whole job /// of the `MintHiveSenderToken` node. /// /// # Errors /// When the store or the homeserver refuses a step, the swarm appservice /// token has not been published, or the new token authenticates as someone /// else. pub async fn ensure_hive_sender_token(base: &str, hive: &str) -> Result<()> { let store = crate::store::connect() .await .context("logging in to the swarm secret store")?; let as_token = appservice_token(&store).await?; let http = homeserver::client()?; let path = matrix::sender_token_path(hive)?; let reason = match classify_hive(hive, &probe_at(&store, &http, base, &path).await?) { Decision::Keep => { tracing::debug!(hive, "hive sender token is current; left as it is"); return Ok(()); } Decision::Mint(reason) => reason, }; let localpart = matrix::hive_localpart(hive); mint_at(&store, &http, base, &localpart, &as_token, &path).await?; tracing::info!(hive, ?reason, %path, "hive sender token minted and stored"); Ok(()) } /// One pass: every hive in `hives`, observed. /// /// Fails as a whole when the swarm appservice token is not published, for /// the reason `agent_token`'s pass does. async fn observe_all(base: &str, hives: &[String]) -> Result> { let store = crate::store::connect() .await .context("logging in to the swarm secret store")?; appservice_token(&store).await?; let http = homeserver::client()?; let mut observed = Vec::with_capacity(hives.len()); for hive in hives { let o = match matrix::sender_token_path(hive) { Ok(path) => match probe_at(&store, &http, base, &path).await { Ok(p) => Observed::Decided(classify_hive(hive, &p)), Err(e) => { tracing::warn!(hive, error = %format!("{e:#}"), "hive sender token: read failed"); Observed::Unknown } }, Err(e) => { tracing::warn!(hive, error = %e, "hive sender token: the hive name forms no store path"); Observed::Unknown } }; observed.push((hive.clone(), o)); } Ok(observed) } /// Check every hive's sender token now and every [`RECONCILE_INTERVAL`] /// after, and hand the hives that need one to `enqueue`, which inserts a /// `MintHiveSenderToken` node for each. /// /// The roster is the controller's hive directory. A pass that fails is /// logged and retried on the next tick; it never stops the daemon. pub fn spawn(base: Arc, hives: Vec, enqueue: impl Fn(Vec) + Send + 'static) { tokio::spawn(async move { let mut ticker = tokio::time::interval(RECONCILE_INTERVAL); loop { ticker.tick().await; match observe_all(&base, &hives).await { Ok(observed) => { let minting = plan(&observed); if minting.is_empty() { tracing::debug!( checked = observed.len(), "hive sender tokens: all current" ); } else { tracing::info!( checked = observed.len(), minting = minting.len(), "hive sender tokens: queueing mints" ); enqueue(minting); } } Err(e) => tracing::warn!( error = %format!("{e:#}"), retry_in_s = RECONCILE_INTERVAL.as_secs(), "hive sender tokens: pass failed; retrying next tick" ), } } }); } #[cfg(test)] mod tests { use super::super::agent_token::{MintReason, Probe}; use super::*; use swarm_matrix_client::Whoami; fn user(id: &str) -> Probe { Probe::Whoami(Whoami::User(id.to_owned())) } #[test] fn a_live_token_for_the_hive_account_is_kept() { assert_eq!( classify_hive("pr1ma", &user("@hive-pr1ma:t.local")), Decision::Keep ); } #[test] fn nothing_stored_mints() { // A hive whose homeserver is remote: nothing has ever written its path. assert_eq!( classify_hive("pr1ma", &Probe::NotStored), Decision::Mint(MintReason::NotStored) ); } #[test] fn a_dead_token_mints() { // A token the homeserver revoked, or a device a manual login replaced. assert_eq!( classify_hive("pr1ma", &Probe::Whoami(Whoami::UnknownToken)), Decision::Mint(MintReason::Revoked) ); } #[test] fn a_token_for_another_account_mints() { // The bare hive name is an agent's localpart, not the hive's account, // and another hive's account is not this one's. for other in ["@pr1ma:t.local", "@hive-beta:t.local", "@hive:t.local"] { assert_eq!( classify_hive("pr1ma", &user(other)), Decision::Mint(MintReason::OtherUser), "{other}" ); } } #[test] fn an_outage_plans_nothing() { // A mint replaces the token the hive is running on, so a pass that // could not read must not mint for every hive. let observed = [ ("alpha".to_owned(), Observed::Unknown), ("beta".to_owned(), Observed::Unknown), ]; assert!(plan(&observed).is_empty()); } #[test] fn the_published_path_is_the_one_the_hive_reads() { // hive-c0re's `stored_sender_token` resolves this path; the literal is // what the bao grant names. assert_eq!( matrix::sender_token_path("pr1ma").expect("a plain name is legal"), "swarm/hives/pr1ma/matrix/sender-token" ); } }