gsm-core 0.4.13

Core types and platform abstractions for the Greentic messaging runtime.
Documentation
use greentic_secrets::spec::{
    Scope, SecretUri, SecretVersion, SecretsBackend, VersionedSecret, helpers::record_from_plain,
};
use gsm_core::platforms::webchat::{
    config::Config,
    standalone::{StandaloneState, router},
};
use http::{HeaderName, HeaderValue, Method};
use std::{fs::OpenOptions, sync::Arc, time::Duration};
use tower_http::cors::{Any, CorsLayer};
use tracing_appender::non_blocking::WorkerGuard;
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};

#[tokio::main]
async fn main() -> anyhow::Result<()> {
    let _guard = init_logging()?;
    let config = Config::default();
    let signing_secret = match std::env::var("WEBCHAT_JWT_SIGNING_KEY") {
        Ok(value) => {
            tracing::info!("WEBCHAT_JWT_SIGNING_KEY detected");
            value
        }
        Err(_) => {
            tracing::warn!("WEBCHAT_JWT_SIGNING_KEY not set; using local-dev-secret");
            "local-dev-secret".into()
        }
    };
    let secrets = Arc::new(StaticSecretsBackend::new(signing_secret));
    let signing_scope = Scope::new("global", "webchat", None)?;
    let provider = gsm_core::platforms::webchat::WebChatProvider::new(config, secrets)
        .with_signing_scope(signing_scope);
    let state = Arc::new(StandaloneState::new(provider).await?);

    let listener = tokio::net::TcpListener::bind("0.0.0.0:8090").await?;
    let (cors_layer, cors_summary) = cors_layer_from_env();
    tracing::info!(
        "CORS configuration loaded: origins={:?} headers={:?} methods={:?} max_age={}s",
        cors_summary.allowed_origins,
        cors_summary.allowed_headers,
        cors_summary.allowed_methods,
        cors_summary.max_age
    );
    let app = router(Arc::clone(&state)).layer(cors_layer);
    axum::serve(listener, app.into_make_service()).await?;
    Ok(())
}

#[derive(Debug)]
struct CorsConfigSummary {
    allowed_origins: Vec<String>,
    allowed_headers: Vec<String>,
    allowed_methods: Vec<String>,
    max_age: u64,
}

fn cors_layer_from_env() -> (CorsLayer, CorsConfigSummary) {
    let origin_env = std::env::var("WEBCHAT_CORS_ALLOWED")
        .or_else(|_| std::env::var("CORS_ALLOWED_ORIGINS"))
        .unwrap_or_else(|_| "http://localhost:5174".into());
    let (origin_strings, origin_values) =
        split_and_parse(&origin_env, |value| value.parse::<HeaderValue>().ok());

    let header_env = std::env::var("CORS_ALLOWED_HEADERS")
        .unwrap_or_else(|_| "Authorization,Content-Type,x-ms-bot-agent,x-requested-with".into());
    let (header_strings, headers) =
        split_and_parse(&header_env, |value| value.parse::<HeaderName>().ok());

    let method_env =
        std::env::var("CORS_ALLOWED_METHODS").unwrap_or_else(|_| "GET,POST,OPTIONS".into());
    let (method_strings, mut methods) = split_and_parse(&method_env, |value| {
        Method::from_bytes(value.as_bytes()).ok()
    });

    let max_age = std::env::var("CORS_MAX_AGE")
        .ok()
        .and_then(|val| val.parse::<u64>().ok())
        .unwrap_or(600);

    if methods.is_empty() {
        methods = vec![Method::GET, Method::POST, Method::OPTIONS];
    }

    let mut layer = CorsLayer::new()
        .allow_headers(headers)
        .allow_methods(methods.clone())
        .max_age(Duration::from_secs(max_age));

    layer = if origin_values.is_empty() {
        layer.allow_origin(Any)
    } else {
        layer.allow_origin(origin_values)
    };

    let summary = CorsConfigSummary {
        allowed_origins: if origin_strings.is_empty() {
            vec!["* (any)".into()]
        } else {
            origin_strings
        },
        allowed_headers: header_strings,
        allowed_methods: if method_strings.is_empty() {
            vec!["GET".into(), "POST".into(), "OPTIONS".into()]
        } else {
            method_strings
        },
        max_age,
    };

    (layer, summary)
}

fn split_and_parse<T, F>(input: &str, mut parse: F) -> (Vec<String>, Vec<T>)
where
    F: FnMut(&str) -> Option<T>,
{
    let mut raw_values = Vec::new();
    let mut parsed_values = Vec::new();
    for segment in input.split(',') {
        let trimmed = segment.trim();
        if trimmed.is_empty() {
            continue;
        }
        raw_values.push(trimmed.to_string());
        if let Some(value) = parse(trimmed) {
            parsed_values.push(value);
        }
    }
    (raw_values, parsed_values)
}

fn init_logging() -> anyhow::Result<WorkerGuard> {
    let log_path =
        std::env::var("WEBCHAT_LOG_FILE").unwrap_or_else(|_| "webchat-standalone.log".into());
    let file = OpenOptions::new()
        .create(true)
        .append(true)
        .open(&log_path)?;
    let (writer, guard) = tracing_appender::non_blocking(file);

    let filter =
        tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into());
    tracing_subscriber::registry()
        .with(filter)
        .with(
            tracing_subscriber::fmt::layer()
                .with_writer(writer)
                .with_ansi(false),
        )
        .init();

    tracing::info!("webchat standalone logging to {}", log_path);
    Ok(guard)
}

#[derive(Clone)]
struct StaticSecretsBackend {
    secret: String,
}

impl StaticSecretsBackend {
    fn new(secret: String) -> Self {
        Self { secret }
    }
}

impl SecretsBackend for StaticSecretsBackend {
    fn put(
        &self,
        _record: greentic_secrets::spec::SecretRecord,
    ) -> greentic_secrets::spec::Result<greentic_secrets::spec::SecretVersion> {
        unimplemented!("static backend does not support write operations")
    }

    fn get(
        &self,
        uri: &SecretUri,
        _version: Option<u64>,
    ) -> greentic_secrets::spec::Result<Option<VersionedSecret>> {
        if uri.category() == "webchat" && uri.name() == "jwt_signing_key" {
            let record = record_from_plain(self.secret.clone());
            Ok(Some(VersionedSecret {
                version: 1,
                deleted: false,
                record: Some(record),
            }))
        } else {
            Ok(None)
        }
    }

    fn list(
        &self,
        _scope: &Scope,
        _category_prefix: Option<&str>,
        _name_prefix: Option<&str>,
    ) -> greentic_secrets::spec::Result<Vec<greentic_secrets::spec::SecretListItem>> {
        unimplemented!("static backend does not support list operations")
    }

    fn delete(&self, _uri: &SecretUri) -> greentic_secrets::spec::Result<SecretVersion> {
        unimplemented!("static backend does not support delete operations")
    }

    fn versions(&self, _uri: &SecretUri) -> greentic_secrets::spec::Result<Vec<SecretVersion>> {
        unimplemented!("static backend does not support version operations")
    }

    fn exists(&self, uri: &SecretUri) -> greentic_secrets::spec::Result<bool> {
        Ok(uri.category() == "webchat" && uri.name() == "jwt_signing_key")
    }
}