fix: make stripe webhooks explicitly toggleable with mandatory secret validation #23
@@ -28,5 +28,5 @@ 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_WEBHOOK_SECRET=whsec_test_00000000000000000000000000 # Webhook signing secret (use real value in production)
|
||||
|
||||
+24
-22
@@ -30,27 +30,29 @@ backend/
|
||||
|
||||
Environment variables:
|
||||
|
||||
| Variable | Description | Default |
|
||||
|---|---|---|
|
||||
| `DATABASE_URL` | SQLite URL. Relative paths are resolved under `backend/`. | `sqlite://<backend>/data/caravel.db` |
|
||||
| `HOST` | API bind host (also used for NIP-98 `u` host check) | `127.0.0.1` |
|
||||
| `PORT` | API bind port | `2892` |
|
||||
| `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://<backend>/data/caravel.db` |
|
||||
| `HOST` | API bind host (also used for NIP-98 `u` host check) | `127.0.0.1` |
|
||||
| `PORT` | API bind port | `2892` |
|
||||
| `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_WEBHOOK_SECRET` | Stripe webhook signing secret used to verify `Stripe-Signature` headers | _required_ |
|
||||
| `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 +68,7 @@ Public exceptions:
|
||||
|
||||
- `GET /plans`
|
||||
- `GET /plans/:id`
|
||||
- `POST /stripe/webhook` (validated with Stripe signatures instead)
|
||||
- `POST /stripe/webhook` (validated with Stripe signatures)
|
||||
|
||||
- `GET /identity` — get auth identity (`pubkey`, `is_admin`)
|
||||
- `GET /tenants` — list tenants (admin)
|
||||
|
||||
@@ -178,6 +178,7 @@ Notes:
|
||||
- Reads raw request body and `Stripe-Signature` header
|
||||
- Calls `billing.handle_webhook(payload, signature)`
|
||||
- Returns `200` on success, `400` on signature verification failure
|
||||
- Startup requires non-empty `STRIPE_WEBHOOK_SECRET`
|
||||
|
||||
--- Utilities
|
||||
|
||||
|
||||
@@ -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_secret_key: String` - Stripe API key used for billing API operations, from `STRIPE_SECRET_KEY`
|
||||
- `stripe_webhook_secret: String` - secret for verifying Stripe webhook signatures, from `STRIPE_WEBHOOK_SECRET`
|
||||
- `query: Query`
|
||||
- `command: Command`
|
||||
@@ -13,6 +14,8 @@ Members:
|
||||
## `pub fn new(query: Query, command: Command, robot: Robot) -> Self`
|
||||
|
||||
- Reads environment and populates members
|
||||
- Panics if `STRIPE_SECRET_KEY` is missing/empty
|
||||
- Panics if `STRIPE_WEBHOOK_SECRET` is missing/empty
|
||||
|
||||
## `pub fn start(&self)`
|
||||
|
||||
@@ -109,4 +112,3 @@ Skip invoices with `amount_due` of 0.
|
||||
|
||||
- Look up tenant by `stripe_customer_id`
|
||||
- Clear `stripe_subscription_id` via `command.clear_tenant_subscription`
|
||||
|
||||
|
||||
+4
-3
@@ -139,7 +139,7 @@ impl Api {
|
||||
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))
|
||||
@@ -158,8 +158,9 @@ impl Api {
|
||||
"/tenants/:pubkey/stripe/session",
|
||||
get(create_stripe_session),
|
||||
)
|
||||
.route("/stripe/webhook", post(stripe_webhook))
|
||||
.with_state(state)
|
||||
.route("/stripe/webhook", post(stripe_webhook));
|
||||
|
||||
router.with_state(state)
|
||||
}
|
||||
|
||||
fn extract_auth_pubkey(&self, headers: &HeaderMap) -> std::result::Result<String, ApiError> {
|
||||
|
||||
+105
-6
@@ -95,6 +95,9 @@ impl Billing {
|
||||
panic!("missing STRIPE_SECRET_KEY environment variable");
|
||||
}
|
||||
let stripe_webhook_secret = std::env::var("STRIPE_WEBHOOK_SECRET").unwrap_or_default();
|
||||
if stripe_webhook_secret.trim().is_empty() {
|
||||
panic!("missing STRIPE_WEBHOOK_SECRET environment variable");
|
||||
}
|
||||
let btc_quote_api_base =
|
||||
std::env::var("BTC_PRICE_API_BASE").unwrap_or_else(|_| COINBASE_SPOT_API.to_string());
|
||||
Self {
|
||||
@@ -949,7 +952,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<Mutex<()>> = OnceLock::new();
|
||||
@@ -964,6 +968,14 @@ 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") },
|
||||
}
|
||||
}
|
||||
|
||||
struct StripeSecretKeyGuard {
|
||||
previous: Option<String>,
|
||||
}
|
||||
@@ -982,6 +994,24 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
struct StripeWebhookSecretGuard {
|
||||
previous: Option<String>,
|
||||
}
|
||||
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
async fn test_pool() -> SqlitePool {
|
||||
let connect_options = SqliteConnectOptions::from_str("sqlite::memory:")
|
||||
.expect("valid sqlite memory url")
|
||||
@@ -1003,8 +1033,9 @@ 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 pool = test_pool().await;
|
||||
let query = Query::new(pool.clone());
|
||||
@@ -1034,9 +1065,76 @@ mod tests {
|
||||
}
|
||||
|
||||
#[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"));
|
||||
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 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::<String>() {
|
||||
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 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::<String>() {
|
||||
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_secrets() {
|
||||
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 pool = test_pool().await;
|
||||
let billing = Billing::new(
|
||||
@@ -1046,5 +1144,6 @@ mod tests {
|
||||
);
|
||||
|
||||
assert_eq!(billing.stripe_secret_key, "sk_test_dummy");
|
||||
assert_eq!(billing.stripe_webhook_secret, "whsec_test_dummy");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
+1
-1
@@ -3,8 +3,8 @@ mod billing;
|
||||
mod command;
|
||||
mod infra;
|
||||
mod models;
|
||||
mod query;
|
||||
mod pool;
|
||||
mod query;
|
||||
mod robot;
|
||||
|
||||
use anyhow::Result;
|
||||
|
||||
+1
-2
@@ -21,8 +21,7 @@ pub async fn create_pool() -> Result<SqlitePool> {
|
||||
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)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user