forked from coracle/caravel
Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 45ffee16ef | |||
| 48f20dc1a5 | |||
| c261d8a146 | |||
| 21b36272b8 | |||
| a26bc1127d | |||
| bc79da34cf | |||
| 38e3a64312 | |||
| d209353abd | |||
| 08c9a2920b | |||
| c47727b909 | |||
| 0705da8b09 | |||
| ca26d41eef | |||
| 44f9928070 | |||
| 87dcf53d74 | |||
| bcbce5c058 |
@@ -53,7 +53,7 @@ The rest of the defaults work as-is. `ROBOT_*`, `LIVEKIT_*`, billing, and Stripe
|
||||
cp frontend/.env.template frontend/.env
|
||||
```
|
||||
|
||||
The defaults (`VITE_API_URL=http://127.0.0.1:3000`) point at the backend and work out of the box.
|
||||
The defaults (`VITE_API_URL=http://127.0.0.1:2892`) point at the backend and work out of the box.
|
||||
|
||||
### 4. Install dependencies and run
|
||||
|
||||
@@ -62,7 +62,7 @@ cd frontend && bun install && cd ..
|
||||
just dev
|
||||
```
|
||||
|
||||
This starts the backend (with auto-reload on file changes) at `http://127.0.0.1:3000` and the frontend at `http://127.0.0.1:5173`.
|
||||
This starts the backend (with auto-reload on file changes) at `http://127.0.0.1:2892` and the frontend at `http://127.0.0.1:5173`.
|
||||
|
||||
## Project docs
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Server
|
||||
HOST=127.0.0.1
|
||||
PORT=3000
|
||||
PORT=2892
|
||||
ALLOW_ORIGINS= # Optional comma-separated allowed CORS origins; empty = permissive
|
||||
|
||||
# Auth
|
||||
@@ -28,5 +28,5 @@ LIVEKIT_API_SECRET=
|
||||
|
||||
# Billing
|
||||
NWC_URL= # Nostr Wallet Connect URL for generating Lightning invoices
|
||||
STRIPE_SECRET_KEY= # Stripe API secret key (sk_...)
|
||||
STRIPE_WEBHOOK_SECRET= # Secret for verifying Stripe webhook signatures (whsec_...)
|
||||
STRIPE_SECRET_KEY= # Required Stripe API secret key (sk_...)
|
||||
STRIPE_WEBHOOK_SECRET=whsec_test_00000000000000000000000000 # Webhook signing secret (use real value in production)
|
||||
|
||||
+1
-1
@@ -26,6 +26,6 @@ WORKDIR /app
|
||||
|
||||
COPY --from=build /app/target/release/backend /app/backend
|
||||
|
||||
EXPOSE 3000
|
||||
EXPOSE 2892
|
||||
|
||||
CMD ["/app/backend"]
|
||||
|
||||
+36
-27
@@ -30,27 +30,29 @@ backend/
|
||||
|
||||
Environment variables:
|
||||
|
||||
| Variable | Description | Default |
|
||||
|---|---|---|
|
||||
| `DATABASE_URL` | SQLite URL. Relative paths are resolved under `backend/`. | `sqlite://<backend>/data/caravel.db` |
|
||||
| `HOST` | API bind host (also used for NIP-98 `u` host check) | `127.0.0.1` |
|
||||
| `PORT` | API bind port | `3000` |
|
||||
| `ADMINS` | Comma-separated admin pubkeys (hex) | _optional_ |
|
||||
| `ALLOW_ORIGINS` | Comma-separated CORS origins. If empty, CORS is permissive. | _optional_ |
|
||||
| `ZOOID_API_URL` | Zooid API base URL used by infra worker | _required for infra sync_ |
|
||||
| `ZOOID_API_SECRET` | Nostr secret key used for authentication of requests to the zooid API | _required_ |
|
||||
| `RELAY_DOMAIN` | Base domain appended to relay subdomains | empty |
|
||||
| `LIVEKIT_URL` | LiveKit URL sent to zooid when relay livekit is enabled | _optional_ |
|
||||
| `LIVEKIT_API_KEY` | LiveKit API key sent to zooid | _optional_ |
|
||||
| `LIVEKIT_API_SECRET` | LiveKit API secret sent to zooid | _optional_ |
|
||||
| `NWC_URL` | Platform NWC URL used to generate BOLT11 invoices | _required for invoice generation_ |
|
||||
| `ROBOT_SECRET` | Robot Nostr secret key | _required_ |
|
||||
| `ROBOT_NAME` | Robot display name (kind `0`) | _optional_ |
|
||||
| `ROBOT_DESCRIPTION` | Robot description (kind `0`) | _optional_ |
|
||||
| `ROBOT_PICTURE` | Robot picture URL (kind `0`) | _optional_ |
|
||||
| `ROBOT_OUTBOX_RELAYS` | Comma-separated relays published as kind `10002` | _required_ |
|
||||
| `ROBOT_INDEXER_RELAYS` | Comma-separated relays used for recipient relay discovery | _required_ |
|
||||
| `ROBOT_MESSAGING_RELAYS` | Comma-separated relays published as kind `10050` | _required_ |
|
||||
| Variable | Description | Default |
|
||||
| ------------------------ | ----------------------------------------------------------------------- | ------------------------------------ |
|
||||
| `DATABASE_URL` | SQLite URL. Relative paths are resolved under `backend/`. | `sqlite://<backend>/data/caravel.db` |
|
||||
| `HOST` | API bind host (also used for NIP-98 `u` host check) | `127.0.0.1` |
|
||||
| `PORT` | API bind port | `2892` |
|
||||
| `ADMINS` | Comma-separated admin pubkeys (hex) | _optional_ |
|
||||
| `ALLOW_ORIGINS` | Comma-separated CORS origins. If empty, CORS is permissive. | _optional_ |
|
||||
| `ZOOID_API_URL` | Zooid API base URL used by infra worker | _required for infra sync_ |
|
||||
| `ZOOID_API_SECRET` | Nostr secret key used for authentication of requests to the zooid API | _required_ |
|
||||
| `RELAY_DOMAIN` | Base domain appended to relay subdomains | empty |
|
||||
| `LIVEKIT_URL` | LiveKit URL sent to zooid when relay livekit is enabled | _optional_ |
|
||||
| `LIVEKIT_API_KEY` | LiveKit API key sent to zooid | _optional_ |
|
||||
| `LIVEKIT_API_SECRET` | LiveKit API secret sent to zooid | _optional_ |
|
||||
| `NWC_URL` | Platform NWC URL used to generate BOLT11 invoices | _required for invoice generation_ |
|
||||
| `STRIPE_SECRET_KEY` | Stripe API secret key used for billing API operations | _required_ |
|
||||
| `STRIPE_WEBHOOK_SECRET` | Stripe webhook signing secret used to verify `Stripe-Signature` headers | _required_ |
|
||||
| `ROBOT_SECRET` | Robot Nostr secret key | _required_ |
|
||||
| `ROBOT_NAME` | Robot display name (kind `0`) | _optional_ |
|
||||
| `ROBOT_DESCRIPTION` | Robot description (kind `0`) | _optional_ |
|
||||
| `ROBOT_PICTURE` | Robot picture URL (kind `0`) | _optional_ |
|
||||
| `ROBOT_OUTBOX_RELAYS` | Comma-separated relays published as kind `10002` | _required_ |
|
||||
| `ROBOT_INDEXER_RELAYS` | Comma-separated relays used for recipient relay discovery | _required_ |
|
||||
| `ROBOT_MESSAGING_RELAYS` | Comma-separated relays published as kind `10050` | _required_ |
|
||||
|
||||
Relay list env vars are comma-separated and trimmed. If a relay has no `ws://` or `wss://` scheme, `wss://` is prepended.
|
||||
|
||||
@@ -66,19 +68,26 @@ Public exceptions:
|
||||
|
||||
- `GET /plans`
|
||||
- `GET /plans/:id`
|
||||
- `POST /stripe/webhook` (validated with Stripe signatures instead)
|
||||
- `POST /stripe/webhook` (validated with Stripe signatures)
|
||||
|
||||
- `GET /identity` — get auth identity (`pubkey`, `is_admin`)
|
||||
- `GET /identity` — get auth identity (`pubkey`, `is_admin`); side-effect-free
|
||||
- `GET /tenants` — list tenants (admin)
|
||||
- `POST /tenants` — create current auth pubkey as tenant
|
||||
- `POST /tenants` — idempotently ensure a tenant row exists for the current auth pubkey (creates Stripe customer + tenant on first call, returns existing tenant otherwise)
|
||||
- `GET /tenants/:pubkey` — get tenant (admin or same tenant)
|
||||
- `PUT /tenants/:pubkey/billing` — update tenant `nwc_url` (admin or same tenant)
|
||||
- `GET /relays` — list relays (`?tenant=<pubkey>` allowed for admin only)
|
||||
- `PUT /tenants/:pubkey` — update tenant `nwc_url` (admin or same tenant)
|
||||
- `GET /tenants/:pubkey/relays` — list tenant relays (admin or same tenant)
|
||||
- `GET /relays` — list relays (admin)
|
||||
- `POST /relays` — create relay (admin or relay tenant)
|
||||
- `GET /relays/:id` — get relay (admin or relay tenant)
|
||||
- `GET /relays/:id/members` — list relay members from zooid (admin or relay tenant)
|
||||
- `PUT /relays/:id` — update relay (admin or relay tenant)
|
||||
- `GET /relays/:id/activity` — list relay activity (admin or relay tenant)
|
||||
- `POST /relays/:id/deactivate` — deactivate relay (admin or relay tenant)
|
||||
- `GET /invoices` — list invoices (`?tenant=<pubkey>` allowed for admin only)
|
||||
- `POST /relays/:id/reactivate` — reactivate relay (admin or relay tenant)
|
||||
- `GET /tenants/:pubkey/invoices` — list tenant invoices (admin or same tenant)
|
||||
- `GET /invoices/:id` — get invoice (admin or same tenant)
|
||||
- `GET /invoices/:id/bolt11` — get invoice bolt11 (admin or same tenant)
|
||||
- `GET /tenants/:pubkey/stripe/session` — create Stripe customer portal session (admin or same tenant)
|
||||
|
||||
## API Auth Model
|
||||
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
CREATE INDEX IF NOT EXISTS idx_tenant_stripe_customer_id
|
||||
ON tenant (stripe_customer_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_relay_tenant_id
|
||||
ON relay (tenant, id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_relay_tenant_status_plan
|
||||
ON relay (tenant, status, plan);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_activity_resource_type_resource_id_created_at_id
|
||||
ON activity (resource_type, resource_id, created_at DESC, id DESC);
|
||||
+26
-4
@@ -9,6 +9,7 @@ Members:
|
||||
- `query: Query`
|
||||
- `command: Command`
|
||||
- `billing: Billing`
|
||||
- `infra: Infra`
|
||||
|
||||
Notes:
|
||||
|
||||
@@ -46,9 +47,8 @@ Notes:
|
||||
|
||||
- Serves `GET /identity`
|
||||
- Authorizes anyone, but must be authorized
|
||||
- If a tenant for the identity doesn't exist:
|
||||
- Call the Stripe API to create a new customer
|
||||
- Create a new tenant using `command.create_tenant` with payload and `stripe_customer_id`. No subscription is created yet — that happens when the first relay is added.
|
||||
- Side-effect-free: returns `{ pubkey, is_admin }` only
|
||||
- Clients must call `POST /tenants` before any tenant-scoped write
|
||||
- Return `data` is an `Identity` struct
|
||||
|
||||
--- Tenant routes
|
||||
@@ -59,6 +59,18 @@ Notes:
|
||||
- Authorizes admin only
|
||||
- Return `data` is a list of tenant structs from `query.list_tenants`
|
||||
|
||||
## `async fn create_tenant(...) -> Response`
|
||||
|
||||
- Serves `POST /tenants`
|
||||
- Authorizes anyone, but must be authorized
|
||||
- No request body; target pubkey is derived from NIP-98 auth
|
||||
- Idempotent: if a tenant already exists for the auth pubkey, return it without calling Stripe or writing to the DB
|
||||
- Otherwise, call the Stripe API to create a new customer and create a new tenant using `command.create_tenant` with the resulting `stripe_customer_id`. No subscription is created yet — that happens when the first relay is added.
|
||||
- On unique-constraint race (`pubkey-exists`), re-fetch and return the existing tenant
|
||||
- If Stripe customer creation fails, return `code=stripe-customer-create-failed`
|
||||
- Always returns `200` (create-or-get is uniform)
|
||||
- Return `data` is a single `Tenant` struct
|
||||
|
||||
## `async fn get_tenant(...) -> Response`
|
||||
|
||||
- Serves `GET /tenants/:pubkey`
|
||||
@@ -92,6 +104,14 @@ Notes:
|
||||
- Authorizes admin or relay owner
|
||||
- Return `data` is a single relay struct from `query.get_relay`
|
||||
|
||||
## `async fn list_relay_members(...) -> Response`
|
||||
|
||||
- Serves `GET /relays/:id/members`
|
||||
- Authorizes admin or relay owner
|
||||
- For unsynced relays, returns an empty member list without calling zooid
|
||||
- For synced relays, proxies the member list from zooid via `infra`
|
||||
- Return `data` is `{ members }`
|
||||
|
||||
## `async fn create_relay(...) -> Response`
|
||||
|
||||
- Serves `POST /relays`
|
||||
@@ -106,6 +126,7 @@ Notes:
|
||||
- Serves `PUT /relays/:id`
|
||||
- Authorizes admin or relay owner
|
||||
- Validates/prepares the relay data to be saved using `prepare_relay`
|
||||
- If the requested plan changes to a plan with a finite member limit and the current member count exceeds that limit, return a `422` with `code=member-limit-exceeded`
|
||||
- Updates the given relay using `command.update_relay`
|
||||
- If relay is a duplicate by subdomain, return a `422` with `code=subdomain-exists`
|
||||
- Return `data` is a single relay struct.
|
||||
@@ -121,7 +142,7 @@ Notes:
|
||||
|
||||
- Serves `POST /relays/:id/deactivate`
|
||||
- Authorizes admin or relay owner
|
||||
- If relay is already inactive, return a `400` with `code=relay-is-inactive`
|
||||
- If relay status is `inactive` or `delinquent`, return a `400` with `code=relay-is-inactive`
|
||||
- Call `command.deactivate_relay`
|
||||
- Return `data` is empty
|
||||
|
||||
@@ -178,6 +199,7 @@ Notes:
|
||||
- Reads raw request body and `Stripe-Signature` header
|
||||
- Calls `billing.handle_webhook(payload, signature)`
|
||||
- Returns `200` on success, `400` on signature verification failure
|
||||
- Startup requires non-empty `STRIPE_WEBHOOK_SECRET`
|
||||
|
||||
--- Utilities
|
||||
|
||||
|
||||
+39
-7
@@ -5,6 +5,7 @@ Billing encapsulates logic related to synchronizing state with Stripe, processin
|
||||
Members:
|
||||
|
||||
- `nwc_url: String` - a nostr wallet connect URL used to **create** bolt11 invoices (i.e. receive payments), from `NWC_URL`
|
||||
- `stripe_secret_key: String` - Stripe API key used for billing API operations, from `STRIPE_SECRET_KEY`
|
||||
- `stripe_webhook_secret: String` - secret for verifying Stripe webhook signatures, from `STRIPE_WEBHOOK_SECRET`
|
||||
- `query: Query`
|
||||
- `command: Command`
|
||||
@@ -13,6 +14,8 @@ Members:
|
||||
## `pub fn new(query: Query, command: Command, robot: Robot) -> Self`
|
||||
|
||||
- Reads environment and populates members
|
||||
- Panics if `STRIPE_SECRET_KEY` is missing/empty
|
||||
- Panics if `STRIPE_WEBHOOK_SECRET` is missing/empty
|
||||
|
||||
## `pub fn start(&self)`
|
||||
|
||||
@@ -23,12 +26,15 @@ Members:
|
||||
|
||||
Manages the Stripe subscription and subscription items for a relay's tenant. Only paid (non-free) relays interact with Stripe. Free-only tenants have no subscription. Must be idempotent.
|
||||
|
||||
Stripe uses **pay-in-advance** by default: when a subscription is first created, Stripe immediately generates an open invoice for the current period. The `invoice.created` webhook fires shortly after and `handle_invoice_created` attempts payment.
|
||||
|
||||
- Fetch the relay and tenant associated with the `activity`
|
||||
- **If relay plan is `free`**: if the relay has a `stripe_subscription_item_id`, delete it via the Stripe API and call `command.delete_relay_subscription_item`. Then check cleanup (below). Return early.
|
||||
- **If relay is `inactive`**: if the relay has a `stripe_subscription_item_id`, delete it via the Stripe API and call `command.delete_relay_subscription_item`. Then check cleanup (below). Return early.
|
||||
- **If relay plan is `free`**: if the relay has a `stripe_subscription_item_id`, delete it via the Stripe API and call `command.delete_relay_subscription_item`. Then run downgrade proration validation by previewing the upcoming invoice and logging proration lines/amounts. Then check cleanup (below). Return early.
|
||||
- **If relay is `inactive` or `delinquent`**: if the relay has a `stripe_subscription_item_id`, delete it via the Stripe API and call `command.delete_relay_subscription_item`. Then check cleanup (below). Return early.
|
||||
- **If relay is `active` and on a paid plan**:
|
||||
- **Ensure subscription exists**: If the tenant has no `stripe_subscription_id`, create a Stripe subscription for the customer with `collection_method: "charge_automatically"` and the relay's price as the first item. Save the subscription ID via `command.set_tenant_subscription` and the item ID via `command.set_relay_subscription_item`. Return early.
|
||||
- **Sync the subscription item**: If the tenant already has a subscription, create or update the relay's Stripe subscription item to the plan's `stripe_price_id` via the Stripe API, then call `command.set_relay_subscription_item`.
|
||||
- **Downgrade validation**: when changing an existing subscription item, detect if the new plan amount is lower than the current one. If yes, preview the upcoming Stripe invoice and log proration line/amount details to validate expected credit/proration behavior.
|
||||
- **Clean up empty subscription**: After any item deletion, check if the tenant has any remaining active paid relays. If none and the tenant has a `stripe_subscription_id`, cancel the Stripe subscription immediately and call `command.clear_tenant_subscription`.
|
||||
|
||||
## `pub fn handle_webhook(&self, payload: &str, signature: &str) -> Result<()>`
|
||||
@@ -41,6 +47,7 @@ Manages the Stripe subscription and subscription items for a relay's tenant. Onl
|
||||
- `invoice.overdue` -> `self.handle_invoice_overdue`
|
||||
- `customer.subscription.updated` -> `self.handle_subscription_updated`
|
||||
- `customer.subscription.deleted` -> `self.handle_subscription_deleted`
|
||||
- `payment_method.attached` -> `self.handle_payment_method_attached`
|
||||
- Unknown event types are ignored (return Ok)
|
||||
|
||||
## `pub async fn stripe_list_invoices(&self, customer_id: &str) -> Result<Value>`
|
||||
@@ -63,16 +70,37 @@ Manages the Stripe subscription and subscription items for a relay's tenant. Onl
|
||||
- Creates a Stripe Customer Portal session for the given customer
|
||||
- Returns the portal session URL
|
||||
|
||||
## `pub async fn pay_outstanding_nwc_invoices(&self, tenant: &Tenant) -> Result<()>`
|
||||
|
||||
Called when a tenant first sets their NWC URL (via `PUT /tenants/:pubkey`). Attempts to pay any currently open invoices for the tenant using their NWC wallet, so that invoices created before NWC was configured are not left unpaid.
|
||||
|
||||
- If `tenant.nwc_url` is empty, return early.
|
||||
- List all Stripe invoices for `tenant.stripe_customer_id` via `stripe_list_invoices`.
|
||||
- For each invoice with `status == "open"` and `amount_due > 0`:
|
||||
- Attempt NWC payment via `nwc_pay_invoice`.
|
||||
- On success: call `stripe_pay_invoice_out_of_band` and `command.clear_tenant_nwc_error`.
|
||||
- On failure: call `command.set_tenant_nwc_error` and log the error; continue to the next invoice.
|
||||
|
||||
## `pub async fn pay_outstanding_card_invoices(&self, tenant: &Tenant) -> Result<()>`
|
||||
|
||||
Attempts Stripe-side collection for open invoices when the tenant has a card on file.
|
||||
|
||||
- If tenant has no card payment method, return early.
|
||||
- List all Stripe invoices for `tenant.stripe_customer_id`.
|
||||
- For each invoice with `status == "open"` and `amount_due > 0`:
|
||||
- Call Stripe `POST /v1/invoices/:id/pay` to retry collection using the card on file.
|
||||
- Log and continue on failures.
|
||||
|
||||
## `fn handle_invoice_created(&self, invoice: &Invoice)`
|
||||
|
||||
Attempts to pay a new subscription invoice. Payment priority:
|
||||
Attempts to pay a new subscription invoice. Because Stripe defaults to pay-in-advance, this webhook fires immediately when a subscription is created (i.e. when a paid relay is added or a plan is upgraded). Payment priority:
|
||||
|
||||
1. **NWC auto-pay**: If the tenant has a `nwc_url`:
|
||||
- Create a bolt11 Lightning invoice for the invoice amount using `self.nwc_url` (the receiving/system wallet)
|
||||
- Pay the bolt11 invoice using the tenant's `nwc_url` (the spending/tenant wallet)
|
||||
- If payment succeeds: call Stripe `POST /v1/invoices/:id/pay` with `paid_out_of_band: true`. Clear `nwc_error` via `command.clear_tenant_nwc_error`.
|
||||
- If payment fails: set `nwc_error` on tenant via `command.set_tenant_nwc_error`. Fall through to next option.
|
||||
2. **Card on file**: If the tenant has a payment method on the Stripe customer, do nothing — Stripe will charge automatically.
|
||||
2. **Card on file**: If the tenant has a payment method on the Stripe customer, do nothing here — Stripe will charge automatically for this invoice attempt.
|
||||
3. **Manual payment**: If neither NWC nor card is available, send a DM via `robot.send_dm` notifying the tenant that payment is due with a link to the application for manual Lightning payment.
|
||||
|
||||
Skip invoices with `amount_due` of 0.
|
||||
@@ -82,7 +110,7 @@ Skip invoices with `amount_due` of 0.
|
||||
- Look up tenant by `stripe_customer_id`
|
||||
- If tenant has `past_due_at` set:
|
||||
- Clear `past_due_at` via `command.clear_tenant_past_due`
|
||||
- Find all `inactive` relays for the tenant that were deactivated due to non-payment (i.e. relays on paid plans that are inactive)
|
||||
- Find all `delinquent` relays on paid plans for the tenant (relays marked delinquent by the billing system due to non-payment)
|
||||
- Reactivate each one via `command.activate_relay`
|
||||
|
||||
## `fn handle_invoice_payment_failed(&self, invoice: &Invoice)`
|
||||
@@ -95,7 +123,7 @@ Skip invoices with `amount_due` of 0.
|
||||
## `fn handle_invoice_overdue(&self, invoice: &Invoice)`
|
||||
|
||||
- Look up tenant by `stripe_customer_id`
|
||||
- Deactivate all active relays on paid plans via `command.deactivate_relay`
|
||||
- Mark all active relays on paid plans as delinquent via `command.mark_relay_delinquent` (sets status to `delinquent`, distinct from user-initiated `deactivate_relay`)
|
||||
- Send a DM via `robot.send_dm` notifying the tenant that their paid relays have been deactivated due to non-payment
|
||||
|
||||
## `fn handle_subscription_updated(&self, subscription: &Subscription)`
|
||||
@@ -103,10 +131,14 @@ Skip invoices with `amount_due` of 0.
|
||||
- Look up tenant by `stripe_customer_id`
|
||||
- If subscription status is `canceled` or `unpaid`:
|
||||
- Clear `stripe_subscription_id` via `command.clear_tenant_subscription`
|
||||
- Deactivate all active paid relays for the tenant via `command.deactivate_relay`
|
||||
- Mark all active paid relays as delinquent via `command.mark_relay_delinquent`
|
||||
|
||||
## `fn handle_subscription_deleted(&self, subscription: &Subscription)`
|
||||
|
||||
- Look up tenant by `stripe_customer_id`
|
||||
- Clear `stripe_subscription_id` via `command.clear_tenant_subscription`
|
||||
|
||||
## `fn handle_payment_method_attached(&self, stripe_customer_id: &str)`
|
||||
|
||||
- Look up tenant by `stripe_customer_id`
|
||||
- Call `pay_outstanding_card_invoices` so invoices that were due before card setup are retried immediately
|
||||
|
||||
@@ -44,6 +44,14 @@ Notes:
|
||||
|
||||
- Sets relay status to `inactive`
|
||||
- Logs activity as `(deactivate_relay, relay_id)`
|
||||
- Used for user/admin-initiated deactivation only
|
||||
|
||||
## `pub fn mark_relay_delinquent(&self, relay: &Relay) -> Result<()>`
|
||||
|
||||
- Sets relay status to `delinquent`
|
||||
- Logs activity as `(deactivate_relay, relay_id)`
|
||||
- Used exclusively by the billing system when a relay's subscription becomes past due
|
||||
- `delinquent` relays are automatically reactivated via `activate_relay` when payment is received
|
||||
|
||||
## `pub fn activate_relay(&self, relay: &Relay) -> Result<()>`
|
||||
|
||||
|
||||
@@ -15,12 +15,15 @@ Members:
|
||||
## `pub async fn start(self)`
|
||||
|
||||
- Subscribes to `command.notify`
|
||||
- On startup, schedules delayed sync retries for relays whose `sync_error` is non-empty.
|
||||
- Loops on `rx.recv()`, calling `handle_activity` for each received `Activity`.
|
||||
|
||||
## `async fn handle_activity(&self, activity: &Activity)`
|
||||
|
||||
- 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`).
|
||||
- For `create_relay`, `update_relay`, `activate_relay`, or `deactivate_relay` activity, calls `sync_and_report` immediately.
|
||||
- For `fail_relay_sync`, schedules a delayed retry using exponential backoff based on consecutive failures for the relay.
|
||||
- Retry scheduling stops after the configured max attempts to avoid infinite retry loops.
|
||||
- Other activity types are ignored (e.g. `complete_relay_sync`).
|
||||
|
||||
## `async fn sync_and_report(&self, relay: &Relay, is_new: bool)`
|
||||
|
||||
@@ -30,5 +33,6 @@ Members:
|
||||
## `async fn sync_relay(&self, relay: &Relay, is_new: bool)`
|
||||
|
||||
- If `is_new`, sends `POST /relay/:id` to create the relay in zooid.
|
||||
- Otherwise, sends `PUT /relay/:id` to update it.
|
||||
- Passes full relay configuration in the body including host, schema, secret, inactive flag, info, policy, groups, management, blossom, livekit, push, and roles.
|
||||
- Otherwise, sends `PATCH /relay/:id` to update it.
|
||||
- Includes `secret` only for relay creation (`POST`) so updates do not rotate relay identity.
|
||||
- Passes relay configuration in the body including host, schema, inactive flag, info, policy, groups, management, blossom, livekit, push, and roles.
|
||||
|
||||
@@ -69,7 +69,7 @@ A relay is a nostr relay owned by a `tenant` and hosted by the attached zooid in
|
||||
- `subdomain` - the relay's subdomain
|
||||
- `plan` - the relay's plan
|
||||
- `stripe_subscription_item_id` (nullable) - the Stripe subscription item id. Only set for relays on paid plans.
|
||||
- `status` - `active|inactive`. Only `active` relays count toward billing.
|
||||
- `status` - one of `active|inactive|delinquent`. Only `active` relays count toward billing. `delinquent` is set by the billing system when a relay's subscription becomes past due; `inactive` is set when a user or admin manually deactivates a relay.
|
||||
- `synced` - whether the relay has been successfully synced to zooid at least once.
|
||||
- `sync_error` - a string indicating any errors encountered when synchronizing.
|
||||
- `info_name` - the relay's name
|
||||
@@ -85,9 +85,8 @@ A relay is a nostr relay owned by a `tenant` and hosted by the attached zooid in
|
||||
|
||||
Some attributes persisted to zooid via API have special handling:
|
||||
|
||||
- The relay's `secret` is generated once and persisted to the zooid configuration but isn't stored in the database.
|
||||
- The relay's `secret` is generated once on relay creation, persisted to the zooid configuration, and isn't stored in the database. Relay updates do not resend `secret`.
|
||||
- The relay's `host` is calculated based on `subdomain` + `RELAY_DOMAIN`
|
||||
- The value of `inactive` is calculated based on `status`
|
||||
- The relay's `livekit_*` configuration is inferred based on environment variables and `livekit_enabled`.
|
||||
- The relay's `roles` are hard-coded for now.
|
||||
|
||||
|
||||
+248
-78
@@ -1,6 +1,6 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::anyhow;
|
||||
use anyhow::{Result, anyhow};
|
||||
use axum::{
|
||||
Json, Router,
|
||||
extract::{Path, State},
|
||||
@@ -14,6 +14,7 @@ use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::billing::{Billing, InvoiceLookupError};
|
||||
use crate::command::Command;
|
||||
use crate::infra::Infra;
|
||||
use crate::models::{
|
||||
RELAY_STATUS_ACTIVE, RELAY_STATUS_DELINQUENT, RELAY_STATUS_INACTIVE, Relay, Tenant,
|
||||
};
|
||||
@@ -27,6 +28,7 @@ pub struct Api {
|
||||
query: Query,
|
||||
command: Command,
|
||||
billing: Billing,
|
||||
infra: Infra,
|
||||
}
|
||||
|
||||
async fn stripe_webhook(
|
||||
@@ -117,7 +119,7 @@ fn map_invoice_lookup_error(error: InvoiceLookupError) -> ApiError {
|
||||
}
|
||||
|
||||
impl Api {
|
||||
pub fn new(query: Query, command: Command, billing: Billing) -> Self {
|
||||
pub fn new(query: Query, command: Command, billing: Billing, infra: Infra) -> Self {
|
||||
let host = std::env::var("HOST").unwrap_or_else(|_| "127.0.0.1".to_string());
|
||||
let admins = std::env::var("ADMINS")
|
||||
.unwrap_or_default()
|
||||
@@ -131,6 +133,7 @@ impl Api {
|
||||
query,
|
||||
command,
|
||||
billing,
|
||||
infra,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -139,15 +142,16 @@ impl Api {
|
||||
api: Arc::new(self),
|
||||
};
|
||||
|
||||
Router::new()
|
||||
let router = Router::new()
|
||||
.route("/identity", get(get_identity))
|
||||
.route("/plans", get(list_plans))
|
||||
.route("/plans/:id", get(get_plan))
|
||||
.route("/tenants", get(list_tenants))
|
||||
.route("/tenants", get(list_tenants).post(create_tenant))
|
||||
.route("/tenants/:pubkey", get(get_tenant).put(update_tenant))
|
||||
.route("/tenants/:pubkey/relays", get(list_tenant_relays))
|
||||
.route("/relays", get(list_relays).post(create_relay))
|
||||
.route("/relays/:id", get(get_relay).put(update_relay))
|
||||
.route("/relays/:id/members", get(list_relay_members))
|
||||
.route("/relays/:id/activity", get(list_relay_activity))
|
||||
.route("/relays/:id/deactivate", post(deactivate_relay))
|
||||
.route("/relays/:id/reactivate", post(reactivate_relay))
|
||||
@@ -158,8 +162,9 @@ impl Api {
|
||||
"/tenants/:pubkey/stripe/session",
|
||||
get(create_stripe_session),
|
||||
)
|
||||
.route("/stripe/webhook", post(stripe_webhook))
|
||||
.with_state(state)
|
||||
.route("/stripe/webhook", post(stripe_webhook));
|
||||
|
||||
router.with_state(state)
|
||||
}
|
||||
|
||||
fn extract_auth_pubkey(&self, headers: &HeaderMap) -> std::result::Result<String, ApiError> {
|
||||
@@ -250,22 +255,24 @@ impl Api {
|
||||
}
|
||||
}
|
||||
|
||||
fn prepare_relay(&self, mut relay: Relay) -> anyhow::Result<Relay> {
|
||||
if !relay
|
||||
.subdomain
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
|
||||
{
|
||||
return Err(anyhow!("invalid-subdomain"));
|
||||
async fn fetch_relay_members(&self, relay: &Relay) -> Result<Vec<String>> {
|
||||
if relay.synced == 0 {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let plan = Query::get_plan(&relay.plan).ok_or_else(|| anyhow!("invalid-plan"))?;
|
||||
self.infra.list_relay_members(&relay.id).await
|
||||
}
|
||||
|
||||
fn prepare_relay(&self, mut relay: Relay) -> std::result::Result<Relay, RelayValidationError> {
|
||||
validate_subdomain_label(&relay.subdomain)?;
|
||||
|
||||
let plan = Query::get_plan(&relay.plan).ok_or(RelayValidationError::InvalidPlan)?;
|
||||
|
||||
if !plan.blossom && relay.blossom_enabled == 1 {
|
||||
return Err(anyhow!("premium-feature"));
|
||||
return Err(RelayValidationError::PremiumFeature);
|
||||
}
|
||||
if !plan.livekit && relay.livekit_enabled == 1 {
|
||||
return Err(anyhow!("premium-feature"));
|
||||
return Err(RelayValidationError::PremiumFeature);
|
||||
}
|
||||
|
||||
if relay.schema.is_empty() {
|
||||
@@ -288,6 +295,96 @@ impl Api {
|
||||
}
|
||||
}
|
||||
|
||||
const SUBDOMAIN_LABEL_MAX_LEN: usize = 63;
|
||||
const RESERVED_SUBDOMAIN_LABELS: [&str; 2] = ["api", "admin"];
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum SubdomainValidationError {
|
||||
Empty,
|
||||
TooLong,
|
||||
Reserved,
|
||||
EdgeHyphen,
|
||||
InvalidCharacters,
|
||||
}
|
||||
|
||||
impl SubdomainValidationError {
|
||||
fn code(self) -> &'static str {
|
||||
match self {
|
||||
Self::Empty => "subdomain-empty",
|
||||
Self::TooLong => "subdomain-too-long",
|
||||
Self::Reserved => "subdomain-reserved",
|
||||
Self::EdgeHyphen => "subdomain-invalid-hyphen",
|
||||
Self::InvalidCharacters => "subdomain-invalid-characters",
|
||||
}
|
||||
}
|
||||
|
||||
fn message(self) -> &'static str {
|
||||
match self {
|
||||
Self::Empty => "subdomain is required",
|
||||
Self::TooLong => "subdomain must be 63 characters or fewer",
|
||||
Self::Reserved => "subdomain is reserved",
|
||||
Self::EdgeHyphen => "subdomain cannot start or end with a hyphen",
|
||||
Self::InvalidCharacters => {
|
||||
"subdomain may only contain lowercase letters, numbers, and hyphens"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum RelayValidationError {
|
||||
InvalidPlan,
|
||||
PremiumFeature,
|
||||
Subdomain(SubdomainValidationError),
|
||||
}
|
||||
|
||||
impl RelayValidationError {
|
||||
fn code(self) -> &'static str {
|
||||
match self {
|
||||
Self::InvalidPlan => "invalid-plan",
|
||||
Self::PremiumFeature => "premium-feature",
|
||||
Self::Subdomain(reason) => reason.code(),
|
||||
}
|
||||
}
|
||||
|
||||
fn message(self) -> &'static str {
|
||||
match self {
|
||||
Self::InvalidPlan => "plan not found",
|
||||
Self::PremiumFeature => "feature requires a paid plan",
|
||||
Self::Subdomain(reason) => reason.message(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<SubdomainValidationError> for RelayValidationError {
|
||||
fn from(value: SubdomainValidationError) -> Self {
|
||||
Self::Subdomain(value)
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_subdomain_label(subdomain: &str) -> std::result::Result<(), SubdomainValidationError> {
|
||||
if subdomain.is_empty() {
|
||||
return Err(SubdomainValidationError::Empty);
|
||||
}
|
||||
if subdomain.len() > SUBDOMAIN_LABEL_MAX_LEN {
|
||||
return Err(SubdomainValidationError::TooLong);
|
||||
}
|
||||
if subdomain.starts_with('-') || subdomain.ends_with('-') {
|
||||
return Err(SubdomainValidationError::EdgeHyphen);
|
||||
}
|
||||
if RESERVED_SUBDOMAIN_LABELS.contains(&subdomain) {
|
||||
return Err(SubdomainValidationError::Reserved);
|
||||
}
|
||||
if !subdomain
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
|
||||
{
|
||||
return Err(SubdomainValidationError::InvalidCharacters);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn ok<T: Serialize>(status: StatusCode, data: T) -> Response {
|
||||
(status, Json(OkResponse { data, code: "ok" })).into_response()
|
||||
}
|
||||
@@ -315,6 +412,14 @@ fn parse_bool_default(value: i64, default: i64) -> i64 {
|
||||
}
|
||||
}
|
||||
|
||||
fn relay_validation_error_response(error: RelayValidationError) -> Response {
|
||||
err(
|
||||
StatusCode::UNPROCESSABLE_ENTITY,
|
||||
error.code(),
|
||||
error.message(),
|
||||
)
|
||||
}
|
||||
|
||||
fn map_unique_error(err: &anyhow::Error) -> Option<&'static str> {
|
||||
let sqlx_err = err.downcast_ref::<sqlx::Error>()?;
|
||||
let sqlx::Error::Database(db_err) = sqlx_err else {
|
||||
@@ -400,10 +505,17 @@ async fn get_identity(
|
||||
) -> std::result::Result<Response, ApiError> {
|
||||
let pubkey = state.api.extract_auth_pubkey(&headers)?;
|
||||
let is_admin = state.api.admins.iter().any(|a| a == &pubkey);
|
||||
Ok(ok(StatusCode::OK, IdentityResponse { pubkey, is_admin }))
|
||||
}
|
||||
|
||||
async fn create_tenant(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
) -> std::result::Result<Response, ApiError> {
|
||||
let pubkey = state.api.extract_auth_pubkey(&headers)?;
|
||||
|
||||
// Ensure tenant exists.
|
||||
match state.api.query.get_tenant(&pubkey).await {
|
||||
Ok(Some(_)) => {}
|
||||
Ok(Some(t)) => Ok(ok(StatusCode::OK, t)),
|
||||
Ok(None) => {
|
||||
let stripe_customer_id = match state.api.billing.stripe_create_customer(&pubkey).await {
|
||||
Ok(id) => id,
|
||||
@@ -427,27 +539,35 @@ async fn get_identity(
|
||||
};
|
||||
|
||||
match state.api.command.create_tenant(&tenant).await {
|
||||
Ok(()) => {}
|
||||
Err(e) if matches!(map_unique_error(&e), Some("pubkey-exists")) => {}
|
||||
Err(e) => {
|
||||
return Ok(err(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"internal",
|
||||
&e.to_string(),
|
||||
));
|
||||
Ok(()) => Ok(ok(StatusCode::OK, tenant)),
|
||||
Err(e) if matches!(map_unique_error(&e), Some("pubkey-exists")) => {
|
||||
match state.api.query.get_tenant(&pubkey).await {
|
||||
Ok(Some(t)) => Ok(ok(StatusCode::OK, t)),
|
||||
Ok(None) => Ok(err(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"internal",
|
||||
"tenant row missing after unique-constraint race",
|
||||
)),
|
||||
Err(e) => Ok(err(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"internal",
|
||||
&e.to_string(),
|
||||
)),
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
Err(e) => {
|
||||
return Ok(err(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"internal",
|
||||
&e.to_string(),
|
||||
));
|
||||
Err(e) => Ok(err(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"internal",
|
||||
&e.to_string(),
|
||||
)),
|
||||
}
|
||||
}
|
||||
Err(e) => Ok(err(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"internal",
|
||||
&e.to_string(),
|
||||
)),
|
||||
}
|
||||
|
||||
Ok(ok(StatusCode::OK, IdentityResponse { pubkey, is_admin }))
|
||||
}
|
||||
|
||||
async fn get_plan(Path(id): Path<String>) -> Response {
|
||||
@@ -561,6 +681,40 @@ async fn list_relay_activity(
|
||||
}
|
||||
}
|
||||
|
||||
async fn list_relay_members(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Path(id): Path<String>,
|
||||
) -> std::result::Result<Response, ApiError> {
|
||||
let auth = state.api.extract_auth_pubkey(&headers)?;
|
||||
|
||||
let relay = match state.api.query.get_relay(&id).await {
|
||||
Ok(Some(r)) => r,
|
||||
Ok(None) => return Ok(err(StatusCode::NOT_FOUND, "not-found", "relay not found")),
|
||||
Err(e) => {
|
||||
return Ok(err(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"internal",
|
||||
&e.to_string(),
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
state.api.require_admin_or_tenant(&auth, &relay.tenant)?;
|
||||
|
||||
match state.api.fetch_relay_members(&relay).await {
|
||||
Ok(members) => Ok(ok(
|
||||
StatusCode::OK,
|
||||
serde_json::json!({ "members": members }),
|
||||
)),
|
||||
Err(e) => Ok(err(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"internal",
|
||||
&e.to_string(),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
async fn create_relay(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
@@ -592,27 +746,9 @@ async fn create_relay(
|
||||
};
|
||||
|
||||
relay = match state.api.prepare_relay(relay) {
|
||||
Err(e) if e.to_string() == "invalid-plan" => {
|
||||
return Ok(err(
|
||||
StatusCode::UNPROCESSABLE_ENTITY,
|
||||
"invalid-plan",
|
||||
"plan not found",
|
||||
));
|
||||
}
|
||||
Ok(r) => r,
|
||||
Err(e) if e.to_string() == "premium-feature" => {
|
||||
return Ok(err(
|
||||
StatusCode::UNPROCESSABLE_ENTITY,
|
||||
"premium-feature",
|
||||
"feature requires a paid plan",
|
||||
));
|
||||
}
|
||||
Err(_) => {
|
||||
return Ok(err(
|
||||
StatusCode::UNPROCESSABLE_ENTITY,
|
||||
"invalid-relay",
|
||||
"relay validation failed",
|
||||
));
|
||||
Err(e) => {
|
||||
return Ok(relay_validation_error_response(e));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -658,10 +794,13 @@ async fn update_relay(
|
||||
|
||||
state.api.require_admin_or_tenant(&auth, &relay.tenant)?;
|
||||
|
||||
let current_plan = relay.plan.clone();
|
||||
let requested_plan = payload.plan.clone();
|
||||
|
||||
if let Some(v) = payload.subdomain {
|
||||
relay.subdomain = v;
|
||||
}
|
||||
if let Some(v) = payload.plan {
|
||||
if let Some(v) = requested_plan.clone() {
|
||||
relay.plan = v;
|
||||
}
|
||||
if let Some(v) = payload.info_name {
|
||||
@@ -696,30 +835,44 @@ async fn update_relay(
|
||||
}
|
||||
|
||||
relay = match state.api.prepare_relay(relay) {
|
||||
Err(e) if e.to_string() == "invalid-plan" => {
|
||||
return Ok(err(
|
||||
StatusCode::UNPROCESSABLE_ENTITY,
|
||||
"invalid-plan",
|
||||
"plan not found",
|
||||
));
|
||||
}
|
||||
Ok(r) => r,
|
||||
Err(e) if e.to_string() == "premium-feature" => {
|
||||
return Ok(err(
|
||||
StatusCode::UNPROCESSABLE_ENTITY,
|
||||
"premium-feature",
|
||||
"feature requires a paid plan",
|
||||
));
|
||||
}
|
||||
Err(_) => {
|
||||
return Ok(err(
|
||||
StatusCode::UNPROCESSABLE_ENTITY,
|
||||
"invalid-relay",
|
||||
"relay validation failed",
|
||||
));
|
||||
Err(e) => {
|
||||
return Ok(relay_validation_error_response(e));
|
||||
}
|
||||
};
|
||||
|
||||
let plan_changed = requested_plan
|
||||
.as_deref()
|
||||
.is_some_and(|requested| requested != current_plan);
|
||||
|
||||
if plan_changed {
|
||||
let selected_plan = Query::get_plan(&relay.plan).expect("validated plan must exist");
|
||||
if let Some(limit) = selected_plan.members {
|
||||
let current_members = match state.api.fetch_relay_members(&relay).await {
|
||||
Ok(members) => members.len() as i64,
|
||||
Err(e) => {
|
||||
return Ok(err(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"internal",
|
||||
&e.to_string(),
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
if current_members > limit {
|
||||
let message = format!(
|
||||
"relay has {current_members} members, which exceeds the {} plan limit of {limit}",
|
||||
selected_plan.name.to_lowercase()
|
||||
);
|
||||
return Ok(err(
|
||||
StatusCode::UNPROCESSABLE_ENTITY,
|
||||
"member-limit-exceeded",
|
||||
&message,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
match state.api.command.update_relay(&relay).await {
|
||||
Ok(()) => Ok(ok(StatusCode::OK, relay)),
|
||||
Err(e) => {
|
||||
@@ -929,12 +1082,29 @@ async fn update_tenant(
|
||||
state.api.require_admin_or_tenant(&auth, &pubkey)?;
|
||||
let mut tenant = state.api.get_tenant_or_404(&pubkey).await?;
|
||||
|
||||
let nwc_previously_empty = tenant.nwc_url.is_empty();
|
||||
if let Some(nwc_url) = payload.nwc_url {
|
||||
tenant.nwc_url = nwc_url;
|
||||
}
|
||||
|
||||
match state.api.command.update_tenant(&tenant).await {
|
||||
Ok(()) => Ok(ok(StatusCode::OK, tenant)),
|
||||
Ok(()) => {
|
||||
// When NWC is first connected, attempt to pay any outstanding open invoices.
|
||||
if nwc_previously_empty && !tenant.nwc_url.is_empty() {
|
||||
let billing = state.api.billing.clone();
|
||||
let tenant_clone = tenant.clone();
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = billing.pay_outstanding_nwc_invoices(&tenant_clone).await {
|
||||
tracing::error!(
|
||||
error = %e,
|
||||
pubkey = %tenant_clone.pubkey,
|
||||
"pay_outstanding_nwc_invoices failed after NWC setup"
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
Ok(ok(StatusCode::OK, tenant))
|
||||
}
|
||||
Err(e) => Ok(err(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"internal",
|
||||
|
||||
+400
-19
@@ -17,6 +17,9 @@ type HmacSha256 = Hmac<Sha256>;
|
||||
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 MANUAL_LIGHTNING_PAYMENT_DM: &str = "Payment is due for your relay subscription. Please visit the application to complete a manual Lightning payment.";
|
||||
const NWC_ERROR_DM_PREFIX: &str = "NWC auto-payment failed:";
|
||||
const NWC_ERROR_DM_MAX_CHARS: usize = 240;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum InvoiceLookupError {
|
||||
@@ -80,7 +83,6 @@ pub struct Billing {
|
||||
nwc_url: String,
|
||||
stripe_secret_key: String,
|
||||
stripe_webhook_secret: String,
|
||||
btc_quote_api_base: String,
|
||||
http: reqwest::Client,
|
||||
query: Query,
|
||||
command: Command,
|
||||
@@ -95,13 +97,13 @@ impl Billing {
|
||||
panic!("missing STRIPE_SECRET_KEY environment variable");
|
||||
}
|
||||
let stripe_webhook_secret = std::env::var("STRIPE_WEBHOOK_SECRET").unwrap_or_default();
|
||||
let btc_quote_api_base =
|
||||
std::env::var("BTC_PRICE_API_BASE").unwrap_or_else(|_| COINBASE_SPOT_API.to_string());
|
||||
if stripe_webhook_secret.trim().is_empty() {
|
||||
panic!("missing STRIPE_WEBHOOK_SECRET environment variable");
|
||||
}
|
||||
Self {
|
||||
nwc_url,
|
||||
stripe_secret_key,
|
||||
stripe_webhook_secret,
|
||||
btc_quote_api_base,
|
||||
http: reqwest::Client::new(),
|
||||
query,
|
||||
command,
|
||||
@@ -164,6 +166,8 @@ impl Billing {
|
||||
self.command
|
||||
.delete_relay_subscription_item(&relay.id)
|
||||
.await?;
|
||||
self.validate_downgrade_proration(&tenant, "free-plan-downgrade")
|
||||
.await;
|
||||
}
|
||||
self.cleanup_empty_subscription(&tenant.pubkey).await?;
|
||||
return Ok(());
|
||||
@@ -203,8 +207,28 @@ impl Billing {
|
||||
// Sync the subscription item: create or update
|
||||
let subscription_id = tenant.stripe_subscription_id.as_ref().unwrap();
|
||||
let item_id = if let Some(ref existing_item_id) = relay.stripe_subscription_item_id {
|
||||
self.stripe_update_subscription_item(existing_item_id, stripe_price_id)
|
||||
.await?
|
||||
let is_downgrade = self
|
||||
.is_subscription_item_downgrade(existing_item_id, plan.amount)
|
||||
.await
|
||||
.unwrap_or_else(|error| {
|
||||
tracing::warn!(
|
||||
error = %error,
|
||||
relay_id = %relay.id,
|
||||
"failed to determine relay plan downgrade direction"
|
||||
);
|
||||
false
|
||||
});
|
||||
|
||||
let updated_item_id = self
|
||||
.stripe_update_subscription_item(existing_item_id, stripe_price_id)
|
||||
.await?;
|
||||
|
||||
if is_downgrade {
|
||||
self.validate_downgrade_proration(&tenant, "paid-plan-downgrade")
|
||||
.await;
|
||||
}
|
||||
|
||||
updated_item_id
|
||||
} else {
|
||||
self.stripe_create_subscription_item(subscription_id, stripe_price_id)
|
||||
.await?
|
||||
@@ -272,6 +296,10 @@ impl Billing {
|
||||
let customer = obj["customer"].as_str().unwrap_or_default();
|
||||
self.handle_subscription_deleted(customer).await?;
|
||||
}
|
||||
"payment_method.attached" => {
|
||||
let customer = obj["customer"].as_str().unwrap_or_default();
|
||||
self.handle_payment_method_attached(customer).await?;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
@@ -331,6 +359,8 @@ impl Billing {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let mut nwc_error_for_dm: Option<String> = None;
|
||||
|
||||
// 1. NWC auto-pay: if the tenant has a nwc_url
|
||||
if !tenant.nwc_url.is_empty() {
|
||||
match self
|
||||
@@ -347,6 +377,14 @@ impl Billing {
|
||||
self.command
|
||||
.set_tenant_nwc_error(&tenant.pubkey, &error_msg)
|
||||
.await?;
|
||||
tracing::warn!(
|
||||
error = %e,
|
||||
tenant_pubkey = %tenant.pubkey,
|
||||
stripe_customer_id,
|
||||
invoice_id,
|
||||
"nwc auto-payment failed for invoice.created"
|
||||
);
|
||||
nwc_error_for_dm = summarize_nwc_error_for_dm(&error_msg);
|
||||
// Fall through to next option
|
||||
}
|
||||
}
|
||||
@@ -361,12 +399,8 @@ impl Billing {
|
||||
}
|
||||
|
||||
// 3. Manual payment: send a DM
|
||||
self.robot
|
||||
.send_dm(
|
||||
&tenant.pubkey,
|
||||
"Payment is due for your relay subscription. Please visit the application to complete a manual Lightning payment.",
|
||||
)
|
||||
.await?;
|
||||
let dm_message = manual_lightning_payment_dm(nwc_error_for_dm.as_deref());
|
||||
self.robot.send_dm(&tenant.pubkey, &dm_message).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -489,6 +523,103 @@ impl Billing {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn handle_payment_method_attached(&self, stripe_customer_id: &str) -> Result<()> {
|
||||
if stripe_customer_id.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let Some(tenant) = self
|
||||
.query
|
||||
.get_tenant_by_stripe_customer_id(stripe_customer_id)
|
||||
.await?
|
||||
else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
self.pay_outstanding_card_invoices(&tenant).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn is_subscription_item_downgrade(
|
||||
&self,
|
||||
item_id: &str,
|
||||
next_plan_amount: i64,
|
||||
) -> Result<bool> {
|
||||
let Some(current_price_id) = self.stripe_get_subscription_item_price_id(item_id).await?
|
||||
else {
|
||||
return Ok(false);
|
||||
};
|
||||
|
||||
let Some(current_plan_amount) = Self::plan_amount_from_price_id(¤t_price_id) else {
|
||||
return Ok(false);
|
||||
};
|
||||
|
||||
Ok(next_plan_amount < current_plan_amount)
|
||||
}
|
||||
|
||||
fn plan_amount_from_price_id(price_id: &str) -> Option<i64> {
|
||||
Query::list_plans().into_iter().find_map(|plan| {
|
||||
if plan.stripe_price_id.as_deref() == Some(price_id) {
|
||||
Some(plan.amount)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async fn validate_downgrade_proration(&self, tenant: &crate::models::Tenant, context: &str) {
|
||||
match self
|
||||
.stripe_preview_upcoming_invoice(
|
||||
&tenant.stripe_customer_id,
|
||||
tenant.stripe_subscription_id.as_deref(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(upcoming) => {
|
||||
let lines = upcoming["lines"]["data"]
|
||||
.as_array()
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
let proration_lines = lines
|
||||
.iter()
|
||||
.filter(|line| line["proration"].as_bool().unwrap_or(false))
|
||||
.count();
|
||||
let amount_due = upcoming["amount_due"]
|
||||
.as_i64()
|
||||
.unwrap_or_else(|| upcoming["total"].as_i64().unwrap_or(0));
|
||||
let currency = upcoming["currency"].as_str().unwrap_or("usd");
|
||||
let preview_id = upcoming["id"].as_str().unwrap_or_default();
|
||||
|
||||
tracing::info!(
|
||||
tenant_pubkey = %tenant.pubkey,
|
||||
stripe_customer_id = %tenant.stripe_customer_id,
|
||||
context,
|
||||
preview_id,
|
||||
proration_lines,
|
||||
amount_due,
|
||||
currency,
|
||||
"validated Stripe proration preview for downgrade"
|
||||
);
|
||||
|
||||
if proration_lines == 0 {
|
||||
tracing::warn!(
|
||||
tenant_pubkey = %tenant.pubkey,
|
||||
context,
|
||||
"downgrade proration preview has no proration lines; verify in Stripe dashboard"
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(error) => {
|
||||
tracing::warn!(
|
||||
error = %error,
|
||||
tenant_pubkey = %tenant.pubkey,
|
||||
context,
|
||||
"failed to fetch downgrade proration preview"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Public API helpers ---
|
||||
|
||||
pub async fn get_invoice_with_tenant(
|
||||
@@ -596,6 +727,93 @@ impl Billing {
|
||||
Ok(invoice_response.invoice)
|
||||
}
|
||||
|
||||
pub async fn pay_outstanding_nwc_invoices(&self, tenant: &crate::models::Tenant) -> Result<()> {
|
||||
if tenant.nwc_url.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let invoices = self
|
||||
.stripe_list_invoices(&tenant.stripe_customer_id)
|
||||
.await?;
|
||||
let invoices_arr = invoices.as_array().cloned().unwrap_or_default();
|
||||
|
||||
for invoice in &invoices_arr {
|
||||
let status = invoice["status"].as_str().unwrap_or_default();
|
||||
let amount_due = invoice["amount_due"].as_i64().unwrap_or(0);
|
||||
let invoice_id = invoice["id"].as_str().unwrap_or_default();
|
||||
let currency = invoice["currency"].as_str().unwrap_or("usd");
|
||||
|
||||
if status != "open" || amount_due == 0 || invoice_id.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
match self
|
||||
.nwc_pay_invoice(amount_due, currency, &tenant.nwc_url)
|
||||
.await
|
||||
{
|
||||
Ok(()) => {
|
||||
if let Err(e) = self.stripe_pay_invoice_out_of_band(invoice_id).await {
|
||||
tracing::error!(
|
||||
error = %e,
|
||||
invoice_id,
|
||||
"failed to mark invoice paid out of band"
|
||||
);
|
||||
} else {
|
||||
let _ = self.command.clear_tenant_nwc_error(&tenant.pubkey).await;
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
let error_msg = format!("{e}");
|
||||
tracing::error!(
|
||||
error = %e,
|
||||
invoice_id,
|
||||
"nwc payment failed for outstanding invoice"
|
||||
);
|
||||
let _ = self
|
||||
.command
|
||||
.set_tenant_nwc_error(&tenant.pubkey, &error_msg)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn pay_outstanding_card_invoices(&self, tenant: &crate::models::Tenant) -> Result<()> {
|
||||
if !self
|
||||
.stripe_has_payment_method(&tenant.stripe_customer_id)
|
||||
.await?
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let invoices = self
|
||||
.stripe_list_invoices(&tenant.stripe_customer_id)
|
||||
.await?;
|
||||
let invoices_arr = invoices.as_array().cloned().unwrap_or_default();
|
||||
|
||||
for invoice in &invoices_arr {
|
||||
let status = invoice["status"].as_str().unwrap_or_default();
|
||||
let amount_due = invoice["amount_due"].as_i64().unwrap_or(0);
|
||||
let invoice_id = invoice["id"].as_str().unwrap_or_default();
|
||||
|
||||
if status != "open" || amount_due == 0 || invoice_id.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Err(error) = self.stripe_pay_invoice(invoice_id).await {
|
||||
tracing::error!(
|
||||
error = %error,
|
||||
invoice_id,
|
||||
"failed to retry card payment for outstanding invoice"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn stripe_create_portal_session(&self, customer_id: &str) -> Result<String> {
|
||||
let resp = self
|
||||
.http
|
||||
@@ -712,6 +930,48 @@ impl Billing {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn stripe_pay_invoice(&self, invoice_id: &str) -> Result<()> {
|
||||
self.http
|
||||
.post(format!("{STRIPE_API}/invoices/{invoice_id}/pay"))
|
||||
.bearer_auth(&self.stripe_secret_key)
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status()?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn stripe_get_subscription_item_price_id(&self, item_id: &str) -> Result<Option<String>> {
|
||||
let resp = self
|
||||
.http
|
||||
.get(format!("{STRIPE_API}/subscription_items/{item_id}"))
|
||||
.bearer_auth(&self.stripe_secret_key)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
let body: serde_json::Value = resp.error_for_status()?.json().await?;
|
||||
Ok(body["price"]["id"].as_str().map(ToString::to_string))
|
||||
}
|
||||
|
||||
async fn stripe_preview_upcoming_invoice(
|
||||
&self,
|
||||
customer_id: &str,
|
||||
subscription_id: Option<&str>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let mut req = self
|
||||
.http
|
||||
.get(format!("{STRIPE_API}/invoices/upcoming"))
|
||||
.bearer_auth(&self.stripe_secret_key)
|
||||
.query(&[("customer", customer_id)]);
|
||||
|
||||
if let Some(subscription_id) = subscription_id {
|
||||
req = req.query(&[("subscription", subscription_id)]);
|
||||
}
|
||||
|
||||
let body: serde_json::Value = req.send().await?.error_for_status()?.json().await?;
|
||||
Ok(body)
|
||||
}
|
||||
|
||||
async fn stripe_pay_invoice_out_of_band(&self, invoice_id: &str) -> Result<()> {
|
||||
self.http
|
||||
.post(format!("{STRIPE_API}/invoices/{invoice_id}/pay"))
|
||||
@@ -802,7 +1062,7 @@ impl Billing {
|
||||
}
|
||||
|
||||
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
|
||||
fetch_btc_spot_price_from_base(&self.http, COINBASE_SPOT_API, currency).await
|
||||
}
|
||||
|
||||
fn currency_minor_exponent(currency: &str) -> Result<u8> {
|
||||
@@ -846,6 +1106,31 @@ pub async fn fetch_btc_spot_price_from_base(
|
||||
Ok(amount)
|
||||
}
|
||||
|
||||
fn summarize_nwc_error_for_dm(error: &str) -> Option<String> {
|
||||
let normalized = error.split_whitespace().collect::<Vec<_>>().join(" ");
|
||||
if normalized.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
if normalized.chars().count() <= NWC_ERROR_DM_MAX_CHARS {
|
||||
return Some(normalized);
|
||||
}
|
||||
|
||||
let prefix_len = NWC_ERROR_DM_MAX_CHARS.saturating_sub(3);
|
||||
let mut truncated = normalized.chars().take(prefix_len).collect::<String>();
|
||||
truncated.push_str("...");
|
||||
Some(truncated)
|
||||
}
|
||||
|
||||
fn manual_lightning_payment_dm(nwc_error: Option<&str>) -> String {
|
||||
match nwc_error {
|
||||
Some(error) if !error.is_empty() => {
|
||||
format!("{MANUAL_LIGHTNING_PAYMENT_DM}\n\n{NWC_ERROR_DM_PREFIX} {error}")
|
||||
}
|
||||
_ => MANUAL_LIGHTNING_PAYMENT_DM.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn fiat_minor_to_msats_from_quote(
|
||||
amount_due_minor: i64,
|
||||
currency: &str,
|
||||
@@ -949,7 +1234,8 @@ mod tests {
|
||||
use sqlx::SqlitePool;
|
||||
use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions};
|
||||
use std::str::FromStr;
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
use std::sync::OnceLock;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
fn env_lock() -> &'static Mutex<()> {
|
||||
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
|
||||
@@ -964,6 +1250,14 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(unused_unsafe)]
|
||||
fn set_stripe_webhook_secret(value: Option<&str>) {
|
||||
match value {
|
||||
Some(v) => unsafe { std::env::set_var("STRIPE_WEBHOOK_SECRET", v) },
|
||||
None => unsafe { std::env::remove_var("STRIPE_WEBHOOK_SECRET") },
|
||||
}
|
||||
}
|
||||
|
||||
struct StripeSecretKeyGuard {
|
||||
previous: Option<String>,
|
||||
}
|
||||
@@ -982,6 +1276,24 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
struct StripeWebhookSecretGuard {
|
||||
previous: Option<String>,
|
||||
}
|
||||
|
||||
impl StripeWebhookSecretGuard {
|
||||
fn set(value: Option<&str>) -> Self {
|
||||
let previous = std::env::var("STRIPE_WEBHOOK_SECRET").ok();
|
||||
set_stripe_webhook_secret(value);
|
||||
Self { previous }
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for StripeWebhookSecretGuard {
|
||||
fn drop(&mut self) {
|
||||
set_stripe_webhook_secret(self.previous.as_deref());
|
||||
}
|
||||
}
|
||||
|
||||
async fn test_pool() -> SqlitePool {
|
||||
let connect_options = SqliteConnectOptions::from_str("sqlite::memory:")
|
||||
.expect("valid sqlite memory url")
|
||||
@@ -1003,8 +1315,9 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn billing_new_panics_without_stripe_secret_key() {
|
||||
let _lock = env_lock().lock().expect("acquire env lock");
|
||||
let _env = StripeSecretKeyGuard::set(None);
|
||||
let _lock = env_lock().lock().await;
|
||||
let _secret_env = StripeSecretKeyGuard::set(None);
|
||||
let _webhook_env = StripeWebhookSecretGuard::set(Some("whsec_test_dummy"));
|
||||
|
||||
let pool = test_pool().await;
|
||||
let query = Query::new(pool.clone());
|
||||
@@ -1034,9 +1347,76 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn billing_new_accepts_non_empty_stripe_secret_key() {
|
||||
let _lock = env_lock().lock().expect("acquire env lock");
|
||||
let _env = StripeSecretKeyGuard::set(Some("sk_test_dummy"));
|
||||
async fn billing_new_panics_without_stripe_webhook_secret() {
|
||||
let _lock = env_lock().lock().await;
|
||||
let _secret_env = StripeSecretKeyGuard::set(Some("sk_test_dummy"));
|
||||
let _webhook_env = StripeWebhookSecretGuard::set(None);
|
||||
|
||||
let pool = test_pool().await;
|
||||
let query = Query::new(pool.clone());
|
||||
let command = Command::new(pool);
|
||||
let robot = Robot::test_stub();
|
||||
|
||||
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||
Billing::new(query, command, robot)
|
||||
}));
|
||||
|
||||
let panic_payload = match result {
|
||||
Ok(_) => panic!("constructor should panic when STRIPE_WEBHOOK_SECRET is missing"),
|
||||
Err(payload) => payload,
|
||||
};
|
||||
let panic_msg = if let Some(msg) = panic_payload.downcast_ref::<&str>() {
|
||||
(*msg).to_string()
|
||||
} else if let Some(msg) = panic_payload.downcast_ref::<String>() {
|
||||
msg.clone()
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
|
||||
assert!(
|
||||
panic_msg.contains("missing STRIPE_WEBHOOK_SECRET environment variable"),
|
||||
"unexpected panic: {panic_msg}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn billing_new_panics_with_blank_stripe_webhook_secret() {
|
||||
let _lock = env_lock().lock().await;
|
||||
let _secret_env = StripeSecretKeyGuard::set(Some("sk_test_dummy"));
|
||||
let _webhook_env = StripeWebhookSecretGuard::set(Some(" "));
|
||||
|
||||
let pool = test_pool().await;
|
||||
let query = Query::new(pool.clone());
|
||||
let command = Command::new(pool);
|
||||
let robot = Robot::test_stub();
|
||||
|
||||
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||
Billing::new(query, command, robot)
|
||||
}));
|
||||
|
||||
let panic_payload = match result {
|
||||
Ok(_) => panic!("constructor should panic when STRIPE_WEBHOOK_SECRET is blank"),
|
||||
Err(payload) => payload,
|
||||
};
|
||||
let panic_msg = if let Some(msg) = panic_payload.downcast_ref::<&str>() {
|
||||
(*msg).to_string()
|
||||
} else if let Some(msg) = panic_payload.downcast_ref::<String>() {
|
||||
msg.clone()
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
|
||||
assert!(
|
||||
panic_msg.contains("missing STRIPE_WEBHOOK_SECRET environment variable"),
|
||||
"unexpected panic: {panic_msg}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn billing_new_accepts_non_empty_stripe_secrets() {
|
||||
let _lock = env_lock().lock().await;
|
||||
let _secret_env = StripeSecretKeyGuard::set(Some("sk_test_dummy"));
|
||||
let _webhook_env = StripeWebhookSecretGuard::set(Some("whsec_test_dummy"));
|
||||
|
||||
let pool = test_pool().await;
|
||||
let billing = Billing::new(
|
||||
@@ -1046,5 +1426,6 @@ mod tests {
|
||||
);
|
||||
|
||||
assert_eq!(billing.stripe_secret_key, "sk_test_dummy");
|
||||
assert_eq!(billing.stripe_webhook_secret, "whsec_test_dummy");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,7 +32,7 @@ impl Command {
|
||||
.fetch_one(&mut **tx)
|
||||
.await?
|
||||
}
|
||||
_ => anyhow::bail!("unknown resource_type: {}", resource_type),
|
||||
_ => anyhow::bail!("unknown resource_type: {resource_type}"),
|
||||
};
|
||||
|
||||
let id = uuid::Uuid::new_v4().to_string();
|
||||
|
||||
+239
-59
@@ -1,10 +1,15 @@
|
||||
use anyhow::Result;
|
||||
use nostr_sdk::prelude::*;
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::command::Command;
|
||||
use crate::models::{Activity, RELAY_STATUS_DELINQUENT, RELAY_STATUS_INACTIVE};
|
||||
use crate::models::{Activity, RELAY_STATUS_DELINQUENT, RELAY_STATUS_INACTIVE, Relay};
|
||||
use crate::query::Query;
|
||||
|
||||
const RELAY_SYNC_RETRY_BASE_DELAY_SECS: u64 = 30;
|
||||
const RELAY_SYNC_RETRY_MAX_DELAY_SECS: u64 = 15 * 60;
|
||||
const RELAY_SYNC_RETRY_MAX_ATTEMPTS: usize = 6;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Infra {
|
||||
api_url: String,
|
||||
@@ -18,14 +23,22 @@ pub struct Infra {
|
||||
}
|
||||
|
||||
impl Infra {
|
||||
pub fn new(query: Query, command: Command) -> Self {
|
||||
pub fn new(query: Query, command: Command) -> Result<Self> {
|
||||
let api_url = std::env::var("ZOOID_API_URL").unwrap_or_default();
|
||||
let relay_domain = std::env::var("RELAY_DOMAIN").unwrap_or_default();
|
||||
let livekit_url = std::env::var("LIVEKIT_URL").unwrap_or_default();
|
||||
let livekit_api_key = std::env::var("LIVEKIT_API_KEY").unwrap_or_default();
|
||||
let livekit_api_secret = std::env::var("LIVEKIT_API_SECRET").unwrap_or_default();
|
||||
let api_secret = std::env::var("ZOOID_API_SECRET").unwrap_or_default();
|
||||
Self {
|
||||
|
||||
if api_url.trim().is_empty() {
|
||||
anyhow::bail!("missing ZOOID_API_URL");
|
||||
}
|
||||
if api_secret.trim().is_empty() {
|
||||
anyhow::bail!("missing ZOOID_API_SECRET");
|
||||
}
|
||||
|
||||
Ok(Self {
|
||||
api_url,
|
||||
relay_domain,
|
||||
livekit_url,
|
||||
@@ -34,12 +47,16 @@ impl Infra {
|
||||
api_secret,
|
||||
query,
|
||||
command,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn start(self) {
|
||||
let mut rx = self.command.notify.subscribe();
|
||||
|
||||
if let Err(e) = self.schedule_startup_retries().await {
|
||||
tracing::error!(error = %e, "failed to schedule relay sync retries on startup");
|
||||
}
|
||||
|
||||
loop {
|
||||
match rx.recv().await {
|
||||
Ok(activity) => {
|
||||
@@ -58,19 +75,88 @@ impl Infra {
|
||||
async fn handle_activity(&self, activity: &Activity) -> Result<()> {
|
||||
let needs_sync = should_sync_relay_activity(activity.activity_type.as_str());
|
||||
|
||||
if needs_sync {
|
||||
let Some(relay) = self.query.get_relay(&activity.resource_id).await? else {
|
||||
return Ok(());
|
||||
};
|
||||
if !needs_sync || activity.resource_type != "relay" {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let is_new = relay.synced == 0;
|
||||
self.sync_and_report(&relay, is_new).await;
|
||||
if activity.activity_type == "fail_relay_sync" {
|
||||
self.schedule_relay_sync_retry(&activity.resource_id, "activity")
|
||||
.await?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let Some(relay) = self.query.get_relay(&activity.resource_id).await? else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let is_new = relay.synced == 0;
|
||||
self.sync_and_report(&relay, is_new).await;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn schedule_startup_retries(&self) -> Result<()> {
|
||||
let relays = self.query.list_relays_with_sync_error().await?;
|
||||
|
||||
for relay in relays {
|
||||
self.schedule_relay_sync_retry(&relay.id, "startup").await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn sync_and_report(&self, relay: &crate::models::Relay, is_new: bool) {
|
||||
async fn schedule_relay_sync_retry(&self, relay_id: &str, source: &str) -> Result<()> {
|
||||
let activities = self.query.list_activity_for_relay(relay_id).await?;
|
||||
let consecutive_failures = consecutive_sync_failures(&activities);
|
||||
|
||||
let Some(delay) = relay_sync_retry_delay(consecutive_failures) else {
|
||||
tracing::warn!(
|
||||
relay = relay_id,
|
||||
consecutive_failures,
|
||||
max_attempts = RELAY_SYNC_RETRY_MAX_ATTEMPTS,
|
||||
"relay sync retries exhausted; awaiting manual intervention"
|
||||
);
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
tracing::info!(
|
||||
relay = relay_id,
|
||||
source,
|
||||
consecutive_failures,
|
||||
delay_secs = delay.as_secs(),
|
||||
"scheduled relay sync retry"
|
||||
);
|
||||
|
||||
let relay_id = relay_id.to_string();
|
||||
let infra = self.clone();
|
||||
|
||||
tokio::spawn(async move {
|
||||
tokio::time::sleep(delay).await;
|
||||
|
||||
if let Err(e) = infra.retry_relay_sync(&relay_id).await {
|
||||
tracing::error!(relay = %relay_id, error = %e, "relay sync retry task failed");
|
||||
}
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn retry_relay_sync(&self, relay_id: &str) -> Result<()> {
|
||||
let Some(relay) = self.query.get_relay(relay_id).await? else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
if relay.sync_error.trim().is_empty() {
|
||||
tracing::debug!(relay = %relay.id, "skip relay sync retry; relay has no sync_error");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let is_new = relay.synced == 0;
|
||||
self.sync_and_report(&relay, is_new).await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn sync_and_report(&self, relay: &Relay, is_new: bool) {
|
||||
match self.sync_relay(relay, is_new).await {
|
||||
Ok(()) => {
|
||||
tracing::info!(relay = %relay.id, "relay sync succeeded");
|
||||
@@ -96,7 +182,29 @@ impl Infra {
|
||||
Ok(auth)
|
||||
}
|
||||
|
||||
async fn sync_relay(&self, relay: &crate::models::Relay, is_new: bool) -> Result<()> {
|
||||
pub async fn list_relay_members(&self, relay_id: &str) -> Result<Vec<String>> {
|
||||
let client = reqwest::Client::new();
|
||||
let base = self.api_url.trim_end_matches('/');
|
||||
let url = format!("{base}/relay/{relay_id}/members");
|
||||
let auth = self.nip98_auth(&url, HttpMethod::GET).await?;
|
||||
|
||||
let response = client
|
||||
.get(&url)
|
||||
.header("Authorization", auth)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
let status = response.status();
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
anyhow::bail!("zooid members returned {status}: {body}");
|
||||
}
|
||||
|
||||
let body = response.text().await?;
|
||||
parse_relay_members_response(&body)
|
||||
}
|
||||
|
||||
async fn sync_relay(&self, relay: &Relay, is_new: bool) -> Result<()> {
|
||||
let client = reqwest::Client::new();
|
||||
let base = self.api_url.trim_end_matches('/');
|
||||
|
||||
@@ -106,8 +214,6 @@ impl Infra {
|
||||
format!("{}.{}", relay.subdomain, self.relay_domain)
|
||||
};
|
||||
|
||||
let secret = Keys::generate().secret_key().to_secret_hex();
|
||||
|
||||
let livekit = if relay.livekit_enabled == 1 {
|
||||
serde_json::json!({
|
||||
"enabled": true,
|
||||
@@ -119,65 +225,139 @@ impl Infra {
|
||||
serde_json::json!({ "enabled": false })
|
||||
};
|
||||
|
||||
let body = serde_json::json!({
|
||||
"host": host,
|
||||
"schema": relay.schema,
|
||||
"secret": secret,
|
||||
"inactive": relay.status == RELAY_STATUS_INACTIVE
|
||||
|| relay.status == RELAY_STATUS_DELINQUENT,
|
||||
"info": {
|
||||
"name": relay.info_name,
|
||||
"icon": relay.info_icon,
|
||||
"description": relay.info_description,
|
||||
"pubkey": relay.tenant,
|
||||
},
|
||||
"policy": {
|
||||
"public_join": relay.policy_public_join == 1,
|
||||
"strip_signatures": relay.policy_strip_signatures == 1,
|
||||
},
|
||||
"groups": { "enabled": relay.groups_enabled == 1 },
|
||||
"management": { "enabled": relay.management_enabled == 1 },
|
||||
"blossom": { "enabled": relay.blossom_enabled == 1 },
|
||||
"livekit": livekit,
|
||||
"push": { "enabled": relay.push_enabled == 1 },
|
||||
"roles": {
|
||||
"admin": { "can_manage": true, "can_invite": true },
|
||||
"member": { "can_invite": true },
|
||||
},
|
||||
});
|
||||
let body = relay_sync_body(
|
||||
relay,
|
||||
host,
|
||||
livekit,
|
||||
is_new.then(|| Keys::generate().secret_key().to_secret_hex()),
|
||||
);
|
||||
|
||||
let response = if is_new {
|
||||
let url = format!("{}/relay/{}", base, relay.id);
|
||||
let auth = self.nip98_auth(&url, HttpMethod::POST).await?;
|
||||
client
|
||||
.post(&url)
|
||||
.header("Authorization", auth)
|
||||
.json(&body)
|
||||
.send()
|
||||
.await?
|
||||
let url = format!("{}/relay/{}", base, relay.id);
|
||||
let auth = self
|
||||
.nip98_auth(&url, zooid_sync_http_method(is_new))
|
||||
.await?;
|
||||
|
||||
let request = if is_new {
|
||||
client.post(&url)
|
||||
} else {
|
||||
let url = format!("{}/relay/{}", base, relay.id);
|
||||
let auth = self.nip98_auth(&url, HttpMethod::PUT).await?;
|
||||
client
|
||||
.put(&url)
|
||||
.header("Authorization", auth)
|
||||
.json(&body)
|
||||
.send()
|
||||
.await?
|
||||
client.patch(&url)
|
||||
};
|
||||
|
||||
let response = request
|
||||
.header("Authorization", auth)
|
||||
.json(&body)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
let status = response.status();
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
anyhow::bail!("zooid sync returned {}: {}", status, body)
|
||||
anyhow::bail!("zooid sync returned {status}: {body}")
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn zooid_sync_http_method(is_new: bool) -> HttpMethod {
|
||||
if is_new {
|
||||
HttpMethod::POST
|
||||
} else {
|
||||
HttpMethod::PATCH
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_relay_members_response(body: &str) -> Result<Vec<String>> {
|
||||
let value: serde_json::Value = serde_json::from_str(body)?;
|
||||
|
||||
if let Some(members) = members_from_value(&value) {
|
||||
return Ok(members);
|
||||
}
|
||||
if let Some(members) = value.get("members").and_then(members_from_value) {
|
||||
return Ok(members);
|
||||
}
|
||||
if let Some(members) = value
|
||||
.get("data")
|
||||
.and_then(|data| data.get("members"))
|
||||
.and_then(members_from_value)
|
||||
{
|
||||
return Ok(members);
|
||||
}
|
||||
|
||||
anyhow::bail!("zooid members response missing members array")
|
||||
}
|
||||
|
||||
fn members_from_value(value: &serde_json::Value) -> Option<Vec<String>> {
|
||||
let values = value.as_array()?;
|
||||
values
|
||||
.iter()
|
||||
.map(|value| value.as_str().map(ToString::to_string))
|
||||
.collect()
|
||||
}
|
||||
|
||||
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"
|
||||
"create_relay" | "update_relay" | "activate_relay" | "deactivate_relay" | "fail_relay_sync"
|
||||
)
|
||||
}
|
||||
|
||||
fn consecutive_sync_failures(activities: &[Activity]) -> usize {
|
||||
activities
|
||||
.iter()
|
||||
.take_while(|activity| activity.activity_type == "fail_relay_sync")
|
||||
.count()
|
||||
}
|
||||
|
||||
fn relay_sync_retry_delay(consecutive_failures: usize) -> Option<Duration> {
|
||||
let retry_attempt = consecutive_failures.max(1);
|
||||
if retry_attempt > RELAY_SYNC_RETRY_MAX_ATTEMPTS {
|
||||
return None;
|
||||
}
|
||||
|
||||
let exponent = (retry_attempt - 1).min(31);
|
||||
let multiplier = 1u64 << exponent;
|
||||
let delay_secs = RELAY_SYNC_RETRY_BASE_DELAY_SECS
|
||||
.saturating_mul(multiplier)
|
||||
.min(RELAY_SYNC_RETRY_MAX_DELAY_SECS);
|
||||
|
||||
Some(Duration::from_secs(delay_secs))
|
||||
}
|
||||
|
||||
+4
-4
@@ -3,8 +3,8 @@ mod billing;
|
||||
mod command;
|
||||
mod infra;
|
||||
mod models;
|
||||
mod query;
|
||||
mod pool;
|
||||
mod query;
|
||||
mod robot;
|
||||
|
||||
use anyhow::Result;
|
||||
@@ -33,14 +33,14 @@ async fn main() -> Result<()> {
|
||||
let query = Query::new(pool.clone());
|
||||
let command = Command::new(pool);
|
||||
let billing = Billing::new(query.clone(), command.clone(), robot.clone());
|
||||
let infra = Infra::new(query.clone(), command.clone());
|
||||
let api = Api::new(query, command, billing.clone());
|
||||
let infra = Infra::new(query.clone(), command.clone())?;
|
||||
let api = Api::new(query, command, billing.clone(), infra.clone());
|
||||
|
||||
let host = std::env::var("HOST").unwrap_or_else(|_| "127.0.0.1".to_string());
|
||||
let port: u16 = std::env::var("PORT")
|
||||
.ok()
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(3000);
|
||||
.unwrap_or(2892);
|
||||
let origins: Vec<String> = std::env::var("ALLOW_ORIGINS")
|
||||
.unwrap_or_default()
|
||||
.split(',')
|
||||
|
||||
+1
-2
@@ -21,8 +21,7 @@ pub async fn create_pool() -> Result<SqlitePool> {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
|
||||
let connect_options =
|
||||
SqliteConnectOptions::from_str(&database_url)?.create_if_missing(true);
|
||||
let connect_options = SqliteConnectOptions::from_str(&database_url)?.create_if_missing(true);
|
||||
|
||||
let pool = SqlitePoolOptions::new()
|
||||
.max_connections(5)
|
||||
|
||||
@@ -94,6 +94,23 @@ impl Query {
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
pub async fn list_relays_with_sync_error(&self) -> Result<Vec<Relay>> {
|
||||
let rows = sqlx::query_as::<_, Relay>(
|
||||
"SELECT id, tenant, schema, subdomain, plan, stripe_subscription_item_id,
|
||||
status, sync_error,
|
||||
info_name, info_icon, info_description,
|
||||
policy_public_join, policy_strip_signatures,
|
||||
groups_enabled, management_enabled, blossom_enabled,
|
||||
livekit_enabled, push_enabled, synced
|
||||
FROM relay
|
||||
WHERE TRIM(sync_error) != ''
|
||||
ORDER BY id",
|
||||
)
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
pub async fn list_relays_for_tenant(&self, tenant_id: &str) -> Result<Vec<Relay>> {
|
||||
let rows = sqlx::query_as::<_, Relay>(
|
||||
"SELECT id, tenant, schema, subdomain, plan, stripe_subscription_item_id,
|
||||
|
||||
@@ -25,7 +25,7 @@ async fn quote_endpoint_can_be_stubbed_deterministically() {
|
||||
|
||||
assert_eq!(btc_price, 50_000.0);
|
||||
|
||||
let msats = fiat_minor_to_msats_from_quote(100, "USD", btc_price)
|
||||
.expect("convert quoted fiat amount");
|
||||
let msats =
|
||||
fiat_minor_to_msats_from_quote(100, "USD", btc_price).expect("convert quoted fiat amount");
|
||||
assert_eq!(msats, 2_000_000);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# Backend API base URL
|
||||
VITE_API_URL=http://127.0.0.1:3000
|
||||
VITE_API_URL=http://127.0.0.1:2892
|
||||
|
||||
# Platform display name shown in UI
|
||||
VITE_PLATFORM_NAME=Caravel
|
||||
|
||||
+1
-1
@@ -32,7 +32,7 @@ Environment variables (see `.env.template`):
|
||||
|
||||
| Variable | Description | Default |
|
||||
|---|---|---|
|
||||
| `VITE_API_URL` | Backend API base URL | `http://127.0.0.1:3000` |
|
||||
| `VITE_API_URL` | Backend API base URL | `http://127.0.0.1:2892` |
|
||||
|
||||
## Running
|
||||
|
||||
|
||||
@@ -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 |
@@ -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 |
@@ -0,0 +1,151 @@
|
||||
import { For, Show, createEffect, createSignal } from "solid-js"
|
||||
import Modal from "@/components/Modal"
|
||||
|
||||
type ConfirmDialogProps = {
|
||||
open: boolean
|
||||
title: string
|
||||
description: string
|
||||
/** Optional bullet points shown in a warning box below the description */
|
||||
details?: string[]
|
||||
confirmLabel: string
|
||||
busyLabel?: string
|
||||
busy?: boolean
|
||||
tone?: "danger" | "primary"
|
||||
onConfirm: () => void | Promise<void>
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
const TONE_STYLES: Record<NonNullable<ConfirmDialogProps["tone"]>, string> = {
|
||||
danger: "bg-red-600 text-white hover:bg-red-700",
|
||||
primary: "bg-blue-600 text-white hover:bg-blue-700",
|
||||
}
|
||||
|
||||
const DETAIL_BOX_STYLES: Record<NonNullable<ConfirmDialogProps["tone"]>, string> = {
|
||||
danger: "border-amber-200 bg-amber-50 text-amber-800",
|
||||
primary: "border-blue-200 bg-blue-50 text-blue-800",
|
||||
}
|
||||
|
||||
type ConfirmDialogSnapshot = {
|
||||
title: string
|
||||
description: string
|
||||
details?: string[]
|
||||
confirmLabel: string
|
||||
busyLabel?: string
|
||||
busy: boolean
|
||||
tone: NonNullable<ConfirmDialogProps["tone"]>
|
||||
}
|
||||
|
||||
export default function ConfirmDialog(props: ConfirmDialogProps) {
|
||||
const [snapshot, setSnapshot] = createSignal<ConfirmDialogSnapshot>({
|
||||
title: props.title,
|
||||
description: props.description,
|
||||
details: props.details ? [...props.details] : undefined,
|
||||
confirmLabel: props.confirmLabel,
|
||||
busyLabel: props.busyLabel,
|
||||
busy: props.busy ?? false,
|
||||
tone: props.tone ?? "primary",
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (!props.open) return
|
||||
|
||||
setSnapshot({
|
||||
title: props.title,
|
||||
description: props.description,
|
||||
details: props.details ? [...props.details] : undefined,
|
||||
confirmLabel: props.confirmLabel,
|
||||
busyLabel: props.busyLabel,
|
||||
busy: props.busy ?? false,
|
||||
tone: props.tone ?? "primary",
|
||||
})
|
||||
})
|
||||
|
||||
const content = () => props.open
|
||||
? {
|
||||
title: props.title,
|
||||
description: props.description,
|
||||
details: props.details,
|
||||
confirmLabel: props.confirmLabel,
|
||||
busyLabel: props.busyLabel,
|
||||
busy: props.busy ?? false,
|
||||
tone: props.tone ?? "primary",
|
||||
}
|
||||
: snapshot()
|
||||
const tone = () => content().tone
|
||||
const confirmText = () => content().busy ? (content().busyLabel ?? content().confirmLabel) : content().confirmLabel
|
||||
|
||||
function handleClose() {
|
||||
if (props.busy) return
|
||||
props.onClose()
|
||||
}
|
||||
|
||||
function handleConfirm() {
|
||||
if (props.busy) return
|
||||
void props.onConfirm()
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={props.open}
|
||||
onClose={handleClose}
|
||||
wrapperClass="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
|
||||
panelClass="w-full max-w-md rounded-2xl bg-white shadow-xl overflow-hidden"
|
||||
>
|
||||
<div class="px-6 pt-6 pb-4 border-b border-gray-100">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold text-gray-900">{content().title}</h2>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClose}
|
||||
disabled={content().busy}
|
||||
class="shrink-0 rounded p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-700 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
aria-label="Close"
|
||||
>
|
||||
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M18 6L6 18M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="px-6 py-4">
|
||||
<div class="space-y-3 text-left">
|
||||
<p class="text-sm text-gray-600">{content().description}</p>
|
||||
<Show when={content().details && content().details!.length > 0}>
|
||||
<ul class={`w-full rounded-lg border px-4 py-3 space-y-1.5 ${DETAIL_BOX_STYLES[tone()]}`}>
|
||||
<For each={content().details}>
|
||||
{(item) => (
|
||||
<li class="flex items-start gap-2 text-sm">
|
||||
<span class="mt-0.5 shrink-0 select-none">•</span>
|
||||
<span>{item}</span>
|
||||
</li>
|
||||
)}
|
||||
</For>
|
||||
</ul>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="px-6 py-4 flex justify-end gap-3 border-t border-gray-100">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClose}
|
||||
disabled={content().busy}
|
||||
class="rounded-lg border border-gray-300 px-4 py-2 text-sm text-gray-700 transition-colors hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleConfirm}
|
||||
disabled={content().busy}
|
||||
class={`rounded-lg px-4 py-2 text-sm font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-50 ${TONE_STYLES[tone()]}`}
|
||||
>
|
||||
{confirmText()}
|
||||
</button>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
@@ -3,7 +3,6 @@ import QRCode from "qrcode"
|
||||
import Modal from "@/components/Modal"
|
||||
import PaymentSetup from "@/components/PaymentSetup"
|
||||
import { getInvoice, getInvoiceBolt11 } from "@/lib/api"
|
||||
import { tenantNeedsPaymentSetup } from "@/lib/hooks"
|
||||
|
||||
type PayStatus = "idle" | "loading" | "success" | "error"
|
||||
type Bolt11Status = "idle" | "loading" | "ready" | "error"
|
||||
@@ -26,8 +25,8 @@ export default function PaymentDialog(props: PaymentDialogProps) {
|
||||
const [bolt11Error, setBolt11Error] = createSignal("")
|
||||
const [payStatus, setPayStatus] = createSignal<PayStatus>("idle")
|
||||
const [payError, setPayError] = createSignal("")
|
||||
const [showSetup, setShowSetup] = createSignal(false)
|
||||
const [showPaymentSetup, setShowPaymentSetup] = createSignal(false)
|
||||
const [setupSaved, setSetupSaved] = createSignal(false)
|
||||
|
||||
async function loadBolt11() {
|
||||
if (!props.invoice.id) return
|
||||
@@ -63,7 +62,6 @@ export default function PaymentDialog(props: PaymentDialogProps) {
|
||||
const invoice = await getInvoice(props.invoice.id)
|
||||
if (invoice.status === "paid") {
|
||||
setPayStatus("success")
|
||||
tenantNeedsPaymentSetup().then(needs => setShowSetup(needs)).catch(() => {})
|
||||
} else {
|
||||
setPayStatus("error")
|
||||
setPayError("Payment not yet confirmed. Please try again after sending.")
|
||||
@@ -81,7 +79,6 @@ export default function PaymentDialog(props: PaymentDialogProps) {
|
||||
setBolt11Error("")
|
||||
setBolt11("")
|
||||
setQrDataUrl("")
|
||||
setShowSetup(false)
|
||||
props.onClose()
|
||||
}
|
||||
|
||||
@@ -160,6 +157,15 @@ export default function PaymentDialog(props: PaymentDialogProps) {
|
||||
</button>
|
||||
</div>
|
||||
</Show>
|
||||
<div class="text-center pt-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPaymentSetup(true)}
|
||||
class="text-sm text-blue-600 hover:text-blue-700"
|
||||
>
|
||||
Set up payment method instead
|
||||
</button>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
}
|
||||
@@ -172,15 +178,13 @@ export default function PaymentDialog(props: PaymentDialogProps) {
|
||||
</div>
|
||||
<p class="text-sm font-medium text-gray-900">Payment confirmed!</p>
|
||||
<p class="text-xs text-gray-500">Thank you. Your account is up to date.</p>
|
||||
<Show when={showSetup()}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPaymentSetup(true)}
|
||||
class="mt-2 text-sm font-medium text-blue-600 hover:text-blue-700"
|
||||
>
|
||||
Set up automatic payments
|
||||
</button>
|
||||
</Show>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPaymentSetup(true)}
|
||||
class="mt-2 text-sm font-medium text-blue-600 hover:text-blue-700"
|
||||
>
|
||||
Set up automatic payments
|
||||
</button>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
@@ -228,7 +232,14 @@ export default function PaymentDialog(props: PaymentDialogProps) {
|
||||
</Modal>
|
||||
<PaymentSetup
|
||||
open={showPaymentSetup()}
|
||||
onClose={() => setShowPaymentSetup(false)}
|
||||
onClose={() => {
|
||||
setShowPaymentSetup(false)
|
||||
if (setupSaved()) {
|
||||
setSetupSaved(false)
|
||||
props.onClose()
|
||||
}
|
||||
}}
|
||||
onSaved={() => setSetupSaved(true)}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -9,6 +9,7 @@ type Tab = "nwc" | "card"
|
||||
type PaymentSetupProps = {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
onSaved?: () => void
|
||||
}
|
||||
|
||||
export default function PaymentSetup(props: PaymentSetupProps) {
|
||||
@@ -27,6 +28,7 @@ export default function PaymentSetup(props: PaymentSetupProps) {
|
||||
try {
|
||||
await updateActiveTenant({ nwc_url: url })
|
||||
setSaved(true)
|
||||
props.onSaved?.()
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Failed to save wallet connection")
|
||||
} finally {
|
||||
@@ -64,7 +66,7 @@ export default function PaymentSetup(props: PaymentSetupProps) {
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold text-gray-900">Set Up Payments</h2>
|
||||
<p class="text-sm text-gray-500 mt-1">Choose how you'd like to pay for your relay.</p>
|
||||
<p class="text-sm text-gray-500 mt-1">Choose how you'd like to pay once invoices are issued for your relay.</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
@@ -144,7 +146,7 @@ export default function PaymentSetup(props: PaymentSetupProps) {
|
||||
<line x1="1" y1="10" x2="23" y2="10" />
|
||||
</svg>
|
||||
</div>
|
||||
<p class="text-sm text-gray-600">Add a payment card via Stripe to enable automatic billing.</p>
|
||||
<p class="text-sm text-gray-600">Add a payment card via Stripe to enable automatic billing. If an invoice is currently due, we will retry collection after card setup.</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={openPortal}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { A } from "@solidjs/router"
|
||||
import { Show, createEffect, createSignal, onCleanup } from "solid-js"
|
||||
import type { Relay, PlanId } from "@/lib/api"
|
||||
import menuDotsIcon from "@/assets/menu-dots-2.svg"
|
||||
import ConfirmDialog from "@/components/ConfirmDialog"
|
||||
import Field from "@/components/Field"
|
||||
import PricingTable from "@/components/PricingTable"
|
||||
import ToggleButton from "@/components/ToggleButton"
|
||||
@@ -51,8 +52,8 @@ type RelayDetailCardProps = {
|
||||
currentMembers?: number
|
||||
showTenant?: boolean
|
||||
editHref?: string
|
||||
onDeactivate?: () => void
|
||||
onReactivate?: () => void
|
||||
onDeactivate?: () => void | Promise<void>
|
||||
onReactivate?: () => void | Promise<void>
|
||||
deactivating?: boolean
|
||||
reactivating?: boolean
|
||||
onTogglePublicJoin?: () => void
|
||||
@@ -76,6 +77,7 @@ export default function RelayDetailCard(props: RelayDetailCardProps) {
|
||||
}
|
||||
const [menuOpen, setMenuOpen] = createSignal(false)
|
||||
const [plan, setPlan] = createSignal<PlanId>(props.relay.plan)
|
||||
const [pendingAction, setPendingAction] = createSignal<"deactivate" | "reactivate" | null>(null)
|
||||
|
||||
let menuContainerRef: HTMLDivElement | undefined
|
||||
|
||||
@@ -86,6 +88,24 @@ export default function RelayDetailCard(props: RelayDetailCardProps) {
|
||||
}
|
||||
const planLimited = () => (props.enforcePlanLimits ?? true) && r().plan === "free"
|
||||
const showPlanActions = () => props.showPlanActions ?? true
|
||||
const actionBusy = () => pendingAction() === "deactivate" ? !!props.deactivating : pendingAction() === "reactivate" ? !!props.reactivating : false
|
||||
const relayLabel = () => r().info_name || r().subdomain
|
||||
const confirmTitle = () => pendingAction() === "deactivate" ? "Deactivate relay?" : "Reactivate relay?"
|
||||
const confirmDescription = () => pendingAction() === "deactivate"
|
||||
? `${relayLabel()} will be taken offline immediately.`
|
||||
: `${relayLabel()} will come back online and start accepting connections.`
|
||||
const confirmDetails = () => pendingAction() === "deactivate"
|
||||
? [
|
||||
"All client connections will be dropped immediately.",
|
||||
"Members will be unable to read from or publish to the relay.",
|
||||
"Scheduled and automated tasks (billing, syncing) will be paused.",
|
||||
"All relay data, settings, and members are preserved, nothing is deleted.",
|
||||
"You can reactivate at any time from this page.",
|
||||
]
|
||||
: undefined
|
||||
const confirmLabel = () => pendingAction() === "deactivate" ? "Yes, deactivate" : "Yes, reactivate"
|
||||
const confirmBusyLabel = () => pendingAction() === "deactivate" ? "Deactivating..." : "Reactivating..."
|
||||
const confirmTone = () => pendingAction() === "deactivate" ? "danger" : "primary"
|
||||
|
||||
async function changePlan(plan: PlanId) {
|
||||
setPlan(plan)
|
||||
@@ -97,6 +117,29 @@ export default function RelayDetailCard(props: RelayDetailCardProps) {
|
||||
}
|
||||
}
|
||||
|
||||
function openActionDialog(action: "deactivate" | "reactivate") {
|
||||
setMenuOpen(false)
|
||||
setPendingAction(action)
|
||||
}
|
||||
|
||||
function closeActionDialog() {
|
||||
if (actionBusy()) return
|
||||
setPendingAction(null)
|
||||
}
|
||||
|
||||
async function confirmAction() {
|
||||
const action = pendingAction()
|
||||
if (!action) return
|
||||
|
||||
if (action === "deactivate") {
|
||||
await props.onDeactivate?.()
|
||||
} else {
|
||||
await props.onReactivate?.()
|
||||
}
|
||||
|
||||
setPendingAction(null)
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
if (!menuOpen()) return
|
||||
|
||||
@@ -128,7 +171,7 @@ export default function RelayDetailCard(props: RelayDetailCardProps) {
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div class="flex items-start gap-4 min-w-0">
|
||||
<Show when={r().info_icon}>
|
||||
<img src={r().info_icon} alt="" class="w-14 h-14 rounded-xl object-cover flex-shrink-0 border border-gray-200" />
|
||||
<img src={r().info_icon} alt="" class="w-14 h-14 rounded-xl object-cover shrink-0 border border-gray-200" />
|
||||
</Show>
|
||||
<div class="min-w-0">
|
||||
<div class="flex items-center gap-3 flex-wrap">
|
||||
@@ -148,7 +191,7 @@ export default function RelayDetailCard(props: RelayDetailCardProps) {
|
||||
</div>
|
||||
|
||||
<Show when={props.editHref && (props.onDeactivate || props.onReactivate)}>
|
||||
<div class="relative flex-shrink-0" ref={menuContainerRef}>
|
||||
<div class="relative shrink-0" ref={menuContainerRef}>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex h-9 w-9 items-center justify-center rounded-lg border border-gray-200 hover:bg-gray-50"
|
||||
@@ -177,8 +220,7 @@ export default function RelayDetailCard(props: RelayDetailCardProps) {
|
||||
type="button"
|
||||
class="block w-full text-left px-3 py-2 text-sm text-red-600 hover:bg-red-50 disabled:opacity-50"
|
||||
onClick={() => {
|
||||
setMenuOpen(false)
|
||||
props.onDeactivate?.()
|
||||
openActionDialog("deactivate")
|
||||
}}
|
||||
disabled={props.deactivating}
|
||||
>
|
||||
@@ -190,8 +232,7 @@ export default function RelayDetailCard(props: RelayDetailCardProps) {
|
||||
type="button"
|
||||
class="block w-full text-left px-3 py-2 text-sm text-blue-600 hover:bg-blue-50 disabled:opacity-50"
|
||||
onClick={() => {
|
||||
setMenuOpen(false)
|
||||
props.onReactivate?.()
|
||||
openActionDialog("reactivate")
|
||||
}}
|
||||
disabled={props.reactivating}
|
||||
>
|
||||
@@ -203,6 +244,13 @@ export default function RelayDetailCard(props: RelayDetailCardProps) {
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<Show when={r().sync_error}>
|
||||
<div class="rounded-lg border border-red-200 bg-red-50 px-4 py-3">
|
||||
<p class="text-sm font-semibold text-red-800">Provisioning error</p>
|
||||
<p class="mt-1 text-sm text-red-700 font-mono break-all">{r().sync_error}</p>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<hr class="border-gray-200" />
|
||||
|
||||
<DetailSection title="Policy">
|
||||
@@ -339,6 +387,19 @@ export default function RelayDetailCard(props: RelayDetailCardProps) {
|
||||
</Show>
|
||||
</DetailSection>
|
||||
</Show>
|
||||
|
||||
<ConfirmDialog
|
||||
open={pendingAction() !== null}
|
||||
title={confirmTitle()}
|
||||
description={confirmDescription()}
|
||||
details={confirmDetails()}
|
||||
confirmLabel={confirmLabel()}
|
||||
busyLabel={confirmBusyLabel()}
|
||||
busy={actionBusy()}
|
||||
tone={confirmTone()}
|
||||
onConfirm={confirmAction}
|
||||
onClose={closeActionDialog}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { createEffect, createMemo, createSignal, For } from "solid-js"
|
||||
import type { Relay } from "@/lib/hooks"
|
||||
import { slugify } from "@/lib/slugify"
|
||||
import { validateSubdomainLabel } from "@/lib/subdomain"
|
||||
import { setToastMessage } from "@/components/Toast"
|
||||
import { plans } from "@/lib/state"
|
||||
|
||||
@@ -31,6 +32,12 @@ export default function RelayForm(props: RelayFormProps) {
|
||||
return
|
||||
}
|
||||
|
||||
const subdomainError = validateSubdomainLabel(subdomain())
|
||||
if (subdomainError) {
|
||||
setToastMessage(subdomainError)
|
||||
return
|
||||
}
|
||||
|
||||
setToastMessage("")
|
||||
setSubmitting(true)
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { A } from "@solidjs/router"
|
||||
import { Show } from "solid-js"
|
||||
import type { Relay } from "@/lib/api"
|
||||
|
||||
type RelayListItemProps = {
|
||||
@@ -19,7 +20,17 @@ export default function RelayListItem(props: RelayListItemProps) {
|
||||
<p class="text-xs text-gray-500 break-all mt-1">Tenant: {props.relay.tenant}</p>
|
||||
)}
|
||||
</div>
|
||||
<p class="text-xs uppercase tracking-wide text-gray-500">{props.relay.status}</p>
|
||||
<Show
|
||||
when={props.relay.sync_error}
|
||||
fallback={<p class="text-xs uppercase tracking-wide text-gray-500">{props.relay.status}</p>}
|
||||
>
|
||||
<span
|
||||
class="inline-flex items-center rounded-full border border-red-200 bg-red-50 px-2.5 py-0.5 text-xs font-medium text-red-700 max-w-56 truncate"
|
||||
title={props.relay.sync_error}
|
||||
>
|
||||
{props.relay.sync_error}
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
</A>
|
||||
</li>
|
||||
|
||||
@@ -205,6 +205,10 @@ export function getIdentity() {
|
||||
return callApi<undefined, Identity>("GET", "/identity")
|
||||
}
|
||||
|
||||
export function createTenant() {
|
||||
return callApi<undefined, Tenant>("POST", "/tenants")
|
||||
}
|
||||
|
||||
export function getPlan(id: string) {
|
||||
return callApi<undefined, Plan>("GET", `/plans/${id}`)
|
||||
}
|
||||
@@ -237,6 +241,10 @@ export function getRelay(id: string) {
|
||||
return callApi<undefined, Relay>("GET", `/relays/${id}`)
|
||||
}
|
||||
|
||||
export function listRelayMembers(id: string) {
|
||||
return callApi<undefined, { members: string[] }>("GET", `/relays/${id}/members`)
|
||||
}
|
||||
|
||||
export function listRelayActivity(id: string) {
|
||||
return callApi<undefined, { activity: Activity[] }>("GET", `/relays/${id}/activity`)
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ import { createEffect, createResource, createSignal, onCleanup } from "solid-js"
|
||||
import { getProfilePicture } from "applesauce-core/helpers/profile"
|
||||
import { createOutboxMap, selectOptimalRelays, setFallbackRelays } from "applesauce-core/helpers/relay-selection"
|
||||
import { includeMailboxes } from "applesauce-core/observable"
|
||||
import { Relay as NostrRelay, RelayManagement } from "applesauce-relay"
|
||||
import { map, of } from "rxjs"
|
||||
import {
|
||||
createRelay,
|
||||
@@ -12,12 +11,14 @@ import {
|
||||
getTenant,
|
||||
listRelayActivity,
|
||||
listRelays,
|
||||
listTenantInvoices,
|
||||
listTenantRelays,
|
||||
listTenants,
|
||||
updateRelay,
|
||||
updateTenant,
|
||||
type Activity,
|
||||
type CreateRelayInput,
|
||||
type Invoice,
|
||||
type Relay,
|
||||
type Tenant,
|
||||
type UpdateRelayInput,
|
||||
@@ -137,14 +138,12 @@ export async function tenantNeedsPaymentSetup(): Promise<boolean> {
|
||||
return !tenant.nwc_url && !tenant.stripe_subscription_id
|
||||
}
|
||||
|
||||
export async function getRelayMembers(url: string) {
|
||||
const management = new RelayManagement(new NostrRelay(url), account()!.signer)
|
||||
|
||||
try {
|
||||
return await management.listAllowedPubkeys()
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
export async function getLatestOpenInvoice(): Promise<Invoice | null> {
|
||||
const invoices = await listTenantInvoices(account()!.pubkey)
|
||||
const open = invoices
|
||||
.filter(inv => inv.status === "open" && inv.amount_due > 0)
|
||||
.sort((a, b) => b.period_start - a.period_start)
|
||||
return open[0] ?? null
|
||||
}
|
||||
|
||||
export type { Activity, Relay, Tenant }
|
||||
export type { Activity, Invoice, Relay, Tenant }
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
const SUBDOMAIN_LABEL_MAX_LEN = 63
|
||||
const RESERVED_SUBDOMAIN_LABELS = new Set(["api", "admin"])
|
||||
|
||||
export function validateSubdomainLabel(subdomain: string): string | null {
|
||||
if (subdomain.length === 0) {
|
||||
return "subdomain is required"
|
||||
}
|
||||
if (subdomain.length > SUBDOMAIN_LABEL_MAX_LEN) {
|
||||
return "subdomain must be 63 characters or fewer"
|
||||
}
|
||||
if (subdomain.startsWith("-") || subdomain.endsWith("-")) {
|
||||
return "subdomain cannot start or end with a hyphen"
|
||||
}
|
||||
if (RESERVED_SUBDOMAIN_LABELS.has(subdomain)) {
|
||||
return "subdomain is reserved"
|
||||
}
|
||||
if (!/^[a-z0-9-]+$/.test(subdomain)) {
|
||||
return "subdomain may only contain lowercase letters, numbers, and hyphens"
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { createSignal } from "solid-js"
|
||||
import { updateRelayById, deactivateRelayById, reactivateRelayById, tenantNeedsPaymentSetup, type Relay } from "@/lib/hooks"
|
||||
import { updateRelayById, deactivateRelayById, reactivateRelayById, getLatestOpenInvoice, type Relay } from "@/lib/hooks"
|
||||
import { setToastMessage } from "@/components/Toast"
|
||||
import type { PlanId } from "@/lib/api"
|
||||
import type { Invoice, PlanId } from "@/lib/api"
|
||||
|
||||
function toBool(value: number | undefined, fallback: boolean): boolean {
|
||||
if (value === 0) return false
|
||||
@@ -30,7 +30,7 @@ export default function useRelayToggles(
|
||||
{ refetch, mutate }: RelayActions,
|
||||
) {
|
||||
const [busy, setBusy] = createSignal(false)
|
||||
const [needsPaymentSetup, setNeedsPaymentSetup] = createSignal(false)
|
||||
const [pendingInvoice, setPendingInvoice] = createSignal<Invoice | undefined>()
|
||||
|
||||
async function updateRelay(next: Relay, previous: Relay) {
|
||||
mutate(next)
|
||||
@@ -101,8 +101,8 @@ export default function useRelayToggles(
|
||||
}
|
||||
|
||||
if (plan !== "free") {
|
||||
const needs = await tenantNeedsPaymentSetup()
|
||||
if (needs) setNeedsPaymentSetup(true)
|
||||
const invoice = await getLatestOpenInvoice()
|
||||
if (invoice) setPendingInvoice(invoice)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -116,5 +116,5 @@ export default function useRelayToggles(
|
||||
onToggleLivekitSupport: () => toggle("livekit_enabled", relay()?.plan !== "free"),
|
||||
}
|
||||
|
||||
return { busy, handleDeactivate, handleReactivate, handleUpdatePlan, needsPaymentSetup, clearNeedsPaymentSetup: () => setNeedsPaymentSetup(false), toggles }
|
||||
return { busy, handleDeactivate, handleReactivate, handleUpdatePlan, pendingInvoice, clearPendingInvoice: () => setPendingInvoice(undefined), toggles }
|
||||
}
|
||||
|
||||
@@ -8,6 +8,9 @@ import Modal from "@/components/Modal"
|
||||
import Login from "@/views/Login"
|
||||
import { createRelayForActiveTenant } from "@/lib/hooks"
|
||||
import { account } from "@/lib/state"
|
||||
import FlotillaLogo from "@/assets/flotilla-logo.svg"
|
||||
import ChachiLogo from "@/assets/chachi-logo.svg"
|
||||
import NostordLogo from "@/assets/nostord-logo.svg"
|
||||
|
||||
export default function Home() {
|
||||
const navigate = useNavigate()
|
||||
@@ -223,9 +226,7 @@ export default function Home() {
|
||||
>
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-12 h-12 rounded-2xl bg-linear-to-br from-blue-500 to-indigo-600 flex items-center justify-center text-white font-bold text-xl shadow-md shadow-blue-200">
|
||||
F
|
||||
</div>
|
||||
<img src={FlotillaLogo} alt="Flotilla" class="w-12 h-12 rounded-2xl shadow-md shadow-blue-200" />
|
||||
<div>
|
||||
<h3 class="text-lg font-bold text-gray-900 group-hover:text-blue-600 transition-colors">Flotilla</h3>
|
||||
<p class="text-xs text-gray-400">flotilla.social</p>
|
||||
@@ -263,9 +264,7 @@ export default function Home() {
|
||||
>
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-12 h-12 rounded-2xl bg-linear-to-br from-purple-500 to-pink-500 flex items-center justify-center text-white font-bold text-xl shadow-md shadow-purple-200">
|
||||
C
|
||||
</div>
|
||||
<img src={ChachiLogo} alt="Chachi" class="w-12 h-12 rounded-2xl shadow-md shadow-purple-200" />
|
||||
<div>
|
||||
<h3 class="text-lg font-bold text-gray-900 group-hover:text-purple-600 transition-colors">Chachi</h3>
|
||||
<p class="text-xs text-gray-400">chachi.chat</p>
|
||||
@@ -303,9 +302,7 @@ export default function Home() {
|
||||
>
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-12 h-12 rounded-2xl bg-linear-to-br from-amber-500 to-orange-500 flex items-center justify-center text-white font-bold text-xl shadow-md shadow-amber-200">
|
||||
N
|
||||
</div>
|
||||
<img src={NostordLogo} alt="Nostrord" class="w-12 h-12 rounded-2xl shadow-md shadow-amber-200" />
|
||||
<div>
|
||||
<h3 class="text-lg font-bold text-gray-900 group-hover:text-amber-600 transition-colors">Nostrord</h3>
|
||||
<p class="text-xs text-gray-400">nostrord.com</p>
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { useParams } from "@solidjs/router"
|
||||
import { Show } from "solid-js"
|
||||
import { createResource, Show } from "solid-js"
|
||||
import BackLink from "@/components/BackLink"
|
||||
import PageContainer from "@/components/PageContainer"
|
||||
import RelayDetailCard from "@/components/RelayDetailCard"
|
||||
import ResourceState from "@/components/ResourceState"
|
||||
import useMinLoading from "@/components/useMinLoading"
|
||||
import ActivityFeed from "@/components/ActivityFeed"
|
||||
import { listRelayMembers } from "@/lib/api"
|
||||
import { useRelay, useRelayActivity } from "@/lib/hooks"
|
||||
import useRelayToggles from "@/lib/useRelayToggles"
|
||||
|
||||
@@ -13,6 +14,15 @@ export default function AdminRelayDetail() {
|
||||
const params = useParams()
|
||||
const relayId = () => params.id ?? ""
|
||||
const [relay, { refetch, mutate }] = useRelay(relayId)
|
||||
const [members] = createResource(relayId, async (id) => {
|
||||
if (!id) return []
|
||||
|
||||
try {
|
||||
return (await listRelayMembers(id)).members
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
})
|
||||
const loading = useMinLoading(() => relay.loading && !relay())
|
||||
const [activity] = useRelayActivity(relayId)
|
||||
const { busy, handleDeactivate, handleReactivate, toggles } = useRelayToggles(relayId, relay, { refetch, mutate })
|
||||
@@ -26,6 +36,7 @@ export default function AdminRelayDetail() {
|
||||
<div class="space-y-6 mb-6">
|
||||
<RelayDetailCard
|
||||
relay={r()}
|
||||
currentMembers={members()?.length}
|
||||
showTenant
|
||||
editHref={`/admin/relays/${params.id}/edit`}
|
||||
onDeactivate={handleDeactivate}
|
||||
|
||||
@@ -1,27 +1,59 @@
|
||||
import { useParams } from "@solidjs/router"
|
||||
import { createMemo, createResource, Show } from "solid-js"
|
||||
import { createMemo, createResource, createSignal, Show } from "solid-js"
|
||||
import BackLink from "@/components/BackLink"
|
||||
import PageContainer from "@/components/PageContainer"
|
||||
import PaymentDialog from "@/components/PaymentDialog"
|
||||
import PaymentSetup from "@/components/PaymentSetup"
|
||||
import RelayDetailCard from "@/components/RelayDetailCard"
|
||||
import ResourceState from "@/components/ResourceState"
|
||||
import useMinLoading from "@/components/useMinLoading"
|
||||
import ActivityFeed from "@/components/ActivityFeed"
|
||||
import { getRelayMembers, useRelay, useRelayActivity } from "@/lib/hooks"
|
||||
import { listRelayMembers } from "@/lib/api"
|
||||
import { getLatestOpenInvoice, useRelay, useRelayActivity, useTenant } from "@/lib/hooks"
|
||||
import useRelayToggles from "@/lib/useRelayToggles"
|
||||
import { plans } from "@/lib/state"
|
||||
|
||||
export default function RelayDetail() {
|
||||
const params = useParams()
|
||||
const relayId = () => params.id ?? ""
|
||||
const [relay, { refetch, mutate }] = useRelay(relayId)
|
||||
const relayUrl = createMemo(() => {
|
||||
const subdomain = relay()?.subdomain
|
||||
return subdomain ? `wss://${subdomain}.spaces.coracle.social` : undefined
|
||||
const [members] = createResource(relayId, async (id) => {
|
||||
if (!id) return []
|
||||
|
||||
try {
|
||||
return (await listRelayMembers(id)).members
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
})
|
||||
const [members] = createResource(relayUrl, getRelayMembers)
|
||||
const loading = useMinLoading(() => relay.loading && !relay())
|
||||
const [activity] = useRelayActivity(relayId)
|
||||
const { busy, handleDeactivate, handleReactivate, handleUpdatePlan, needsPaymentSetup, clearNeedsPaymentSetup, toggles } = useRelayToggles(relayId, relay, { refetch, mutate })
|
||||
const { busy, handleDeactivate, handleReactivate, handleUpdatePlan, pendingInvoice, clearPendingInvoice, toggles } = useRelayToggles(relayId, relay, { refetch, mutate })
|
||||
|
||||
const [tenant, { refetch: refetchTenant }] = useTenant()
|
||||
const [paymentSetupOpen, setPaymentSetupOpen] = createSignal(false)
|
||||
const [invoiceDialogOpen, setInvoiceDialogOpen] = createSignal(false)
|
||||
const [paymentBannerDismissed, setPaymentBannerDismissed] = createSignal(false)
|
||||
|
||||
const isPaidRelay = createMemo(() => {
|
||||
const r = relay()
|
||||
if (!r) return false
|
||||
const plan = plans().find(p => p.id === r.plan)
|
||||
return !!(plan && plan.amount > 0)
|
||||
})
|
||||
|
||||
const [openInvoice, { refetch: refetchOpenInvoice }] = createResource(
|
||||
isPaidRelay,
|
||||
async (paid) => paid ? getLatestOpenInvoice() : null
|
||||
)
|
||||
|
||||
const showPaymentNudge = createMemo(() => {
|
||||
if (paymentBannerDismissed()) return false
|
||||
if (!isPaidRelay()) return false
|
||||
const t = tenant()
|
||||
if (!t) return false
|
||||
return !t.nwc_url
|
||||
})
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
@@ -30,9 +62,47 @@ export default function RelayDetail() {
|
||||
<Show when={!loading() && relay()}>
|
||||
{(r) => (
|
||||
<div class="space-y-6 mb-6">
|
||||
<Show when={showPaymentNudge()}>
|
||||
<div class="rounded-lg border border-amber-200 bg-amber-50 p-4 flex items-start justify-between gap-4">
|
||||
<div class="min-w-0">
|
||||
<p class="text-sm font-medium text-amber-800">Payment setup recommended</p>
|
||||
<p class="text-sm text-amber-700 mt-1">
|
||||
This relay is on a paid plan. Invoices are due when your subscription starts. Set up NWC or Stripe for automatic payments, or pay open invoices via Lightning.
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-3 shrink-0">
|
||||
<Show when={openInvoice()}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setInvoiceDialogOpen(true)}
|
||||
class="text-sm font-medium text-amber-800 underline hover:text-amber-900 whitespace-nowrap"
|
||||
>
|
||||
Pay invoice
|
||||
</button>
|
||||
</Show>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPaymentSetupOpen(true)}
|
||||
class="text-sm font-medium text-amber-800 underline hover:text-amber-900 whitespace-nowrap"
|
||||
>
|
||||
Set up payments
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPaymentBannerDismissed(true)}
|
||||
aria-label="Dismiss"
|
||||
class="text-amber-500 hover:text-amber-800 shrink-0"
|
||||
>
|
||||
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M18 6L6 18M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
<RelayDetailCard
|
||||
relay={r()}
|
||||
currentMembers={members.length}
|
||||
currentMembers={members()?.length}
|
||||
editHref={`/relays/${params.id}/edit`}
|
||||
onDeactivate={handleDeactivate}
|
||||
onReactivate={handleReactivate}
|
||||
@@ -45,9 +115,37 @@ export default function RelayDetail() {
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
<Show when={pendingInvoice()}>
|
||||
{(inv) => (
|
||||
<PaymentDialog
|
||||
invoice={inv()}
|
||||
open={true}
|
||||
onClose={() => {
|
||||
clearPendingInvoice()
|
||||
void refetchTenant()
|
||||
void refetchOpenInvoice()
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
<Show when={openInvoice()}>
|
||||
{(inv) => (
|
||||
<PaymentDialog
|
||||
invoice={inv()!}
|
||||
open={invoiceDialogOpen()}
|
||||
onClose={() => {
|
||||
setInvoiceDialogOpen(false)
|
||||
void refetchOpenInvoice()
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
<PaymentSetup
|
||||
open={needsPaymentSetup()}
|
||||
onClose={clearNeedsPaymentSetup}
|
||||
open={paymentSetupOpen()}
|
||||
onClose={() => {
|
||||
setPaymentSetupOpen(false)
|
||||
void refetchTenant()
|
||||
}}
|
||||
/>
|
||||
</PageContainer>
|
||||
)
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useNavigate, useParams } from "@solidjs/router"
|
||||
import { Show } from "solid-js"
|
||||
import RelayForm, { type RelayFormValues } from "@/components/RelayForm"
|
||||
import { slugify } from "@/lib/slugify"
|
||||
import BackLink from "@/components/BackLink"
|
||||
import PageContainer from "@/components/PageContainer"
|
||||
import ResourceState from "@/components/ResourceState"
|
||||
@@ -18,7 +17,7 @@ export default function RelayEdit(props: { basePath?: string; title?: string })
|
||||
|
||||
async function handleSubmit(values: RelayFormValues) {
|
||||
await updateRelayById(relayId(), {
|
||||
subdomain: slugify(values.subdomain),
|
||||
subdomain: values.subdomain,
|
||||
info_name: values.info_name.trim(),
|
||||
info_icon: values.info_icon.trim(),
|
||||
info_description: values.info_description.trim(),
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
import { createSignal } from "solid-js"
|
||||
import { createSignal, Show } from "solid-js"
|
||||
import { useNavigate } from "@solidjs/router"
|
||||
import BackLink from "@/components/BackLink"
|
||||
import PageContainer from "@/components/PageContainer"
|
||||
import PaymentSetup from "@/components/PaymentSetup"
|
||||
import PaymentDialog from "@/components/PaymentDialog"
|
||||
import RelayForm, { type RelayFormValues } from "@/components/RelayForm"
|
||||
import { createRelayForActiveTenant, tenantNeedsPaymentSetup } from "@/lib/hooks"
|
||||
import { createRelayForActiveTenant, getLatestOpenInvoice } from "@/lib/hooks"
|
||||
import type { Invoice } from "@/lib/api"
|
||||
|
||||
export default function RelayNew() {
|
||||
const navigate = useNavigate()
|
||||
const [showPaymentSetup, setShowPaymentSetup] = createSignal(false)
|
||||
const [pendingInvoice, setPendingInvoice] = createSignal<Invoice | undefined>()
|
||||
let createdRelayId = ""
|
||||
|
||||
async function handleSubmit(values: RelayFormValues) {
|
||||
@@ -16,9 +17,9 @@ export default function RelayNew() {
|
||||
createdRelayId = relay.id
|
||||
|
||||
if (values.plan !== "free") {
|
||||
const needs = await tenantNeedsPaymentSetup()
|
||||
if (needs) {
|
||||
setShowPaymentSetup(true)
|
||||
const invoice = await getLatestOpenInvoice()
|
||||
if (invoice) {
|
||||
setPendingInvoice(invoice)
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -27,7 +28,7 @@ export default function RelayNew() {
|
||||
}
|
||||
|
||||
function handleDialogClose() {
|
||||
setShowPaymentSetup(false)
|
||||
setPendingInvoice(undefined)
|
||||
navigate(`/relays/${createdRelayId}`)
|
||||
}
|
||||
|
||||
@@ -41,10 +42,15 @@ export default function RelayNew() {
|
||||
submitLabel="Create Relay"
|
||||
submittingLabel="Creating..."
|
||||
/>
|
||||
<PaymentSetup
|
||||
open={showPaymentSetup()}
|
||||
onClose={handleDialogClose}
|
||||
/>
|
||||
<Show when={pendingInvoice()}>
|
||||
{(inv) => (
|
||||
<PaymentDialog
|
||||
invoice={inv()}
|
||||
open={true}
|
||||
onClose={handleDialogClose}
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
</PageContainer>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { PasswordSigner } from "applesauce-signers"
|
||||
import QrScanner from "qr-scanner"
|
||||
import QRCode from "qrcode"
|
||||
import { accountManager, identity, PLATFORM_NAME } from "@/lib/state"
|
||||
import { createTenant } from "@/lib/api"
|
||||
import useMinLoading from "@/components/useMinLoading"
|
||||
|
||||
const NIP46_RELAYS = ['wss://bucket.coracle.social', 'wss://ephemeral.snowflare.cc']
|
||||
@@ -69,6 +70,12 @@ export default function Login(props: LoginPageProps = {}) {
|
||||
async function completeLogin(account: ExtensionAccount | NostrConnectAccount | PrivateKeyAccount | PasswordAccount) {
|
||||
accountManager.addAccount(account)
|
||||
accountManager.setActive(account)
|
||||
try {
|
||||
await createTenant()
|
||||
} catch (e) {
|
||||
accountManager.removeAccount(account)
|
||||
throw e
|
||||
}
|
||||
await props.onAuthenticated?.()
|
||||
}
|
||||
|
||||
|
||||
+14
-7
@@ -1,13 +1,20 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import { defineConfig, loadEnv } from 'vite'
|
||||
import solid from 'vite-plugin-solid'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
import { fileURLToPath, URL } from 'node:url'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [tailwindcss(), solid()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||||
export default defineConfig(({mode}) => {
|
||||
const env = loadEnv(mode, process.cwd(), '')
|
||||
|
||||
return {
|
||||
plugins: [tailwindcss(), solid()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||||
},
|
||||
},
|
||||
},
|
||||
server: {
|
||||
port: Number(env.VITE_PORT) || 5173,
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user