diff --git a/.env.example b/.env.example index 90e352f4..85054c34 100644 --- a/.env.example +++ b/.env.example @@ -48,6 +48,12 @@ ALLOWED_ORIGINS=https://keycast.example.com,https://peek.verse.app,https://*.ope # All bunker URLs will reference these relays (deployment-wide setting) BUNKER_RELAYS=wss://relay.divine.video,wss://relay.primal.net,wss://relay.nsec.app,wss://nos.lol +# ATProto control-plane integration (used by /api/user/atproto/enable) +# Required in production (service fails closed if unset) +DIVINE_SKY_ATPROTO_CONTROL_PLANE_URL=http://127.0.0.1:3201 +# Optional service-to-service bearer token for ATProto control-plane requests +KEYCAST_ATPROTO_TOKEN= + # SendGrid API key for email sending # REQUIRED in production (RUST_ENV=production or NODE_ENV=production) # In development, falls back to logging emails to console if not set diff --git a/api/src/api/http/atproto.rs b/api/src/api/http/atproto.rs index 2232f273..e23a95c1 100644 --- a/api/src/api/http/atproto.rs +++ b/api/src/api/http/atproto.rs @@ -414,8 +414,9 @@ fn map_control_error(error: AtprotoControlError) -> AuthError { AtprotoControlError::ProvisioningTrigger(err) => { tracing::warn!("ATProto provisioning trigger failed: {}", err); AuthError::ServiceUnavailable { - message: "ATProto setup is temporarily unavailable. Please try again shortly." - .to_string(), + message: + "ATProto provisioning is temporarily unavailable. Please try again shortly." + .to_string(), retry_after: Some(30), } } @@ -577,10 +578,15 @@ mod tests { "request failed: connection refused".to_string(), )); - assert!( - matches!(error, AuthError::ServiceUnavailable { .. }), - "provisioning dependency failures should not be classified as internal errors" - ); + match error { + AuthError::ServiceUnavailable { message, .. } => { + assert!( + message.contains("ATProto provisioning"), + "service-unavailable message should identify the dependency" + ); + } + _ => panic!("provisioning dependency failures must map to service unavailable"), + } } #[test] diff --git a/api/src/atproto_provisioning.rs b/api/src/atproto_provisioning.rs index 1f402bc2..ed7f94eb 100644 --- a/api/src/atproto_provisioning.rs +++ b/api/src/atproto_provisioning.rs @@ -3,6 +3,9 @@ use serde::Serialize; const DEFAULT_ATPROTO_CONTROL_PLANE_URL: &str = "http://127.0.0.1:3201"; const DEFAULT_HANDLE_DOMAIN: &str = "divine.video"; +const CONTROL_PLANE_URL_ENV: &str = "DIVINE_SKY_ATPROTO_CONTROL_PLANE_URL"; +const NODE_ENV_VAR: &str = "NODE_ENV"; +const PRODUCTION_ENV: &str = "production"; #[derive(Debug, thiserror::Error)] pub enum AtprotoProvisioningError { @@ -13,6 +16,8 @@ pub enum AtprotoProvisioningError { status: reqwest::StatusCode, body: String, }, + #[error("invalid ATProto provisioning configuration: {0}")] + Configuration(String), } #[derive(Debug, Serialize)] @@ -22,9 +27,41 @@ struct EnableProvisioningRequest { crosspost_enabled: bool, } -fn control_plane_base_url() -> String { - std::env::var("DIVINE_SKY_ATPROTO_CONTROL_PLANE_URL") - .unwrap_or_else(|_| DEFAULT_ATPROTO_CONTROL_PLANE_URL.to_string()) +fn is_production_environment() -> bool { + std::env::var(NODE_ENV_VAR) + .map(|value| value.eq_ignore_ascii_case(PRODUCTION_ENV)) + .unwrap_or(false) +} + +fn validate_control_plane_base_url(url: &str) -> Result<(), AtprotoProvisioningError> { + reqwest::Url::parse(url).map_err(|error| { + AtprotoProvisioningError::Configuration(format!( + "{CONTROL_PLANE_URL_ENV} must be a valid URL: {error}" + )) + })?; + Ok(()) +} + +fn control_plane_base_url() -> Result { + if let Ok(base_url) = std::env::var(CONTROL_PLANE_URL_ENV) { + let trimmed = base_url.trim(); + if trimmed.is_empty() { + return Err(AtprotoProvisioningError::Configuration(format!( + "{CONTROL_PLANE_URL_ENV} is set but empty" + ))); + } + + validate_control_plane_base_url(trimmed)?; + return Ok(trimmed.to_string()); + } + + if is_production_environment() { + return Err(AtprotoProvisioningError::Configuration(format!( + "{CONTROL_PLANE_URL_ENV} must be configured in production" + ))); + } + + Ok(DEFAULT_ATPROTO_CONTROL_PLANE_URL.to_string()) } fn handle_domain() -> String { @@ -46,7 +83,7 @@ pub async fn request_enable( username: &str, crosspost_enabled: bool, ) -> Result<(), AtprotoProvisioningError> { - let base = control_plane_base_url(); + let base = control_plane_base_url()?; let domain = handle_domain(); let url = format!("{}/api/account-links/opt-in", base.trim_end_matches('/')); let handle = format!("{}.{}", username.trim().to_ascii_lowercase(), domain); @@ -72,7 +109,7 @@ pub async fn request_enable( } pub async fn request_reenable(nostr_pubkey: &str) -> Result<(), AtprotoProvisioningError> { - let base = control_plane_base_url(); + let base = control_plane_base_url()?; let encoded_pubkey = urlencoding::encode(nostr_pubkey); let url = format!( "{}/api/account-links/{}/enable", @@ -93,7 +130,7 @@ pub async fn request_reenable(nostr_pubkey: &str) -> Result<(), AtprotoProvision } pub async fn request_disable(nostr_pubkey: &str) -> Result<(), AtprotoProvisioningError> { - let base = control_plane_base_url(); + let base = control_plane_base_url()?; let encoded_pubkey = urlencoding::encode(nostr_pubkey); let url = format!( "{}/api/account-links/{}/disable", diff --git a/api/tests/atproto_provisioning_test.rs b/api/tests/atproto_provisioning_test.rs index 2b27909f..eceac5b2 100644 --- a/api/tests/atproto_provisioning_test.rs +++ b/api/tests/atproto_provisioning_test.rs @@ -33,6 +33,12 @@ impl EnvGuard { std::env::set_var(key, value); Self { key, previous } } + + fn unset(key: &'static str) -> Self { + let previous = std::env::var(key).ok(); + std::env::remove_var(key); + Self { key, previous } + } } impl Drop for EnvGuard { @@ -146,3 +152,22 @@ async fn request_reenable_posts_enable_endpoint() { } ); } + +#[tokio::test] +#[serial] +async fn request_enable_fails_closed_when_control_plane_url_missing_in_production() { + let _node_env = EnvGuard::set("NODE_ENV", "production"); + let _base = EnvGuard::unset("DIVINE_SKY_ATPROTO_CONTROL_PLANE_URL"); + + let error = keycast_api::atproto_provisioning::request_enable("npub1prod", "Alice", true) + .await + .expect_err("production should fail closed without explicit control-plane URL"); + + assert!(matches!( + error, + keycast_api::atproto_provisioning::AtprotoProvisioningError::Configuration(_) + )); + assert!(error + .to_string() + .contains("DIVINE_SKY_ATPROTO_CONTROL_PLANE_URL")); +} diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index 47440334..8fe47b9e 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -118,6 +118,7 @@ This allows 5x higher concurrency without blocking request threads on bcrypt. | `keycast-ucan-secret` | `SERVER_NSEC` | Server nsec for token signing | | `keycast-sendgrid-api-key` | `SENDGRID_API_KEY` | Email (disabled: `DISABLE_EMAILS=true`) | | `keycast-redis-url` | `REDIS_URL` | Redis connection | +| `keycast-atproto-token` | `KEYCAST_ATPROTO_TOKEN` | Optional bearer token for ATProto control-plane requests | ### Plain Variables (cloudbuild.yaml) @@ -129,6 +130,7 @@ This allows 5x higher concurrency without blocking request threads on bcrypt. | `AWS_KMS_KEY_ID` | unset (only for `KMS_PROVIDER=aws`) | | `AWS_REGION` | `us-east-1` (only for `KMS_PROVIDER=aws`) | | `ALLOWED_ORIGINS` | `https://login.divine.video,https://divine.video,https://*.openvine-app.pages.dev` | +| `DIVINE_SKY_ATPROTO_CONTROL_PLANE_URL` | `https://` | | `RUST_LOG` | `info` | | `SQLX_POOL_SIZE` | `50` | | `SQLX_STATEMENT_CACHE` | `100` | diff --git a/keycast/src/main.rs b/keycast/src/main.rs index d3fe1bd8..6b96045b 100644 --- a/keycast/src/main.rs +++ b/keycast/src/main.rs @@ -235,6 +235,26 @@ fn parse_origin(origin: &str) -> Option<(&str, &str)> { Some((scheme, host)) } +fn is_production_environment() -> bool { + env::var("NODE_ENV") + .map(|value| value.eq_ignore_ascii_case("production")) + .unwrap_or(false) +} + +fn validate_atproto_control_plane_environment(is_production: bool) -> Result<(), String> { + if !is_production { + return Ok(()); + } + + match env::var("DIVINE_SKY_ATPROTO_CONTROL_PLANE_URL") { + Ok(value) if !value.trim().is_empty() => Ok(()), + _ => Err( + "DIVINE_SKY_ATPROTO_CONTROL_PLANE_URL must be set in production (ATProto control-plane base URL)" + .to_string(), + ), + } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum KmsProvider { File, @@ -387,48 +407,58 @@ async fn cache_control_middleware(request: Request, next: Next) -> Respons /// Validate required environment variables at startup fn validate_environment() -> Result<(), String> { - let mut errors = Vec::new(); + let mut errors: Vec = Vec::new(); // Required variables if env::var("DATABASE_URL").is_err() { - errors.push("DATABASE_URL must be set (PostgreSQL connection string)"); + errors.push("DATABASE_URL must be set (PostgreSQL connection string)".to_string()); } if env::var("ALLOWED_ORIGINS").is_err() { - errors.push("ALLOWED_ORIGINS must be set (comma-separated CORS origins)"); + errors.push("ALLOWED_ORIGINS must be set (comma-separated CORS origins)".to_string()); } if env::var("SERVER_NSEC").is_err() { - errors.push("SERVER_NSEC must be set (server's Nostr secret key for signing UCANs)"); + errors.push( + "SERVER_NSEC must be set (server's Nostr secret key for signing UCANs)".to_string(), + ); } if env::var("REDIS_URL").is_err() { - errors.push("REDIS_URL must be set (Redis/Memorystore URL for cluster coordination)"); + errors.push( + "REDIS_URL must be set (Redis/Memorystore URL for cluster coordination)".to_string(), + ); } let kms_provider = resolve_kms_provider()?; match kms_provider { KmsProvider::File => { if env::var("MASTER_KEY_PATH").is_err() { - errors.push("MASTER_KEY_PATH must be set when KMS_PROVIDER=file"); + errors.push("MASTER_KEY_PATH must be set when KMS_PROVIDER=file".to_string()); } } KmsProvider::Gcp => { if env::var("GCP_PROJECT_ID").is_err() { - errors.push("GCP_PROJECT_ID must be set when KMS_PROVIDER=gcp"); + errors.push("GCP_PROJECT_ID must be set when KMS_PROVIDER=gcp".to_string()); } } KmsProvider::Aws => { if env::var("AWS_KMS_KEY_ID").is_err() { - errors.push("AWS_KMS_KEY_ID must be set when KMS_PROVIDER=aws"); + errors.push("AWS_KMS_KEY_ID must be set when KMS_PROVIDER=aws".to_string()); } #[cfg(not(feature = "aws"))] { - errors.push("KMS_PROVIDER=aws requires building keycast with --features aws"); + errors.push( + "KMS_PROVIDER=aws requires building keycast with --features aws".to_string(), + ); } } } + if let Err(error) = validate_atproto_control_plane_environment(is_production_environment()) { + errors.push(error); + } + // Tenant isolation configuration let has_allowed_domains = env::var("ALLOWED_TENANT_DOMAINS").is_ok(); let has_auto_provisioning = env::var("ENABLE_TENANT_AUTO_PROVISIONING") @@ -458,7 +488,7 @@ fn validate_environment() -> Result<(), String> { // Validate email configuration (fail-closed in production) if let Err(e) = keycast_api::email_service::create_email_sender() { - errors.push(Box::leak(e.into_boxed_str())); + errors.push(e); } if !errors.is_empty() { @@ -539,7 +569,7 @@ async fn async_main(worker_threads: usize) -> Result<(), Box