Compare commits

..

1 Commits

20 changed files with 193 additions and 889 deletions
+1 -1
View File
@@ -4,7 +4,7 @@ PORT=3000
ALLOW_ORIGINS= # Optional comma-separated allowed CORS origins; empty = permissive
# Auth
ADMINS= # Comma-separated hex pubkeys with admin access
ADMINS= # Comma-separated admin keys (hex pubkey or npub)
# Database
DATABASE_URL=sqlite://data/caravel.db
+2 -18
View File
@@ -35,7 +35,7 @@ Environment variables:
| `DATABASE_URL` | SQLite URL. Relative paths are resolved under `backend/`. | `sqlite://<backend>/data/caravel.db` |
| `HOST` | API bind host (also used for NIP-98 `u` host check) | `127.0.0.1` |
| `PORT` | API bind port | `3000` |
| `ADMINS` | Comma-separated admin pubkeys (hex) | _optional_ |
| `ADMINS` | Comma-separated admin pubkeys (`hex` or `npub`) | _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_ |
@@ -60,13 +60,7 @@ See [spec](spec) for more details
## API Routes
Most API routes are NIP-98 protected.
Public exceptions:
- `GET /plans`
- `GET /plans/:id`
- `POST /stripe/webhook` (validated with Stripe signatures instead)
All routes are NIP-98 protected.
- `GET /identity` — get auth identity (`pubkey`, `is_admin`)
- `GET /tenants` — list tenants (admin)
@@ -79,13 +73,3 @@ Public exceptions:
- `PUT /relays/:id` — update relay (admin or relay tenant)
- `POST /relays/:id/deactivate` — deactivate relay (admin or relay tenant)
- `GET /invoices` — list invoices (`?tenant=<pubkey>` allowed for admin only)
## API Auth Model
Caravel intentionally uses a session-style variant of NIP-98 for client-to-backend API auth.
- Frontend signs one kind `27235` event with `u = VITE_API_URL` and caches that header for about 10 minutes.
- Backend verifies event kind, signature, and that `u` contains configured `HOST`.
- Backend intentionally does not bind auth to exact request URL/method/query, and does not enforce payload hash, timestamp freshness window, or replay cache.
- Goal: reduce repeated wallet signing prompts and avoid cookie-based sessions.
- Tradeoff: this is weaker request-intent binding than strict NIP-98 semantics.
+1 -1
View File
@@ -12,7 +12,7 @@ CREATE TABLE IF NOT EXISTS tenant (
nwc_url TEXT NOT NULL DEFAULT '',
nwc_error TEXT,
created_at INTEGER NOT NULL,
stripe_customer_id TEXT NOT NULL,
stripe_customer_id TEXT NOT NULL DEFAULT '',
stripe_subscription_id TEXT,
past_due_at INTEGER
);
+5 -8
View File
@@ -184,11 +184,9 @@ Notes:
## `extract_auth_pubkey(&self, headers: &HeaderMap) -> Result<String>`
- Parses `Authorization` header
- Validates event kind (`27235`) and signature using `nostr_sdk`
- Validates event `u` contains configured `HOST`
- Intentionally does **not** enforce exact request URL/method/query matching
- Intentionally does **not** validate `payload` tag/hash, `created_at` freshness window, or replay nonce/cache
- This is a deliberate session-style tradeoff to reduce repeated signer prompts in the client
- Validates event kind and signature using `nostr_sdk`
- Validates event `u` against `HOST` (not the request path. Non-standard, but correct)
- Does not validate `method` tag
- Returns pubkey if header all checks pass
Refer to https://github.com/nostr-protocol/nips/blob/master/98.md for details. Use `nostr_sdk` functionality where possible.
@@ -204,8 +202,7 @@ Refer to https://github.com/nostr-protocol/nips/blob/master/98.md for details. U
## `prepare_relay(&self, relay: Relay) -> anyhow::Result<Relay>`
- Validate `subdomain`
- Validate that `plan` matches a known plan id from `Query::list_plans`
- If selected `plan` does not include `blossom` and `blossom` is enabled, return `premium-feature`
- If selected `plan` does not include `livekit` and `livekit` is enabled, return `premium-feature`
- If `plan` is free and `blossom` is enabled, return `premium-feature`
- If `plan` is free and `livekit` is enabled, return `premium-feature`
- Populate `schema` if not already set
- Populate missing fields using reasonable defaults
+1 -1
View File
@@ -19,7 +19,7 @@ Members:
## `async fn handle_activity(&self, activity: &Activity)`
- For `create_relay`, `update_relay`, `activate_relay`, or `deactivate_relay` activity, calls `sync_and_report`.
- For `create_relay`, `update_relay`, or `deactivate_relay` activity, calls `sync_and_report`.
- All other activity types are ignored (e.g. `fail_relay_sync`, `complete_relay_sync`).
## `async fn sync_and_report(&self, relay: &Relay, is_new: bool)`
+64 -113
View File
@@ -9,14 +9,12 @@ use axum::{
routing::{get, post},
};
use base64::Engine;
use nostr_sdk::{Event, JsonUtil, Kind};
use nostr_sdk::{Event, JsonUtil, Keys, Kind, PublicKey};
use serde::{Deserialize, Serialize};
use crate::billing::{Billing, InvoiceLookupError};
use crate::billing::Billing;
use crate::command::Command;
use crate::models::{
RELAY_STATUS_ACTIVE, RELAY_STATUS_DELINQUENT, RELAY_STATUS_INACTIVE, Relay, Tenant,
};
use crate::models::{Relay, Tenant};
use crate::query::Query;
use axum::body::Bytes;
@@ -72,11 +70,6 @@ enum ApiError {
Unauthorized(anyhow::Error),
Forbidden(&'static str),
NotFound(&'static str),
Client {
status: StatusCode,
code: &'static str,
message: &'static str,
},
Internal(String),
}
@@ -86,44 +79,18 @@ impl IntoResponse for ApiError {
Self::Unauthorized(e) => err(StatusCode::UNAUTHORIZED, "unauthorized", &e.to_string()),
Self::Forbidden(message) => err(StatusCode::FORBIDDEN, "forbidden", message),
Self::NotFound(message) => err(StatusCode::NOT_FOUND, "not-found", message),
Self::Client {
status,
code,
message,
} => err(status, code, message),
Self::Internal(message) => err(StatusCode::INTERNAL_SERVER_ERROR, "internal", &message),
}
}
}
fn map_invoice_lookup_error(error: InvoiceLookupError) -> ApiError {
match error {
InvoiceLookupError::StripeClient { status } => {
let status = StatusCode::from_u16(status.as_u16()).unwrap_or(StatusCode::BAD_REQUEST);
match status {
StatusCode::NOT_FOUND => ApiError::NotFound("invoice not found"),
StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN => {
ApiError::Forbidden("invoice access denied")
}
_ => ApiError::Client {
status,
code: "invoice-request-rejected",
message: "invoice request rejected",
},
}
}
InvoiceLookupError::Internal(error) => ApiError::Internal(error.to_string()),
}
}
impl Api {
pub fn new(query: Query, command: Command, billing: Billing) -> Self {
let host = std::env::var("HOST").unwrap_or_else(|_| "127.0.0.1".to_string());
let admins = std::env::var("ADMINS")
.unwrap_or_default()
.split(',')
.map(|v| v.trim().to_lowercase())
.filter(|v| !v.is_empty())
.filter_map(parse_admin_pubkey)
.collect();
Self {
host,
@@ -209,9 +176,6 @@ impl Api {
return Err(ApiError::Unauthorized(anyhow!("missing u tag")));
};
// Intentional session-style variant of NIP-98 for Caravel API auth.
// We validate signer identity plus host affinity, and do not bind to exact
// request URL/method or maintain replay state here.
if !self.host.is_empty() && !got_u.contains(&self.host) {
return Err(ApiError::Unauthorized(anyhow!(
"authorization host mismatch"
@@ -259,12 +223,10 @@ impl Api {
return Err(anyhow!("invalid-subdomain"));
}
let plan = Query::get_plan(&relay.plan).ok_or_else(|| anyhow!("invalid-plan"))?;
if !plan.blossom && relay.blossom_enabled == 1 {
if relay.plan == "free" && relay.blossom_enabled == 1 {
return Err(anyhow!("premium-feature"));
}
if !plan.livekit && relay.livekit_enabled == 1 {
if relay.plan == "free" && relay.livekit_enabled == 1 {
return Err(anyhow!("premium-feature"));
}
@@ -272,22 +234,44 @@ impl Api {
relay.schema = format!("{}_{}", relay.subdomain.replace('-', "_"), relay.id);
}
if relay.status.is_empty() {
relay.status = RELAY_STATUS_ACTIVE.to_string();
relay.status = "active".to_string();
}
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, if plan.blossom { 1 } else { 0 });
relay.livekit_enabled =
parse_bool_default(relay.livekit_enabled, if plan.livekit { 1 } else { 0 });
relay.blossom_enabled = parse_bool_default(
relay.blossom_enabled,
if relay.plan == "free" { 0 } else { 1 },
);
relay.livekit_enabled = parse_bool_default(
relay.livekit_enabled,
if relay.plan == "free" { 0 } else { 1 },
);
relay.push_enabled = parse_bool_default(relay.push_enabled, 1);
Ok(relay)
}
}
fn parse_admin_pubkey(value: &str) -> Option<String> {
let value = value.trim();
if value.is_empty() {
return None;
}
if let Ok(pubkey) = PublicKey::parse(value) {
return Some(pubkey.to_hex());
}
// Allow nsec values by deriving their pubkey so admin matching still works.
if let Ok(keys) = Keys::parse(value) {
return Some(keys.public_key().to_hex());
}
None
}
fn ok<T: Serialize>(status: StatusCode, data: T) -> Response {
(status, Json(OkResponse { data, code: "ok" })).into_response()
}
@@ -401,57 +385,39 @@ async fn get_identity(
let pubkey = state.api.extract_auth_pubkey(&headers)?;
let is_admin = state.api.admins.iter().any(|a| a == &pubkey);
// Ensure tenant exists.
match state.api.query.get_tenant(&pubkey).await {
Ok(Some(_)) => {}
Ok(None) => {
let stripe_customer_id = match state.api.billing.stripe_create_customer(&pubkey).await {
Ok(id) => id,
Err(e) => {
return Ok(err(
StatusCode::INTERNAL_SERVER_ERROR,
"stripe-customer-create-failed",
&e.to_string(),
));
}
};
// Only create if tenant doesn't exist yet
if let Ok(None) = state.api.query.get_tenant(&pubkey).await {
// TODO: Call Stripe API to create a new customer
let stripe_customer_id = String::new();
let tenant = Tenant {
pubkey: pubkey.clone(),
nwc_url: String::new(),
nwc_error: None,
created_at: now_ts(),
stripe_customer_id,
stripe_subscription_id: None,
past_due_at: None,
};
let tenant = Tenant {
pubkey: pubkey.clone(),
nwc_url: String::new(),
nwc_error: None,
created_at: now_ts(),
stripe_customer_id,
stripe_subscription_id: None,
past_due_at: None,
};
match state.api.command.create_tenant(&tenant).await {
Ok(()) => {}
Err(e) if matches!(map_unique_error(&e), Some("pubkey-exists")) => {}
Err(e) => {
return Ok(err(
StatusCode::INTERNAL_SERVER_ERROR,
"internal",
&e.to_string(),
));
}
};
}
Err(e) => {
return Ok(err(
StatusCode::INTERNAL_SERVER_ERROR,
"internal",
&e.to_string(),
));
}
match state.api.command.create_tenant(&tenant).await {
Ok(()) => {}
Err(e) if matches!(map_unique_error(&e), Some("pubkey-exists")) => {}
Err(e) => {
return Ok(err(
StatusCode::INTERNAL_SERVER_ERROR,
"internal",
&e.to_string(),
));
}
};
}
Ok(ok(StatusCode::OK, IdentityResponse { pubkey, is_admin }))
}
async fn get_plan(Path(id): Path<String>) -> Response {
match Query::get_plan(&id) {
match Query::list_plans().into_iter().find(|p| p.id == id) {
Some(plan) => ok(StatusCode::OK, plan),
None => err(StatusCode::NOT_FOUND, "not-found", "plan not found"),
}
@@ -576,7 +542,7 @@ async fn create_relay(
subdomain: payload.subdomain,
plan: payload.plan,
stripe_subscription_item_id: None,
status: RELAY_STATUS_ACTIVE.to_string(),
status: "active".to_string(),
sync_error: String::new(),
info_name: payload.info_name.unwrap_or_default(),
info_icon: payload.info_icon.unwrap_or_default(),
@@ -592,13 +558,6 @@ async fn create_relay(
};
relay = match state.api.prepare_relay(relay) {
Err(e) if e.to_string() == "invalid-plan" => {
return Ok(err(
StatusCode::UNPROCESSABLE_ENTITY,
"invalid-plan",
"plan not found",
));
}
Ok(r) => r,
Err(e) if e.to_string() == "premium-feature" => {
return Ok(err(
@@ -696,13 +655,6 @@ async fn update_relay(
}
relay = match state.api.prepare_relay(relay) {
Err(e) if e.to_string() == "invalid-plan" => {
return Ok(err(
StatusCode::UNPROCESSABLE_ENTITY,
"invalid-plan",
"plan not found",
));
}
Ok(r) => r,
Err(e) if e.to_string() == "premium-feature" => {
return Ok(err(
@@ -761,7 +713,7 @@ async fn deactivate_relay(
state.api.require_admin_or_tenant(&auth, &relay.tenant)?;
if relay.status == RELAY_STATUS_INACTIVE || relay.status == RELAY_STATUS_DELINQUENT {
if relay.status == "inactive" {
return Ok(err(
StatusCode::BAD_REQUEST,
"relay-is-inactive",
@@ -800,7 +752,7 @@ async fn reactivate_relay(
state.api.require_admin_or_tenant(&auth, &relay.tenant)?;
if relay.status == RELAY_STATUS_ACTIVE {
if relay.status == "active" {
return Ok(err(
StatusCode::BAD_REQUEST,
"relay-is-active",
@@ -853,7 +805,7 @@ async fn get_invoice(
.billing
.get_invoice_with_tenant(&id)
.await
.map_err(map_invoice_lookup_error)?;
.map_err(|e| ApiError::Internal(e.to_string()))?;
state.api.require_admin_or_tenant(&auth, &tenant.pubkey)?;
Ok(ok(StatusCode::OK, invoice))
@@ -870,7 +822,7 @@ async fn get_invoice_bolt11(
.billing
.get_invoice_with_tenant(&id)
.await
.map_err(map_invoice_lookup_error)?;
.map_err(|e| ApiError::Internal(e.to_string()))?;
state.api.require_admin_or_tenant(&auth, &tenant.pubkey)?;
let status = invoice["status"].as_str().unwrap_or_default();
@@ -883,9 +835,8 @@ async fn get_invoice_bolt11(
}
let amount_due = invoice["amount_due"].as_i64().unwrap_or(0);
let currency = invoice["currency"].as_str().unwrap_or("usd");
match state.api.billing.create_bolt11(amount_due, currency).await {
match state.api.billing.create_bolt11(amount_due).await {
Ok(bolt11) => Ok(ok(StatusCode::OK, serde_json::json!({ "bolt11": bolt11 }))),
Err(e) => Ok(err(
StatusCode::INTERNAL_SERVER_ERROR,
+46 -410
View File
@@ -1,58 +1,20 @@
use anyhow::{Result, anyhow};
use hmac::{Hmac, Mac};
use nwc::prelude::{
MakeInvoiceRequest, NWC, NostrWalletConnectURI, PayInvoiceRequest as NwcPayInvoiceRequest,
MakeInvoiceRequest, NostrWalletConnectURI, PayInvoiceRequest as NwcPayInvoiceRequest, NWC,
};
use sha2::Sha256;
use crate::command::Command;
use crate::models::{
Activity, RELAY_STATUS_ACTIVE, RELAY_STATUS_DELINQUENT, RELAY_STATUS_INACTIVE, Relay,
};
use crate::models::Activity;
use crate::query::Query;
use crate::robot::Robot;
type HmacSha256 = Hmac<Sha256>;
const STRIPE_API: &str = "https://api.stripe.com/v1";
const COINBASE_SPOT_API: &str = "https://api.coinbase.com/v2/prices";
const WEBHOOK_TOLERANCE_SECS: i64 = 300;
#[derive(Debug)]
pub enum InvoiceLookupError {
StripeClient { status: reqwest::StatusCode },
Internal(anyhow::Error),
}
impl std::fmt::Display for InvoiceLookupError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::StripeClient { status } => {
write!(
f,
"stripe invoice lookup failed with status {}",
status.as_u16()
)
}
Self::Internal(error) => write!(f, "{error}"),
}
}
}
impl std::error::Error for InvoiceLookupError {}
impl From<anyhow::Error> for InvoiceLookupError {
fn from(value: anyhow::Error) -> Self {
Self::Internal(value)
}
}
impl From<reqwest::Error> for InvoiceLookupError {
fn from(value: reqwest::Error) -> Self {
Self::Internal(value.into())
}
}
#[derive(serde::Deserialize)]
struct StripeEvent {
#[serde(rename = "type")]
@@ -65,22 +27,11 @@ struct StripeEventData {
object: serde_json::Value,
}
#[derive(serde::Deserialize)]
struct CoinbaseSpotPriceResponse {
data: CoinbaseSpotPriceData,
}
#[derive(serde::Deserialize)]
struct CoinbaseSpotPriceData {
amount: String,
}
#[derive(Clone)]
pub struct Billing {
nwc_url: String,
stripe_secret_key: String,
stripe_webhook_secret: String,
btc_quote_api_base: String,
http: reqwest::Client,
query: Query,
command: Command,
@@ -91,17 +42,11 @@ impl Billing {
pub fn new(query: Query, command: Command, robot: Robot) -> Self {
let nwc_url = std::env::var("NWC_URL").unwrap_or_default();
let stripe_secret_key = std::env::var("STRIPE_SECRET_KEY").unwrap_or_default();
if stripe_secret_key.trim().is_empty() {
panic!("missing STRIPE_SECRET_KEY environment variable");
}
let stripe_webhook_secret = std::env::var("STRIPE_WEBHOOK_SECRET").unwrap_or_default();
let btc_quote_api_base =
std::env::var("BTC_PRICE_API_BASE").unwrap_or_else(|_| COINBASE_SPOT_API.to_string());
Self {
nwc_url,
stripe_secret_key,
stripe_webhook_secret,
btc_quote_api_base,
http: reqwest::Client::new(),
query,
command,
@@ -130,12 +75,8 @@ impl Billing {
async fn handle_activity(&self, activity: &Activity) -> Result<()> {
let needs_billing_sync = matches!(
activity.activity_type.as_str(),
"create_relay"
| "update_relay"
| "activate_relay"
| "deactivate_relay"
| "fail_relay_sync"
| "complete_relay_sync"
"create_relay" | "update_relay" | "activate_relay" | "deactivate_relay"
| "fail_relay_sync" | "complete_relay_sync"
);
if needs_billing_sync {
@@ -154,34 +95,35 @@ impl Billing {
return Ok(());
};
let plan = Query::get_plan(&relay.plan)
.ok_or_else(|| anyhow!("unknown relay plan id: {}", relay.plan))?;
// Free plan: remove subscription item if exists, then clean up
if plan.id == "free" {
if relay.plan == "free" {
if let Some(ref item_id) = relay.stripe_subscription_item_id {
self.stripe_delete_subscription_item(item_id).await?;
self.command
.delete_relay_subscription_item(&relay.id)
.await?;
self.command.delete_relay_subscription_item(&relay.id).await?;
}
self.cleanup_empty_subscription(&tenant.pubkey).await?;
return Ok(());
}
// Inactive relay: remove subscription item if exists, then clean up
if relay.status == RELAY_STATUS_INACTIVE || relay.status == RELAY_STATUS_DELINQUENT {
if relay.status == "inactive" {
if let Some(ref item_id) = relay.stripe_subscription_item_id {
self.stripe_delete_subscription_item(item_id).await?;
self.command
.delete_relay_subscription_item(&relay.id)
.await?;
self.command.delete_relay_subscription_item(&relay.id).await?;
}
self.cleanup_empty_subscription(&tenant.pubkey).await?;
return Ok(());
}
// Active relay on a paid plan
let plan = Query::list_plans()
.into_iter()
.find(|p| p.id == relay.plan);
let Some(plan) = plan else {
return Ok(());
};
let Some(ref stripe_price_id) = plan.stripe_price_id else {
return Ok(());
};
@@ -228,9 +170,7 @@ impl Billing {
if let Some(ref subscription_id) = tenant.stripe_subscription_id {
self.stripe_cancel_subscription(subscription_id).await?;
self.command
.clear_tenant_subscription(tenant_pubkey)
.await?;
self.command.clear_tenant_subscription(tenant_pubkey).await?;
}
Ok(())
@@ -246,9 +186,8 @@ impl Billing {
"invoice.created" => {
let customer = obj["customer"].as_str().unwrap_or_default();
let amount_due = obj["amount_due"].as_i64().unwrap_or(0);
let currency = obj["currency"].as_str().unwrap_or("usd");
let invoice_id = obj["id"].as_str().unwrap_or_default();
self.handle_invoice_created(customer, amount_due, currency, invoice_id)
self.handle_invoice_created(customer, amount_due, invoice_id)
.await?;
}
"invoice.paid" => {
@@ -301,9 +240,7 @@ impl Billing {
return Err(anyhow!("webhook signature mismatch"));
}
let ts: i64 = timestamp
.parse()
.map_err(|_| anyhow!("bad webhook timestamp"))?;
let ts: i64 = timestamp.parse().map_err(|_| anyhow!("bad webhook timestamp"))?;
let now = chrono::Utc::now().timestamp();
if (now - ts).abs() > WEBHOOK_TOLERANCE_SECS {
return Err(anyhow!("webhook timestamp outside tolerance"));
@@ -316,7 +253,6 @@ impl Billing {
&self,
stripe_customer_id: &str,
amount_due: i64,
currency: &str,
invoice_id: &str,
) -> Result<()> {
if amount_due == 0 {
@@ -333,13 +269,12 @@ impl Billing {
// 1. NWC auto-pay: if the tenant has a nwc_url
if !tenant.nwc_url.is_empty() {
match self
.nwc_pay_invoice(amount_due, currency, &tenant.nwc_url)
.await
{
match self.nwc_pay_invoice(amount_due, &tenant.nwc_url).await {
Ok(()) => {
self.stripe_pay_invoice_out_of_band(invoice_id).await?;
self.command.clear_tenant_nwc_error(&tenant.pubkey).await?;
self.command
.clear_tenant_nwc_error(&tenant.pubkey)
.await?;
return Ok(());
}
Err(e) => {
@@ -385,7 +320,7 @@ impl Billing {
let relays = self.query.list_relays_for_tenant(&tenant.pubkey).await?;
for relay in relays {
if Self::should_reactivate_after_payment(&relay) {
if relay.status == "inactive" && relay.plan != "free" {
self.command.activate_relay(&relay).await?;
}
}
@@ -439,8 +374,8 @@ impl Billing {
let relays = self.query.list_relays_for_tenant(&tenant.pubkey).await?;
for relay in relays {
if relay.status == RELAY_STATUS_ACTIVE && Query::is_paid_plan(&relay.plan) {
self.command.mark_relay_delinquent(&relay).await?;
if relay.status == "active" && relay.plan != "free" {
self.command.deactivate_relay(&relay).await?;
}
}
@@ -474,8 +409,8 @@ impl Billing {
let relays = self.query.list_relays_for_tenant(&tenant.pubkey).await?;
for relay in relays {
if relay.status == RELAY_STATUS_ACTIVE && Query::is_paid_plan(&relay.plan) {
self.command.mark_relay_delinquent(&relay).await?;
if relay.status == "active" && relay.plan != "free" {
self.command.deactivate_relay(&relay).await?;
}
}
@@ -494,48 +429,19 @@ impl Billing {
pub async fn get_invoice_with_tenant(
&self,
invoice_id: &str,
) -> std::result::Result<(serde_json::Value, crate::models::Tenant), InvoiceLookupError> {
) -> Result<(serde_json::Value, crate::models::Tenant)> {
let invoice = self.stripe_get_invoice(invoice_id).await?;
let customer_id = invoice["customer"]
.as_str()
.ok_or_else(|| InvoiceLookupError::Internal(anyhow!("invoice missing customer")))?;
.ok_or_else(|| anyhow!("invoice missing customer"))?;
let tenant = self
.query
.get_tenant_by_stripe_customer_id(customer_id)
.await?
.ok_or_else(|| {
InvoiceLookupError::Internal(anyhow!("tenant not found for customer"))
})?;
.ok_or_else(|| anyhow!("tenant not found for customer"))?;
Ok((invoice, tenant))
}
pub async fn stripe_create_customer(&self, tenant_pubkey: &str) -> Result<String> {
let short_pubkey: String = tenant_pubkey.chars().take(12).collect();
let display_name = format!("Caravel tenant {short_pubkey}");
let resp = self
.http
.post(format!("{STRIPE_API}/customers"))
.bearer_auth(&self.stripe_secret_key)
.form(&[
("name", display_name.as_str()),
("metadata[tenant_pubkey]", tenant_pubkey),
])
.send()
.await?;
let body: serde_json::Value = resp.error_for_status()?.json().await?;
let customer_id = body["id"]
.as_str()
.ok_or_else(|| anyhow!("missing customer id"))?;
if !customer_id.starts_with("cus_") {
return Err(anyhow!("unexpected customer id format"));
}
Ok(customer_id.to_string())
}
pub async fn stripe_list_invoices(&self, customer_id: &str) -> Result<serde_json::Value> {
let resp = self
.http
@@ -549,10 +455,7 @@ impl Billing {
Ok(body["data"].clone())
}
pub async fn stripe_get_invoice(
&self,
invoice_id: &str,
) -> std::result::Result<serde_json::Value, InvoiceLookupError> {
pub async fn stripe_get_invoice(&self, invoice_id: &str) -> Result<serde_json::Value> {
let resp = self
.http
.get(format!("{STRIPE_API}/invoices/{invoice_id}"))
@@ -560,22 +463,14 @@ impl Billing {
.send()
.await?;
if resp.status().is_client_error() {
return Err(InvoiceLookupError::StripeClient {
status: resp.status(),
});
}
let body: serde_json::Value = resp.error_for_status()?.json().await?;
Ok(body)
}
pub async fn create_bolt11(&self, amount_due_minor: i64, currency: &str) -> Result<String> {
let amount_msats = self.fiat_minor_to_msats(amount_due_minor, currency).await?;
pub async fn create_bolt11(&self, amount_due_cents: i64) -> Result<String> {
let amount_msats = (amount_due_cents as u64) * 1000; // placeholder conversion
let system_uri: NostrWalletConnectURI = self
.nwc_url
.parse()
let system_uri: NostrWalletConnectURI = self.nwc_url.parse()
.map_err(|_| anyhow!("invalid system NWC URL"))?;
let system_nwc = NWC::new(system_uri);
@@ -655,7 +550,10 @@ impl Billing {
.http
.post(format!("{STRIPE_API}/subscription_items"))
.bearer_auth(&self.stripe_secret_key)
.form(&[("subscription", subscription_id), ("price", price_id)])
.form(&[
("subscription", subscription_id),
("price", price_id),
])
.send()
.await?;
@@ -744,18 +642,14 @@ impl Billing {
// --- NWC helpers ---
async fn nwc_pay_invoice(
&self,
amount_due_minor: i64,
currency: &str,
tenant_nwc_url: &str,
) -> Result<()> {
let amount_msats = self.fiat_minor_to_msats(amount_due_minor, currency).await?;
async fn nwc_pay_invoice(&self, amount_due_cents: i64, tenant_nwc_url: &str) -> Result<()> {
// Convert USD cents to millisatoshis (approximate: 1 sat ≈ variable USD)
// amount_due is in cents from Stripe. We create a Lightning invoice for the exact amount.
// The NWC make_invoice amount is in millisatoshis.
let amount_msats = (amount_due_cents as u64) * 1000; // placeholder conversion, actual rate would come from exchange
// Create a bolt11 invoice using the system wallet (self.nwc_url)
let system_uri: NostrWalletConnectURI = self
.nwc_url
.parse()
let system_uri: NostrWalletConnectURI = self.nwc_url.parse()
.map_err(|_| anyhow!("invalid system NWC URL"))?;
let system_nwc = NWC::new(system_uri);
@@ -774,8 +668,7 @@ impl Billing {
system_nwc.shutdown().await;
// Pay the bolt11 invoice using the tenant's wallet
let tenant_uri: NostrWalletConnectURI = tenant_nwc_url
.parse()
let tenant_uri: NostrWalletConnectURI = tenant_nwc_url.parse()
.map_err(|_| anyhow!("invalid tenant NWC URL"))?;
let tenant_nwc = NWC::new(tenant_uri);
@@ -790,261 +683,4 @@ impl Billing {
Ok(())
}
async fn fiat_minor_to_msats(&self, amount_due_minor: i64, currency: &str) -> Result<u64> {
let normalized_currency = currency.to_uppercase();
let btc_price = self.fetch_btc_spot_price(&normalized_currency).await?;
fiat_minor_to_msats_from_quote(amount_due_minor, &normalized_currency, btc_price)
}
fn should_reactivate_after_payment(relay: &Relay) -> bool {
relay.status == RELAY_STATUS_DELINQUENT && Query::is_paid_plan(&relay.plan)
}
async fn fetch_btc_spot_price(&self, currency: &str) -> Result<f64> {
fetch_btc_spot_price_from_base(&self.http, &self.btc_quote_api_base, currency).await
}
fn currency_minor_exponent(currency: &str) -> Result<u8> {
let normalized = currency.to_uppercase();
let exponent = match normalized.as_str() {
// Zero-decimal currencies in Stripe.
"BIF" | "CLP" | "DJF" | "GNF" | "JPY" | "KMF" | "KRW" | "MGA" | "PYG" | "RWF"
| "UGX" | "VND" | "VUV" | "XAF" | "XOF" | "XPF" => 0,
// Three-decimal currencies in Stripe.
"BHD" | "JOD" | "KWD" | "OMR" | "TND" => 3,
_ if normalized.chars().all(|c| c.is_ascii_alphabetic()) && normalized.len() == 3 => 2,
_ => return Err(anyhow!("invalid currency code: {currency}")),
};
Ok(exponent)
}
}
pub async fn fetch_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('/'));
let resp = http.get(url).send().await?;
let body: CoinbaseSpotPriceResponse = resp.error_for_status()?.json().await?;
let amount = 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)
}
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 exponent = Billing::currency_minor_exponent(currency)?;
let divisor = 10_f64.powi(exponent 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 * 100_000_000_000.0;
// Guard against tiny floating point artifacts at integer boundaries.
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)
}
#[cfg(test)]
mod tests {
use super::{Billing, fiat_minor_to_msats_from_quote};
use crate::models::{
RELAY_STATUS_ACTIVE, RELAY_STATUS_DELINQUENT, RELAY_STATUS_INACTIVE, Relay,
};
fn relay_fixture(status: &str, plan: &str) -> Relay {
Relay {
id: "relay-1".to_string(),
tenant: "tenant-1".to_string(),
schema: "tenant_1".to_string(),
subdomain: "relay-1".to_string(),
plan: plan.to_string(),
stripe_subscription_item_id: None,
status: status.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: 1,
livekit_enabled: 1,
push_enabled: 1,
synced: 1,
}
}
#[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 reactivates_only_delinquent_paid_relays_after_payment() {
let delinquent_paid = relay_fixture(RELAY_STATUS_DELINQUENT, "basic");
assert!(Billing::should_reactivate_after_payment(&delinquent_paid));
let manually_inactive_paid = relay_fixture(RELAY_STATUS_INACTIVE, "basic");
assert!(!Billing::should_reactivate_after_payment(
&manually_inactive_paid
));
let free_delinquent = relay_fixture(RELAY_STATUS_DELINQUENT, "free");
assert!(!Billing::should_reactivate_after_payment(&free_delinquent));
let active_paid = relay_fixture(RELAY_STATUS_ACTIVE, "basic");
assert!(!Billing::should_reactivate_after_payment(&active_paid));
let unknown_status_paid = relay_fixture("suspended", "basic");
assert!(!Billing::should_reactivate_after_payment(
&unknown_status_paid
));
}
use super::*;
use sqlx::SqlitePool;
use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions};
use std::str::FromStr;
use std::sync::{Mutex, OnceLock};
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") },
}
}
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());
}
}
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().expect("acquire env lock");
let _env = StripeSecretKeyGuard::set(None);
let pool = test_pool().await;
let query = Query::new(pool.clone());
let command = Command::new(pool);
let robot = Robot::test_stub();
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
Billing::new(query, command, robot)
}));
let panic_payload = match result {
Ok(_) => panic!("constructor should panic when STRIPE_SECRET_KEY is missing"),
Err(payload) => payload,
};
let panic_msg = if let Some(msg) = panic_payload.downcast_ref::<&str>() {
(*msg).to_string()
} else if let Some(msg) = panic_payload.downcast_ref::<String>() {
msg.clone()
} else {
String::new()
};
assert!(
panic_msg.contains("missing STRIPE_SECRET_KEY environment variable"),
"unexpected panic: {panic_msg}"
);
}
#[tokio::test]
async fn billing_new_accepts_non_empty_stripe_secret_key() {
let _lock = env_lock().lock().expect("acquire env lock");
let _env = StripeSecretKeyGuard::set(Some("sk_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");
}
}
+22 -99
View File
@@ -2,9 +2,7 @@ use anyhow::Result;
use sqlx::{Sqlite, SqlitePool, Transaction};
use tokio::sync::broadcast;
use crate::models::{
Activity, RELAY_STATUS_ACTIVE, RELAY_STATUS_DELINQUENT, RELAY_STATUS_INACTIVE, Relay, Tenant,
};
use crate::models::{Activity, Relay, Tenant};
#[derive(Clone)]
pub struct Command {
@@ -66,10 +64,6 @@ impl Command {
}
pub async fn create_tenant(&self, tenant: &Tenant) -> Result<()> {
if tenant.stripe_customer_id.trim().is_empty() {
anyhow::bail!("stripe_customer_id is required");
}
let mut tx = self.pool.begin().await?;
sqlx::query(
@@ -83,8 +77,7 @@ impl Command {
.execute(&mut *tx)
.await?;
let activity =
Self::insert_activity(&mut tx, "create_tenant", "tenant", &tenant.pubkey).await?;
let activity = Self::insert_activity(&mut tx, "create_tenant", "tenant", &tenant.pubkey).await?;
tx.commit().await?;
self.emit(activity);
@@ -100,8 +93,7 @@ impl Command {
.execute(&mut *tx)
.await?;
let activity =
Self::insert_activity(&mut tx, "update_tenant", "tenant", &tenant.pubkey).await?;
let activity = Self::insert_activity(&mut tx, "update_tenant", "tenant", &tenant.pubkey).await?;
tx.commit().await?;
self.emit(activity);
@@ -186,30 +178,14 @@ impl Command {
}
pub async fn deactivate_relay(&self, relay: &Relay) -> Result<()> {
self.set_relay_status(&relay.id, RELAY_STATUS_INACTIVE, "deactivate_relay")
.await
}
pub async fn mark_relay_delinquent(&self, relay: &Relay) -> Result<()> {
self.set_relay_status(&relay.id, RELAY_STATUS_DELINQUENT, "deactivate_relay")
.await
}
async fn set_relay_status(
&self,
relay_id: &str,
status: &str,
activity_type: &str,
) -> Result<()> {
let mut tx = self.pool.begin().await?;
sqlx::query("UPDATE relay SET status = ? WHERE id = ?")
.bind(status)
.bind(relay_id)
sqlx::query("UPDATE relay SET status = 'inactive' WHERE id = ?")
.bind(&relay.id)
.execute(&mut *tx)
.await?;
let activity = Self::insert_activity(&mut tx, activity_type, "relay", relay_id).await?;
let activity = Self::insert_activity(&mut tx, "deactivate_relay", "relay", &relay.id).await?;
tx.commit().await?;
self.emit(activity);
@@ -217,8 +193,18 @@ impl Command {
}
pub async fn activate_relay(&self, relay: &Relay) -> Result<()> {
self.set_relay_status(&relay.id, RELAY_STATUS_ACTIVE, "activate_relay")
.await
let mut tx = self.pool.begin().await?;
sqlx::query("UPDATE relay SET status = 'active' WHERE id = ?")
.bind(&relay.id)
.execute(&mut *tx)
.await?;
let activity = Self::insert_activity(&mut tx, "activate_relay", "relay", &relay.id).await?;
tx.commit().await?;
self.emit(activity);
Ok(())
}
pub async fn fail_relay_sync(&self, relay: &Relay, sync_error: String) -> Result<()> {
@@ -230,8 +216,7 @@ impl Command {
.execute(&mut *tx)
.await?;
let activity =
Self::insert_activity(&mut tx, "fail_relay_sync", "relay", &relay.id).await?;
let activity = Self::insert_activity(&mut tx, "fail_relay_sync", "relay", &relay.id).await?;
tx.commit().await?;
self.emit(activity);
@@ -246,8 +231,7 @@ impl Command {
.execute(&mut *tx)
.await?;
let activity =
Self::insert_activity(&mut tx, "complete_relay_sync", "relay", relay_id).await?;
let activity = Self::insert_activity(&mut tx, "complete_relay_sync", "relay", relay_id).await?;
tx.commit().await?;
self.emit(activity);
@@ -262,11 +246,7 @@ impl Command {
Ok(())
}
pub async fn set_relay_subscription_item(
&self,
relay_id: &str,
stripe_subscription_item_id: &str,
) -> Result<()> {
pub async fn set_relay_subscription_item(&self, relay_id: &str, stripe_subscription_item_id: &str) -> Result<()> {
sqlx::query("UPDATE relay SET stripe_subscription_item_id = ? WHERE id = ?")
.bind(stripe_subscription_item_id)
.bind(relay_id)
@@ -275,11 +255,7 @@ impl Command {
Ok(())
}
pub async fn set_tenant_subscription(
&self,
pubkey: &str,
stripe_subscription_id: &str,
) -> Result<()> {
pub async fn set_tenant_subscription(&self, pubkey: &str, stripe_subscription_id: &str) -> Result<()> {
sqlx::query("UPDATE tenant SET stripe_subscription_id = ? WHERE pubkey = ?")
.bind(stripe_subscription_id)
.bind(pubkey)
@@ -331,56 +307,3 @@ impl Command {
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use sqlx::SqlitePool;
use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions};
use std::str::FromStr;
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 create_tenant_rejects_empty_stripe_customer_id() {
let pool = test_pool().await;
let command = Command::new(pool);
let tenant = Tenant {
pubkey: "tenant_pubkey".to_string(),
nwc_url: String::new(),
nwc_error: None,
created_at: 0,
stripe_customer_id: " ".to_string(),
stripe_subscription_id: None,
past_due_at: None,
};
let err = command
.create_tenant(&tenant)
.await
.expect_err("empty customer id must be rejected");
assert!(
err.to_string().contains("stripe_customer_id is required"),
"unexpected error: {err}"
);
}
}
+9 -26
View File
@@ -2,7 +2,7 @@ use anyhow::Result;
use nostr_sdk::prelude::*;
use crate::command::Command;
use crate::models::{Activity, RELAY_STATUS_DELINQUENT, RELAY_STATUS_INACTIVE};
use crate::models::Activity;
use crate::query::Query;
#[derive(Clone)]
@@ -56,7 +56,10 @@ 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" | "deactivate_relay"
);
if needs_sync {
let Some(relay) = self.query.get_relay(&activity.resource_id).await? else {
@@ -90,9 +93,7 @@ impl Infra {
async fn nip98_auth(&self, url: &str, method: HttpMethod) -> Result<String> {
let keys = Keys::parse(&self.api_secret)?;
let server_url = Url::parse(url)?;
let auth = HttpData::new(server_url, method)
.to_authorization(&keys)
.await?;
let auth = HttpData::new(server_url, method).to_authorization(&keys).await?;
Ok(auth)
}
@@ -123,8 +124,7 @@ impl Infra {
"host": host,
"schema": relay.schema,
"secret": secret,
"inactive": relay.status == RELAY_STATUS_INACTIVE
|| relay.status == RELAY_STATUS_DELINQUENT,
"inactive": relay.status == "inactive",
"info": {
"name": relay.info_name,
"icon": relay.info_icon,
@@ -149,21 +149,11 @@ impl Infra {
let response = if is_new {
let url = format!("{}/relay/{}", base, relay.id);
let auth = self.nip98_auth(&url, HttpMethod::POST).await?;
client
.post(&url)
.header("Authorization", auth)
.json(&body)
.send()
.await?
client.post(&url).header("Authorization", auth).json(&body).send().await?
} else {
let url = format!("{}/relay/{}", base, relay.id);
let auth = self.nip98_auth(&url, HttpMethod::PUT).await?;
client
.put(&url)
.header("Authorization", auth)
.json(&body)
.send()
.await?
client.put(&url).header("Authorization", auth).json(&body).send().await?
};
if !response.status().is_success() {
@@ -174,10 +164,3 @@ impl Infra {
Ok(())
}
}
fn should_sync_relay_activity(activity_type: &str) -> bool {
matches!(
activity_type,
"create_relay" | "update_relay" | "activate_relay" | "deactivate_relay"
)
}
-4
View File
@@ -1,9 +1,5 @@
use serde::{Deserialize, Serialize};
pub const RELAY_STATUS_ACTIVE: &str = "active";
pub const RELAY_STATUS_INACTIVE: &str = "inactive";
pub const RELAY_STATUS_DELINQUENT: &str = "delinquent";
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
pub struct Activity {
pub id: String,
+8 -22
View File
@@ -68,19 +68,9 @@ impl Query {
]
}
pub fn get_plan(plan_id: &str) -> Option<Plan> {
Self::list_plans().into_iter().find(|p| p.id == plan_id)
}
pub fn is_paid_plan(plan_id: &str) -> bool {
Self::get_plan(plan_id)
.map(|p| p.id != "free")
.unwrap_or(false)
}
pub async fn list_relays(&self) -> Result<Vec<Relay>> {
let rows = sqlx::query_as::<_, Relay>(
"SELECT id, tenant, schema, subdomain, plan, stripe_subscription_item_id,
"SELECT id, tenant, schema, subdomain, plan, stripe_subscription_item_id,
status, sync_error,
info_name, info_icon, info_description,
policy_public_join, policy_strip_signatures,
@@ -96,7 +86,7 @@ impl Query {
pub async fn list_relays_for_tenant(&self, tenant_id: &str) -> Result<Vec<Relay>> {
let rows = sqlx::query_as::<_, Relay>(
"SELECT id, tenant, schema, subdomain, plan, stripe_subscription_item_id,
"SELECT id, tenant, schema, subdomain, plan, stripe_subscription_item_id,
status, sync_error,
info_name, info_icon, info_description,
policy_public_join, policy_strip_signatures,
@@ -114,7 +104,7 @@ impl Query {
pub async fn get_relay(&self, id: &str) -> Result<Option<Relay>> {
let row = sqlx::query_as::<_, Relay>(
"SELECT id, tenant, schema, subdomain, plan, stripe_subscription_item_id,
"SELECT id, tenant, schema, subdomain, plan, stripe_subscription_item_id,
status, sync_error,
info_name, info_icon, info_description,
policy_public_join, policy_strip_signatures,
@@ -129,10 +119,7 @@ impl Query {
Ok(row)
}
pub async fn get_tenant_by_stripe_customer_id(
&self,
stripe_customer_id: &str,
) -> Result<Option<Tenant>> {
pub async fn get_tenant_by_stripe_customer_id(&self, stripe_customer_id: &str) -> Result<Option<Tenant>> {
let row = sqlx::query_as::<_, Tenant>(
"SELECT pubkey, nwc_url, nwc_error, created_at, stripe_customer_id, stripe_subscription_id, past_due_at
FROM tenant
@@ -145,14 +132,13 @@ impl Query {
}
pub async fn has_active_paid_relays(&self, tenant_id: &str) -> Result<bool> {
let plans = sqlx::query_scalar::<_, String>(
"SELECT plan FROM relay WHERE tenant = ? AND status = 'active'",
let count = sqlx::query_scalar::<_, i64>(
"SELECT COUNT(*) FROM relay WHERE tenant = ? AND status = 'active' AND plan != 'free'",
)
.bind(tenant_id)
.fetch_all(&self.pool)
.fetch_one(&self.pool)
.await?;
Ok(plans.into_iter().any(|plan| Self::is_paid_plan(&plan)))
Ok(count > 0)
}
pub async fn list_activity_for_relay(&self, relay_id: &str) -> Result<Vec<Activity>> {
-20
View File
@@ -254,23 +254,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())),
}
}
}
-31
View File
@@ -1,31 +0,0 @@
use axum::{Json, Router, routing::get};
use backend::billing::{fetch_btc_spot_price_from_base, fiat_minor_to_msats_from_quote};
#[tokio::test]
async fn quote_endpoint_can_be_stubbed_deterministically() {
async fn spot() -> Json<serde_json::Value> {
Json(serde_json::json!({ "data": { "amount": "50000.00" } }))
}
let app = Router::new().route("/v2/prices/BTC-USD/spot", get(spot));
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.expect("bind test server");
let addr = listener.local_addr().expect("get local addr");
tokio::spawn(async move {
axum::serve(listener, app).await.expect("serve quote stub");
});
let client = reqwest::Client::new();
let base = format!("http://{addr}/v2/prices");
let btc_price = fetch_btc_spot_price_from_base(&client, &base, "USD")
.await
.expect("fetch stubbed quote");
assert_eq!(btc_price, 50_000.0);
let msats = fiat_minor_to_msats_from_quote(100, "USD", btc_price)
.expect("convert quoted fiat amount");
assert_eq!(msats, 2_000_000);
}
+2 -5
View File
@@ -51,11 +51,8 @@ npm run preview
## Authentication
- Tenant requests use an intentional session-style variant of NIP-98:
- The client signs one kind `27235` event with `u = VITE_API_URL`.
- The resulting `Authorization` header is cached for about 10 minutes to avoid repeated signer prompts.
- The backend validates signer identity + host affinity rather than exact URL/method binding per request.
- Admin routes require a pubkey listed in `ADMINS` on the backend.
- Tenant requests use NIP-98 tokens derived from the logged-in user
- Admin routes require a pubkey listed in `PLATFORM_ADMIN_PUBKEYS` on the backend
## Routes
-1
View File
@@ -1 +0,0 @@
<svg xmlns:xlink="http://www.w3.org/1999/xlink" width="220" height="220" viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M0 32C0 14.3269 14.3269 0 32 0V0C49.6731 0 64 14.3269 64 32V32C64 49.6731 49.6731 64 32 64H8C3.58172 64 0 60.4183 0 56V32Z" fill="#6D29D9"></path><path fill-rule="evenodd" clip-rule="evenodd" d="M21 22C19.3431 22 18 23.3431 18 25C18 26.1046 17.1046 27 16 27C14.8954 27 14 26.1046 14 25C14 21.134 17.134 18 21 18C24.866 18 28 21.134 28 25C28 26.1046 27.1046 27 26 27C24.8954 27 24 26.1046 24 25C24 23.3431 22.6569 22 21 22ZM43 22C41.3431 22 40 23.3431 40 25C40 26.1046 39.1046 27 38 27C36.8954 27 36 26.1046 36 25C36 21.134 39.134 18 43 18C46.866 18 50 21.134 50 25C50 26.1046 49.1046 27 48 27C46.8954 27 46 26.1046 46 25C46 23.3431 44.6569 22 43 22Z" fill="#FFFFFF"></path><path d="M32 47C38.6985 47 44.2982 42.2956 45.6755 36.0106C45.9829 34.608 44.5 33.5552 43.1016 33.8813C40.0379 34.5957 35.7213 35.1538 32 35.1538C28.2787 35.1538 23.9621 34.5957 20.8984 33.8813C19.5 33.5552 18.0171 34.608 18.3245 36.0106C19.7018 42.2956 25.3015 47 32 47Z" fill="#FFFFFF"></path></svg>

Before

Width:  |  Height:  |  Size: 1.1 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 11 KiB

-21
View File
@@ -1,21 +0,0 @@
<svg xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" xmlns="http://www.w3.org/2000/svg" xmlns:svg="http://www.w3.org/2000/svg" width="66.145837" height="66.145837" viewBox="0 0 66.145837 66.145837" version="1.1" id="svg1" inkscape:version="1.4.2 (ebf0e940d0, 2025-05-08)" sodipodi:docname="nostrord.svg">
<sodipodi:namedview id="namedview1" pagecolor="#ffffff" bordercolor="#000000" borderopacity="0.25" inkscape:showpageshadow="2" inkscape:pageopacity="0.0" inkscape:pagecheckerboard="0" inkscape:deskcolor="#d1d1d1" inkscape:document-units="mm" inkscape:zoom="2.1971895" inkscape:cx="172.94822" inkscape:cy="184.78151" inkscape:window-width="2048" inkscape:window-height="1083" inkscape:window-x="0" inkscape:window-y="0" inkscape:window-maximized="1" inkscape:current-layer="layer1"/>
<defs id="defs1"/>
<g inkscape:label="Layer 1" inkscape:groupmode="layer" id="layer1" transform="translate(-80.278336,-100.91589)">
<rect x="80.278336" y="100.91589" width="66.145836" height="66.145836" rx="15.434029" fill="#0a0a0a" id="rect1-6-9-7-7-9-8-6" style="stroke-width:1.10243"/>
<g id="g32" transform="matrix(0.56473291,0,0,0.56473291,-434.56644,229.88942)">
<path d="m 941.12408,-145.50974 c 0,7.28807 10.93211,10.93211 29.15233,10.93211 18.2201,0 29.1523,-3.64404 29.1523,-10.93211 v -32.79632 c 0,-14.57615 -10.9322,-25.50826 -29.1523,-25.50826 -18.22022,0 -29.15233,10.93211 -29.15233,25.50826 z" fill="#fafafa" id="path3-6-2-5-9-7-4-1" style="stroke-width:1.82202"/>
<path d="m 948.41215,-171.01799 c 0,-7.28807 10.93211,-10.93211 21.86426,-10.93211 10.9321,0 21.8642,3.64404 21.8642,10.93211 v 7.28807 c 0,7.28807 -10.9321,10.9321 -21.8642,10.9321 -10.93215,0 -21.86426,-3.64403 -21.86426,-10.9321 z" fill="#0a0a0a" id="path4-1-3-6-2-3-5-5" style="stroke-width:1.82202"/>
<ellipse cx="959.34424" cy="-167.37396" rx="5.4660549" ry="7.2880735" fill="#fafafa" id="ellipse4-5-7-2-0-6-0-5" style="stroke-width:1.82202"/>
<ellipse cx="981.2085" cy="-167.37396" rx="5.4660549" ry="7.2880735" fill="#fafafa" id="ellipse5-5-5-9-2-1-3-4" style="stroke-width:1.82202"/>
<g id="g12-2-3-2-6-7" transform="matrix(1.3286857,0,0,1.3286857,15.786725,-131.79559)">
<path d="m 740.53601,-40.469391 14,-14 q 3,-2 1,2 l -11,14 q -2,2 -4,1 z" fill="#fafafa" id="path3-9-7-7-9-1-6"/>
<path d="m 738.53601,-34.469391 10,-10 q 2,-2 1,1 l -8,11 q -2,2 -3,1 z" fill="#fafafa" id="path4-73-0-5-3-0-5"/>
</g>
<g id="g12-2-2-9-1-6-6" transform="matrix(-1.3286857,0,0,1.3286857,1924.6621,-131.79559)">
<path d="m 740.53601,-40.469391 14,-14 q 3,-2 1,2 l -11,14 q -2,2 -4,1 z" fill="#fafafa" id="path3-9-7-6-2-9-3-9"/>
<path d="m 738.53601,-34.469391 10,-10 q 2,-2 1,1 l -8,11 q -2,2 -3,1 z" fill="#fafafa" id="path4-73-0-1-2-4-2-3"/>
</g>
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 2.9 KiB

+25 -55
View File
@@ -6,7 +6,6 @@ import { getInvoice, getInvoiceBolt11 } from "@/lib/api"
import { tenantNeedsPaymentSetup } from "@/lib/hooks"
type PayStatus = "idle" | "loading" | "success" | "error"
type Bolt11Status = "idle" | "loading" | "ready" | "error"
type PaymentInvoice = {
id: string
@@ -22,34 +21,20 @@ type PaymentDialogProps = {
export default function PaymentDialog(props: PaymentDialogProps) {
const [bolt11, setBolt11] = createSignal("")
const [qrDataUrl, setQrDataUrl] = createSignal("")
const [bolt11Status, setBolt11Status] = createSignal<Bolt11Status>("idle")
const [bolt11Error, setBolt11Error] = createSignal("")
const [payStatus, setPayStatus] = createSignal<PayStatus>("idle")
const [payError, setPayError] = createSignal("")
const [showSetup, setShowSetup] = createSignal(false)
const [showPaymentSetup, setShowPaymentSetup] = createSignal(false)
async function loadBolt11() {
if (!props.invoice.id) return
setBolt11Status("loading")
setBolt11Error("")
setBolt11("")
setQrDataUrl("")
createEffect(async () => {
if (!props.open || !props.invoice.id) return
try {
const { bolt11: invoice } = await getInvoiceBolt11(props.invoice.id)
setBolt11(invoice)
setQrDataUrl(await QRCode.toDataURL(invoice, { width: 256, margin: 2 }))
setBolt11Status("ready")
} catch (e) {
setBolt11Status("error")
setBolt11Error(e instanceof Error ? e.message : "Failed to generate Lightning invoice")
} catch {
// bolt11 generation may fail
}
}
createEffect(() => {
if (!props.open || !props.invoice.id) return
void loadBolt11()
})
function copyBolt11() {
@@ -77,8 +62,6 @@ export default function PaymentDialog(props: PaymentDialogProps) {
function handleClose() {
setPayStatus("idle")
setPayError("")
setBolt11Status("idle")
setBolt11Error("")
setBolt11("")
setQrDataUrl("")
setShowSetup(false)
@@ -121,46 +104,33 @@ export default function PaymentDialog(props: PaymentDialogProps) {
when={payStatus() === "success"}
fallback={
<div class="w-full space-y-3">
<Show when={bolt11Status() === "idle" || bolt11Status() === "loading"}>
<div class="flex items-center justify-center py-12 text-sm text-gray-400">Generating invoice...</div>
<Show
when={qrDataUrl()}
fallback={<div class="flex items-center justify-center py-12 text-sm text-gray-400">Generating invoice...</div>}
>
<img src={qrDataUrl()} alt="Lightning invoice QR code" class="mx-auto rounded-lg" />
</Show>
<Show when={bolt11Status() === "error"}>
<div class="rounded-lg border border-red-200 bg-red-50 p-4">
<p class="text-sm font-medium text-red-700">Unable to generate invoice</p>
<p class="mt-1 text-xs text-red-600 wrap-break-word">{bolt11Error()}</p>
<Show when={bolt11()}>
<div class="flex rounded-lg border border-gray-300">
<input
type="text"
readOnly
value={bolt11()}
class="min-w-0 flex-1 rounded-l-lg border-0 px-3 py-2 text-xs text-gray-500 bg-transparent focus:outline-none"
/>
<button
type="button"
onClick={() => void loadBolt11()}
class="mt-3 inline-flex items-center rounded-lg bg-red-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-red-700"
class="flex items-center px-3 text-gray-400 hover:text-gray-700"
onClick={copyBolt11}
title="Copy invoice"
>
Retry
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="9" y="9" width="13" height="13" rx="2" />
<path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1" />
</svg>
</button>
</div>
</Show>
<Show when={bolt11Status() === "ready"}>
<img src={qrDataUrl()} alt="Lightning invoice QR code" class="mx-auto rounded-lg" />
<Show when={bolt11()}>
<div class="flex rounded-lg border border-gray-300">
<input
type="text"
readOnly
value={bolt11()}
class="min-w-0 flex-1 rounded-l-lg border-0 px-3 py-2 text-xs text-gray-500 bg-transparent focus:outline-none"
/>
<button
type="button"
class="flex items-center px-3 text-gray-400 hover:text-gray-700"
onClick={copyBolt11}
title="Copy invoice"
>
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="9" y="9" width="13" height="13" rx="2" />
<path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1" />
</svg>
</button>
</div>
</Show>
</Show>
</div>
}
>
@@ -218,7 +188,7 @@ export default function PaymentDialog(props: PaymentDialogProps) {
<button
type="button"
onClick={checkPayment}
disabled={payStatus() === "loading" || bolt11Status() !== "ready"}
disabled={payStatus() === "loading"}
class="py-2 px-4 bg-blue-600 text-white text-sm font-medium rounded-lg hover:bg-blue-700 disabled:opacity-50 transition-colors"
>
{payStatus() === "loading" ? "Checking..." : "Complete Payment"}
-2
View File
@@ -145,8 +145,6 @@ export async function makeAuth(): Promise<string | undefined> {
kind: 27235,
content: "",
created_at: Math.floor(now / 1000),
// Intentional session-style auth: sign the API base URL once, then reuse
// the header briefly to avoid prompting the signer on every request.
tags: [["u", API_URL]],
})
+7 -46
View File
@@ -8,9 +8,6 @@ import Modal from "@/components/Modal"
import Login from "@/views/Login"
import { createRelayForActiveTenant } from "@/lib/hooks"
import { account } from "@/lib/state"
import FlotillaLogo from "@/assets/flotilla-logo.svg"
import ChachiLogo from "@/assets/chachi-logo.svg"
import NostordLogo from "@/assets/nostord-logo.svg"
export default function Home() {
const navigate = useNavigate()
@@ -216,7 +213,7 @@ export default function Home() {
</p>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Flotilla */}
<a
href="https://flotilla.social"
@@ -226,7 +223,9 @@ export default function Home() {
>
<div class="flex items-start justify-between gap-4">
<div class="flex items-center gap-3">
<img src={FlotillaLogo} alt="Flotilla" class="w-12 h-12 rounded-2xl shadow-md shadow-blue-200" />
<div class="w-12 h-12 rounded-2xl bg-gradient-to-br from-blue-500 to-indigo-600 flex items-center justify-center text-white font-bold text-xl shadow-md shadow-blue-200">
F
</div>
<div>
<h3 class="text-lg font-bold text-gray-900 group-hover:text-blue-600 transition-colors">Flotilla</h3>
<p class="text-xs text-gray-400">flotilla.social</p>
@@ -264,7 +263,9 @@ export default function Home() {
>
<div class="flex items-start justify-between gap-4">
<div class="flex items-center gap-3">
<img src={ChachiLogo} alt="Chachi" class="w-12 h-12 rounded-2xl shadow-md shadow-purple-200" />
<div class="w-12 h-12 rounded-2xl bg-gradient-to-br from-purple-500 to-pink-500 flex items-center justify-center text-white font-bold text-xl shadow-md shadow-purple-200">
C
</div>
<div>
<h3 class="text-lg font-bold text-gray-900 group-hover:text-purple-600 transition-colors">Chachi</h3>
<p class="text-xs text-gray-400">chachi.chat</p>
@@ -292,45 +293,6 @@ export default function Home() {
</span>
</div>
</a>
{/* Nostrord */}
<a
href="https://nostrord.com/"
target="_blank"
rel="noopener noreferrer"
class="group flex flex-col gap-5 rounded-2xl border border-gray-200 bg-white p-8 hover:border-amber-300 hover:shadow-md transition-all"
>
<div class="flex items-start justify-between gap-4">
<div class="flex items-center gap-3">
<img src={NostordLogo} alt="Nostrord" class="w-12 h-12 rounded-2xl shadow-md shadow-amber-200" />
<div>
<h3 class="text-lg font-bold text-gray-900 group-hover:text-amber-600 transition-colors">Nostrord</h3>
<p class="text-xs text-gray-400">nostrord.com</p>
</div>
</div>
<span class="text-gray-300 group-hover:text-amber-400 transition-colors mt-1">
<ExternalLinkIcon />
</span>
</div>
<p class="text-sm text-gray-600 leading-relaxed">
A NIP-29 client built for decentralized group chat on Nostr. Create
censorship-resistant communities with admin roles, moderation, and access
controlall powered by your relay.
</p>
<div class="space-y-2">
{["Decentralized group chat with NIP-29", "Censorship-resistant communities", "Admin roles & moderation"].map(f => (
<div class="flex items-start gap-2 text-sm text-gray-600">
<CheckIcon />
{f}
</div>
))}
</div>
<div class="mt-auto pt-2">
<span class="inline-flex items-center gap-1.5 text-sm font-semibold text-amber-600">
Visit nostrord.com <ExternalLinkIcon />
</span>
</div>
</a>
</div>
</section>
@@ -376,7 +338,6 @@ export default function Home() {
<div class="flex gap-4">
<a href="https://flotilla.social" target="_blank" rel="noopener noreferrer" class="hover:text-gray-600 transition-colors">Flotilla</a>
<a href="https://chachi.chat" target="_blank" rel="noopener noreferrer" class="hover:text-gray-600 transition-colors">Chachi</a>
<a href="https://nostrord.com/" target="_blank" rel="noopener noreferrer" class="hover:text-gray-600 transition-colors">Nostrord</a>
</div>
</div>
</footer>