Add env struct

This commit is contained in:
Jon Staab
2026-05-14 15:24:57 -07:00
parent 066c91a4d1
commit 26f05e8b8f
14 changed files with 293 additions and 593 deletions
+8 -12
View File
@@ -1,10 +1,8 @@
# Server
HOST=127.0.0.1
PORT=2892
ALLOW_ORIGINS= # Optional comma-separated allowed CORS origins; empty = permissive
# Auth
ADMINS= # Comma-separated hex pubkeys with admin access
SERVER_HOST=127.0.0.1
SERVER_PORT=2892
SERVER_ALLOW_ORIGINS= # Optional comma-separated allowed CORS origins; empty = permissive
SERVER_ADMIN_PUBKEYS= # Comma-separated hex pubkeys with admin access
# Database
DATABASE_URL=sqlite://data/caravel.db
@@ -14,13 +12,13 @@ ROBOT_SECRET= # Nostr private key (hex)
ROBOT_NAME=
ROBOT_DESCRIPTION=
ROBOT_PICTURE=
ROBOT_OUTBOX_RELAYS=relay.damus.io,relay.primal.net,nos.lol
ROBOT_INDEXER_RELAYS=purplepag.es,relay.damus.io,indexer.coracle.social
ROBOT_MESSAGING_RELAYS=auth.nostr1.com,relay.keychat.io,relay.ditto.pub
ROBOT_WALLET= # Nostr Wallet Connect URL for generating Lightning invoices
ROBOT_OUTBOX_RELAYS=wss://relay.damus.io,wss://relay.primal.net,wss://nos.lol
ROBOT_INDEXER_RELAYS=wss://purplepag.es,wss://relay.damus.io,wss://indexer.coracle.social
ROBOT_MESSAGING_RELAYS=wss://auth.nostr1.com,wss://relay.keychat.io,wss://relay.ditto.pub
# Zooid
ZOOID_API_URL=http://127.0.0.1:3334
ZOOID_API_SECRET=
RELAY_DOMAIN=spaces.coracle.social
LIVEKIT_URL=
LIVEKIT_API_KEY=
@@ -34,8 +32,6 @@ BLOSSOM_S3_ACCESS_KEY=
BLOSSOM_S3_SECRET_KEY=
# Billing
NWC_URL= # Nostr Wallet Connect URL for generating Lightning invoices
ENCRYPTION_SECRET= # Nostr secret key (hex or nsec) used to encrypt tenant NWC URLs at rest
STRIPE_SECRET_KEY= # Required Stripe API secret key (sk_...)
STRIPE_WEBHOOK_SECRET=whsec_test_00000000000000000000000000 # Webhook signing secret (use real value in production)
STRIPE_PRICE_BASIC= # Stripe price ID (price_...) for the Basic plan; required for paid plans
-1
View File
@@ -38,7 +38,6 @@ Environment variables:
| `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_ |
+47 -24
View File
@@ -14,6 +14,7 @@ use serde::{Deserialize, Serialize};
use crate::billing::Billing;
use crate::command::Command;
use crate::env::Env;
use crate::infra::Infra;
use crate::models::{
RELAY_STATUS_ACTIVE, RELAY_STATUS_DELINQUENT, RELAY_STATUS_INACTIVE, Relay, Tenant,
@@ -24,8 +25,7 @@ use axum::body::Bytes;
#[derive(Clone)]
pub struct Api {
host: String,
admins: Vec<String>,
env: Env,
query: Query,
command: Command,
billing: Billing,
@@ -120,17 +120,15 @@ fn map_invoice_lookup_error(error: InvoiceLookupError) -> ApiError {
}
impl Api {
pub fn new(query: Query, command: Command, billing: Billing, infra: Infra) -> Self {
let host = std::env::var("HOST").unwrap_or_else(|_| "127.0.0.1".to_string());
let admins = std::env::var("ADMINS")
.unwrap_or_default()
.split(',')
.map(|v| v.trim().to_lowercase())
.filter(|v| !v.is_empty())
.collect();
pub fn new(
query: Query,
command: Command,
billing: Billing,
infra: Infra,
env: &Env,
) -> Self {
Self {
host,
admins,
env: env.clone(),
query,
command,
billing,
@@ -218,7 +216,7 @@ impl Api {
// Intentional session-style variant of NIP-98 for Caravel API auth.
// We validate signer identity plus host affinity, and do not bind to exact
// request URL/method or maintain replay state here.
if !self.host.is_empty() && !got_u.contains(&self.host) {
if !self.env.server_host.is_empty() && !got_u.contains(&self.env.server_host) {
return Err(ApiError::Unauthorized(anyhow!(
"authorization host mismatch"
)));
@@ -228,7 +226,12 @@ impl Api {
}
fn require_admin(&self, authorized_pubkey: &str) -> std::result::Result<(), ApiError> {
if self.admins.iter().any(|a| a == authorized_pubkey) {
if self
.env
.server_admin_pubkeys
.iter()
.any(|a| a == authorized_pubkey)
{
Ok(())
} else {
Err(ApiError::Forbidden("admin required"))
@@ -240,7 +243,12 @@ impl Api {
authorized_pubkey: &str,
tenant_pubkey: &str,
) -> std::result::Result<(), ApiError> {
if self.admins.iter().any(|a| a == authorized_pubkey) || authorized_pubkey == tenant_pubkey
if self
.env
.server_admin_pubkeys
.iter()
.any(|a| a == authorized_pubkey)
|| authorized_pubkey == tenant_pubkey
{
Ok(())
} else {
@@ -267,7 +275,10 @@ impl Api {
fn prepare_relay(&self, mut relay: Relay) -> std::result::Result<Relay, RelayValidationError> {
validate_subdomain_label(&relay.subdomain)?;
let plan = Query::get_plan(&relay.plan).ok_or(RelayValidationError::InvalidPlan)?;
let plan = self
.query
.get_plan(&relay.plan)
.ok_or(RelayValidationError::InvalidPlan)?;
if !plan.blossom && relay.blossom_enabled == 1 {
return Err(RelayValidationError::PremiumFeature);
@@ -524,8 +535,8 @@ async fn list_tenants(
}
}
async fn list_plans() -> Response {
ok(StatusCode::OK, Query::list_plans())
async fn list_plans(State(state): State<AppState>) -> Response {
ok(StatusCode::OK, state.api.query.list_plans())
}
async fn get_identity(
@@ -533,7 +544,12 @@ async fn get_identity(
headers: HeaderMap,
) -> std::result::Result<Response, ApiError> {
let pubkey = state.api.extract_auth_pubkey(&headers)?;
let is_admin = state.api.admins.iter().any(|a| a == &pubkey);
let is_admin = state
.api
.env
.server_admin_pubkeys
.iter()
.any(|a| a == &pubkey);
Ok(ok(StatusCode::OK, IdentityResponse { pubkey, is_admin }))
}
@@ -599,8 +615,8 @@ async fn create_tenant(
}
}
async fn get_plan(Path(id): Path<String>) -> Response {
match Query::get_plan(&id) {
async fn get_plan(State(state): State<AppState>, Path(id): Path<String>) -> Response {
match state.api.query.get_plan(&id) {
Some(plan) => ok(StatusCode::OK, plan),
None => err(StatusCode::NOT_FOUND, "not-found", "plan not found"),
}
@@ -881,7 +897,11 @@ async fn update_relay(
.is_some_and(|requested| requested != current_plan);
if plan_changed {
let selected_plan = Query::get_plan(&relay.plan).expect("validated plan must exist");
let selected_plan = state
.api
.query
.get_plan(&relay.plan)
.expect("validated plan must exist");
if let Some(limit) = selected_plan.members {
let current_members = match state.api.fetch_relay_members(&relay).await {
Ok(members) => members.len() as i64,
@@ -1147,8 +1167,11 @@ async fn update_tenant(
if nwc_url.is_empty() {
tenant.nwc_url = String::new();
} else {
tenant.nwc_url =
crate::cipher::encrypt(&nwc_url).map_err(|e| ApiError::Internal(e.to_string()))?;
tenant.nwc_url = state
.api
.env
.encrypt(&nwc_url)
.map_err(|e| ApiError::Internal(e.to_string()))?;
}
}
+12 -238
View File
@@ -3,6 +3,7 @@ use std::collections::BTreeMap;
use crate::bitcoin;
use crate::command::Command;
use crate::env::Env;
use crate::models::{Activity, RELAY_STATUS_ACTIVE, RELAY_STATUS_DELINQUENT, Relay};
use crate::query::Query;
use crate::robot::Robot;
@@ -24,16 +25,21 @@ enum NwcInvoicePaymentOutcome {
pub struct Billing {
stripe: Stripe,
wallet: Wallet,
env: Env,
query: Query,
command: Command,
robot: Robot,
}
impl Billing {
pub fn new(query: Query, command: Command, robot: Robot) -> Self {
pub fn new(query: Query, command: Command, robot: Robot, env: &Env) -> Self {
Self {
stripe: Stripe::from_env(),
wallet: Wallet::from_url(&std::env::var("NWC_URL").unwrap_or_default()).expect("invalid NWC_URL"),
stripe: Stripe::new(
env.stripe_secret_key.clone(),
env.stripe_webhook_secret.clone(),
),
wallet: Wallet::from_url(&env.robot_wallet).expect("invalid ROBOT_WALLET"),
env: env.clone(),
query,
command,
robot,
@@ -136,7 +142,7 @@ impl Billing {
if relay.status != RELAY_STATUS_ACTIVE {
continue;
}
let Some(plan) = Query::get_plan(&relay.plan) else {
let Some(plan) = self.query.get_plan(&relay.plan) else {
tracing::warn!(relay = %relay.id, plan = %relay.plan, "active relay on unknown plan; not billed");
continue;
};
@@ -370,7 +376,7 @@ impl Billing {
// 1. NWC auto-pay: if the tenant has a nwc_url
if !tenant.nwc_url.is_empty() {
let plain_nwc_url = crate::cipher::decrypt(&tenant.nwc_url)?;
let plain_nwc_url = self.env.decrypt(&tenant.nwc_url)?;
match self
.nwc_pay_invoice(
invoice_id,
@@ -725,7 +731,7 @@ impl Billing {
return Ok(());
}
let plain_nwc_url = crate::cipher::decrypt(&tenant.nwc_url)?;
let plain_nwc_url = self.env.decrypt(&tenant.nwc_url)?;
let invoices = self
.stripe
@@ -1040,236 +1046,4 @@ mod tests {
));
}
use super::*;
use sqlx::SqlitePool;
use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions};
use std::str::FromStr;
use std::sync::OnceLock;
use tokio::sync::Mutex;
fn env_lock() -> &'static Mutex<()> {
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
LOCK.get_or_init(|| Mutex::new(()))
}
#[allow(unused_unsafe)]
fn set_stripe_secret_key(value: Option<&str>) {
match value {
Some(v) => unsafe { std::env::set_var("STRIPE_SECRET_KEY", v) },
None => unsafe { std::env::remove_var("STRIPE_SECRET_KEY") },
}
}
#[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_nwc_url(value: Option<&str>) {
match value {
Some(v) => unsafe { std::env::set_var("NWC_URL", v) },
None => unsafe { std::env::remove_var("NWC_URL") },
}
}
/// A syntactically valid NWC URI usable for `Wallet::from_env()` in tests.
/// The keys are random fixtures — the wallet is never actually contacted.
const TEST_NWC_URL: &str = "nostr+walletconnect://ef9824790df75f1f71d3fb9ffe9d8350f169df5cdd56a7a38592b407c61f4be7?relay=wss://relay.example.com&secret=baee312da88dcc52e9315e3962c2ea1bc8fdb5682a7fd6e6559084a41387e797";
struct StripeSecretKeyGuard {
previous: Option<String>,
}
impl StripeSecretKeyGuard {
fn set(value: Option<&str>) -> Self {
let previous = std::env::var("STRIPE_SECRET_KEY").ok();
set_stripe_secret_key(value);
Self { previous }
}
}
impl Drop for StripeSecretKeyGuard {
fn drop(&mut self) {
set_stripe_secret_key(self.previous.as_deref());
}
}
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());
}
}
struct NwcUrlGuard {
previous: Option<String>,
}
impl NwcUrlGuard {
fn set(value: Option<&str>) -> Self {
let previous = std::env::var("NWC_URL").ok();
set_nwc_url(value);
Self { previous }
}
}
impl Drop for NwcUrlGuard {
fn drop(&mut self) {
set_nwc_url(self.previous.as_deref());
}
}
async fn test_pool() -> SqlitePool {
let connect_options = SqliteConnectOptions::from_str("sqlite::memory:")
.expect("valid sqlite memory url")
.create_if_missing(true);
let pool = SqlitePoolOptions::new()
.max_connections(1)
.connect_with(connect_options)
.await
.expect("connect sqlite memory db");
sqlx::migrate!("./migrations")
.run(&pool)
.await
.expect("run migrations");
pool
}
#[tokio::test]
async fn billing_new_panics_without_stripe_secret_key() {
let _lock = env_lock().lock().await;
let _secret_env = StripeSecretKeyGuard::set(None);
let _webhook_env = StripeWebhookSecretGuard::set(Some("whsec_test_dummy"));
let _nwc_env = NwcUrlGuard::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_SECRET_KEY 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_SECRET_KEY environment variable"),
"unexpected panic: {panic_msg}"
);
}
#[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 _nwc_env = NwcUrlGuard::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 _nwc_env = NwcUrlGuard::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 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 _nwc_env = NwcUrlGuard::set(Some(TEST_NWC_URL));
let pool = test_pool().await;
let billing = Billing::new(
Query::new(pool.clone()),
Command::new(pool),
Robot::test_stub(),
);
assert_eq!(billing.stripe.secret_key, "sk_test_dummy");
assert_eq!(billing.stripe.webhook_secret, "whsec_test_dummy");
}
}
-28
View File
@@ -1,28 +0,0 @@
use anyhow::{Result, anyhow};
use nostr_sdk::prelude::*;
pub fn encrypt(plaintext: &str) -> Result<String> {
let keys = load_key()?;
nip44::encrypt(
keys.secret_key(),
&keys.public_key(),
plaintext,
nip44::Version::V2,
)
.map_err(|e| anyhow!("encryption failed: {e}"))
}
pub fn decrypt(ciphertext: &str) -> Result<String> {
let keys = load_key()?;
nip44::decrypt(keys.secret_key(), &keys.public_key(), ciphertext)
.map_err(|e| anyhow!("decryption failed: {e}"))
}
fn load_key() -> Result<Keys> {
let secret = std::env::var("ENCRYPTION_SECRET")
.map_err(|_| anyhow!("missing ENCRYPTION_SECRET environment variable"))?;
if secret.trim().is_empty() {
return Err(anyhow!("ENCRYPTION_SECRET is empty"));
}
Keys::parse(&secret).map_err(|e| anyhow!("invalid ENCRYPTION_SECRET: {e}"))
}
+126
View File
@@ -0,0 +1,126 @@
use anyhow::{Result, anyhow};
use nostr_sdk::prelude::*;
#[derive(Clone)]
pub struct Env {
pub server_host: String,
pub server_port: u16,
pub server_admin_pubkeys: Vec<String>,
pub server_allow_origins: Vec<String>,
pub database_url: String,
pub robot_name: String,
pub robot_wallet: String,
pub robot_picture: String,
pub robot_description: String,
pub robot_outbox_relays: Vec<String>,
pub robot_indexer_relays: Vec<String>,
pub robot_messaging_relays: Vec<String>,
pub blossom_s3_region: String,
pub blossom_s3_bucket: String,
pub blossom_s3_endpoint: String,
pub blossom_s3_access_key: String,
pub blossom_s3_secret_key: String,
pub zooid_api_url: String,
pub relay_domain: String,
pub livekit_url: String,
pub livekit_api_key: String,
pub livekit_api_secret: String,
pub stripe_secret_key: String,
pub stripe_webhook_secret: String,
pub stripe_price_basic: String,
pub stripe_price_growth: String,
/// Parsed from `robot_secret`; used for nostr signing and nip44 encryption.
pub keys: Keys,
}
impl Env {
pub fn load() -> Self {
let keys = Keys::parse(&require_str("ROBOT_SECRET"))
.expect("ROBOT_SECRET is not a valid nostr secret key");
Self {
server_host: require_str("SERVER_HOST"),
server_port: require_u16("SERVER_PORT"),
server_admin_pubkeys: require_csv("SERVER_ADMIN_PUBKEYS"),
server_allow_origins: require_csv("SERVER_ALLOW_ORIGINS"),
database_url: require_str("DATABASE_URL"),
robot_name: require_str("ROBOT_NAME"),
robot_wallet: require_str("ROBOT_WALLET"),
robot_picture: require_str("ROBOT_PICTURE"),
robot_description: require_str("ROBOT_DESCRIPTION"),
robot_outbox_relays: require_csv("ROBOT_OUTBOX_RELAYS"),
robot_indexer_relays: require_csv("ROBOT_INDEXER_RELAYS"),
robot_messaging_relays: require_csv("ROBOT_MESSAGING_RELAYS"),
blossom_s3_region: require_str("BLOSSOM_S3_REGION"),
blossom_s3_bucket: require_str("BLOSSOM_S3_BUCKET"),
blossom_s3_endpoint: require_str("BLOSSOM_S3_ENDPOINT"),
blossom_s3_access_key: require_str("BLOSSOM_S3_ACCESS_KEY"),
blossom_s3_secret_key: require_str("BLOSSOM_S3_SECRET_KEY"),
zooid_api_url: require_str("ZOOID_API_URL"),
relay_domain: require_str("RELAY_DOMAIN"),
livekit_url: require_str("LIVEKIT_URL"),
livekit_api_key: require_str("LIVEKIT_API_KEY"),
livekit_api_secret: require_str("LIVEKIT_API_SECRET"),
stripe_secret_key: require_str("STRIPE_SECRET_KEY"),
stripe_webhook_secret: require_str("STRIPE_WEBHOOK_SECRET"),
stripe_price_basic: require_str("STRIPE_PRICE_BASIC"),
stripe_price_growth: require_str("STRIPE_PRICE_GROWTH"),
keys,
}
}
pub fn encrypt(&self, plaintext: &str) -> Result<String> {
nip44::encrypt(
self.keys.secret_key(),
&self.keys.public_key(),
plaintext,
nip44::Version::V2,
)
.map_err(|e| anyhow!("encryption failed: {e}"))
}
pub fn decrypt(&self, ciphertext: &str) -> Result<String> {
nip44::decrypt(self.keys.secret_key(), &self.keys.public_key(), ciphertext)
.map_err(|e| anyhow!("decryption failed: {e}"))
}
pub async fn make_auth(&self, url: &str, method: HttpMethod) -> Result<String> {
let server_url = Url::parse(url)?;
let auth = HttpData::new(server_url, method)
.to_authorization(&self.keys)
.await?;
Ok(auth)
}
}
fn require_str(key: &str) -> String {
let v = std::env::var(key)
.unwrap_or_else(|_| panic!("{key} is required"))
.trim()
.to_string();
if v.is_empty() {
panic!("{key} is required")
}
v
}
fn require_u16(key: &str) -> u16 {
require_str(key)
.parse()
.unwrap_or_else(|_| panic!("{key} is invalid"))
}
fn require_csv(key: &str) -> Vec<String> {
let v: Vec<String> = std::env::var(key)
.unwrap_or_default()
.split(',')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect();
if v.is_empty() {
panic!("{key} is required");
}
v
}
+29 -107
View File
@@ -3,6 +3,7 @@ use nostr_sdk::prelude::*;
use std::time::Duration;
use crate::command::Command;
use crate::env::Env;
use crate::models::{Activity, RELAY_STATUS_DELINQUENT, RELAY_STATUS_INACTIVE, Relay};
use crate::query::Query;
@@ -10,88 +11,20 @@ const RELAY_SYNC_RETRY_BASE_DELAY_SECS: u64 = 30;
const RELAY_SYNC_RETRY_MAX_DELAY_SECS: u64 = 15 * 60;
const RELAY_SYNC_RETRY_MAX_ATTEMPTS: usize = 6;
/// Blossom S3 settings from env; relay sync sets `key_prefix` to the relay schema.
#[derive(Clone)]
struct BlossomS3Sync {
endpoint: String,
region: String,
bucket: String,
access_key: String,
secret_key: String,
}
impl BlossomS3Sync {
fn from_env() -> Option<Self> {
let region = std::env::var("BLOSSOM_S3_REGION").unwrap_or_default();
let bucket = std::env::var("BLOSSOM_S3_BUCKET").unwrap_or_default();
let access_key = std::env::var("BLOSSOM_S3_ACCESS_KEY").unwrap_or_default();
let secret_key = std::env::var("BLOSSOM_S3_SECRET_KEY").unwrap_or_default();
let region = region.trim().to_string();
let bucket = bucket.trim().to_string();
let access_key = access_key.trim().to_string();
let secret_key = secret_key.trim().to_string();
if region.is_empty() || bucket.is_empty() || access_key.is_empty() || secret_key.is_empty() {
return None;
}
let endpoint = std::env::var("BLOSSOM_S3_ENDPOINT")
.unwrap_or_default()
.trim()
.to_string();
Some(Self {
endpoint,
region,
bucket,
access_key,
secret_key,
})
}
}
#[derive(Clone)]
pub struct Infra {
api_url: String,
relay_domain: String,
livekit_url: String,
livekit_api_key: String,
livekit_api_secret: String,
api_secret: String,
blossom_s3: Option<BlossomS3Sync>,
env: Env,
query: Query,
command: Command,
}
impl Infra {
pub fn new(query: Query, command: Command) -> Result<Self> {
let api_url = std::env::var("ZOOID_API_URL").unwrap_or_default();
let relay_domain = std::env::var("RELAY_DOMAIN").unwrap_or_default();
let livekit_url = std::env::var("LIVEKIT_URL").unwrap_or_default();
let livekit_api_key = std::env::var("LIVEKIT_API_KEY").unwrap_or_default();
let livekit_api_secret = std::env::var("LIVEKIT_API_SECRET").unwrap_or_default();
let api_secret = std::env::var("ZOOID_API_SECRET").unwrap_or_default();
let blossom_s3 = BlossomS3Sync::from_env();
if api_url.trim().is_empty() {
anyhow::bail!("missing ZOOID_API_URL");
}
if api_secret.trim().is_empty() {
anyhow::bail!("missing ZOOID_API_SECRET");
}
Ok(Self {
api_url,
relay_domain,
livekit_url,
livekit_api_key,
livekit_api_secret,
api_secret,
blossom_s3,
pub fn new(query: Query, command: Command, env: &Env) -> Self {
Self {
env: env.clone(),
query,
command,
})
}
}
pub async fn start(self) {
@@ -241,20 +174,11 @@ impl Infra {
}
}
async fn nip98_auth(&self, url: &str, method: HttpMethod) -> Result<String> {
let keys = Keys::parse(&self.api_secret)?;
let server_url = Url::parse(url)?;
let auth = HttpData::new(server_url, method)
.to_authorization(&keys)
.await?;
Ok(auth)
}
pub async fn list_relay_members(&self, relay_id: &str) -> Result<Vec<String>> {
let client = reqwest::Client::new();
let base = self.api_url.trim_end_matches('/');
let base = self.env.zooid_api_url.trim_end_matches('/');
let url = format!("{base}/relay/{relay_id}/members");
let auth = self.nip98_auth(&url, HttpMethod::GET).await?;
let auth = self.env.make_auth(&url, HttpMethod::GET).await?;
let response = client
.get(&url)
@@ -274,20 +198,20 @@ impl Infra {
async fn sync_relay(&self, relay: &Relay, is_new: bool) -> Result<()> {
let client = reqwest::Client::new();
let base = self.api_url.trim_end_matches('/');
let base = self.env.zooid_api_url.trim_end_matches('/');
let host = if self.relay_domain.is_empty() {
let host = if self.env.relay_domain.is_empty() {
relay.subdomain.clone()
} else {
format!("{}.{}", relay.subdomain, self.relay_domain)
format!("{}.{}", relay.subdomain, self.env.relay_domain)
};
let livekit = if relay.livekit_enabled == 1 {
serde_json::json!({
"enabled": true,
"server_url": self.livekit_url,
"api_key": self.livekit_api_key,
"api_secret": self.livekit_api_secret,
"server_url": self.env.livekit_url,
"api_key": self.env.livekit_api_key,
"api_secret": self.env.livekit_api_secret,
})
} else {
serde_json::json!({ "enabled": false })
@@ -298,12 +222,13 @@ impl Infra {
host,
livekit,
is_new.then(|| Keys::generate().secret_key().to_secret_hex()),
self.blossom_s3.as_ref(),
&self.env,
);
let url = format!("{}/relay/{}", base, relay.id);
let auth = self
.nip98_auth(&url, zooid_sync_http_method(is_new))
.env
.make_auth(&url, zooid_sync_http_method(is_new))
.await?;
let request = if is_new {
@@ -368,9 +293,9 @@ fn relay_sync_body(
host: String,
livekit: serde_json::Value,
secret: Option<String>,
blossom_s3: Option<&BlossomS3Sync>,
env: &Env,
) -> serde_json::Value {
let blossom = blossom_sync_json(relay, blossom_s3);
let blossom = blossom_sync_json(relay, env);
let mut body = serde_json::json!({
"host": host,
@@ -405,38 +330,35 @@ fn relay_sync_body(
body
}
fn blossom_sync_json(relay: &Relay, blossom_s3: Option<&BlossomS3Sync>) -> serde_json::Value {
let enabled = relay.blossom_enabled == 1;
if !enabled {
/// Relay sync sets `key_prefix` to the relay schema so each relay gets its own
/// blob namespace within the shared bucket.
fn blossom_sync_json(relay: &Relay, env: &Env) -> serde_json::Value {
if relay.blossom_enabled != 1 {
return serde_json::json!({ "enabled": false });
}
let Some(s3) = blossom_s3 else {
return serde_json::json!({ "enabled": true });
};
let mut s3_obj = serde_json::Map::new();
if !s3.endpoint.trim().is_empty() {
if !env.blossom_s3_endpoint.trim().is_empty() {
s3_obj.insert(
"endpoint".to_string(),
serde_json::Value::String(s3.endpoint.clone()),
serde_json::Value::String(env.blossom_s3_endpoint.clone()),
);
}
s3_obj.insert(
"region".to_string(),
serde_json::Value::String(s3.region.clone()),
serde_json::Value::String(env.blossom_s3_region.clone()),
);
s3_obj.insert(
"bucket".to_string(),
serde_json::Value::String(s3.bucket.clone()),
serde_json::Value::String(env.blossom_s3_bucket.clone()),
);
s3_obj.insert(
"access_key".to_string(),
serde_json::Value::String(s3.access_key.clone()),
serde_json::Value::String(env.blossom_s3_access_key.clone()),
);
s3_obj.insert(
"secret_key".to_string(),
serde_json::Value::String(s3.secret_key.clone()),
serde_json::Value::String(env.blossom_s3_secret_key.clone()),
);
s3_obj.insert(
"key_prefix".to_string(),
+1 -1
View File
@@ -1,8 +1,8 @@
pub mod api;
pub mod billing;
pub mod bitcoin;
pub mod cipher;
pub mod command;
pub mod env;
pub mod infra;
pub mod models;
pub mod pool;
+15 -22
View File
@@ -1,8 +1,8 @@
mod api;
mod billing;
mod bitcoin;
mod cipher;
mod command;
mod env;
mod infra;
mod models;
mod pool;
@@ -19,6 +19,7 @@ use tower_http::cors::{AllowOrigin, CorsLayer};
use crate::api::Api;
use crate::billing::Billing;
use crate::command::Command;
use crate::env::Env;
use crate::infra::Infra;
use crate::query::Query;
use crate::robot::Robot;
@@ -32,30 +33,21 @@ async fn main() -> Result<()> {
.with(tracing_subscriber::fmt::layer())
.init();
let pool = pool::create_pool().await?;
let robot = Robot::new().await?;
let query = Query::new(pool.clone());
let env = Env::load();
let pool = pool::create_pool(&env.database_url).await?;
let robot = Robot::new(&env).await?;
let query = Query::new(pool.clone(), &env);
let command = Command::new(pool);
let billing = Billing::new(query.clone(), command.clone(), robot.clone());
let infra = Infra::new(query.clone(), command.clone())?;
let api = Api::new(query, command, billing.clone(), infra.clone());
let billing = Billing::new(query.clone(), command.clone(), robot.clone(), &env);
let infra = Infra::new(query.clone(), command.clone(), &env);
let api = Api::new(query, command, billing.clone(), infra.clone(), &env);
let host = std::env::var("HOST").unwrap_or_else(|_| "127.0.0.1".to_string());
let port: u16 = std::env::var("PORT")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(2892);
let origins: Vec<String> = std::env::var("ALLOW_ORIGINS")
.unwrap_or_default()
.split(',')
.map(|v| v.trim().to_string())
.filter(|v| !v.is_empty())
.collect();
let cors = if origins.is_empty() {
let cors = if env.server_allow_origins.is_empty() {
CorsLayer::permissive()
} else {
let parsed = origins
let parsed = env
.server_allow_origins
.iter()
.filter_map(|o| o.parse::<axum::http::HeaderValue>().ok())
.collect::<Vec<_>>();
@@ -72,7 +64,8 @@ async fn main() -> Result<()> {
billing.start().await;
});
let listener = tokio::net::TcpListener::bind(format!("{host}:{port}")).await?;
let listener =
tokio::net::TcpListener::bind(format!("{}:{}", env.server_host, env.server_port)).await?;
axum::serve(listener, app).await?;
Ok(())
}
+2 -4
View File
@@ -7,10 +7,8 @@ use sqlx::{
sqlite::{SqliteConnectOptions, SqlitePoolOptions},
};
pub async fn create_pool() -> Result<SqlitePool> {
let raw_database_url = std::env::var("DATABASE_URL")
.unwrap_or_else(|_| format!("sqlite://{}/data/caravel.db", env!("CARGO_MANIFEST_DIR")));
let database_url = normalize_sqlite_url(&raw_database_url);
pub async fn create_pool(database_url: &str) -> Result<SqlitePool> {
let database_url = normalize_sqlite_url(database_url);
if let Some(path) = database_url.strip_prefix("sqlite://")
&& !path.is_empty()
+15 -10
View File
@@ -1,16 +1,21 @@
use anyhow::Result;
use sqlx::SqlitePool;
use crate::env::Env;
use crate::models::{Activity, Plan, Relay, Tenant};
#[derive(Clone)]
pub struct Query {
pool: SqlitePool,
env: Env,
}
impl Query {
pub fn new(pool: SqlitePool) -> Self {
Self { pool }
pub fn new(pool: SqlitePool, env: &Env) -> Self {
Self {
pool,
env: env.clone(),
}
}
pub async fn list_tenants(&self) -> Result<Vec<Tenant>> {
@@ -36,7 +41,7 @@ impl Query {
Ok(row)
}
pub fn list_plans() -> Vec<Plan> {
pub fn list_plans(&self) -> Vec<Plan> {
vec![
Plan {
id: "free".to_string(),
@@ -54,7 +59,7 @@ impl Query {
members: Some(100),
blossom: true,
livekit: true,
stripe_price_id: Some(std::env::var("STRIPE_PRICE_BASIC").unwrap_or_default()),
stripe_price_id: Some(self.env.stripe_price_basic.clone()),
},
Plan {
id: "growth".to_string(),
@@ -63,19 +68,19 @@ impl Query {
members: None,
blossom: true,
livekit: true,
stripe_price_id: Some(std::env::var("STRIPE_PRICE_GROWTH").unwrap_or_default()),
stripe_price_id: Some(self.env.stripe_price_growth.clone()),
},
]
}
pub fn get_plan(plan_id: &str) -> Option<Plan> {
Self::list_plans().into_iter().find(|p| p.id == plan_id)
pub fn get_plan(&self, plan_id: &str) -> Option<Plan> {
self.list_plans().into_iter().find(|p| p.id == plan_id)
}
/// True for any plan that costs money. Doesn't require an instance because
/// the answer doesn't depend on Stripe price ids — only the canonical plan id.
pub fn is_paid_plan(plan_id: &str) -> bool {
Self::get_plan(plan_id)
.map(|p| p.id != "free")
.unwrap_or(false)
matches!(plan_id, "basic" | "growth")
}
pub async fn list_relays(&self) -> Result<Vec<Relay>> {
+37 -132
View File
@@ -5,15 +5,11 @@ use anyhow::{Result, anyhow};
use nostr_sdk::prelude::*;
use tokio::sync::Mutex;
use crate::env::Env;
#[derive(Clone)]
pub struct Robot {
secret: String,
name: String,
description: String,
picture: String,
outbox_client: Client,
indexer_client: Client,
messaging_client: Client,
env: Env,
outbox_cache: std::sync::Arc<Mutex<HashMap<String, CacheEntry>>>,
dm_cache: std::sync::Arc<Mutex<HashMap<String, CacheEntry>>>,
}
@@ -25,84 +21,61 @@ struct CacheEntry {
}
impl Robot {
pub async fn new() -> Result<Self> {
let secret = std::env::var("ROBOT_SECRET").unwrap_or_default();
if secret.trim().is_empty() {
return Err(anyhow!("ROBOT_SECRET is required"));
}
let name = std::env::var("ROBOT_NAME").unwrap_or_default();
let description = std::env::var("ROBOT_DESCRIPTION").unwrap_or_default();
let picture = std::env::var("ROBOT_PICTURE").unwrap_or_default();
let outbox_relays = split_relays("ROBOT_OUTBOX_RELAYS");
let indexer_relays = split_relays("ROBOT_INDEXER_RELAYS");
let messaging_relays = split_relays("ROBOT_MESSAGING_RELAYS");
if outbox_relays.is_empty() {
return Err(anyhow!("ROBOT_OUTBOX_RELAYS is required"));
}
if indexer_relays.is_empty() {
return Err(anyhow!("ROBOT_INDEXER_RELAYS is required"));
}
if messaging_relays.is_empty() {
return Err(anyhow!("ROBOT_MESSAGING_RELAYS is required"));
}
let outbox_client = client_with_relays(&secret, &outbox_relays).await?;
let indexer_client = client_with_relays(&secret, &indexer_relays).await?;
let messaging_client = client_with_relays(&secret, &messaging_relays).await?;
pub async fn new(env: &Env) -> Result<Self> {
let robot = Self {
secret,
name,
description,
picture,
outbox_client,
indexer_client,
messaging_client,
env: env.clone(),
outbox_cache: std::sync::Arc::new(Mutex::new(HashMap::new())),
dm_cache: std::sync::Arc::new(Mutex::new(HashMap::new())),
};
robot
.publish_identity(&outbox_relays, &messaging_relays)
.await?;
robot.publish_identity().await?;
Ok(robot)
}
async fn make_client(&self, relays: &[String]) -> Result<Client> {
let client = Client::new(self.env.keys.clone());
for relay in relays {
client.add_relay(relay).await?;
}
client.connect().await;
Ok(client)
}
async fn publish_identity(
&self,
outbox_relays: &[String],
messaging_relays: &[String],
) -> Result<()> {
let mut metadata = Metadata::new();
if !self.name.is_empty() {
metadata = metadata.name(&self.name);
if !self.env.robot_name.is_empty() {
metadata = metadata.name(&self.env.robot_name);
}
if !self.description.is_empty() {
metadata = metadata.about(&self.description);
if !self.env.robot_description.is_empty() {
metadata = metadata.about(&self.env.robot_description);
}
if !self.picture.is_empty() {
metadata = metadata.picture(Url::parse(&self.picture)?);
if !self.env.robot_picture.is_empty() {
metadata = metadata.picture(Url::parse(&self.env.robot_picture)?);
}
self.outbox_client
let outbox_client = self.make_client(&self.env.robot_outbox_relays).await?;
let indexer_client = self.make_client(&self.env.robot_indexer_relays).await?;
outbox_client
.send_event_builder(EventBuilder::metadata(&metadata))
.await?;
let outbox_tags = outbox_relays
let outbox_tags = self.env.robot_outbox_relays
.iter()
.map(|r| Tag::parse(["r", r.as_str()]))
.collect::<std::result::Result<Vec<_>, _>>()?;
self.outbox_client
outbox_client
.send_event_builder(EventBuilder::new(Kind::Custom(10002), "").tags(outbox_tags))
.await?;
let messaging_tags = messaging_relays
let messaging_tags = self.env.robot_messaging_relays
.iter()
.map(|r| Tag::parse(["relay", r.as_str()]))
.collect::<std::result::Result<Vec<_>, _>>()?;
self.indexer_client
indexer_client
.send_event_builder(EventBuilder::new(Kind::Custom(10050), "").tags(messaging_tags))
.await?;
@@ -123,14 +96,8 @@ impl Robot {
}
let recipient_pubkey = PublicKey::parse(recipient)?;
let client = self.messaging_client.clone();
for relay in dm_relays {
let _ = client.add_relay(relay).await;
}
client.connect().await;
client
.send_private_msg(recipient_pubkey, message, [])
.await?;
let client = self.make_client(&dm_relays).await?;
client.send_private_msg(recipient_pubkey, message, []).await?;
Ok(())
}
@@ -141,10 +108,8 @@ impl Robot {
let pubkey = PublicKey::parse(recipient)?;
let filter = Filter::new().author(pubkey).kind(Kind::Custom(10002));
let events = self
.indexer_client
.fetch_events(filter, Duration::from_secs(5))
.await?;
let client = self.make_client(&self.env.robot_indexer_relays).await?;
let events = client.fetch_events(filter, Duration::from_secs(5)).await?;
let mut relays = Vec::new();
if let Some(event) = events.into_iter().max_by_key(|e| e.created_at) {
@@ -163,11 +128,8 @@ impl Robot {
pub async fn fetch_nostr_name(&self, pubkey: &str) -> Option<String> {
let pubkey = PublicKey::parse(pubkey).ok()?;
let filter = Filter::new().author(pubkey).kind(Kind::Metadata).limit(1);
let events = self
.indexer_client
.fetch_events(filter, Duration::from_secs(5))
.await
.ok()?;
let client = self.make_client(&self.env.robot_indexer_relays).await.ok()?;
let events = client.fetch_events(filter, Duration::from_secs(5)).await.ok()?;
let event = events.into_iter().max_by_key(|e| e.created_at)?;
let content: serde_json::Value = serde_json::from_str(&event.content).ok()?;
let name = content
@@ -189,13 +151,7 @@ impl Robot {
}
let pubkey = PublicKey::parse(recipient)?;
let keys = Keys::parse(&self.secret)?;
let client = Client::new(keys);
for relay in outbox_relays {
client.add_relay(relay).await?;
}
client.connect().await;
let client = self.make_client(outbox_relays).await?;
let filter = Filter::new().author(pubkey).kind(Kind::Custom(10050));
let events = client.fetch_events(filter, Duration::from_secs(5)).await?;
@@ -214,37 +170,6 @@ impl Robot {
}
}
fn split_relays(key: &str) -> Vec<String> {
std::env::var(key)
.unwrap_or_default()
.split(',')
.map(|v| normalize_relay_url(v.trim()))
.filter(|v| !v.is_empty())
.collect()
}
fn normalize_relay_url(url: &str) -> String {
if url.is_empty() {
return String::new();
}
if url.starts_with("ws://") || url.starts_with("wss://") {
url.to_string()
} else {
format!("wss://{url}")
}
}
async fn client_with_relays(secret: &str, relays: &[String]) -> Result<Client> {
let keys = Keys::parse(secret)?;
let client = Client::new(keys);
for relay in relays {
client.add_relay(relay).await?;
}
client.connect().await;
Ok(client)
}
async fn get_cached(
cache: &std::sync::Arc<Mutex<HashMap<String, CacheEntry>>>,
key: &str,
@@ -273,23 +198,3 @@ async fn set_cached(
},
);
}
#[cfg(test)]
impl Robot {
pub fn test_stub() -> Self {
let keys = Keys::generate();
let client = Client::new(keys);
Self {
secret: String::new(),
name: String::new(),
description: String::new(),
picture: String::new(),
outbox_client: client.clone(),
indexer_client: client.clone(),
messaging_client: client,
outbox_cache: std::sync::Arc::new(Mutex::new(HashMap::new())),
dm_cache: std::sync::Arc::new(Mutex::new(HashMap::new())),
}
}
}
-14
View File
@@ -73,20 +73,6 @@ pub struct Stripe {
}
impl Stripe {
/// Builds the client from the environment: `STRIPE_SECRET_KEY` and
/// `STRIPE_WEBHOOK_SECRET`, both required. Panics if either is missing or blank.
pub fn from_env() -> Self {
let secret_key = std::env::var("STRIPE_SECRET_KEY").unwrap_or_default();
if secret_key.trim().is_empty() {
panic!("missing STRIPE_SECRET_KEY environment variable");
}
let webhook_secret = std::env::var("STRIPE_WEBHOOK_SECRET").unwrap_or_default();
if webhook_secret.trim().is_empty() {
panic!("missing STRIPE_WEBHOOK_SECRET environment variable");
}
Self::new(secret_key, webhook_secret)
}
pub fn new(secret_key: String, webhook_secret: String) -> Self {
Self {
secret_key,
+1
View File
@@ -1,2 +1,3 @@
- [ ] Split web utilities and controllers. Use decorators for implementing auth
- [ ] Fix billing by using stripe as a backend to do proration, then mark invoices paid manually when using bitcoin.
- [ ] Send a payment link instead of an invoice so we can generate/pay on the fly