Compare commits

...

7 Commits

26 changed files with 353 additions and 143 deletions
+2 -2
View File
@@ -53,7 +53,7 @@ The rest of the defaults work as-is. `ROBOT_*`, `LIVEKIT_*`, billing, and Stripe
cp frontend/.env.template frontend/.env
```
The defaults (`VITE_API_URL=http://127.0.0.1:3000`) point at the backend and work out of the box.
The defaults (`VITE_API_URL=http://127.0.0.1:2892`) point at the backend and work out of the box.
### 4. Install dependencies and run
@@ -62,7 +62,7 @@ cd frontend && bun install && cd ..
just dev
```
This starts the backend (with auto-reload on file changes) at `http://127.0.0.1:3000` and the frontend at `http://127.0.0.1:5173`.
This starts the backend (with auto-reload on file changes) at `http://127.0.0.1:2892` and the frontend at `http://127.0.0.1:5173`.
## Project docs
+3 -3
View File
@@ -1,6 +1,6 @@
# Server
HOST=127.0.0.1
PORT=3000
PORT=2892
ALLOW_ORIGINS= # Optional comma-separated allowed CORS origins; empty = permissive
# Auth
@@ -28,5 +28,5 @@ LIVEKIT_API_SECRET=
# Billing
NWC_URL= # Nostr Wallet Connect URL for generating Lightning invoices
STRIPE_SECRET_KEY= # Stripe API secret key (sk_...)
STRIPE_WEBHOOK_SECRET= # Secret for verifying Stripe webhook signatures (whsec_...)
STRIPE_SECRET_KEY= # Required Stripe API secret key (sk_...)
STRIPE_WEBHOOK_SECRET=whsec_test_00000000000000000000000000 # Webhook signing secret (use real value in production)
+1 -1
View File
@@ -26,6 +26,6 @@ WORKDIR /app
COPY --from=build /app/target/release/backend /app/backend
EXPOSE 3000
EXPOSE 2892
CMD ["/app/backend"]
+26 -24
View File
@@ -30,27 +30,29 @@ backend/
Environment variables:
| Variable | Description | Default |
|---|---|---|
| `DATABASE_URL` | SQLite URL. Relative paths are resolved under `backend/`. | `sqlite://<backend>/data/caravel.db` |
| `HOST` | API bind host (also used for NIP-98 `u` host check) | `127.0.0.1` |
| `PORT` | API bind port | `3000` |
| `ADMINS` | Comma-separated admin pubkeys (hex) | _optional_ |
| `ALLOW_ORIGINS` | Comma-separated CORS origins. If empty, CORS is permissive. | _optional_ |
| `ZOOID_API_URL` | Zooid API base URL used by infra worker | _required for infra sync_ |
| `ZOOID_API_SECRET` | Nostr secret key used for authentication of requests to the zooid API | _required_ |
| `RELAY_DOMAIN` | Base domain appended to relay subdomains | empty |
| `LIVEKIT_URL` | LiveKit URL sent to zooid when relay livekit is enabled | _optional_ |
| `LIVEKIT_API_KEY` | LiveKit API key sent to zooid | _optional_ |
| `LIVEKIT_API_SECRET` | LiveKit API secret sent to zooid | _optional_ |
| `NWC_URL` | Platform NWC URL used to generate BOLT11 invoices | _required for invoice generation_ |
| `ROBOT_SECRET` | Robot Nostr secret key | _required_ |
| `ROBOT_NAME` | Robot display name (kind `0`) | _optional_ |
| `ROBOT_DESCRIPTION` | Robot description (kind `0`) | _optional_ |
| `ROBOT_PICTURE` | Robot picture URL (kind `0`) | _optional_ |
| `ROBOT_OUTBOX_RELAYS` | Comma-separated relays published as kind `10002` | _required_ |
| `ROBOT_INDEXER_RELAYS` | Comma-separated relays used for recipient relay discovery | _required_ |
| `ROBOT_MESSAGING_RELAYS` | Comma-separated relays published as kind `10050` | _required_ |
| Variable | Description | Default |
| ------------------------ | ----------------------------------------------------------------------- | ------------------------------------ |
| `DATABASE_URL` | SQLite URL. Relative paths are resolved under `backend/`. | `sqlite://<backend>/data/caravel.db` |
| `HOST` | API bind host (also used for NIP-98 `u` host check) | `127.0.0.1` |
| `PORT` | API bind port | `2892` |
| `ADMINS` | Comma-separated admin pubkeys (hex) | _optional_ |
| `ALLOW_ORIGINS` | Comma-separated CORS origins. If empty, CORS is permissive. | _optional_ |
| `ZOOID_API_URL` | Zooid API base URL used by infra worker | _required for infra sync_ |
| `ZOOID_API_SECRET` | Nostr secret key used for authentication of requests to the zooid API | _required_ |
| `RELAY_DOMAIN` | Base domain appended to relay subdomains | empty |
| `LIVEKIT_URL` | LiveKit URL sent to zooid when relay livekit is enabled | _optional_ |
| `LIVEKIT_API_KEY` | LiveKit API key sent to zooid | _optional_ |
| `LIVEKIT_API_SECRET` | LiveKit API secret sent to zooid | _optional_ |
| `NWC_URL` | Platform NWC URL used to generate BOLT11 invoices | _required for invoice generation_ |
| `STRIPE_SECRET_KEY` | Stripe API secret key used for billing API operations | _required_ |
| `STRIPE_WEBHOOK_SECRET` | Stripe webhook signing secret used to verify `Stripe-Signature` headers | _required_ |
| `ROBOT_SECRET` | Robot Nostr secret key | _required_ |
| `ROBOT_NAME` | Robot display name (kind `0`) | _optional_ |
| `ROBOT_DESCRIPTION` | Robot description (kind `0`) | _optional_ |
| `ROBOT_PICTURE` | Robot picture URL (kind `0`) | _optional_ |
| `ROBOT_OUTBOX_RELAYS` | Comma-separated relays published as kind `10002` | _required_ |
| `ROBOT_INDEXER_RELAYS` | Comma-separated relays used for recipient relay discovery | _required_ |
| `ROBOT_MESSAGING_RELAYS` | Comma-separated relays published as kind `10050` | _required_ |
Relay list env vars are comma-separated and trimmed. If a relay has no `ws://` or `wss://` scheme, `wss://` is prepended.
@@ -66,11 +68,11 @@ Public exceptions:
- `GET /plans`
- `GET /plans/:id`
- `POST /stripe/webhook` (validated with Stripe signatures instead)
- `POST /stripe/webhook` (validated with Stripe signatures)
- `GET /identity` — get auth identity (`pubkey`, `is_admin`)
- `GET /identity` — get auth identity (`pubkey`, `is_admin`); side-effect-free
- `GET /tenants` — list tenants (admin)
- `POST /tenants`create current auth pubkey as tenant
- `POST /tenants`idempotently ensure a tenant row exists for the current auth pubkey (creates Stripe customer + tenant on first call, returns existing tenant otherwise)
- `GET /tenants/:pubkey` — get tenant (admin or same tenant)
- `PUT /tenants/:pubkey/billing` — update tenant `nwc_url` (admin or same tenant)
- `GET /relays` — list relays (`?tenant=<pubkey>` allowed for admin only)
+15 -3
View File
@@ -46,9 +46,8 @@ Notes:
- Serves `GET /identity`
- Authorizes anyone, but must be authorized
- If a tenant for the identity doesn't exist:
- Call the Stripe API to create a new customer
- Create a new tenant using `command.create_tenant` with payload and `stripe_customer_id`. No subscription is created yet — that happens when the first relay is added.
- Side-effect-free: returns `{ pubkey, is_admin }` only
- Clients must call `POST /tenants` before any tenant-scoped write
- Return `data` is an `Identity` struct
--- Tenant routes
@@ -59,6 +58,18 @@ Notes:
- Authorizes admin only
- Return `data` is a list of tenant structs from `query.list_tenants`
## `async fn create_tenant(...) -> Response`
- Serves `POST /tenants`
- Authorizes anyone, but must be authorized
- No request body; target pubkey is derived from NIP-98 auth
- Idempotent: if a tenant already exists for the auth pubkey, return it without calling Stripe or writing to the DB
- Otherwise, call the Stripe API to create a new customer and create a new tenant using `command.create_tenant` with the resulting `stripe_customer_id`. No subscription is created yet — that happens when the first relay is added.
- On unique-constraint race (`pubkey-exists`), re-fetch and return the existing tenant
- If Stripe customer creation fails, return `code=stripe-customer-create-failed`
- Always returns `200` (create-or-get is uniform)
- Return `data` is a single `Tenant` struct
## `async fn get_tenant(...) -> Response`
- Serves `GET /tenants/:pubkey`
@@ -178,6 +189,7 @@ Notes:
- Reads raw request body and `Stripe-Signature` header
- Calls `billing.handle_webhook(payload, signature)`
- Returns `200` on success, `400` on signature verification failure
- Startup requires non-empty `STRIPE_WEBHOOK_SECRET`
--- Utilities
+3 -1
View File
@@ -5,6 +5,7 @@ Billing encapsulates logic related to synchronizing state with Stripe, processin
Members:
- `nwc_url: String` - a nostr wallet connect URL used to **create** bolt11 invoices (i.e. receive payments), from `NWC_URL`
- `stripe_secret_key: String` - Stripe API key used for billing API operations, from `STRIPE_SECRET_KEY`
- `stripe_webhook_secret: String` - secret for verifying Stripe webhook signatures, from `STRIPE_WEBHOOK_SECRET`
- `query: Query`
- `command: Command`
@@ -13,6 +14,8 @@ Members:
## `pub fn new(query: Query, command: Command, robot: Robot) -> Self`
- Reads environment and populates members
- Panics if `STRIPE_SECRET_KEY` is missing/empty
- Panics if `STRIPE_WEBHOOK_SECRET` is missing/empty
## `pub fn start(&self)`
@@ -109,4 +112,3 @@ Skip invoices with `amount_due` of 0.
- Look up tenant by `stripe_customer_id`
- Clear `stripe_subscription_id` via `command.clear_tenant_subscription`
+3 -2
View File
@@ -30,5 +30,6 @@ Members:
## `async fn sync_relay(&self, relay: &Relay, is_new: bool)`
- If `is_new`, sends `POST /relay/:id` to create the relay in zooid.
- Otherwise, sends `PUT /relay/:id` to update it.
- Passes full relay configuration in the body including host, schema, secret, inactive flag, info, policy, groups, management, blossom, livekit, push, and roles.
- Otherwise, sends `PATCH /relay/:id` to update it.
- Includes `secret` only for relay creation (`POST`) so updates do not rotate relay identity.
- Passes relay configuration in the body including host, schema, inactive flag, info, policy, groups, management, blossom, livekit, push, and roles.
+1 -2
View File
@@ -85,9 +85,8 @@ A relay is a nostr relay owned by a `tenant` and hosted by the attached zooid in
Some attributes persisted to zooid via API have special handling:
- The relay's `secret` is generated once and persisted to the zooid configuration but isn't stored in the database.
- The relay's `secret` is generated once on relay creation, persisted to the zooid configuration, and isn't stored in the database. Relay updates do not resend `secret`.
- The relay's `host` is calculated based on `subdomain` + `RELAY_DOMAIN`
- The value of `inactive` is calculated based on `status`
- The relay's `livekit_*` configuration is inferred based on environment variables and `livekit_enabled`.
- The relay's `roles` are hard-coded for now.
+40 -24
View File
@@ -139,11 +139,11 @@ impl Api {
api: Arc::new(self),
};
Router::new()
let router = Router::new()
.route("/identity", get(get_identity))
.route("/plans", get(list_plans))
.route("/plans/:id", get(get_plan))
.route("/tenants", get(list_tenants))
.route("/tenants", get(list_tenants).post(create_tenant))
.route("/tenants/:pubkey", get(get_tenant).put(update_tenant))
.route("/tenants/:pubkey/relays", get(list_tenant_relays))
.route("/relays", get(list_relays).post(create_relay))
@@ -158,8 +158,9 @@ impl Api {
"/tenants/:pubkey/stripe/session",
get(create_stripe_session),
)
.route("/stripe/webhook", post(stripe_webhook))
.with_state(state)
.route("/stripe/webhook", post(stripe_webhook));
router.with_state(state)
}
fn extract_auth_pubkey(&self, headers: &HeaderMap) -> std::result::Result<String, ApiError> {
@@ -400,10 +401,17 @@ async fn get_identity(
) -> std::result::Result<Response, ApiError> {
let pubkey = state.api.extract_auth_pubkey(&headers)?;
let is_admin = state.api.admins.iter().any(|a| a == &pubkey);
Ok(ok(StatusCode::OK, IdentityResponse { pubkey, is_admin }))
}
async fn create_tenant(
State(state): State<AppState>,
headers: HeaderMap,
) -> std::result::Result<Response, ApiError> {
let pubkey = state.api.extract_auth_pubkey(&headers)?;
// Ensure tenant exists.
match state.api.query.get_tenant(&pubkey).await {
Ok(Some(_)) => {}
Ok(Some(t)) => Ok(ok(StatusCode::OK, t)),
Ok(None) => {
let stripe_customer_id = match state.api.billing.stripe_create_customer(&pubkey).await {
Ok(id) => id,
@@ -427,27 +435,35 @@ async fn get_identity(
};
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(ok(StatusCode::OK, tenant)),
Err(e) if matches!(map_unique_error(&e), Some("pubkey-exists")) => {
match state.api.query.get_tenant(&pubkey).await {
Ok(Some(t)) => Ok(ok(StatusCode::OK, t)),
Ok(None) => Ok(err(
StatusCode::INTERNAL_SERVER_ERROR,
"internal",
"tenant row missing after unique-constraint race",
)),
Err(e) => Ok(err(
StatusCode::INTERNAL_SERVER_ERROR,
"internal",
&e.to_string(),
)),
}
}
};
}
Err(e) => {
return Ok(err(
StatusCode::INTERNAL_SERVER_ERROR,
"internal",
&e.to_string(),
));
Err(e) => Ok(err(
StatusCode::INTERNAL_SERVER_ERROR,
"internal",
&e.to_string(),
)),
}
}
Err(e) => 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 {
+105 -6
View File
@@ -95,6 +95,9 @@ impl Billing {
panic!("missing STRIPE_SECRET_KEY environment variable");
}
let stripe_webhook_secret = std::env::var("STRIPE_WEBHOOK_SECRET").unwrap_or_default();
if stripe_webhook_secret.trim().is_empty() {
panic!("missing STRIPE_WEBHOOK_SECRET environment variable");
}
let btc_quote_api_base =
std::env::var("BTC_PRICE_API_BASE").unwrap_or_else(|_| COINBASE_SPOT_API.to_string());
Self {
@@ -949,7 +952,8 @@ mod tests {
use sqlx::SqlitePool;
use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions};
use std::str::FromStr;
use std::sync::{Mutex, OnceLock};
use std::sync::OnceLock;
use tokio::sync::Mutex;
fn env_lock() -> &'static Mutex<()> {
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
@@ -964,6 +968,14 @@ mod tests {
}
}
#[allow(unused_unsafe)]
fn set_stripe_webhook_secret(value: Option<&str>) {
match value {
Some(v) => unsafe { std::env::set_var("STRIPE_WEBHOOK_SECRET", v) },
None => unsafe { std::env::remove_var("STRIPE_WEBHOOK_SECRET") },
}
}
struct StripeSecretKeyGuard {
previous: Option<String>,
}
@@ -982,6 +994,24 @@ mod tests {
}
}
struct StripeWebhookSecretGuard {
previous: Option<String>,
}
impl StripeWebhookSecretGuard {
fn set(value: Option<&str>) -> Self {
let previous = std::env::var("STRIPE_WEBHOOK_SECRET").ok();
set_stripe_webhook_secret(value);
Self { previous }
}
}
impl Drop for StripeWebhookSecretGuard {
fn drop(&mut self) {
set_stripe_webhook_secret(self.previous.as_deref());
}
}
async fn test_pool() -> SqlitePool {
let connect_options = SqliteConnectOptions::from_str("sqlite::memory:")
.expect("valid sqlite memory url")
@@ -1003,8 +1033,9 @@ mod tests {
#[tokio::test]
async fn billing_new_panics_without_stripe_secret_key() {
let _lock = env_lock().lock().expect("acquire env lock");
let _env = StripeSecretKeyGuard::set(None);
let _lock = env_lock().lock().await;
let _secret_env = StripeSecretKeyGuard::set(None);
let _webhook_env = StripeWebhookSecretGuard::set(Some("whsec_test_dummy"));
let pool = test_pool().await;
let query = Query::new(pool.clone());
@@ -1034,9 +1065,76 @@ mod tests {
}
#[tokio::test]
async fn billing_new_accepts_non_empty_stripe_secret_key() {
let _lock = env_lock().lock().expect("acquire env lock");
let _env = StripeSecretKeyGuard::set(Some("sk_test_dummy"));
async fn billing_new_panics_without_stripe_webhook_secret() {
let _lock = env_lock().lock().await;
let _secret_env = StripeSecretKeyGuard::set(Some("sk_test_dummy"));
let _webhook_env = StripeWebhookSecretGuard::set(None);
let pool = test_pool().await;
let query = Query::new(pool.clone());
let command = Command::new(pool);
let robot = Robot::test_stub();
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
Billing::new(query, command, robot)
}));
let panic_payload = match result {
Ok(_) => panic!("constructor should panic when STRIPE_WEBHOOK_SECRET is missing"),
Err(payload) => payload,
};
let panic_msg = if let Some(msg) = panic_payload.downcast_ref::<&str>() {
(*msg).to_string()
} else if let Some(msg) = panic_payload.downcast_ref::<String>() {
msg.clone()
} else {
String::new()
};
assert!(
panic_msg.contains("missing STRIPE_WEBHOOK_SECRET environment variable"),
"unexpected panic: {panic_msg}"
);
}
#[tokio::test]
async fn billing_new_panics_with_blank_stripe_webhook_secret() {
let _lock = env_lock().lock().await;
let _secret_env = StripeSecretKeyGuard::set(Some("sk_test_dummy"));
let _webhook_env = StripeWebhookSecretGuard::set(Some(" "));
let pool = test_pool().await;
let query = Query::new(pool.clone());
let command = Command::new(pool);
let robot = Robot::test_stub();
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
Billing::new(query, command, robot)
}));
let panic_payload = match result {
Ok(_) => panic!("constructor should panic when STRIPE_WEBHOOK_SECRET is blank"),
Err(payload) => payload,
};
let panic_msg = if let Some(msg) = panic_payload.downcast_ref::<&str>() {
(*msg).to_string()
} else if let Some(msg) = panic_payload.downcast_ref::<String>() {
msg.clone()
} else {
String::new()
};
assert!(
panic_msg.contains("missing STRIPE_WEBHOOK_SECRET environment variable"),
"unexpected panic: {panic_msg}"
);
}
#[tokio::test]
async fn billing_new_accepts_non_empty_stripe_secrets() {
let _lock = env_lock().lock().await;
let _secret_env = StripeSecretKeyGuard::set(Some("sk_test_dummy"));
let _webhook_env = StripeWebhookSecretGuard::set(Some("whsec_test_dummy"));
let pool = test_pool().await;
let billing = Billing::new(
@@ -1046,5 +1144,6 @@ mod tests {
);
assert_eq!(billing.stripe_secret_key, "sk_test_dummy");
assert_eq!(billing.stripe_webhook_secret, "whsec_test_dummy");
}
}
+1 -1
View File
@@ -32,7 +32,7 @@ impl Command {
.fetch_one(&mut **tx)
.await?
}
_ => anyhow::bail!("unknown resource_type: {}", resource_type),
_ => anyhow::bail!("unknown resource_type: {resource_type}"),
};
let id = uuid::Uuid::new_v4().to_string();
+69 -49
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, Relay, RELAY_STATUS_DELINQUENT, RELAY_STATUS_INACTIVE};
use crate::query::Query;
#[derive(Clone)]
@@ -70,7 +70,7 @@ impl Infra {
Ok(())
}
async fn sync_and_report(&self, relay: &crate::models::Relay, is_new: bool) {
async fn sync_and_report(&self, relay: &Relay, is_new: bool) {
match self.sync_relay(relay, is_new).await {
Ok(()) => {
tracing::info!(relay = %relay.id, "relay sync succeeded");
@@ -96,7 +96,7 @@ impl Infra {
Ok(auth)
}
async fn sync_relay(&self, relay: &crate::models::Relay, is_new: bool) -> Result<()> {
async fn sync_relay(&self, relay: &Relay, is_new: bool) -> Result<()> {
let client = reqwest::Client::new();
let base = self.api_url.trim_end_matches('/');
@@ -106,8 +106,6 @@ impl Infra {
format!("{}.{}", relay.subdomain, self.relay_domain)
};
let secret = Keys::generate().secret_key().to_secret_hex();
let livekit = if relay.livekit_enabled == 1 {
serde_json::json!({
"enabled": true,
@@ -119,62 +117,84 @@ impl Infra {
serde_json::json!({ "enabled": false })
};
let body = serde_json::json!({
"host": host,
"schema": relay.schema,
"secret": secret,
"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": { "enabled": relay.blossom_enabled == 1 },
"livekit": livekit,
"push": { "enabled": relay.push_enabled == 1 },
"roles": {
"admin": { "can_manage": true, "can_invite": true },
"member": { "can_invite": true },
},
});
let body = relay_sync_body(
relay,
host,
livekit,
is_new.then(|| Keys::generate().secret_key().to_secret_hex()),
);
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?
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 {
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.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)
anyhow::bail!("zooid sync returned {status}: {body}")
}
Ok(())
}
}
fn zooid_sync_http_method(is_new: bool) -> HttpMethod {
if is_new {
HttpMethod::POST
} else {
HttpMethod::PATCH
}
}
fn relay_sync_body(
relay: &Relay,
host: String,
livekit: serde_json::Value,
secret: Option<String>,
) -> serde_json::Value {
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": { "enabled": relay.blossom_enabled == 1 },
"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 should_sync_relay_activity(activity_type: &str) -> bool {
matches!(
activity_type,
+2 -2
View File
@@ -3,8 +3,8 @@ mod billing;
mod command;
mod infra;
mod models;
mod query;
mod pool;
mod query;
mod robot;
use anyhow::Result;
@@ -40,7 +40,7 @@ async fn main() -> Result<()> {
let port: u16 = std::env::var("PORT")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(3000);
.unwrap_or(2892);
let origins: Vec<String> = std::env::var("ALLOW_ORIGINS")
.unwrap_or_default()
.split(',')
+1 -2
View File
@@ -21,8 +21,7 @@ pub async fn create_pool() -> Result<SqlitePool> {
std::fs::create_dir_all(parent)?;
}
let connect_options =
SqliteConnectOptions::from_str(&database_url)?.create_if_missing(true);
let connect_options = SqliteConnectOptions::from_str(&database_url)?.create_if_missing(true);
let pool = SqlitePoolOptions::new()
.max_connections(5)
+2 -2
View File
@@ -25,7 +25,7 @@ async fn quote_endpoint_can_be_stubbed_deterministically() {
assert_eq!(btc_price, 50_000.0);
let msats = fiat_minor_to_msats_from_quote(100, "USD", btc_price)
.expect("convert quoted fiat amount");
let msats =
fiat_minor_to_msats_from_quote(100, "USD", btc_price).expect("convert quoted fiat amount");
assert_eq!(msats, 2_000_000);
}
+1 -1
View File
@@ -1,5 +1,5 @@
# Backend API base URL
VITE_API_URL=http://127.0.0.1:3000
VITE_API_URL=http://127.0.0.1:2892
# Platform display name shown in UI
VITE_PLATFORM_NAME=Caravel
+1 -1
View File
@@ -32,7 +32,7 @@ Environment variables (see `.env.template`):
| Variable | Description | Default |
|---|---|---|
| `VITE_API_URL` | Backend API base URL | `http://127.0.0.1:3000` |
| `VITE_API_URL` | Backend API base URL | `http://127.0.0.1:2892` |
## Running
+1
View File
@@ -0,0 +1 @@
<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>

After

Width:  |  Height:  |  Size: 1.1 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 11 KiB

+21
View File
@@ -0,0 +1,21 @@
<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>

After

Width:  |  Height:  |  Size: 2.9 KiB

@@ -203,6 +203,13 @@ export default function RelayDetailCard(props: RelayDetailCardProps) {
</Show>
</div>
<Show when={r().sync_error}>
<div class="rounded-lg border border-red-200 bg-red-50 px-4 py-3">
<p class="text-sm font-semibold text-red-800">Provisioning error</p>
<p class="mt-1 text-sm text-red-700 font-mono break-all">{r().sync_error}</p>
</div>
</Show>
<hr class="border-gray-200" />
<DetailSection title="Policy">
+12 -1
View File
@@ -1,4 +1,5 @@
import { A } from "@solidjs/router"
import { Show } from "solid-js"
import type { Relay } from "@/lib/api"
type RelayListItemProps = {
@@ -19,7 +20,17 @@ export default function RelayListItem(props: RelayListItemProps) {
<p class="text-xs text-gray-500 break-all mt-1">Tenant: {props.relay.tenant}</p>
)}
</div>
<p class="text-xs uppercase tracking-wide text-gray-500">{props.relay.status}</p>
<Show
when={props.relay.sync_error}
fallback={<p class="text-xs uppercase tracking-wide text-gray-500">{props.relay.status}</p>}
>
<span
class="inline-flex items-center rounded-full border border-red-200 bg-red-50 px-2.5 py-0.5 text-xs font-medium text-red-700 max-w-56 truncate"
title={props.relay.sync_error}
>
{props.relay.sync_error}
</span>
</Show>
</div>
</A>
</li>
+4
View File
@@ -205,6 +205,10 @@ export function getIdentity() {
return callApi<undefined, Identity>("GET", "/identity")
}
export function createTenant() {
return callApi<undefined, Tenant>("POST", "/tenants")
}
export function getPlan(id: string) {
return callApi<undefined, Plan>("GET", `/plans/${id}`)
}
+6 -9
View File
@@ -8,6 +8,9 @@ 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()
@@ -223,9 +226,7 @@ export default function Home() {
>
<div class="flex items-start justify-between gap-4">
<div class="flex items-center gap-3">
<div class="w-12 h-12 rounded-2xl bg-linear-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>
<img src={FlotillaLogo} alt="Flotilla" class="w-12 h-12 rounded-2xl shadow-md shadow-blue-200" />
<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>
@@ -263,9 +264,7 @@ export default function Home() {
>
<div class="flex items-start justify-between gap-4">
<div class="flex items-center gap-3">
<div class="w-12 h-12 rounded-2xl bg-linear-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>
<img src={ChachiLogo} alt="Chachi" class="w-12 h-12 rounded-2xl shadow-md shadow-purple-200" />
<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>
@@ -303,9 +302,7 @@ export default function Home() {
>
<div class="flex items-start justify-between gap-4">
<div class="flex items-center gap-3">
<div class="w-12 h-12 rounded-2xl bg-linear-to-br from-amber-500 to-orange-500 flex items-center justify-center text-white font-bold text-xl shadow-md shadow-amber-200">
N
</div>
<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>
+7
View File
@@ -5,6 +5,7 @@ import { PasswordSigner } from "applesauce-signers"
import QrScanner from "qr-scanner"
import QRCode from "qrcode"
import { accountManager, identity, PLATFORM_NAME } from "@/lib/state"
import { createTenant } from "@/lib/api"
import useMinLoading from "@/components/useMinLoading"
const NIP46_RELAYS = ['wss://bucket.coracle.social', 'wss://ephemeral.snowflare.cc']
@@ -69,6 +70,12 @@ export default function Login(props: LoginPageProps = {}) {
async function completeLogin(account: ExtensionAccount | NostrConnectAccount | PrivateKeyAccount | PasswordAccount) {
accountManager.addAccount(account)
accountManager.setActive(account)
try {
await createTenant()
} catch (e) {
accountManager.removeAccount(account)
throw e
}
await props.onAuthenticated?.()
}
+14 -7
View File
@@ -1,13 +1,20 @@
import { defineConfig } from 'vite'
import { defineConfig, loadEnv } from 'vite'
import solid from 'vite-plugin-solid'
import tailwindcss from '@tailwindcss/vite'
import { fileURLToPath, URL } from 'node:url'
export default defineConfig({
plugins: [tailwindcss(), solid()],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)),
export default defineConfig(({mode}) => {
const env = loadEnv(mode, process.cwd(), '')
return {
plugins: [tailwindcss(), solid()],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)),
},
},
},
server: {
port: Number(env.VITE_PORT) || 5173,
},
}
})