liminal-server 0.25.4

Standalone server for the liminal messaging bus
Documentation
//! Prepared calls borrow the bearer inside its admitted authentication owner.

use std::sync::Arc;

use super::live::{HttpLysAsk, PreparedOwner};
use super::lys_pass_adapter::LysPass;
use crate::agents::waiting::durable::ApprovalAgents;
use crate::services::lifecycle::{Lifecycle, site};

/// Server-selected owners and immutable request data; carries no credential.
pub struct SiteRequest<'a> {
    pub lifecycle: &'a Arc<Lifecycle>,
    pub owners: &'a Arc<ApprovalAgents>,
    pub config: &'a crate::config::types::LysConfig,
    pub handle: &'a tokio::runtime::Handle,
    pub operation: &'a str,
    pub target: &'a str,
    pub change: site::SiteChange,
    pub at_ms: u64,
}

/// A permission decision records exact words; it executes no network command.
pub struct PermissionRequest<'a> {
    pub lifecycle: &'a Arc<Lifecycle>,
    pub owners: &'a Arc<ApprovalAgents>,
    pub config: &'a crate::config::types::LysConfig,
    pub handle: &'a tokio::runtime::Handle,
    pub operation: &'a str,
    pub words: &'a str,
    pub at_ms: u64,
}

pub(crate) fn validate_permission_words(words: &str) -> Result<(), lys_pass::Error> {
    #[derive(serde::Deserialize)]
    #[serde(deny_unknown_fields)]
    struct Words {
        title: String,
        options: Vec<OptionWord>,
    }
    #[derive(serde::Deserialize)]
    #[serde(deny_unknown_fields, rename_all = "camelCase")]
    struct OptionWord {
        option_id: String,
        name: String,
        kind: String,
    }
    let value: Words =
        serde_json::from_str(words).map_err(|_| lys_pass::Error::Invalid("permission words"))?;
    let mut ids = std::collections::BTreeSet::new();
    if value.title.trim().is_empty()
        || value.options.is_empty()
        || value.options.iter().any(|option| {
            option.option_id.is_empty()
                || option.name.is_empty()
                || !ids.insert(&option.option_id)
                || (option.kind.is_empty() || option.kind.chars().any(char::is_control))
        })
    {
        return Err(lys_pass::Error::Invalid("permission options"));
    }
    Ok(())
}

impl PermissionRequest<'_> {
    pub(super) fn execute(self, pass: &LysPass) -> Result<serde_json::Value, lys_pass::Error> {
        validate_permission_words(self.words)?;
        let limits = self
            .config
            .drafts
            .ok_or(lys_pass::Error::CannotAsk("draft limits missing"))?;
        let keeper = Arc::new(PreparedOwner::new(
            self.owners.clone(),
            self.lifecycle.clone(),
            pass.shared_verified(),
            limits.pull,
            limits.waiting,
        ));
        let mut live = HttpLysAsk::new(
            self.config
                .api_url
                .as_deref()
                .unwrap_or(&self.config.issuer),
            self.handle.clone(),
        )?
        .with_drafts(keeper);
        if let Some(path) = &self.config.app_credential_path {
            let credential = std::fs::read_to_string(path)
                .map_err(|_| lys_pass::Error::CannotAsk("draft connector credential unreadable"))?;
            live = live.asking_as(credential.trim().to_owned());
        }
        let target = lys_pass::Target::new("liminal.agent", pass.holder(), "agent_permission")?;
        let answer = live.ask_prepared(
            pass.raw_token(),
            &super::live::PreparedAct {
                holder: pass.holder(),
                target: &target,
                operation: self.operation,
                words: self.words,
                at_ms: self.at_ms,
            },
            limits.pull,
        )?;
        match answer.decision {
            super::lys_pass_adapter::LiveAnswer::Held { draft, .. } => {
                Ok(serde_json::json!({"state":"held","draft":draft,"words":self.words}))
            }
            _ => Err(lys_pass::Error::CannotAsk(
                "permission requires a held draft",
            )),
        }
    }
}

impl SiteRequest<'_> {
    pub(super) fn execute(self, pass: &LysPass) -> Result<serde_json::Value, lys_pass::Error> {
        let limits = self
            .config
            .drafts
            .ok_or(lys_pass::Error::CannotAsk("draft limits missing"))?;
        let keeper = Arc::new(PreparedOwner::new(
            self.owners.clone(),
            self.lifecycle.clone(),
            pass.shared_verified(),
            limits.pull,
            limits.waiting,
        ));
        let mut live = HttpLysAsk::new(
            self.config
                .api_url
                .as_deref()
                .unwrap_or(&self.config.issuer),
            self.handle.clone(),
        )?
        .with_drafts(keeper);
        if let Some(path) = &self.config.app_credential_path {
            let credential = std::fs::read_to_string(path)
                .map_err(|_| lys_pass::Error::CannotAsk("draft connector credential unreadable"))?;
            live = live.asking_as(credential.trim().to_owned());
        }
        site::preparation::hold_verified(
            site::preparation::VerifiedContext {
                lifecycle: self.lifecycle,
                pass: pass.verified(),
                token: pass.raw_token(),
                live: &live,
                at_ms: self.at_ms,
                limits: limits.pull,
            },
            self.operation,
            self.target,
            self.change,
        )
        .map_err(|error| {
            tracing::warn!(%error, "the construction act was not held");
            lys_pass::Error::CannotAsk("prepared construction refused")
        })
    }
}

/// The site adapter forwards the admitted session; it cannot read its bearer.
pub(crate) struct SiteHolder {
    pub lifecycle: Arc<Lifecycle>,
    pub owners: Arc<ApprovalAgents>,
    pub config: crate::config::types::LysConfig,
    pub handle: tokio::runtime::Handle,
}

impl std::fmt::Debug for SiteHolder {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("SiteHolder").finish_non_exhaustive()
    }
}

impl crate::server::connection::services::SiteDraftHolder for SiteHolder {
    fn hold(
        &self,
        lifecycle: &Lifecycle,
        session: &dyn super::session::LysSession,
        operation: &str,
        target: &str,
        change: site::SiteChange,
        at_ms: u64,
    ) -> Result<serde_json::Value, site::SiteError> {
        if !std::ptr::eq(lifecycle, self.lifecycle.as_ref()) {
            return Err(site::SiteError::Invalid("construction owner mismatch"));
        }
        session
            .hold_site(SiteRequest {
                lifecycle: &self.lifecycle,
                owners: &self.owners,
                config: &self.config,
                handle: &self.handle,
                operation,
                target,
                change,
                at_ms,
            })
            .map_err(|error| {
                // The caller hears the stable name; the operator's log names what failed.
                tracing::warn!(target = %target, %error, "a construction draft could not be held");
                site::SiteError::Pass(error.name().into())
            })
    }
}