openlatch-client 0.5.8

OpenLatch runtime enforcement node — the capture-and-enforce adapter that evaluates every covered action against a coding agent's Autonomy Zone before it runs
//! Cloud envelope helpers — header construction for cloud API requests.
//!
//! With the CloudEvents v1.0.2 migration the outbound wire format is
//! `application/cloudevents-batch+json` — a bare JSON array of
//! EventEnvelope objects. The `CloudEventPayload` wrapper that used to
//! bundle envelope + raw_event is gone; the raw payload now lives under
//! `envelope.data` and every OpenLatch metadata field is a CloudEvents
//! extension attribute on the envelope itself.

// Re-export generated cloud types for use by the worker.
pub use crate::generated::types::CloudIngestionRequest;

use reqwest::header::{HeaderMap, HeaderValue, AUTHORIZATION, CONTENT_TYPE};

/// CloudEvents structured-mode Content-Type for a batch of events.
pub const CLOUDEVENTS_BATCH_CONTENT_TYPE: &str = "application/cloudevents-batch+json";

/// Request header carrying this install's `agent_id` — one openlatch-client
/// install. Also sent on the bundle poll
/// (`daemon::policy_poller::AGENT_ID_HEADER`, defined against this same
/// constant) and the config-monitor alerts long-poll. Defined here, in the
/// leaf module, so every sender shares one spelling.
pub const AGENT_ID_HEADER: &str = "X-OpenLatch-Agent-Id";

/// Build HTTP request headers for a cloud API POST.
///
/// # Headers set
/// - `Authorization: Bearer <key>` — derived from the secret (only call point for `.expose_secret()`)
/// - `Content-Type: application/cloudevents-batch+json`
/// - `X-Client-Version: <crate version>`
/// - `X-Request-ID: <request_id>` (caller provides a UUIDv7 string)
/// - `X-OpenLatch-Machine-Id: <host_key>` — omitted when `host_key` is `None`
/// - `X-OpenLatch-Agent-Id: <agent_id>` — omitted when `agent_id` is `None`.
///   Sent on both ingest requests, including the empty `[]` renegotiation
///   probe, which carries no envelope and would otherwise be attributed to no
///   install (openlatch-platform#915).
///
/// `host_key` and `agent_id` are explicit parameters rather than
/// process-globals because the values are resolved once by the daemon and
/// handed down through [`CloudConfig`](crate::cloud::CloudConfig): a global
/// would be unsettable per test, and every caller here already holds the
/// config.
///
/// # Security
/// The API key is exposed only here, in the `Authorization` header value.
/// It is NEVER passed to tracing macros or other functions.
pub fn build_cloud_headers(
    api_key: &secrecy::SecretString,
    request_id: &str,
    host_key: Option<&str>,
    agent_id: Option<&str>,
) -> reqwest::header::HeaderMap {
    use secrecy::ExposeSecret;

    let mut headers = HeaderMap::new();

    // Authorization: Bearer <key>
    let auth_value = format!("Bearer {}", api_key.expose_secret());
    if let Ok(val) = HeaderValue::from_str(&auth_value) {
        headers.insert(AUTHORIZATION, val);
    }

    // Content-Type: application/cloudevents-batch+json
    headers.insert(
        CONTENT_TYPE,
        HeaderValue::from_static(CLOUDEVENTS_BATCH_CONTENT_TYPE),
    );

    // X-Client-Version: the build's reported identity (build.rs), which on an
    // unreleased build is not the crate version.
    if let Ok(val) = HeaderValue::from_str(env!("OPENLATCH_VERSION")) {
        headers.insert("X-Client-Version", val);
    }

    // X-Request-ID: <UUIDv7>
    if let Ok(val) = HeaderValue::from_str(request_id) {
        headers.insert("X-Request-ID", val);
    }

    // X-OpenLatch-Machine-Id: which machine this is, for licensing. Omitted
    // when this host has neither a machine identifier nor an agent id — never
    // sent empty, which the platform would read as a host key of its own.
    if let Some(key) = host_key {
        if let Ok(val) = HeaderValue::from_str(key) {
            headers.insert("X-OpenLatch-Machine-Id", val);
        }
    }

    // X-OpenLatch-Agent-Id: this install, for platform attribution. Omitted
    // when `agent_id` is `None` — never sent empty.
    if let Some(id) = agent_id {
        if let Ok(val) = HeaderValue::from_str(id) {
            headers.insert(AGENT_ID_HEADER, val);
        }
    }

    headers
}

// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------

#[cfg(test)]
mod tests {
    use super::*;
    use secrecy::SecretString;

    #[test]
    fn test_build_cloud_headers_sets_authorization_bearer() {
        let key = SecretString::from("test-api-key".to_string());
        let headers = build_cloud_headers(&key, "req-id-123", None, None);

        let auth = headers
            .get("Authorization")
            .expect("Authorization header must be present");
        let auth_str = auth.to_str().unwrap();
        assert!(
            auth_str.starts_with("Bearer "),
            "Authorization must start with 'Bearer ': {auth_str}"
        );
        assert!(
            auth_str.contains("test-api-key"),
            "Authorization must contain the API key"
        );
    }

    #[test]
    fn test_build_cloud_headers_sets_content_type_cloudevents_batch() {
        let key = SecretString::from("key".to_string());
        let headers = build_cloud_headers(&key, "req-id-456", None, None);
        let ct = headers
            .get("Content-Type")
            .expect("Content-Type must be present");
        assert_eq!(ct.to_str().unwrap(), "application/cloudevents-batch+json");
    }

    #[test]
    fn test_build_cloud_headers_sets_x_client_version() {
        let key = SecretString::from("key".to_string());
        let headers = build_cloud_headers(&key, "req-id-789", None, None);
        let ver = headers
            .get("X-Client-Version")
            .expect("X-Client-Version must be present");
        assert!(!ver.to_str().unwrap().is_empty());
    }

    #[test]
    fn test_build_cloud_headers_sets_x_request_id() {
        let key = SecretString::from("key".to_string());
        let request_id = "01234567-89ab-7cde-f012-3456789abcde";
        let headers = build_cloud_headers(&key, request_id, None, None);
        let rid = headers
            .get("X-Request-ID")
            .expect("X-Request-ID must be present");
        assert_eq!(rid.to_str().unwrap(), request_id);
    }

    #[test]
    fn machine_id_header_is_sent_when_a_host_key_exists_and_omitted_otherwise() {
        let key = SecretString::from("key".to_string());
        const HOST: &str = "9262b296baa37a205734509345581251";

        let headers = build_cloud_headers(&key, "req-id-host", Some(HOST), None);
        assert_eq!(
            headers
                .get("X-OpenLatch-Machine-Id")
                .expect("X-OpenLatch-Machine-Id must be present with a host key")
                .to_str()
                .unwrap(),
            HOST
        );

        // No key means no header — not an empty one. The platform reads the
        // header's presence as "this host identified itself".
        let headers = build_cloud_headers(&key, "req-id-none", None, None);
        assert!(headers.get("X-OpenLatch-Machine-Id").is_none());
    }

    #[test]
    fn agent_id_header_is_sent_when_an_agent_id_exists_and_omitted_otherwise() {
        let key = SecretString::from("key".to_string());
        const AGENT: &str = "agt_test_448";

        let headers = build_cloud_headers(&key, "req-id-agent", None, Some(AGENT));
        assert_eq!(
            headers
                .get(AGENT_ID_HEADER)
                .expect("X-OpenLatch-Agent-Id must be present with an agent id")
                .to_str()
                .unwrap(),
            AGENT
        );

        // No agent id (e.g. before `init`) means no header — not an empty one.
        let headers = build_cloud_headers(&key, "req-id-no-agent", None, None);
        assert!(headers.get(AGENT_ID_HEADER).is_none());
    }

    #[test]
    fn test_cloud_ingestion_request_is_transparent_array_of_envelopes() {
        // CloudIngestionRequest is now a transparent newtype over
        // Vec<EventEnvelope>. Its wire form is a bare JSON array.
        let batch = CloudIngestionRequest(Vec::new());
        let json = serde_json::to_value(&batch).unwrap();
        assert!(json.is_array(), "batch must serialise to a JSON array");
        assert_eq!(json.as_array().unwrap().len(), 0);
    }
}