forked from coracle/caravel
Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7134915665 | |||
| cfa52d739f | |||
| 6abe62b569 | |||
| cd7b84439e | |||
| 1c3e0d619a | |||
| 5590b14074 | |||
| 26f05e8b8f | |||
| 066c91a4d1 | |||
| 3ed021214a |
@@ -1,4 +1,5 @@
|
||||
ref
|
||||
todo.md
|
||||
node_modules
|
||||
target
|
||||
data
|
||||
|
||||
+8
-12
@@ -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
|
||||
|
||||
Generated
+13
@@ -210,6 +210,7 @@ dependencies = [
|
||||
"nostr-sdk",
|
||||
"nwc",
|
||||
"rand 0.8.5",
|
||||
"regex",
|
||||
"reqwest",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -1894,6 +1895,18 @@ dependencies = [
|
||||
"bitflags",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "regex"
|
||||
version = "1.12.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276"
|
||||
dependencies = [
|
||||
"aho-corasick",
|
||||
"memchr",
|
||||
"regex-automata",
|
||||
"regex-syntax",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "regex-automata"
|
||||
version = "0.4.14"
|
||||
|
||||
@@ -24,6 +24,7 @@ hmac = "0.12"
|
||||
sha2 = "0.10"
|
||||
dotenvy = "0.15.7"
|
||||
base64 = "0.22"
|
||||
regex = "1"
|
||||
|
||||
[dev-dependencies]
|
||||
tower = { version = "0.5", features = ["util"] }
|
||||
|
||||
@@ -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_ |
|
||||
|
||||
@@ -2,20 +2,20 @@
|
||||
|
||||
Billing encapsulates logic related to synchronizing state with Stripe, processing payments via NWC, and managing subscription lifecycle.
|
||||
|
||||
It owns the domain logic only: every raw Stripe REST call goes through the `Stripe` wrapper (see `spec/stripe.md`), and every Lightning (NWC) wallet operation and the fiat-minor-units → millisatoshi conversion go through the helpers in `spec/bitcoin.md`.
|
||||
It owns the domain logic only: Stripe REST calls go through `Stripe` (see `spec/stripe.md`), NWC wallet operations through `Wallet` (see `spec/wallet.md`), and fiat → msats conversion through `bitcoin` (see `spec/bitcoin.md`).
|
||||
|
||||
Members:
|
||||
|
||||
- `stripe: Stripe` - thin wrapper around the Stripe REST API (see `spec/stripe.md`), built from `STRIPE_SECRET_KEY` / `STRIPE_WEBHOOK_SECRET`
|
||||
- `bitcoin: Bitcoin` - the Bitcoin-facing config: system NWC wallet (`NWC_URL`) plus the BTC price-feed HTTP client (see `spec/bitcoin.md`)
|
||||
- `stripe: Stripe` - Stripe REST wrapper (see `spec/stripe.md`)
|
||||
- `wallet: Wallet` - the system NWC wallet, used to issue and look up bolt11 invoices (see `spec/wallet.md`)
|
||||
- `query: Query`
|
||||
- `command: Command`
|
||||
- `robot: Robot`
|
||||
|
||||
## `pub fn new(query: Query, command: Command, robot: Robot) -> Self`
|
||||
|
||||
- Builds `stripe` via `Stripe::from_env()` and `bitcoin` via `Bitcoin::from_env()`
|
||||
- Panics if `STRIPE_SECRET_KEY` or `STRIPE_WEBHOOK_SECRET` is missing/empty (`NWC_URL` is optional)
|
||||
- Builds `stripe` via `Stripe::from_env()` and `wallet` from `NWC_URL`
|
||||
- Panics if `STRIPE_SECRET_KEY`, `STRIPE_WEBHOOK_SECRET`, or `NWC_URL` is missing or malformed
|
||||
|
||||
## `pub fn start(&self)`
|
||||
|
||||
@@ -72,7 +72,7 @@ Stripe uses **pay-in-advance** by default: when a subscription is first created,
|
||||
|
||||
## `pub async fn reconcile_manual_lightning_invoice(&self, invoice_id: &str, invoice: &Value) -> Result<Value, InvoiceLookupError>`
|
||||
|
||||
If `invoice.status == "open"` and a manual-Lightning bolt11 was previously issued for it (`query.get_invoice_manual_lightning_bolt11`), check whether that bolt11 has settled (`bitcoin.system_wallet().invoice_settled(...)`). If it has, mark the Stripe invoice paid out of band (`stripe.pay_invoice_out_of_band`) and return the refreshed invoice. On any lookup/settlement failure, log and return the invoice unchanged.
|
||||
If `invoice.status == "open"` and a manual-Lightning bolt11 was previously issued for it (`query.get_invoice_manual_lightning_bolt11`), check whether that bolt11 has settled (`self.wallet.is_settled(...)`). If it has, mark the Stripe invoice paid out of band (`stripe.pay_invoice_out_of_band`) and return the refreshed invoice. On any lookup/settlement failure, log and return the invoice unchanged.
|
||||
|
||||
## `pub async fn get_or_create_manual_lightning_bolt11(&self, invoice_id: &str, tenant_pubkey: &str, amount_due_minor: i64, currency: &str) -> Result<String>`
|
||||
|
||||
@@ -81,8 +81,8 @@ If `invoice.status == "open"` and a manual-Lightning bolt11 was previously issue
|
||||
|
||||
## `pub async fn create_bolt11(&self, amount_due_minor: i64, currency: &str) -> Result<String>`
|
||||
|
||||
- Converts the fiat amount to msats via `bitcoin.fiat_minor_to_msats` (fetches the live BTC spot price — see `spec/bitcoin.md`)
|
||||
- Issues a bolt11 invoice for that amount on the system NWC wallet (`bitcoin.system_wallet().make_invoice(...)`)
|
||||
- Converts the fiat amount to msats via `bitcoin::fiat_to_msats` (fetches the live BTC spot price — see `spec/bitcoin.md`)
|
||||
- Issues a bolt11 invoice for that amount on the system NWC wallet (`self.wallet.make_invoice(...)`)
|
||||
- Returns the bolt11 invoice string
|
||||
|
||||
## `pub async fn pay_outstanding_nwc_invoices(&self, tenant: &Tenant) -> Result<()>`
|
||||
@@ -111,7 +111,7 @@ Attempts Stripe-side collection for open invoices when the tenant has a card on
|
||||
Attempts to pay a new subscription invoice. Because Stripe defaults to pay-in-advance, this webhook fires immediately when a subscription is created (i.e. when a paid relay is added or a plan is upgraded). Payment priority:
|
||||
|
||||
1. **NWC auto-pay**: If the tenant has a `nwc_url`, run `nwc_pay_invoice` (decrypting the tenant's stored `nwc_url` first):
|
||||
- The system wallet (`bitcoin.system_wallet()`) issues a bolt11 invoice for the fiat amount; the tenant's wallet (`Wallet::parse` of the decrypted URL) pays it. A `pending` row in `invoice_nwc_payment` guards against double-charging across retries.
|
||||
- The system wallet (`self.wallet`) issues a bolt11 invoice for the fiat amount; the tenant's wallet (`Wallet::from_url` of the decrypted URL) pays it. A `pending` row in `invoice_nwc_payment` guards against double-charging across retries.
|
||||
- If payment succeeds: mark the Stripe invoice paid out of band (`stripe.pay_invoice_out_of_band`) and clear `nwc_error` via `command.clear_tenant_nwc_error`. Done.
|
||||
- If it fails before any charge could have gone out: set `nwc_error` on the tenant via `command.set_tenant_nwc_error`, and fall through to the next option (carrying a short summary of the error into the eventual DM).
|
||||
- If it fails after a charge may have gone out (needs reconciliation): set `nwc_error` and return the error without falling through — a human must reconcile before any retry.
|
||||
|
||||
+6
-57
@@ -1,62 +1,11 @@
|
||||
# `bitcoin` — Bitcoin / Lightning helpers
|
||||
# `bitcoin` — fiat ↔ Bitcoin conversion
|
||||
|
||||
Small wrappers around the Bitcoin-facing services the app talks to: Nostr Wallet Connect (NWC) wallets for Lightning invoices/payments, and a fiat↔BTC spot price feed, plus the fiat-minor-units → millisatoshi conversion that ties them together. The billing-specific orchestration (which wallet pays which invoice, double-charge guards, DMs, etc.) lives in `spec/billing.md`.
|
||||
Free async helpers for pricing fiat amounts in Lightning units against a live BTC spot price. The NWC wallet lives in `spec/wallet.md`; billing orchestration lives in `spec/billing.md`.
|
||||
|
||||
## `pub struct Bitcoin`
|
||||
## `pub async fn fiat_to_msats(amount_fiat_minor: i64, currency: &str) -> Result<u64>`
|
||||
|
||||
Owns the app's Bitcoin-facing configuration: the system NWC wallet URL (the wallet used to *receive* payments — issue and look up bolt11 invoices) and the HTTP client used for the fiat↔BTC spot price feed.
|
||||
Converts a Stripe-style minor-unit fiat amount to millisatoshis using the live BTC spot price for `currency` and Stripe's per-currency decimal exponent (most currencies 2; `JPY`/`KRW`/… 0; `BHD`/`KWD`/… 3).
|
||||
|
||||
Members:
|
||||
## `pub async fn get_bitcoin_price(currency: &str) -> Result<f64>`
|
||||
|
||||
- `system_nwc_url: String` - from `NWC_URL`
|
||||
- `http: reqwest::Client`
|
||||
|
||||
### `pub fn from_env() -> Self`
|
||||
|
||||
Reads `NWC_URL` (the system / receiving wallet) and constructs a fresh `reqwest::Client`. Unlike the Stripe keys, `NWC_URL` is **optional**: if it's unset, Lightning operations fail at use time with a clear error rather than panicking at startup. This is what `Billing::new` calls.
|
||||
|
||||
### `pub fn system_wallet(&self) -> Result<Wallet>`
|
||||
|
||||
Returns the system `Wallet` (parsed from `system_nwc_url` with label `"system"`). Errors if `NWC_URL` is unset or malformed.
|
||||
|
||||
### `pub async fn fiat_minor_to_msats(&self, amount_due_minor: i64, currency: &str) -> Result<u64>`
|
||||
|
||||
Fetches the live BTC spot price (via `btc_spot_price_from_base` against Coinbase) for `currency` and converts `amount_due_minor` to millisatoshis via `fiat_minor_to_msats_from_quote`. `currency` is upper-cased before use.
|
||||
|
||||
## `pub struct Wallet`
|
||||
|
||||
A handle to a single NWC wallet. Each operation opens a fresh NWC connection and tears it down (`shutdown`) afterwards.
|
||||
|
||||
Member:
|
||||
|
||||
- `uri: NostrWalletConnectURI` - the parsed `nostr+walletconnect://…` URI
|
||||
|
||||
### `pub fn parse(uri: &str, label: &str) -> Result<Self>`
|
||||
|
||||
Parses an `nostr+walletconnect://` URI. `label` (e.g. `"system"` / `"tenant"`) only flavours the error message — `invalid {label} NWC URL` — so callers can tell which wallet was misconfigured.
|
||||
|
||||
### `pub async fn make_invoice(&self, amount_msats: u64, description: &str) -> Result<String>`
|
||||
|
||||
Issues a bolt11 invoice for `amount_msats` with the given description. Returns the bolt11 string.
|
||||
|
||||
### `pub async fn pay_invoice(&self, bolt11: String) -> Result<()>`
|
||||
|
||||
Pays a bolt11 invoice.
|
||||
|
||||
### `pub async fn invoice_settled(&self, bolt11: &str) -> Result<bool>`
|
||||
|
||||
Looks up a bolt11 invoice (previously issued by this wallet) and returns whether it has settled — true if the transaction state is `Settled` or `settled_at` is present.
|
||||
|
||||
## `pub async fn btc_spot_price_from_base(http: &reqwest::Client, api_base: &str, currency: &str) -> Result<f64>`
|
||||
|
||||
Fetches the BTC spot price denominated in `currency` (an ISO-4217 code) from a Coinbase-shaped API at `api_base` (`{api_base}/BTC-{currency}/spot`); production callers reach it via `Bitcoin::fiat_minor_to_msats` with the real Coinbase base, while tests can point it at a stub. Errors if the quote is missing, unparseable, or non-positive.
|
||||
|
||||
## `pub fn fiat_minor_to_msats_from_quote(amount_due_minor: i64, currency: &str, btc_price_in_fiat: f64) -> Result<u64>`
|
||||
|
||||
Converts a fiat amount expressed in minor units (cents, etc.) to millisatoshis, given a BTC price quote in that currency.
|
||||
|
||||
- Errors if `amount_due_minor <= 0` or `btc_price_in_fiat <= 0`
|
||||
- Converts minor units to a major-unit amount using the currency's decimal exponent (most currencies 2; `JPY`/`KRW`/… 0; `BHD`/`KWD`/… 3 — following Stripe's currency conventions; an unrecognized or malformed code is an error)
|
||||
- `msats = (amount_fiat / btc_price_in_fiat) * 100_000_000_000`
|
||||
- Rounds **up** so we never under-charge, but snaps to the nearest integer when within `1e-6` of one to avoid floating-point artifacts at integer boundaries
|
||||
- Errors if the result is non-finite, non-positive, or exceeds `u64::MAX`
|
||||
Returns the current BTC spot price in `currency`, fetched from Coinbase's public spot-price endpoint.
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
# `pub struct Wallet`
|
||||
|
||||
A handle to a single Nostr Wallet Connect (NWC) wallet. `Billing` holds one as its system wallet (receives — issues and looks up invoices); tenant wallets (pay invoices) are constructed ad-hoc from the decrypted `tenant.nwc_url` at the call site.
|
||||
|
||||
Member:
|
||||
|
||||
- `url: NostrWalletConnectURI` — the parsed `nostr+walletconnect://…` URI
|
||||
|
||||
## `pub fn from_url(url: &str) -> Result<Self>`
|
||||
|
||||
Parses an `nostr+walletconnect://` URI.
|
||||
|
||||
## `pub async fn make_invoice(&self, amount_msats: u64, description: &str) -> Result<String>`
|
||||
|
||||
Issues a bolt11 invoice for `amount_msats` and returns it.
|
||||
|
||||
## `pub async fn pay_invoice(&self, bolt11: String) -> Result<()>`
|
||||
|
||||
Pays a bolt11 invoice.
|
||||
|
||||
## `pub async fn is_settled(&self, bolt11: &str) -> Result<bool>`
|
||||
|
||||
Returns whether a bolt11 invoice (previously issued by this wallet) has settled.
|
||||
+133
-1103
File diff suppressed because it is too large
Load Diff
+23
-225
@@ -1,12 +1,14 @@
|
||||
use anyhow::{Result, anyhow};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use crate::bitcoin::{Bitcoin, Wallet};
|
||||
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;
|
||||
use crate::stripe::{InvoiceLookupError, Stripe};
|
||||
use crate::wallet::Wallet;
|
||||
|
||||
const MANUAL_LIGHTNING_PAYMENT_DM: &str = "Payment is due for your relay subscription. Please visit the application to complete a manual Lightning payment.";
|
||||
const NWC_ERROR_DM_PREFIX: &str = "NWC auto-payment failed:";
|
||||
@@ -22,17 +24,22 @@ enum NwcInvoicePaymentOutcome {
|
||||
#[derive(Clone)]
|
||||
pub struct Billing {
|
||||
stripe: Stripe,
|
||||
bitcoin: Bitcoin,
|
||||
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(),
|
||||
bitcoin: Bitcoin::from_env(),
|
||||
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,
|
||||
@@ -135,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;
|
||||
};
|
||||
@@ -369,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,
|
||||
@@ -713,12 +720,8 @@ impl Billing {
|
||||
}
|
||||
|
||||
pub async fn create_bolt11(&self, amount_due_minor: i64, currency: &str) -> Result<String> {
|
||||
let amount_msats = self
|
||||
.bitcoin
|
||||
.fiat_minor_to_msats(amount_due_minor, currency)
|
||||
.await?;
|
||||
self.bitcoin
|
||||
.system_wallet()?
|
||||
let amount_msats = bitcoin::fiat_to_msats(amount_due_minor, currency).await?;
|
||||
self.wallet
|
||||
.make_invoice(amount_msats, LIGHTNING_INVOICE_DESCRIPTION)
|
||||
.await
|
||||
}
|
||||
@@ -728,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
|
||||
@@ -891,7 +894,7 @@ impl Billing {
|
||||
}
|
||||
|
||||
async fn is_manual_lightning_invoice_settled(&self, bolt11: &str) -> Result<bool> {
|
||||
self.bitcoin.system_wallet()?.invoice_settled(bolt11).await
|
||||
self.wallet.is_settled(bolt11).await
|
||||
}
|
||||
|
||||
/// Charges a Stripe invoice over Lightning: the system wallet issues a bolt11
|
||||
@@ -912,20 +915,13 @@ impl Billing {
|
||||
return Ok(existing_outcome);
|
||||
}
|
||||
|
||||
let amount_msats = match self
|
||||
.bitcoin
|
||||
.fiat_minor_to_msats(amount_due_minor, currency)
|
||||
.await
|
||||
{
|
||||
Ok(amount_msats) => amount_msats,
|
||||
let amount_msats = match bitcoin::fiat_to_msats(amount_due_minor, currency).await {
|
||||
Ok(msats) => msats,
|
||||
Err(error) => return Ok(NwcInvoicePaymentOutcome::Fallback(error)),
|
||||
};
|
||||
|
||||
let system_wallet = match self.bitcoin.system_wallet() {
|
||||
Ok(wallet) => wallet,
|
||||
Err(error) => return Ok(NwcInvoicePaymentOutcome::Fallback(error)),
|
||||
};
|
||||
let bolt11 = match system_wallet
|
||||
let bolt11 = match self
|
||||
.wallet
|
||||
.make_invoice(amount_msats, LIGHTNING_INVOICE_DESCRIPTION)
|
||||
.await
|
||||
{
|
||||
@@ -933,7 +929,7 @@ impl Billing {
|
||||
Err(error) => return Ok(NwcInvoicePaymentOutcome::Fallback(error)),
|
||||
};
|
||||
|
||||
let tenant_wallet = match Wallet::parse(tenant_nwc_url, "tenant") {
|
||||
let tenant_wallet = match Wallet::from_url(tenant_nwc_url) {
|
||||
Ok(wallet) => wallet,
|
||||
Err(error) => return Ok(NwcInvoicePaymentOutcome::Fallback(error)),
|
||||
};
|
||||
@@ -1050,202 +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") },
|
||||
}
|
||||
}
|
||||
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
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 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 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(
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
||||
+11
-183
@@ -1,51 +1,11 @@
|
||||
//! Small wrappers around the Bitcoin-facing services this app talks to: Nostr
|
||||
//! Wallet Connect wallets (for Lightning invoices/payments) and a fiat↔BTC spot
|
||||
//! price feed, plus the fiat-minor-units → millisatoshi conversion that ties them
|
||||
//! together. The billing-specific orchestration lives in [`crate::billing`].
|
||||
|
||||
use anyhow::{Result, anyhow};
|
||||
use nwc::prelude::{
|
||||
LookupInvoiceRequest, LookupInvoiceResponse, MakeInvoiceRequest, NWC, NostrWalletConnectURI,
|
||||
PayInvoiceRequest, TransactionState,
|
||||
};
|
||||
|
||||
const COINBASE_SPOT_API: &str = "https://api.coinbase.com/v2/prices";
|
||||
/// Millisatoshis per bitcoin.
|
||||
const MSATS_PER_BTC: f64 = 100_000_000_000.0;
|
||||
|
||||
/// Owns the app's Bitcoin-facing configuration: the system NWC wallet URL (the
|
||||
/// wallet used to *receive* payments — issue and look up bolt11 invoices) and the
|
||||
/// HTTP client used for the fiat↔BTC spot price feed.
|
||||
#[derive(Clone)]
|
||||
pub struct Bitcoin {
|
||||
system_nwc_url: String,
|
||||
http: reqwest::Client,
|
||||
}
|
||||
|
||||
impl Bitcoin {
|
||||
/// Reads `NWC_URL` (the system / receiving wallet). Unlike the Stripe keys this
|
||||
/// is optional: if it's unset, Lightning operations fail at use time with a
|
||||
/// clear error rather than at startup.
|
||||
pub fn from_env() -> Self {
|
||||
Self {
|
||||
system_nwc_url: std::env::var("NWC_URL").unwrap_or_default(),
|
||||
http: reqwest::Client::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The system wallet — issues and looks up the bolt11 invoices we want paid to
|
||||
/// us. Errors if `NWC_URL` is unset or malformed.
|
||||
pub fn system_wallet(&self) -> Result<Wallet> {
|
||||
Wallet::parse(&self.system_nwc_url, "system")
|
||||
}
|
||||
|
||||
/// Fetches the live BTC spot price and converts a fiat amount in minor units
|
||||
/// (cents, etc.) to millisatoshis.
|
||||
pub async fn fiat_minor_to_msats(&self, amount_due_minor: i64, currency: &str) -> Result<u64> {
|
||||
let currency = currency.to_uppercase();
|
||||
let btc_price = btc_spot_price_from_base(&self.http, COINBASE_SPOT_API, ¤cy).await?;
|
||||
fiat_minor_to_msats_from_quote(amount_due_minor, ¤cy, btc_price)
|
||||
}
|
||||
pub async fn fiat_to_msats(amount_fiat_minor: i64, currency: &str) -> Result<u64> {
|
||||
let price = get_bitcoin_price(¤cy.to_uppercase()).await?;
|
||||
let divisor = 10_f64.powi(currency_minor_exponent(currency)? as i32);
|
||||
let amount_fiat = (amount_fiat_minor as f64) / divisor;
|
||||
let amount_msats = (amount_fiat / price * 100_000_000_000.0).round();
|
||||
Ok(amount_msats as u64)
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
@@ -58,123 +18,17 @@ struct CoinbaseSpotPriceData {
|
||||
amount: String,
|
||||
}
|
||||
|
||||
/// A handle to a single Nostr Wallet Connect wallet. Each operation opens a fresh
|
||||
/// connection and tears it down afterwards.
|
||||
pub struct Wallet {
|
||||
uri: NostrWalletConnectURI,
|
||||
}
|
||||
|
||||
impl Wallet {
|
||||
/// Parses an `nostr+walletconnect://` URI. `label` only flavours the error
|
||||
/// message so callers can tell which wallet was misconfigured.
|
||||
pub fn parse(uri: &str, label: &str) -> Result<Self> {
|
||||
let uri = uri
|
||||
.parse::<NostrWalletConnectURI>()
|
||||
.map_err(|_| anyhow!("invalid {label} NWC URL"))?;
|
||||
Ok(Self { uri })
|
||||
}
|
||||
|
||||
/// Issues a bolt11 invoice for `amount_msats`.
|
||||
pub async fn make_invoice(&self, amount_msats: u64, description: &str) -> Result<String> {
|
||||
let nwc = NWC::new(self.uri.clone());
|
||||
let result = nwc
|
||||
.make_invoice(MakeInvoiceRequest {
|
||||
amount: amount_msats,
|
||||
description: Some(description.to_string()),
|
||||
description_hash: None,
|
||||
expiry: None,
|
||||
})
|
||||
.await;
|
||||
nwc.shutdown().await;
|
||||
Ok(result
|
||||
.map_err(|e| anyhow!("failed to create invoice: {e}"))?
|
||||
.invoice)
|
||||
}
|
||||
|
||||
/// Pays a bolt11 invoice.
|
||||
pub async fn pay_invoice(&self, bolt11: String) -> Result<()> {
|
||||
let nwc = NWC::new(self.uri.clone());
|
||||
let result = nwc.pay_invoice(PayInvoiceRequest::new(bolt11)).await;
|
||||
nwc.shutdown().await;
|
||||
result.map(|_| ()).map_err(|e| anyhow!("{e}"))
|
||||
}
|
||||
|
||||
/// Returns whether a bolt11 invoice (previously issued by this wallet) has been
|
||||
/// settled.
|
||||
pub async fn invoice_settled(&self, bolt11: &str) -> Result<bool> {
|
||||
let nwc = NWC::new(self.uri.clone());
|
||||
let result = nwc
|
||||
.lookup_invoice(LookupInvoiceRequest {
|
||||
payment_hash: None,
|
||||
invoice: Some(bolt11.to_string()),
|
||||
})
|
||||
.await;
|
||||
nwc.shutdown().await;
|
||||
let response = result.map_err(|e| anyhow!("failed to lookup invoice: {e}"))?;
|
||||
Ok(lookup_invoice_response_is_settled(&response))
|
||||
}
|
||||
}
|
||||
|
||||
fn lookup_invoice_response_is_settled(response: &LookupInvoiceResponse) -> bool {
|
||||
response.state == Some(TransactionState::Settled) || response.settled_at.is_some()
|
||||
}
|
||||
|
||||
/// Fetches the BTC spot price denominated in `currency` (an ISO-4217 code) from a
|
||||
/// Coinbase-shaped API at `api_base`. Exposed so tests can stub the price feed;
|
||||
/// production callers go through [`Bitcoin::fiat_minor_to_msats`].
|
||||
pub async fn btc_spot_price_from_base(
|
||||
http: &reqwest::Client,
|
||||
api_base: &str,
|
||||
currency: &str,
|
||||
) -> Result<f64> {
|
||||
let pair = format!("BTC-{currency}");
|
||||
let url = format!("{}/{pair}/spot", api_base.trim_end_matches('/'));
|
||||
pub async fn get_bitcoin_price(currency: &str) -> Result<f64> {
|
||||
let http = reqwest::Client::new();
|
||||
let url = format!("https://api.coinbase.com/v2/prices/BTC-{currency}/spot");
|
||||
let resp = http.get(url).send().await?;
|
||||
let body: CoinbaseSpotPriceResponse = resp.error_for_status()?.json().await?;
|
||||
|
||||
let amount = body
|
||||
Ok(body
|
||||
.data
|
||||
.amount
|
||||
.parse::<f64>()
|
||||
.map_err(|e| anyhow!("invalid BTC spot quote for {currency}: {e}"))?;
|
||||
if amount <= 0.0 {
|
||||
return Err(anyhow!(
|
||||
"invalid non-positive BTC spot quote for {currency}"
|
||||
));
|
||||
}
|
||||
Ok(amount)
|
||||
}
|
||||
|
||||
/// Converts a fiat amount expressed in minor units (cents, etc.) to millisatoshis,
|
||||
/// given a BTC price quote in that currency. Rounds up so we never under-charge,
|
||||
/// but snaps to the nearest integer when within a hair of one to avoid floating
|
||||
/// point artifacts at integer boundaries.
|
||||
pub fn fiat_minor_to_msats_from_quote(
|
||||
amount_due_minor: i64,
|
||||
currency: &str,
|
||||
btc_price_in_fiat: f64,
|
||||
) -> Result<u64> {
|
||||
if amount_due_minor <= 0 {
|
||||
return Err(anyhow!("amount_due must be positive"));
|
||||
}
|
||||
if btc_price_in_fiat <= 0.0 {
|
||||
return Err(anyhow!("btc_price_in_fiat must be positive"));
|
||||
}
|
||||
|
||||
let divisor = 10_f64.powi(currency_minor_exponent(currency)? as i32);
|
||||
let amount_fiat = (amount_due_minor as f64) / divisor;
|
||||
let amount_btc = amount_fiat / btc_price_in_fiat;
|
||||
let raw_msats = amount_btc * MSATS_PER_BTC;
|
||||
let amount_msats = if (raw_msats - raw_msats.round()).abs() < 1e-6 {
|
||||
raw_msats.round()
|
||||
} else {
|
||||
raw_msats.ceil()
|
||||
};
|
||||
|
||||
if !amount_msats.is_finite() || amount_msats <= 0.0 || amount_msats > u64::MAX as f64 {
|
||||
return Err(anyhow!("calculated msat amount is out of bounds"));
|
||||
}
|
||||
Ok(amount_msats as u64)
|
||||
.map_err(|e| anyhow!("invalid BTC spot quote for {currency}: {e}"))?)
|
||||
}
|
||||
|
||||
/// Number of decimal places in `currency`'s minor unit, following Stripe's
|
||||
@@ -192,29 +46,3 @@ fn currency_minor_exponent(currency: &str) -> Result<u8> {
|
||||
};
|
||||
Ok(exponent)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::fiat_minor_to_msats_from_quote;
|
||||
|
||||
#[test]
|
||||
fn converts_usd_minor_units_with_quote() {
|
||||
let msats = fiat_minor_to_msats_from_quote(100, "usd", 100_000.0)
|
||||
.expect("conversion should succeed");
|
||||
assert_eq!(msats, 1_000_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn converts_zero_decimal_currency_with_quote() {
|
||||
let msats = fiat_minor_to_msats_from_quote(100, "jpy", 10_000_000.0)
|
||||
.expect("conversion should succeed");
|
||||
assert_eq!(msats, 1_000_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_malformed_currency_code() {
|
||||
// Not three ASCII letters: rejected outright.
|
||||
assert!(fiat_minor_to_msats_from_quote(100, "usdd", 100_000.0).is_err());
|
||||
assert!(fiat_minor_to_msats_from_quote(100, "us1", 100_000.0).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}"))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
+161
-337
@@ -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) {
|
||||
@@ -121,15 +54,17 @@ impl Infra {
|
||||
}
|
||||
|
||||
async fn handle_activity(&self, activity: &Activity) -> Result<()> {
|
||||
let needs_sync = should_sync_relay_activity(activity.activity_type.as_str());
|
||||
let needs_sync = matches!(
|
||||
activity.activity_type.as_str(),
|
||||
"create_relay" | "update_relay" | "activate_relay" | "deactivate_relay" | "fail_relay_sync"
|
||||
);
|
||||
|
||||
if !needs_sync || activity.resource_type != "relay" {
|
||||
if activity.resource_type != "relay" || !needs_sync {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if activity.activity_type == "fail_relay_sync" {
|
||||
self.schedule_relay_sync_retry(&activity.resource_id, "activity")
|
||||
.await?;
|
||||
self.schedule_relay_sync_retry(&activity.resource_id, "activity").await?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
@@ -137,9 +72,7 @@ impl Infra {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let is_new = self.relay_sync_is_new(&relay).await?;
|
||||
self.sync_and_report(&relay, is_new).await;
|
||||
|
||||
self.sync_relay(&relay).await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -154,8 +87,7 @@ impl Infra {
|
||||
|
||||
for relay in relays {
|
||||
if relay.sync_error.trim().is_empty() {
|
||||
let is_new = self.relay_sync_is_new(&relay).await?;
|
||||
self.sync_and_report(&relay, is_new).await;
|
||||
self.sync_relay(&relay).await;
|
||||
} else {
|
||||
self.schedule_relay_sync_retry(&relay.id, source).await?;
|
||||
}
|
||||
@@ -165,10 +97,28 @@ impl Infra {
|
||||
}
|
||||
|
||||
async fn schedule_relay_sync_retry(&self, relay_id: &str, source: &str) -> Result<()> {
|
||||
let activities = self.query.list_activity_for_relay(relay_id).await?;
|
||||
let consecutive_failures = consecutive_sync_failures(&activities);
|
||||
fn get_retry_delay(consecutive_failures: usize) -> Option<Duration> {
|
||||
let retry_attempt = consecutive_failures.max(1);
|
||||
if retry_attempt > RELAY_SYNC_RETRY_MAX_ATTEMPTS {
|
||||
return None;
|
||||
}
|
||||
|
||||
let Some(delay) = relay_sync_retry_delay(consecutive_failures) else {
|
||||
let exponent = (retry_attempt - 1).min(31);
|
||||
let multiplier = 1u64 << exponent;
|
||||
let delay_secs = RELAY_SYNC_RETRY_BASE_DELAY_SECS
|
||||
.saturating_mul(multiplier)
|
||||
.min(RELAY_SYNC_RETRY_MAX_DELAY_SECS);
|
||||
|
||||
Some(Duration::from_secs(delay_secs))
|
||||
}
|
||||
|
||||
let activities = self.query.list_activity_for_relay(relay_id).await?;
|
||||
let consecutive_failures = activities
|
||||
.iter()
|
||||
.take_while(|activity| activity.activity_type == "fail_relay_sync")
|
||||
.count();
|
||||
|
||||
let Some(delay) = get_retry_delay(consecutive_failures) else {
|
||||
tracing::warn!(
|
||||
relay = relay_id,
|
||||
consecutive_failures,
|
||||
@@ -192,40 +142,20 @@ impl Infra {
|
||||
tokio::spawn(async move {
|
||||
tokio::time::sleep(delay).await;
|
||||
|
||||
if let Err(e) = infra.retry_relay_sync(&relay_id).await {
|
||||
tracing::error!(relay = %relay_id, error = %e, "relay sync retry task failed");
|
||||
match infra.query.get_relay(&relay_id).await {
|
||||
Ok(Some(relay)) => infra.sync_relay(&relay).await,
|
||||
Ok(None) => {}
|
||||
Err(e) => {
|
||||
tracing::error!(relay = %relay_id, error = %e, "relay sync retry task failed");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn retry_relay_sync(&self, relay_id: &str) -> Result<()> {
|
||||
let Some(relay) = self.query.get_relay(relay_id).await? else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
if relay.sync_error.trim().is_empty() {
|
||||
tracing::debug!(relay = %relay.id, "skip relay sync retry; relay has no sync_error");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let is_new = self.relay_sync_is_new(&relay).await?;
|
||||
self.sync_and_report(&relay, is_new).await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn relay_sync_is_new(&self, relay: &Relay) -> Result<bool> {
|
||||
if relay.synced == 1 {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let has_completed_sync = self.query.relay_has_completed_sync(&relay.id).await?;
|
||||
Ok(!has_completed_sync)
|
||||
}
|
||||
|
||||
async fn sync_and_report(&self, relay: &Relay, is_new: bool) {
|
||||
match self.sync_relay(relay, is_new).await {
|
||||
async fn sync_relay(&self, relay: &Relay) {
|
||||
match self.try_sync_relay(relay).await {
|
||||
Ok(()) => {
|
||||
tracing::info!(relay = %relay.id, "relay sync succeeded");
|
||||
if let Err(e) = self.command.complete_relay_sync(&relay.id).await {
|
||||
@@ -241,240 +171,134 @@ 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)
|
||||
async fn try_sync_relay(&self, relay: &Relay) -> Result<()> {
|
||||
// A relay is "new" (POST with a freshly generated secret) only if it has
|
||||
// never completed a sync. `synced == 1` short-circuits the activity lookup;
|
||||
// otherwise check the activity history so that a re-sync after an update
|
||||
// (which resets `synced` to 0) PATCHes instead of clobbering the secret.
|
||||
let is_new = relay.synced != 1
|
||||
&& !self.query.relay_has_completed_sync(&relay.id).await?;
|
||||
|
||||
let mut body = serde_json::json!({
|
||||
"host": format!("{}.{}", relay.subdomain, self.env.relay_domain),
|
||||
"schema": relay.schema,
|
||||
"inactive": relay.status == RELAY_STATUS_INACTIVE
|
||||
|| relay.status == RELAY_STATUS_DELINQUENT,
|
||||
"info": {
|
||||
"name": relay.info_name,
|
||||
"icon": relay.info_icon,
|
||||
"description": relay.info_description,
|
||||
"pubkey": relay.tenant,
|
||||
},
|
||||
"policy": {
|
||||
"public_join": relay.policy_public_join == 1,
|
||||
"strip_signatures": relay.policy_strip_signatures == 1,
|
||||
},
|
||||
"groups": { "enabled": relay.groups_enabled == 1 },
|
||||
"management": { "enabled": relay.management_enabled == 1 },
|
||||
"blossom": if relay.blossom_enabled == 1 {
|
||||
serde_json::json!({
|
||||
"enabled": true,
|
||||
"adapter": "s3",
|
||||
"s3": {
|
||||
"endpoint": self.env.blossom_s3_endpoint,
|
||||
"region": self.env.blossom_s3_region,
|
||||
"bucket": self.env.blossom_s3_bucket,
|
||||
"access_key": self.env.blossom_s3_access_key,
|
||||
"secret_key": self.env.blossom_s3_secret_key,
|
||||
"key_prefix": relay.schema,
|
||||
},
|
||||
})
|
||||
} else {
|
||||
serde_json::json!({ "enabled": false })
|
||||
},
|
||||
"livekit": if relay.livekit_enabled == 1 {
|
||||
serde_json::json!({
|
||||
"enabled": true,
|
||||
"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 })
|
||||
},
|
||||
"push": { "enabled": relay.push_enabled == 1 },
|
||||
"roles": {
|
||||
"admin": { "can_manage": true, "can_invite": true },
|
||||
"member": { "can_invite": true },
|
||||
},
|
||||
});
|
||||
|
||||
// Only provide a secret if the relay is new. This allows us to not store the relay secrets on our side.
|
||||
if is_new {
|
||||
if let Some(obj) = body.as_object_mut() {
|
||||
obj.insert(
|
||||
"secret".to_string(),
|
||||
serde_json::Value::String(Keys::generate().secret_key().to_secret_hex()),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let method = if is_new { HttpMethod::POST } else { HttpMethod::PATCH };
|
||||
self.request(method, &format!("relay/{}", relay.id), Some(&body))
|
||||
.await?;
|
||||
Ok(auth)
|
||||
Ok(())
|
||||
}
|
||||
|
||||
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 url = format!("{base}/relay/{relay_id}/members");
|
||||
let auth = self.nip98_auth(&url, HttpMethod::GET).await?;
|
||||
#[derive(serde::Deserialize)]
|
||||
struct MembersResponse {
|
||||
members: Vec<String>,
|
||||
}
|
||||
|
||||
let response = client
|
||||
.get(&url)
|
||||
.header("Authorization", auth)
|
||||
.send()
|
||||
let response = self
|
||||
.request(HttpMethod::GET, &format!("relay/{relay_id}/members"), None)
|
||||
.await?;
|
||||
let parsed: MembersResponse = response.json().await?;
|
||||
Ok(parsed.members)
|
||||
}
|
||||
|
||||
// Internal utilities
|
||||
|
||||
/// Sends an authenticated request to the zooid API at `path` (relative to
|
||||
/// `env.zooid_api_url`). Returns the response on 2xx; bails with the body
|
||||
/// text otherwise.
|
||||
async fn request(
|
||||
&self,
|
||||
method: HttpMethod,
|
||||
path: &str,
|
||||
body: Option<&serde_json::Value>,
|
||||
) -> Result<reqwest::Response> {
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(5))
|
||||
.build()?;
|
||||
let base = self.env.zooid_api_url.trim_end_matches('/');
|
||||
let path = path.trim_start_matches('/');
|
||||
let url = format!("{base}/{path}");
|
||||
let auth = self.env.make_auth(&url, method).await?;
|
||||
|
||||
let reqwest_method = match method {
|
||||
HttpMethod::GET => reqwest::Method::GET,
|
||||
HttpMethod::POST => reqwest::Method::POST,
|
||||
HttpMethod::PUT => reqwest::Method::PUT,
|
||||
HttpMethod::PATCH => reqwest::Method::PATCH,
|
||||
};
|
||||
|
||||
let mut req = client
|
||||
.request(reqwest_method, &url)
|
||||
.header("Authorization", auth);
|
||||
if let Some(body) = body {
|
||||
req = req.json(body);
|
||||
}
|
||||
|
||||
let response = req.send().await?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
let status = response.status();
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
anyhow::bail!("zooid members returned {status}: {body}");
|
||||
let text = response.text().await.unwrap_or_default();
|
||||
anyhow::bail!("zooid {method} {path} returned {status}: {text}");
|
||||
}
|
||||
|
||||
let body = response.text().await?;
|
||||
parse_relay_members_response(&body)
|
||||
}
|
||||
|
||||
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 host = if self.relay_domain.is_empty() {
|
||||
relay.subdomain.clone()
|
||||
} else {
|
||||
format!("{}.{}", relay.subdomain, self.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,
|
||||
})
|
||||
} else {
|
||||
serde_json::json!({ "enabled": false })
|
||||
};
|
||||
|
||||
let body = relay_sync_body(
|
||||
relay,
|
||||
host,
|
||||
livekit,
|
||||
is_new.then(|| Keys::generate().secret_key().to_secret_hex()),
|
||||
self.blossom_s3.as_ref(),
|
||||
);
|
||||
|
||||
let url = format!("{}/relay/{}", base, relay.id);
|
||||
let auth = self
|
||||
.nip98_auth(&url, zooid_sync_http_method(is_new))
|
||||
.await?;
|
||||
|
||||
let request = if is_new {
|
||||
client.post(&url)
|
||||
} else {
|
||||
client.patch(&url)
|
||||
};
|
||||
|
||||
let response = request
|
||||
.header("Authorization", auth)
|
||||
.json(&body)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
let status = response.status();
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
anyhow::bail!("zooid sync returned {status}: {body}")
|
||||
}
|
||||
Ok(())
|
||||
Ok(response)
|
||||
}
|
||||
}
|
||||
|
||||
fn zooid_sync_http_method(is_new: bool) -> HttpMethod {
|
||||
if is_new {
|
||||
HttpMethod::POST
|
||||
} else {
|
||||
HttpMethod::PATCH
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_relay_members_response(body: &str) -> Result<Vec<String>> {
|
||||
let value: serde_json::Value = serde_json::from_str(body)?;
|
||||
|
||||
if let Some(members) = members_from_value(&value) {
|
||||
return Ok(members);
|
||||
}
|
||||
if let Some(members) = value.get("members").and_then(members_from_value) {
|
||||
return Ok(members);
|
||||
}
|
||||
if let Some(members) = value
|
||||
.get("data")
|
||||
.and_then(|data| data.get("members"))
|
||||
.and_then(members_from_value)
|
||||
{
|
||||
return Ok(members);
|
||||
}
|
||||
|
||||
anyhow::bail!("zooid members response missing members array")
|
||||
}
|
||||
|
||||
fn members_from_value(value: &serde_json::Value) -> Option<Vec<String>> {
|
||||
let values = value.as_array()?;
|
||||
values
|
||||
.iter()
|
||||
.map(|value| value.as_str().map(ToString::to_string))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn relay_sync_body(
|
||||
relay: &Relay,
|
||||
host: String,
|
||||
livekit: serde_json::Value,
|
||||
secret: Option<String>,
|
||||
blossom_s3: Option<&BlossomS3Sync>,
|
||||
) -> serde_json::Value {
|
||||
let blossom = blossom_sync_json(relay, blossom_s3);
|
||||
|
||||
let mut body = serde_json::json!({
|
||||
"host": host,
|
||||
"schema": relay.schema,
|
||||
"inactive": relay.status == RELAY_STATUS_INACTIVE
|
||||
|| relay.status == RELAY_STATUS_DELINQUENT,
|
||||
"info": {
|
||||
"name": relay.info_name,
|
||||
"icon": relay.info_icon,
|
||||
"description": relay.info_description,
|
||||
"pubkey": relay.tenant,
|
||||
},
|
||||
"policy": {
|
||||
"public_join": relay.policy_public_join == 1,
|
||||
"strip_signatures": relay.policy_strip_signatures == 1,
|
||||
},
|
||||
"groups": { "enabled": relay.groups_enabled == 1 },
|
||||
"management": { "enabled": relay.management_enabled == 1 },
|
||||
"blossom": blossom,
|
||||
"livekit": livekit,
|
||||
"push": { "enabled": relay.push_enabled == 1 },
|
||||
"roles": {
|
||||
"admin": { "can_manage": true, "can_invite": true },
|
||||
"member": { "can_invite": true },
|
||||
},
|
||||
});
|
||||
|
||||
if let (Some(secret), Some(body_obj)) = (secret, body.as_object_mut()) {
|
||||
body_obj.insert("secret".to_string(), serde_json::Value::String(secret));
|
||||
}
|
||||
|
||||
body
|
||||
}
|
||||
|
||||
fn blossom_sync_json(relay: &Relay, blossom_s3: Option<&BlossomS3Sync>) -> serde_json::Value {
|
||||
let enabled = relay.blossom_enabled == 1;
|
||||
if !enabled {
|
||||
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() {
|
||||
s3_obj.insert(
|
||||
"endpoint".to_string(),
|
||||
serde_json::Value::String(s3.endpoint.clone()),
|
||||
);
|
||||
}
|
||||
s3_obj.insert(
|
||||
"region".to_string(),
|
||||
serde_json::Value::String(s3.region.clone()),
|
||||
);
|
||||
s3_obj.insert(
|
||||
"bucket".to_string(),
|
||||
serde_json::Value::String(s3.bucket.clone()),
|
||||
);
|
||||
s3_obj.insert(
|
||||
"access_key".to_string(),
|
||||
serde_json::Value::String(s3.access_key.clone()),
|
||||
);
|
||||
s3_obj.insert(
|
||||
"secret_key".to_string(),
|
||||
serde_json::Value::String(s3.secret_key.clone()),
|
||||
);
|
||||
s3_obj.insert(
|
||||
"key_prefix".to_string(),
|
||||
serde_json::Value::String(relay.schema.clone()),
|
||||
);
|
||||
|
||||
serde_json::json!({
|
||||
"enabled": true,
|
||||
"adapter": "s3",
|
||||
"s3": serde_json::Value::Object(s3_obj),
|
||||
})
|
||||
}
|
||||
|
||||
fn should_sync_relay_activity(activity_type: &str) -> bool {
|
||||
matches!(
|
||||
activity_type,
|
||||
"create_relay" | "update_relay" | "activate_relay" | "deactivate_relay" | "fail_relay_sync"
|
||||
)
|
||||
}
|
||||
|
||||
fn consecutive_sync_failures(activities: &[Activity]) -> usize {
|
||||
activities
|
||||
.iter()
|
||||
.take_while(|activity| activity.activity_type == "fail_relay_sync")
|
||||
.count()
|
||||
}
|
||||
|
||||
fn relay_sync_retry_delay(consecutive_failures: usize) -> Option<Duration> {
|
||||
let retry_attempt = consecutive_failures.max(1);
|
||||
if retry_attempt > RELAY_SYNC_RETRY_MAX_ATTEMPTS {
|
||||
return None;
|
||||
}
|
||||
|
||||
let exponent = (retry_attempt - 1).min(31);
|
||||
let multiplier = 1u64 << exponent;
|
||||
let delay_secs = RELAY_SYNC_RETRY_BASE_DELAY_SECS
|
||||
.saturating_mul(multiplier)
|
||||
.min(RELAY_SYNC_RETRY_MAX_DELAY_SECS);
|
||||
|
||||
Some(Duration::from_secs(delay_secs))
|
||||
}
|
||||
|
||||
+4
-1
@@ -1,11 +1,14 @@
|
||||
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;
|
||||
pub mod query;
|
||||
pub mod robot;
|
||||
pub mod routes;
|
||||
pub mod stripe;
|
||||
pub mod wallet;
|
||||
pub mod web;
|
||||
|
||||
+18
-22
@@ -1,14 +1,17 @@
|
||||
mod api;
|
||||
mod billing;
|
||||
mod bitcoin;
|
||||
mod cipher;
|
||||
mod command;
|
||||
mod env;
|
||||
mod infra;
|
||||
mod models;
|
||||
mod pool;
|
||||
mod query;
|
||||
mod robot;
|
||||
mod routes;
|
||||
mod stripe;
|
||||
mod wallet;
|
||||
mod web;
|
||||
|
||||
use anyhow::Result;
|
||||
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
|
||||
@@ -18,6 +21,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;
|
||||
@@ -31,30 +35,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<_>>();
|
||||
@@ -71,7 +66,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(())
|
||||
}
|
||||
|
||||
@@ -36,6 +36,20 @@ pub struct Tenant {
|
||||
pub past_due_at: Option<i64>,
|
||||
}
|
||||
|
||||
impl Default for Tenant {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
pubkey: String::new(),
|
||||
nwc_url: String::new(),
|
||||
nwc_error: None,
|
||||
created_at: 0,
|
||||
stripe_customer_id: String::new(),
|
||||
stripe_subscription_id: None,
|
||||
past_due_at: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
|
||||
pub struct Relay {
|
||||
pub id: String,
|
||||
@@ -58,3 +72,29 @@ pub struct Relay {
|
||||
pub push_enabled: i64,
|
||||
pub synced: i64,
|
||||
}
|
||||
|
||||
impl Default for Relay {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
id: String::new(),
|
||||
tenant: String::new(),
|
||||
schema: String::new(),
|
||||
subdomain: String::new(),
|
||||
plan: String::new(),
|
||||
stripe_subscription_item_id: None,
|
||||
status: RELAY_STATUS_ACTIVE.to_string(),
|
||||
sync_error: String::new(),
|
||||
info_name: String::new(),
|
||||
info_icon: String::new(),
|
||||
info_description: String::new(),
|
||||
policy_public_join: 0,
|
||||
policy_strip_signatures: 0,
|
||||
groups_enabled: 1,
|
||||
management_enabled: 1,
|
||||
blossom_enabled: 0,
|
||||
livekit_enabled: 0,
|
||||
push_enabled: 1,
|
||||
synced: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+2
-4
@@ -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
@@ -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
@@ -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())),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::extract::State;
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::api::{Api, AuthedPubkey};
|
||||
use crate::web::{ApiResult, ok};
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct IdentityResponse {
|
||||
pubkey: String,
|
||||
is_admin: bool,
|
||||
}
|
||||
|
||||
pub async fn get_identity(
|
||||
State(api): State<Arc<Api>>,
|
||||
AuthedPubkey(pubkey): AuthedPubkey,
|
||||
) -> ApiResult {
|
||||
let is_admin = api.is_admin(&pubkey);
|
||||
ok(IdentityResponse { pubkey, is_admin })
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::extract::{Path, State};
|
||||
use reqwest::StatusCode;
|
||||
|
||||
use crate::api::{Api, AuthedPubkey};
|
||||
use crate::stripe::InvoiceLookupError;
|
||||
use crate::web::{ApiError, ApiResult, bad_request, internal, not_found, ok};
|
||||
|
||||
pub async fn list_tenant_invoices(
|
||||
State(api): State<Arc<Api>>,
|
||||
AuthedPubkey(auth): AuthedPubkey,
|
||||
Path(pubkey): Path<String>,
|
||||
) -> ApiResult {
|
||||
api.require_admin_or_tenant(&auth, &pubkey)?;
|
||||
let tenant = api.get_tenant_or_404(&pubkey).await?;
|
||||
|
||||
let invoices = api
|
||||
.billing
|
||||
.stripe_list_invoices(&tenant.stripe_customer_id)
|
||||
.await
|
||||
.map_err(internal)?;
|
||||
ok(invoices)
|
||||
}
|
||||
|
||||
pub async fn get_invoice(
|
||||
State(api): State<Arc<Api>>,
|
||||
AuthedPubkey(auth): AuthedPubkey,
|
||||
Path(id): Path<String>,
|
||||
) -> ApiResult {
|
||||
let (invoice, tenant) = api
|
||||
.billing
|
||||
.get_invoice_with_tenant(&id)
|
||||
.await
|
||||
.map_err(map_invoice_lookup_error)?;
|
||||
api.require_admin_or_tenant(&auth, &tenant.pubkey)?;
|
||||
|
||||
let invoice = api
|
||||
.billing
|
||||
.reconcile_manual_lightning_invoice(&id, &invoice)
|
||||
.await
|
||||
.map_err(map_invoice_lookup_error)?;
|
||||
|
||||
ok(invoice)
|
||||
}
|
||||
|
||||
pub async fn get_invoice_bolt11(
|
||||
State(api): State<Arc<Api>>,
|
||||
AuthedPubkey(auth): AuthedPubkey,
|
||||
Path(id): Path<String>,
|
||||
) -> ApiResult {
|
||||
let (invoice, tenant) = api
|
||||
.billing
|
||||
.get_invoice_with_tenant(&id)
|
||||
.await
|
||||
.map_err(map_invoice_lookup_error)?;
|
||||
api.require_admin_or_tenant(&auth, &tenant.pubkey)?;
|
||||
|
||||
let invoice = api
|
||||
.billing
|
||||
.reconcile_manual_lightning_invoice(&id, &invoice)
|
||||
.await
|
||||
.map_err(map_invoice_lookup_error)?;
|
||||
|
||||
let status = invoice["status"].as_str().unwrap_or_default();
|
||||
if status != "open" {
|
||||
return Err(bad_request("invoice-not-open", "invoice is not open"));
|
||||
}
|
||||
|
||||
let amount_due = invoice["amount_due"].as_i64().unwrap_or(0);
|
||||
let currency = invoice["currency"].as_str().unwrap_or("usd");
|
||||
|
||||
let bolt11 = api
|
||||
.billing
|
||||
.get_or_create_manual_lightning_bolt11(&id, &tenant.pubkey, amount_due, currency)
|
||||
.await
|
||||
.map_err(internal)?;
|
||||
ok(serde_json::json!({ "bolt11": bolt11 }))
|
||||
}
|
||||
|
||||
fn map_invoice_lookup_error(error: InvoiceLookupError) -> ApiError {
|
||||
match error {
|
||||
InvoiceLookupError::StripeClient { status } => match status {
|
||||
StatusCode::NOT_FOUND => not_found("invoice not found"),
|
||||
_ => {
|
||||
tracing::warn!(%status, "stripe invoice request returned unexpected status");
|
||||
internal("invoice request rejected")
|
||||
}
|
||||
},
|
||||
InvoiceLookupError::Internal(error) => internal(error),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
pub mod identity;
|
||||
pub mod invoices;
|
||||
pub mod plans;
|
||||
pub mod relays;
|
||||
pub mod stripe;
|
||||
pub mod tenants;
|
||||
@@ -0,0 +1,17 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::extract::{Path, State};
|
||||
|
||||
use crate::api::Api;
|
||||
use crate::web::{ApiResult, not_found, ok};
|
||||
|
||||
pub async fn list_plans(State(api): State<Arc<Api>>) -> ApiResult {
|
||||
ok(api.query.list_plans())
|
||||
}
|
||||
|
||||
pub async fn get_plan(State(api): State<Arc<Api>>, Path(id): Path<String>) -> ApiResult {
|
||||
match api.query.get_plan(&id) {
|
||||
Some(plan) => ok(plan),
|
||||
None => Err(not_found("plan not found")),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,315 @@
|
||||
use std::sync::{Arc, LazyLock};
|
||||
|
||||
use anyhow::Result;
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Path, State},
|
||||
};
|
||||
use regex::Regex;
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::api::{Api, AuthedPubkey};
|
||||
use crate::models::{
|
||||
RELAY_STATUS_ACTIVE, RELAY_STATUS_DELINQUENT, RELAY_STATUS_INACTIVE, Relay,
|
||||
};
|
||||
use crate::web::{
|
||||
ApiError, ApiResult, bad_request, created, internal, map_unique_error, ok,
|
||||
parse_bool_default, unprocessable,
|
||||
};
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct CreateRelayRequest {
|
||||
pub tenant: String,
|
||||
pub subdomain: String,
|
||||
pub plan: String,
|
||||
pub info_name: String,
|
||||
pub info_icon: String,
|
||||
pub info_description: String,
|
||||
pub policy_public_join: i64,
|
||||
pub policy_strip_signatures: i64,
|
||||
pub groups_enabled: i64,
|
||||
pub management_enabled: i64,
|
||||
pub blossom_enabled: i64,
|
||||
pub livekit_enabled: i64,
|
||||
pub push_enabled: i64,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct UpdateRelayRequest {
|
||||
pub subdomain: Option<String>,
|
||||
pub plan: Option<String>,
|
||||
pub info_name: Option<String>,
|
||||
pub info_icon: Option<String>,
|
||||
pub info_description: Option<String>,
|
||||
pub policy_public_join: Option<i64>,
|
||||
pub policy_strip_signatures: Option<i64>,
|
||||
pub groups_enabled: Option<i64>,
|
||||
pub management_enabled: Option<i64>,
|
||||
pub blossom_enabled: Option<i64>,
|
||||
pub livekit_enabled: Option<i64>,
|
||||
pub push_enabled: Option<i64>,
|
||||
}
|
||||
|
||||
pub async fn list_relays(
|
||||
State(api): State<Arc<Api>>,
|
||||
AuthedPubkey(auth): AuthedPubkey,
|
||||
) -> ApiResult {
|
||||
api.require_admin(&auth)?;
|
||||
|
||||
let relays = api.query.list_relays().await.map_err(internal)?;
|
||||
ok(relays)
|
||||
}
|
||||
|
||||
pub async fn get_relay(
|
||||
State(api): State<Arc<Api>>,
|
||||
AuthedPubkey(auth): AuthedPubkey,
|
||||
Path(id): Path<String>,
|
||||
) -> ApiResult {
|
||||
let relay = api.get_relay_or_404(&id).await?;
|
||||
api.require_admin_or_tenant(&auth, &relay.tenant)?;
|
||||
ok(relay)
|
||||
}
|
||||
|
||||
pub async fn list_relay_activity(
|
||||
State(api): State<Arc<Api>>,
|
||||
AuthedPubkey(auth): AuthedPubkey,
|
||||
Path(id): Path<String>,
|
||||
) -> ApiResult {
|
||||
let relay = api.get_relay_or_404(&id).await?;
|
||||
api.require_admin_or_tenant(&auth, &relay.tenant)?;
|
||||
|
||||
let activity = api
|
||||
.query
|
||||
.list_activity_for_relay(&id)
|
||||
.await
|
||||
.map_err(internal)?;
|
||||
ok(serde_json::json!({ "activity": activity }))
|
||||
}
|
||||
|
||||
pub async fn list_relay_members(
|
||||
State(api): State<Arc<Api>>,
|
||||
AuthedPubkey(auth): AuthedPubkey,
|
||||
Path(id): Path<String>,
|
||||
) -> ApiResult {
|
||||
let relay = api.get_relay_or_404(&id).await?;
|
||||
api.require_admin_or_tenant(&auth, &relay.tenant)?;
|
||||
|
||||
let members = fetch_relay_members(&api, &relay).await.map_err(internal)?;
|
||||
ok(serde_json::json!({ "members": members }))
|
||||
}
|
||||
|
||||
pub async fn create_relay(
|
||||
State(api): State<Arc<Api>>,
|
||||
AuthedPubkey(auth): AuthedPubkey,
|
||||
Json(payload): Json<CreateRelayRequest>,
|
||||
) -> ApiResult {
|
||||
api.require_admin_or_tenant(&auth, &payload.tenant)?;
|
||||
|
||||
let relay_id = format!(
|
||||
"{}_{}",
|
||||
payload.subdomain.replace('-', "_"),
|
||||
&uuid::Uuid::new_v4().simple().to_string()[..8]
|
||||
);
|
||||
|
||||
let relay = Relay {
|
||||
id: relay_id.clone(),
|
||||
tenant: payload.tenant,
|
||||
schema: relay_id.clone(),
|
||||
subdomain: payload.subdomain,
|
||||
plan: payload.plan,
|
||||
info_name: payload.info_name,
|
||||
info_icon: payload.info_icon,
|
||||
info_description: payload.info_description,
|
||||
policy_public_join: payload.policy_public_join,
|
||||
policy_strip_signatures: payload.policy_strip_signatures,
|
||||
groups_enabled: payload.groups_enabled,
|
||||
management_enabled: payload.management_enabled,
|
||||
blossom_enabled: payload.blossom_enabled,
|
||||
livekit_enabled: payload.livekit_enabled,
|
||||
push_enabled: payload.push_enabled,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let relay = prepare_relay(&api, relay)?;
|
||||
|
||||
api.command
|
||||
.create_relay(&relay)
|
||||
.await
|
||||
.map_err(map_relay_write_error)?;
|
||||
created(relay)
|
||||
}
|
||||
|
||||
pub async fn update_relay(
|
||||
State(api): State<Arc<Api>>,
|
||||
AuthedPubkey(auth): AuthedPubkey,
|
||||
Path(id): Path<String>,
|
||||
Json(payload): Json<UpdateRelayRequest>,
|
||||
) -> ApiResult {
|
||||
let mut relay = api.get_relay_or_404(&id).await?;
|
||||
api.require_admin_or_tenant(&auth, &relay.tenant)?;
|
||||
|
||||
let current_plan = relay.plan.clone();
|
||||
let requested_plan = payload.plan.clone();
|
||||
|
||||
if let Some(v) = payload.subdomain {
|
||||
relay.subdomain = v;
|
||||
}
|
||||
if let Some(v) = requested_plan.clone() {
|
||||
relay.plan = v;
|
||||
}
|
||||
if let Some(v) = payload.info_name {
|
||||
relay.info_name = v;
|
||||
}
|
||||
if let Some(v) = payload.info_icon {
|
||||
relay.info_icon = v;
|
||||
}
|
||||
if let Some(v) = payload.info_description {
|
||||
relay.info_description = v;
|
||||
}
|
||||
if let Some(v) = payload.policy_public_join {
|
||||
relay.policy_public_join = v;
|
||||
}
|
||||
if let Some(v) = payload.policy_strip_signatures {
|
||||
relay.policy_strip_signatures = v;
|
||||
}
|
||||
if let Some(v) = payload.groups_enabled {
|
||||
relay.groups_enabled = v;
|
||||
}
|
||||
if let Some(v) = payload.management_enabled {
|
||||
relay.management_enabled = v;
|
||||
}
|
||||
if let Some(v) = payload.blossom_enabled {
|
||||
relay.blossom_enabled = v;
|
||||
}
|
||||
if let Some(v) = payload.livekit_enabled {
|
||||
relay.livekit_enabled = v;
|
||||
}
|
||||
if let Some(v) = payload.push_enabled {
|
||||
relay.push_enabled = v;
|
||||
}
|
||||
|
||||
let relay = prepare_relay(&api, relay)?;
|
||||
|
||||
let plan_changed = requested_plan
|
||||
.as_deref()
|
||||
.is_some_and(|requested| requested != current_plan);
|
||||
|
||||
if plan_changed {
|
||||
let selected_plan = api
|
||||
.query
|
||||
.get_plan(&relay.plan)
|
||||
.expect("validated plan must exist");
|
||||
if let Some(limit) = selected_plan.members {
|
||||
let current_members = fetch_relay_members(&api, &relay)
|
||||
.await
|
||||
.map_err(internal)?
|
||||
.len() as i64;
|
||||
|
||||
if current_members > limit {
|
||||
let message = format!(
|
||||
"relay has {current_members} members, which exceeds the {} plan limit of {limit}",
|
||||
selected_plan.name.to_lowercase()
|
||||
);
|
||||
return Err(unprocessable("member-limit-exceeded", &message));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
api.command
|
||||
.update_relay(&relay)
|
||||
.await
|
||||
.map_err(map_relay_write_error)?;
|
||||
ok(relay)
|
||||
}
|
||||
|
||||
pub async fn deactivate_relay(
|
||||
State(api): State<Arc<Api>>,
|
||||
AuthedPubkey(auth): AuthedPubkey,
|
||||
Path(id): Path<String>,
|
||||
) -> ApiResult {
|
||||
let relay = api.get_relay_or_404(&id).await?;
|
||||
api.require_admin_or_tenant(&auth, &relay.tenant)?;
|
||||
|
||||
if relay.status == RELAY_STATUS_DELINQUENT {
|
||||
return Err(bad_request("relay-is-delinquent", "relay is delinquent"));
|
||||
}
|
||||
|
||||
if relay.status == RELAY_STATUS_INACTIVE {
|
||||
return Err(bad_request("relay-is-inactive", "relay is already inactive"));
|
||||
}
|
||||
|
||||
api.command
|
||||
.deactivate_relay(&relay)
|
||||
.await
|
||||
.map_err(internal)?;
|
||||
ok(())
|
||||
}
|
||||
|
||||
pub async fn reactivate_relay(
|
||||
State(api): State<Arc<Api>>,
|
||||
AuthedPubkey(auth): AuthedPubkey,
|
||||
Path(id): Path<String>,
|
||||
) -> ApiResult {
|
||||
let relay = api.get_relay_or_404(&id).await?;
|
||||
api.require_admin_or_tenant(&auth, &relay.tenant)?;
|
||||
|
||||
if relay.status == RELAY_STATUS_DELINQUENT {
|
||||
return Err(bad_request("relay-is-delinquent", "relay is delinquent"));
|
||||
}
|
||||
|
||||
if relay.status == RELAY_STATUS_ACTIVE {
|
||||
return Err(bad_request("relay-is-active", "relay is already active"));
|
||||
}
|
||||
|
||||
api.command.activate_relay(&relay).await.map_err(internal)?;
|
||||
ok(())
|
||||
}
|
||||
|
||||
// --- helpers ----------------------------------------------------------------
|
||||
|
||||
async fn fetch_relay_members(api: &Api, relay: &Relay) -> Result<Vec<String>> {
|
||||
if relay.synced == 0 {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
api.infra.list_relay_members(&relay.id).await
|
||||
}
|
||||
|
||||
const RESERVED_SUBDOMAINS: [&str; 3] = ["api", "admin", "internal"];
|
||||
|
||||
static SUBDOMAIN_RE: LazyLock<Regex> =
|
||||
LazyLock::new(|| Regex::new(r"^[a-z0-9][a-z0-9-]{1,61}[a-z0-9]$").unwrap());
|
||||
|
||||
fn prepare_relay(api: &Api, mut relay: Relay) -> Result<Relay, ApiError> {
|
||||
if !SUBDOMAIN_RE.is_match(&relay.subdomain)
|
||||
|| RESERVED_SUBDOMAINS.contains(&relay.subdomain.as_str()) {
|
||||
return Err(unprocessable("invalid-subdomain", "subdomain is invalid"));
|
||||
}
|
||||
|
||||
let plan = api
|
||||
.query
|
||||
.get_plan(&relay.plan)
|
||||
.ok_or_else(|| unprocessable("invalid-plan", "plan not found"))?;
|
||||
|
||||
if (!plan.blossom && relay.blossom_enabled == 1) || (!plan.livekit && relay.livekit_enabled == 1) {
|
||||
return Err(unprocessable("premium-feature", "feature requires a paid plan"));
|
||||
}
|
||||
|
||||
relay.policy_public_join = parse_bool_default(relay.policy_public_join, 0);
|
||||
relay.policy_strip_signatures = parse_bool_default(relay.policy_strip_signatures, 0);
|
||||
relay.groups_enabled = parse_bool_default(relay.groups_enabled, 1);
|
||||
relay.management_enabled = parse_bool_default(relay.management_enabled, 1);
|
||||
relay.blossom_enabled = parse_bool_default(relay.blossom_enabled, 0);
|
||||
relay.livekit_enabled = parse_bool_default(relay.livekit_enabled, 0);
|
||||
relay.push_enabled = parse_bool_default(relay.push_enabled, 1);
|
||||
|
||||
Ok(relay)
|
||||
}
|
||||
|
||||
fn map_relay_write_error(e: anyhow::Error) -> ApiError {
|
||||
if matches!(map_unique_error(&e), Some("subdomain-exists")) {
|
||||
unprocessable("subdomain-exists", "subdomain already exists")
|
||||
} else {
|
||||
internal(e)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::{
|
||||
body::Bytes,
|
||||
extract::{Path, Query as QueryParams, State},
|
||||
http::HeaderMap,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::api::{Api, AuthedPubkey};
|
||||
use crate::web::{ApiResult, bad_request, internal, ok};
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct StripeSessionParams {
|
||||
return_url: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn create_stripe_session(
|
||||
State(api): State<Arc<Api>>,
|
||||
AuthedPubkey(auth): AuthedPubkey,
|
||||
Path(pubkey): Path<String>,
|
||||
QueryParams(params): QueryParams<StripeSessionParams>,
|
||||
) -> ApiResult {
|
||||
api.require_admin_or_tenant(&auth, &pubkey)?;
|
||||
let tenant = api.get_tenant_or_404(&pubkey).await?;
|
||||
|
||||
let url = api
|
||||
.billing
|
||||
.stripe_create_portal_session(&tenant.stripe_customer_id, params.return_url.as_deref())
|
||||
.await
|
||||
.map_err(internal)?;
|
||||
ok(serde_json::json!({ "url": url }))
|
||||
}
|
||||
|
||||
/// Stripe webhook endpoint. Authenticated via `Stripe-Signature` verification
|
||||
/// on the raw body, not via NIP-98, so it does not use `AuthedPubkey`.
|
||||
pub async fn stripe_webhook(
|
||||
State(api): State<Arc<Api>>,
|
||||
headers: HeaderMap,
|
||||
body: Bytes,
|
||||
) -> ApiResult {
|
||||
let signature = headers
|
||||
.get("Stripe-Signature")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or("");
|
||||
|
||||
let payload = std::str::from_utf8(&body)
|
||||
.map_err(|_| bad_request("bad-request", "invalid payload"))?;
|
||||
|
||||
api.billing
|
||||
.handle_webhook(payload, signature)
|
||||
.await
|
||||
.map_err(|e| bad_request("webhook-error", &e.to_string()))?;
|
||||
ok(())
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Path, State},
|
||||
};
|
||||
use chrono::Utc;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::api::{Api, AuthedPubkey};
|
||||
use crate::models::Tenant;
|
||||
use crate::web::{ApiResult, internal, map_unique_error, ok};
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct TenantResponse {
|
||||
pub pubkey: String,
|
||||
pub nwc_is_set: bool,
|
||||
pub nwc_error: Option<String>,
|
||||
pub created_at: i64,
|
||||
pub stripe_customer_id: String,
|
||||
pub stripe_subscription_id: Option<String>,
|
||||
pub past_due_at: Option<i64>,
|
||||
}
|
||||
|
||||
impl From<Tenant> for TenantResponse {
|
||||
fn from(t: Tenant) -> Self {
|
||||
TenantResponse {
|
||||
nwc_is_set: !t.nwc_url.is_empty(),
|
||||
pubkey: t.pubkey,
|
||||
nwc_error: t.nwc_error,
|
||||
created_at: t.created_at,
|
||||
stripe_customer_id: t.stripe_customer_id,
|
||||
stripe_subscription_id: t.stripe_subscription_id,
|
||||
past_due_at: t.past_due_at,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct UpdateTenantRequest {
|
||||
pub nwc_url: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn list_tenants(
|
||||
State(api): State<Arc<Api>>,
|
||||
AuthedPubkey(auth): AuthedPubkey,
|
||||
) -> ApiResult {
|
||||
api.require_admin(&auth)?;
|
||||
|
||||
let tenants = api.query.list_tenants().await.map_err(internal)?;
|
||||
ok(tenants
|
||||
.into_iter()
|
||||
.map(TenantResponse::from)
|
||||
.collect::<Vec<_>>())
|
||||
}
|
||||
|
||||
/// Creates the tenant row for the calling pubkey. Idempotent: if the tenant
|
||||
/// already exists (including a unique-constraint race) we return the existing
|
||||
/// row.
|
||||
pub async fn create_tenant(
|
||||
State(api): State<Arc<Api>>,
|
||||
AuthedPubkey(pubkey): AuthedPubkey,
|
||||
) -> ApiResult {
|
||||
if let Some(t) = api.query.get_tenant(&pubkey).await.map_err(internal)? {
|
||||
return ok(TenantResponse::from(t));
|
||||
}
|
||||
|
||||
let stripe_customer_id = api
|
||||
.billing
|
||||
.stripe_create_customer(&pubkey)
|
||||
.await
|
||||
.map_err(internal)?;
|
||||
|
||||
let tenant = Tenant {
|
||||
pubkey: pubkey.clone(),
|
||||
created_at: Utc::now().timestamp(),
|
||||
stripe_customer_id,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
match api.command.create_tenant(&tenant).await {
|
||||
Ok(()) => ok(TenantResponse::from(tenant)),
|
||||
Err(e) if matches!(map_unique_error(&e), Some("pubkey-exists")) => {
|
||||
match api.query.get_tenant(&pubkey).await {
|
||||
Ok(Some(t)) => ok(TenantResponse::from(t)),
|
||||
Ok(None) => Err(internal("tenant row missing after unique-constraint race")),
|
||||
Err(e) => Err(internal(e)),
|
||||
}
|
||||
}
|
||||
Err(e) => Err(internal(e)),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_tenant(
|
||||
State(api): State<Arc<Api>>,
|
||||
AuthedPubkey(auth): AuthedPubkey,
|
||||
Path(pubkey): Path<String>,
|
||||
) -> ApiResult {
|
||||
api.require_admin_or_tenant(&auth, &pubkey)?;
|
||||
let tenant = api.get_tenant_or_404(&pubkey).await?;
|
||||
ok(TenantResponse::from(tenant))
|
||||
}
|
||||
|
||||
pub async fn update_tenant(
|
||||
State(api): State<Arc<Api>>,
|
||||
AuthedPubkey(auth): AuthedPubkey,
|
||||
Path(pubkey): Path<String>,
|
||||
Json(payload): Json<UpdateTenantRequest>,
|
||||
) -> ApiResult {
|
||||
api.require_admin_or_tenant(&auth, &pubkey)?;
|
||||
let mut tenant = api.get_tenant_or_404(&pubkey).await?;
|
||||
|
||||
// Encrypt tenant's nwc_url at rest
|
||||
if let Some(nwc_url) = payload.nwc_url {
|
||||
if nwc_url.is_empty() {
|
||||
tenant.nwc_url = String::new();
|
||||
} else {
|
||||
tenant.nwc_url = api.env.encrypt(&nwc_url).map_err(internal)?;
|
||||
}
|
||||
}
|
||||
|
||||
api.command.update_tenant(&tenant).await.map_err(internal)?;
|
||||
ok(TenantResponse::from(tenant))
|
||||
}
|
||||
|
||||
pub async fn list_tenant_relays(
|
||||
State(api): State<Arc<Api>>,
|
||||
AuthedPubkey(auth): AuthedPubkey,
|
||||
Path(pubkey): Path<String>,
|
||||
) -> ApiResult {
|
||||
api.require_admin_or_tenant(&auth, &pubkey)?;
|
||||
|
||||
let relays = api
|
||||
.query
|
||||
.list_relays_for_tenant(&pubkey)
|
||||
.await
|
||||
.map_err(internal)?;
|
||||
ok(relays)
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
use anyhow::{Result, anyhow};
|
||||
use nwc::prelude::{
|
||||
LookupInvoiceRequest, MakeInvoiceRequest, NWC, NostrWalletConnectURI, PayInvoiceRequest,
|
||||
TransactionState,
|
||||
};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Wallet {
|
||||
url: NostrWalletConnectURI,
|
||||
}
|
||||
|
||||
impl Wallet {
|
||||
pub fn from_url(url: &str) -> Result<Self> {
|
||||
let url = url
|
||||
.parse::<NostrWalletConnectURI>()
|
||||
.map_err(|_| anyhow!("invalid NWC URL"))?;
|
||||
Ok(Self { url })
|
||||
}
|
||||
|
||||
pub async fn make_invoice(&self, amount_msats: u64, description: &str) -> Result<String> {
|
||||
let nwc = NWC::new(self.url.clone());
|
||||
let result = nwc
|
||||
.make_invoice(MakeInvoiceRequest {
|
||||
amount: amount_msats,
|
||||
description: Some(description.to_string()),
|
||||
description_hash: None,
|
||||
expiry: None,
|
||||
})
|
||||
.await;
|
||||
nwc.shutdown().await;
|
||||
Ok(result
|
||||
.map_err(|e| anyhow!("failed to create invoice: {e}"))?
|
||||
.invoice)
|
||||
}
|
||||
|
||||
pub async fn pay_invoice(&self, bolt11: String) -> Result<()> {
|
||||
let nwc = NWC::new(self.url.clone());
|
||||
let result = nwc.pay_invoice(PayInvoiceRequest::new(bolt11)).await;
|
||||
nwc.shutdown().await;
|
||||
result.map(|_| ()).map_err(|e| anyhow!("{e}"))
|
||||
}
|
||||
|
||||
pub async fn is_settled(&self, bolt11: &str) -> Result<bool> {
|
||||
let nwc = NWC::new(self.url.clone());
|
||||
let result = nwc
|
||||
.lookup_invoice(LookupInvoiceRequest {
|
||||
payment_hash: None,
|
||||
invoice: Some(bolt11.to_string()),
|
||||
})
|
||||
.await;
|
||||
nwc.shutdown().await;
|
||||
let response = result.map_err(|e| anyhow!("failed to lookup invoice: {e}"))?;
|
||||
Ok(response.state == Some(TransactionState::Settled) || response.settled_at.is_some())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
//! General-purpose HTTP helpers shared across route handlers.
|
||||
//!
|
||||
//! Success builders (`res`, `ok`, `created`) return [`ApiResult`] so they
|
||||
//! can sit at the end of a handler without an `Ok(..)` wrap. Error builders
|
||||
//! (`err`, `not_found`, `forbidden`, …) return [`ApiError`] so they compose
|
||||
//! with `.map_err(...)` and with explicit `Err(...)` returns.
|
||||
|
||||
use std::fmt::Display;
|
||||
|
||||
use axum::{
|
||||
Json,
|
||||
http::StatusCode,
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use serde::Serialize;
|
||||
|
||||
pub struct ApiError(pub Box<Response>);
|
||||
|
||||
impl IntoResponse for ApiError {
|
||||
fn into_response(self) -> Response {
|
||||
*self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Response> for ApiError {
|
||||
fn from(r: Response) -> Self {
|
||||
Self(Box::new(r))
|
||||
}
|
||||
}
|
||||
|
||||
pub type ApiResult = Result<Response, ApiError>;
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct DataResponse<T: Serialize> {
|
||||
pub data: T,
|
||||
pub code: &'static str,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct ErrorResponse {
|
||||
pub error: String,
|
||||
pub code: String,
|
||||
}
|
||||
|
||||
// --- success builders (return ApiResult) ------------------------------------
|
||||
|
||||
pub fn res<T: Serialize>(status: StatusCode, data: T) -> ApiResult {
|
||||
Ok((status, Json(DataResponse { data, code: "ok" })).into_response())
|
||||
}
|
||||
|
||||
pub fn ok<T: Serialize>(data: T) -> ApiResult {
|
||||
res(StatusCode::OK, data)
|
||||
}
|
||||
|
||||
pub fn created<T: Serialize>(data: T) -> ApiResult {
|
||||
res(StatusCode::CREATED, data)
|
||||
}
|
||||
|
||||
// --- error builders (return ApiError) ---------------------------------------
|
||||
|
||||
pub fn err(status: StatusCode, code: &str, message: &str) -> ApiError {
|
||||
(
|
||||
status,
|
||||
Json(ErrorResponse {
|
||||
error: message.to_string(),
|
||||
code: code.to_string(),
|
||||
}),
|
||||
)
|
||||
.into_response()
|
||||
.into()
|
||||
}
|
||||
|
||||
pub fn unauthorized(reason: impl Display) -> ApiError {
|
||||
err(StatusCode::UNAUTHORIZED, "unauthorized", &reason.to_string())
|
||||
}
|
||||
|
||||
pub fn forbidden(message: &str) -> ApiError {
|
||||
err(StatusCode::FORBIDDEN, "forbidden", message)
|
||||
}
|
||||
|
||||
pub fn not_found(message: &str) -> ApiError {
|
||||
err(StatusCode::NOT_FOUND, "not-found", message)
|
||||
}
|
||||
|
||||
pub fn bad_request(code: &str, message: &str) -> ApiError {
|
||||
err(StatusCode::BAD_REQUEST, code, message)
|
||||
}
|
||||
|
||||
pub fn unprocessable(code: &str, message: &str) -> ApiError {
|
||||
err(StatusCode::UNPROCESSABLE_ENTITY, code, message)
|
||||
}
|
||||
|
||||
pub fn internal(reason: impl Display) -> ApiError {
|
||||
err(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"internal",
|
||||
&reason.to_string(),
|
||||
)
|
||||
}
|
||||
|
||||
// --- misc utilities ---------------------------------------------------------
|
||||
|
||||
pub fn parse_bool_default(value: i64, default: i64) -> i64 {
|
||||
if value == 0 || value == 1 {
|
||||
value
|
||||
} else {
|
||||
default
|
||||
}
|
||||
}
|
||||
|
||||
/// Recognize sqlite UNIQUE constraint violations on known columns so the
|
||||
/// caller can translate them into 422 responses instead of opaque 500s.
|
||||
pub fn map_unique_error(err: &anyhow::Error) -> Option<&'static str> {
|
||||
let sqlx_err = err.downcast_ref::<sqlx::Error>()?;
|
||||
let sqlx::Error::Database(db_err) = sqlx_err else {
|
||||
return None;
|
||||
};
|
||||
if db_err.message().contains("pubkey") {
|
||||
return Some("pubkey-exists");
|
||||
}
|
||||
if db_err.message().contains("subdomain") {
|
||||
return Some("subdomain-exists");
|
||||
}
|
||||
None
|
||||
}
|
||||
Reference in New Issue
Block a user