forked from coracle/caravel
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b82d6de80e |
@@ -53,7 +53,7 @@ The rest of the defaults work as-is. `ROBOT_*`, `LIVEKIT_*`, billing, and Stripe
|
|||||||
cp frontend/.env.template frontend/.env
|
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
|
### 4. Install dependencies and run
|
||||||
|
|
||||||
@@ -62,7 +62,7 @@ cd frontend && bun install && cd ..
|
|||||||
just dev
|
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
|
## Project docs
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Server
|
# Server
|
||||||
HOST=127.0.0.1
|
HOST=127.0.0.1
|
||||||
PORT=2892
|
PORT=3000
|
||||||
ALLOW_ORIGINS= # Optional comma-separated allowed CORS origins; empty = permissive
|
ALLOW_ORIGINS= # Optional comma-separated allowed CORS origins; empty = permissive
|
||||||
|
|
||||||
# Auth
|
# Auth
|
||||||
@@ -28,5 +28,5 @@ 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
|
||||||
STRIPE_SECRET_KEY= # Required Stripe API secret key (sk_...)
|
STRIPE_SECRET_KEY= # Stripe API secret key (sk_...)
|
||||||
STRIPE_WEBHOOK_SECRET=whsec_test_00000000000000000000000000 # Webhook signing secret (use real value in production)
|
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
|
COPY --from=build /app/target/release/backend /app/backend
|
||||||
|
|
||||||
EXPOSE 2892
|
EXPOSE 3000
|
||||||
|
|
||||||
CMD ["/app/backend"]
|
CMD ["/app/backend"]
|
||||||
|
|||||||
+22
-24
@@ -30,29 +30,27 @@ 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 | `3000` |
|
||||||
| `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_ |
|
||||||
| `STRIPE_SECRET_KEY` | Stripe API secret key used for billing API operations | _required_ |
|
| `ROBOT_SECRET` | Robot Nostr secret key | _required_ |
|
||||||
| `STRIPE_WEBHOOK_SECRET` | Stripe webhook signing secret used to verify `Stripe-Signature` headers | _required_ |
|
| `ROBOT_NAME` | Robot display name (kind `0`) | _optional_ |
|
||||||
| `ROBOT_SECRET` | Robot Nostr secret key | _required_ |
|
| `ROBOT_DESCRIPTION` | Robot description (kind `0`) | _optional_ |
|
||||||
| `ROBOT_NAME` | Robot display name (kind `0`) | _optional_ |
|
| `ROBOT_PICTURE` | Robot picture URL (kind `0`) | _optional_ |
|
||||||
| `ROBOT_DESCRIPTION` | Robot description (kind `0`) | _optional_ |
|
| `ROBOT_OUTBOX_RELAYS` | Comma-separated relays published as kind `10002` | _required_ |
|
||||||
| `ROBOT_PICTURE` | Robot picture URL (kind `0`) | _optional_ |
|
| `ROBOT_INDEXER_RELAYS` | Comma-separated relays used for recipient relay discovery | _required_ |
|
||||||
| `ROBOT_OUTBOX_RELAYS` | Comma-separated relays published as kind `10002` | _required_ |
|
| `ROBOT_MESSAGING_RELAYS` | Comma-separated relays published as kind `10050` | _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.
|
Relay list env vars are comma-separated and trimmed. If a relay has no `ws://` or `wss://` scheme, `wss://` is prepended.
|
||||||
|
|
||||||
@@ -68,7 +66,7 @@ Public exceptions:
|
|||||||
|
|
||||||
- `GET /plans`
|
- `GET /plans`
|
||||||
- `GET /plans/:id`
|
- `GET /plans/:id`
|
||||||
- `POST /stripe/webhook` (validated with Stripe signatures)
|
- `POST /stripe/webhook` (validated with Stripe signatures instead)
|
||||||
|
|
||||||
- `GET /identity` — get auth identity (`pubkey`, `is_admin`)
|
- `GET /identity` — get auth identity (`pubkey`, `is_admin`)
|
||||||
- `GET /tenants` — list tenants (admin)
|
- `GET /tenants` — list tenants (admin)
|
||||||
|
|||||||
+1
-2
@@ -121,7 +121,7 @@ Notes:
|
|||||||
|
|
||||||
- Serves `POST /relays/:id/deactivate`
|
- Serves `POST /relays/:id/deactivate`
|
||||||
- Authorizes admin or relay owner
|
- Authorizes admin or relay owner
|
||||||
- If relay status is `inactive` or `delinquent`, return a `400` with `code=relay-is-inactive`
|
- If relay is already inactive, return a `400` with `code=relay-is-inactive`
|
||||||
- Call `command.deactivate_relay`
|
- Call `command.deactivate_relay`
|
||||||
- Return `data` is empty
|
- Return `data` is empty
|
||||||
|
|
||||||
@@ -178,7 +178,6 @@ Notes:
|
|||||||
- Reads raw request body and `Stripe-Signature` header
|
- Reads raw request body and `Stripe-Signature` header
|
||||||
- Calls `billing.handle_webhook(payload, signature)`
|
- Calls `billing.handle_webhook(payload, signature)`
|
||||||
- Returns `200` on success, `400` on signature verification failure
|
- Returns `200` on success, `400` on signature verification failure
|
||||||
- Startup requires non-empty `STRIPE_WEBHOOK_SECRET`
|
|
||||||
|
|
||||||
--- Utilities
|
--- Utilities
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ Billing encapsulates logic related to synchronizing state with Stripe, processin
|
|||||||
Members:
|
Members:
|
||||||
|
|
||||||
- `nwc_url: String` - a nostr wallet connect URL used to **create** bolt11 invoices (i.e. receive payments), from `NWC_URL`
|
- `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`
|
- `stripe_webhook_secret: String` - secret for verifying Stripe webhook signatures, from `STRIPE_WEBHOOK_SECRET`
|
||||||
- `query: Query`
|
- `query: Query`
|
||||||
- `command: Command`
|
- `command: Command`
|
||||||
@@ -14,8 +13,6 @@ Members:
|
|||||||
## `pub fn new(query: Query, command: Command, robot: Robot) -> Self`
|
## `pub fn new(query: Query, command: Command, robot: Robot) -> Self`
|
||||||
|
|
||||||
- Reads environment and populates members
|
- 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)`
|
## `pub fn start(&self)`
|
||||||
|
|
||||||
@@ -28,7 +25,7 @@ Manages the Stripe subscription and subscription items for a relay's tenant. Onl
|
|||||||
|
|
||||||
- Fetch the relay and tenant associated with the `activity`
|
- Fetch the relay and tenant associated with the `activity`
|
||||||
- **If relay plan is `free`**: if the relay has a `stripe_subscription_item_id`, delete it via the Stripe API and call `command.delete_relay_subscription_item`. Then check cleanup (below). Return early.
|
- **If relay plan is `free`**: if the relay has a `stripe_subscription_item_id`, delete it via the Stripe API and call `command.delete_relay_subscription_item`. Then check cleanup (below). Return early.
|
||||||
- **If relay is `inactive` or `delinquent`**: if the relay has a `stripe_subscription_item_id`, delete it via the Stripe API and call `command.delete_relay_subscription_item`. Then check cleanup (below). Return early.
|
- **If relay is `inactive`**: if the relay has a `stripe_subscription_item_id`, delete it via the Stripe API and call `command.delete_relay_subscription_item`. Then check cleanup (below). Return early.
|
||||||
- **If relay is `active` and on a paid plan**:
|
- **If relay is `active` and on a paid plan**:
|
||||||
- **Ensure subscription exists**: If the tenant has no `stripe_subscription_id`, create a Stripe subscription for the customer with `collection_method: "charge_automatically"` and the relay's price as the first item. Save the subscription ID via `command.set_tenant_subscription` and the item ID via `command.set_relay_subscription_item`. Return early.
|
- **Ensure subscription exists**: If the tenant has no `stripe_subscription_id`, create a Stripe subscription for the customer with `collection_method: "charge_automatically"` and the relay's price as the first item. Save the subscription ID via `command.set_tenant_subscription` and the item ID via `command.set_relay_subscription_item`. Return early.
|
||||||
- **Sync the subscription item**: If the tenant already has a subscription, create or update the relay's Stripe subscription item to the plan's `stripe_price_id` via the Stripe API, then call `command.set_relay_subscription_item`.
|
- **Sync the subscription item**: If the tenant already has a subscription, create or update the relay's Stripe subscription item to the plan's `stripe_price_id` via the Stripe API, then call `command.set_relay_subscription_item`.
|
||||||
@@ -85,7 +82,7 @@ Skip invoices with `amount_due` of 0.
|
|||||||
- Look up tenant by `stripe_customer_id`
|
- Look up tenant by `stripe_customer_id`
|
||||||
- If tenant has `past_due_at` set:
|
- If tenant has `past_due_at` set:
|
||||||
- Clear `past_due_at` via `command.clear_tenant_past_due`
|
- Clear `past_due_at` via `command.clear_tenant_past_due`
|
||||||
- Find all `delinquent` relays on paid plans for the tenant (relays marked delinquent by the billing system due to non-payment)
|
- Find all `inactive` relays for the tenant that were deactivated due to non-payment (i.e. relays on paid plans that are inactive)
|
||||||
- Reactivate each one via `command.activate_relay`
|
- Reactivate each one via `command.activate_relay`
|
||||||
|
|
||||||
## `fn handle_invoice_payment_failed(&self, invoice: &Invoice)`
|
## `fn handle_invoice_payment_failed(&self, invoice: &Invoice)`
|
||||||
@@ -98,7 +95,7 @@ Skip invoices with `amount_due` of 0.
|
|||||||
## `fn handle_invoice_overdue(&self, invoice: &Invoice)`
|
## `fn handle_invoice_overdue(&self, invoice: &Invoice)`
|
||||||
|
|
||||||
- Look up tenant by `stripe_customer_id`
|
- Look up tenant by `stripe_customer_id`
|
||||||
- Mark all active relays on paid plans as delinquent via `command.mark_relay_delinquent` (sets status to `delinquent`, distinct from user-initiated `deactivate_relay`)
|
- Deactivate all active relays on paid plans via `command.deactivate_relay`
|
||||||
- Send a DM via `robot.send_dm` notifying the tenant that their paid relays have been deactivated due to non-payment
|
- Send a DM via `robot.send_dm` notifying the tenant that their paid relays have been deactivated due to non-payment
|
||||||
|
|
||||||
## `fn handle_subscription_updated(&self, subscription: &Subscription)`
|
## `fn handle_subscription_updated(&self, subscription: &Subscription)`
|
||||||
@@ -106,9 +103,10 @@ Skip invoices with `amount_due` of 0.
|
|||||||
- Look up tenant by `stripe_customer_id`
|
- Look up tenant by `stripe_customer_id`
|
||||||
- If subscription status is `canceled` or `unpaid`:
|
- If subscription status is `canceled` or `unpaid`:
|
||||||
- Clear `stripe_subscription_id` via `command.clear_tenant_subscription`
|
- Clear `stripe_subscription_id` via `command.clear_tenant_subscription`
|
||||||
- Mark all active paid relays as delinquent via `command.mark_relay_delinquent`
|
- Deactivate all active paid relays for the tenant via `command.deactivate_relay`
|
||||||
|
|
||||||
## `fn handle_subscription_deleted(&self, subscription: &Subscription)`
|
## `fn handle_subscription_deleted(&self, subscription: &Subscription)`
|
||||||
|
|
||||||
- Look up tenant by `stripe_customer_id`
|
- Look up tenant by `stripe_customer_id`
|
||||||
- Clear `stripe_subscription_id` via `command.clear_tenant_subscription`
|
- Clear `stripe_subscription_id` via `command.clear_tenant_subscription`
|
||||||
|
|
||||||
|
|||||||
@@ -44,14 +44,6 @@ Notes:
|
|||||||
|
|
||||||
- Sets relay status to `inactive`
|
- Sets relay status to `inactive`
|
||||||
- Logs activity as `(deactivate_relay, relay_id)`
|
- Logs activity as `(deactivate_relay, relay_id)`
|
||||||
- Used for user/admin-initiated deactivation only
|
|
||||||
|
|
||||||
## `pub fn mark_relay_delinquent(&self, relay: &Relay) -> Result<()>`
|
|
||||||
|
|
||||||
- Sets relay status to `delinquent`
|
|
||||||
- Logs activity as `(deactivate_relay, relay_id)`
|
|
||||||
- Used exclusively by the billing system when a relay's subscription becomes past due
|
|
||||||
- `delinquent` relays are automatically reactivated via `activate_relay` when payment is received
|
|
||||||
|
|
||||||
## `pub fn activate_relay(&self, relay: &Relay) -> Result<()>`
|
## `pub fn activate_relay(&self, relay: &Relay) -> Result<()>`
|
||||||
|
|
||||||
|
|||||||
@@ -30,6 +30,5 @@ Members:
|
|||||||
## `async fn sync_relay(&self, relay: &Relay, is_new: bool)`
|
## `async fn sync_relay(&self, relay: &Relay, is_new: bool)`
|
||||||
|
|
||||||
- If `is_new`, sends `POST /relay/:id` to create the relay in zooid.
|
- If `is_new`, sends `POST /relay/:id` to create the relay in zooid.
|
||||||
- Otherwise, sends `PATCH /relay/:id` to update it.
|
- Otherwise, sends `PUT /relay/:id` to update it.
|
||||||
- Includes `secret` only for relay creation (`POST`) so updates do not rotate relay identity.
|
- Passes full relay configuration in the body including host, schema, secret, inactive flag, info, policy, groups, management, blossom, livekit, push, and roles.
|
||||||
- Passes relay configuration in the body including host, schema, inactive flag, info, policy, groups, management, blossom, livekit, push, and roles.
|
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ A relay is a nostr relay owned by a `tenant` and hosted by the attached zooid in
|
|||||||
- `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.
|
||||||
- `status` - one of `active|inactive|delinquent`. Only `active` relays count toward billing. `delinquent` is set by the billing system when a relay's subscription becomes past due; `inactive` is set when a user or admin manually deactivates a relay.
|
- `status` - `active|inactive`. Only `active` relays count toward billing.
|
||||||
- `synced` - whether the relay has been successfully synced to zooid at least once.
|
- `synced` - whether the relay has been successfully synced to zooid at least once.
|
||||||
- `sync_error` - a string indicating any errors encountered when synchronizing.
|
- `sync_error` - a string indicating any errors encountered when synchronizing.
|
||||||
- `info_name` - the relay's name
|
- `info_name` - the relay's name
|
||||||
@@ -85,8 +85,9 @@ 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:
|
Some attributes persisted to zooid via API have special handling:
|
||||||
|
|
||||||
- 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 `secret` is generated once and persisted to the zooid configuration but isn't stored in the database.
|
||||||
- The relay's `host` is calculated based on `subdomain` + `RELAY_DOMAIN`
|
- The relay's `host` is calculated based on `subdomain` + `RELAY_DOMAIN`
|
||||||
- The value of `inactive` is calculated based on `status`
|
- 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 `livekit_*` configuration is inferred based on environment variables and `livekit_enabled`.
|
||||||
- The relay's `roles` are hard-coded for now.
|
- The relay's `roles` are hard-coded for now.
|
||||||
|
|
||||||
|
|||||||
+3
-4
@@ -139,7 +139,7 @@ impl Api {
|
|||||||
api: Arc::new(self),
|
api: Arc::new(self),
|
||||||
};
|
};
|
||||||
|
|
||||||
let router = Router::new()
|
Router::new()
|
||||||
.route("/identity", get(get_identity))
|
.route("/identity", get(get_identity))
|
||||||
.route("/plans", get(list_plans))
|
.route("/plans", get(list_plans))
|
||||||
.route("/plans/:id", get(get_plan))
|
.route("/plans/:id", get(get_plan))
|
||||||
@@ -158,9 +158,8 @@ impl Api {
|
|||||||
"/tenants/:pubkey/stripe/session",
|
"/tenants/:pubkey/stripe/session",
|
||||||
get(create_stripe_session),
|
get(create_stripe_session),
|
||||||
)
|
)
|
||||||
.route("/stripe/webhook", post(stripe_webhook));
|
.route("/stripe/webhook", post(stripe_webhook))
|
||||||
|
.with_state(state)
|
||||||
router.with_state(state)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn extract_auth_pubkey(&self, headers: &HeaderMap) -> std::result::Result<String, ApiError> {
|
fn extract_auth_pubkey(&self, headers: &HeaderMap) -> std::result::Result<String, ApiError> {
|
||||||
|
|||||||
+6
-105
@@ -95,9 +95,6 @@ impl Billing {
|
|||||||
panic!("missing STRIPE_SECRET_KEY environment variable");
|
panic!("missing STRIPE_SECRET_KEY environment variable");
|
||||||
}
|
}
|
||||||
let stripe_webhook_secret = std::env::var("STRIPE_WEBHOOK_SECRET").unwrap_or_default();
|
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 =
|
let btc_quote_api_base =
|
||||||
std::env::var("BTC_PRICE_API_BASE").unwrap_or_else(|_| COINBASE_SPOT_API.to_string());
|
std::env::var("BTC_PRICE_API_BASE").unwrap_or_else(|_| COINBASE_SPOT_API.to_string());
|
||||||
Self {
|
Self {
|
||||||
@@ -952,8 +949,7 @@ mod tests {
|
|||||||
use sqlx::SqlitePool;
|
use sqlx::SqlitePool;
|
||||||
use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions};
|
use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions};
|
||||||
use std::str::FromStr;
|
use std::str::FromStr;
|
||||||
use std::sync::OnceLock;
|
use std::sync::{Mutex, OnceLock};
|
||||||
use tokio::sync::Mutex;
|
|
||||||
|
|
||||||
fn env_lock() -> &'static Mutex<()> {
|
fn env_lock() -> &'static Mutex<()> {
|
||||||
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
|
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
|
||||||
@@ -968,14 +964,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 {
|
struct StripeSecretKeyGuard {
|
||||||
previous: Option<String>,
|
previous: Option<String>,
|
||||||
}
|
}
|
||||||
@@ -994,24 +982,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 {
|
async fn test_pool() -> SqlitePool {
|
||||||
let connect_options = SqliteConnectOptions::from_str("sqlite::memory:")
|
let connect_options = SqliteConnectOptions::from_str("sqlite::memory:")
|
||||||
.expect("valid sqlite memory url")
|
.expect("valid sqlite memory url")
|
||||||
@@ -1033,9 +1003,8 @@ mod tests {
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn billing_new_panics_without_stripe_secret_key() {
|
async fn billing_new_panics_without_stripe_secret_key() {
|
||||||
let _lock = env_lock().lock().await;
|
let _lock = env_lock().lock().expect("acquire env lock");
|
||||||
let _secret_env = StripeSecretKeyGuard::set(None);
|
let _env = StripeSecretKeyGuard::set(None);
|
||||||
let _webhook_env = StripeWebhookSecretGuard::set(Some("whsec_test_dummy"));
|
|
||||||
|
|
||||||
let pool = test_pool().await;
|
let pool = test_pool().await;
|
||||||
let query = Query::new(pool.clone());
|
let query = Query::new(pool.clone());
|
||||||
@@ -1065,76 +1034,9 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn billing_new_panics_without_stripe_webhook_secret() {
|
async fn billing_new_accepts_non_empty_stripe_secret_key() {
|
||||||
let _lock = env_lock().lock().await;
|
let _lock = env_lock().lock().expect("acquire env lock");
|
||||||
let _secret_env = StripeSecretKeyGuard::set(Some("sk_test_dummy"));
|
let _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 pool = test_pool().await;
|
||||||
let billing = Billing::new(
|
let billing = Billing::new(
|
||||||
@@ -1144,6 +1046,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");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ impl Command {
|
|||||||
.fetch_one(&mut **tx)
|
.fetch_one(&mut **tx)
|
||||||
.await?
|
.await?
|
||||||
}
|
}
|
||||||
_ => anyhow::bail!("unknown resource_type: {resource_type}"),
|
_ => anyhow::bail!("unknown resource_type: {}", resource_type),
|
||||||
};
|
};
|
||||||
|
|
||||||
let id = uuid::Uuid::new_v4().to_string();
|
let id = uuid::Uuid::new_v4().to_string();
|
||||||
|
|||||||
+49
-69
@@ -2,7 +2,7 @@ use anyhow::Result;
|
|||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
|
|
||||||
use crate::command::Command;
|
use crate::command::Command;
|
||||||
use crate::models::{Activity, Relay, RELAY_STATUS_DELINQUENT, RELAY_STATUS_INACTIVE};
|
use crate::models::{Activity, RELAY_STATUS_DELINQUENT, RELAY_STATUS_INACTIVE};
|
||||||
use crate::query::Query;
|
use crate::query::Query;
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
@@ -70,7 +70,7 @@ impl Infra {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn sync_and_report(&self, relay: &Relay, is_new: bool) {
|
async fn sync_and_report(&self, relay: &crate::models::Relay, is_new: bool) {
|
||||||
match self.sync_relay(relay, is_new).await {
|
match self.sync_relay(relay, is_new).await {
|
||||||
Ok(()) => {
|
Ok(()) => {
|
||||||
tracing::info!(relay = %relay.id, "relay sync succeeded");
|
tracing::info!(relay = %relay.id, "relay sync succeeded");
|
||||||
@@ -96,7 +96,7 @@ impl Infra {
|
|||||||
Ok(auth)
|
Ok(auth)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn sync_relay(&self, relay: &Relay, is_new: bool) -> Result<()> {
|
async fn sync_relay(&self, relay: &crate::models::Relay, is_new: bool) -> Result<()> {
|
||||||
let client = reqwest::Client::new();
|
let client = reqwest::Client::new();
|
||||||
let base = self.api_url.trim_end_matches('/');
|
let base = self.api_url.trim_end_matches('/');
|
||||||
|
|
||||||
@@ -106,6 +106,8 @@ impl Infra {
|
|||||||
format!("{}.{}", relay.subdomain, self.relay_domain)
|
format!("{}.{}", relay.subdomain, self.relay_domain)
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let secret = Keys::generate().secret_key().to_secret_hex();
|
||||||
|
|
||||||
let livekit = if relay.livekit_enabled == 1 {
|
let livekit = if relay.livekit_enabled == 1 {
|
||||||
serde_json::json!({
|
serde_json::json!({
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
@@ -117,84 +119,62 @@ impl Infra {
|
|||||||
serde_json::json!({ "enabled": false })
|
serde_json::json!({ "enabled": false })
|
||||||
};
|
};
|
||||||
|
|
||||||
let body = relay_sync_body(
|
let body = serde_json::json!({
|
||||||
relay,
|
"host": host,
|
||||||
host,
|
"schema": relay.schema,
|
||||||
livekit,
|
"secret": secret,
|
||||||
is_new.then(|| Keys::generate().secret_key().to_secret_hex()),
|
"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 url = format!("{}/relay/{}", base, relay.id);
|
let response = if is_new {
|
||||||
let auth = self.nip98_auth(&url, zooid_sync_http_method(is_new)).await?;
|
let url = format!("{}/relay/{}", base, relay.id);
|
||||||
|
let auth = self.nip98_auth(&url, HttpMethod::POST).await?;
|
||||||
let request = if is_new {
|
client
|
||||||
client.post(&url)
|
.post(&url)
|
||||||
|
.header("Authorization", auth)
|
||||||
|
.json(&body)
|
||||||
|
.send()
|
||||||
|
.await?
|
||||||
} else {
|
} else {
|
||||||
client.patch(&url)
|
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?
|
||||||
};
|
};
|
||||||
|
|
||||||
let response = request
|
|
||||||
.header("Authorization", auth)
|
|
||||||
.json(&body)
|
|
||||||
.send()
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
if !response.status().is_success() {
|
if !response.status().is_success() {
|
||||||
let status = response.status();
|
let status = response.status();
|
||||||
let body = response.text().await.unwrap_or_default();
|
let body = response.text().await.unwrap_or_default();
|
||||||
anyhow::bail!("zooid sync returned {status}: {body}")
|
anyhow::bail!("zooid sync returned {}: {}", status, body)
|
||||||
}
|
}
|
||||||
Ok(())
|
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 {
|
fn should_sync_relay_activity(activity_type: &str) -> bool {
|
||||||
matches!(
|
matches!(
|
||||||
activity_type,
|
activity_type,
|
||||||
|
|||||||
+2
-2
@@ -3,8 +3,8 @@ mod billing;
|
|||||||
mod command;
|
mod command;
|
||||||
mod infra;
|
mod infra;
|
||||||
mod models;
|
mod models;
|
||||||
mod pool;
|
|
||||||
mod query;
|
mod query;
|
||||||
|
mod pool;
|
||||||
mod robot;
|
mod robot;
|
||||||
|
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
@@ -40,7 +40,7 @@ async fn main() -> Result<()> {
|
|||||||
let port: u16 = std::env::var("PORT")
|
let port: u16 = std::env::var("PORT")
|
||||||
.ok()
|
.ok()
|
||||||
.and_then(|v| v.parse().ok())
|
.and_then(|v| v.parse().ok())
|
||||||
.unwrap_or(2892);
|
.unwrap_or(3000);
|
||||||
let origins: Vec<String> = std::env::var("ALLOW_ORIGINS")
|
let origins: Vec<String> = std::env::var("ALLOW_ORIGINS")
|
||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
.split(',')
|
.split(',')
|
||||||
|
|||||||
+2
-1
@@ -21,7 +21,8 @@ pub async fn create_pool() -> Result<SqlitePool> {
|
|||||||
std::fs::create_dir_all(parent)?;
|
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()
|
let pool = SqlitePoolOptions::new()
|
||||||
.max_connections(5)
|
.max_connections(5)
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ async fn quote_endpoint_can_be_stubbed_deterministically() {
|
|||||||
|
|
||||||
assert_eq!(btc_price, 50_000.0);
|
assert_eq!(btc_price, 50_000.0);
|
||||||
|
|
||||||
let msats =
|
let msats = fiat_minor_to_msats_from_quote(100, "USD", btc_price)
|
||||||
fiat_minor_to_msats_from_quote(100, "USD", btc_price).expect("convert quoted fiat amount");
|
.expect("convert quoted fiat amount");
|
||||||
assert_eq!(msats, 2_000_000);
|
assert_eq!(msats, 2_000_000);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
# Backend API base URL
|
# 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
|
# Platform display name shown in UI
|
||||||
VITE_PLATFORM_NAME=Caravel
|
VITE_PLATFORM_NAME=Caravel
|
||||||
|
|||||||
+1
-1
@@ -32,7 +32,7 @@ Environment variables (see `.env.template`):
|
|||||||
|
|
||||||
| Variable | Description | Default |
|
| 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
|
## Running
|
||||||
|
|
||||||
|
|||||||
+7
-14
@@ -1,20 +1,13 @@
|
|||||||
import { defineConfig, loadEnv } from 'vite'
|
import { defineConfig } from 'vite'
|
||||||
import solid from 'vite-plugin-solid'
|
import solid from 'vite-plugin-solid'
|
||||||
import tailwindcss from '@tailwindcss/vite'
|
import tailwindcss from '@tailwindcss/vite'
|
||||||
import { fileURLToPath, URL } from 'node:url'
|
import { fileURLToPath, URL } from 'node:url'
|
||||||
|
|
||||||
export default defineConfig(({mode}) => {
|
export default defineConfig({
|
||||||
const env = loadEnv(mode, process.cwd(), '')
|
plugins: [tailwindcss(), solid()],
|
||||||
|
resolve: {
|
||||||
return {
|
alias: {
|
||||||
plugins: [tailwindcss(), solid()],
|
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||||||
resolve: {
|
|
||||||
alias: {
|
|
||||||
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
server: {
|
},
|
||||||
port: Number(env.VITE_PORT) || 5173,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user