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