use aws_lc_rs::encoding::{AsDer, Pkcs8V1Der};
use aws_lc_rs::rsa::{KeyPair, KeySize};
use base64::Engine;
use indexmap::IndexMap;
use super::*;
const ISSUER: &str = "${JWT_ISSUER:?Configure the trusted IAM session issuer}";
const AUDIENCE: &str = "${JWT_AUDIENCE:?Configure the trusted IAM session audience}";
const CORS: &str = "${CORS_ORIGINS:-}";
const SEED_PASSWORD: &str = "IAM_SEED_ADMIN_PASSWORD";
fn compose_path(root: &Path, manifest: &Value) -> PathBuf {
root.join(
manifest
.get("docker_compose_path")
.and_then(Value::as_str)
.unwrap_or("docker-compose.yaml"),
)
}
fn projects(manifest: &Value) -> impl Iterator<Item = &toml::Table> {
manifest
.get("projects")
.and_then(Value::as_array)
.into_iter()
.flatten()
.filter_map(Value::as_table)
}
fn is_rust(project: &toml::Table) -> bool {
project
.get("metadata")
.and_then(|metadata| metadata.get("language"))
.and_then(Value::as_str)
.is_none_or(|language| language == "rust")
}
fn iam_project(manifest: &Value) -> Option<String> {
projects(manifest)
.find(|project| {
project.get("variant").and_then(Value::as_str) == Some("iam-base") && is_rust(project)
})
.and_then(|project| project.get("name").and_then(Value::as_str))
.map(str::to_owned)
}
fn custom_services(manifest: &Value) -> Vec<String> {
projects(manifest)
.filter(|project| {
project.get("type").and_then(Value::as_str) == Some("Service") && is_rust(project)
})
.filter(|project| {
project
.get("variant")
.and_then(Value::as_str)
.is_none_or(|variant| variant == "agent")
})
.filter_map(|project| project.get("name").and_then(Value::as_str))
.map(str::to_owned)
.collect()
}
fn ensure(env: &mut IndexMap<String, String>, key: &str, value: impl Into<String>) {
env.entry(key.to_owned()).or_insert_with(|| value.into());
}
fn module_service(manifest: &Value, compose: &DockerCompose, variants: &[&str]) -> Option<String> {
projects(manifest)
.filter(|project| {
project
.get("variant")
.and_then(Value::as_str)
.is_some_and(|variant| variants.contains(&variant))
})
.filter_map(|project| project.get("name").and_then(Value::as_str))
.find(|name| compose.services.contains_key(*name))
.map(str::to_owned)
}
fn service_urls(manifest: &Value, compose: &DockerCompose) -> IndexMap<String, String> {
projects(manifest)
.filter_map(|project| project.get("name").and_then(Value::as_str))
.filter_map(|name| {
let port = compose
.services
.get(name)?
.environment
.as_ref()?
.get("PORT")?;
Some((
format!("{}_URL", name.to_case(Case::UpperSnake)),
format!("http://{name}:{port}"),
))
})
.collect()
}
fn typescript_reads(root: &Path, manifest: &Value, project: &str) -> Vec<String> {
let modules = manifest
.get("modules_path")
.and_then(Value::as_str)
.unwrap_or("src");
read_to_string(root.join(modules).join(project).join("registrations.ts"))
.ok()
.and_then(|source| {
crate::core::ast::infrastructure::env::extract_env_vars_from_source(&source).ok()
})
.map(|usages| usages.into_iter().map(|usage| usage.var_name).collect())
.unwrap_or_default()
}
fn typescript_consumers(root: &Path, manifest: &Value, compose: &mut DockerCompose) {
let typescript: Vec<String> = projects(manifest)
.filter(|project| !is_rust(project))
.filter_map(|project| project.get("name").and_then(Value::as_str))
.map(str::to_owned)
.collect();
let native: Vec<String> = projects(manifest)
.filter(|project| is_rust(project))
.filter_map(|project| project.get("name").and_then(Value::as_str))
.map(str::to_owned)
.collect();
let iam = module_service(manifest, compose, &["iam-base", "iam-better-auth"]);
let billing = module_service(manifest, compose, &["billing-base", "billing-stripe"]);
let module_env = |module: &Option<String>, key: &str| {
module
.as_ref()
.and_then(|name| {
compose
.services
.get(name)?
.environment
.as_ref()?
.get(key)
.cloned()
})
.filter(|value| !unset(value))
};
let hmac =
module_env(&iam, "HMAC_SECRET_KEY").or_else(|| module_env(&billing, "HMAC_SECRET_KEY"));
let jwks = module_env(&iam, "JWKS_PUBLIC_KEY_URL");
let urls = service_urls(manifest, compose);
let modules = manifest
.get("modules_path")
.and_then(Value::as_str)
.unwrap_or("src");
for project in &typescript {
let reads = typescript_reads(root, manifest, project);
let local = env_values(&root.join(modules).join(project).join(".env.local"));
let own_url = format!("{}_URL", project.to_case(Case::UpperSnake));
for (name, service) in compose.services.iter_mut() {
let owned = !native.contains(name)
&& (name == project || name.starts_with(&format!("{project}-")));
let Some(env) = service.environment.as_mut().filter(|_| owned) else {
continue;
};
let mut wanted: Vec<String> = reads.clone();
wanted.extend(env.keys().cloned());
if reads.is_empty() && env.contains_key("HMAC_SECRET_KEY") {
wanted.extend(["IAM_URL".to_owned(), "BILLING_URL".to_owned()]);
}
for key in wanted {
if !env.get(&key).is_none_or(|value| unset(value)) {
continue;
}
let value = match key.as_str() {
"HMAC_SECRET_KEY" => hmac.clone(),
"JWKS_PUBLIC_KEY_URL" => jwks.clone(),
"STRIPE_API_KEY" | "STRIPE_WEBHOOK_SECRET" => Some(format!("${{{key}:-}}")),
_ if urls.contains_key(&key) => {
urls.get(&key).filter(|_| key != own_url).cloned()
}
_ => local.get(&key).and_then(|value| from_local(&key, value)),
};
if let Some(value) = value {
env.insert(key, value);
}
}
}
}
}
fn from_local(key: &str, value: &str) -> Option<String> {
if value.is_empty() || value.contains("localhost") || value.contains("127.0.0.1") {
return None;
}
let secret = ["SECRET", "PASSWORD", "TOKEN", "PRIVATE"]
.iter()
.any(|word| key.contains(word))
|| key.ends_with("_KEY");
Some(match (secret, unset(value)) {
(false, _) => value.to_owned(),
(true, true) => format!("${{{key}:-{value}}}"),
(true, false) => format!("${{{key}:?Set {key} in .env}}"),
})
}
fn unset(value: &str) -> bool {
value.is_empty() || value.starts_with("replace-with-")
}
pub(super) fn compose(root: &Path) -> Result<()> {
let manifest = read_manifest_value(&root.join(".forklaunch/manifest.toml"))?;
let path = compose_path(root, &manifest);
if !path.is_file() {
return Ok(());
}
let mut compose = read_or_create_rust_docker_compose(&path)?;
let iam = iam_project(&manifest).filter(|name| compose.services.contains_key(name));
if let Some(iam) = &iam {
let service = compose.services.get_mut(iam).expect("IAM compose service");
let env = service.environment.get_or_insert_with(IndexMap::new);
let signing = [
"SESSION_JWT_PRIVATE_KEY_PEM",
"SESSION_JWT_KEY_ID",
"SESSION_JWT_ISSUER",
"SESSION_JWT_AUDIENCE",
];
if !signing.iter().any(|key| env.contains_key(*key)) {
ensure(
env,
"SESSION_JWT_PRIVATE_KEY_PEM",
"${IAM_SESSION_JWT_PRIVATE_KEY_PEM:?Configure the IAM session signing key}",
);
ensure(
env,
"SESSION_JWT_KEY_ID",
"${IAM_SESSION_JWT_KEY_ID:?Configure the IAM session signing key id}",
);
ensure(env, "SESSION_JWT_ISSUER", ISSUER);
ensure(env, "SESSION_JWT_AUDIENCE", AUDIENCE);
}
ensure(env, "CORS_ORIGINS", CORS);
let seed = format!("{iam}-seed");
if !compose.services.contains_key(&seed) {
let source = compose.services[iam].clone();
if let Some(DockerCommand::Multiple(command)) = &source.command
&& let Some(binary) = command.first()
{
let mut environment = source.environment.clone().unwrap_or_default();
environment.insert(
SEED_PASSWORD.into(),
format!("${{{SEED_PASSWORD}:?Set the seeded IAM admin password}}"),
);
let service = DockerService {
image: source.image.clone(),
restart: Some(Restart::No),
environment: Some(environment),
networks: source.networks.clone(),
working_dir: source.working_dir.clone(),
command: Some(DockerCommand::Multiple(vec![
binary.clone(),
"--seed".into(),
])),
..Default::default()
};
compose.services.insert(seed, service);
}
}
}
typescript_consumers(root, &manifest, &mut compose);
let custom = custom_services(&manifest);
for (name, service) in compose.services.iter_mut() {
let env = service.environment.get_or_insert_with(IndexMap::new);
if custom.contains(name) {
if iam.is_some() {
let prefix = name.to_case(Case::UpperSnake);
ensure(
env,
"JWKS_PUBLIC_KEY_URL",
format!("${{{prefix}_JWKS_PUBLIC_KEY_URL:?Configure trusted JWKS}}"),
);
}
ensure(env, "CORS_ORIGINS", CORS);
}
if env.contains_key("JWKS_PUBLIC_KEY_URL") {
ensure(env, "JWT_ISSUER", ISSUER);
ensure(env, "JWT_AUDIENCE", AUDIENCE);
}
}
write(&path, yaml_to_string(&compose)?)?;
Ok(())
}
pub(super) fn local_value(name: &str, app: &str) -> Option<String> {
if name.ends_with("SESSION_JWT_PRIVATE_KEY_PEM") {
return Some(development_key());
}
if name.ends_with("SESSION_JWT_KEY_ID") {
return Some(format!("{app}-dev-1"));
}
match name {
"JWT_ISSUER" => Some(format!("{app}-iam")),
"JWT_AUDIENCE" => Some(format!("{app}-api")),
SEED_PASSWORD => {
let mut bytes = [0; 18];
getrandom::fill(&mut bytes).expect("Failed to generate random bytes");
Some(base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes))
}
_ => None,
}
}
fn development_key() -> String {
let key = KeyPair::generate(KeySize::Rsa2048).expect("Failed to generate an RSA key");
let der: Pkcs8V1Der<'static> = key.as_der().expect("Failed to encode the RSA key");
let body = base64::engine::general_purpose::STANDARD.encode(der.as_ref());
let mut pem = String::from("-----BEGIN PRIVATE KEY-----\n");
for line in body.as_bytes().chunks(64) {
pem.push_str(std::str::from_utf8(line).expect("base64 is ASCII"));
pem.push('\n');
}
pem.push_str("-----END PRIVATE KEY-----\n");
base64::engine::general_purpose::STANDARD.encode(pem.as_bytes())
}
fn env_values(path: &Path) -> IndexMap<String, String> {
read_to_string(path)
.unwrap_or_default()
.lines()
.filter(|line| !line.trim_start().starts_with('#'))
.filter_map(|line| line.split_once('='))
.map(|(key, value)| (key.trim().to_owned(), value.trim().to_owned()))
.collect()
}
pub(super) fn local_envs(root: &Path) -> Result<()> {
let manifest = read_manifest_value(&root.join(".forklaunch/manifest.toml"))?;
let path = compose_path(root, &manifest);
if !path.is_file() {
return Ok(());
}
let compose = read_or_create_rust_docker_compose(&path)?;
let root_env = env_values(&root.join(".env"));
let jwks = iam_project(&manifest)
.and_then(|iam| compose.services.get(&iam))
.and_then(|iam| iam.ports.as_deref())
.and_then(|ports| {
ports
.iter()
.find_map(|port| crate::core::docker::compose_host_port(port))
})
.map(|port| format!("http://localhost:{port}/api/auth/jwks"));
for project in projects(&manifest).filter(|project| is_rust(project)) {
let Some(name) = project.get("name").and_then(Value::as_str) else {
continue;
};
let Some(env) = compose
.services
.get(name)
.and_then(|service| service.environment.as_ref())
else {
continue;
};
if !env.contains_key("JWKS_PUBLIC_KEY_URL") {
continue;
}
let crate_path = project
.get("metadata")
.and_then(|metadata| metadata.get("cratePath"))
.and_then(Value::as_str)
.map_or_else(|| format!("src/{name}"), str::to_owned);
let local = root.join(crate_path).join(".env.local");
if !local.is_file() {
continue;
}
let existing = read_to_string(&local)?;
let present = env_values(&local);
let mut content = existing.clone();
if !content.is_empty() && !content.ends_with('\n') {
content.push('\n');
}
let values = [
("JWKS_PUBLIC_KEY_URL", jwks.clone()),
("JWT_ISSUER", root_env.get("JWT_ISSUER").cloned()),
("JWT_AUDIENCE", root_env.get("JWT_AUDIENCE").cloned()),
];
for (key, value) in values {
if let Some(value) = value.filter(|_| !present.contains_key(key)) {
content.push_str(&format!("{key}={value}\n"));
}
}
if content != existing {
write(&local, content)?;
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn typescript_consumers_use_the_rust_modules_wiring() {
let manifest: Value = toml::from_str(
r#"
[[projects]]
name = "iam"
variant = "iam-base"
[[projects]]
name = "billing"
variant = "billing-stripe"
[[projects]]
name = "svc"
[projects.metadata]
language = "typescript"
[[projects]]
name = "jobs"
[projects.metadata]
language = "typescript"
[[projects]]
name = "svc-native"
"#,
)
.unwrap();
let mut compose: DockerCompose = yaml_from_str(r#"
services:
iam:
environment: { PORT: "8000", HMAC_SECRET_KEY: "${IAM_HMAC_SECRET_KEY:?x}", JWKS_PUBLIC_KEY_URL: "${IAM_JWKS_PUBLIC_KEY_URL:?x}" }
billing:
environment: { PORT: "8001", HMAC_SECRET_KEY: "${BILLING_HMAC_SECRET_KEY:?x}" }
svc:
environment: { HMAC_SECRET_KEY: "", JWKS_PUBLIC_KEY_URL: replace-with-jwks-public-key-url }
jobs-worker:
environment: { HMAC_SECRET_KEY: custom, IAM_URL: "http://custom:1" }
jobs-plain:
environment: { REDIS_URL: "redis://redis:6379/1" }
svc-native:
environment: { HMAC_SECRET_KEY: "" }
"#).unwrap();
typescript_consumers(Path::new("/nonexistent"), &manifest, &mut compose);
let env = |name: &str| compose.services[name].environment.clone().unwrap();
let svc = env("svc");
assert_eq!(svc["HMAC_SECRET_KEY"], "${IAM_HMAC_SECRET_KEY:?x}");
assert_eq!(svc["JWKS_PUBLIC_KEY_URL"], "${IAM_JWKS_PUBLIC_KEY_URL:?x}");
assert_eq!(svc["IAM_URL"], "http://iam:8000");
assert_eq!(svc["BILLING_URL"], "http://billing:8001");
let worker = env("jobs-worker");
assert_eq!(worker["HMAC_SECRET_KEY"], "custom");
assert_eq!(worker["IAM_URL"], "http://custom:1");
assert_eq!(worker["BILLING_URL"], "http://billing:8001");
assert!(!env("jobs-plain").contains_key("IAM_URL"));
assert_eq!(env("svc-native")["HMAC_SECRET_KEY"], "");
}
fn registrations(root: &Path, project: &str, keys: &[&str]) {
let entries: String = keys
.iter()
.map(|key| format!(" {key}: {{ lifetime: Lifetime.Singleton, type: string, value: getEnvVar('{key}') }},\n"))
.collect();
let dir = root.join("src").join(project);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(
dir.join("registrations.ts"),
format!("const env = configInjector.chain({{\n{entries}}});\n"),
)
.unwrap();
}
#[test]
fn typescript_consumers_use_modules_of_either_language() {
let root = tempfile::tempdir().unwrap();
registrations(
root.path(),
"reminders",
&[
"IAM_URL",
"BILLING_URL",
"HMAC_SECRET_KEY",
"JWKS_PUBLIC_KEY_URL",
"REDIS_URL",
],
);
registrations(
root.path(),
"ecommerce",
&[
"STRIPE_API_KEY",
"STRIPE_WEBHOOK_SECRET",
"HMAC_SECRET_KEY",
"PORT",
"PAYPAL_BASE_URL",
"PAYPAL_CLIENT_SECRET",
"PAYPAL_WEBHOOK_ID",
"ORDER_EVENT_QUEUE",
"REDIS_URL",
"SIGNING_KEY",
],
);
std::fs::write(
root.path().join("src/ecommerce/.env.local"),
"STRIPE_API_KEY=replace-with-stripe-api-key\nPAYPAL_BASE_URL=https://api-m.sandbox.paypal.com\n\
PAYPAL_CLIENT_SECRET=replace-with-paypal-client-secret\nPAYPAL_WEBHOOK_ID=replace-with-paypal-webhook-id\n\
ORDER_EVENT_QUEUE=order-events\nREDIS_URL=redis://localhost:6379/0\nSIGNING_KEY=real-secret\n",
)
.unwrap();
registrations(
root.path(),
"iam",
&["HMAC_SECRET_KEY", "JWKS_PUBLIC_KEY_URL", "PORT"],
);
let manifest: Value = toml::from_str(
r#"
language = "mixed"
modules_path = "src"
[[projects]]
name = "iam"
variant = "iam-better-auth"
[projects.metadata]
language = "typescript"
[[projects]]
name = "billing"
variant = "billing-stripe"
[projects.metadata]
language = "rust"
[[projects]]
name = "ecommerce"
variant = "ecommerce-stripe"
[projects.metadata]
language = "typescript"
[[projects]]
name = "reminders"
[projects.metadata]
language = "typescript"
"#,
)
.unwrap();
let mut compose: DockerCompose = yaml_from_str(r#"
services:
iam:
environment: { PORT: "8001", HMAC_SECRET_KEY: shared, JWKS_PUBLIC_KEY_URL: "http://iam:8001/api/auth/jwks" }
billing:
environment: { PORT: "8002", HMAC_SECRET_KEY: "${BILLING_HMAC_SECRET_KEY:?x}", STRIPE_API_KEY: "${STRIPE_API_KEY:-}" }
ecommerce:
environment: { PORT: "8003", HMAC_SECRET_KEY: shared }
ecommerce-worker:
environment: { PORT: "8003", HMAC_SECRET_KEY: shared, STRIPE_API_KEY: replace-with-stripe-api-key }
reminders-server:
environment: { PORT: "8004", REDIS_URL: "redis://redis:6379/1" }
reminders-worker:
environment: { HMAC_SECRET_KEY: "", JWKS_PUBLIC_KEY_URL: replace-with-jwks-public-key-url }
"#).unwrap();
typescript_consumers(root.path(), &manifest, &mut compose);
let env = |name: &str| compose.services[name].environment.clone().unwrap();
for worker in ["reminders-server", "reminders-worker"] {
let worker = env(worker);
assert_eq!(worker["IAM_URL"], "http://iam:8001");
assert_eq!(worker["BILLING_URL"], "http://billing:8002");
assert_eq!(worker["HMAC_SECRET_KEY"], "shared");
assert_eq!(
worker["JWKS_PUBLIC_KEY_URL"],
"http://iam:8001/api/auth/jwks"
);
}
assert_eq!(env("reminders-server")["REDIS_URL"], "redis://redis:6379/1");
for service in ["ecommerce", "ecommerce-worker"] {
let service = env(service);
assert_eq!(service["STRIPE_API_KEY"], "${STRIPE_API_KEY:-}");
assert_eq!(
service["STRIPE_WEBHOOK_SECRET"],
"${STRIPE_WEBHOOK_SECRET:-}"
);
assert!(!service.contains_key("IAM_URL"));
assert_eq!(
service["PAYPAL_BASE_URL"],
"https://api-m.sandbox.paypal.com"
);
assert_eq!(
service["PAYPAL_WEBHOOK_ID"],
"replace-with-paypal-webhook-id"
);
assert_eq!(service["ORDER_EVENT_QUEUE"], "order-events");
assert_eq!(
service["PAYPAL_CLIENT_SECRET"],
"${PAYPAL_CLIENT_SECRET:-replace-with-paypal-client-secret}"
);
assert_eq!(
service["SIGNING_KEY"],
"${SIGNING_KEY:?Set SIGNING_KEY in .env}"
);
assert!(!service.contains_key("REDIS_URL"));
}
assert!(!env("iam").contains_key("IAM_URL"));
assert!(!env("billing").contains_key("IAM_URL"));
assert!(!env("billing").contains_key("JWKS_PUBLIC_KEY_URL"));
}
#[test]
fn development_values_are_fresh_and_single_line() {
let key = local_value("IAM_SESSION_JWT_PRIVATE_KEY_PEM", "app").unwrap();
assert!(!key.contains('\n'));
let pem = String::from_utf8(
base64::engine::general_purpose::STANDARD
.decode(&key)
.unwrap(),
)
.unwrap();
assert!(pem.starts_with("-----BEGIN PRIVATE KEY-----\n"));
assert!(pem.ends_with("-----END PRIVATE KEY-----\n"));
assert!(pem.lines().all(|line| line.len() <= 64));
let body: String = pem
.lines()
.filter(|line| !line.starts_with("-----"))
.collect();
let der = base64::engine::general_purpose::STANDARD
.decode(body)
.unwrap();
assert!(KeyPair::from_pkcs8(&der).is_ok());
assert_ne!(
local_value("IAM_SESSION_JWT_PRIVATE_KEY_PEM", "app").unwrap(),
key
);
assert_eq!(
local_value("IAM_SESSION_JWT_KEY_ID", "app").as_deref(),
Some("app-dev-1")
);
assert_eq!(local_value("JWT_ISSUER", "app").as_deref(), Some("app-iam"));
assert_eq!(
local_value("JWT_AUDIENCE", "app").as_deref(),
Some("app-api")
);
let password = local_value(SEED_PASSWORD, "app").unwrap();
assert_eq!(password.len(), 24);
assert_ne!(local_value(SEED_PASSWORD, "app").unwrap(), password);
assert_eq!(local_value("OTHER", "app"), None);
}
}