Compare commits

..

16 Commits

Author SHA1 Message Date
userAdityaa 41602e21c2 feat: display relay provisioning errors in UI 2026-04-20 18:08:38 +00:00
hodlbod c47727b909 Merge pull request 'Add tenant create endpoint' (#27) from create-tenant into master 2026-04-20 15:56:03 +00:00
Jon Staab 0705da8b09 Add tenant create endpoint 2026-04-20 15:55:56 +00:00
userAdityaa ca26d41eef fix: relay secret rotation on infra sync updates (#26)
Co-authored-by: userAdityaa <aditya.chaudhary1558@gmail.com>
Co-committed-by: userAdityaa <aditya.chaudhary1558@gmail.com>
2026-04-18 13:24:08 +00:00
userAdityaa 44f9928070 fix: make stripe webhooks explicitly toggleable with mandatory secret validation (#23)
Co-authored-by: userAdityaa <aditya.chaudhary1558@gmail.com>
Co-committed-by: userAdityaa <aditya.chaudhary1558@gmail.com>
2026-04-17 22:57:37 +00:00
Jon Staab 87dcf53d74 Change default backend port 2026-04-17 13:23:26 -07:00
userAdityaa bcbce5c058 chore: replace placeholder letter badges with actual SVG logos (#24)
Co-authored-by: userAdityaa <aditya.chaudhary1558@gmail.com>
Co-committed-by: userAdityaa <aditya.chaudhary1558@gmail.com>
2026-04-17 18:36:35 +00:00
userAdityaa 90e488d87e feat: add Nostrord to recommended apps (#22)
Co-authored-by: userAdityaa <aditya.chaudhary1558@gmail.com>
Co-committed-by: userAdityaa <aditya.chaudhary1558@gmail.com>
2026-04-17 15:23:20 +00:00
userAdityaa 334f05783f chore: harden relay plan validation to prevent billing bypass and plan-state drift (#20)
Co-authored-by: userAdityaa <aditya.chaudhary1558@gmail.com>
Co-committed-by: userAdityaa <aditya.chaudhary1558@gmail.com>
2026-04-16 21:35:43 +00:00
userAdityaa 145b511f9d docs(auth): document intentional session-style NIP-98 model (#16)
Co-authored-by: userAdityaa <aditya.chaudhary1558@gmail.com>
Co-committed-by: userAdityaa <aditya.chaudhary1558@gmail.com>
2026-04-16 15:40:50 +00:00
userAdityaa bac763c925 fix: invoice error mapping so Stripe 4xx responses are not returned as 500 (#17)
Co-authored-by: userAdityaa <aditya.chaudhary1558@gmail.com>
Co-committed-by: userAdityaa <aditya.chaudhary1558@gmail.com>
2026-04-16 15:39:35 +00:00
userAdityaa 85d37f53ce fix: respect activity_type in set_relay_status and include activate_relay (#14)
Co-authored-by: userAdityaa <aditya.chaudhary1558@gmail.com>
Co-committed-by: userAdityaa <aditya.chaudhary1558@gmail.com>
2026-04-15 20:39:06 +00:00
userAdityaa 072031d0c3 feat(frontend): handle bolt11 generation failures in payment dialog (#11)
Co-authored-by: userAdityaa <aditya.chaudhary1558@gmail.com>
Co-committed-by: userAdityaa <aditya.chaudhary1558@gmail.com>
2026-04-14 23:35:13 +00:00
userAdityaa ce595c8bc5 Ensure all tenants have valid Stripe customer IDs (#5)
Co-authored-by: userAdityaa <aditya.chaudhary1558@gmail.com>
Co-committed-by: userAdityaa <aditya.chaudhary1558@gmail.com>
2026-04-14 23:06:48 +00:00
userAdityaa 1d4034340b fix: invoice.paid reactivating manually deactivated relays (#10)
Co-authored-by: userAdityaa <aditya.chaudhary1558@gmail.com>
Co-committed-by: userAdityaa <aditya.chaudhary1558@gmail.com>
2026-04-14 22:10:40 +00:00
userAdityaa 9a8d02b286 fiat invoice to Lightning msat conversion by applying real-time BTC FX quotes (#7)
Co-authored-by: userAdityaa <aditya.chaudhary1558@gmail.com>
Co-committed-by: userAdityaa <aditya.chaudhary1558@gmail.com>
2026-04-13 21:05:21 +00:00
31 changed files with 1169 additions and 287 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 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 ### 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: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 ## Project docs
+4 -4
View File
@@ -1,10 +1,10 @@
# Server # Server
HOST=127.0.0.1 HOST=127.0.0.1
PORT=3000 PORT=2892
ALLOW_ORIGINS= # Optional comma-separated allowed CORS origins; empty = permissive ALLOW_ORIGINS= # Optional comma-separated allowed CORS origins; empty = permissive
# Auth # Auth
ADMINS= # Comma-separated admin keys (hex pubkey or npub) ADMINS= # Comma-separated hex pubkeys with admin access
# Database # Database
DATABASE_URL=sqlite://data/caravel.db DATABASE_URL=sqlite://data/caravel.db
@@ -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= # Stripe API secret key (sk_...) STRIPE_SECRET_KEY= # Required Stripe API secret key (sk_...)
STRIPE_WEBHOOK_SECRET= # Secret for verifying Stripe webhook signatures (whsec_...) 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 COPY --from=build /app/target/release/backend /app/backend
EXPOSE 3000 EXPOSE 2892
CMD ["/app/backend"] CMD ["/app/backend"]
+42 -24
View File
@@ -30,27 +30,29 @@ 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 | `3000` | | `PORT` | API bind port | `2892` |
| `ADMINS` | Comma-separated admin pubkeys (`hex` or `npub`) | _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_ |
| `ROBOT_SECRET` | Robot Nostr secret key | _required_ | | `STRIPE_SECRET_KEY` | Stripe API secret key used for billing API operations | _required_ |
| `ROBOT_NAME` | Robot display name (kind `0`) | _optional_ | | `STRIPE_WEBHOOK_SECRET` | Stripe webhook signing secret used to verify `Stripe-Signature` headers | _required_ |
| `ROBOT_DESCRIPTION` | Robot description (kind `0`) | _optional_ | | `ROBOT_SECRET` | Robot Nostr secret key | _required_ |
| `ROBOT_PICTURE` | Robot picture URL (kind `0`) | _optional_ | | `ROBOT_NAME` | Robot display name (kind `0`) | _optional_ |
| `ROBOT_OUTBOX_RELAYS` | Comma-separated relays published as kind `10002` | _required_ | | `ROBOT_DESCRIPTION` | Robot description (kind `0`) | _optional_ |
| `ROBOT_INDEXER_RELAYS` | Comma-separated relays used for recipient relay discovery | _required_ | | `ROBOT_PICTURE` | Robot picture URL (kind `0`) | _optional_ |
| `ROBOT_MESSAGING_RELAYS` | Comma-separated relays published as kind `10050` | _required_ | | `ROBOT_OUTBOX_RELAYS` | Comma-separated relays published as kind `10002` | _required_ |
| `ROBOT_INDEXER_RELAYS` | Comma-separated relays used for recipient relay discovery | _required_ |
| `ROBOT_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.
@@ -60,11 +62,17 @@ See [spec](spec) for more details
## API Routes ## API Routes
All routes are NIP-98 protected. Most API routes are NIP-98 protected.
- `GET /identity` — get auth identity (`pubkey`, `is_admin`) Public exceptions:
- `GET /plans`
- `GET /plans/:id`
- `POST /stripe/webhook` (validated with Stripe signatures)
- `GET /identity` — get auth identity (`pubkey`, `is_admin`); side-effect-free
- `GET /tenants` — list tenants (admin) - `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) - `GET /tenants/:pubkey` — get tenant (admin or same tenant)
- `PUT /tenants/:pubkey/billing` — update tenant `nwc_url` (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) - `GET /relays` — list relays (`?tenant=<pubkey>` allowed for admin only)
@@ -73,3 +81,13 @@ All routes are NIP-98 protected.
- `PUT /relays/:id` — update relay (admin or relay tenant) - `PUT /relays/:id` — update relay (admin or relay tenant)
- `POST /relays/:id/deactivate` — deactivate 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) - `GET /invoices` — list invoices (`?tenant=<pubkey>` allowed for admin only)
## API Auth Model
Caravel intentionally uses a session-style variant of NIP-98 for client-to-backend API auth.
- Frontend signs one kind `27235` event with `u = VITE_API_URL` and caches that header for about 10 minutes.
- Backend verifies event kind, signature, and that `u` contains configured `HOST`.
- Backend intentionally does not bind auth to exact request URL/method/query, and does not enforce payload hash, timestamp freshness window, or replay cache.
- Goal: reduce repeated wallet signing prompts and avoid cookie-based sessions.
- Tradeoff: this is weaker request-intent binding than strict NIP-98 semantics.
+1 -1
View File
@@ -12,7 +12,7 @@ CREATE TABLE IF NOT EXISTS tenant (
nwc_url TEXT NOT NULL DEFAULT '', nwc_url TEXT NOT NULL DEFAULT '',
nwc_error TEXT, nwc_error TEXT,
created_at INTEGER NOT NULL, created_at INTEGER NOT NULL,
stripe_customer_id TEXT NOT NULL DEFAULT '', stripe_customer_id TEXT NOT NULL,
stripe_subscription_id TEXT, stripe_subscription_id TEXT,
past_due_at INTEGER past_due_at INTEGER
); );
+23 -8
View File
@@ -46,9 +46,8 @@ Notes:
- Serves `GET /identity` - Serves `GET /identity`
- Authorizes anyone, but must be authorized - Authorizes anyone, but must be authorized
- If a tenant for the identity doesn't exist: - Side-effect-free: returns `{ pubkey, is_admin }` only
- Call the Stripe API to create a new customer - Clients must call `POST /tenants` before any tenant-scoped write
- 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.
- Return `data` is an `Identity` struct - Return `data` is an `Identity` struct
--- Tenant routes --- Tenant routes
@@ -59,6 +58,18 @@ Notes:
- Authorizes admin only - Authorizes admin only
- Return `data` is a list of tenant structs from `query.list_tenants` - 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` ## `async fn get_tenant(...) -> Response`
- Serves `GET /tenants/:pubkey` - Serves `GET /tenants/:pubkey`
@@ -178,15 +189,18 @@ 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
## `extract_auth_pubkey(&self, headers: &HeaderMap) -> Result<String>` ## `extract_auth_pubkey(&self, headers: &HeaderMap) -> Result<String>`
- Parses `Authorization` header - Parses `Authorization` header
- Validates event kind and signature using `nostr_sdk` - Validates event kind (`27235`) and signature using `nostr_sdk`
- Validates event `u` against `HOST` (not the request path. Non-standard, but correct) - Validates event `u` contains configured `HOST`
- Does not validate `method` tag - 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
- Returns pubkey if header all checks pass - 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. Refer to https://github.com/nostr-protocol/nips/blob/master/98.md for details. Use `nostr_sdk` functionality where possible.
@@ -202,7 +216,8 @@ Refer to https://github.com/nostr-protocol/nips/blob/master/98.md for details. U
## `prepare_relay(&self, relay: Relay) -> anyhow::Result<Relay>` ## `prepare_relay(&self, relay: Relay) -> anyhow::Result<Relay>`
- Validate `subdomain` - Validate `subdomain`
- If `plan` is free and `blossom` is enabled, return `premium-feature` - Validate that `plan` matches a known plan id from `Query::list_plans`
- If `plan` is free and `livekit` is enabled, return `premium-feature` - 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`
- Populate `schema` if not already set - Populate `schema` if not already set
- Populate missing fields using reasonable defaults - Populate missing fields using reasonable defaults
+3 -1
View File
@@ -5,6 +5,7 @@ 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`
@@ -13,6 +14,8 @@ 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)`
@@ -109,4 +112,3 @@ Skip invoices with `amount_due` of 0.
- 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`
+4 -3
View File
@@ -19,7 +19,7 @@ Members:
## `async fn handle_activity(&self, activity: &Activity)` ## `async fn handle_activity(&self, activity: &Activity)`
- For `create_relay`, `update_relay`, or `deactivate_relay` activity, calls `sync_and_report`. - For `create_relay`, `update_relay`, `activate_relay`, or `deactivate_relay` activity, calls `sync_and_report`.
- All other activity types are ignored (e.g. `fail_relay_sync`, `complete_relay_sync`). - All other activity types are ignored (e.g. `fail_relay_sync`, `complete_relay_sync`).
## `async fn sync_and_report(&self, relay: &Relay, is_new: bool)` ## `async fn sync_and_report(&self, relay: &Relay, is_new: bool)`
@@ -30,5 +30,6 @@ 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 `PUT /relay/:id` to update it. - Otherwise, sends `PATCH /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. - 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: 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 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.
+138 -73
View File
@@ -9,12 +9,14 @@ use axum::{
routing::{get, post}, routing::{get, post},
}; };
use base64::Engine; use base64::Engine;
use nostr_sdk::{Event, JsonUtil, Keys, Kind, PublicKey}; use nostr_sdk::{Event, JsonUtil, Kind};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::billing::Billing; use crate::billing::{Billing, InvoiceLookupError};
use crate::command::Command; use crate::command::Command;
use crate::models::{Relay, Tenant}; use crate::models::{
RELAY_STATUS_ACTIVE, RELAY_STATUS_DELINQUENT, RELAY_STATUS_INACTIVE, Relay, Tenant,
};
use crate::query::Query; use crate::query::Query;
use axum::body::Bytes; use axum::body::Bytes;
@@ -70,6 +72,11 @@ enum ApiError {
Unauthorized(anyhow::Error), Unauthorized(anyhow::Error),
Forbidden(&'static str), Forbidden(&'static str),
NotFound(&'static str), NotFound(&'static str),
Client {
status: StatusCode,
code: &'static str,
message: &'static str,
},
Internal(String), Internal(String),
} }
@@ -79,18 +86,44 @@ impl IntoResponse for ApiError {
Self::Unauthorized(e) => err(StatusCode::UNAUTHORIZED, "unauthorized", &e.to_string()), Self::Unauthorized(e) => err(StatusCode::UNAUTHORIZED, "unauthorized", &e.to_string()),
Self::Forbidden(message) => err(StatusCode::FORBIDDEN, "forbidden", message), Self::Forbidden(message) => err(StatusCode::FORBIDDEN, "forbidden", message),
Self::NotFound(message) => err(StatusCode::NOT_FOUND, "not-found", message), Self::NotFound(message) => err(StatusCode::NOT_FOUND, "not-found", message),
Self::Client {
status,
code,
message,
} => err(status, code, message),
Self::Internal(message) => err(StatusCode::INTERNAL_SERVER_ERROR, "internal", &message), Self::Internal(message) => err(StatusCode::INTERNAL_SERVER_ERROR, "internal", &message),
} }
} }
} }
fn map_invoice_lookup_error(error: InvoiceLookupError) -> ApiError {
match error {
InvoiceLookupError::StripeClient { status } => {
let status = StatusCode::from_u16(status.as_u16()).unwrap_or(StatusCode::BAD_REQUEST);
match status {
StatusCode::NOT_FOUND => ApiError::NotFound("invoice not found"),
StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN => {
ApiError::Forbidden("invoice access denied")
}
_ => ApiError::Client {
status,
code: "invoice-request-rejected",
message: "invoice request rejected",
},
}
}
InvoiceLookupError::Internal(error) => ApiError::Internal(error.to_string()),
}
}
impl Api { impl Api {
pub fn new(query: Query, command: Command, billing: Billing) -> Self { pub fn new(query: Query, command: Command, billing: Billing) -> Self {
let host = std::env::var("HOST").unwrap_or_else(|_| "127.0.0.1".to_string()); let host = std::env::var("HOST").unwrap_or_else(|_| "127.0.0.1".to_string());
let admins = std::env::var("ADMINS") let admins = std::env::var("ADMINS")
.unwrap_or_default() .unwrap_or_default()
.split(',') .split(',')
.filter_map(parse_admin_pubkey) .map(|v| v.trim().to_lowercase())
.filter(|v| !v.is_empty())
.collect(); .collect();
Self { Self {
host, host,
@@ -106,11 +139,11 @@ impl Api {
api: Arc::new(self), api: Arc::new(self),
}; };
Router::new() let router = 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))
.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", get(get_tenant).put(update_tenant))
.route("/tenants/:pubkey/relays", get(list_tenant_relays)) .route("/tenants/:pubkey/relays", get(list_tenant_relays))
.route("/relays", get(list_relays).post(create_relay)) .route("/relays", get(list_relays).post(create_relay))
@@ -125,8 +158,9 @@ 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> {
@@ -176,6 +210,9 @@ impl Api {
return Err(ApiError::Unauthorized(anyhow!("missing u tag"))); 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) { if !self.host.is_empty() && !got_u.contains(&self.host) {
return Err(ApiError::Unauthorized(anyhow!( return Err(ApiError::Unauthorized(anyhow!(
"authorization host mismatch" "authorization host mismatch"
@@ -223,10 +260,12 @@ impl Api {
return Err(anyhow!("invalid-subdomain")); return Err(anyhow!("invalid-subdomain"));
} }
if relay.plan == "free" && relay.blossom_enabled == 1 { let plan = Query::get_plan(&relay.plan).ok_or_else(|| anyhow!("invalid-plan"))?;
if !plan.blossom && relay.blossom_enabled == 1 {
return Err(anyhow!("premium-feature")); return Err(anyhow!("premium-feature"));
} }
if relay.plan == "free" && relay.livekit_enabled == 1 { if !plan.livekit && relay.livekit_enabled == 1 {
return Err(anyhow!("premium-feature")); return Err(anyhow!("premium-feature"));
} }
@@ -234,44 +273,22 @@ impl Api {
relay.schema = format!("{}_{}", relay.subdomain.replace('-', "_"), relay.id); relay.schema = format!("{}_{}", relay.subdomain.replace('-', "_"), relay.id);
} }
if relay.status.is_empty() { if relay.status.is_empty() {
relay.status = "active".to_string(); relay.status = RELAY_STATUS_ACTIVE.to_string();
} }
relay.policy_public_join = parse_bool_default(relay.policy_public_join, 0); relay.policy_public_join = parse_bool_default(relay.policy_public_join, 0);
relay.policy_strip_signatures = parse_bool_default(relay.policy_strip_signatures, 0); relay.policy_strip_signatures = parse_bool_default(relay.policy_strip_signatures, 0);
relay.groups_enabled = parse_bool_default(relay.groups_enabled, 1); relay.groups_enabled = parse_bool_default(relay.groups_enabled, 1);
relay.management_enabled = parse_bool_default(relay.management_enabled, 1); relay.management_enabled = parse_bool_default(relay.management_enabled, 1);
relay.blossom_enabled = parse_bool_default( relay.blossom_enabled =
relay.blossom_enabled, parse_bool_default(relay.blossom_enabled, if plan.blossom { 1 } else { 0 });
if relay.plan == "free" { 0 } else { 1 }, relay.livekit_enabled =
); parse_bool_default(relay.livekit_enabled, if plan.livekit { 1 } else { 0 });
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); relay.push_enabled = parse_bool_default(relay.push_enabled, 1);
Ok(relay) Ok(relay)
} }
} }
fn parse_admin_pubkey(value: &str) -> Option<String> {
let value = value.trim();
if value.is_empty() {
return None;
}
if let Ok(pubkey) = PublicKey::parse(value) {
return Some(pubkey.to_hex());
}
// Allow nsec values by deriving their pubkey so admin matching still works.
if let Ok(keys) = Keys::parse(value) {
return Some(keys.public_key().to_hex());
}
None
}
fn ok<T: Serialize>(status: StatusCode, data: T) -> Response { fn ok<T: Serialize>(status: StatusCode, data: T) -> Response {
(status, Json(OkResponse { data, code: "ok" })).into_response() (status, Json(OkResponse { data, code: "ok" })).into_response()
} }
@@ -384,40 +401,73 @@ async fn get_identity(
) -> std::result::Result<Response, ApiError> { ) -> std::result::Result<Response, ApiError> {
let pubkey = state.api.extract_auth_pubkey(&headers)?; let pubkey = state.api.extract_auth_pubkey(&headers)?;
let is_admin = state.api.admins.iter().any(|a| a == &pubkey); let is_admin = state.api.admins.iter().any(|a| a == &pubkey);
// Only create if tenant doesn't exist yet
if let Ok(None) = state.api.query.get_tenant(&pubkey).await {
// TODO: Call Stripe API to create a new customer
let stripe_customer_id = String::new();
let tenant = Tenant {
pubkey: pubkey.clone(),
nwc_url: String::new(),
nwc_error: None,
created_at: now_ts(),
stripe_customer_id,
stripe_subscription_id: None,
past_due_at: None,
};
match state.api.command.create_tenant(&tenant).await {
Ok(()) => {}
Err(e) if matches!(map_unique_error(&e), Some("pubkey-exists")) => {}
Err(e) => {
return Ok(err(
StatusCode::INTERNAL_SERVER_ERROR,
"internal",
&e.to_string(),
));
}
};
}
Ok(ok(StatusCode::OK, IdentityResponse { pubkey, is_admin })) 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)?;
match state.api.query.get_tenant(&pubkey).await {
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,
Err(e) => {
return Ok(err(
StatusCode::INTERNAL_SERVER_ERROR,
"stripe-customer-create-failed",
&e.to_string(),
));
}
};
let tenant = Tenant {
pubkey: pubkey.clone(),
nwc_url: String::new(),
nwc_error: None,
created_at: now_ts(),
stripe_customer_id,
stripe_subscription_id: None,
past_due_at: None,
};
match state.api.command.create_tenant(&tenant).await {
Ok(()) => 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) => Ok(err(
StatusCode::INTERNAL_SERVER_ERROR,
"internal",
&e.to_string(),
)),
}
}
Err(e) => Ok(err(
StatusCode::INTERNAL_SERVER_ERROR,
"internal",
&e.to_string(),
)),
}
}
async fn get_plan(Path(id): Path<String>) -> Response { async fn get_plan(Path(id): Path<String>) -> Response {
match Query::list_plans().into_iter().find(|p| p.id == id) { match Query::get_plan(&id) {
Some(plan) => ok(StatusCode::OK, plan), Some(plan) => ok(StatusCode::OK, plan),
None => err(StatusCode::NOT_FOUND, "not-found", "plan not found"), None => err(StatusCode::NOT_FOUND, "not-found", "plan not found"),
} }
@@ -542,7 +592,7 @@ async fn create_relay(
subdomain: payload.subdomain, subdomain: payload.subdomain,
plan: payload.plan, plan: payload.plan,
stripe_subscription_item_id: None, stripe_subscription_item_id: None,
status: "active".to_string(), status: RELAY_STATUS_ACTIVE.to_string(),
sync_error: String::new(), sync_error: String::new(),
info_name: payload.info_name.unwrap_or_default(), info_name: payload.info_name.unwrap_or_default(),
info_icon: payload.info_icon.unwrap_or_default(), info_icon: payload.info_icon.unwrap_or_default(),
@@ -558,6 +608,13 @@ async fn create_relay(
}; };
relay = match state.api.prepare_relay(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, Ok(r) => r,
Err(e) if e.to_string() == "premium-feature" => { Err(e) if e.to_string() == "premium-feature" => {
return Ok(err( return Ok(err(
@@ -655,6 +712,13 @@ async fn update_relay(
} }
relay = match state.api.prepare_relay(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, Ok(r) => r,
Err(e) if e.to_string() == "premium-feature" => { Err(e) if e.to_string() == "premium-feature" => {
return Ok(err( return Ok(err(
@@ -713,7 +777,7 @@ async fn deactivate_relay(
state.api.require_admin_or_tenant(&auth, &relay.tenant)?; state.api.require_admin_or_tenant(&auth, &relay.tenant)?;
if relay.status == "inactive" { if relay.status == RELAY_STATUS_INACTIVE || relay.status == RELAY_STATUS_DELINQUENT {
return Ok(err( return Ok(err(
StatusCode::BAD_REQUEST, StatusCode::BAD_REQUEST,
"relay-is-inactive", "relay-is-inactive",
@@ -752,7 +816,7 @@ async fn reactivate_relay(
state.api.require_admin_or_tenant(&auth, &relay.tenant)?; state.api.require_admin_or_tenant(&auth, &relay.tenant)?;
if relay.status == "active" { if relay.status == RELAY_STATUS_ACTIVE {
return Ok(err( return Ok(err(
StatusCode::BAD_REQUEST, StatusCode::BAD_REQUEST,
"relay-is-active", "relay-is-active",
@@ -805,7 +869,7 @@ async fn get_invoice(
.billing .billing
.get_invoice_with_tenant(&id) .get_invoice_with_tenant(&id)
.await .await
.map_err(|e| ApiError::Internal(e.to_string()))?; .map_err(map_invoice_lookup_error)?;
state.api.require_admin_or_tenant(&auth, &tenant.pubkey)?; state.api.require_admin_or_tenant(&auth, &tenant.pubkey)?;
Ok(ok(StatusCode::OK, invoice)) Ok(ok(StatusCode::OK, invoice))
@@ -822,7 +886,7 @@ async fn get_invoice_bolt11(
.billing .billing
.get_invoice_with_tenant(&id) .get_invoice_with_tenant(&id)
.await .await
.map_err(|e| ApiError::Internal(e.to_string()))?; .map_err(map_invoice_lookup_error)?;
state.api.require_admin_or_tenant(&auth, &tenant.pubkey)?; state.api.require_admin_or_tenant(&auth, &tenant.pubkey)?;
let status = invoice["status"].as_str().unwrap_or_default(); let status = invoice["status"].as_str().unwrap_or_default();
@@ -835,8 +899,9 @@ async fn get_invoice_bolt11(
} }
let amount_due = invoice["amount_due"].as_i64().unwrap_or(0); let amount_due = invoice["amount_due"].as_i64().unwrap_or(0);
let currency = invoice["currency"].as_str().unwrap_or("usd");
match state.api.billing.create_bolt11(amount_due).await { match state.api.billing.create_bolt11(amount_due, currency).await {
Ok(bolt11) => Ok(ok(StatusCode::OK, serde_json::json!({ "bolt11": bolt11 }))), Ok(bolt11) => Ok(ok(StatusCode::OK, serde_json::json!({ "bolt11": bolt11 }))),
Err(e) => Ok(err( Err(e) => Ok(err(
StatusCode::INTERNAL_SERVER_ERROR, StatusCode::INTERNAL_SERVER_ERROR,
+509 -46
View File
@@ -1,20 +1,58 @@
use anyhow::{Result, anyhow}; use anyhow::{Result, anyhow};
use hmac::{Hmac, Mac}; use hmac::{Hmac, Mac};
use nwc::prelude::{ use nwc::prelude::{
MakeInvoiceRequest, NostrWalletConnectURI, PayInvoiceRequest as NwcPayInvoiceRequest, NWC, MakeInvoiceRequest, NWC, NostrWalletConnectURI, PayInvoiceRequest as NwcPayInvoiceRequest,
}; };
use sha2::Sha256; use sha2::Sha256;
use crate::command::Command; use crate::command::Command;
use crate::models::Activity; use crate::models::{
Activity, RELAY_STATUS_ACTIVE, RELAY_STATUS_DELINQUENT, RELAY_STATUS_INACTIVE, Relay,
};
use crate::query::Query; use crate::query::Query;
use crate::robot::Robot; use crate::robot::Robot;
type HmacSha256 = Hmac<Sha256>; type HmacSha256 = Hmac<Sha256>;
const STRIPE_API: &str = "https://api.stripe.com/v1"; const STRIPE_API: &str = "https://api.stripe.com/v1";
const COINBASE_SPOT_API: &str = "https://api.coinbase.com/v2/prices";
const WEBHOOK_TOLERANCE_SECS: i64 = 300; const WEBHOOK_TOLERANCE_SECS: i64 = 300;
#[derive(Debug)]
pub enum InvoiceLookupError {
StripeClient { status: reqwest::StatusCode },
Internal(anyhow::Error),
}
impl std::fmt::Display for InvoiceLookupError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::StripeClient { status } => {
write!(
f,
"stripe invoice lookup failed with status {}",
status.as_u16()
)
}
Self::Internal(error) => write!(f, "{error}"),
}
}
}
impl std::error::Error for InvoiceLookupError {}
impl From<anyhow::Error> for InvoiceLookupError {
fn from(value: anyhow::Error) -> Self {
Self::Internal(value)
}
}
impl From<reqwest::Error> for InvoiceLookupError {
fn from(value: reqwest::Error) -> Self {
Self::Internal(value.into())
}
}
#[derive(serde::Deserialize)] #[derive(serde::Deserialize)]
struct StripeEvent { struct StripeEvent {
#[serde(rename = "type")] #[serde(rename = "type")]
@@ -27,11 +65,22 @@ struct StripeEventData {
object: serde_json::Value, object: serde_json::Value,
} }
#[derive(serde::Deserialize)]
struct CoinbaseSpotPriceResponse {
data: CoinbaseSpotPriceData,
}
#[derive(serde::Deserialize)]
struct CoinbaseSpotPriceData {
amount: String,
}
#[derive(Clone)] #[derive(Clone)]
pub struct Billing { pub struct Billing {
nwc_url: String, nwc_url: String,
stripe_secret_key: String, stripe_secret_key: String,
stripe_webhook_secret: String, stripe_webhook_secret: String,
btc_quote_api_base: String,
http: reqwest::Client, http: reqwest::Client,
query: Query, query: Query,
command: Command, command: Command,
@@ -42,11 +91,20 @@ impl Billing {
pub fn new(query: Query, command: Command, robot: Robot) -> Self { pub fn new(query: Query, command: Command, robot: Robot) -> Self {
let nwc_url = std::env::var("NWC_URL").unwrap_or_default(); let nwc_url = std::env::var("NWC_URL").unwrap_or_default();
let stripe_secret_key = std::env::var("STRIPE_SECRET_KEY").unwrap_or_default(); let stripe_secret_key = std::env::var("STRIPE_SECRET_KEY").unwrap_or_default();
if stripe_secret_key.trim().is_empty() {
panic!("missing STRIPE_SECRET_KEY environment variable");
}
let stripe_webhook_secret = std::env::var("STRIPE_WEBHOOK_SECRET").unwrap_or_default(); let 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 { Self {
nwc_url, nwc_url,
stripe_secret_key, stripe_secret_key,
stripe_webhook_secret, stripe_webhook_secret,
btc_quote_api_base,
http: reqwest::Client::new(), http: reqwest::Client::new(),
query, query,
command, command,
@@ -75,8 +133,12 @@ impl Billing {
async fn handle_activity(&self, activity: &Activity) -> Result<()> { async fn handle_activity(&self, activity: &Activity) -> Result<()> {
let needs_billing_sync = matches!( let needs_billing_sync = matches!(
activity.activity_type.as_str(), activity.activity_type.as_str(),
"create_relay" | "update_relay" | "activate_relay" | "deactivate_relay" "create_relay"
| "fail_relay_sync" | "complete_relay_sync" | "update_relay"
| "activate_relay"
| "deactivate_relay"
| "fail_relay_sync"
| "complete_relay_sync"
); );
if needs_billing_sync { if needs_billing_sync {
@@ -95,35 +157,34 @@ impl Billing {
return Ok(()); 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 // Free plan: remove subscription item if exists, then clean up
if relay.plan == "free" { if plan.id == "free" {
if let Some(ref item_id) = relay.stripe_subscription_item_id { if let Some(ref item_id) = relay.stripe_subscription_item_id {
self.stripe_delete_subscription_item(item_id).await?; self.stripe_delete_subscription_item(item_id).await?;
self.command.delete_relay_subscription_item(&relay.id).await?; self.command
.delete_relay_subscription_item(&relay.id)
.await?;
} }
self.cleanup_empty_subscription(&tenant.pubkey).await?; self.cleanup_empty_subscription(&tenant.pubkey).await?;
return Ok(()); return Ok(());
} }
// Inactive relay: remove subscription item if exists, then clean up // Inactive relay: remove subscription item if exists, then clean up
if relay.status == "inactive" { if relay.status == RELAY_STATUS_INACTIVE || relay.status == RELAY_STATUS_DELINQUENT {
if let Some(ref item_id) = relay.stripe_subscription_item_id { if let Some(ref item_id) = relay.stripe_subscription_item_id {
self.stripe_delete_subscription_item(item_id).await?; self.stripe_delete_subscription_item(item_id).await?;
self.command.delete_relay_subscription_item(&relay.id).await?; self.command
.delete_relay_subscription_item(&relay.id)
.await?;
} }
self.cleanup_empty_subscription(&tenant.pubkey).await?; self.cleanup_empty_subscription(&tenant.pubkey).await?;
return Ok(()); return Ok(());
} }
// Active relay on a paid plan // 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 { let Some(ref stripe_price_id) = plan.stripe_price_id else {
return Ok(()); return Ok(());
}; };
@@ -170,7 +231,9 @@ impl Billing {
if let Some(ref subscription_id) = tenant.stripe_subscription_id { if let Some(ref subscription_id) = tenant.stripe_subscription_id {
self.stripe_cancel_subscription(subscription_id).await?; self.stripe_cancel_subscription(subscription_id).await?;
self.command.clear_tenant_subscription(tenant_pubkey).await?; self.command
.clear_tenant_subscription(tenant_pubkey)
.await?;
} }
Ok(()) Ok(())
@@ -186,8 +249,9 @@ impl Billing {
"invoice.created" => { "invoice.created" => {
let customer = obj["customer"].as_str().unwrap_or_default(); let customer = obj["customer"].as_str().unwrap_or_default();
let amount_due = obj["amount_due"].as_i64().unwrap_or(0); let amount_due = obj["amount_due"].as_i64().unwrap_or(0);
let currency = obj["currency"].as_str().unwrap_or("usd");
let invoice_id = obj["id"].as_str().unwrap_or_default(); let invoice_id = obj["id"].as_str().unwrap_or_default();
self.handle_invoice_created(customer, amount_due, invoice_id) self.handle_invoice_created(customer, amount_due, currency, invoice_id)
.await?; .await?;
} }
"invoice.paid" => { "invoice.paid" => {
@@ -240,7 +304,9 @@ impl Billing {
return Err(anyhow!("webhook signature mismatch")); return Err(anyhow!("webhook signature mismatch"));
} }
let ts: i64 = timestamp.parse().map_err(|_| anyhow!("bad webhook timestamp"))?; let ts: i64 = timestamp
.parse()
.map_err(|_| anyhow!("bad webhook timestamp"))?;
let now = chrono::Utc::now().timestamp(); let now = chrono::Utc::now().timestamp();
if (now - ts).abs() > WEBHOOK_TOLERANCE_SECS { if (now - ts).abs() > WEBHOOK_TOLERANCE_SECS {
return Err(anyhow!("webhook timestamp outside tolerance")); return Err(anyhow!("webhook timestamp outside tolerance"));
@@ -253,6 +319,7 @@ impl Billing {
&self, &self,
stripe_customer_id: &str, stripe_customer_id: &str,
amount_due: i64, amount_due: i64,
currency: &str,
invoice_id: &str, invoice_id: &str,
) -> Result<()> { ) -> Result<()> {
if amount_due == 0 { if amount_due == 0 {
@@ -269,12 +336,13 @@ impl Billing {
// 1. NWC auto-pay: if the tenant has a nwc_url // 1. NWC auto-pay: if the tenant has a nwc_url
if !tenant.nwc_url.is_empty() { if !tenant.nwc_url.is_empty() {
match self.nwc_pay_invoice(amount_due, &tenant.nwc_url).await { match self
.nwc_pay_invoice(amount_due, currency, &tenant.nwc_url)
.await
{
Ok(()) => { Ok(()) => {
self.stripe_pay_invoice_out_of_band(invoice_id).await?; self.stripe_pay_invoice_out_of_band(invoice_id).await?;
self.command self.command.clear_tenant_nwc_error(&tenant.pubkey).await?;
.clear_tenant_nwc_error(&tenant.pubkey)
.await?;
return Ok(()); return Ok(());
} }
Err(e) => { Err(e) => {
@@ -320,7 +388,7 @@ impl Billing {
let relays = self.query.list_relays_for_tenant(&tenant.pubkey).await?; let relays = self.query.list_relays_for_tenant(&tenant.pubkey).await?;
for relay in relays { for relay in relays {
if relay.status == "inactive" && relay.plan != "free" { if Self::should_reactivate_after_payment(&relay) {
self.command.activate_relay(&relay).await?; self.command.activate_relay(&relay).await?;
} }
} }
@@ -374,8 +442,8 @@ impl Billing {
let relays = self.query.list_relays_for_tenant(&tenant.pubkey).await?; let relays = self.query.list_relays_for_tenant(&tenant.pubkey).await?;
for relay in relays { for relay in relays {
if relay.status == "active" && relay.plan != "free" { if relay.status == RELAY_STATUS_ACTIVE && Query::is_paid_plan(&relay.plan) {
self.command.deactivate_relay(&relay).await?; self.command.mark_relay_delinquent(&relay).await?;
} }
} }
@@ -409,8 +477,8 @@ impl Billing {
let relays = self.query.list_relays_for_tenant(&tenant.pubkey).await?; let relays = self.query.list_relays_for_tenant(&tenant.pubkey).await?;
for relay in relays { for relay in relays {
if relay.status == "active" && relay.plan != "free" { if relay.status == RELAY_STATUS_ACTIVE && Query::is_paid_plan(&relay.plan) {
self.command.deactivate_relay(&relay).await?; self.command.mark_relay_delinquent(&relay).await?;
} }
} }
@@ -429,19 +497,48 @@ impl Billing {
pub async fn get_invoice_with_tenant( pub async fn get_invoice_with_tenant(
&self, &self,
invoice_id: &str, invoice_id: &str,
) -> Result<(serde_json::Value, crate::models::Tenant)> { ) -> std::result::Result<(serde_json::Value, crate::models::Tenant), InvoiceLookupError> {
let invoice = self.stripe_get_invoice(invoice_id).await?; let invoice = self.stripe_get_invoice(invoice_id).await?;
let customer_id = invoice["customer"] let customer_id = invoice["customer"]
.as_str() .as_str()
.ok_or_else(|| anyhow!("invoice missing customer"))?; .ok_or_else(|| InvoiceLookupError::Internal(anyhow!("invoice missing customer")))?;
let tenant = self let tenant = self
.query .query
.get_tenant_by_stripe_customer_id(customer_id) .get_tenant_by_stripe_customer_id(customer_id)
.await? .await?
.ok_or_else(|| anyhow!("tenant not found for customer"))?; .ok_or_else(|| {
InvoiceLookupError::Internal(anyhow!("tenant not found for customer"))
})?;
Ok((invoice, tenant)) Ok((invoice, tenant))
} }
pub async fn stripe_create_customer(&self, tenant_pubkey: &str) -> Result<String> {
let short_pubkey: String = tenant_pubkey.chars().take(12).collect();
let display_name = format!("Caravel tenant {short_pubkey}");
let resp = self
.http
.post(format!("{STRIPE_API}/customers"))
.bearer_auth(&self.stripe_secret_key)
.form(&[
("name", display_name.as_str()),
("metadata[tenant_pubkey]", tenant_pubkey),
])
.send()
.await?;
let body: serde_json::Value = resp.error_for_status()?.json().await?;
let customer_id = body["id"]
.as_str()
.ok_or_else(|| anyhow!("missing customer id"))?;
if !customer_id.starts_with("cus_") {
return Err(anyhow!("unexpected customer id format"));
}
Ok(customer_id.to_string())
}
pub async fn stripe_list_invoices(&self, customer_id: &str) -> Result<serde_json::Value> { pub async fn stripe_list_invoices(&self, customer_id: &str) -> Result<serde_json::Value> {
let resp = self let resp = self
.http .http
@@ -455,7 +552,10 @@ impl Billing {
Ok(body["data"].clone()) Ok(body["data"].clone())
} }
pub async fn stripe_get_invoice(&self, invoice_id: &str) -> Result<serde_json::Value> { pub async fn stripe_get_invoice(
&self,
invoice_id: &str,
) -> std::result::Result<serde_json::Value, InvoiceLookupError> {
let resp = self let resp = self
.http .http
.get(format!("{STRIPE_API}/invoices/{invoice_id}")) .get(format!("{STRIPE_API}/invoices/{invoice_id}"))
@@ -463,14 +563,22 @@ impl Billing {
.send() .send()
.await?; .await?;
if resp.status().is_client_error() {
return Err(InvoiceLookupError::StripeClient {
status: resp.status(),
});
}
let body: serde_json::Value = resp.error_for_status()?.json().await?; let body: serde_json::Value = resp.error_for_status()?.json().await?;
Ok(body) Ok(body)
} }
pub async fn create_bolt11(&self, amount_due_cents: i64) -> Result<String> { pub async fn create_bolt11(&self, amount_due_minor: i64, currency: &str) -> Result<String> {
let amount_msats = (amount_due_cents as u64) * 1000; // placeholder conversion let amount_msats = self.fiat_minor_to_msats(amount_due_minor, currency).await?;
let system_uri: NostrWalletConnectURI = self.nwc_url.parse() let system_uri: NostrWalletConnectURI = self
.nwc_url
.parse()
.map_err(|_| anyhow!("invalid system NWC URL"))?; .map_err(|_| anyhow!("invalid system NWC URL"))?;
let system_nwc = NWC::new(system_uri); let system_nwc = NWC::new(system_uri);
@@ -550,10 +658,7 @@ impl Billing {
.http .http
.post(format!("{STRIPE_API}/subscription_items")) .post(format!("{STRIPE_API}/subscription_items"))
.bearer_auth(&self.stripe_secret_key) .bearer_auth(&self.stripe_secret_key)
.form(&[ .form(&[("subscription", subscription_id), ("price", price_id)])
("subscription", subscription_id),
("price", price_id),
])
.send() .send()
.await?; .await?;
@@ -642,14 +747,18 @@ impl Billing {
// --- NWC helpers --- // --- NWC helpers ---
async fn nwc_pay_invoice(&self, amount_due_cents: i64, tenant_nwc_url: &str) -> Result<()> { async fn nwc_pay_invoice(
// Convert USD cents to millisatoshis (approximate: 1 sat ≈ variable USD) &self,
// amount_due is in cents from Stripe. We create a Lightning invoice for the exact amount. amount_due_minor: i64,
// The NWC make_invoice amount is in millisatoshis. currency: &str,
let amount_msats = (amount_due_cents as u64) * 1000; // placeholder conversion, actual rate would come from exchange tenant_nwc_url: &str,
) -> Result<()> {
let amount_msats = self.fiat_minor_to_msats(amount_due_minor, currency).await?;
// Create a bolt11 invoice using the system wallet (self.nwc_url) // Create a bolt11 invoice using the system wallet (self.nwc_url)
let system_uri: NostrWalletConnectURI = self.nwc_url.parse() let system_uri: NostrWalletConnectURI = self
.nwc_url
.parse()
.map_err(|_| anyhow!("invalid system NWC URL"))?; .map_err(|_| anyhow!("invalid system NWC URL"))?;
let system_nwc = NWC::new(system_uri); let system_nwc = NWC::new(system_uri);
@@ -668,7 +777,8 @@ impl Billing {
system_nwc.shutdown().await; system_nwc.shutdown().await;
// Pay the bolt11 invoice using the tenant's wallet // Pay the bolt11 invoice using the tenant's wallet
let tenant_uri: NostrWalletConnectURI = tenant_nwc_url.parse() let tenant_uri: NostrWalletConnectURI = tenant_nwc_url
.parse()
.map_err(|_| anyhow!("invalid tenant NWC URL"))?; .map_err(|_| anyhow!("invalid tenant NWC URL"))?;
let tenant_nwc = NWC::new(tenant_uri); let tenant_nwc = NWC::new(tenant_uri);
@@ -683,4 +793,357 @@ impl Billing {
Ok(()) Ok(())
} }
async fn fiat_minor_to_msats(&self, amount_due_minor: i64, currency: &str) -> Result<u64> {
let normalized_currency = currency.to_uppercase();
let btc_price = self.fetch_btc_spot_price(&normalized_currency).await?;
fiat_minor_to_msats_from_quote(amount_due_minor, &normalized_currency, btc_price)
}
fn should_reactivate_after_payment(relay: &Relay) -> bool {
relay.status == RELAY_STATUS_DELINQUENT && Query::is_paid_plan(&relay.plan)
}
async fn fetch_btc_spot_price(&self, currency: &str) -> Result<f64> {
fetch_btc_spot_price_from_base(&self.http, &self.btc_quote_api_base, currency).await
}
fn currency_minor_exponent(currency: &str) -> Result<u8> {
let normalized = currency.to_uppercase();
let exponent = match normalized.as_str() {
// Zero-decimal currencies in Stripe.
"BIF" | "CLP" | "DJF" | "GNF" | "JPY" | "KMF" | "KRW" | "MGA" | "PYG" | "RWF"
| "UGX" | "VND" | "VUV" | "XAF" | "XOF" | "XPF" => 0,
// Three-decimal currencies in Stripe.
"BHD" | "JOD" | "KWD" | "OMR" | "TND" => 3,
_ if normalized.chars().all(|c| c.is_ascii_alphabetic()) && normalized.len() == 3 => 2,
_ => return Err(anyhow!("invalid currency code: {currency}")),
};
Ok(exponent)
}
}
pub async fn fetch_btc_spot_price_from_base(
http: &reqwest::Client,
api_base: &str,
currency: &str,
) -> Result<f64> {
let pair = format!("BTC-{currency}");
let url = format!("{}/{pair}/spot", api_base.trim_end_matches('/'));
let resp = http.get(url).send().await?;
let body: CoinbaseSpotPriceResponse = resp.error_for_status()?.json().await?;
let amount = body
.data
.amount
.parse::<f64>()
.map_err(|e| anyhow!("invalid BTC spot quote for {currency}: {e}"))?;
if amount <= 0.0 {
return Err(anyhow!(
"invalid non-positive BTC spot quote for {currency}"
));
}
Ok(amount)
}
pub fn fiat_minor_to_msats_from_quote(
amount_due_minor: i64,
currency: &str,
btc_price_in_fiat: f64,
) -> Result<u64> {
if amount_due_minor <= 0 {
return Err(anyhow!("amount_due must be positive"));
}
if btc_price_in_fiat <= 0.0 {
return Err(anyhow!("btc_price_in_fiat must be positive"));
}
let exponent = Billing::currency_minor_exponent(currency)?;
let divisor = 10_f64.powi(exponent as i32);
let amount_fiat = (amount_due_minor as f64) / divisor;
let amount_btc = amount_fiat / btc_price_in_fiat;
let raw_msats = amount_btc * 100_000_000_000.0;
// Guard against tiny floating point artifacts at integer boundaries.
let amount_msats = if (raw_msats - raw_msats.round()).abs() < 1e-6 {
raw_msats.round()
} else {
raw_msats.ceil()
};
if !amount_msats.is_finite() || amount_msats <= 0.0 || amount_msats > u64::MAX as f64 {
return Err(anyhow!("calculated msat amount is out of bounds"));
}
Ok(amount_msats as u64)
}
#[cfg(test)]
mod tests {
use super::{Billing, fiat_minor_to_msats_from_quote};
use crate::models::{
RELAY_STATUS_ACTIVE, RELAY_STATUS_DELINQUENT, RELAY_STATUS_INACTIVE, Relay,
};
fn relay_fixture(status: &str, plan: &str) -> Relay {
Relay {
id: "relay-1".to_string(),
tenant: "tenant-1".to_string(),
schema: "tenant_1".to_string(),
subdomain: "relay-1".to_string(),
plan: plan.to_string(),
stripe_subscription_item_id: None,
status: status.to_string(),
sync_error: String::new(),
info_name: String::new(),
info_icon: String::new(),
info_description: String::new(),
policy_public_join: 0,
policy_strip_signatures: 0,
groups_enabled: 1,
management_enabled: 1,
blossom_enabled: 1,
livekit_enabled: 1,
push_enabled: 1,
synced: 1,
}
}
#[test]
fn converts_usd_minor_units_with_quote() {
let msats = fiat_minor_to_msats_from_quote(100, "usd", 100_000.0)
.expect("conversion should succeed");
assert_eq!(msats, 1_000_000);
}
#[test]
fn converts_zero_decimal_currency_with_quote() {
let msats = fiat_minor_to_msats_from_quote(100, "jpy", 10_000_000.0)
.expect("conversion should succeed");
assert_eq!(msats, 1_000_000);
}
#[test]
fn reactivates_only_delinquent_paid_relays_after_payment() {
let delinquent_paid = relay_fixture(RELAY_STATUS_DELINQUENT, "basic");
assert!(Billing::should_reactivate_after_payment(&delinquent_paid));
let manually_inactive_paid = relay_fixture(RELAY_STATUS_INACTIVE, "basic");
assert!(!Billing::should_reactivate_after_payment(
&manually_inactive_paid
));
let free_delinquent = relay_fixture(RELAY_STATUS_DELINQUENT, "free");
assert!(!Billing::should_reactivate_after_payment(&free_delinquent));
let active_paid = relay_fixture(RELAY_STATUS_ACTIVE, "basic");
assert!(!Billing::should_reactivate_after_payment(&active_paid));
let unknown_status_paid = relay_fixture("suspended", "basic");
assert!(!Billing::should_reactivate_after_payment(
&unknown_status_paid
));
}
use super::*;
use sqlx::SqlitePool;
use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions};
use std::str::FromStr;
use std::sync::OnceLock;
use tokio::sync::Mutex;
fn env_lock() -> &'static Mutex<()> {
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
LOCK.get_or_init(|| Mutex::new(()))
}
#[allow(unused_unsafe)]
fn set_stripe_secret_key(value: Option<&str>) {
match value {
Some(v) => unsafe { std::env::set_var("STRIPE_SECRET_KEY", v) },
None => unsafe { std::env::remove_var("STRIPE_SECRET_KEY") },
}
}
#[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>,
}
impl StripeSecretKeyGuard {
fn set(value: Option<&str>) -> Self {
let previous = std::env::var("STRIPE_SECRET_KEY").ok();
set_stripe_secret_key(value);
Self { previous }
}
}
impl Drop for StripeSecretKeyGuard {
fn drop(&mut self) {
set_stripe_secret_key(self.previous.as_deref());
}
}
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")
.create_if_missing(true);
let pool = SqlitePoolOptions::new()
.max_connections(1)
.connect_with(connect_options)
.await
.expect("connect sqlite memory db");
sqlx::migrate!("./migrations")
.run(&pool)
.await
.expect("run migrations");
pool
}
#[tokio::test]
async fn billing_new_panics_without_stripe_secret_key() {
let _lock = env_lock().lock().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());
let command = Command::new(pool);
let robot = Robot::test_stub();
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
Billing::new(query, command, robot)
}));
let panic_payload = match result {
Ok(_) => panic!("constructor should panic when STRIPE_SECRET_KEY is missing"),
Err(payload) => payload,
};
let panic_msg = if let Some(msg) = panic_payload.downcast_ref::<&str>() {
(*msg).to_string()
} else if let Some(msg) = panic_payload.downcast_ref::<String>() {
msg.clone()
} else {
String::new()
};
assert!(
panic_msg.contains("missing STRIPE_SECRET_KEY environment variable"),
"unexpected panic: {panic_msg}"
);
}
#[tokio::test]
async fn billing_new_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(
Query::new(pool.clone()),
Command::new(pool),
Robot::test_stub(),
);
assert_eq!(billing.stripe_secret_key, "sk_test_dummy");
assert_eq!(billing.stripe_webhook_secret, "whsec_test_dummy");
}
} }
+100 -23
View File
@@ -2,7 +2,9 @@ use anyhow::Result;
use sqlx::{Sqlite, SqlitePool, Transaction}; use sqlx::{Sqlite, SqlitePool, Transaction};
use tokio::sync::broadcast; use tokio::sync::broadcast;
use crate::models::{Activity, Relay, Tenant}; use crate::models::{
Activity, RELAY_STATUS_ACTIVE, RELAY_STATUS_DELINQUENT, RELAY_STATUS_INACTIVE, Relay, Tenant,
};
#[derive(Clone)] #[derive(Clone)]
pub struct Command { pub struct Command {
@@ -30,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();
@@ -64,6 +66,10 @@ impl Command {
} }
pub async fn create_tenant(&self, tenant: &Tenant) -> Result<()> { pub async fn create_tenant(&self, tenant: &Tenant) -> Result<()> {
if tenant.stripe_customer_id.trim().is_empty() {
anyhow::bail!("stripe_customer_id is required");
}
let mut tx = self.pool.begin().await?; let mut tx = self.pool.begin().await?;
sqlx::query( sqlx::query(
@@ -77,7 +83,8 @@ impl Command {
.execute(&mut *tx) .execute(&mut *tx)
.await?; .await?;
let activity = Self::insert_activity(&mut tx, "create_tenant", "tenant", &tenant.pubkey).await?; let activity =
Self::insert_activity(&mut tx, "create_tenant", "tenant", &tenant.pubkey).await?;
tx.commit().await?; tx.commit().await?;
self.emit(activity); self.emit(activity);
@@ -93,7 +100,8 @@ impl Command {
.execute(&mut *tx) .execute(&mut *tx)
.await?; .await?;
let activity = Self::insert_activity(&mut tx, "update_tenant", "tenant", &tenant.pubkey).await?; let activity =
Self::insert_activity(&mut tx, "update_tenant", "tenant", &tenant.pubkey).await?;
tx.commit().await?; tx.commit().await?;
self.emit(activity); self.emit(activity);
@@ -178,14 +186,30 @@ impl Command {
} }
pub async fn deactivate_relay(&self, relay: &Relay) -> Result<()> { pub async fn deactivate_relay(&self, relay: &Relay) -> Result<()> {
self.set_relay_status(&relay.id, RELAY_STATUS_INACTIVE, "deactivate_relay")
.await
}
pub async fn mark_relay_delinquent(&self, relay: &Relay) -> Result<()> {
self.set_relay_status(&relay.id, RELAY_STATUS_DELINQUENT, "deactivate_relay")
.await
}
async fn set_relay_status(
&self,
relay_id: &str,
status: &str,
activity_type: &str,
) -> Result<()> {
let mut tx = self.pool.begin().await?; let mut tx = self.pool.begin().await?;
sqlx::query("UPDATE relay SET status = 'inactive' WHERE id = ?") sqlx::query("UPDATE relay SET status = ? WHERE id = ?")
.bind(&relay.id) .bind(status)
.bind(relay_id)
.execute(&mut *tx) .execute(&mut *tx)
.await?; .await?;
let activity = Self::insert_activity(&mut tx, "deactivate_relay", "relay", &relay.id).await?; let activity = Self::insert_activity(&mut tx, activity_type, "relay", relay_id).await?;
tx.commit().await?; tx.commit().await?;
self.emit(activity); self.emit(activity);
@@ -193,18 +217,8 @@ impl Command {
} }
pub async fn activate_relay(&self, relay: &Relay) -> Result<()> { pub async fn activate_relay(&self, relay: &Relay) -> Result<()> {
let mut tx = self.pool.begin().await?; self.set_relay_status(&relay.id, RELAY_STATUS_ACTIVE, "activate_relay")
.await
sqlx::query("UPDATE relay SET status = 'active' WHERE id = ?")
.bind(&relay.id)
.execute(&mut *tx)
.await?;
let activity = Self::insert_activity(&mut tx, "activate_relay", "relay", &relay.id).await?;
tx.commit().await?;
self.emit(activity);
Ok(())
} }
pub async fn fail_relay_sync(&self, relay: &Relay, sync_error: String) -> Result<()> { pub async fn fail_relay_sync(&self, relay: &Relay, sync_error: String) -> Result<()> {
@@ -216,7 +230,8 @@ impl Command {
.execute(&mut *tx) .execute(&mut *tx)
.await?; .await?;
let activity = Self::insert_activity(&mut tx, "fail_relay_sync", "relay", &relay.id).await?; let activity =
Self::insert_activity(&mut tx, "fail_relay_sync", "relay", &relay.id).await?;
tx.commit().await?; tx.commit().await?;
self.emit(activity); self.emit(activity);
@@ -231,7 +246,8 @@ impl Command {
.execute(&mut *tx) .execute(&mut *tx)
.await?; .await?;
let activity = Self::insert_activity(&mut tx, "complete_relay_sync", "relay", relay_id).await?; let activity =
Self::insert_activity(&mut tx, "complete_relay_sync", "relay", relay_id).await?;
tx.commit().await?; tx.commit().await?;
self.emit(activity); self.emit(activity);
@@ -246,7 +262,11 @@ impl Command {
Ok(()) Ok(())
} }
pub async fn set_relay_subscription_item(&self, relay_id: &str, stripe_subscription_item_id: &str) -> Result<()> { pub async fn set_relay_subscription_item(
&self,
relay_id: &str,
stripe_subscription_item_id: &str,
) -> Result<()> {
sqlx::query("UPDATE relay SET stripe_subscription_item_id = ? WHERE id = ?") sqlx::query("UPDATE relay SET stripe_subscription_item_id = ? WHERE id = ?")
.bind(stripe_subscription_item_id) .bind(stripe_subscription_item_id)
.bind(relay_id) .bind(relay_id)
@@ -255,7 +275,11 @@ impl Command {
Ok(()) Ok(())
} }
pub async fn set_tenant_subscription(&self, pubkey: &str, stripe_subscription_id: &str) -> Result<()> { pub async fn set_tenant_subscription(
&self,
pubkey: &str,
stripe_subscription_id: &str,
) -> Result<()> {
sqlx::query("UPDATE tenant SET stripe_subscription_id = ? WHERE pubkey = ?") sqlx::query("UPDATE tenant SET stripe_subscription_id = ? WHERE pubkey = ?")
.bind(stripe_subscription_id) .bind(stripe_subscription_id)
.bind(pubkey) .bind(pubkey)
@@ -307,3 +331,56 @@ impl Command {
Ok(()) Ok(())
} }
} }
#[cfg(test)]
mod tests {
use super::*;
use sqlx::SqlitePool;
use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions};
use std::str::FromStr;
async fn test_pool() -> SqlitePool {
let connect_options = SqliteConnectOptions::from_str("sqlite::memory:")
.expect("valid sqlite memory url")
.create_if_missing(true);
let pool = SqlitePoolOptions::new()
.max_connections(1)
.connect_with(connect_options)
.await
.expect("connect sqlite memory db");
sqlx::migrate!("./migrations")
.run(&pool)
.await
.expect("run migrations");
pool
}
#[tokio::test]
async fn create_tenant_rejects_empty_stripe_customer_id() {
let pool = test_pool().await;
let command = Command::new(pool);
let tenant = Tenant {
pubkey: "tenant_pubkey".to_string(),
nwc_url: String::new(),
nwc_error: None,
created_at: 0,
stripe_customer_id: " ".to_string(),
stripe_subscription_id: None,
past_due_at: None,
};
let err = command
.create_tenant(&tenant)
.await
.expect_err("empty customer id must be rejected");
assert!(
err.to_string().contains("stripe_customer_id is required"),
"unexpected error: {err}"
);
}
}
+80 -43
View File
@@ -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; use crate::models::{Activity, Relay, RELAY_STATUS_DELINQUENT, RELAY_STATUS_INACTIVE};
use crate::query::Query; use crate::query::Query;
#[derive(Clone)] #[derive(Clone)]
@@ -56,10 +56,7 @@ impl Infra {
} }
async fn handle_activity(&self, activity: &Activity) -> Result<()> { async fn handle_activity(&self, activity: &Activity) -> Result<()> {
let needs_sync = matches!( let needs_sync = should_sync_relay_activity(activity.activity_type.as_str());
activity.activity_type.as_str(),
"create_relay" | "update_relay" | "deactivate_relay"
);
if needs_sync { if needs_sync {
let Some(relay) = self.query.get_relay(&activity.resource_id).await? else { let Some(relay) = self.query.get_relay(&activity.resource_id).await? else {
@@ -73,7 +70,7 @@ impl Infra {
Ok(()) 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 { 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");
@@ -93,11 +90,13 @@ impl Infra {
async fn nip98_auth(&self, url: &str, method: HttpMethod) -> Result<String> { async fn nip98_auth(&self, url: &str, method: HttpMethod) -> Result<String> {
let keys = Keys::parse(&self.api_secret)?; let keys = Keys::parse(&self.api_secret)?;
let server_url = Url::parse(url)?; let server_url = Url::parse(url)?;
let auth = HttpData::new(server_url, method).to_authorization(&keys).await?; let auth = HttpData::new(server_url, method)
.to_authorization(&keys)
.await?;
Ok(auth) 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 client = reqwest::Client::new();
let base = self.api_url.trim_end_matches('/'); let base = self.api_url.trim_end_matches('/');
@@ -107,8 +106,6 @@ 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,
@@ -120,47 +117,87 @@ impl Infra {
serde_json::json!({ "enabled": false }) serde_json::json!({ "enabled": false })
}; };
let body = serde_json::json!({ let body = relay_sync_body(
"host": host, relay,
"schema": relay.schema, host,
"secret": secret, livekit,
"inactive": relay.status == "inactive", is_new.then(|| Keys::generate().secret_key().to_secret_hex()),
"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 response = if is_new { let url = format!("{}/relay/{}", base, relay.id);
let url = format!("{}/relay/{}", base, relay.id); let auth = self.nip98_auth(&url, zooid_sync_http_method(is_new)).await?;
let auth = self.nip98_auth(&url, HttpMethod::POST).await?;
client.post(&url).header("Authorization", auth).json(&body).send().await? let request = if is_new {
client.post(&url)
} else { } else {
let url = format!("{}/relay/{}", base, relay.id); client.patch(&url)
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 {
matches!(
activity_type,
"create_relay" | "update_relay" | "activate_relay" | "deactivate_relay"
)
}
+2 -2
View File
@@ -3,8 +3,8 @@ mod billing;
mod command; mod command;
mod infra; mod infra;
mod models; mod models;
mod query;
mod pool; mod pool;
mod query;
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(3000); .unwrap_or(2892);
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(',')
+4
View File
@@ -1,5 +1,9 @@
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
pub const RELAY_STATUS_ACTIVE: &str = "active";
pub const RELAY_STATUS_INACTIVE: &str = "inactive";
pub const RELAY_STATUS_DELINQUENT: &str = "delinquent";
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] #[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
pub struct Activity { pub struct Activity {
pub id: String, pub id: String,
+1 -2
View File
@@ -21,8 +21,7 @@ pub async fn create_pool() -> Result<SqlitePool> {
std::fs::create_dir_all(parent)?; std::fs::create_dir_all(parent)?;
} }
let connect_options = let connect_options = SqliteConnectOptions::from_str(&database_url)?.create_if_missing(true);
SqliteConnectOptions::from_str(&database_url)?.create_if_missing(true);
let pool = SqlitePoolOptions::new() let pool = SqlitePoolOptions::new()
.max_connections(5) .max_connections(5)
+22 -8
View File
@@ -68,9 +68,19 @@ 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>> { pub async fn list_relays(&self) -> Result<Vec<Relay>> {
let rows = sqlx::query_as::<_, Relay>( let rows = sqlx::query_as::<_, Relay>(
"SELECT id, tenant, schema, subdomain, plan, stripe_subscription_item_id, "SELECT id, tenant, schema, subdomain, plan, stripe_subscription_item_id,
status, sync_error, status, sync_error,
info_name, info_icon, info_description, info_name, info_icon, info_description,
policy_public_join, policy_strip_signatures, policy_public_join, policy_strip_signatures,
@@ -86,7 +96,7 @@ impl Query {
pub async fn list_relays_for_tenant(&self, tenant_id: &str) -> Result<Vec<Relay>> { pub async fn list_relays_for_tenant(&self, tenant_id: &str) -> Result<Vec<Relay>> {
let rows = sqlx::query_as::<_, Relay>( let rows = sqlx::query_as::<_, Relay>(
"SELECT id, tenant, schema, subdomain, plan, stripe_subscription_item_id, "SELECT id, tenant, schema, subdomain, plan, stripe_subscription_item_id,
status, sync_error, status, sync_error,
info_name, info_icon, info_description, info_name, info_icon, info_description,
policy_public_join, policy_strip_signatures, policy_public_join, policy_strip_signatures,
@@ -104,7 +114,7 @@ impl Query {
pub async fn get_relay(&self, id: &str) -> Result<Option<Relay>> { pub async fn get_relay(&self, id: &str) -> Result<Option<Relay>> {
let row = sqlx::query_as::<_, Relay>( let row = sqlx::query_as::<_, Relay>(
"SELECT id, tenant, schema, subdomain, plan, stripe_subscription_item_id, "SELECT id, tenant, schema, subdomain, plan, stripe_subscription_item_id,
status, sync_error, status, sync_error,
info_name, info_icon, info_description, info_name, info_icon, info_description,
policy_public_join, policy_strip_signatures, policy_public_join, policy_strip_signatures,
@@ -119,7 +129,10 @@ impl Query {
Ok(row) Ok(row)
} }
pub async fn get_tenant_by_stripe_customer_id(&self, stripe_customer_id: &str) -> Result<Option<Tenant>> { pub async fn get_tenant_by_stripe_customer_id(
&self,
stripe_customer_id: &str,
) -> Result<Option<Tenant>> {
let row = sqlx::query_as::<_, Tenant>( let row = sqlx::query_as::<_, Tenant>(
"SELECT pubkey, nwc_url, nwc_error, created_at, stripe_customer_id, stripe_subscription_id, past_due_at "SELECT pubkey, nwc_url, nwc_error, created_at, stripe_customer_id, stripe_subscription_id, past_due_at
FROM tenant FROM tenant
@@ -132,13 +145,14 @@ impl Query {
} }
pub async fn has_active_paid_relays(&self, tenant_id: &str) -> Result<bool> { pub async fn has_active_paid_relays(&self, tenant_id: &str) -> Result<bool> {
let count = sqlx::query_scalar::<_, i64>( let plans = sqlx::query_scalar::<_, String>(
"SELECT COUNT(*) FROM relay WHERE tenant = ? AND status = 'active' AND plan != 'free'", "SELECT plan FROM relay WHERE tenant = ? AND status = 'active'",
) )
.bind(tenant_id) .bind(tenant_id)
.fetch_one(&self.pool) .fetch_all(&self.pool)
.await?; .await?;
Ok(count > 0)
Ok(plans.into_iter().any(|plan| Self::is_paid_plan(&plan)))
} }
pub async fn list_activity_for_relay(&self, relay_id: &str) -> Result<Vec<Activity>> { pub async fn list_activity_for_relay(&self, relay_id: &str) -> Result<Vec<Activity>> {
+20
View File
@@ -254,3 +254,23 @@ async fn set_cached(
}, },
); );
} }
#[cfg(test)]
impl Robot {
pub fn test_stub() -> Self {
let keys = Keys::generate();
let client = Client::new(keys);
Self {
secret: String::new(),
name: String::new(),
description: String::new(),
picture: String::new(),
outbox_client: client.clone(),
indexer_client: client.clone(),
messaging_client: client,
outbox_cache: std::sync::Arc::new(Mutex::new(HashMap::new())),
dm_cache: std::sync::Arc::new(Mutex::new(HashMap::new())),
}
}
}
+31
View File
@@ -0,0 +1,31 @@
use axum::{Json, Router, routing::get};
use backend::billing::{fetch_btc_spot_price_from_base, fiat_minor_to_msats_from_quote};
#[tokio::test]
async fn quote_endpoint_can_be_stubbed_deterministically() {
async fn spot() -> Json<serde_json::Value> {
Json(serde_json::json!({ "data": { "amount": "50000.00" } }))
}
let app = Router::new().route("/v2/prices/BTC-USD/spot", get(spot));
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.expect("bind test server");
let addr = listener.local_addr().expect("get local addr");
tokio::spawn(async move {
axum::serve(listener, app).await.expect("serve quote stub");
});
let client = reqwest::Client::new();
let base = format!("http://{addr}/v2/prices");
let btc_price = fetch_btc_spot_price_from_base(&client, &base, "USD")
.await
.expect("fetch stubbed quote");
assert_eq!(btc_price, 50_000.0);
let msats =
fiat_minor_to_msats_from_quote(100, "USD", btc_price).expect("convert quoted fiat amount");
assert_eq!(msats, 2_000_000);
}
+1 -1
View File
@@ -1,5 +1,5 @@
# Backend API base URL # 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 # Platform display name shown in UI
VITE_PLATFORM_NAME=Caravel VITE_PLATFORM_NAME=Caravel
+6 -3
View File
@@ -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:3000` | | `VITE_API_URL` | Backend API base URL | `http://127.0.0.1:2892` |
## Running ## Running
@@ -51,8 +51,11 @@ npm run preview
## Authentication ## Authentication
- Tenant requests use NIP-98 tokens derived from the logged-in user - Tenant requests use an intentional session-style variant of NIP-98:
- Admin routes require a pubkey listed in `PLATFORM_ADMIN_PUBKEYS` on the backend - 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.
## Routes ## Routes
+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

+55 -25
View File
@@ -6,6 +6,7 @@ import { getInvoice, getInvoiceBolt11 } from "@/lib/api"
import { tenantNeedsPaymentSetup } from "@/lib/hooks" import { tenantNeedsPaymentSetup } from "@/lib/hooks"
type PayStatus = "idle" | "loading" | "success" | "error" type PayStatus = "idle" | "loading" | "success" | "error"
type Bolt11Status = "idle" | "loading" | "ready" | "error"
type PaymentInvoice = { type PaymentInvoice = {
id: string id: string
@@ -21,20 +22,34 @@ type PaymentDialogProps = {
export default function PaymentDialog(props: PaymentDialogProps) { export default function PaymentDialog(props: PaymentDialogProps) {
const [bolt11, setBolt11] = createSignal("") const [bolt11, setBolt11] = createSignal("")
const [qrDataUrl, setQrDataUrl] = createSignal("") const [qrDataUrl, setQrDataUrl] = createSignal("")
const [bolt11Status, setBolt11Status] = createSignal<Bolt11Status>("idle")
const [bolt11Error, setBolt11Error] = createSignal("")
const [payStatus, setPayStatus] = createSignal<PayStatus>("idle") const [payStatus, setPayStatus] = createSignal<PayStatus>("idle")
const [payError, setPayError] = createSignal("") const [payError, setPayError] = createSignal("")
const [showSetup, setShowSetup] = createSignal(false) const [showSetup, setShowSetup] = createSignal(false)
const [showPaymentSetup, setShowPaymentSetup] = createSignal(false) const [showPaymentSetup, setShowPaymentSetup] = createSignal(false)
createEffect(async () => { async function loadBolt11() {
if (!props.open || !props.invoice.id) return if (!props.invoice.id) return
setBolt11Status("loading")
setBolt11Error("")
setBolt11("")
setQrDataUrl("")
try { try {
const { bolt11: invoice } = await getInvoiceBolt11(props.invoice.id) const { bolt11: invoice } = await getInvoiceBolt11(props.invoice.id)
setBolt11(invoice) setBolt11(invoice)
setQrDataUrl(await QRCode.toDataURL(invoice, { width: 256, margin: 2 })) setQrDataUrl(await QRCode.toDataURL(invoice, { width: 256, margin: 2 }))
} catch { setBolt11Status("ready")
// bolt11 generation may fail } catch (e) {
setBolt11Status("error")
setBolt11Error(e instanceof Error ? e.message : "Failed to generate Lightning invoice")
} }
}
createEffect(() => {
if (!props.open || !props.invoice.id) return
void loadBolt11()
}) })
function copyBolt11() { function copyBolt11() {
@@ -62,6 +77,8 @@ export default function PaymentDialog(props: PaymentDialogProps) {
function handleClose() { function handleClose() {
setPayStatus("idle") setPayStatus("idle")
setPayError("") setPayError("")
setBolt11Status("idle")
setBolt11Error("")
setBolt11("") setBolt11("")
setQrDataUrl("") setQrDataUrl("")
setShowSetup(false) setShowSetup(false)
@@ -104,33 +121,46 @@ export default function PaymentDialog(props: PaymentDialogProps) {
when={payStatus() === "success"} when={payStatus() === "success"}
fallback={ fallback={
<div class="w-full space-y-3"> <div class="w-full space-y-3">
<Show <Show when={bolt11Status() === "idle" || bolt11Status() === "loading"}>
when={qrDataUrl()} <div class="flex items-center justify-center py-12 text-sm text-gray-400">Generating invoice...</div>
fallback={<div class="flex items-center justify-center py-12 text-sm text-gray-400">Generating invoice...</div>}
>
<img src={qrDataUrl()} alt="Lightning invoice QR code" class="mx-auto rounded-lg" />
</Show> </Show>
<Show when={bolt11()}> <Show when={bolt11Status() === "error"}>
<div class="flex rounded-lg border border-gray-300"> <div class="rounded-lg border border-red-200 bg-red-50 p-4">
<input <p class="text-sm font-medium text-red-700">Unable to generate invoice</p>
type="text" <p class="mt-1 text-xs text-red-600 wrap-break-word">{bolt11Error()}</p>
readOnly
value={bolt11()}
class="min-w-0 flex-1 rounded-l-lg border-0 px-3 py-2 text-xs text-gray-500 bg-transparent focus:outline-none"
/>
<button <button
type="button" type="button"
class="flex items-center px-3 text-gray-400 hover:text-gray-700" onClick={() => void loadBolt11()}
onClick={copyBolt11} class="mt-3 inline-flex items-center rounded-lg bg-red-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-red-700"
title="Copy invoice"
> >
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> Retry
<rect x="9" y="9" width="13" height="13" rx="2" />
<path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1" />
</svg>
</button> </button>
</div> </div>
</Show> </Show>
<Show when={bolt11Status() === "ready"}>
<img src={qrDataUrl()} alt="Lightning invoice QR code" class="mx-auto rounded-lg" />
<Show when={bolt11()}>
<div class="flex rounded-lg border border-gray-300">
<input
type="text"
readOnly
value={bolt11()}
class="min-w-0 flex-1 rounded-l-lg border-0 px-3 py-2 text-xs text-gray-500 bg-transparent focus:outline-none"
/>
<button
type="button"
class="flex items-center px-3 text-gray-400 hover:text-gray-700"
onClick={copyBolt11}
title="Copy invoice"
>
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="9" y="9" width="13" height="13" rx="2" />
<path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1" />
</svg>
</button>
</div>
</Show>
</Show>
</div> </div>
} }
> >
@@ -188,7 +218,7 @@ export default function PaymentDialog(props: PaymentDialogProps) {
<button <button
type="button" type="button"
onClick={checkPayment} onClick={checkPayment}
disabled={payStatus() === "loading"} disabled={payStatus() === "loading" || bolt11Status() !== "ready"}
class="py-2 px-4 bg-blue-600 text-white text-sm font-medium rounded-lg hover:bg-blue-700 disabled:opacity-50 transition-colors" class="py-2 px-4 bg-blue-600 text-white text-sm font-medium rounded-lg hover:bg-blue-700 disabled:opacity-50 transition-colors"
> >
{payStatus() === "loading" ? "Checking..." : "Complete Payment"} {payStatus() === "loading" ? "Checking..." : "Complete Payment"}
@@ -203,6 +203,13 @@ export default function RelayDetailCard(props: RelayDetailCardProps) {
</Show> </Show>
</div> </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" /> <hr class="border-gray-200" />
<DetailSection title="Policy"> <DetailSection title="Policy">
+12 -1
View File
@@ -1,4 +1,5 @@
import { A } from "@solidjs/router" import { A } from "@solidjs/router"
import { Show } from "solid-js"
import type { Relay } from "@/lib/api" import type { Relay } from "@/lib/api"
type RelayListItemProps = { 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> <p class="text-xs text-gray-500 break-all mt-1">Tenant: {props.relay.tenant}</p>
)} )}
</div> </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> </div>
</A> </A>
</li> </li>
+6
View File
@@ -145,6 +145,8 @@ export async function makeAuth(): Promise<string | undefined> {
kind: 27235, kind: 27235,
content: "", content: "",
created_at: Math.floor(now / 1000), 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]], tags: [["u", API_URL]],
}) })
@@ -203,6 +205,10 @@ export function getIdentity() {
return callApi<undefined, Identity>("GET", "/identity") return callApi<undefined, Identity>("GET", "/identity")
} }
export function createTenant() {
return callApi<undefined, Tenant>("POST", "/tenants")
}
export function getPlan(id: string) { export function getPlan(id: string) {
return callApi<undefined, Plan>("GET", `/plans/${id}`) return callApi<undefined, Plan>("GET", `/plans/${id}`)
} }
+46 -7
View File
@@ -8,6 +8,9 @@ import Modal from "@/components/Modal"
import Login from "@/views/Login" import Login from "@/views/Login"
import { createRelayForActiveTenant } from "@/lib/hooks" import { createRelayForActiveTenant } from "@/lib/hooks"
import { account } from "@/lib/state" 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() { export default function Home() {
const navigate = useNavigate() const navigate = useNavigate()
@@ -213,7 +216,7 @@ export default function Home() {
</p> </p>
</div> </div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6"> <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{/* Flotilla */} {/* Flotilla */}
<a <a
href="https://flotilla.social" href="https://flotilla.social"
@@ -223,9 +226,7 @@ export default function Home() {
> >
<div class="flex items-start justify-between gap-4"> <div class="flex items-start justify-between gap-4">
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<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"> <img src={FlotillaLogo} alt="Flotilla" class="w-12 h-12 rounded-2xl shadow-md shadow-blue-200" />
F
</div>
<div> <div>
<h3 class="text-lg font-bold text-gray-900 group-hover:text-blue-600 transition-colors">Flotilla</h3> <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> <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-start justify-between gap-4">
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<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"> <img src={ChachiLogo} alt="Chachi" class="w-12 h-12 rounded-2xl shadow-md shadow-purple-200" />
C
</div>
<div> <div>
<h3 class="text-lg font-bold text-gray-900 group-hover:text-purple-600 transition-colors">Chachi</h3> <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> <p class="text-xs text-gray-400">chachi.chat</p>
@@ -293,6 +292,45 @@ export default function Home() {
</span> </span>
</div> </div>
</a> </a>
{/* Nostrord */}
<a
href="https://nostrord.com/"
target="_blank"
rel="noopener noreferrer"
class="group flex flex-col gap-5 rounded-2xl border border-gray-200 bg-white p-8 hover:border-amber-300 hover:shadow-md transition-all"
>
<div class="flex items-start justify-between gap-4">
<div class="flex items-center gap-3">
<img src={NostordLogo} alt="Nostrord" class="w-12 h-12 rounded-2xl shadow-md shadow-amber-200" />
<div>
<h3 class="text-lg font-bold text-gray-900 group-hover:text-amber-600 transition-colors">Nostrord</h3>
<p class="text-xs text-gray-400">nostrord.com</p>
</div>
</div>
<span class="text-gray-300 group-hover:text-amber-400 transition-colors mt-1">
<ExternalLinkIcon />
</span>
</div>
<p class="text-sm text-gray-600 leading-relaxed">
A NIP-29 client built for decentralized group chat on Nostr. Create
censorship-resistant communities with admin roles, moderation, and access
controlall powered by your relay.
</p>
<div class="space-y-2">
{["Decentralized group chat with NIP-29", "Censorship-resistant communities", "Admin roles & moderation"].map(f => (
<div class="flex items-start gap-2 text-sm text-gray-600">
<CheckIcon />
{f}
</div>
))}
</div>
<div class="mt-auto pt-2">
<span class="inline-flex items-center gap-1.5 text-sm font-semibold text-amber-600">
Visit nostrord.com <ExternalLinkIcon />
</span>
</div>
</a>
</div> </div>
</section> </section>
@@ -338,6 +376,7 @@ export default function Home() {
<div class="flex gap-4"> <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://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://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>
</div> </div>
</footer> </footer>
+7
View File
@@ -5,6 +5,7 @@ import { PasswordSigner } from "applesauce-signers"
import QrScanner from "qr-scanner" import QrScanner from "qr-scanner"
import QRCode from "qrcode" import QRCode from "qrcode"
import { accountManager, identity, PLATFORM_NAME } from "@/lib/state" import { accountManager, identity, PLATFORM_NAME } from "@/lib/state"
import { createTenant } from "@/lib/api"
import useMinLoading from "@/components/useMinLoading" import useMinLoading from "@/components/useMinLoading"
const NIP46_RELAYS = ['wss://bucket.coracle.social', 'wss://ephemeral.snowflare.cc'] 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) { async function completeLogin(account: ExtensionAccount | NostrConnectAccount | PrivateKeyAccount | PasswordAccount) {
accountManager.addAccount(account) accountManager.addAccount(account)
accountManager.setActive(account) accountManager.setActive(account)
try {
await createTenant()
} catch (e) {
accountManager.removeAccount(account)
throw e
}
await props.onAuthenticated?.() 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 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({ export default defineConfig(({mode}) => {
plugins: [tailwindcss(), solid()], const env = loadEnv(mode, process.cwd(), '')
resolve: {
alias: { return {
'@': fileURLToPath(new URL('./src', import.meta.url)), plugins: [tailwindcss(), solid()],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)),
},
}, },
}, server: {
port: Number(env.VITE_PORT) || 5173,
},
}
}) })