forked from coracle/caravel
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4e33eae45b |
@@ -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:2892`) point at the backend and work out of the box.
|
||||
The defaults (`VITE_API_URL=http://127.0.0.1:3000`) 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:2892` 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:3000` and the frontend at `http://127.0.0.1:5173`.
|
||||
|
||||
## Project docs
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Server
|
||||
HOST=127.0.0.1
|
||||
PORT=2892
|
||||
PORT=3000
|
||||
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= # Required Stripe API secret key (sk_...)
|
||||
STRIPE_WEBHOOK_SECRET=whsec_test_00000000000000000000000000 # Webhook signing secret (use real value in production)
|
||||
STRIPE_SECRET_KEY= # Stripe API secret key (sk_...)
|
||||
STRIPE_WEBHOOK_SECRET= # Secret for verifying Stripe webhook signatures (whsec_...)
|
||||
|
||||
+1
-1
@@ -26,6 +26,6 @@ WORKDIR /app
|
||||
|
||||
COPY --from=build /app/target/release/backend /app/backend
|
||||
|
||||
EXPOSE 2892
|
||||
EXPOSE 3000
|
||||
|
||||
CMD ["/app/backend"]
|
||||
|
||||
+22
-40
@@ -30,29 +30,27 @@ 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 | `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_ |
|
||||
| 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_ |
|
||||
|
||||
Relay list env vars are comma-separated and trimmed. If a relay has no `ws://` or `wss://` scheme, `wss://` is prepended.
|
||||
|
||||
@@ -62,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)
|
||||
All routes are NIP-98 protected.
|
||||
|
||||
- `GET /identity` — get auth identity (`pubkey`, `is_admin`)
|
||||
- `GET /tenants` — list tenants (admin)
|
||||
@@ -81,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.
|
||||
|
||||
+5
-9
@@ -178,18 +178,15 @@ 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
|
||||
|
||||
## `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.
|
||||
@@ -205,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
|
||||
|
||||
@@ -5,7 +5,6 @@ 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`
|
||||
@@ -14,8 +13,6 @@ 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)`
|
||||
|
||||
@@ -112,3 +109,4 @@ Skip invoices with `amount_due` of 0.
|
||||
|
||||
- Look up tenant by `stripe_customer_id`
|
||||
- Clear `stripe_subscription_id` via `command.clear_tenant_subscription`
|
||||
|
||||
|
||||
+14
-30
@@ -139,7 +139,7 @@ impl Api {
|
||||
api: Arc::new(self),
|
||||
};
|
||||
|
||||
let router = Router::new()
|
||||
Router::new()
|
||||
.route("/identity", get(get_identity))
|
||||
.route("/plans", get(list_plans))
|
||||
.route("/plans/:id", get(get_plan))
|
||||
@@ -158,9 +158,8 @@ impl Api {
|
||||
"/tenants/:pubkey/stripe/session",
|
||||
get(create_stripe_session),
|
||||
)
|
||||
.route("/stripe/webhook", post(stripe_webhook));
|
||||
|
||||
router.with_state(state)
|
||||
.route("/stripe/webhook", post(stripe_webhook))
|
||||
.with_state(state)
|
||||
}
|
||||
|
||||
fn extract_auth_pubkey(&self, headers: &HeaderMap) -> std::result::Result<String, ApiError> {
|
||||
@@ -210,9 +209,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"
|
||||
@@ -260,12 +256,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"));
|
||||
}
|
||||
|
||||
@@ -279,10 +273,14 @@ impl Api {
|
||||
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)
|
||||
@@ -452,7 +450,7 @@ async fn get_identity(
|
||||
}
|
||||
|
||||
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"),
|
||||
}
|
||||
@@ -593,13 +591,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(
|
||||
@@ -697,13 +688,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(
|
||||
|
||||
+16
-112
@@ -95,9 +95,6 @@ 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 {
|
||||
@@ -157,11 +154,8 @@ 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
|
||||
@@ -185,6 +179,12 @@ impl Billing {
|
||||
}
|
||||
|
||||
// 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(());
|
||||
};
|
||||
@@ -442,7 +442,7 @@ 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) {
|
||||
if relay.status == RELAY_STATUS_ACTIVE && relay.plan != "free" {
|
||||
self.command.mark_relay_delinquent(&relay).await?;
|
||||
}
|
||||
}
|
||||
@@ -477,7 +477,7 @@ 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) {
|
||||
if relay.status == RELAY_STATUS_ACTIVE && relay.plan != "free" {
|
||||
self.command.mark_relay_delinquent(&relay).await?;
|
||||
}
|
||||
}
|
||||
@@ -801,7 +801,7 @@ impl Billing {
|
||||
}
|
||||
|
||||
fn should_reactivate_after_payment(relay: &Relay) -> bool {
|
||||
relay.status == RELAY_STATUS_DELINQUENT && Query::is_paid_plan(&relay.plan)
|
||||
relay.status == RELAY_STATUS_DELINQUENT && relay.plan != "free"
|
||||
}
|
||||
|
||||
async fn fetch_btc_spot_price(&self, currency: &str) -> Result<f64> {
|
||||
@@ -952,8 +952,7 @@ mod tests {
|
||||
use sqlx::SqlitePool;
|
||||
use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions};
|
||||
use std::str::FromStr;
|
||||
use std::sync::OnceLock;
|
||||
use tokio::sync::Mutex;
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
|
||||
fn env_lock() -> &'static Mutex<()> {
|
||||
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
|
||||
@@ -968,14 +967,6 @@ 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>,
|
||||
}
|
||||
@@ -994,24 +985,6 @@ 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")
|
||||
@@ -1033,9 +1006,8 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn billing_new_panics_without_stripe_secret_key() {
|
||||
let _lock = env_lock().lock().await;
|
||||
let _secret_env = StripeSecretKeyGuard::set(None);
|
||||
let _webhook_env = StripeWebhookSecretGuard::set(Some("whsec_test_dummy"));
|
||||
let _lock = env_lock().lock().expect("acquire env lock");
|
||||
let _env = StripeSecretKeyGuard::set(None);
|
||||
|
||||
let pool = test_pool().await;
|
||||
let query = Query::new(pool.clone());
|
||||
@@ -1065,76 +1037,9 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn billing_new_panics_without_stripe_webhook_secret() {
|
||||
let _lock = env_lock().lock().await;
|
||||
let _secret_env = StripeSecretKeyGuard::set(Some("sk_test_dummy"));
|
||||
let _webhook_env = StripeWebhookSecretGuard::set(None);
|
||||
|
||||
let pool = test_pool().await;
|
||||
let query = Query::new(pool.clone());
|
||||
let command = Command::new(pool);
|
||||
let robot = Robot::test_stub();
|
||||
|
||||
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||
Billing::new(query, command, robot)
|
||||
}));
|
||||
|
||||
let panic_payload = match result {
|
||||
Ok(_) => panic!("constructor should panic when STRIPE_WEBHOOK_SECRET is missing"),
|
||||
Err(payload) => payload,
|
||||
};
|
||||
let panic_msg = if let Some(msg) = panic_payload.downcast_ref::<&str>() {
|
||||
(*msg).to_string()
|
||||
} else if let Some(msg) = panic_payload.downcast_ref::<String>() {
|
||||
msg.clone()
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
|
||||
assert!(
|
||||
panic_msg.contains("missing STRIPE_WEBHOOK_SECRET environment variable"),
|
||||
"unexpected panic: {panic_msg}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn billing_new_panics_with_blank_stripe_webhook_secret() {
|
||||
let _lock = env_lock().lock().await;
|
||||
let _secret_env = StripeSecretKeyGuard::set(Some("sk_test_dummy"));
|
||||
let _webhook_env = StripeWebhookSecretGuard::set(Some(" "));
|
||||
|
||||
let pool = test_pool().await;
|
||||
let query = Query::new(pool.clone());
|
||||
let command = Command::new(pool);
|
||||
let robot = Robot::test_stub();
|
||||
|
||||
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||
Billing::new(query, command, robot)
|
||||
}));
|
||||
|
||||
let panic_payload = match result {
|
||||
Ok(_) => panic!("constructor should panic when STRIPE_WEBHOOK_SECRET is blank"),
|
||||
Err(payload) => payload,
|
||||
};
|
||||
let panic_msg = if let Some(msg) = panic_payload.downcast_ref::<&str>() {
|
||||
(*msg).to_string()
|
||||
} else if let Some(msg) = panic_payload.downcast_ref::<String>() {
|
||||
msg.clone()
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
|
||||
assert!(
|
||||
panic_msg.contains("missing STRIPE_WEBHOOK_SECRET environment variable"),
|
||||
"unexpected panic: {panic_msg}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn billing_new_accepts_non_empty_stripe_secrets() {
|
||||
let _lock = env_lock().lock().await;
|
||||
let _secret_env = StripeSecretKeyGuard::set(Some("sk_test_dummy"));
|
||||
let _webhook_env = StripeWebhookSecretGuard::set(Some("whsec_test_dummy"));
|
||||
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(
|
||||
@@ -1144,6 +1049,5 @@ mod tests {
|
||||
);
|
||||
|
||||
assert_eq!(billing.stripe_secret_key, "sk_test_dummy");
|
||||
assert_eq!(billing.stripe_webhook_secret, "whsec_test_dummy");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -169,7 +169,7 @@ impl Infra {
|
||||
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(())
|
||||
}
|
||||
|
||||
+2
-2
@@ -3,8 +3,8 @@ mod billing;
|
||||
mod command;
|
||||
mod infra;
|
||||
mod models;
|
||||
mod pool;
|
||||
mod query;
|
||||
mod pool;
|
||||
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(2892);
|
||||
.unwrap_or(3000);
|
||||
let origins: Vec<String> = std::env::var("ALLOW_ORIGINS")
|
||||
.unwrap_or_default()
|
||||
.split(',')
|
||||
|
||||
+2
-1
@@ -21,7 +21,8 @@ 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)
|
||||
|
||||
+4
-15
@@ -68,16 +68,6 @@ 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,
|
||||
@@ -145,14 +135,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>> {
|
||||
|
||||
@@ -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,5 +1,5 @@
|
||||
# Backend API base URL
|
||||
VITE_API_URL=http://127.0.0.1:2892
|
||||
VITE_API_URL=http://127.0.0.1:3000
|
||||
|
||||
# Platform display name shown in UI
|
||||
VITE_PLATFORM_NAME=Caravel
|
||||
|
||||
+3
-6
@@ -32,7 +32,7 @@ Environment variables (see `.env.template`):
|
||||
|
||||
| Variable | Description | Default |
|
||||
|---|---|---|
|
||||
| `VITE_API_URL` | Backend API base URL | `http://127.0.0.1:2892` |
|
||||
| `VITE_API_URL` | Backend API base URL | `http://127.0.0.1:3000` |
|
||||
|
||||
## Running
|
||||
|
||||
@@ -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 +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 |
@@ -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 |
@@ -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]],
|
||||
})
|
||||
|
||||
|
||||
@@ -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
|
||||
control—all 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>
|
||||
|
||||
+7
-14
@@ -1,20 +1,13 @@
|
||||
import { defineConfig, loadEnv } from 'vite'
|
||||
import { defineConfig } from 'vite'
|
||||
import solid from 'vite-plugin-solid'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
import { fileURLToPath, URL } from 'node:url'
|
||||
|
||||
export default defineConfig(({mode}) => {
|
||||
const env = loadEnv(mode, process.cwd(), '')
|
||||
|
||||
return {
|
||||
plugins: [tailwindcss(), solid()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||||
},
|
||||
export default defineConfig({
|
||||
plugins: [tailwindcss(), solid()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||||
},
|
||||
server: {
|
||||
port: Number(env.VITE_PORT) || 5173,
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user