diff --git a/backend/.env.template b/backend/.env.template index a21db77..4a80eb7 100644 --- a/backend/.env.template +++ b/backend/.env.template @@ -28,5 +28,6 @@ LIVEKIT_API_SECRET= # Billing NWC_URL= # Nostr Wallet Connect URL for generating Lightning invoices -STRIPE_SECRET_KEY= # Stripe API secret key (sk_...) -STRIPE_WEBHOOK_SECRET= # Secret for verifying Stripe webhook signatures (whsec_...) +STRIPE_SECRET_KEY= # Required Stripe API secret key (sk_...) +STRIPE_WEBHOOKS_ENABLED=false # Local-friendly default; set true to enable Stripe webhooks and require STRIPE_WEBHOOK_SECRET +STRIPE_WEBHOOK_SECRET= # Required webhook signing secret when STRIPE_WEBHOOKS_ENABLED=true (whsec_...) diff --git a/backend/README.md b/backend/README.md index 01e41da..a897e0a 100644 --- a/backend/README.md +++ b/backend/README.md @@ -30,27 +30,30 @@ backend/ Environment variables: -| Variable | Description | Default | -|---|---|---| -| `DATABASE_URL` | SQLite URL. Relative paths are resolved under `backend/`. | `sqlite:///data/caravel.db` | -| `HOST` | API bind host (also used for NIP-98 `u` host check) | `127.0.0.1` | -| `PORT` | API bind port | `3000` | -| `ADMINS` | Comma-separated admin pubkeys (hex) | _optional_ | -| `ALLOW_ORIGINS` | Comma-separated CORS origins. If empty, CORS is permissive. | _optional_ | -| `ZOOID_API_URL` | Zooid API base URL used by infra worker | _required for infra sync_ | -| `ZOOID_API_SECRET` | Nostr secret key used for authentication of requests to the zooid API | _required_ | -| `RELAY_DOMAIN` | Base domain appended to relay subdomains | empty | -| `LIVEKIT_URL` | LiveKit URL sent to zooid when relay livekit is enabled | _optional_ | -| `LIVEKIT_API_KEY` | LiveKit API key sent to zooid | _optional_ | -| `LIVEKIT_API_SECRET` | LiveKit API secret sent to zooid | _optional_ | -| `NWC_URL` | Platform NWC URL used to generate BOLT11 invoices | _required for invoice generation_ | -| `ROBOT_SECRET` | Robot Nostr secret key | _required_ | -| `ROBOT_NAME` | Robot display name (kind `0`) | _optional_ | -| `ROBOT_DESCRIPTION` | Robot description (kind `0`) | _optional_ | -| `ROBOT_PICTURE` | Robot picture URL (kind `0`) | _optional_ | -| `ROBOT_OUTBOX_RELAYS` | Comma-separated relays published as kind `10002` | _required_ | -| `ROBOT_INDEXER_RELAYS` | Comma-separated relays used for recipient relay discovery | _required_ | -| `ROBOT_MESSAGING_RELAYS` | Comma-separated relays published as kind `10050` | _required_ | +| Variable | Description | Default | +| ------------------------- | ----------------------------------------------------------------------- | ---------------------------------------------- | +| `DATABASE_URL` | SQLite URL. Relative paths are resolved under `backend/`. | `sqlite:///data/caravel.db` | +| `HOST` | API bind host (also used for NIP-98 `u` host check) | `127.0.0.1` | +| `PORT` | API bind port | `3000` | +| `ADMINS` | Comma-separated admin pubkeys (hex) | _optional_ | +| `ALLOW_ORIGINS` | Comma-separated CORS origins. If empty, CORS is permissive. | _optional_ | +| `ZOOID_API_URL` | Zooid API base URL used by infra worker | _required for infra sync_ | +| `ZOOID_API_SECRET` | Nostr secret key used for authentication of requests to the zooid API | _required_ | +| `RELAY_DOMAIN` | Base domain appended to relay subdomains | empty | +| `LIVEKIT_URL` | LiveKit URL sent to zooid when relay livekit is enabled | _optional_ | +| `LIVEKIT_API_KEY` | LiveKit API key sent to zooid | _optional_ | +| `LIVEKIT_API_SECRET` | LiveKit API secret sent to zooid | _optional_ | +| `NWC_URL` | Platform NWC URL used to generate BOLT11 invoices | _required for invoice generation_ | +| `STRIPE_SECRET_KEY` | Stripe API secret key used for billing API operations | _required_ | +| `STRIPE_WEBHOOKS_ENABLED` | Enables `POST /stripe/webhook` handling (`true`/`false`) | debug builds: `false`, release builds: `true` | +| `STRIPE_WEBHOOK_SECRET` | Stripe webhook signing secret used to verify `Stripe-Signature` headers | _required when `STRIPE_WEBHOOKS_ENABLED=true`_ | +| `ROBOT_SECRET` | Robot Nostr secret key | _required_ | +| `ROBOT_NAME` | Robot display name (kind `0`) | _optional_ | +| `ROBOT_DESCRIPTION` | Robot description (kind `0`) | _optional_ | +| `ROBOT_PICTURE` | Robot picture URL (kind `0`) | _optional_ | +| `ROBOT_OUTBOX_RELAYS` | Comma-separated relays published as kind `10002` | _required_ | +| `ROBOT_INDEXER_RELAYS` | Comma-separated relays used for recipient relay discovery | _required_ | +| `ROBOT_MESSAGING_RELAYS` | Comma-separated relays published as kind `10050` | _required_ | Relay list env vars are comma-separated and trimmed. If a relay has no `ws://` or `wss://` scheme, `wss://` is prepended. @@ -66,7 +69,7 @@ Public exceptions: - `GET /plans` - `GET /plans/:id` -- `POST /stripe/webhook` (validated with Stripe signatures instead) +- `POST /stripe/webhook` (only when enabled; validated with Stripe signatures) - `GET /identity` — get auth identity (`pubkey`, `is_admin`) - `GET /tenants` — list tenants (admin) diff --git a/backend/spec/api.md b/backend/spec/api.md index 35d09dc..73a6069 100644 --- a/backend/spec/api.md +++ b/backend/spec/api.md @@ -173,11 +173,12 @@ Notes: ## `async fn stripe_webhook(...) -> Response` -- Serves `POST /stripe/webhook` +- Serves `POST /stripe/webhook` when `STRIPE_WEBHOOKS_ENABLED=true` - No NIP-98 authentication — uses Stripe signature verification instead - Reads raw request body and `Stripe-Signature` header - Calls `billing.handle_webhook(payload, signature)` - Returns `200` on success, `400` on signature verification failure +- Route is not registered when Stripe webhooks are disabled --- Utilities diff --git a/backend/spec/billing.md b/backend/spec/billing.md index 270d988..ba0ddfe 100644 --- a/backend/spec/billing.md +++ b/backend/spec/billing.md @@ -5,6 +5,7 @@ Billing encapsulates logic related to synchronizing state with Stripe, processin Members: - `nwc_url: String` - a nostr wallet connect URL used to **create** bolt11 invoices (i.e. receive payments), from `NWC_URL` +- `stripe_webhooks_enabled: bool` - enables/disables Stripe webhook handling, from `STRIPE_WEBHOOKS_ENABLED` - `stripe_webhook_secret: String` - secret for verifying Stripe webhook signatures, from `STRIPE_WEBHOOK_SECRET` - `query: Query` - `command: Command` @@ -13,6 +14,10 @@ Members: ## `pub fn new(query: Query, command: Command, robot: Robot) -> Self` - Reads environment and populates members +- Panics if `STRIPE_SECRET_KEY` is missing/empty +- Parses `STRIPE_WEBHOOKS_ENABLED` as a boolean (`true/false`, `1/0`, `yes/no`, `on/off`) +- Defaults `STRIPE_WEBHOOKS_ENABLED` to `false` in debug builds and `true` in release builds +- Panics if `STRIPE_WEBHOOK_SECRET` is missing/empty while `STRIPE_WEBHOOKS_ENABLED=true` ## `pub fn start(&self)` @@ -33,6 +38,7 @@ Manages the Stripe subscription and subscription items for a relay's tenant. Onl ## `pub fn handle_webhook(&self, payload: &str, signature: &str) -> Result<()>` +- Returns an error when Stripe webhooks are disabled - Verify the webhook signature using `self.stripe_webhook_secret` - Parse the event and dispatch by type: - `invoice.created` -> `self.handle_invoice_created` @@ -109,4 +115,3 @@ Skip invoices with `amount_due` of 0. - Look up tenant by `stripe_customer_id` - Clear `stripe_subscription_id` via `command.clear_tenant_subscription` - diff --git a/backend/src/api.rs b/backend/src/api.rs index 34ae5fd..766d564 100644 --- a/backend/src/api.rs +++ b/backend/src/api.rs @@ -135,11 +135,12 @@ impl Api { } pub fn router(self) -> Router { + let stripe_webhooks_enabled = self.billing.webhooks_enabled(); let state = AppState { api: Arc::new(self), }; - Router::new() + let router = Router::new() .route("/identity", get(get_identity)) .route("/plans", get(list_plans)) .route("/plans/:id", get(get_plan)) @@ -157,9 +158,15 @@ impl Api { .route( "/tenants/:pubkey/stripe/session", get(create_stripe_session), - ) - .route("/stripe/webhook", post(stripe_webhook)) - .with_state(state) + ); + + let router = if stripe_webhooks_enabled { + router.route("/stripe/webhook", post(stripe_webhook)) + } else { + router + }; + + router.with_state(state) } fn extract_auth_pubkey(&self, headers: &HeaderMap) -> std::result::Result { diff --git a/backend/src/billing.rs b/backend/src/billing.rs index 997f0f0..3dd6504 100644 --- a/backend/src/billing.rs +++ b/backend/src/billing.rs @@ -18,6 +18,23 @@ const STRIPE_API: &str = "https://api.stripe.com/v1"; const COINBASE_SPOT_API: &str = "https://api.coinbase.com/v2/prices"; const WEBHOOK_TOLERANCE_SECS: i64 = 300; +fn parse_env_bool(name: &str, default: bool) -> bool { + match std::env::var(name) { + Ok(raw) => { + let normalized = raw.trim().to_ascii_lowercase(); + match normalized.as_str() { + "" => default, + "1" | "true" | "yes" | "on" => true, + "0" | "false" | "no" | "off" => false, + _ => panic!( + "invalid {name} environment variable value: {raw} (expected one of true,false,1,0,yes,no,on,off)" + ), + } + } + Err(_) => default, + } +} + #[derive(Debug)] pub enum InvoiceLookupError { StripeClient { status: reqwest::StatusCode }, @@ -79,6 +96,7 @@ struct CoinbaseSpotPriceData { pub struct Billing { nwc_url: String, stripe_secret_key: String, + stripe_webhooks_enabled: bool, stripe_webhook_secret: String, btc_quote_api_base: String, http: reqwest::Client, @@ -94,12 +112,22 @@ impl Billing { if stripe_secret_key.trim().is_empty() { panic!("missing STRIPE_SECRET_KEY environment variable"); } + let default_webhooks_enabled = !cfg!(debug_assertions); + let stripe_webhooks_enabled = + parse_env_bool("STRIPE_WEBHOOKS_ENABLED", default_webhooks_enabled); let stripe_webhook_secret = std::env::var("STRIPE_WEBHOOK_SECRET").unwrap_or_default(); + if stripe_webhooks_enabled && stripe_webhook_secret.trim().is_empty() { + panic!("missing STRIPE_WEBHOOK_SECRET environment variable"); + } + if !stripe_webhooks_enabled { + tracing::warn!("stripe webhooks disabled; /stripe/webhook route is inactive"); + } let btc_quote_api_base = std::env::var("BTC_PRICE_API_BASE").unwrap_or_else(|_| COINBASE_SPOT_API.to_string()); Self { nwc_url, stripe_secret_key, + stripe_webhooks_enabled, stripe_webhook_secret, btc_quote_api_base, http: reqwest::Client::new(), @@ -109,6 +137,10 @@ impl Billing { } } + pub fn webhooks_enabled(&self) -> bool { + self.stripe_webhooks_enabled + } + pub async fn start(self) { let mut rx = self.command.notify.subscribe(); @@ -237,6 +269,10 @@ impl Billing { } pub async fn handle_webhook(&self, payload: &str, signature: &str) -> Result<()> { + if !self.stripe_webhooks_enabled { + return Err(anyhow!("stripe webhooks are disabled")); + } + self.verify_webhook_signature(payload, signature)?; let event: StripeEvent = serde_json::from_str(payload)?; @@ -949,7 +985,8 @@ mod tests { use sqlx::SqlitePool; use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions}; use std::str::FromStr; - use std::sync::{Mutex, OnceLock}; + use std::sync::OnceLock; + use tokio::sync::Mutex; fn env_lock() -> &'static Mutex<()> { static LOCK: OnceLock> = OnceLock::new(); @@ -964,6 +1001,22 @@ mod tests { } } + #[allow(unused_unsafe)] + fn set_stripe_webhook_secret(value: Option<&str>) { + match value { + Some(v) => unsafe { std::env::set_var("STRIPE_WEBHOOK_SECRET", v) }, + None => unsafe { std::env::remove_var("STRIPE_WEBHOOK_SECRET") }, + } + } + + #[allow(unused_unsafe)] + fn set_stripe_webhooks_enabled(value: Option<&str>) { + match value { + Some(v) => unsafe { std::env::set_var("STRIPE_WEBHOOKS_ENABLED", v) }, + None => unsafe { std::env::remove_var("STRIPE_WEBHOOKS_ENABLED") }, + } + } + struct StripeSecretKeyGuard { previous: Option, } @@ -982,6 +1035,42 @@ mod tests { } } + struct StripeWebhookSecretGuard { + previous: Option, + } + + impl StripeWebhookSecretGuard { + fn set(value: Option<&str>) -> Self { + let previous = std::env::var("STRIPE_WEBHOOK_SECRET").ok(); + set_stripe_webhook_secret(value); + Self { previous } + } + } + + impl Drop for StripeWebhookSecretGuard { + fn drop(&mut self) { + set_stripe_webhook_secret(self.previous.as_deref()); + } + } + + struct StripeWebhooksEnabledGuard { + previous: Option, + } + + impl StripeWebhooksEnabledGuard { + fn set(value: Option<&str>) -> Self { + let previous = std::env::var("STRIPE_WEBHOOKS_ENABLED").ok(); + set_stripe_webhooks_enabled(value); + Self { previous } + } + } + + impl Drop for StripeWebhooksEnabledGuard { + fn drop(&mut self) { + set_stripe_webhooks_enabled(self.previous.as_deref()); + } + } + async fn test_pool() -> SqlitePool { let connect_options = SqliteConnectOptions::from_str("sqlite::memory:") .expect("valid sqlite memory url") @@ -1003,8 +1092,10 @@ mod tests { #[tokio::test] async fn billing_new_panics_without_stripe_secret_key() { - let _lock = env_lock().lock().expect("acquire env lock"); - let _env = StripeSecretKeyGuard::set(None); + let _lock = env_lock().lock().await; + let _secret_env = StripeSecretKeyGuard::set(None); + let _webhook_env = StripeWebhookSecretGuard::set(Some("whsec_test_dummy")); + let _webhooks_enabled_env = StripeWebhooksEnabledGuard::set(Some("true")); let pool = test_pool().await; let query = Query::new(pool.clone()); @@ -1033,10 +1124,80 @@ mod tests { ); } + #[tokio::test] + async fn billing_new_panics_without_stripe_webhook_secret() { + let _lock = env_lock().lock().await; + let _secret_env = StripeSecretKeyGuard::set(Some("sk_test_dummy")); + let _webhook_env = StripeWebhookSecretGuard::set(None); + let _webhooks_enabled_env = StripeWebhooksEnabledGuard::set(Some("true")); + + let pool = test_pool().await; + let query = Query::new(pool.clone()); + let command = Command::new(pool); + let robot = Robot::test_stub(); + + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + Billing::new(query, command, robot) + })); + + let panic_payload = match result { + Ok(_) => panic!("constructor should panic when STRIPE_WEBHOOK_SECRET is missing"), + Err(payload) => payload, + }; + let panic_msg = if let Some(msg) = panic_payload.downcast_ref::<&str>() { + (*msg).to_string() + } else if let Some(msg) = panic_payload.downcast_ref::() { + msg.clone() + } else { + String::new() + }; + + assert!( + panic_msg.contains("missing STRIPE_WEBHOOK_SECRET environment variable"), + "unexpected panic: {panic_msg}" + ); + } + + #[tokio::test] + async fn billing_new_panics_with_blank_stripe_webhook_secret() { + let _lock = env_lock().lock().await; + let _secret_env = StripeSecretKeyGuard::set(Some("sk_test_dummy")); + let _webhook_env = StripeWebhookSecretGuard::set(Some(" ")); + let _webhooks_enabled_env = StripeWebhooksEnabledGuard::set(Some("true")); + + let pool = test_pool().await; + let query = Query::new(pool.clone()); + let command = Command::new(pool); + let robot = Robot::test_stub(); + + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + Billing::new(query, command, robot) + })); + + let panic_payload = match result { + Ok(_) => panic!("constructor should panic when STRIPE_WEBHOOK_SECRET is blank"), + Err(payload) => payload, + }; + let panic_msg = if let Some(msg) = panic_payload.downcast_ref::<&str>() { + (*msg).to_string() + } else if let Some(msg) = panic_payload.downcast_ref::() { + msg.clone() + } else { + String::new() + }; + + assert!( + panic_msg.contains("missing STRIPE_WEBHOOK_SECRET environment variable"), + "unexpected panic: {panic_msg}" + ); + } + #[tokio::test] async fn billing_new_accepts_non_empty_stripe_secret_key() { - let _lock = env_lock().lock().expect("acquire env lock"); - let _env = StripeSecretKeyGuard::set(Some("sk_test_dummy")); + let _lock = env_lock().lock().await; + let _secret_env = StripeSecretKeyGuard::set(Some("sk_test_dummy")); + let _webhook_env = StripeWebhookSecretGuard::set(Some("whsec_test_dummy")); + let _webhooks_enabled_env = StripeWebhooksEnabledGuard::set(Some("true")); let pool = test_pool().await; let billing = Billing::new( @@ -1046,5 +1207,24 @@ mod tests { ); assert_eq!(billing.stripe_secret_key, "sk_test_dummy"); + assert!(billing.webhooks_enabled()); + assert_eq!(billing.stripe_webhook_secret, "whsec_test_dummy"); + } + + #[tokio::test] + async fn billing_new_allows_missing_webhook_secret_when_webhooks_disabled() { + let _lock = env_lock().lock().await; + let _secret_env = StripeSecretKeyGuard::set(Some("sk_test_dummy")); + let _webhook_env = StripeWebhookSecretGuard::set(None); + let _webhooks_enabled_env = StripeWebhooksEnabledGuard::set(Some("false")); + + let pool = test_pool().await; + let billing = Billing::new( + Query::new(pool.clone()), + Command::new(pool), + Robot::test_stub(), + ); + + assert!(!billing.webhooks_enabled()); } } diff --git a/backend/src/command.rs b/backend/src/command.rs index e75100a..85062b2 100644 --- a/backend/src/command.rs +++ b/backend/src/command.rs @@ -32,7 +32,7 @@ impl Command { .fetch_one(&mut **tx) .await? } - _ => anyhow::bail!("unknown resource_type: {}", resource_type), + _ => anyhow::bail!("unknown resource_type: {resource_type}"), }; let id = uuid::Uuid::new_v4().to_string(); diff --git a/backend/src/infra.rs b/backend/src/infra.rs index 1f7232f..fea69c0 100644 --- a/backend/src/infra.rs +++ b/backend/src/infra.rs @@ -169,7 +169,7 @@ impl Infra { if !response.status().is_success() { let status = response.status(); let body = response.text().await.unwrap_or_default(); - anyhow::bail!("zooid sync returned {}: {}", status, body) + anyhow::bail!("zooid sync returned {status}: {body}") } Ok(()) } diff --git a/backend/src/main.rs b/backend/src/main.rs index 336e372..343e59d 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -3,8 +3,8 @@ mod billing; mod command; mod infra; mod models; -mod query; mod pool; +mod query; mod robot; use anyhow::Result; diff --git a/backend/src/pool.rs b/backend/src/pool.rs index 1fbe829..165bc9e 100644 --- a/backend/src/pool.rs +++ b/backend/src/pool.rs @@ -21,8 +21,7 @@ pub async fn create_pool() -> Result { std::fs::create_dir_all(parent)?; } - let connect_options = - SqliteConnectOptions::from_str(&database_url)?.create_if_missing(true); + let connect_options = SqliteConnectOptions::from_str(&database_url)?.create_if_missing(true); let pool = SqlitePoolOptions::new() .max_connections(5) diff --git a/backend/tests/btc_quote_stub.rs b/backend/tests/btc_quote_stub.rs index bb23464..12a2dae 100644 --- a/backend/tests/btc_quote_stub.rs +++ b/backend/tests/btc_quote_stub.rs @@ -25,7 +25,7 @@ async fn quote_endpoint_can_be_stubbed_deterministically() { assert_eq!(btc_price, 50_000.0); - let msats = fiat_minor_to_msats_from_quote(100, "USD", btc_price) - .expect("convert quoted fiat amount"); + let msats = + fiat_minor_to_msats_from_quote(100, "USD", btc_price).expect("convert quoted fiat amount"); assert_eq!(msats, 2_000_000); }