Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
18 changes: 12 additions & 6 deletions api/src/api/http/atproto.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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),
}
}
Expand Down Expand Up @@ -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]
Expand Down
49 changes: 43 additions & 6 deletions api/src/atproto_provisioning.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -13,6 +16,8 @@ pub enum AtprotoProvisioningError {
status: reqwest::StatusCode,
body: String,
},
#[error("invalid ATProto provisioning configuration: {0}")]
Configuration(String),
}

#[derive(Debug, Serialize)]
Expand All @@ -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<String, AtprotoProvisioningError> {
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 {
Expand All @@ -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);
Expand All @@ -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",
Expand All @@ -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",
Expand Down
25 changes: 25 additions & 0 deletions api/tests/atproto_provisioning_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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"));
}
2 changes: 2 additions & 0 deletions docs/DEPLOYMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand All @@ -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://<atproto-control-plane-host>` |
| `RUST_LOG` | `info` |
| `SQLX_POOL_SIZE` | `50` |
| `SQLX_STATEMENT_CACHE` | `100` |
Expand Down
86 changes: 75 additions & 11 deletions keycast/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -387,48 +407,58 @@ async fn cache_control_middleware(request: Request<Body>, next: Next) -> Respons

/// Validate required environment variables at startup
fn validate_environment() -> Result<(), String> {
let mut errors = Vec::new();
let mut errors: Vec<String> = 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")
Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -539,7 +569,7 @@ async fn async_main(worker_threads: usize) -> Result<(), Box<dyn std::error::Err
}

// Initialize tracing with JSON format in production for GCP Cloud Logging
let is_production = std::env::var("NODE_ENV").unwrap_or_default() == "production";
let is_production = is_production_environment();
let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info"));

if is_production {
Expand Down Expand Up @@ -1181,6 +1211,40 @@ mod tests {
std::env::remove_var("KMS_PROVIDER");
}

#[test]
#[serial]
fn test_validate_atproto_control_plane_requires_url_in_production() {
std::env::remove_var("DIVINE_SKY_ATPROTO_CONTROL_PLANE_URL");

let result = validate_atproto_control_plane_environment(true);

assert!(result.is_err());
}

#[test]
#[serial]
fn test_validate_atproto_control_plane_accepts_url_in_production() {
std::env::set_var(
"DIVINE_SKY_ATPROTO_CONTROL_PLANE_URL",
"https://atproto-control-plane.example",
);

let result = validate_atproto_control_plane_environment(true);

assert!(result.is_ok());
std::env::remove_var("DIVINE_SKY_ATPROTO_CONTROL_PLANE_URL");
}

#[test]
#[serial]
fn test_validate_atproto_control_plane_not_required_outside_production() {
std::env::remove_var("DIVINE_SKY_ATPROTO_CONTROL_PLANE_URL");

let result = validate_atproto_control_plane_environment(false);

assert!(result.is_ok());
}

#[test]
#[serial]
fn test_inject_runtime_env_with_head_tag() {
Expand Down
Loading