liminal-server 0.25.0

Standalone server for the liminal messaging bus
Documentation
//! SRV-038: whether the network has a root in its Lys, asked when the grants
//! are complete and said out loud.
//!
//! A server started against a Lys whose grant book holds nothing on its
//! network starts ready with no word: the stream is complete, merely empty,
//! and every grant-bound request is then refused. The question asked here is
//! Lys's held (`POST /api/grants/held`, DIRECTORY-108; SRV-040): may any
//! identity take `admin` on `liminal.network:<network_id>`, judged from the
//! whole book and naming no one. It is asked when the
//! grant stream first reaches ready and at every reset it applies, never on a
//! timer and never per request.
//!
//! An absent root is one error naming the network and the Lys; a question Lys
//! refused or answered unreadably is one warning with a stable cause and no
//! response words; a held root is silent. `/health` carries the latest answer
//! as `lys_root`.

use std::sync::{Arc, Mutex};

/// Where the root question stands.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum LysRoot {
    /// The grant stream has not yet been ready, so nothing was asked.
    NotAsked,
    /// One identity or more may take `admin` on the network.
    Held,
    /// No identity may: every grant-bound request will be refused.
    Absent,
    /// Lys refused the question or its answer could not be read.
    Unknown {
        /// The stable cause, never Lys's words.
        cause: &'static str,
    },
}

impl LysRoot {
    /// The stable cause of an unknown answer.
    #[must_use]
    pub const fn cause(&self) -> Option<&'static str> {
        match self {
            Self::Unknown { cause } => Some(cause),
            Self::NotAsked | Self::Held | Self::Absent => None,
        }
    }

    /// The value `/health` reports for this answer.
    #[must_use]
    pub const fn health(&self) -> &'static str {
        match self {
            Self::NotAsked => "not_asked",
            Self::Held => "held",
            Self::Absent => "absent",
            Self::Unknown { .. } => "unknown",
        }
    }
}

/// The root question for one network, and its latest answer.
pub struct RootPresence {
    network_id: String,
    api: reqwest::Url,
    credential: zeroize::Zeroizing<String>,
    client: reqwest::Client,
    handle: tokio::runtime::Handle,
    answer: Mutex<LysRoot>,
}

impl std::fmt::Debug for RootPresence {
    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        formatter
            .debug_struct("RootPresence")
            .field("network_id", &self.network_id)
            .field("answer", &self.answer())
            .finish_non_exhaustive()
    }
}

/// The held answer: whether anyone may take the action. Its revision is not read.
#[derive(serde::Deserialize)]
struct HeldAnswer {
    held: bool,
}

impl RootPresence {
    /// The question for `network_id`, asked of the Lys API at `api_url` with
    /// this liminal's app `credential`, on `handle`'s runtime.
    ///
    /// # Errors
    /// `api_url` is not a URL, or the HTTP client cannot be built.
    pub fn new(
        network_id: &str,
        api_url: &str,
        credential: String,
        handle: tokio::runtime::Handle,
    ) -> Result<Self, lys_pass::Error> {
        let mut api: reqwest::Url = api_url
            .parse()
            .map_err(|_| lys_pass::Error::Invalid("the Lys API base is not a URL"))?;
        if !api.path().ends_with('/') {
            api.set_path(&format!("{}/", api.path()));
        }
        let client = reqwest::Client::builder()
            .no_proxy()
            .redirect(reqwest::redirect::Policy::none())
            .build()
            .map_err(|error| lys_pass::Error::Transport(Box::new(error)))?;
        Ok(Self {
            network_id: network_id.to_owned(),
            api,
            credential: zeroize::Zeroizing::new(credential),
            client,
            handle,
            answer: Mutex::new(LysRoot::NotAsked),
        })
    }

    /// The latest answer, or `lys_root_state_poisoned` when a panic left the
    /// answer's lock poisoned: the stored answer is then not trusted.
    #[must_use]
    pub fn answer(&self) -> LysRoot {
        self.answer.lock().map_or(
            LysRoot::Unknown {
                cause: "lys_root_state_poisoned",
            },
            |answer| answer.clone(),
        )
    }

    /// Asks once from a thread outside the runtime, records the answer and
    /// says it: an error when absent, a warning when unknown, nothing when held.
    pub fn ask(&self) {
        let handle = &self.handle;
        let answer =
            std::thread::scope(|scope| scope.spawn(|| handle.block_on(self.question())).join())
                .unwrap_or(LysRoot::Unknown {
                    cause: "lys_root_ask_panicked",
                });
        self.record(answer);
    }

    /// Asks once on the question's own runtime without waiting for the answer:
    /// SRV-039's ask after the listener binds, so start never waits on Lys.
    pub fn ask_in_background(self: &Arc<Self>) {
        let root = Arc::clone(self);
        drop(self.handle.spawn(async move { root.ask_now().await }));
    }

    /// [`Self::ask`] from inside the runtime: the grant stream's reader asks
    /// here when a new ready fence opens.
    pub async fn ask_now(&self) {
        let answer = self.question().await;
        self.record(answer);
    }

    /// Stores an answer without saying it, for the health tests.
    #[cfg(test)]
    pub(crate) fn record_for_tests(&self, answer: LysRoot) {
        self.store(answer);
    }

    fn record(&self, answer: LysRoot) {
        match &answer {
            LysRoot::Absent => tracing::error!(
                target: "liminal::lys_root",
                network = %self.network_id,
                lys = %self.api,
                "the network has no root in its Lys: no identity may administer \
                 liminal.network:{}, so every grant-bound request is refused until a root \
                 is given there",
                self.network_id
            ),
            LysRoot::Unknown { cause } => tracing::warn!(
                target: "liminal::lys_root",
                network = %self.network_id,
                lys = %self.api,
                cause,
                "whether the network has a root in its Lys could not be read"
            ),
            LysRoot::Held | LysRoot::NotAsked => {}
        }
        self.store(answer);
    }

    /// Keeps the answer; a poisoned lock keeps nothing, and [`Self::answer`]
    /// then names the poisoning rather than an answer it cannot trust.
    fn store(&self, answer: LysRoot) {
        if let Ok(mut held) = self.answer.lock() {
            *held = answer;
        }
    }

    async fn question(&self) -> LysRoot {
        let Ok(url) = self.api.join("grants/held") else {
            return LysRoot::Unknown {
                cause: "lys_root_route_invalid",
            };
        };
        let body = serde_json::json!({
            "route": "api",
            "resource": { "kind": "liminal.network", "id": self.network_id },
            "action": "admin",
        });
        let page = async {
            let response = self
                .client
                .post(url)
                .bearer_auth(self.credential.as_str())
                .json(&body)
                .send()
                .await
                .map_err(|_| "lys_root_unreachable")?;
            // A Lys without the held route (DIRECTORY-108) cannot say; `who`
            // is never asked instead, since it lists only what the asker sees
            // and its empty page cannot mean that no one holds the root. Such
            // a Lys answers 404, or 405 where its `/grants/{id}` path matches.
            if matches!(
                response.status(),
                reqwest::StatusCode::NOT_FOUND | reqwest::StatusCode::METHOD_NOT_ALLOWED
            ) {
                return Err("lys_root_route_absent");
            }
            if !response.status().is_success() {
                return Err("lys_root_refused");
            }
            response
                .json::<HeldAnswer>()
                .await
                .map_err(|_| "lys_root_unreadable")
        };
        match page.await {
            Ok(HeldAnswer { held: true }) => LysRoot::Held,
            Ok(HeldAnswer { held: false }) => LysRoot::Absent,
            Err(cause) => LysRoot::Unknown { cause },
        }
    }
}