forked from coracle/caravel
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4e33eae45b |
@@ -53,7 +53,7 @@ The rest of the defaults work as-is. `ROBOT_*`, `LIVEKIT_*`, billing, and Stripe
|
||||
cp frontend/.env.template frontend/.env
|
||||
```
|
||||
|
||||
The defaults (`VITE_API_URL=http://127.0.0.1:2892`) point at the backend and work out of the box.
|
||||
The defaults (`VITE_API_URL=http://127.0.0.1:3000`) point at the backend and work out of the box.
|
||||
|
||||
### 4. Install dependencies and run
|
||||
|
||||
@@ -62,7 +62,7 @@ cd frontend && bun install && cd ..
|
||||
just dev
|
||||
```
|
||||
|
||||
This starts the backend (with auto-reload on file changes) at `http://127.0.0.1:2892` and the frontend at `http://127.0.0.1:5173`.
|
||||
This starts the backend (with auto-reload on file changes) at `http://127.0.0.1:3000` and the frontend at `http://127.0.0.1:5173`.
|
||||
|
||||
## Project docs
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Server
|
||||
HOST=127.0.0.1
|
||||
PORT=2892
|
||||
PORT=3000
|
||||
ALLOW_ORIGINS= # Optional comma-separated allowed CORS origins; empty = permissive
|
||||
|
||||
# Auth
|
||||
@@ -28,5 +28,5 @@ LIVEKIT_API_SECRET=
|
||||
|
||||
# Billing
|
||||
NWC_URL= # Nostr Wallet Connect URL for generating Lightning invoices
|
||||
STRIPE_SECRET_KEY= # Required Stripe API secret key (sk_...)
|
||||
STRIPE_WEBHOOK_SECRET=whsec_test_00000000000000000000000000 # Webhook signing secret (use real value in production)
|
||||
STRIPE_SECRET_KEY= # Stripe API secret key (sk_...)
|
||||
STRIPE_WEBHOOK_SECRET= # Secret for verifying Stripe webhook signatures (whsec_...)
|
||||
|
||||
+1
-1
@@ -26,6 +26,6 @@ WORKDIR /app
|
||||
|
||||
COPY --from=build /app/target/release/backend /app/backend
|
||||
|
||||
EXPOSE 2892
|
||||
EXPOSE 3000
|
||||
|
||||
CMD ["/app/backend"]
|
||||
|
||||
+27
-52
@@ -30,29 +30,27 @@ backend/
|
||||
|
||||
Environment variables:
|
||||
|
||||
| Variable | Description | Default |
|
||||
| ------------------------ | ----------------------------------------------------------------------- | ------------------------------------ |
|
||||
| `DATABASE_URL` | SQLite URL. Relative paths are resolved under `backend/`. | `sqlite://<backend>/data/caravel.db` |
|
||||
| `HOST` | API bind host (also used for NIP-98 `u` host check) | `127.0.0.1` |
|
||||
| `PORT` | API bind port | `2892` |
|
||||
| `ADMINS` | Comma-separated admin pubkeys (hex) | _optional_ |
|
||||
| `ALLOW_ORIGINS` | Comma-separated CORS origins. If empty, CORS is permissive. | _optional_ |
|
||||
| `ZOOID_API_URL` | Zooid API base URL used by infra worker | _required for infra sync_ |
|
||||
| `ZOOID_API_SECRET` | Nostr secret key used for authentication of requests to the zooid API | _required_ |
|
||||
| `RELAY_DOMAIN` | Base domain appended to relay subdomains | empty |
|
||||
| `LIVEKIT_URL` | LiveKit URL sent to zooid when relay livekit is enabled | _optional_ |
|
||||
| `LIVEKIT_API_KEY` | LiveKit API key sent to zooid | _optional_ |
|
||||
| `LIVEKIT_API_SECRET` | LiveKit API secret sent to zooid | _optional_ |
|
||||
| `NWC_URL` | Platform NWC URL used to generate BOLT11 invoices | _required for invoice generation_ |
|
||||
| `STRIPE_SECRET_KEY` | Stripe API secret key used for billing API operations | _required_ |
|
||||
| `STRIPE_WEBHOOK_SECRET` | Stripe webhook signing secret used to verify `Stripe-Signature` headers | _required_ |
|
||||
| `ROBOT_SECRET` | Robot Nostr secret key | _required_ |
|
||||
| `ROBOT_NAME` | Robot display name (kind `0`) | _optional_ |
|
||||
| `ROBOT_DESCRIPTION` | Robot description (kind `0`) | _optional_ |
|
||||
| `ROBOT_PICTURE` | Robot picture URL (kind `0`) | _optional_ |
|
||||
| `ROBOT_OUTBOX_RELAYS` | Comma-separated relays published as kind `10002` | _required_ |
|
||||
| `ROBOT_INDEXER_RELAYS` | Comma-separated relays used for recipient relay discovery | _required_ |
|
||||
| `ROBOT_MESSAGING_RELAYS` | Comma-separated relays published as kind `10050` | _required_ |
|
||||
| Variable | Description | Default |
|
||||
|---|---|---|
|
||||
| `DATABASE_URL` | SQLite URL. Relative paths are resolved under `backend/`. | `sqlite://<backend>/data/caravel.db` |
|
||||
| `HOST` | API bind host (also used for NIP-98 `u` host check) | `127.0.0.1` |
|
||||
| `PORT` | API bind port | `3000` |
|
||||
| `ADMINS` | Comma-separated admin pubkeys (hex) | _optional_ |
|
||||
| `ALLOW_ORIGINS` | Comma-separated CORS origins. If empty, CORS is permissive. | _optional_ |
|
||||
| `ZOOID_API_URL` | Zooid API base URL used by infra worker | _required for infra sync_ |
|
||||
| `ZOOID_API_SECRET` | Nostr secret key used for authentication of requests to the zooid API | _required_ |
|
||||
| `RELAY_DOMAIN` | Base domain appended to relay subdomains | empty |
|
||||
| `LIVEKIT_URL` | LiveKit URL sent to zooid when relay livekit is enabled | _optional_ |
|
||||
| `LIVEKIT_API_KEY` | LiveKit API key sent to zooid | _optional_ |
|
||||
| `LIVEKIT_API_SECRET` | LiveKit API secret sent to zooid | _optional_ |
|
||||
| `NWC_URL` | Platform NWC URL used to generate BOLT11 invoices | _required for invoice generation_ |
|
||||
| `ROBOT_SECRET` | Robot Nostr secret key | _required_ |
|
||||
| `ROBOT_NAME` | Robot display name (kind `0`) | _optional_ |
|
||||
| `ROBOT_DESCRIPTION` | Robot description (kind `0`) | _optional_ |
|
||||
| `ROBOT_PICTURE` | Robot picture URL (kind `0`) | _optional_ |
|
||||
| `ROBOT_OUTBOX_RELAYS` | Comma-separated relays published as kind `10002` | _required_ |
|
||||
| `ROBOT_INDEXER_RELAYS` | Comma-separated relays used for recipient relay discovery | _required_ |
|
||||
| `ROBOT_MESSAGING_RELAYS` | Comma-separated relays published as kind `10050` | _required_ |
|
||||
|
||||
Relay list env vars are comma-separated and trimmed. If a relay has no `ws://` or `wss://` scheme, `wss://` is prepended.
|
||||
|
||||
@@ -62,39 +60,16 @@ See [spec](spec) for more details
|
||||
|
||||
## API Routes
|
||||
|
||||
Most API routes are NIP-98 protected.
|
||||
All routes are NIP-98 protected.
|
||||
|
||||
Public exceptions:
|
||||
|
||||
- `GET /plans`
|
||||
- `GET /plans/:id`
|
||||
- `POST /stripe/webhook` (validated with Stripe signatures)
|
||||
|
||||
- `GET /identity` — get auth identity (`pubkey`, `is_admin`); side-effect-free
|
||||
- `GET /identity` — get auth identity (`pubkey`, `is_admin`)
|
||||
- `GET /tenants` — list tenants (admin)
|
||||
- `POST /tenants` — idempotently ensure a tenant row exists for the current auth pubkey (creates Stripe customer + tenant on first call, returns existing tenant otherwise)
|
||||
- `POST /tenants` — create current auth pubkey as tenant
|
||||
- `GET /tenants/:pubkey` — get tenant (admin or same tenant)
|
||||
- `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)
|
||||
- `PUT /tenants/:pubkey/billing` — update tenant `nwc_url` (admin or same tenant)
|
||||
- `GET /relays` — list relays (`?tenant=<pubkey>` allowed for admin only)
|
||||
- `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)
|
||||
- `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
|
||||
|
||||
Caravel intentionally uses a session-style variant of NIP-98 for client-to-backend API auth.
|
||||
|
||||
- Frontend signs one kind `27235` event with `u = VITE_API_URL` and caches that header for about 10 minutes.
|
||||
- Backend verifies event kind, signature, and that `u` contains configured `HOST`.
|
||||
- Backend intentionally does not bind auth to exact request URL/method/query, and does not enforce payload hash, timestamp freshness window, or replay cache.
|
||||
- Goal: reduce repeated wallet signing prompts and avoid cookie-based sessions.
|
||||
- Tradeoff: this is weaker request-intent binding than strict NIP-98 semantics.
|
||||
- `GET /invoices` — list invoices (`?tenant=<pubkey>` allowed for admin only)
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
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);
|
||||
+9
-34
@@ -9,7 +9,6 @@ Members:
|
||||
- `query: Query`
|
||||
- `command: Command`
|
||||
- `billing: Billing`
|
||||
- `infra: Infra`
|
||||
|
||||
Notes:
|
||||
|
||||
@@ -47,8 +46,9 @@ Notes:
|
||||
|
||||
- Serves `GET /identity`
|
||||
- Authorizes anyone, but must be authorized
|
||||
- Side-effect-free: returns `{ pubkey, is_admin }` only
|
||||
- Clients must call `POST /tenants` before any tenant-scoped write
|
||||
- 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.
|
||||
- Return `data` is an `Identity` struct
|
||||
|
||||
--- Tenant routes
|
||||
@@ -59,18 +59,6 @@ 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`
|
||||
@@ -104,14 +92,6 @@ 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`
|
||||
@@ -126,7 +106,6 @@ 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.
|
||||
@@ -142,7 +121,7 @@ Notes:
|
||||
|
||||
- Serves `POST /relays/:id/deactivate`
|
||||
- Authorizes admin or relay owner
|
||||
- If relay status is `inactive` or `delinquent`, return a `400` with `code=relay-is-inactive`
|
||||
- If relay is already inactive, return a `400` with `code=relay-is-inactive`
|
||||
- Call `command.deactivate_relay`
|
||||
- Return `data` is empty
|
||||
|
||||
@@ -199,18 +178,15 @@ Notes:
|
||||
- Reads raw request body and `Stripe-Signature` header
|
||||
- Calls `billing.handle_webhook(payload, signature)`
|
||||
- Returns `200` on success, `400` on signature verification failure
|
||||
- Startup requires non-empty `STRIPE_WEBHOOK_SECRET`
|
||||
|
||||
--- Utilities
|
||||
|
||||
## `extract_auth_pubkey(&self, headers: &HeaderMap) -> Result<String>`
|
||||
|
||||
- Parses `Authorization` header
|
||||
- Validates event kind (`27235`) and signature using `nostr_sdk`
|
||||
- Validates event `u` contains configured `HOST`
|
||||
- Intentionally does **not** enforce exact request URL/method/query matching
|
||||
- Intentionally does **not** validate `payload` tag/hash, `created_at` freshness window, or replay nonce/cache
|
||||
- This is a deliberate session-style tradeoff to reduce repeated signer prompts in the client
|
||||
- Validates event kind and signature using `nostr_sdk`
|
||||
- Validates event `u` against `HOST` (not the request path. Non-standard, but correct)
|
||||
- Does not validate `method` tag
|
||||
- Returns pubkey if header all checks pass
|
||||
|
||||
Refer to https://github.com/nostr-protocol/nips/blob/master/98.md for details. Use `nostr_sdk` functionality where possible.
|
||||
@@ -226,8 +202,7 @@ Refer to https://github.com/nostr-protocol/nips/blob/master/98.md for details. U
|
||||
## `prepare_relay(&self, relay: Relay) -> anyhow::Result<Relay>`
|
||||
|
||||
- Validate `subdomain`
|
||||
- Validate that `plan` matches a known plan id from `Query::list_plans`
|
||||
- If selected `plan` does not include `blossom` and `blossom` is enabled, return `premium-feature`
|
||||
- If selected `plan` does not include `livekit` and `livekit` is enabled, return `premium-feature`
|
||||
- If `plan` is free and `blossom` is enabled, return `premium-feature`
|
||||
- If `plan` is free and `livekit` is enabled, return `premium-feature`
|
||||
- Populate `schema` if not already set
|
||||
- Populate missing fields using reasonable defaults
|
||||
|
||||
+7
-39
@@ -5,7 +5,6 @@ Billing encapsulates logic related to synchronizing state with Stripe, processin
|
||||
Members:
|
||||
|
||||
- `nwc_url: String` - a nostr wallet connect URL used to **create** bolt11 invoices (i.e. receive payments), from `NWC_URL`
|
||||
- `stripe_secret_key: String` - Stripe API key used for billing API operations, from `STRIPE_SECRET_KEY`
|
||||
- `stripe_webhook_secret: String` - secret for verifying Stripe webhook signatures, from `STRIPE_WEBHOOK_SECRET`
|
||||
- `query: Query`
|
||||
- `command: Command`
|
||||
@@ -14,8 +13,6 @@ Members:
|
||||
## `pub fn new(query: Query, command: Command, robot: Robot) -> Self`
|
||||
|
||||
- Reads environment and populates members
|
||||
- Panics if `STRIPE_SECRET_KEY` is missing/empty
|
||||
- Panics if `STRIPE_WEBHOOK_SECRET` is missing/empty
|
||||
|
||||
## `pub fn start(&self)`
|
||||
|
||||
@@ -26,15 +23,12 @@ 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 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 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 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<()>`
|
||||
@@ -47,7 +41,6 @@ Stripe uses **pay-in-advance** by default: when a subscription is first created,
|
||||
- `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>`
|
||||
@@ -70,37 +63,16 @@ Stripe uses **pay-in-advance** by default: when a subscription is first created,
|
||||
- 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. 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:
|
||||
Attempts to pay a new subscription invoice. 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 here — Stripe will charge automatically for this invoice attempt.
|
||||
2. **Card on file**: If the tenant has a payment method on the Stripe customer, do nothing — Stripe will charge automatically.
|
||||
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.
|
||||
@@ -110,7 +82,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 `delinquent` relays on paid plans for the tenant (relays marked delinquent by the billing system due to non-payment)
|
||||
- Find all `inactive` relays for the tenant that were deactivated due to non-payment (i.e. relays on paid plans that are inactive)
|
||||
- Reactivate each one via `command.activate_relay`
|
||||
|
||||
## `fn handle_invoice_payment_failed(&self, invoice: &Invoice)`
|
||||
@@ -123,7 +95,7 @@ Skip invoices with `amount_due` of 0.
|
||||
## `fn handle_invoice_overdue(&self, invoice: &Invoice)`
|
||||
|
||||
- Look up tenant by `stripe_customer_id`
|
||||
- Mark all active relays on paid plans as delinquent via `command.mark_relay_delinquent` (sets status to `delinquent`, distinct from user-initiated `deactivate_relay`)
|
||||
- Deactivate all active relays on paid plans via `command.deactivate_relay`
|
||||
- Send a DM via `robot.send_dm` notifying the tenant that their paid relays have been deactivated due to non-payment
|
||||
|
||||
## `fn handle_subscription_updated(&self, subscription: &Subscription)`
|
||||
@@ -131,14 +103,10 @@ 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`
|
||||
- Mark all active paid relays as delinquent via `command.mark_relay_delinquent`
|
||||
- Deactivate all active paid relays for the tenant via `command.deactivate_relay`
|
||||
|
||||
## `fn handle_subscription_deleted(&self, subscription: &Subscription)`
|
||||
|
||||
- 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,14 +44,6 @@ 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<()>`
|
||||
|
||||
|
||||
@@ -30,6 +30,5 @@ 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 `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.
|
||||
- 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.
|
||||
|
||||
@@ -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` - one of `active|inactive|delinquent`. Only `active` relays count toward billing. `delinquent` is set by the billing system when a relay's subscription becomes past due; `inactive` is set when a user or admin manually deactivates a relay.
|
||||
- `status` - `active|inactive`. Only `active` relays count toward billing.
|
||||
- `synced` - whether the relay has been successfully synced to zooid at least once.
|
||||
- `sync_error` - a string indicating any errors encountered when synchronizing.
|
||||
- `info_name` - the relay's name
|
||||
@@ -85,8 +85,9 @@ A relay is a nostr relay owned by a `tenant` and hosted by the attached zooid in
|
||||
|
||||
Some attributes persisted to zooid via API have special handling:
|
||||
|
||||
- The relay's `secret` is generated once on relay creation, persisted to the zooid configuration, and isn't stored in the database. Relay updates do not resend `secret`.
|
||||
- The relay's `secret` is generated once and persisted to the zooid configuration but isn't stored in the database.
|
||||
- The relay's `host` is calculated based on `subdomain` + `RELAY_DOMAIN`
|
||||
- The 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.
|
||||
|
||||
|
||||
+74
-259
@@ -1,6 +1,6 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::{Result, anyhow};
|
||||
use anyhow::anyhow;
|
||||
use axum::{
|
||||
Json, Router,
|
||||
extract::{Path, State},
|
||||
@@ -14,7 +14,6 @@ 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,
|
||||
};
|
||||
@@ -28,7 +27,6 @@ pub struct Api {
|
||||
query: Query,
|
||||
command: Command,
|
||||
billing: Billing,
|
||||
infra: Infra,
|
||||
}
|
||||
|
||||
async fn stripe_webhook(
|
||||
@@ -119,7 +117,7 @@ fn map_invoice_lookup_error(error: InvoiceLookupError) -> ApiError {
|
||||
}
|
||||
|
||||
impl Api {
|
||||
pub fn new(query: Query, command: Command, billing: Billing, infra: Infra) -> Self {
|
||||
pub fn new(query: Query, command: Command, billing: Billing) -> Self {
|
||||
let host = std::env::var("HOST").unwrap_or_else(|_| "127.0.0.1".to_string());
|
||||
let admins = std::env::var("ADMINS")
|
||||
.unwrap_or_default()
|
||||
@@ -133,7 +131,6 @@ impl Api {
|
||||
query,
|
||||
command,
|
||||
billing,
|
||||
infra,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -142,16 +139,15 @@ impl Api {
|
||||
api: Arc::new(self),
|
||||
};
|
||||
|
||||
let router = Router::new()
|
||||
Router::new()
|
||||
.route("/identity", get(get_identity))
|
||||
.route("/plans", get(list_plans))
|
||||
.route("/plans/:id", get(get_plan))
|
||||
.route("/tenants", get(list_tenants).post(create_tenant))
|
||||
.route("/tenants", get(list_tenants))
|
||||
.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))
|
||||
@@ -162,9 +158,8 @@ impl Api {
|
||||
"/tenants/:pubkey/stripe/session",
|
||||
get(create_stripe_session),
|
||||
)
|
||||
.route("/stripe/webhook", post(stripe_webhook));
|
||||
|
||||
router.with_state(state)
|
||||
.route("/stripe/webhook", post(stripe_webhook))
|
||||
.with_state(state)
|
||||
}
|
||||
|
||||
fn extract_auth_pubkey(&self, headers: &HeaderMap) -> std::result::Result<String, ApiError> {
|
||||
@@ -214,9 +209,6 @@ impl Api {
|
||||
return Err(ApiError::Unauthorized(anyhow!("missing u tag")));
|
||||
};
|
||||
|
||||
// Intentional session-style variant of NIP-98 for Caravel API auth.
|
||||
// We validate signer identity plus host affinity, and do not bind to exact
|
||||
// request URL/method or maintain replay state here.
|
||||
if !self.host.is_empty() && !got_u.contains(&self.host) {
|
||||
return Err(ApiError::Unauthorized(anyhow!(
|
||||
"authorization host mismatch"
|
||||
@@ -255,24 +247,20 @@ impl Api {
|
||||
}
|
||||
}
|
||||
|
||||
async fn fetch_relay_members(&self, relay: &Relay) -> Result<Vec<String>> {
|
||||
if relay.synced == 0 {
|
||||
return Ok(Vec::new());
|
||||
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"));
|
||||
}
|
||||
|
||||
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(RelayValidationError::PremiumFeature);
|
||||
if relay.plan == "free" && relay.blossom_enabled == 1 {
|
||||
return Err(anyhow!("premium-feature"));
|
||||
}
|
||||
if !plan.livekit && relay.livekit_enabled == 1 {
|
||||
return Err(RelayValidationError::PremiumFeature);
|
||||
if relay.plan == "free" && relay.livekit_enabled == 1 {
|
||||
return Err(anyhow!("premium-feature"));
|
||||
}
|
||||
|
||||
if relay.schema.is_empty() {
|
||||
@@ -285,106 +273,20 @@ impl Api {
|
||||
relay.policy_strip_signatures = parse_bool_default(relay.policy_strip_signatures, 0);
|
||||
relay.groups_enabled = parse_bool_default(relay.groups_enabled, 1);
|
||||
relay.management_enabled = parse_bool_default(relay.management_enabled, 1);
|
||||
relay.blossom_enabled =
|
||||
parse_bool_default(relay.blossom_enabled, if plan.blossom { 1 } else { 0 });
|
||||
relay.livekit_enabled =
|
||||
parse_bool_default(relay.livekit_enabled, if plan.livekit { 1 } else { 0 });
|
||||
relay.blossom_enabled = parse_bool_default(
|
||||
relay.blossom_enabled,
|
||||
if relay.plan == "free" { 0 } else { 1 },
|
||||
);
|
||||
relay.livekit_enabled = parse_bool_default(
|
||||
relay.livekit_enabled,
|
||||
if relay.plan == "free" { 0 } else { 1 },
|
||||
);
|
||||
relay.push_enabled = parse_bool_default(relay.push_enabled, 1);
|
||||
|
||||
Ok(relay)
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
@@ -412,14 +314,6 @@ 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 {
|
||||
@@ -505,17 +399,10 @@ 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(t)) => Ok(ok(StatusCode::OK, t)),
|
||||
Ok(Some(_)) => {}
|
||||
Ok(None) => {
|
||||
let stripe_customer_id = match state.api.billing.stripe_create_customer(&pubkey).await {
|
||||
Ok(id) => id,
|
||||
@@ -539,39 +426,31 @@ async fn create_tenant(
|
||||
};
|
||||
|
||||
match state.api.command.create_tenant(&tenant).await {
|
||||
Ok(()) => Ok(ok(StatusCode::OK, tenant)),
|
||||
Err(e) if matches!(map_unique_error(&e), Some("pubkey-exists")) => {
|
||||
match state.api.query.get_tenant(&pubkey).await {
|
||||
Ok(Some(t)) => Ok(ok(StatusCode::OK, t)),
|
||||
Ok(None) => Ok(err(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"internal",
|
||||
"tenant row missing after unique-constraint race",
|
||||
)),
|
||||
Err(e) => Ok(err(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"internal",
|
||||
&e.to_string(),
|
||||
)),
|
||||
}
|
||||
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(),
|
||||
));
|
||||
}
|
||||
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(),
|
||||
)),
|
||||
}
|
||||
|
||||
Ok(ok(StatusCode::OK, IdentityResponse { pubkey, is_admin }))
|
||||
}
|
||||
|
||||
async fn get_plan(Path(id): Path<String>) -> Response {
|
||||
match Query::get_plan(&id) {
|
||||
match Query::list_plans().into_iter().find(|p| p.id == id) {
|
||||
Some(plan) => ok(StatusCode::OK, plan),
|
||||
None => err(StatusCode::NOT_FOUND, "not-found", "plan not found"),
|
||||
}
|
||||
@@ -681,40 +560,6 @@ 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,
|
||||
@@ -747,8 +592,19 @@ async fn create_relay(
|
||||
|
||||
relay = match state.api.prepare_relay(relay) {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
return Ok(relay_validation_error_response(e));
|
||||
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",
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -794,13 +650,10 @@ 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) = requested_plan.clone() {
|
||||
if let Some(v) = payload.plan {
|
||||
relay.plan = v;
|
||||
}
|
||||
if let Some(v) = payload.info_name {
|
||||
@@ -836,43 +689,22 @@ async fn update_relay(
|
||||
|
||||
relay = match state.api.prepare_relay(relay) {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
return Ok(relay_validation_error_response(e));
|
||||
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",
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
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) => {
|
||||
@@ -1082,29 +914,12 @@ 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(()) => {
|
||||
// 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))
|
||||
}
|
||||
Ok(()) => Ok(ok(StatusCode::OK, tenant)),
|
||||
Err(e) => Ok(err(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"internal",
|
||||
|
||||
+18
-366
@@ -95,9 +95,6 @@ impl Billing {
|
||||
panic!("missing STRIPE_SECRET_KEY environment variable");
|
||||
}
|
||||
let stripe_webhook_secret = std::env::var("STRIPE_WEBHOOK_SECRET").unwrap_or_default();
|
||||
if stripe_webhook_secret.trim().is_empty() {
|
||||
panic!("missing STRIPE_WEBHOOK_SECRET environment variable");
|
||||
}
|
||||
let btc_quote_api_base =
|
||||
std::env::var("BTC_PRICE_API_BASE").unwrap_or_else(|_| COINBASE_SPOT_API.to_string());
|
||||
Self {
|
||||
@@ -157,18 +154,13 @@ impl Billing {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let plan = Query::get_plan(&relay.plan)
|
||||
.ok_or_else(|| anyhow!("unknown relay plan id: {}", relay.plan))?;
|
||||
|
||||
// Free plan: remove subscription item if exists, then clean up
|
||||
if plan.id == "free" {
|
||||
if relay.plan == "free" {
|
||||
if let Some(ref item_id) = relay.stripe_subscription_item_id {
|
||||
self.stripe_delete_subscription_item(item_id).await?;
|
||||
self.command
|
||||
.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(());
|
||||
@@ -187,6 +179,12 @@ impl Billing {
|
||||
}
|
||||
|
||||
// Active relay on a paid plan
|
||||
let plan = Query::list_plans().into_iter().find(|p| p.id == relay.plan);
|
||||
|
||||
let Some(plan) = plan else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let Some(ref stripe_price_id) = plan.stripe_price_id else {
|
||||
return Ok(());
|
||||
};
|
||||
@@ -208,28 +206,8 @@ 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 {
|
||||
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
|
||||
self.stripe_update_subscription_item(existing_item_id, stripe_price_id)
|
||||
.await?
|
||||
} else {
|
||||
self.stripe_create_subscription_item(subscription_id, stripe_price_id)
|
||||
.await?
|
||||
@@ -297,10 +275,6 @@ 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?;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
@@ -468,7 +442,7 @@ impl Billing {
|
||||
|
||||
let relays = self.query.list_relays_for_tenant(&tenant.pubkey).await?;
|
||||
for relay in relays {
|
||||
if relay.status == RELAY_STATUS_ACTIVE && Query::is_paid_plan(&relay.plan) {
|
||||
if relay.status == RELAY_STATUS_ACTIVE && relay.plan != "free" {
|
||||
self.command.mark_relay_delinquent(&relay).await?;
|
||||
}
|
||||
}
|
||||
@@ -503,7 +477,7 @@ impl Billing {
|
||||
|
||||
let relays = self.query.list_relays_for_tenant(&tenant.pubkey).await?;
|
||||
for relay in relays {
|
||||
if relay.status == RELAY_STATUS_ACTIVE && Query::is_paid_plan(&relay.plan) {
|
||||
if relay.status == RELAY_STATUS_ACTIVE && relay.plan != "free" {
|
||||
self.command.mark_relay_delinquent(&relay).await?;
|
||||
}
|
||||
}
|
||||
@@ -518,103 +492,6 @@ 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(
|
||||
@@ -722,93 +599,6 @@ 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
|
||||
@@ -925,48 +715,6 @@ 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"))
|
||||
@@ -1053,7 +801,7 @@ impl Billing {
|
||||
}
|
||||
|
||||
fn should_reactivate_after_payment(relay: &Relay) -> bool {
|
||||
relay.status == RELAY_STATUS_DELINQUENT && Query::is_paid_plan(&relay.plan)
|
||||
relay.status == RELAY_STATUS_DELINQUENT && relay.plan != "free"
|
||||
}
|
||||
|
||||
async fn fetch_btc_spot_price(&self, currency: &str) -> Result<f64> {
|
||||
@@ -1204,8 +952,7 @@ mod tests {
|
||||
use sqlx::SqlitePool;
|
||||
use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions};
|
||||
use std::str::FromStr;
|
||||
use std::sync::OnceLock;
|
||||
use tokio::sync::Mutex;
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
|
||||
fn env_lock() -> &'static Mutex<()> {
|
||||
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
|
||||
@@ -1220,14 +967,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(unused_unsafe)]
|
||||
fn set_stripe_webhook_secret(value: Option<&str>) {
|
||||
match value {
|
||||
Some(v) => unsafe { std::env::set_var("STRIPE_WEBHOOK_SECRET", v) },
|
||||
None => unsafe { std::env::remove_var("STRIPE_WEBHOOK_SECRET") },
|
||||
}
|
||||
}
|
||||
|
||||
struct StripeSecretKeyGuard {
|
||||
previous: Option<String>,
|
||||
}
|
||||
@@ -1246,24 +985,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
struct StripeWebhookSecretGuard {
|
||||
previous: Option<String>,
|
||||
}
|
||||
|
||||
impl StripeWebhookSecretGuard {
|
||||
fn set(value: Option<&str>) -> Self {
|
||||
let previous = std::env::var("STRIPE_WEBHOOK_SECRET").ok();
|
||||
set_stripe_webhook_secret(value);
|
||||
Self { previous }
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for StripeWebhookSecretGuard {
|
||||
fn drop(&mut self) {
|
||||
set_stripe_webhook_secret(self.previous.as_deref());
|
||||
}
|
||||
}
|
||||
|
||||
async fn test_pool() -> SqlitePool {
|
||||
let connect_options = SqliteConnectOptions::from_str("sqlite::memory:")
|
||||
.expect("valid sqlite memory url")
|
||||
@@ -1285,9 +1006,8 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn billing_new_panics_without_stripe_secret_key() {
|
||||
let _lock = env_lock().lock().await;
|
||||
let _secret_env = StripeSecretKeyGuard::set(None);
|
||||
let _webhook_env = StripeWebhookSecretGuard::set(Some("whsec_test_dummy"));
|
||||
let _lock = env_lock().lock().expect("acquire env lock");
|
||||
let _env = StripeSecretKeyGuard::set(None);
|
||||
|
||||
let pool = test_pool().await;
|
||||
let query = Query::new(pool.clone());
|
||||
@@ -1317,76 +1037,9 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn billing_new_panics_without_stripe_webhook_secret() {
|
||||
let _lock = env_lock().lock().await;
|
||||
let _secret_env = StripeSecretKeyGuard::set(Some("sk_test_dummy"));
|
||||
let _webhook_env = StripeWebhookSecretGuard::set(None);
|
||||
|
||||
let pool = test_pool().await;
|
||||
let query = Query::new(pool.clone());
|
||||
let command = Command::new(pool);
|
||||
let robot = Robot::test_stub();
|
||||
|
||||
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||
Billing::new(query, command, robot)
|
||||
}));
|
||||
|
||||
let panic_payload = match result {
|
||||
Ok(_) => panic!("constructor should panic when STRIPE_WEBHOOK_SECRET is missing"),
|
||||
Err(payload) => payload,
|
||||
};
|
||||
let panic_msg = if let Some(msg) = panic_payload.downcast_ref::<&str>() {
|
||||
(*msg).to_string()
|
||||
} else if let Some(msg) = panic_payload.downcast_ref::<String>() {
|
||||
msg.clone()
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
|
||||
assert!(
|
||||
panic_msg.contains("missing STRIPE_WEBHOOK_SECRET environment variable"),
|
||||
"unexpected panic: {panic_msg}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn billing_new_panics_with_blank_stripe_webhook_secret() {
|
||||
let _lock = env_lock().lock().await;
|
||||
let _secret_env = StripeSecretKeyGuard::set(Some("sk_test_dummy"));
|
||||
let _webhook_env = StripeWebhookSecretGuard::set(Some(" "));
|
||||
|
||||
let pool = test_pool().await;
|
||||
let query = Query::new(pool.clone());
|
||||
let command = Command::new(pool);
|
||||
let robot = Robot::test_stub();
|
||||
|
||||
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||
Billing::new(query, command, robot)
|
||||
}));
|
||||
|
||||
let panic_payload = match result {
|
||||
Ok(_) => panic!("constructor should panic when STRIPE_WEBHOOK_SECRET is blank"),
|
||||
Err(payload) => payload,
|
||||
};
|
||||
let panic_msg = if let Some(msg) = panic_payload.downcast_ref::<&str>() {
|
||||
(*msg).to_string()
|
||||
} else if let Some(msg) = panic_payload.downcast_ref::<String>() {
|
||||
msg.clone()
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
|
||||
assert!(
|
||||
panic_msg.contains("missing STRIPE_WEBHOOK_SECRET environment variable"),
|
||||
"unexpected panic: {panic_msg}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn billing_new_accepts_non_empty_stripe_secrets() {
|
||||
let _lock = env_lock().lock().await;
|
||||
let _secret_env = StripeSecretKeyGuard::set(Some("sk_test_dummy"));
|
||||
let _webhook_env = StripeWebhookSecretGuard::set(Some("whsec_test_dummy"));
|
||||
async fn billing_new_accepts_non_empty_stripe_secret_key() {
|
||||
let _lock = env_lock().lock().expect("acquire env lock");
|
||||
let _env = StripeSecretKeyGuard::set(Some("sk_test_dummy"));
|
||||
|
||||
let pool = test_pool().await;
|
||||
let billing = Billing::new(
|
||||
@@ -1396,6 +1049,5 @@ mod tests {
|
||||
);
|
||||
|
||||
assert_eq!(billing.stripe_secret_key, "sk_test_dummy");
|
||||
assert_eq!(billing.stripe_webhook_secret, "whsec_test_dummy");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,7 +32,7 @@ impl Command {
|
||||
.fetch_one(&mut **tx)
|
||||
.await?
|
||||
}
|
||||
_ => anyhow::bail!("unknown resource_type: {resource_type}"),
|
||||
_ => anyhow::bail!("unknown resource_type: {}", resource_type),
|
||||
};
|
||||
|
||||
let id = uuid::Uuid::new_v4().to_string();
|
||||
|
||||
+52
-132
@@ -2,7 +2,7 @@ use anyhow::Result;
|
||||
use nostr_sdk::prelude::*;
|
||||
|
||||
use crate::command::Command;
|
||||
use crate::models::{Activity, RELAY_STATUS_DELINQUENT, RELAY_STATUS_INACTIVE, Relay};
|
||||
use crate::models::{Activity, RELAY_STATUS_DELINQUENT, RELAY_STATUS_INACTIVE};
|
||||
use crate::query::Query;
|
||||
|
||||
#[derive(Clone)]
|
||||
@@ -18,22 +18,14 @@ pub struct Infra {
|
||||
}
|
||||
|
||||
impl Infra {
|
||||
pub fn new(query: Query, command: Command) -> Result<Self> {
|
||||
pub fn new(query: Query, command: Command) -> 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();
|
||||
|
||||
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 {
|
||||
Self {
|
||||
api_url,
|
||||
relay_domain,
|
||||
livekit_url,
|
||||
@@ -42,7 +34,7 @@ impl Infra {
|
||||
api_secret,
|
||||
query,
|
||||
command,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn start(self) {
|
||||
@@ -78,7 +70,7 @@ impl Infra {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn sync_and_report(&self, relay: &Relay, is_new: bool) {
|
||||
async fn sync_and_report(&self, relay: &crate::models::Relay, is_new: bool) {
|
||||
match self.sync_relay(relay, is_new).await {
|
||||
Ok(()) => {
|
||||
tracing::info!(relay = %relay.id, "relay sync succeeded");
|
||||
@@ -104,29 +96,7 @@ impl Infra {
|
||||
Ok(auth)
|
||||
}
|
||||
|
||||
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<()> {
|
||||
async fn sync_relay(&self, relay: &crate::models::Relay, is_new: bool) -> Result<()> {
|
||||
let client = reqwest::Client::new();
|
||||
let base = self.api_url.trim_end_matches('/');
|
||||
|
||||
@@ -136,6 +106,8 @@ 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,
|
||||
@@ -147,114 +119,62 @@ impl Infra {
|
||||
serde_json::json!({ "enabled": false })
|
||||
};
|
||||
|
||||
let body = relay_sync_body(
|
||||
relay,
|
||||
host,
|
||||
livekit,
|
||||
is_new.then(|| Keys::generate().secret_key().to_secret_hex()),
|
||||
);
|
||||
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 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)
|
||||
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?
|
||||
} else {
|
||||
client.patch(&url)
|
||||
let url = format!("{}/relay/{}", base, relay.id);
|
||||
let auth = self.nip98_auth(&url, HttpMethod::PUT).await?;
|
||||
client
|
||||
.put(&url)
|
||||
.header("Authorization", auth)
|
||||
.json(&body)
|
||||
.send()
|
||||
.await?
|
||||
};
|
||||
|
||||
let response = request
|
||||
.header("Authorization", auth)
|
||||
.json(&body)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
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,
|
||||
|
||||
+4
-4
@@ -3,8 +3,8 @@ mod billing;
|
||||
mod command;
|
||||
mod infra;
|
||||
mod models;
|
||||
mod pool;
|
||||
mod query;
|
||||
mod pool;
|
||||
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(), infra.clone());
|
||||
let infra = Infra::new(query.clone(), command.clone());
|
||||
let api = Api::new(query, command, billing.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(2892);
|
||||
.unwrap_or(3000);
|
||||
let origins: Vec<String> = std::env::var("ALLOW_ORIGINS")
|
||||
.unwrap_or_default()
|
||||
.split(',')
|
||||
|
||||
+2
-1
@@ -21,7 +21,8 @@ pub async fn create_pool() -> Result<SqlitePool> {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
|
||||
let connect_options = SqliteConnectOptions::from_str(&database_url)?.create_if_missing(true);
|
||||
let connect_options =
|
||||
SqliteConnectOptions::from_str(&database_url)?.create_if_missing(true);
|
||||
|
||||
let pool = SqlitePoolOptions::new()
|
||||
.max_connections(5)
|
||||
|
||||
+4
-15
@@ -68,16 +68,6 @@ impl Query {
|
||||
]
|
||||
}
|
||||
|
||||
pub fn get_plan(plan_id: &str) -> Option<Plan> {
|
||||
Self::list_plans().into_iter().find(|p| p.id == plan_id)
|
||||
}
|
||||
|
||||
pub fn is_paid_plan(plan_id: &str) -> bool {
|
||||
Self::get_plan(plan_id)
|
||||
.map(|p| p.id != "free")
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
pub async fn list_relays(&self) -> Result<Vec<Relay>> {
|
||||
let rows = sqlx::query_as::<_, Relay>(
|
||||
"SELECT id, tenant, schema, subdomain, plan, stripe_subscription_item_id,
|
||||
@@ -145,14 +135,13 @@ impl Query {
|
||||
}
|
||||
|
||||
pub async fn has_active_paid_relays(&self, tenant_id: &str) -> Result<bool> {
|
||||
let plans = sqlx::query_scalar::<_, String>(
|
||||
"SELECT plan FROM relay WHERE tenant = ? AND status = 'active'",
|
||||
let count = sqlx::query_scalar::<_, i64>(
|
||||
"SELECT COUNT(*) FROM relay WHERE tenant = ? AND status = 'active' AND plan != 'free'",
|
||||
)
|
||||
.bind(tenant_id)
|
||||
.fetch_all(&self.pool)
|
||||
.fetch_one(&self.pool)
|
||||
.await?;
|
||||
|
||||
Ok(plans.into_iter().any(|plan| Self::is_paid_plan(&plan)))
|
||||
Ok(count > 0)
|
||||
}
|
||||
|
||||
pub async fn list_activity_for_relay(&self, relay_id: &str) -> Result<Vec<Activity>> {
|
||||
|
||||
@@ -25,7 +25,7 @@ async fn quote_endpoint_can_be_stubbed_deterministically() {
|
||||
|
||||
assert_eq!(btc_price, 50_000.0);
|
||||
|
||||
let msats =
|
||||
fiat_minor_to_msats_from_quote(100, "USD", btc_price).expect("convert quoted fiat amount");
|
||||
let msats = fiat_minor_to_msats_from_quote(100, "USD", btc_price)
|
||||
.expect("convert quoted fiat amount");
|
||||
assert_eq!(msats, 2_000_000);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# Backend API base URL
|
||||
VITE_API_URL=http://127.0.0.1:2892
|
||||
VITE_API_URL=http://127.0.0.1:3000
|
||||
|
||||
# Platform display name shown in UI
|
||||
VITE_PLATFORM_NAME=Caravel
|
||||
|
||||
+3
-6
@@ -32,7 +32,7 @@ Environment variables (see `.env.template`):
|
||||
|
||||
| Variable | Description | Default |
|
||||
|---|---|---|
|
||||
| `VITE_API_URL` | Backend API base URL | `http://127.0.0.1:2892` |
|
||||
| `VITE_API_URL` | Backend API base URL | `http://127.0.0.1:3000` |
|
||||
|
||||
## Running
|
||||
|
||||
@@ -51,11 +51,8 @@ npm run preview
|
||||
|
||||
## Authentication
|
||||
|
||||
- Tenant requests use an intentional session-style variant of NIP-98:
|
||||
- The client signs one kind `27235` event with `u = VITE_API_URL`.
|
||||
- The resulting `Authorization` header is cached for about 10 minutes to avoid repeated signer prompts.
|
||||
- The backend validates signer identity + host affinity rather than exact URL/method binding per request.
|
||||
- Admin routes require a pubkey listed in `ADMINS` on the backend.
|
||||
- Tenant requests use NIP-98 tokens derived from the logged-in user
|
||||
- Admin routes require a pubkey listed in `PLATFORM_ADMIN_PUBKEYS` on the backend
|
||||
|
||||
## Routes
|
||||
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
<svg xmlns:xlink="http://www.w3.org/1999/xlink" width="220" height="220" viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M0 32C0 14.3269 14.3269 0 32 0V0C49.6731 0 64 14.3269 64 32V32C64 49.6731 49.6731 64 32 64H8C3.58172 64 0 60.4183 0 56V32Z" fill="#6D29D9"></path><path fill-rule="evenodd" clip-rule="evenodd" d="M21 22C19.3431 22 18 23.3431 18 25C18 26.1046 17.1046 27 16 27C14.8954 27 14 26.1046 14 25C14 21.134 17.134 18 21 18C24.866 18 28 21.134 28 25C28 26.1046 27.1046 27 26 27C24.8954 27 24 26.1046 24 25C24 23.3431 22.6569 22 21 22ZM43 22C41.3431 22 40 23.3431 40 25C40 26.1046 39.1046 27 38 27C36.8954 27 36 26.1046 36 25C36 21.134 39.134 18 43 18C46.866 18 50 21.134 50 25C50 26.1046 49.1046 27 48 27C46.8954 27 46 26.1046 46 25C46 23.3431 44.6569 22 43 22Z" fill="#FFFFFF"></path><path d="M32 47C38.6985 47 44.2982 42.2956 45.6755 36.0106C45.9829 34.608 44.5 33.5552 43.1016 33.8813C40.0379 34.5957 35.7213 35.1538 32 35.1538C28.2787 35.1538 23.9621 34.5957 20.8984 33.8813C19.5 33.5552 18.0171 34.608 18.3245 36.0106C19.7018 42.2956 25.3015 47 32 47Z" fill="#FFFFFF"></path></svg>
|
||||
|
Before Width: | Height: | Size: 1.1 KiB |
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 11 KiB |
@@ -1,21 +0,0 @@
|
||||
<svg xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" xmlns="http://www.w3.org/2000/svg" xmlns:svg="http://www.w3.org/2000/svg" width="66.145837" height="66.145837" viewBox="0 0 66.145837 66.145837" version="1.1" id="svg1" inkscape:version="1.4.2 (ebf0e940d0, 2025-05-08)" sodipodi:docname="nostrord.svg">
|
||||
<sodipodi:namedview id="namedview1" pagecolor="#ffffff" bordercolor="#000000" borderopacity="0.25" inkscape:showpageshadow="2" inkscape:pageopacity="0.0" inkscape:pagecheckerboard="0" inkscape:deskcolor="#d1d1d1" inkscape:document-units="mm" inkscape:zoom="2.1971895" inkscape:cx="172.94822" inkscape:cy="184.78151" inkscape:window-width="2048" inkscape:window-height="1083" inkscape:window-x="0" inkscape:window-y="0" inkscape:window-maximized="1" inkscape:current-layer="layer1"/>
|
||||
<defs id="defs1"/>
|
||||
<g inkscape:label="Layer 1" inkscape:groupmode="layer" id="layer1" transform="translate(-80.278336,-100.91589)">
|
||||
<rect x="80.278336" y="100.91589" width="66.145836" height="66.145836" rx="15.434029" fill="#0a0a0a" id="rect1-6-9-7-7-9-8-6" style="stroke-width:1.10243"/>
|
||||
<g id="g32" transform="matrix(0.56473291,0,0,0.56473291,-434.56644,229.88942)">
|
||||
<path d="m 941.12408,-145.50974 c 0,7.28807 10.93211,10.93211 29.15233,10.93211 18.2201,0 29.1523,-3.64404 29.1523,-10.93211 v -32.79632 c 0,-14.57615 -10.9322,-25.50826 -29.1523,-25.50826 -18.22022,0 -29.15233,10.93211 -29.15233,25.50826 z" fill="#fafafa" id="path3-6-2-5-9-7-4-1" style="stroke-width:1.82202"/>
|
||||
<path d="m 948.41215,-171.01799 c 0,-7.28807 10.93211,-10.93211 21.86426,-10.93211 10.9321,0 21.8642,3.64404 21.8642,10.93211 v 7.28807 c 0,7.28807 -10.9321,10.9321 -21.8642,10.9321 -10.93215,0 -21.86426,-3.64403 -21.86426,-10.9321 z" fill="#0a0a0a" id="path4-1-3-6-2-3-5-5" style="stroke-width:1.82202"/>
|
||||
<ellipse cx="959.34424" cy="-167.37396" rx="5.4660549" ry="7.2880735" fill="#fafafa" id="ellipse4-5-7-2-0-6-0-5" style="stroke-width:1.82202"/>
|
||||
<ellipse cx="981.2085" cy="-167.37396" rx="5.4660549" ry="7.2880735" fill="#fafafa" id="ellipse5-5-5-9-2-1-3-4" style="stroke-width:1.82202"/>
|
||||
<g id="g12-2-3-2-6-7" transform="matrix(1.3286857,0,0,1.3286857,15.786725,-131.79559)">
|
||||
<path d="m 740.53601,-40.469391 14,-14 q 3,-2 1,2 l -11,14 q -2,2 -4,1 z" fill="#fafafa" id="path3-9-7-7-9-1-6"/>
|
||||
<path d="m 738.53601,-34.469391 10,-10 q 2,-2 1,1 l -8,11 q -2,2 -3,1 z" fill="#fafafa" id="path4-73-0-5-3-0-5"/>
|
||||
</g>
|
||||
<g id="g12-2-2-9-1-6-6" transform="matrix(-1.3286857,0,0,1.3286857,1924.6621,-131.79559)">
|
||||
<path d="m 740.53601,-40.469391 14,-14 q 3,-2 1,2 l -11,14 q -2,2 -4,1 z" fill="#fafafa" id="path3-9-7-6-2-9-3-9"/>
|
||||
<path d="m 738.53601,-34.469391 10,-10 q 2,-2 1,1 l -8,11 q -2,2 -3,1 z" fill="#fafafa" id="path4-73-0-1-2-4-2-3"/>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 2.9 KiB |
@@ -1,151 +0,0 @@
|
||||
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,6 +3,7 @@ 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"
|
||||
@@ -25,8 +26,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
|
||||
@@ -62,6 +63,7 @@ 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.")
|
||||
@@ -79,6 +81,7 @@ export default function PaymentDialog(props: PaymentDialogProps) {
|
||||
setBolt11Error("")
|
||||
setBolt11("")
|
||||
setQrDataUrl("")
|
||||
setShowSetup(false)
|
||||
props.onClose()
|
||||
}
|
||||
|
||||
@@ -157,15 +160,6 @@ 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>
|
||||
}
|
||||
@@ -178,13 +172,15 @@ 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>
|
||||
<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 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>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
@@ -232,14 +228,7 @@ export default function PaymentDialog(props: PaymentDialogProps) {
|
||||
</Modal>
|
||||
<PaymentSetup
|
||||
open={showPaymentSetup()}
|
||||
onClose={() => {
|
||||
setShowPaymentSetup(false)
|
||||
if (setupSaved()) {
|
||||
setSetupSaved(false)
|
||||
props.onClose()
|
||||
}
|
||||
}}
|
||||
onSaved={() => setSetupSaved(true)}
|
||||
onClose={() => setShowPaymentSetup(false)}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -9,7 +9,6 @@ type Tab = "nwc" | "card"
|
||||
type PaymentSetupProps = {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
onSaved?: () => void
|
||||
}
|
||||
|
||||
export default function PaymentSetup(props: PaymentSetupProps) {
|
||||
@@ -28,7 +27,6 @@ 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 {
|
||||
@@ -66,7 +64,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 once invoices are issued for your relay.</p>
|
||||
<p class="text-sm text-gray-500 mt-1">Choose how you'd like to pay for your relay.</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
@@ -146,7 +144,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. If an invoice is currently due, we will retry collection after card setup.</p>
|
||||
<p class="text-sm text-gray-600">Add a payment card via Stripe to enable automatic billing.</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={openPortal}
|
||||
|
||||
@@ -2,7 +2,6 @@ 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"
|
||||
@@ -52,8 +51,8 @@ type RelayDetailCardProps = {
|
||||
currentMembers?: number
|
||||
showTenant?: boolean
|
||||
editHref?: string
|
||||
onDeactivate?: () => void | Promise<void>
|
||||
onReactivate?: () => void | Promise<void>
|
||||
onDeactivate?: () => void
|
||||
onReactivate?: () => void
|
||||
deactivating?: boolean
|
||||
reactivating?: boolean
|
||||
onTogglePublicJoin?: () => void
|
||||
@@ -77,7 +76,6 @@ 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
|
||||
|
||||
@@ -88,24 +86,6 @@ 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)
|
||||
@@ -117,29 +97,6 @@ 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
|
||||
|
||||
@@ -171,7 +128,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 shrink-0 border border-gray-200" />
|
||||
<img src={r().info_icon} alt="" class="w-14 h-14 rounded-xl object-cover flex-shrink-0 border border-gray-200" />
|
||||
</Show>
|
||||
<div class="min-w-0">
|
||||
<div class="flex items-center gap-3 flex-wrap">
|
||||
@@ -191,7 +148,7 @@ export default function RelayDetailCard(props: RelayDetailCardProps) {
|
||||
</div>
|
||||
|
||||
<Show when={props.editHref && (props.onDeactivate || props.onReactivate)}>
|
||||
<div class="relative shrink-0" ref={menuContainerRef}>
|
||||
<div class="relative flex-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"
|
||||
@@ -220,7 +177,8 @@ 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={() => {
|
||||
openActionDialog("deactivate")
|
||||
setMenuOpen(false)
|
||||
props.onDeactivate?.()
|
||||
}}
|
||||
disabled={props.deactivating}
|
||||
>
|
||||
@@ -232,7 +190,8 @@ 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={() => {
|
||||
openActionDialog("reactivate")
|
||||
setMenuOpen(false)
|
||||
props.onReactivate?.()
|
||||
}}
|
||||
disabled={props.reactivating}
|
||||
>
|
||||
@@ -244,13 +203,6 @@ 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">
|
||||
@@ -387,19 +339,6 @@ 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,7 +1,6 @@
|
||||
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"
|
||||
|
||||
@@ -32,12 +31,6 @@ export default function RelayForm(props: RelayFormProps) {
|
||||
return
|
||||
}
|
||||
|
||||
const subdomainError = validateSubdomainLabel(subdomain())
|
||||
if (subdomainError) {
|
||||
setToastMessage(subdomainError)
|
||||
return
|
||||
}
|
||||
|
||||
setToastMessage("")
|
||||
setSubmitting(true)
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { A } from "@solidjs/router"
|
||||
import { Show } from "solid-js"
|
||||
import type { Relay } from "@/lib/api"
|
||||
|
||||
type RelayListItemProps = {
|
||||
@@ -20,17 +19,7 @@ export default function RelayListItem(props: RelayListItemProps) {
|
||||
<p class="text-xs text-gray-500 break-all mt-1">Tenant: {props.relay.tenant}</p>
|
||||
)}
|
||||
</div>
|
||||
<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>
|
||||
<p class="text-xs uppercase tracking-wide text-gray-500">{props.relay.status}</p>
|
||||
</div>
|
||||
</A>
|
||||
</li>
|
||||
|
||||
@@ -145,8 +145,6 @@ export async function makeAuth(): Promise<string | undefined> {
|
||||
kind: 27235,
|
||||
content: "",
|
||||
created_at: Math.floor(now / 1000),
|
||||
// Intentional session-style auth: sign the API base URL once, then reuse
|
||||
// the header briefly to avoid prompting the signer on every request.
|
||||
tags: [["u", API_URL]],
|
||||
})
|
||||
|
||||
@@ -205,10 +203,6 @@ 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}`)
|
||||
}
|
||||
@@ -241,10 +235,6 @@ 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,6 +2,7 @@ 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,
|
||||
@@ -11,14 +12,12 @@ import {
|
||||
getTenant,
|
||||
listRelayActivity,
|
||||
listRelays,
|
||||
listTenantInvoices,
|
||||
listTenantRelays,
|
||||
listTenants,
|
||||
updateRelay,
|
||||
updateTenant,
|
||||
type Activity,
|
||||
type CreateRelayInput,
|
||||
type Invoice,
|
||||
type Relay,
|
||||
type Tenant,
|
||||
type UpdateRelayInput,
|
||||
@@ -138,12 +137,14 @@ export async function tenantNeedsPaymentSetup(): Promise<boolean> {
|
||||
return !tenant.nwc_url && !tenant.stripe_subscription_id
|
||||
}
|
||||
|
||||
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 async function getRelayMembers(url: string) {
|
||||
const management = new RelayManagement(new NostrRelay(url), account()!.signer)
|
||||
|
||||
try {
|
||||
return await management.listAllowedPubkeys()
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export type { Activity, Invoice, Relay, Tenant }
|
||||
export type { Activity, Relay, Tenant }
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
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, getLatestOpenInvoice, type Relay } from "@/lib/hooks"
|
||||
import { updateRelayById, deactivateRelayById, reactivateRelayById, tenantNeedsPaymentSetup, type Relay } from "@/lib/hooks"
|
||||
import { setToastMessage } from "@/components/Toast"
|
||||
import type { Invoice, PlanId } from "@/lib/api"
|
||||
import type { 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 [pendingInvoice, setPendingInvoice] = createSignal<Invoice | undefined>()
|
||||
const [needsPaymentSetup, setNeedsPaymentSetup] = createSignal(false)
|
||||
|
||||
async function updateRelay(next: Relay, previous: Relay) {
|
||||
mutate(next)
|
||||
@@ -101,8 +101,8 @@ export default function useRelayToggles(
|
||||
}
|
||||
|
||||
if (plan !== "free") {
|
||||
const invoice = await getLatestOpenInvoice()
|
||||
if (invoice) setPendingInvoice(invoice)
|
||||
const needs = await tenantNeedsPaymentSetup()
|
||||
if (needs) setNeedsPaymentSetup(true)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -116,5 +116,5 @@ export default function useRelayToggles(
|
||||
onToggleLivekitSupport: () => toggle("livekit_enabled", relay()?.plan !== "free"),
|
||||
}
|
||||
|
||||
return { busy, handleDeactivate, handleReactivate, handleUpdatePlan, pendingInvoice, clearPendingInvoice: () => setPendingInvoice(undefined), toggles }
|
||||
return { busy, handleDeactivate, handleReactivate, handleUpdatePlan, needsPaymentSetup, clearNeedsPaymentSetup: () => setNeedsPaymentSetup(false), toggles }
|
||||
}
|
||||
|
||||
@@ -8,9 +8,6 @@ import Modal from "@/components/Modal"
|
||||
import Login from "@/views/Login"
|
||||
import { createRelayForActiveTenant } from "@/lib/hooks"
|
||||
import { account } from "@/lib/state"
|
||||
import FlotillaLogo from "@/assets/flotilla-logo.svg"
|
||||
import ChachiLogo from "@/assets/chachi-logo.svg"
|
||||
import NostordLogo from "@/assets/nostord-logo.svg"
|
||||
|
||||
export default function Home() {
|
||||
const navigate = useNavigate()
|
||||
@@ -216,7 +213,7 @@ export default function Home() {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{/* Flotilla */}
|
||||
<a
|
||||
href="https://flotilla.social"
|
||||
@@ -226,7 +223,9 @@ export default function Home() {
|
||||
>
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<img src={FlotillaLogo} alt="Flotilla" class="w-12 h-12 rounded-2xl shadow-md shadow-blue-200" />
|
||||
<div class="w-12 h-12 rounded-2xl bg-gradient-to-br from-blue-500 to-indigo-600 flex items-center justify-center text-white font-bold text-xl shadow-md shadow-blue-200">
|
||||
F
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-lg font-bold text-gray-900 group-hover:text-blue-600 transition-colors">Flotilla</h3>
|
||||
<p class="text-xs text-gray-400">flotilla.social</p>
|
||||
@@ -264,7 +263,9 @@ export default function Home() {
|
||||
>
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<img src={ChachiLogo} alt="Chachi" class="w-12 h-12 rounded-2xl shadow-md shadow-purple-200" />
|
||||
<div class="w-12 h-12 rounded-2xl bg-gradient-to-br from-purple-500 to-pink-500 flex items-center justify-center text-white font-bold text-xl shadow-md shadow-purple-200">
|
||||
C
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-lg font-bold text-gray-900 group-hover:text-purple-600 transition-colors">Chachi</h3>
|
||||
<p class="text-xs text-gray-400">chachi.chat</p>
|
||||
@@ -292,45 +293,6 @@ export default function Home() {
|
||||
</span>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
{/* Nostrord */}
|
||||
<a
|
||||
href="https://nostrord.com/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="group flex flex-col gap-5 rounded-2xl border border-gray-200 bg-white p-8 hover:border-amber-300 hover:shadow-md transition-all"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<img src={NostordLogo} alt="Nostrord" class="w-12 h-12 rounded-2xl shadow-md shadow-amber-200" />
|
||||
<div>
|
||||
<h3 class="text-lg font-bold text-gray-900 group-hover:text-amber-600 transition-colors">Nostrord</h3>
|
||||
<p class="text-xs text-gray-400">nostrord.com</p>
|
||||
</div>
|
||||
</div>
|
||||
<span class="text-gray-300 group-hover:text-amber-400 transition-colors mt-1">
|
||||
<ExternalLinkIcon />
|
||||
</span>
|
||||
</div>
|
||||
<p class="text-sm text-gray-600 leading-relaxed">
|
||||
A NIP-29 client built for decentralized group chat on Nostr. Create
|
||||
censorship-resistant communities with admin roles, moderation, and access
|
||||
control—all powered by your relay.
|
||||
</p>
|
||||
<div class="space-y-2">
|
||||
{["Decentralized group chat with NIP-29", "Censorship-resistant communities", "Admin roles & moderation"].map(f => (
|
||||
<div class="flex items-start gap-2 text-sm text-gray-600">
|
||||
<CheckIcon />
|
||||
{f}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div class="mt-auto pt-2">
|
||||
<span class="inline-flex items-center gap-1.5 text-sm font-semibold text-amber-600">
|
||||
Visit nostrord.com <ExternalLinkIcon />
|
||||
</span>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -376,7 +338,6 @@ export default function Home() {
|
||||
<div class="flex gap-4">
|
||||
<a href="https://flotilla.social" target="_blank" rel="noopener noreferrer" class="hover:text-gray-600 transition-colors">Flotilla</a>
|
||||
<a href="https://chachi.chat" target="_blank" rel="noopener noreferrer" class="hover:text-gray-600 transition-colors">Chachi</a>
|
||||
<a href="https://nostrord.com/" target="_blank" rel="noopener noreferrer" class="hover:text-gray-600 transition-colors">Nostrord</a>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import { useParams } from "@solidjs/router"
|
||||
import { createResource, Show } from "solid-js"
|
||||
import { 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"
|
||||
|
||||
@@ -14,15 +13,6 @@ 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 })
|
||||
@@ -36,7 +26,6 @@ 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,59 +1,27 @@
|
||||
import { useParams } from "@solidjs/router"
|
||||
import { createMemo, createResource, createSignal, Show } from "solid-js"
|
||||
import { createMemo, createResource, 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 { listRelayMembers } from "@/lib/api"
|
||||
import { getLatestOpenInvoice, useRelay, useRelayActivity, useTenant } from "@/lib/hooks"
|
||||
import { getRelayMembers, useRelay, useRelayActivity } 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 [members] = createResource(relayId, async (id) => {
|
||||
if (!id) return []
|
||||
|
||||
try {
|
||||
return (await listRelayMembers(id)).members
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
const relayUrl = createMemo(() => {
|
||||
const subdomain = relay()?.subdomain
|
||||
return subdomain ? `wss://${subdomain}.spaces.coracle.social` : undefined
|
||||
})
|
||||
const [members] = createResource(relayUrl, getRelayMembers)
|
||||
const loading = useMinLoading(() => relay.loading && !relay())
|
||||
const [activity] = useRelayActivity(relayId)
|
||||
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
|
||||
})
|
||||
const { busy, handleDeactivate, handleReactivate, handleUpdatePlan, needsPaymentSetup, clearNeedsPaymentSetup, toggles } = useRelayToggles(relayId, relay, { refetch, mutate })
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
@@ -62,47 +30,9 @@ 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}
|
||||
@@ -115,37 +45,9 @@ 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={paymentSetupOpen()}
|
||||
onClose={() => {
|
||||
setPaymentSetupOpen(false)
|
||||
void refetchTenant()
|
||||
}}
|
||||
open={needsPaymentSetup()}
|
||||
onClose={clearNeedsPaymentSetup}
|
||||
/>
|
||||
</PageContainer>
|
||||
)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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"
|
||||
@@ -17,7 +18,7 @@ export default function RelayEdit(props: { basePath?: string; title?: string })
|
||||
|
||||
async function handleSubmit(values: RelayFormValues) {
|
||||
await updateRelayById(relayId(), {
|
||||
subdomain: values.subdomain,
|
||||
subdomain: slugify(values.subdomain),
|
||||
info_name: values.info_name.trim(),
|
||||
info_icon: values.info_icon.trim(),
|
||||
info_description: values.info_description.trim(),
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
import { createSignal, Show } from "solid-js"
|
||||
import { createSignal } from "solid-js"
|
||||
import { useNavigate } from "@solidjs/router"
|
||||
import BackLink from "@/components/BackLink"
|
||||
import PageContainer from "@/components/PageContainer"
|
||||
import PaymentDialog from "@/components/PaymentDialog"
|
||||
import PaymentSetup from "@/components/PaymentSetup"
|
||||
import RelayForm, { type RelayFormValues } from "@/components/RelayForm"
|
||||
import { createRelayForActiveTenant, getLatestOpenInvoice } from "@/lib/hooks"
|
||||
import type { Invoice } from "@/lib/api"
|
||||
import { createRelayForActiveTenant, tenantNeedsPaymentSetup } from "@/lib/hooks"
|
||||
|
||||
export default function RelayNew() {
|
||||
const navigate = useNavigate()
|
||||
const [pendingInvoice, setPendingInvoice] = createSignal<Invoice | undefined>()
|
||||
const [showPaymentSetup, setShowPaymentSetup] = createSignal(false)
|
||||
let createdRelayId = ""
|
||||
|
||||
async function handleSubmit(values: RelayFormValues) {
|
||||
@@ -17,9 +16,9 @@ export default function RelayNew() {
|
||||
createdRelayId = relay.id
|
||||
|
||||
if (values.plan !== "free") {
|
||||
const invoice = await getLatestOpenInvoice()
|
||||
if (invoice) {
|
||||
setPendingInvoice(invoice)
|
||||
const needs = await tenantNeedsPaymentSetup()
|
||||
if (needs) {
|
||||
setShowPaymentSetup(true)
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -28,7 +27,7 @@ export default function RelayNew() {
|
||||
}
|
||||
|
||||
function handleDialogClose() {
|
||||
setPendingInvoice(undefined)
|
||||
setShowPaymentSetup(false)
|
||||
navigate(`/relays/${createdRelayId}`)
|
||||
}
|
||||
|
||||
@@ -42,15 +41,10 @@ export default function RelayNew() {
|
||||
submitLabel="Create Relay"
|
||||
submittingLabel="Creating..."
|
||||
/>
|
||||
<Show when={pendingInvoice()}>
|
||||
{(inv) => (
|
||||
<PaymentDialog
|
||||
invoice={inv()}
|
||||
open={true}
|
||||
onClose={handleDialogClose}
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
<PaymentSetup
|
||||
open={showPaymentSetup()}
|
||||
onClose={handleDialogClose}
|
||||
/>
|
||||
</PageContainer>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ 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']
|
||||
@@ -70,12 +69,6 @@ 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?.()
|
||||
}
|
||||
|
||||
|
||||
+7
-14
@@ -1,20 +1,13 @@
|
||||
import { defineConfig, loadEnv } from 'vite'
|
||||
import { defineConfig } from 'vite'
|
||||
import solid from 'vite-plugin-solid'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
import { fileURLToPath, URL } from 'node:url'
|
||||
|
||||
export default defineConfig(({mode}) => {
|
||||
const env = loadEnv(mode, process.cwd(), '')
|
||||
|
||||
return {
|
||||
plugins: [tailwindcss(), solid()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||||
},
|
||||
export default defineConfig({
|
||||
plugins: [tailwindcss(), solid()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||||
},
|
||||
server: {
|
||||
port: Number(env.VITE_PORT) || 5173,
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user