Skip to main content

systemprompt_cloud/
secrets_env.rs

1//! Deploy-time mapping of a profile's `secrets.json` to environment variables.
2//!
3//! [`load_secrets_json`] reads the profile's secrets file,
4//! [`map_secrets_to_env_vars`] translates the well-known keys to their
5//! provider environment-variable names (dropping system-managed keys and
6//! advertising custom ones via `CUSTOM_SECRETS`), and
7//! [`read_signing_key_pem`] base64-encodes the JWT signing key for transport
8//! as the `SIGNING_KEY_PEM` secret.
9//!
10//! Copyright (c) systemprompt.io — Business Source License 1.1.
11//! See <https://systemprompt.io> for licensing details.
12
13use std::collections::HashMap;
14use std::path::Path;
15
16use base64::Engine;
17
18use crate::constants::env_vars;
19use crate::error::{CloudError, CloudResult};
20
21pub fn load_secrets_json(path: &Path) -> CloudResult<HashMap<String, String>> {
22    let content = std::fs::read_to_string(path)
23        .map_err(|e| CloudError::deploy_with(format!("Failed to read {}", path.display()), e))?;
24
25    // JSON: free-form user-authored file; non-string entries are skipped, not
26    // rejected.
27    let json: serde_json::Value = serde_json::from_str(&content)
28        .map_err(|e| CloudError::deploy_with("Failed to parse secrets.json", e))?;
29
30    let mut secrets = HashMap::new();
31
32    if let Some(obj) = json.as_object() {
33        for (key, value) in obj {
34            if let Some(s) = value.as_str()
35                && !s.is_empty()
36            {
37                secrets.insert(key.clone(), s.to_owned());
38            }
39        }
40    }
41
42    Ok(secrets)
43}
44
45#[must_use]
46pub fn map_secrets_to_env_vars<S: std::hash::BuildHasher>(
47    secrets: HashMap<String, String, S>,
48) -> HashMap<String, String> {
49    let has_internal = secrets.contains_key("internal_database_url");
50
51    let mut result: HashMap<String, String> = secrets
52        .into_iter()
53        .filter_map(|(k, v)| {
54            let env_key = to_env_var_name(&k, has_internal)?;
55            if env_vars::is_system_managed(&env_key) {
56                tracing::warn!(key = %env_key, "Skipping system-managed variable from secrets.json");
57                return None;
58            }
59            Some((env_key, v))
60        })
61        .collect();
62
63    let custom_keys: Vec<String> = result
64        .keys()
65        .filter(|k| !is_standard_env_var(k))
66        .cloned()
67        .collect();
68
69    if !custom_keys.is_empty() {
70        result.insert(env_vars::CUSTOM_SECRETS.to_owned(), custom_keys.join(","));
71    }
72
73    result
74}
75
76fn to_env_var_name(key: &str, has_internal_db_url: bool) -> Option<String> {
77    match key {
78        "gemini" => Some("GEMINI_API_KEY".to_owned()),
79        "anthropic" => Some("ANTHROPIC_API_KEY".to_owned()),
80        "openai" => Some("OPENAI_API_KEY".to_owned()),
81        "internal_database_url" => Some("DATABASE_URL".to_owned()),
82        "database_url" if has_internal_db_url => None,
83        _ => Some(key.to_uppercase()),
84    }
85}
86
87fn is_standard_env_var(key: &str) -> bool {
88    matches!(
89        key,
90        "OAUTH_AT_REST_PEPPER"
91            | "DATABASE_URL"
92            | "GEMINI_API_KEY"
93            | "ANTHROPIC_API_KEY"
94            | "OPENAI_API_KEY"
95            | "GITHUB_TOKEN"
96    )
97}
98
99pub fn read_signing_key_pem(path: &Path) -> CloudResult<Option<String>> {
100    if !path.exists() {
101        return Ok(None);
102    }
103    let pem = std::fs::read_to_string(path).map_err(|e| {
104        CloudError::deploy_with(format!("reading signing key {}", path.display()), e)
105    })?;
106    Ok(Some(
107        base64::engine::general_purpose::STANDARD.encode(pem.as_bytes()),
108    ))
109}