liminal-server 0.25.4

Standalone server for the liminal messaging bus
Documentation
//! The service lifecycle route (SRV-028 R3): a `ConversationMessage` on a
//! conversation open on `services.list`, `services.create`,
//! `services.deploy`, `services.hold`, `services.wake` or `services.retire`
//! is a lifecycle request, decided on the connection's admitted Lys session
//! and answered on the same conversation.
//!
//! The payload is the service route's shape, an operation id and a body:
//!
//! ```json
//! {"operation": "op-7f3a", "payload": {"template": "echo", "name": "one"}}
//! ```
//!
//! `create` takes `template` and `name`; the other writing verbs take `id`;
//! `list` takes nothing. Creating is asked of Lys live as `deploy_service`
//! on the network; deploying, holding, waking and retiring as
//! `deploy_service` on that `liminal.service`. A held answer acts on
//! nothing and says which draft holds it. `list` answers the templates and
//! the services the holder created or may call a subject of, decided from
//! the pass offline. A refusal is the conversation's error frame, by name.

use liminal::protocol::{CONVERSATION_REPLY_REQUESTED_FLAG, Frame, MessageEnvelope};

use super::service_route::{SERVICE_REFUSED_CODE, parse_request};
use super::state::{ConnectionProcessState, FrameAction};
use super::supervisor::runtime::ConnectionRuntime;
use crate::auth_lys::session::LysSession;
use crate::auth_lys::{
    AccessAction, AccessResource, DeliberateDecision, HotAction, ResourceKind, SubjectBinding,
};
use crate::services::lifecycle::{Lifecycle, LifecycleError, ServiceRecord};

/// The subject prefix every lifecycle verb is under.
pub(super) const PREFIX: &str = "services.";

/// Whether `subject` is a lifecycle verb's.
pub(super) fn is_lifecycle(subject: &str) -> bool {
    subject
        .strip_prefix(PREFIX)
        .is_some_and(|verb| VERBS.contains(&verb))
}

const VERBS: [&str; 6] = ["list", "create", "deploy", "hold", "wake", "retire"];

/// Serves the lifecycle request `envelope` carries on `subject`.
pub(super) fn lifecycle_frame_response(
    runtime: &ConnectionRuntime,
    state: &ConnectionProcessState,
    flags: u8,
    stream_id: u32,
    conversation_id: u64,
    subject: &str,
    envelope: &MessageEnvelope,
) -> FrameAction {
    let refused = |message: String| {
        FrameAction::Respond(Frame::ConversationError {
            flags: 0,
            stream_id,
            conversation_id,
            reason_code: SERVICE_REFUSED_CODE,
            message: Some(message),
        })
    };
    let answer = match serve(runtime, state, subject, &envelope.payload) {
        Ok(answer) => answer,
        Err(message) => return refused(message),
    };
    if flags & CONVERSATION_REPLY_REQUESTED_FLAG == 0 {
        return FrameAction::NoResponse;
    }
    FrameAction::Respond(Frame::ConversationMessage {
        flags: CONVERSATION_REPLY_REQUESTED_FLAG,
        stream_id,
        conversation_id,
        envelope: MessageEnvelope::new(
            envelope.schema_id,
            envelope.causal_context.clone(),
            answer.to_string().into_bytes(),
        ),
    })
}

fn serve(
    runtime: &ConnectionRuntime,
    state: &ConnectionProcessState,
    subject: &str,
    payload: &[u8],
) -> Result<serde_json::Value, String> {
    let verb = subject.strip_prefix(PREFIX).unwrap_or_default();
    let lifecycle = runtime.lifecycle().ok_or_else(|| {
        "lifecycle_unavailable: no [[auth.lys.templates]] are configured".to_owned()
    })?;
    let session = state
        .lys_session
        .as_deref()
        .ok_or_else(|| "lifecycle_needs_a_pass: connect with a Lys pass".to_owned())?;
    let (_, network) = runtime
        .lys_admitter()
        .ok_or_else(|| "no lys admitter is installed".to_owned())?;
    let (operation, body) = parse_request(payload)?;
    let body: serde_json::Value = if body.is_empty() {
        serde_json::Value::Null
    } else {
        serde_json::from_slice(&body).map_err(|error| format!("payload is not JSON: {error}"))?
    };
    let now = super::apply::unix_seconds_now();
    let now_ms = now.saturating_mul(1000);
    let holder = session.holder().to_owned();
    if verb == "list" {
        return lifecycle
            .list(|record| visible(session, network, &holder, record, now))
            .map_err(|error| error.to_string());
    }
    let field = |name: &str| {
        body.get(name)
            .and_then(serde_json::Value::as_str)
            .map(str::to_owned)
            .ok_or_else(|| format!("payload names no {name}"))
    };
    let (resource, act): (AccessResource, Act) = if verb == "create" {
        let template = field("template")?;
        let name = field("name")?;
        (
            AccessResource {
                kind: ResourceKind::Network,
                id: network.to_owned(),
            },
            Box::new(move |lifecycle| {
                lifecycle.create(&template, &name, &holder, &operation, now_ms)
            }),
        )
    } else {
        let id = field("id")?;
        let verb = verb.to_owned();
        (
            AccessResource {
                kind: ResourceKind::Service,
                id: id.clone(),
            },
            Box::new(move |lifecycle| match verb.as_str() {
                "deploy" => lifecycle.deploy(&id, &holder),
                "wake" => lifecycle.wake(&id, &holder),
                "hold" => lifecycle.hold(&id, &holder, now_ms),
                _ => lifecycle.retire(&id, &holder, now_ms),
            }),
        )
    };
    let decision = session
        .decide_live(&resource, AccessAction::DeployService, now)
        .map_err(|error| {
            serde_json::to_string(&error.refusal).unwrap_or_else(|_| error.cause.to_string())
        })?;
    let grant = match decision {
        DeliberateDecision::Outright(receipt) => receipt.grant,
        DeliberateDecision::ByDraft { grant, draft } => {
            return Ok(
                serde_json::json!({"outcome": "held", "mode": "by_draft", "grant": grant, "draft": draft}),
            );
        }
        DeliberateDecision::ByTwo { grant, draft } => {
            return Ok(
                serde_json::json!({"outcome": "held", "mode": "by_two", "grant": grant, "draft": draft}),
            );
        }
    };
    let record = act(lifecycle).map_err(|error| error.to_string())?;
    Ok(serde_json::json!({"outcome": "acted", "grant": grant, "service": record}))
}

/// One lifecycle effect, taken once the decision allows it.
type Act = Box<dyn FnOnce(&std::sync::Arc<Lifecycle>) -> Result<ServiceRecord, LifecycleError>>;

/// Whether `holder` sees `record` in a list: it created it, or its pass may
/// call one of its subjects.
fn visible(
    session: &dyn LysSession,
    network: &str,
    holder: &str,
    record: &ServiceRecord,
    now: u64,
) -> bool {
    record.created_by == holder
        || record.subjects.iter().any(|subject| {
            SubjectBinding::new(network.to_owned(), subject.clone(), subject.clone())
                .is_ok_and(|binding| session.decide(&binding, HotAction::Call, now).is_ok())
        })
}