Compare commits

..

4 Commits

Author SHA1 Message Date
userAdityaa 0775f867e8 feat: open payment modal immediately on relay plan upgrade 2026-05-07 19:34:13 +05:45
Jon Staab dbe25c372f Conflate id and schema 2026-05-05 17:47:13 -07:00
userAdityaa 80a86452d0 chore: encrypt tenant NWC URL at rest and stop secret exposure in tenant APIs (#58)
Co-authored-by: userAdityaa <aditya.chaudhary1558@gmail.com>
Co-committed-by: userAdityaa <aditya.chaudhary1558@gmail.com>
2026-05-05 20:42:12 +00:00
userAdityaa b1e3747ddb fix: manual Lightning payment reconciliation with Stripe invoice state (#54)
Reviewed-on: coracle/caravel#54
Co-authored-by: userAdityaa <aditya.chaudhary1558@gmail.com>
Co-committed-by: userAdityaa <aditya.chaudhary1558@gmail.com>
2026-05-01 23:38:57 +00:00
20 changed files with 168 additions and 159 deletions
+1 -1
View File
@@ -28,6 +28,6 @@ LIVEKIT_API_SECRET=
# Billing # Billing
NWC_URL= # Nostr Wallet Connect URL for generating Lightning invoices NWC_URL= # Nostr Wallet Connect URL for generating Lightning invoices
ENCRYPTION_SECRET= # Nostr secret key (hex or nsec) used for NIP-44 encryption of tenant nwc_url at rest 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_SECRET_KEY= # Required Stripe API secret key (sk_...)
STRIPE_WEBHOOK_SECRET=whsec_test_00000000000000000000000000 # Webhook signing secret (use real value in production) STRIPE_WEBHOOK_SECRET=whsec_test_00000000000000000000000000 # Webhook signing secret (use real value in production)
+24 -24
View File
@@ -30,30 +30,30 @@ backend/
Environment variables: Environment variables:
| Variable | Description | Default | | Variable | Description | Default |
| ------------------------ | --------------------------------------------------------------------------------------- | ---------------------------------------- | | ------------------------ | ----------------------------------------------------------------------- | ------------------------------------ |
| `DATABASE_URL` | SQLite URL. Relative paths are resolved under `backend/`. | `sqlite://<backend>/data/caravel.db` | | `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` | | `HOST` | API bind host (also used for NIP-98 `u` host check) | `127.0.0.1` |
| `PORT` | API bind port | `2892` | | `PORT` | API bind port | `2892` |
| `ADMINS` | Comma-separated admin pubkeys (hex) | _optional_ | | `ADMINS` | Comma-separated admin pubkeys (hex) | _optional_ |
| `ALLOW_ORIGINS` | Comma-separated CORS origins. If empty, CORS is permissive. | _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_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_ | | `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 | | `RELAY_DOMAIN` | Base domain appended to relay subdomains | empty |
| `LIVEKIT_URL` | LiveKit URL sent to zooid when relay livekit is enabled | _optional_ | | `LIVEKIT_URL` | LiveKit URL sent to zooid when relay livekit is enabled | _optional_ |
| `LIVEKIT_API_KEY` | LiveKit API key sent to zooid | _optional_ | | `LIVEKIT_API_KEY` | LiveKit API key sent to zooid | _optional_ |
| `LIVEKIT_API_SECRET` | LiveKit API secret sent to zooid | _optional_ | | `LIVEKIT_API_SECRET` | LiveKit API secret sent to zooid | _optional_ |
| `NWC_URL` | Platform NWC URL used to generate BOLT11 invoices | _required for invoice generation_ | | `NWC_URL` | Platform NWC URL used to generate BOLT11 invoices | _required for invoice generation_ |
| `ENCRYPTION_SECRET` | Nostr secret key (hex or `nsec`) used for NIP-44 encryption of tenant `nwc_url` at rest | _required when tenant `nwc_url` is used_ | | `ENCRYPTION_SECRET` | Nostr secret key (hex or nsec) used to encrypt tenant NWC URLs at rest | _required_ |
| `STRIPE_SECRET_KEY` | Stripe API secret key used for billing API operations | _required_ | | `STRIPE_SECRET_KEY` | Stripe API secret key used for billing API operations | _required_ |
| `STRIPE_WEBHOOK_SECRET` | Stripe webhook signing secret used to verify `Stripe-Signature` headers | _required_ | | `STRIPE_WEBHOOK_SECRET` | Stripe webhook signing secret used to verify `Stripe-Signature` headers | _required_ |
| `ROBOT_SECRET` | Robot Nostr secret key | _required_ | | `ROBOT_SECRET` | Robot Nostr secret key | _required_ |
| `ROBOT_NAME` | Robot display name (kind `0`) | _optional_ | | `ROBOT_NAME` | Robot display name (kind `0`) | _optional_ |
| `ROBOT_DESCRIPTION` | Robot description (kind `0`) | _optional_ | | `ROBOT_DESCRIPTION` | Robot description (kind `0`) | _optional_ |
| `ROBOT_PICTURE` | Robot picture URL (kind `0`) | _optional_ | | `ROBOT_PICTURE` | Robot picture URL (kind `0`) | _optional_ |
| `ROBOT_OUTBOX_RELAYS` | Comma-separated relays published as kind `10002` | _required_ | | `ROBOT_OUTBOX_RELAYS` | Comma-separated relays published as kind `10002` | _required_ |
| `ROBOT_INDEXER_RELAYS` | Comma-separated relays used for recipient relay discovery | _required_ | | `ROBOT_INDEXER_RELAYS` | Comma-separated relays used for recipient relay discovery | _required_ |
| `ROBOT_MESSAGING_RELAYS` | Comma-separated relays published as kind `10050` | _required_ | | `ROBOT_MESSAGING_RELAYS` | Comma-separated relays published as kind `10050` | _required_ |
Relay list env vars are comma-separated and trimmed. If a relay has no `ws://` or `wss://` scheme, `wss://` is prepended. Relay list env vars are comma-separated and trimmed. If a relay has no `ws://` or `wss://` scheme, `wss://` is prepended.
+5 -4
View File
@@ -57,7 +57,7 @@ Notes:
- Serves `GET /tenants` - Serves `GET /tenants`
- Authorizes admin only - Authorizes admin only
- Return `data` is a list of tenant structs from `query.list_tenants` - Return `data` is a list of `TenantResponse` structs (contains `nwc_is_set: bool` instead of `nwc_url`)
## `async fn create_tenant(...) -> Response` ## `async fn create_tenant(...) -> Response`
@@ -69,20 +69,21 @@ Notes:
- On unique-constraint race (`pubkey-exists`), re-fetch and return the existing tenant - On unique-constraint race (`pubkey-exists`), re-fetch and return the existing tenant
- If Stripe customer creation fails, return `code=stripe-customer-create-failed` - If Stripe customer creation fails, return `code=stripe-customer-create-failed`
- Always returns `200` (create-or-get is uniform) - Always returns `200` (create-or-get is uniform)
- Return `data` is a single `Tenant` struct - Return `data` is a single `TenantResponse` struct (contains `nwc_is_set: bool` instead of `nwc_url`)
## `async fn get_tenant(...) -> Response` ## `async fn get_tenant(...) -> Response`
- Serves `GET /tenants/:pubkey` - Serves `GET /tenants/:pubkey`
- Authorizes admin or matching tenant - Authorizes admin or matching tenant
- Return `data` is a single tenant struct from `query.get_tenant` - Return `data` is a single `TenantResponse` struct (contains `nwc_is_set: bool` instead of `nwc_url`)
## `async fn update_tenant(...) -> Response` ## `async fn update_tenant(...) -> Response`
- Serves `PUT /tenants/:pubkey` - Serves `PUT /tenants/:pubkey`
- Authorizes admin or matching tenant - Authorizes admin or matching tenant
- Accepts `nwc_url` in the request body; encrypts it before storage using `cipher::encrypt`
- Updates tenant using `command.update_tenant` - Updates tenant using `command.update_tenant`
- Return `data` is the updated tenant struct - Return `data` is the updated `TenantResponse` struct (contains `nwc_is_set: bool` instead of `nwc_url`)
## `async fn list_tenant_relays(...) -> Response` ## `async fn list_tenant_relays(...) -> Response`
+3 -3
View File
@@ -52,7 +52,7 @@ There are three plans available:
Tenants are customers of the service, identified by a nostr `pubkey`. Public metadata like name etc are pulled from the nostr network. They also have associated billing information. Tenants are customers of the service, identified by a nostr `pubkey`. Public metadata like name etc are pulled from the nostr network. They also have associated billing information.
- `pubkey` is the nostr public key identifying the tenant - `pubkey` is the nostr public key identifying the tenant
- `nwc_url` (private) a nostr wallet connect URL used for **paying** invoices generated by the system on the tenant's behalf - `nwc_url` (private) a nostr wallet connect URL used for **paying** invoices generated by the system on the tenant's behalf; stored encrypted at rest using NIP-44 via `ENCRYPTION_SECRET`; never serialized to API responses — tenant API endpoints expose `nwc_is_set: bool` instead
- `nwc_error` (private) a string indicating the most recent NWC payment error, if any. Cleared on successful NWC payment. - `nwc_error` (private) a string indicating the most recent NWC payment error, if any. Cleared on successful NWC payment.
- `created_at` unix timestamp identifying tenant creation time - `created_at` unix timestamp identifying tenant creation time
- `stripe_customer_id` a string identifying the associated stripe customer - `stripe_customer_id` a string identifying the associated stripe customer
@@ -63,9 +63,9 @@ Tenants are customers of the service, identified by a nostr `pubkey`. Public met
A relay is a nostr relay owned by a `tenant` and hosted by the attached zooid instance. Relay subdomains MUST be unique. A relay is a nostr relay owned by a `tenant` and hosted by the attached zooid instance. Relay subdomains MUST be unique.
- `id` - a random ID identifying the relay - `id` - calculated based on `subdomain` + 8 random hex chars
- `tenant` - the tenant's pubkey - `tenant` - the tenant's pubkey
- `schema` - the relay's db schema (read_only, calculated based on `subdomain` + `id`) - `schema` - the relay's db schema (read only, same as `id`)
- `subdomain` - the relay's subdomain - `subdomain` - the relay's subdomain
- `plan` - the relay's plan - `plan` - the relay's plan
- `stripe_subscription_item_id` (nullable) - the Stripe subscription item id. Only set for relays on paid plans. - `stripe_subscription_item_id` (nullable) - the Stripe subscription item id. Only set for relays on paid plans.
+41 -29
View File
@@ -275,9 +275,6 @@ impl Api {
return Err(RelayValidationError::PremiumFeature); return Err(RelayValidationError::PremiumFeature);
} }
if relay.schema.is_empty() {
relay.schema = format!("{}_{}", relay.subdomain.replace('-', "_"), relay.id);
}
if relay.status.is_empty() { if relay.status.is_empty() {
relay.status = RELAY_STATUS_ACTIVE.to_string(); relay.status = RELAY_STATUS_ACTIVE.to_string();
} }
@@ -448,7 +445,7 @@ struct IdentityResponse {
#[derive(Serialize)] #[derive(Serialize)]
struct TenantResponse { struct TenantResponse {
pubkey: String, pubkey: String,
nwc_configured: bool, nwc_is_set: bool,
nwc_error: Option<String>, nwc_error: Option<String>,
created_at: i64, created_at: i64,
stripe_customer_id: String, stripe_customer_id: String,
@@ -456,6 +453,20 @@ struct TenantResponse {
past_due_at: Option<i64>, 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)] #[derive(Deserialize)]
struct CreateRelayRequest { struct CreateRelayRequest {
tenant: String, tenant: String,
@@ -497,7 +508,13 @@ async fn list_tenants(
state.api.require_admin(&pubkey)?; state.api.require_admin(&pubkey)?;
match state.api.query.list_tenants().await { match state.api.query.list_tenants().await {
Ok(tenants) => Ok(ok(StatusCode::OK, scrub_tenants_for_response(tenants))), Ok(tenants) => Ok(ok(
StatusCode::OK,
tenants
.into_iter()
.map(TenantResponse::from)
.collect::<Vec<_>>(),
)),
Err(e) => Ok(err( Err(e) => Ok(err(
StatusCode::INTERNAL_SERVER_ERROR, StatusCode::INTERNAL_SERVER_ERROR,
"internal", "internal",
@@ -526,7 +543,7 @@ async fn create_tenant(
let pubkey = state.api.extract_auth_pubkey(&headers)?; let pubkey = state.api.extract_auth_pubkey(&headers)?;
match state.api.query.get_tenant(&pubkey).await { match state.api.query.get_tenant(&pubkey).await {
Ok(Some(t)) => Ok(ok(StatusCode::OK, scrub_tenant_for_response(t))), Ok(Some(t)) => Ok(ok(StatusCode::OK, TenantResponse::from(t))),
Ok(None) => { Ok(None) => {
let stripe_customer_id = match state.api.billing.stripe_create_customer(&pubkey).await { let stripe_customer_id = match state.api.billing.stripe_create_customer(&pubkey).await {
Ok(id) => id, Ok(id) => id,
@@ -550,10 +567,10 @@ async fn create_tenant(
}; };
match state.api.command.create_tenant(&tenant).await { match state.api.command.create_tenant(&tenant).await {
Ok(()) => Ok(ok(StatusCode::OK, scrub_tenant_for_response(tenant))), Ok(()) => Ok(ok(StatusCode::OK, TenantResponse::from(tenant))),
Err(e) if matches!(map_unique_error(&e), Some("pubkey-exists")) => { Err(e) if matches!(map_unique_error(&e), Some("pubkey-exists")) => {
match state.api.query.get_tenant(&pubkey).await { match state.api.query.get_tenant(&pubkey).await {
Ok(Some(t)) => Ok(ok(StatusCode::OK, scrub_tenant_for_response(t))), Ok(Some(t)) => Ok(ok(StatusCode::OK, TenantResponse::from(t))),
Ok(None) => Ok(err( Ok(None) => Ok(err(
StatusCode::INTERNAL_SERVER_ERROR, StatusCode::INTERNAL_SERVER_ERROR,
"internal", "internal",
@@ -596,7 +613,7 @@ async fn get_tenant(
let auth = state.api.extract_auth_pubkey(&headers)?; let auth = state.api.extract_auth_pubkey(&headers)?;
state.api.require_admin_or_tenant(&auth, &pubkey)?; state.api.require_admin_or_tenant(&auth, &pubkey)?;
let tenant = state.api.get_tenant_or_404(&pubkey).await?; let tenant = state.api.get_tenant_or_404(&pubkey).await?;
Ok(ok(StatusCode::OK, scrub_tenant_for_response(tenant))) Ok(ok(StatusCode::OK, TenantResponse::from(tenant)))
} }
async fn list_relays( async fn list_relays(
@@ -734,10 +751,16 @@ async fn create_relay(
let auth = state.api.extract_auth_pubkey(&headers)?; let auth = state.api.extract_auth_pubkey(&headers)?;
state.api.require_admin_or_tenant(&auth, &payload.tenant)?; state.api.require_admin_or_tenant(&auth, &payload.tenant)?;
let relay_id = format!(
"{}_{}",
payload.subdomain.replace('-', "_"),
&uuid::Uuid::new_v4().simple().to_string()[..8]
);
let mut relay = Relay { let mut relay = Relay {
id: uuid::Uuid::new_v4().to_string(), id: relay_id.clone(),
tenant: payload.tenant, tenant: payload.tenant,
schema: String::new(), schema: relay_id.clone(),
subdomain: payload.subdomain, subdomain: payload.subdomain,
plan: payload.plan, plan: payload.plan,
stripe_subscription_item_id: None, stripe_subscription_item_id: None,
@@ -1114,7 +1137,12 @@ async fn update_tenant(
let nwc_previously_empty = tenant.nwc_url.is_empty(); let nwc_previously_empty = tenant.nwc_url.is_empty();
if let Some(nwc_url) = payload.nwc_url { if let Some(nwc_url) = payload.nwc_url {
tenant.nwc_url = nwc_url; if nwc_url.is_empty() {
tenant.nwc_url = String::new();
} else {
tenant.nwc_url =
crate::cipher::encrypt(&nwc_url).map_err(|e| ApiError::Internal(e.to_string()))?;
}
} }
match state.api.command.update_tenant(&tenant).await { match state.api.command.update_tenant(&tenant).await {
@@ -1133,7 +1161,7 @@ async fn update_tenant(
} }
}); });
} }
Ok(ok(StatusCode::OK, scrub_tenant_for_response(tenant))) Ok(ok(StatusCode::OK, TenantResponse::from(tenant)))
} }
Err(e) => Ok(err( Err(e) => Ok(err(
StatusCode::INTERNAL_SERVER_ERROR, StatusCode::INTERNAL_SERVER_ERROR,
@@ -1142,19 +1170,3 @@ async fn update_tenant(
)), )),
} }
} }
fn scrub_tenant_for_response(tenant: Tenant) -> TenantResponse {
TenantResponse {
pubkey: tenant.pubkey,
nwc_configured: !tenant.nwc_url.is_empty(),
nwc_error: tenant.nwc_error,
created_at: tenant.created_at,
stripe_customer_id: tenant.stripe_customer_id,
stripe_subscription_id: tenant.stripe_subscription_id,
past_due_at: tenant.past_due_at,
}
}
fn scrub_tenants_for_response(tenants: Vec<Tenant>) -> Vec<TenantResponse> {
tenants.into_iter().map(scrub_tenant_for_response).collect()
}
+9 -9
View File
@@ -151,11 +151,7 @@ impl Billing {
return Ok(()); return Ok(());
} }
tracing::info!( tracing::info!(source, relay_count = relays.len(), "reconciling relay billing state");
source,
relay_count = relays.len(),
"reconciling relay billing state"
);
for relay in relays { for relay in relays {
if let Err(error) = self.sync_relay_subscription_for_relay(&relay).await { if let Err(error) = self.sync_relay_subscription_for_relay(&relay).await {
@@ -426,13 +422,14 @@ impl Billing {
// 1. NWC auto-pay: if the tenant has a nwc_url // 1. NWC auto-pay: if the tenant has a nwc_url
if !tenant.nwc_url.is_empty() { if !tenant.nwc_url.is_empty() {
let plain_nwc_url = crate::cipher::decrypt(&tenant.nwc_url)?;
match self match self
.nwc_pay_invoice( .nwc_pay_invoice(
invoice_id, invoice_id,
&tenant.pubkey, &tenant.pubkey,
amount_due, amount_due,
currency, currency,
&tenant.nwc_url, &plain_nwc_url,
) )
.await? .await?
{ {
@@ -861,6 +858,8 @@ impl Billing {
return Ok(()); return Ok(());
} }
let plain_nwc_url = crate::cipher::decrypt(&tenant.nwc_url)?;
let invoices = self let invoices = self
.stripe_list_invoices(&tenant.stripe_customer_id) .stripe_list_invoices(&tenant.stripe_customer_id)
.await?; .await?;
@@ -882,7 +881,7 @@ impl Billing {
&tenant.pubkey, &tenant.pubkey,
amount_due, amount_due,
currency, currency,
&tenant.nwc_url, &plain_nwc_url,
) )
.await? .await?
{ {
@@ -999,7 +998,8 @@ impl Billing {
customer_id: &str, customer_id: &str,
price_id: &str, price_id: &str,
) -> Result<(String, String)> { ) -> Result<(String, String)> {
let idempotency_key = self.idempotency_key(&["create_subscription", customer_id, price_id]); let idempotency_key =
self.idempotency_key(&["create_subscription", customer_id, price_id]);
let resp = self let resp = self
.http .http
.post(format!("{STRIPE_API}/subscriptions")) .post(format!("{STRIPE_API}/subscriptions"))
@@ -1726,5 +1726,5 @@ mod tests {
assert_eq!(billing.stripe_secret_key, "sk_test_dummy"); assert_eq!(billing.stripe_secret_key, "sk_test_dummy");
assert_eq!(billing.stripe_webhook_secret, "whsec_test_dummy"); assert_eq!(billing.stripe_webhook_secret, "whsec_test_dummy");
} }
}
}
+28
View File
@@ -0,0 +1,28 @@
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}"))
}
+2 -5
View File
@@ -2,7 +2,6 @@ use anyhow::Result;
use sqlx::{Sqlite, SqlitePool, Transaction}; use sqlx::{Sqlite, SqlitePool, Transaction};
use tokio::sync::broadcast; use tokio::sync::broadcast;
use crate::crypto;
use crate::models::{ use crate::models::{
Activity, RELAY_STATUS_ACTIVE, RELAY_STATUS_DELINQUENT, RELAY_STATUS_INACTIVE, Relay, Tenant, Activity, RELAY_STATUS_ACTIVE, RELAY_STATUS_DELINQUENT, RELAY_STATUS_INACTIVE, Relay, Tenant,
}; };
@@ -71,7 +70,6 @@ impl Command {
anyhow::bail!("stripe_customer_id is required"); anyhow::bail!("stripe_customer_id is required");
} }
let encrypted_nwc_url = crypto::encrypt(&tenant.nwc_url)?;
let mut tx = self.pool.begin().await?; let mut tx = self.pool.begin().await?;
sqlx::query( sqlx::query(
@@ -79,7 +77,7 @@ impl Command {
VALUES (?, ?, ?, ?)", VALUES (?, ?, ?, ?)",
) )
.bind(&tenant.pubkey) .bind(&tenant.pubkey)
.bind(&encrypted_nwc_url) .bind(&tenant.nwc_url)
.bind(tenant.created_at) .bind(tenant.created_at)
.bind(&tenant.stripe_customer_id) .bind(&tenant.stripe_customer_id)
.execute(&mut *tx) .execute(&mut *tx)
@@ -94,11 +92,10 @@ impl Command {
} }
pub async fn update_tenant(&self, tenant: &Tenant) -> Result<()> { pub async fn update_tenant(&self, tenant: &Tenant) -> Result<()> {
let encrypted_nwc_url = crypto::encrypt(&tenant.nwc_url)?;
let mut tx = self.pool.begin().await?; let mut tx = self.pool.begin().await?;
sqlx::query("UPDATE tenant SET nwc_url = ? WHERE pubkey = ?") sqlx::query("UPDATE tenant SET nwc_url = ? WHERE pubkey = ?")
.bind(&encrypted_nwc_url) .bind(&tenant.nwc_url)
.bind(&tenant.pubkey) .bind(&tenant.pubkey)
.execute(&mut *tx) .execute(&mut *tx)
.await?; .await?;
-49
View File
@@ -1,49 +0,0 @@
use anyhow::{Result, anyhow};
use nostr_sdk::prelude::{Keys, nip44};
const ENVELOPE_PREFIX: &str = "enc:nip44:v2:";
pub fn encrypt(value: &str) -> Result<String> {
if value.is_empty() {
return Ok(String::new());
}
let keys = parse_encryption_keys()?;
let payload = nip44::encrypt(
keys.secret_key(),
&keys.public_key(),
value,
nip44::Version::V2,
)
.map_err(|e| anyhow!("encrypt failed: {e}"))?;
Ok(format!("{ENVELOPE_PREFIX}{payload}"))
}
pub fn decrypt(value: &str) -> Result<String> {
if value.is_empty() {
return Ok(String::new());
}
let Some(payload) = value.strip_prefix(ENVELOPE_PREFIX) else {
return Ok(value.to_string());
};
let keys = parse_encryption_keys()?;
nip44::decrypt(keys.secret_key(), &keys.public_key(), payload)
.map_err(|e| anyhow!("decrypt failed: {e}"))
}
fn parse_encryption_keys() -> Result<Keys> {
let raw = std::env::var("ENCRYPTION_SECRET")
.map_err(|_| anyhow!("missing ENCRYPTION_SECRET environment variable"))?;
let trimmed = raw.trim();
if trimmed.is_empty() {
return Err(anyhow!("missing ENCRYPTION_SECRET environment variable"));
}
Keys::parse(trimmed).map_err(|e| {
anyhow!("ENCRYPTION_SECRET must be a valid nostr secret key (hex or nsec): {e}")
})
}
+1 -5
View File
@@ -106,11 +106,7 @@ impl Infra {
return Ok(()); return Ok(());
} }
tracing::info!( tracing::info!(source, relay_count = relays.len(), "reconciling pending relay state");
source,
relay_count = relays.len(),
"reconciling pending relay state"
);
for relay in relays { for relay in relays {
if relay.sync_error.trim().is_empty() { if relay.sync_error.trim().is_empty() {
+1 -1
View File
@@ -1,7 +1,7 @@
pub mod api; pub mod api;
pub mod billing; pub mod billing;
pub mod cipher;
pub mod command; pub mod command;
pub mod crypto;
pub mod infra; pub mod infra;
pub mod models; pub mod models;
pub mod pool; pub mod pool;
+1 -1
View File
@@ -1,7 +1,7 @@
mod api; mod api;
mod billing; mod billing;
mod cipher;
mod command; mod command;
mod crypto;
mod infra; mod infra;
mod models; mod models;
mod pool; mod pool;
+3 -9
View File
@@ -1,7 +1,6 @@
use anyhow::Result; use anyhow::Result;
use sqlx::SqlitePool; use sqlx::SqlitePool;
use crate::crypto;
use crate::models::{Activity, Plan, Relay, Tenant}; use crate::models::{Activity, Plan, Relay, Tenant};
#[derive(Clone)] #[derive(Clone)]
@@ -22,7 +21,7 @@ impl Query {
) )
.fetch_all(&self.pool) .fetch_all(&self.pool)
.await?; .await?;
rows.into_iter().map(decrypt_tenant_nwc_url).collect() Ok(rows)
} }
pub async fn get_tenant(&self, pubkey: &str) -> Result<Option<Tenant>> { pub async fn get_tenant(&self, pubkey: &str) -> Result<Option<Tenant>> {
@@ -34,7 +33,7 @@ impl Query {
.bind(pubkey) .bind(pubkey)
.fetch_optional(&self.pool) .fetch_optional(&self.pool)
.await?; .await?;
row.map(decrypt_tenant_nwc_url).transpose() Ok(row)
} }
pub fn list_plans() -> Vec<Plan> { pub fn list_plans() -> Vec<Plan> {
@@ -159,7 +158,7 @@ impl Query {
.bind(stripe_customer_id) .bind(stripe_customer_id)
.fetch_optional(&self.pool) .fetch_optional(&self.pool)
.await?; .await?;
row.map(decrypt_tenant_nwc_url).transpose() Ok(row)
} }
pub async fn get_invoice_nwc_payment_state(&self, invoice_id: &str) -> Result<Option<String>> { pub async fn get_invoice_nwc_payment_state(&self, invoice_id: &str) -> Result<Option<String>> {
@@ -225,8 +224,3 @@ impl Query {
Ok(found.is_some()) Ok(found.is_some())
} }
} }
fn decrypt_tenant_nwc_url(mut tenant: Tenant) -> Result<Tenant> {
tenant.nwc_url = crypto::decrypt(&tenant.nwc_url)?;
Ok(tenant)
}
-1
View File
@@ -99,7 +99,6 @@ export type UpdateRelayInput = {
export type Tenant = { export type Tenant = {
pubkey: string pubkey: string
nwc_url: string nwc_url: string
nwc_configured: boolean
created_at: number created_at: number
stripe_customer_id: string stripe_customer_id: string
stripe_subscription_id: string | null stripe_subscription_id: string | null
+1 -1
View File
@@ -135,7 +135,7 @@ export const reactivateRelayById = (id: string) => reactivateRelay(id)
export async function tenantNeedsPaymentSetup(): Promise<boolean> { export async function tenantNeedsPaymentSetup(): Promise<boolean> {
const tenant = await getTenant(account()!.pubkey) const tenant = await getTenant(account()!.pubkey)
return !tenant.nwc_configured && !tenant.stripe_subscription_id return !tenant.nwc_url && !tenant.stripe_subscription_id
} }
export async function getLatestOpenInvoice(): Promise<Invoice | null> { export async function getLatestOpenInvoice(): Promise<Invoice | null> {
+9 -4
View File
@@ -1,5 +1,5 @@
import { createSignal } from "solid-js" import { createSignal } from "solid-js"
import { updateRelayById, deactivateRelayById, reactivateRelayById, getLatestOpenInvoice, type Relay } from "@/lib/hooks" import { updateRelayById, deactivateRelayById, reactivateRelayById, getLatestOpenInvoice, tenantNeedsPaymentSetup, type Relay } from "@/lib/hooks"
import { setToastMessage } from "@/components/Toast" import { setToastMessage } from "@/components/Toast"
import type { Invoice, PlanId } from "@/lib/api" import type { Invoice, PlanId } from "@/lib/api"
@@ -31,6 +31,7 @@ export default function useRelayToggles(
) { ) {
const [busy, setBusy] = createSignal(false) const [busy, setBusy] = createSignal(false)
const [pendingInvoice, setPendingInvoice] = createSignal<Invoice | undefined>() const [pendingInvoice, setPendingInvoice] = createSignal<Invoice | undefined>()
const [pendingPaymentSetup, setPendingPaymentSetup] = createSignal(false)
async function updateRelay(next: Relay, previous: Relay) { async function updateRelay(next: Relay, previous: Relay) {
mutate(next) mutate(next)
@@ -101,8 +102,12 @@ export default function useRelayToggles(
} }
if (plan !== "free") { if (plan !== "free") {
const invoice = await getLatestOpenInvoice() const needsSetup = await tenantNeedsPaymentSetup()
if (invoice) setPendingInvoice(invoice) if (needsSetup) {
const invoice = await getLatestOpenInvoice()
if (invoice) setPendingInvoice(invoice)
setPendingPaymentSetup(true)
}
} }
} }
@@ -116,5 +121,5 @@ export default function useRelayToggles(
onToggleLivekitSupport: () => toggle("livekit_enabled", relay()?.plan !== "free"), onToggleLivekitSupport: () => toggle("livekit_enabled", relay()?.plan !== "free"),
} }
return { busy, handleDeactivate, handleReactivate, handleUpdatePlan, pendingInvoice, clearPendingInvoice: () => setPendingInvoice(undefined), toggles } return { busy, handleDeactivate, handleReactivate, handleUpdatePlan, pendingInvoice, clearPendingInvoice: () => setPendingInvoice(undefined), pendingPaymentSetup, clearPendingPaymentSetup: () => setPendingPaymentSetup(false), toggles }
} }
+3 -3
View File
@@ -18,9 +18,9 @@ export default function Account() {
const invoicesLoading = useMinLoading(() => invoices.loading) const invoicesLoading = useMinLoading(() => invoices.loading)
const hasBillingChanges = createMemo(() => { const hasBillingChanges = createMemo(() => {
const current = tenant()?.nwc_url?.trim() ?? ""
const next = nwcUrl().trim() const next = nwcUrl().trim()
if (next) return true return current !== next
return tenant()?.nwc_configured ?? false
}) })
createEffect(() => { createEffect(() => {
@@ -169,7 +169,7 @@ export default function Account() {
<p class="text-xs text-gray-500 mt-0.5">{periodLabel()}</p> <p class="text-xs text-gray-500 mt-0.5">{periodLabel()}</p>
</Show> </Show>
</div> </div>
<div class="flex items-center gap-2 shrink-0"> <div class="flex items-center gap-2 flex-shrink-0">
<Show when={isOpen()}> <Show when={isOpen()}>
<span class="text-xs text-blue-600 font-medium">Pay now</span> <span class="text-xs text-blue-600 font-medium">Pay now</span>
</Show> </Show>
+10 -3
View File
@@ -1,5 +1,5 @@
import { useParams } from "@solidjs/router" import { useParams } from "@solidjs/router"
import { createMemo, createResource, createSignal, Show } from "solid-js" import { createEffect, createMemo, createResource, createSignal, Show } from "solid-js"
import BackLink from "@/components/BackLink" import BackLink from "@/components/BackLink"
import PageContainer from "@/components/PageContainer" import PageContainer from "@/components/PageContainer"
import PaymentDialog from "@/components/PaymentDialog" import PaymentDialog from "@/components/PaymentDialog"
@@ -28,13 +28,20 @@ export default function RelayDetail() {
}) })
const loading = useMinLoading(() => relay.loading && !relay()) const loading = useMinLoading(() => relay.loading && !relay())
const [activity] = useRelayActivity(relayId) const [activity] = useRelayActivity(relayId)
const { busy, handleDeactivate, handleReactivate, handleUpdatePlan, pendingInvoice, clearPendingInvoice, toggles } = useRelayToggles(relayId, relay, { refetch, mutate }) const { busy, handleDeactivate, handleReactivate, handleUpdatePlan, pendingInvoice, clearPendingInvoice, pendingPaymentSetup, clearPendingPaymentSetup, toggles } = useRelayToggles(relayId, relay, { refetch, mutate })
const [tenant, { refetch: refetchTenant }] = useTenant() const [tenant, { refetch: refetchTenant }] = useTenant()
const [paymentSetupOpen, setPaymentSetupOpen] = createSignal(false) const [paymentSetupOpen, setPaymentSetupOpen] = createSignal(false)
const [invoiceDialogOpen, setInvoiceDialogOpen] = createSignal(false) const [invoiceDialogOpen, setInvoiceDialogOpen] = createSignal(false)
const [paymentBannerDismissed, setPaymentBannerDismissed] = createSignal(false) const [paymentBannerDismissed, setPaymentBannerDismissed] = createSignal(false)
createEffect(() => {
if (pendingPaymentSetup() && !pendingInvoice()) {
setPaymentSetupOpen(true)
clearPendingPaymentSetup()
}
})
const isPaidRelay = createMemo(() => { const isPaidRelay = createMemo(() => {
const r = relay() const r = relay()
if (!r) return false if (!r) return false
@@ -52,7 +59,7 @@ export default function RelayDetail() {
if (!isPaidRelay()) return false if (!isPaidRelay()) return false
const t = tenant() const t = tenant()
if (!t) return false if (!t) return false
return !t.nwc_configured return !t.nwc_url
}) })
return ( return (
+22 -6
View File
@@ -3,13 +3,15 @@ import { useNavigate } from "@solidjs/router"
import BackLink from "@/components/BackLink" import BackLink from "@/components/BackLink"
import PageContainer from "@/components/PageContainer" import PageContainer from "@/components/PageContainer"
import PaymentDialog from "@/components/PaymentDialog" import PaymentDialog from "@/components/PaymentDialog"
import PaymentSetup from "@/components/PaymentSetup"
import RelayForm, { type RelayFormValues } from "@/components/RelayForm" import RelayForm, { type RelayFormValues } from "@/components/RelayForm"
import { createRelayForActiveTenant, getLatestOpenInvoice } from "@/lib/hooks" import { createRelayForActiveTenant, getLatestOpenInvoice, tenantNeedsPaymentSetup } from "@/lib/hooks"
import type { Invoice } from "@/lib/api" import type { Invoice } from "@/lib/api"
export default function RelayNew() { export default function RelayNew() {
const navigate = useNavigate() const navigate = useNavigate()
const [pendingInvoice, setPendingInvoice] = createSignal<Invoice | undefined>() const [pendingInvoice, setPendingInvoice] = createSignal<Invoice | undefined>()
const [paymentSetupOpen, setPaymentSetupOpen] = createSignal(false)
let createdRelayId = "" let createdRelayId = ""
async function handleSubmit(values: RelayFormValues) { async function handleSubmit(values: RelayFormValues) {
@@ -17,9 +19,14 @@ export default function RelayNew() {
createdRelayId = relay.id createdRelayId = relay.id
if (values.plan !== "free") { if (values.plan !== "free") {
const invoice = await getLatestOpenInvoice() const needsSetup = await tenantNeedsPaymentSetup()
if (invoice) { if (needsSetup) {
setPendingInvoice(invoice) const invoice = await getLatestOpenInvoice()
if (invoice) {
setPendingInvoice(invoice)
return
}
setPaymentSetupOpen(true)
return return
} }
} }
@@ -27,8 +34,13 @@ export default function RelayNew() {
navigate(`/relays/${relay.id}`) navigate(`/relays/${relay.id}`)
} }
function handleDialogClose() { function handleInvoiceClose() {
setPendingInvoice(undefined) setPendingInvoice(undefined)
setPaymentSetupOpen(true)
}
function handleSetupClose() {
setPaymentSetupOpen(false)
navigate(`/relays/${createdRelayId}`) navigate(`/relays/${createdRelayId}`)
} }
@@ -47,10 +59,14 @@ export default function RelayNew() {
<PaymentDialog <PaymentDialog
invoice={inv()} invoice={inv()}
open={true} open={true}
onClose={handleDialogClose} onClose={handleInvoiceClose}
/> />
)} )}
</Show> </Show>
<PaymentSetup
open={paymentSetupOpen()}
onClose={handleSetupClose}
/>
</PageContainer> </PageContainer>
) )
} }
+4 -1
View File
@@ -5,6 +5,9 @@ dev:
cd frontend && bun dev & cd frontend && bun dev &
wait wait
dev-backend:
cd backend && onchange src -ik -- bash -c 'RUST_LOG=backend=info cargo run'
dev-frontend: dev-frontend:
cd frontend && bun run dev cd frontend && bun run dev
@@ -27,7 +30,7 @@ build-backend:
cd backend && cargo build cd backend && cargo build
build-frontend: build-frontend:
cd frontend && bun run build cd frontend && bun i && bun run build
fmt: fmt-backend fmt: fmt-backend