Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 827c65c707 |
@@ -1,59 +0,0 @@
|
||||
name: Docker
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [master]
|
||||
|
||||
env:
|
||||
REGISTRY: gitea.coracle.social
|
||||
|
||||
jobs:
|
||||
build-and-push-image:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
- component: frontend
|
||||
image: coracle/caravel-frontend
|
||||
context: frontend
|
||||
- component: backend
|
||||
image: coracle/caravel-backend
|
||||
context: backend
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Log in to the Container registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: hodlbod
|
||||
password: ${{ secrets.PACKAGE_TOKEN }}
|
||||
|
||||
- name: Extract metadata (tags, labels) for Docker
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ matrix.image }}
|
||||
tags: |
|
||||
type=raw,value=latest,enable=${{ github.ref == 'refs/heads/master' }}
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
with:
|
||||
driver: docker-container
|
||||
|
||||
- name: Build and push Docker image
|
||||
id: push
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: ${{ matrix.context }}
|
||||
push: true
|
||||
platforms: linux/amd64,linux/arm64
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
@@ -1,5 +1,4 @@
|
||||
ref
|
||||
todo.md
|
||||
node_modules
|
||||
target
|
||||
data
|
||||
|
||||
@@ -26,7 +26,7 @@ docker run -it \
|
||||
-v ./config:/app/config \
|
||||
-v ./media:/app/media \
|
||||
-v ./data:/app/data \
|
||||
gitea.coracle.social/coracle/zooid
|
||||
ghcr.io/coracle-social/zooid
|
||||
```
|
||||
|
||||
### 2. Configure the backend
|
||||
|
||||
+15
-19
@@ -1,36 +1,32 @@
|
||||
# Server
|
||||
SERVER_HOST=127.0.0.1
|
||||
SERVER_PORT=2892
|
||||
SERVER_ALLOW_ORIGINS=http://localhost:5173,http://127.0.0.1:5173
|
||||
SERVER_ADMIN_PUBKEYS=
|
||||
APP_URL=http://localhost:5173
|
||||
HOST=127.0.0.1
|
||||
PORT=2892
|
||||
ALLOW_ORIGINS= # Optional comma-separated allowed CORS origins; empty = permissive
|
||||
|
||||
# Auth
|
||||
ADMINS= # Comma-separated hex pubkeys with admin access
|
||||
|
||||
# Database
|
||||
DATABASE_URL=sqlite://data/caravel.db
|
||||
|
||||
# Robot identity
|
||||
ROBOT_SECRET=
|
||||
# Robot identity (published as kind 0)
|
||||
ROBOT_SECRET= # Nostr private key (hex)
|
||||
ROBOT_NAME=
|
||||
ROBOT_DESCRIPTION=
|
||||
ROBOT_PICTURE=
|
||||
ROBOT_WALLET=
|
||||
ROBOT_OUTBOX_RELAYS=wss://relay.damus.io,wss://relay.primal.net,wss://nos.lol
|
||||
ROBOT_INDEXER_RELAYS=wss://purplepag.es,wss://relay.damus.io,wss://indexer.coracle.social
|
||||
ROBOT_MESSAGING_RELAYS=wss://auth.nostr1.com,wss://relay.keychat.io,wss://relay.ditto.pub
|
||||
ROBOT_OUTBOX_RELAYS=relay.damus.io,relay.primal.net,nos.lol
|
||||
ROBOT_INDEXER_RELAYS=purplepag.es,relay.damus.io,indexer.coracle.social
|
||||
ROBOT_MESSAGING_RELAYS=auth.nostr1.com,relay.keychat.io,relay.ditto.pub
|
||||
|
||||
# Zooid
|
||||
ZOOID_API_URL=http://127.0.0.1:3334
|
||||
ZOOID_API_SECRET=
|
||||
RELAY_DOMAIN=spaces.coracle.social
|
||||
LIVEKIT_URL=
|
||||
LIVEKIT_API_KEY=
|
||||
LIVEKIT_API_SECRET=
|
||||
|
||||
# Blossom S3
|
||||
BLOSSOM_S3_ENDPOINT=
|
||||
BLOSSOM_S3_REGION=
|
||||
BLOSSOM_S3_BUCKET=
|
||||
BLOSSOM_S3_ACCESS_KEY=
|
||||
BLOSSOM_S3_SECRET_KEY=
|
||||
|
||||
# Billing
|
||||
STRIPE_SECRET_KEY=
|
||||
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)
|
||||
|
||||
Generated
-13
@@ -210,7 +210,6 @@ dependencies = [
|
||||
"nostr-sdk",
|
||||
"nwc",
|
||||
"rand 0.8.5",
|
||||
"regex",
|
||||
"reqwest",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -1895,18 +1894,6 @@ dependencies = [
|
||||
"bitflags",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "regex"
|
||||
version = "1.12.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276"
|
||||
dependencies = [
|
||||
"aho-corasick",
|
||||
"memchr",
|
||||
"regex-automata",
|
||||
"regex-syntax",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "regex-automata"
|
||||
version = "0.4.14"
|
||||
|
||||
@@ -24,7 +24,6 @@ hmac = "0.12"
|
||||
sha2 = "0.10"
|
||||
dotenvy = "0.15.7"
|
||||
base64 = "0.22"
|
||||
regex = "1"
|
||||
|
||||
[dev-dependencies]
|
||||
tower = { version = "0.5", features = ["util"] }
|
||||
|
||||
+37
-81
@@ -8,7 +8,7 @@ Rust backend for Caravel. It manages tenants, relays, invoices, and background w
|
||||
- Axum (HTTP API)
|
||||
- SQLx + SQLite
|
||||
- Tokio (async runtime + workers)
|
||||
- Nostr SDK (NIP-98 auth, NIP-17 DMs, NIP-47 wallet connect, NIP-44 encryption at rest)
|
||||
- Nostr SDK (NIP-98 auth, NIP-17 DMs, NIP-47 wallet connect)
|
||||
|
||||
## Layout
|
||||
|
||||
@@ -16,82 +16,45 @@ Rust backend for Caravel. It manages tenants, relays, invoices, and background w
|
||||
backend/
|
||||
migrations/
|
||||
0001_init.sql
|
||||
spec/ # Module-by-module design notes
|
||||
src/
|
||||
main.rs # App bootstrap: load Env, build services, serve + spawn workers
|
||||
env.rs # Configuration from the environment (+ NIP-44 encryption, NIP-98 signing)
|
||||
api.rs # Shared Api state, router, NIP-98 auth + authorization helpers
|
||||
web.rs # HTTP response envelope + helpers
|
||||
routes/ # HTTP route handlers (identity, plans, tenants, relays, invoices, stripe)
|
||||
models.rs # Domain models + sqlite rows
|
||||
query.rs # Database reads
|
||||
command.rs # Database writes + activity broadcast
|
||||
pool.rs # SQLite pool + migrations
|
||||
billing.rs # Stripe subscription reconciliation + Lightning collection worker
|
||||
stripe.rs # Thin Stripe REST client
|
||||
wallet.rs # NWC wallet handle (NIP-47)
|
||||
bitcoin.rs # Fiat ↔ BTC/msats conversion
|
||||
infra.rs # Zooid relay-sync worker
|
||||
robot.rs # Nostr robot identity + DM sending
|
||||
api.rs # Axum routes + NIP-98 auth checks
|
||||
billing.rs # Invoice generation + collection worker
|
||||
infra.rs # Zooid sync worker
|
||||
main.rs # App bootstrap
|
||||
models.rs # DB models
|
||||
repo.rs # Data access layer
|
||||
robot.rs # Nostr robot identity + DM sending
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
All configuration is read from the environment by `Env::load()` at startup. **Every variable below is required**: `Env::load()` panics if any is missing or blank, and comma-separated lists must contain at least one entry. Copy `.env.template` to `.env` to get started.
|
||||
Environment variables:
|
||||
|
||||
**Server**
|
||||
| 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 |
|
||||
| ---------------------- | ------------------------------------------------------------------- |
|
||||
| `SERVER_HOST` | API bind host; also the value the NIP-98 `u` tag must contain |
|
||||
| `SERVER_PORT` | API bind port |
|
||||
| `SERVER_ADMIN_PUBKEYS` | Comma-separated admin pubkeys (hex) |
|
||||
| `SERVER_ALLOW_ORIGINS` | Comma-separated allowed CORS origins |
|
||||
| `DATABASE_URL` | SQLite URL; relative `sqlite://` paths are resolved under `backend/` |
|
||||
|
||||
**Robot identity**
|
||||
|
||||
| Variable | Description |
|
||||
| ------------------------ | -------------------------------------------------------------------------------------------------- |
|
||||
| `ROBOT_SECRET` | Robot Nostr secret key; used for signing, NIP-44 encryption of stored NWC URLs, and NIP-98 auth |
|
||||
| `ROBOT_NAME` | Robot display name (kind `0`) |
|
||||
| `ROBOT_DESCRIPTION` | Robot description (kind `0`) |
|
||||
| `ROBOT_PICTURE` | Robot picture URL (kind `0`) |
|
||||
| `ROBOT_WALLET` | System NWC URL used to issue and look up BOLT11 invoices |
|
||||
| `ROBOT_OUTBOX_RELAYS` | Comma-separated relays the robot publishes its profile and kind `10002` relay list to |
|
||||
| `ROBOT_INDEXER_RELAYS` | Comma-separated relays used for recipient relay/profile discovery |
|
||||
| `ROBOT_MESSAGING_RELAYS` | Comma-separated DM relays published as kind `10050` |
|
||||
|
||||
**Relay hosting (zooid / livekit)**
|
||||
|
||||
| Variable | Description |
|
||||
| -------------------- | ------------------------------------------------------- |
|
||||
| `ZOOID_API_URL` | Zooid API base URL used by the infra sync worker |
|
||||
| `RELAY_DOMAIN` | Base domain appended to relay subdomains |
|
||||
| `LIVEKIT_URL` | LiveKit URL sent to zooid when a relay enables livekit |
|
||||
| `LIVEKIT_API_KEY` | LiveKit API key sent to zooid |
|
||||
| `LIVEKIT_API_SECRET` | LiveKit API secret sent to zooid |
|
||||
|
||||
**Blossom S3** — sent to zooid as the S3 adapter config (with `key_prefix` = relay id) when a relay enables blossom.
|
||||
|
||||
| Variable | Description |
|
||||
| ----------------------- | --------------------- |
|
||||
| `BLOSSOM_S3_ENDPOINT` | S3 endpoint URL |
|
||||
| `BLOSSOM_S3_REGION` | S3 region |
|
||||
| `BLOSSOM_S3_BUCKET` | S3 bucket name |
|
||||
| `BLOSSOM_S3_ACCESS_KEY` | S3 access key ID |
|
||||
| `BLOSSOM_S3_SECRET_KEY` | S3 secret access key |
|
||||
|
||||
**Billing (Stripe)**
|
||||
|
||||
| Variable | Description |
|
||||
| ----------------------- | ----------------------------------------------------------------------- |
|
||||
| `STRIPE_SECRET_KEY` | Stripe API secret key used for billing API operations |
|
||||
| `STRIPE_WEBHOOK_SECRET` | Stripe webhook signing secret used to verify `Stripe-Signature` headers |
|
||||
| `STRIPE_PRICE_BASIC` | Stripe price ID (`price_...`) backing the Basic plan |
|
||||
| `STRIPE_PRICE_GROWTH` | Stripe price ID (`price_...`) backing the Growth plan |
|
||||
|
||||
Comma-separated list variables are split on commas and trimmed; empty entries are dropped.
|
||||
Relay list env vars are comma-separated and trimmed. If a relay has no `ws://` or `wss://` scheme, `wss://` is prepended.
|
||||
|
||||
## Schema and Architecture
|
||||
|
||||
@@ -111,27 +74,20 @@ Public exceptions:
|
||||
- `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)
|
||||
- `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)
|
||||
- `GET /invoices` — list invoices (`?tenant=<pubkey>` allowed for admin only)
|
||||
|
||||
## API Auth Model
|
||||
|
||||
Caravel intentionally uses a session-style variant of NIP-98 for client-to-backend API auth.
|
||||
|
||||
- Frontend signs one kind `27235` event with `u = VITE_API_URL` and caches that header for about 10 minutes.
|
||||
- Backend verifies event kind, signature, and that `u` contains configured `SERVER_HOST`.
|
||||
- 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.
|
||||
|
||||
@@ -4,9 +4,7 @@ CREATE TABLE IF NOT EXISTS activity (
|
||||
created_at INTEGER NOT NULL,
|
||||
activity_type TEXT NOT NULL,
|
||||
resource_type TEXT NOT NULL,
|
||||
resource_id TEXT NOT NULL,
|
||||
billed_at INTEGER,
|
||||
plan_id TEXT
|
||||
resource_id TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS tenant (
|
||||
@@ -14,16 +12,18 @@ CREATE TABLE IF NOT EXISTS tenant (
|
||||
nwc_url TEXT NOT NULL DEFAULT '',
|
||||
nwc_error TEXT,
|
||||
created_at INTEGER NOT NULL,
|
||||
billing_anchor INTEGER,
|
||||
stripe_customer_id TEXT NOT NULL,
|
||||
renewed_at INTEGER
|
||||
stripe_subscription_id TEXT,
|
||||
past_due_at INTEGER
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS relay (
|
||||
id TEXT PRIMARY KEY,
|
||||
tenant TEXT NOT NULL,
|
||||
schema TEXT NOT NULL,
|
||||
subdomain TEXT NOT NULL UNIQUE,
|
||||
plan TEXT NOT NULL,
|
||||
stripe_subscription_item_id TEXT,
|
||||
status TEXT NOT NULL,
|
||||
synced INTEGER NOT NULL DEFAULT 0,
|
||||
sync_error TEXT NOT NULL DEFAULT '',
|
||||
@@ -39,65 +39,3 @@ CREATE TABLE IF NOT EXISTS relay (
|
||||
push_enabled INTEGER NOT NULL DEFAULT 1,
|
||||
FOREIGN KEY (tenant) REFERENCES tenant(pubkey)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS invoice (
|
||||
id TEXT PRIMARY KEY,
|
||||
tenant_pubkey TEXT NOT NULL,
|
||||
status TEXT NOT NULL CHECK (status IN ('open','paid','void','churn')),
|
||||
method TEXT CHECK (method IS NULL OR method IN ('nwc','stripe','oob')),
|
||||
period_start INTEGER NOT NULL,
|
||||
period_end INTEGER NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
FOREIGN KEY (tenant_pubkey) REFERENCES tenant(pubkey)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS invoice_item (
|
||||
id TEXT PRIMARY KEY,
|
||||
invoice_id TEXT,
|
||||
activity_id TEXT,
|
||||
tenant_pubkey TEXT NOT NULL,
|
||||
relay_id TEXT NOT NULL,
|
||||
plan TEXT NOT NULL,
|
||||
amount INTEGER NOT NULL,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
created_at INTEGER NOT NULL,
|
||||
FOREIGN KEY (invoice_id) REFERENCES invoice(id),
|
||||
FOREIGN KEY (tenant_pubkey) REFERENCES tenant(pubkey)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS bolt11 (
|
||||
id TEXT PRIMARY KEY,
|
||||
invoice_id TEXT NOT NULL,
|
||||
lnbc TEXT NOT NULL,
|
||||
msats INTEGER NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
expires_at INTEGER NOT NULL,
|
||||
settled_at INTEGER,
|
||||
FOREIGN KEY (invoice_id) REFERENCES invoice(id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS intent (
|
||||
id TEXT PRIMARY KEY,
|
||||
invoice_id TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
FOREIGN KEY (invoice_id) REFERENCES invoice(id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_relay_tenant ON relay (tenant);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_activity_tenant_created ON activity (tenant, created_at);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_activity_resource_created ON activity (resource_id, created_at);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_activity_unbilled ON activity (tenant, created_at) WHERE billed_at IS NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_invoice_tenant_created ON invoice (tenant_pubkey, created_at);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_invoice_item_invoice ON invoice_item (invoice_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_invoice_item_outstanding ON invoice_item (tenant_pubkey) WHERE invoice_id IS NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_bolt11_invoice_created ON bolt11 (invoice_id, created_at);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_intent_invoice ON intent (invoice_id);
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
CREATE INDEX IF NOT EXISTS idx_tenant_stripe_customer_id
|
||||
ON tenant (stripe_customer_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_relay_tenant_id
|
||||
ON relay (tenant, id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_relay_tenant_status_plan
|
||||
ON relay (tenant, status, plan);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_activity_resource_type_resource_id_created_at_id
|
||||
ON activity (resource_type, resource_id, created_at DESC, id DESC);
|
||||
@@ -0,0 +1,223 @@
|
||||
# `pub struct Api`
|
||||
|
||||
Api manages the HTTP interface for the application
|
||||
|
||||
Members:
|
||||
|
||||
- `host: String` - the hostname of the service for checking NIP 98 auth, from `HOST`
|
||||
- `admins: Vec<String>` - a list of admin pubkeys from `ADMINS`
|
||||
- `query: Query`
|
||||
- `command: Command`
|
||||
- `billing: Billing`
|
||||
|
||||
Notes:
|
||||
|
||||
- Authentication is done using NIP 98 comparing `u` to `self.host`, not the incoming request
|
||||
- Each route is responsible for authorization using `self.require_admin` or `self.require_admin_or_tenant`
|
||||
- Successful API responses should be of the form `{data, code: "ok"}` with an appropriate http status code.
|
||||
- Unsuccessful API responses should be of the form `{error, code}` with an appropriate http status code. `code` is a short error code (e.g. `duplicate-subdomain`) and `error` is a human-readable error message.
|
||||
|
||||
## `pub fn new() -> Self`
|
||||
|
||||
- Reads environment and populates members
|
||||
|
||||
## `pub fn router(&self) -> Result<()>`
|
||||
|
||||
- Returns an `axum::Router`
|
||||
|
||||
--- Plan routes
|
||||
|
||||
## `async fn list_plans(...) -> Response`
|
||||
|
||||
- Serves `GET /plans`
|
||||
- No authentication required
|
||||
- Return `data` is a list of plan structs from `Query::list_plans`
|
||||
|
||||
## `async fn get_plan(...) -> Response`
|
||||
|
||||
- Serves `GET /plans/:id`
|
||||
- No authentication required
|
||||
- Return `data` is a single plan struct matching `id`
|
||||
- If plan does not exist, return `404` with `code=not-found`
|
||||
|
||||
--- Identity routes
|
||||
|
||||
## `async fn get_identity(...) -> Response`
|
||||
|
||||
- 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
|
||||
- Return `data` is an `Identity` struct
|
||||
|
||||
--- Tenant routes
|
||||
|
||||
## `async fn list_tenants(...) -> Response`
|
||||
|
||||
- Serves `GET /tenants`
|
||||
- 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`
|
||||
- Authorizes admin or matching tenant
|
||||
- Return `data` is a single tenant struct from `query.get_tenant`
|
||||
|
||||
## `async fn update_tenant(...) -> Response`
|
||||
|
||||
- Serves `PUT /tenants/:pubkey`
|
||||
- Authorizes admin or matching tenant
|
||||
- Updates tenant using `command.update_tenant`
|
||||
- Return `data` is the updated tenant struct
|
||||
|
||||
## `async fn list_tenant_relays(...) -> Response`
|
||||
|
||||
- Serves `GET /tenants/:pubkey/relays`
|
||||
- Authorizes admin or matching tenant
|
||||
- Return `data` is a list of relay structs from `query.list_relays_for_tenant`
|
||||
|
||||
--- Relay routes
|
||||
|
||||
## `async fn list_relays(...) -> Response`
|
||||
|
||||
- Serves `GET /relays`
|
||||
- Authorizes admin only
|
||||
- Return `data` is a list of relay structs from `query.list_relays`
|
||||
|
||||
## `async fn get_relay(...) -> Response`
|
||||
|
||||
- Serves `GET /relays/:id`
|
||||
- Authorizes admin or relay owner
|
||||
- Return `data` is a single relay struct from `query.get_relay`
|
||||
|
||||
## `async fn create_relay(...) -> Response`
|
||||
|
||||
- Serves `POST /relays`
|
||||
- Authorizes admin or matching tenant pubkey in request body
|
||||
- Validates/prepares the relay data to be saved using `prepare_relay`
|
||||
- Creates a new relay using `command.create_relay`
|
||||
- If relay is a duplicate by subdomain, return a `422` with `code=subdomain-exists`
|
||||
- Return `data` is a single relay struct. Use HTTP `201`.
|
||||
|
||||
## `async fn update_relay(...) -> Response`
|
||||
|
||||
- Serves `PUT /relays/:id`
|
||||
- Authorizes admin or relay owner
|
||||
- Validates/prepares the relay data to be saved using `prepare_relay`
|
||||
- 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.
|
||||
|
||||
## `async fn list_relay_activity(...) -> Response`
|
||||
|
||||
- Serves `GET /relays/:id/activity`
|
||||
- Authorizes admin or relay owner
|
||||
- Get activity from `query.list_activity_for_relay`
|
||||
- Return `data` is `{activity}`
|
||||
|
||||
## `async fn deactivate_relay(...) -> Response`
|
||||
|
||||
- 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`
|
||||
- Call `command.deactivate_relay`
|
||||
- Return `data` is empty
|
||||
|
||||
## `async fn reactivate_relay(...) -> Response`
|
||||
|
||||
- Serves `POST /relays/:id/reactivate`
|
||||
- Authorizes admin or relay owner
|
||||
- If relay is already active, return a `400` with `code=relay-is-active`
|
||||
- Call `command.activate_relay`
|
||||
- Return `data` is empty
|
||||
|
||||
--- Invoice routes
|
||||
|
||||
## `async fn list_tenant_invoices(...) -> Response`
|
||||
|
||||
- Serves `GET /tenants/:pubkey/invoices`
|
||||
- Authorizes admin or matching tenant
|
||||
- Looks up tenant by pubkey, fetches invoices from Stripe API using `stripe_customer_id`
|
||||
- Return `data` is a list of Stripe invoice objects: `{ id, status, amount_due, currency, hosted_invoice_url, period_start, period_end }`
|
||||
|
||||
## `async fn get_invoice(...) -> Response`
|
||||
|
||||
- Serves `GET /invoices/:id`
|
||||
- Fetches invoice from Stripe API by ID
|
||||
- Looks up tenant by the invoice's `customer` field, authorizes admin or matching tenant
|
||||
- Return `data` is a single Stripe invoice object
|
||||
- If invoice does not exist, return `404` with `code=not-found`
|
||||
|
||||
## `async fn get_invoice_bolt11(...) -> Response`
|
||||
|
||||
- Serves `GET /invoices/:id/bolt11`
|
||||
- Fetches invoice from Stripe API by ID
|
||||
- Looks up tenant by the invoice's `customer` field, authorizes admin or matching tenant
|
||||
- If invoice `status` is not `open`, return `400` with `code=invoice-not-open`
|
||||
- Creates a bolt11 Lightning invoice for the invoice's `amount_due` using `billing.create_bolt11(amount_due)`
|
||||
- Return `data` is `{ bolt11 }`
|
||||
|
||||
--- Stripe session route
|
||||
|
||||
## `async fn create_stripe_session(...) -> Response`
|
||||
|
||||
- Serves `GET /tenants/:pubkey/stripe/session`
|
||||
- Authorizes admin or matching tenant
|
||||
- Looks up tenant by pubkey
|
||||
- Creates a Stripe Customer Portal session for the tenant's `stripe_customer_id`
|
||||
- Return `data` is `{ url }` — the portal session URL
|
||||
|
||||
--- Stripe webhook route
|
||||
|
||||
## `async fn stripe_webhook(...) -> Response`
|
||||
|
||||
- Serves `POST /stripe/webhook`
|
||||
- No NIP-98 authentication — uses Stripe signature verification instead
|
||||
- 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
|
||||
- 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.
|
||||
|
||||
## `require_admin(&self, authorized_pubkey: &str)`
|
||||
|
||||
- Checks whether `authorized_pubkey` is in `self.admins`. If not, returns an forbidden error
|
||||
|
||||
## `require_admin_or_tenant(&self, authorized_pubkey: &str, tenant_pubkey: &str)`
|
||||
|
||||
- Checks whether `authorized_pubkey` is an admin or matches `tenant_pubkey`
|
||||
|
||||
## `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`
|
||||
- Populate `schema` if not already set
|
||||
- Populate missing fields using reasonable defaults
|
||||
@@ -0,0 +1,144 @@
|
||||
# `pub struct Billing`
|
||||
|
||||
Billing encapsulates logic related to synchronizing state with Stripe, processing payments via NWC, and managing subscription lifecycle.
|
||||
|
||||
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`
|
||||
- `robot: Robot`
|
||||
|
||||
## `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)`
|
||||
|
||||
- Subscribes to `command.notify.subscribe()`
|
||||
- On `create_relay`, `update_relay`, `activate_relay`, `deactivate_relay`, `fail_relay_sync`, and `complete_relay_sync`, call `self.sync_relay_subscription`.
|
||||
|
||||
## `pub fn sync_relay_subscription(&self, activity: &Activity)`
|
||||
|
||||
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 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<()>`
|
||||
|
||||
- Verify the webhook signature using `self.stripe_webhook_secret`
|
||||
- Parse the event and dispatch by type:
|
||||
- `invoice.created` -> `self.handle_invoice_created`
|
||||
- `invoice.paid` -> `self.handle_invoice_paid`
|
||||
- `invoice.payment_failed` -> `self.handle_invoice_payment_failed`
|
||||
- `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>`
|
||||
|
||||
- Fetches invoices from Stripe API for the given customer
|
||||
- Returns the `data` array from the Stripe response
|
||||
|
||||
## `pub async fn stripe_get_invoice(&self, invoice_id: &str) -> Result<Value>`
|
||||
|
||||
- Fetches a single invoice from Stripe API by ID
|
||||
- Returns the full Stripe invoice object
|
||||
|
||||
## `pub async fn create_bolt11(&self, amount_due_cents: i64) -> Result<String>`
|
||||
|
||||
- Creates a bolt11 Lightning invoice for the given amount using the system NWC wallet (`self.nwc_url`)
|
||||
- Returns the bolt11 invoice string
|
||||
|
||||
## `pub async fn stripe_create_portal_session(&self, customer_id: &str) -> Result<String>`
|
||||
|
||||
- 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:
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
## `fn handle_invoice_paid(&self, invoice: &Invoice)`
|
||||
|
||||
- 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)
|
||||
- Reactivate each one via `command.activate_relay`
|
||||
|
||||
## `fn handle_invoice_payment_failed(&self, invoice: &Invoice)`
|
||||
|
||||
- Look up tenant by `stripe_customer_id`
|
||||
- If tenant does not already have `past_due_at` set:
|
||||
- Set `past_due_at` to now via `command.set_tenant_past_due`
|
||||
- Send a DM via `robot.send_dm` notifying the tenant that their payment has failed and their relays may be deactivated if not resolved.
|
||||
|
||||
## `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`)
|
||||
- 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)`
|
||||
|
||||
- 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`
|
||||
|
||||
## `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
|
||||
@@ -0,0 +1,109 @@
|
||||
# `pub struct Command`
|
||||
|
||||
Command writes to the database.
|
||||
|
||||
Members:
|
||||
|
||||
- `pool: SqlitePool` - a sqlite connection pool
|
||||
- `pub notify: broadcast::Sender<Activity>` - callers can subscribe via `command.notify.subscribe()`
|
||||
|
||||
Notes:
|
||||
|
||||
- All public write methods should be atomic
|
||||
- All writes should be accompanied by an activity log entry of `(tenant, activity_type, resource_type, resource_id)`
|
||||
- `insert_activity` builds and returns the `Activity` struct (using `chrono::Utc::now()` for `created_at`)
|
||||
- After each successful commit, sends the `Activity` on the broadcast channel
|
||||
|
||||
## `pub fn new(&self, pool: SqlitePool) -> Self`
|
||||
|
||||
- Assigns pool to self
|
||||
- Creates the broadcast channel
|
||||
|
||||
## `pub fn create_tenant(&self, tenant: &Tenant) -> Result<()>`
|
||||
|
||||
- Creates tenant, may throw sqlite uniqueness error on pubkey
|
||||
- Logs activity as `(create_tenant, tenant_id)`
|
||||
|
||||
## `pub fn update_tenant(&self, tenant: &Tenant) -> Result<()>`
|
||||
|
||||
- Updates tenant
|
||||
- Logs activity as `(update_tenant, tenant_id)`
|
||||
|
||||
## `pub fn create_relay(&self, relay: &Relay) -> Result<()>`
|
||||
|
||||
- Creates relay, may throw sqlite uniqueness error on subdomain
|
||||
- Sets relay status to `active`
|
||||
- Logs activity as `(create_relay, relay_id)`
|
||||
|
||||
## `pub fn update_relay(&self, relay: &Relay) -> Result<()>`
|
||||
|
||||
- Updates relay, may throw sqlite uniqueness error on subdomain
|
||||
- Logs activity as `(update_relay, relay_id)`
|
||||
|
||||
## `pub fn deactivate_relay(&self, relay: &Relay) -> Result<()>`
|
||||
|
||||
- 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<()>`
|
||||
|
||||
- Sets relay status to `active`
|
||||
- Logs activity as `(activate_relay, relay_id)`
|
||||
|
||||
## `pub fn fail_relay_sync(&self, relay: &Relay, sync_error: &str) -> Result<()>`
|
||||
|
||||
- Sets `sync_error` on the relay
|
||||
- Logs activity as `(fail_relay_sync, relay_id)`
|
||||
|
||||
## `pub fn complete_relay_sync(&self, relay_id: &str) -> Result<()>`
|
||||
|
||||
- Sets `synced = 1`, clears `sync_error`
|
||||
- Logs activity as `(complete_relay_sync, relay_id)`
|
||||
|
||||
## `pub fn delete_relay_subscription_item(&self, relay_id: &str) -> Result<()>`
|
||||
|
||||
- Sets `stripe_subscription_item_id = null`
|
||||
- Does not log activity
|
||||
|
||||
## `pub fn set_relay_subscription_item(&self, relay_id: &str, stripe_subscription_item_id: &str) -> Result<()>`
|
||||
|
||||
- Sets `stripe_subscription_item_id`
|
||||
- Does not log activity
|
||||
|
||||
## `pub fn set_tenant_subscription(&self, pubkey: &str, stripe_subscription_id: &str) -> Result<()>`
|
||||
|
||||
- Sets `stripe_subscription_id` on the tenant
|
||||
- Does not log activity
|
||||
|
||||
## `pub fn clear_tenant_subscription(&self, pubkey: &str) -> Result<()>`
|
||||
|
||||
- Sets `stripe_subscription_id = null` on the tenant
|
||||
- Does not log activity
|
||||
|
||||
## `pub fn set_tenant_nwc_error(&self, pubkey: &str, error: &str) -> Result<()>`
|
||||
|
||||
- Sets `nwc_error` on the tenant
|
||||
- Does not log activity
|
||||
|
||||
## `pub fn clear_tenant_nwc_error(&self, pubkey: &str) -> Result<()>`
|
||||
|
||||
- Sets `nwc_error = null` on the tenant
|
||||
- Does not log activity
|
||||
|
||||
## `pub fn set_tenant_past_due(&self, pubkey: &str) -> Result<()>`
|
||||
|
||||
- Sets `past_due_at` to the current timestamp
|
||||
- Does not log activity
|
||||
|
||||
## `pub fn clear_tenant_past_due(&self, pubkey: &str) -> Result<()>`
|
||||
|
||||
- Sets `past_due_at = null` on the tenant
|
||||
- Does not log activity
|
||||
@@ -0,0 +1,35 @@
|
||||
# `pub struct Infra`
|
||||
|
||||
Infra is a service which listens for activity and synchronizes relay updates to a remote zooid instance via `api_url`.
|
||||
|
||||
Members:
|
||||
|
||||
- `api_url: String` - the URL of the zooid instance to be managed, from `ZOOID_API_URL`
|
||||
- `query: Query`
|
||||
- `command: Command`
|
||||
|
||||
## `pub fn new(query: Query, command: Command) -> Self`
|
||||
|
||||
- Reads environment and populates members
|
||||
|
||||
## `pub async fn start(self)`
|
||||
|
||||
- Subscribes to `command.notify`
|
||||
- Loops on `rx.recv()`, calling `handle_activity` for each received `Activity`.
|
||||
|
||||
## `async fn handle_activity(&self, activity: &Activity)`
|
||||
|
||||
- For `create_relay`, `update_relay`, `activate_relay`, or `deactivate_relay` activity, calls `sync_and_report`.
|
||||
- All other activity types are ignored (e.g. `fail_relay_sync`, `complete_relay_sync`).
|
||||
|
||||
## `async fn sync_and_report(&self, relay: &Relay, is_new: bool)`
|
||||
|
||||
- Calls `sync_relay` and on success calls `command.complete_relay_sync`.
|
||||
- On failure calls `command.fail_relay_sync`.
|
||||
|
||||
## `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.
|
||||
@@ -0,0 +1,9 @@
|
||||
# `async fn main() -> Result<()>`
|
||||
|
||||
- Configures logging
|
||||
- Calls `create_pool` to get a `SqlitePool`, then creates `Query`, `Command`, `Robot`, `Billing`, `Api`, and `Infra`
|
||||
- Get an axum router from `api.router`
|
||||
- Adds CORS middleware based on `origins`
|
||||
- Calls `axum::serve` with a listener
|
||||
- Spawns `infra.start`
|
||||
- Spawns `billing.start`
|
||||
@@ -0,0 +1,92 @@
|
||||
This file describes the domain model. This description should be translated into standard structs and sqlite schemas in a way that makes sense.
|
||||
|
||||
- Fields marked as private should use `#[serde(skip_serializing)]` in their definition.
|
||||
- Fields marked as readonly should use `#[serde(skip_deserializing)]` in their definition.
|
||||
|
||||
# Identity
|
||||
|
||||
Identity is a description of a user.
|
||||
|
||||
- `pubkey` - the user's nostr pubkey
|
||||
- `is_admin` - whether the user is an admin
|
||||
|
||||
# Activity
|
||||
|
||||
Activity is an audit log of all actions performed by a user or a worker process. This allows us to trace history to create invoices, synchronize actions to external services, and debug system behavior.
|
||||
|
||||
- `id` - a random activity ID
|
||||
- `tenant` - a tenant ID
|
||||
- `created_at` - unix timestamp when the activity was created
|
||||
- `activity_type` is one of:
|
||||
- `create_tenant`
|
||||
- `update_tenant`
|
||||
- `create_relay`
|
||||
- `update_relay`
|
||||
- `activate_relay`
|
||||
- `deactivate_relay`
|
||||
- `fail_relay_sync`
|
||||
- `complete_relay_sync`
|
||||
- `resource_type` is a string identifying the resource type being modified.
|
||||
- `resource_id` is a string identifying the resource id being modified.
|
||||
|
||||
# Plan
|
||||
|
||||
A plan represents a rate charged for relays at a given feature/usage limit. Plans aren't saved to the database, but are simply hardcoded. However, they are exposed through the API so they can be used as a single source of truth.
|
||||
|
||||
- `id` - the plan slug
|
||||
- `name` - the plan name
|
||||
- `amount` - the plan monthly cost in USD
|
||||
- `members` - the max number of members a relay can have before needing to upgrade. If empty, membership is not limited.
|
||||
- `blossom` - whether blossom media hosting is available on this plan
|
||||
- `livekit` - whether livekit audio/video calls are available on this plan
|
||||
- `stripe_price_id` (nullable) - the identifier of the price in Stripe. Null for the free plan.
|
||||
|
||||
There are three plans available:
|
||||
|
||||
- `free` - $0/mo, up to 10 members, no blossom/livekit
|
||||
- `basic` - $5/mo, up to 100 members, includes blossom/livekit
|
||||
- `growth` - $25/mo, unlimited members, includes blossom/livekit
|
||||
|
||||
# Tenant
|
||||
|
||||
Tenants are customers of the service, identified by a nostr `pubkey`. Public metadata like name etc are pulled from the nostr network. They also have associated billing information.
|
||||
|
||||
- `pubkey` is the nostr public key identifying the tenant
|
||||
- `nwc_url` (private) a nostr wallet connect URL used for **paying** invoices generated by the system on the tenant's behalf
|
||||
- `nwc_error` (private) a string indicating the most recent NWC payment error, if any. Cleared on successful NWC payment.
|
||||
- `created_at` unix timestamp identifying tenant creation time
|
||||
- `stripe_customer_id` a string identifying the associated stripe customer
|
||||
- `stripe_subscription_id` (nullable) a string identifying the associated stripe subscription. Created when the first paid (non-free) relay is activated, deleted when the last paid relay is removed or deactivated. Free-only tenants have no subscription.
|
||||
- `past_due_at` (nullable) unix timestamp when the tenant's subscription became past due. Set on payment failure, cleared on payment success.
|
||||
|
||||
# Relay
|
||||
|
||||
A relay is a nostr relay owned by a `tenant` and hosted by the attached zooid instance. Relay subdomains MUST be unique.
|
||||
|
||||
- `id` - a random ID identifying the relay
|
||||
- `tenant` - the tenant's pubkey
|
||||
- `schema` - the relay's db schema (read_only, calculated based on `subdomain` + `id`)
|
||||
- `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.
|
||||
- `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
|
||||
- `info_icon` - the relay's icon image URL
|
||||
- `info_description` - the relay's description
|
||||
- `policy_public_join` - whether to allow non-members to join the relay without an invite code
|
||||
- `policy_strip_signatures` - whether to remove signatures when serving events to non-admins
|
||||
- `groups_enabled` - whether NIP 29 groups are enabled
|
||||
- `management_enabled` - whether NIP 86 management API is enabled
|
||||
- `blossom_enabled` - whether blossom file storage is enabled
|
||||
- `livekit_enabled` - whether livekit calls are enabled
|
||||
- `push_enabled` - whether relay push is enabled
|
||||
|
||||
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 `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.
|
||||
@@ -0,0 +1,14 @@
|
||||
# `pub async fn create_pool() -> Result<SqlitePool>`
|
||||
|
||||
Creates and returns a sqlite connection pool.
|
||||
|
||||
Notes:
|
||||
|
||||
- Database table names are singular: `activity`, `tenant`, `relay`
|
||||
|
||||
Steps:
|
||||
|
||||
- Reads `DATABASE_URL` from environment
|
||||
- Ensures that any directories referred to in `DATABASE_URL` exist
|
||||
- Initializes the sqlx pool
|
||||
- Runs migrations found in the `migrations` directory
|
||||
@@ -0,0 +1,49 @@
|
||||
# `pub struct Query`
|
||||
|
||||
Query reads from the database.
|
||||
|
||||
Members:
|
||||
|
||||
- `pool: SqlitePool` - a sqlite connection pool
|
||||
|
||||
## `pub fn new(&self, pool: SqlitePool) -> Self`
|
||||
|
||||
- Assigns pool to self
|
||||
|
||||
## `pub fn list_tenants(&self) -> Result<Vec<Tenant>>`
|
||||
|
||||
- Returns all tenants
|
||||
|
||||
## `pub fn get_tenant(&self, pubkey: &str) -> Result<Tenant>`
|
||||
|
||||
- Returns matching tenant
|
||||
|
||||
## `pub fn list_plans() -> Vec<Plan>`
|
||||
|
||||
- Returns the hardcoded relay plans used by the system (`free`, `basic`, `growth`)
|
||||
- This is the source of truth for plan metadata exposed via API
|
||||
|
||||
## `pub fn list_relays(&self) -> Result<Vec<Relay>>`
|
||||
|
||||
- Returns all relays
|
||||
|
||||
## `pub fn list_relays_for_tenant(&self, tenant_id: &str) -> Result<Vec<Relay>>`
|
||||
|
||||
- Returns all relays belonging to the given tenant
|
||||
|
||||
## `pub fn get_relay(&self, id: &str) -> Result<Relay>`
|
||||
|
||||
- Returns matching relay
|
||||
|
||||
## `pub fn get_tenant_by_stripe_customer_id(&self, stripe_customer_id: &str) -> Result<Tenant>`
|
||||
|
||||
- Returns the tenant matching the given `stripe_customer_id`
|
||||
|
||||
## `pub fn has_active_paid_relays(&self, tenant_id: &str) -> Result<bool>`
|
||||
|
||||
- Returns true if the tenant has any relays where `status = 'active'` and `plan != 'free'`
|
||||
|
||||
## `pub fn list_activity_for_relay(&self, relay_id: &str) -> Result<Vec<Activity>>`
|
||||
|
||||
- Returns all activity where `resource_type = 'relay'` and `resource_id = relay_id`
|
||||
- Ordered newest-first
|
||||
@@ -0,0 +1,25 @@
|
||||
# `pub struct Robot`
|
||||
|
||||
Robot is a nostr identity which acts on behalf of the application.
|
||||
|
||||
Members:
|
||||
|
||||
- `secret: String` - a nostr secret key, from `ROBOT_SECRET`
|
||||
- `name: String` - the name of the bot, from `ROBOT_NAME`
|
||||
- `description: String` - the description of the bot, from `ROBOT_DESCRIPTION`
|
||||
- `picture: String` - the picture URL for the bot, from `ROBOT_PICTURE`
|
||||
- `outbox_client: nostr_sdk::Client` - used for publishing relay lists and metadata, connects to `ROBOT_OUTBOX_RELAYS`
|
||||
- `indexer_client: nostr_sdk::Client` - used for publishing relay lists, connects to `ROBOT_INDEXER_RELAYS`
|
||||
- `messagins_client: nostr_sdk::Client` - used for sending and receiving dms, connects to `ROBOT_MESSAGING_RELAYS`
|
||||
|
||||
## `pub fn new() -> Self`
|
||||
|
||||
- Reads environment and populates members. Relay urls should be split and normalized.
|
||||
- Publishes a `kind 0` nostr profile, a `kind 10002` relay list, and `kind 10050` relay selections
|
||||
|
||||
## `pub async fn send_dm(&self, recipient: &str, message: &str) -> Result<()>`
|
||||
|
||||
- Fetches recipient's outbox relays from `indexer_relays` (cached)
|
||||
- Fetches recipient's messaging relays from their outbox relays (cached)
|
||||
- Sends DM to recipient via their messaging relays
|
||||
- If no outbox/messaging relays are found, return an error
|
||||
+979
-160
File diff suppressed because it is too large
Load Diff
+1305
-539
File diff suppressed because it is too large
Load Diff
@@ -1,48 +0,0 @@
|
||||
use anyhow::{Result, anyhow};
|
||||
|
||||
pub async fn fiat_to_msats(amount_fiat_minor: i64, currency: &str) -> Result<u64> {
|
||||
let price = get_bitcoin_price(¤cy.to_uppercase()).await?;
|
||||
let divisor = 10_f64.powi(currency_minor_exponent(currency)? as i32);
|
||||
let amount_fiat = (amount_fiat_minor as f64) / divisor;
|
||||
let amount_msats = (amount_fiat / price * 100_000_000_000.0).round();
|
||||
Ok(amount_msats as u64)
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct CoinbaseSpotPriceResponse {
|
||||
data: CoinbaseSpotPriceData,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct CoinbaseSpotPriceData {
|
||||
amount: String,
|
||||
}
|
||||
|
||||
pub async fn get_bitcoin_price(currency: &str) -> Result<f64> {
|
||||
let http = reqwest::Client::new();
|
||||
let url = format!("https://api.coinbase.com/v2/prices/BTC-{currency}/spot");
|
||||
let resp = http.get(url).send().await?;
|
||||
let body: CoinbaseSpotPriceResponse = resp.error_for_status()?.json().await?;
|
||||
|
||||
body
|
||||
.data
|
||||
.amount
|
||||
.parse::<f64>()
|
||||
.map_err(|e| anyhow!("invalid BTC spot quote for {currency}: {e}"))
|
||||
}
|
||||
|
||||
/// Number of decimal places in `currency`'s minor unit, following Stripe's
|
||||
/// currency conventions (most are 2, JPY/KRW/… are 0, BHD/KWD/… are 3).
|
||||
fn currency_minor_exponent(currency: &str) -> Result<u8> {
|
||||
let normalized = currency.to_uppercase();
|
||||
let exponent = match normalized.as_str() {
|
||||
// Zero-decimal currencies in Stripe.
|
||||
"BIF" | "CLP" | "DJF" | "GNF" | "JPY" | "KMF" | "KRW" | "MGA" | "PYG" | "RWF" | "UGX"
|
||||
| "VND" | "VUV" | "XAF" | "XOF" | "XPF" => 0,
|
||||
// Three-decimal currencies in Stripe.
|
||||
"BHD" | "JOD" | "KWD" | "OMR" | "TND" => 3,
|
||||
_ if normalized.chars().all(|c| c.is_ascii_alphabetic()) && normalized.len() == 3 => 2,
|
||||
_ => return Err(anyhow!("invalid currency code: {currency}")),
|
||||
};
|
||||
Ok(exponent)
|
||||
}
|
||||
+285
-370
@@ -1,16 +1,77 @@
|
||||
use anyhow::Result;
|
||||
use sqlx::{Sqlite, Transaction};
|
||||
use sqlx::{Sqlite, SqlitePool, Transaction};
|
||||
use tokio::sync::broadcast;
|
||||
|
||||
use crate::db::{pool, publish, with_tx};
|
||||
use crate::models::{
|
||||
Activity, Bolt11, Invoice, InvoiceItem, RELAY_STATUS_ACTIVE, RELAY_STATUS_DELINQUENT,
|
||||
RELAY_STATUS_INACTIVE, Relay, Tenant,
|
||||
Activity, RELAY_STATUS_ACTIVE, RELAY_STATUS_DELINQUENT, RELAY_STATUS_INACTIVE, Relay, Tenant,
|
||||
};
|
||||
|
||||
// --- Tenants ---
|
||||
#[derive(Clone)]
|
||||
pub struct Command {
|
||||
pool: SqlitePool,
|
||||
pub notify: broadcast::Sender<Activity>,
|
||||
}
|
||||
|
||||
impl Command {
|
||||
pub fn new(pool: SqlitePool) -> Self {
|
||||
let (notify, _) = broadcast::channel(64);
|
||||
Self { pool, notify }
|
||||
}
|
||||
|
||||
async fn insert_activity(
|
||||
tx: &mut Transaction<'_, Sqlite>,
|
||||
activity_type: &str,
|
||||
resource_type: &str,
|
||||
resource_id: &str,
|
||||
) -> Result<Activity> {
|
||||
let tenant = match resource_type {
|
||||
"tenant" => resource_id.to_string(),
|
||||
"relay" => {
|
||||
sqlx::query_scalar::<_, String>("SELECT tenant FROM relay WHERE id = ?")
|
||||
.bind(resource_id)
|
||||
.fetch_one(&mut **tx)
|
||||
.await?
|
||||
}
|
||||
_ => anyhow::bail!("unknown resource_type: {resource_type}"),
|
||||
};
|
||||
|
||||
let id = uuid::Uuid::new_v4().to_string();
|
||||
let created_at = chrono::Utc::now().timestamp();
|
||||
|
||||
sqlx::query(
|
||||
"INSERT INTO activity (id, tenant, created_at, activity_type, resource_type, resource_id)
|
||||
VALUES (?, ?, ?, ?, ?, ?)",
|
||||
)
|
||||
.bind(&id)
|
||||
.bind(&tenant)
|
||||
.bind(created_at)
|
||||
.bind(activity_type)
|
||||
.bind(resource_type)
|
||||
.bind(resource_id)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
|
||||
Ok(Activity {
|
||||
id,
|
||||
tenant,
|
||||
created_at,
|
||||
activity_type: activity_type.to_string(),
|
||||
resource_type: resource_type.to_string(),
|
||||
resource_id: resource_id.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
fn emit(&self, activity: Activity) {
|
||||
let _ = self.notify.send(activity);
|
||||
}
|
||||
|
||||
pub async fn create_tenant(&self, tenant: &Tenant) -> Result<()> {
|
||||
if tenant.stripe_customer_id.trim().is_empty() {
|
||||
anyhow::bail!("stripe_customer_id is required");
|
||||
}
|
||||
|
||||
let mut tx = self.pool.begin().await?;
|
||||
|
||||
pub async fn create_tenant(tenant: &Tenant) -> Result<()> {
|
||||
let activity = with_tx(async |tx| {
|
||||
sqlx::query(
|
||||
"INSERT INTO tenant (pubkey, nwc_url, created_at, stripe_customer_id)
|
||||
VALUES (?, ?, ?, ?)",
|
||||
@@ -19,61 +80,49 @@ pub async fn create_tenant(tenant: &Tenant) -> Result<()> {
|
||||
.bind(&tenant.nwc_url)
|
||||
.bind(tenant.created_at)
|
||||
.bind(&tenant.stripe_customer_id)
|
||||
.execute(&mut **tx)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
insert_activity_tx(tx, "create_tenant", "tenant", &tenant.pubkey, None).await
|
||||
})
|
||||
.await?;
|
||||
publish(activity);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn update_tenant(tenant: &Tenant) -> Result<()> {
|
||||
let activity = with_tx(async |tx| {
|
||||
let activity =
|
||||
Self::insert_activity(&mut tx, "create_tenant", "tenant", &tenant.pubkey).await?;
|
||||
|
||||
tx.commit().await?;
|
||||
self.emit(activity);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn update_tenant(&self, tenant: &Tenant) -> Result<()> {
|
||||
let mut tx = self.pool.begin().await?;
|
||||
|
||||
sqlx::query("UPDATE tenant SET nwc_url = ? WHERE pubkey = ?")
|
||||
.bind(&tenant.nwc_url)
|
||||
.bind(&tenant.pubkey)
|
||||
.execute(&mut **tx)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
insert_activity_tx(tx, "update_tenant", "tenant", &tenant.pubkey, None).await
|
||||
})
|
||||
.await?;
|
||||
publish(activity);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn set_tenant_billing_anchor(tenant: &Tenant) -> Result<()> {
|
||||
sqlx::query("UPDATE tenant SET billing_anchor = ? WHERE pubkey = ?")
|
||||
.bind(tenant.billing_anchor)
|
||||
.bind(&tenant.pubkey)
|
||||
.execute(pool())
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
let activity =
|
||||
Self::insert_activity(&mut tx, "update_tenant", "tenant", &tenant.pubkey).await?;
|
||||
|
||||
pub async fn clear_tenant_nwc_error(pubkey: &str) -> Result<()> {
|
||||
sqlx::query("UPDATE tenant SET nwc_error = NULL WHERE pubkey = ?")
|
||||
.bind(pubkey)
|
||||
.execute(pool())
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
tx.commit().await?;
|
||||
self.emit(activity);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// --- Relays ---
|
||||
pub async fn create_relay(&self, relay: &Relay) -> Result<()> {
|
||||
let mut tx = self.pool.begin().await?;
|
||||
|
||||
pub async fn create_relay(relay: &Relay) -> Result<()> {
|
||||
let activity = with_tx(async |tx| {
|
||||
sqlx::query(
|
||||
"INSERT INTO relay (
|
||||
id, tenant, subdomain, plan, status, synced, sync_error,
|
||||
id, tenant, schema, subdomain, plan, status, sync_error,
|
||||
info_name, info_icon, info_description,
|
||||
policy_public_join, policy_strip_signatures,
|
||||
groups_enabled, management_enabled, blossom_enabled,
|
||||
livekit_enabled, push_enabled
|
||||
) VALUES (?, ?, ?, ?, 'active', 0, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
) VALUES (?, ?, ?, ?, ?, 'active', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
)
|
||||
.bind(&relay.id)
|
||||
.bind(&relay.tenant)
|
||||
.bind(&relay.schema)
|
||||
.bind(&relay.subdomain)
|
||||
.bind(&relay.plan)
|
||||
.bind(&relay.sync_error)
|
||||
@@ -87,20 +136,22 @@ pub async fn create_relay(relay: &Relay) -> Result<()> {
|
||||
.bind(relay.blossom_enabled)
|
||||
.bind(relay.livekit_enabled)
|
||||
.bind(relay.push_enabled)
|
||||
.execute(&mut **tx)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
insert_activity_tx(tx, "create_relay", "relay", &relay.id, Some(&relay.plan)).await
|
||||
})
|
||||
.await?;
|
||||
publish(activity);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn update_relay(relay: &Relay) -> Result<()> {
|
||||
let activity = with_tx(async |tx| {
|
||||
let activity = Self::insert_activity(&mut tx, "create_relay", "relay", &relay.id).await?;
|
||||
|
||||
tx.commit().await?;
|
||||
self.emit(activity);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn update_relay(&self, relay: &Relay) -> Result<()> {
|
||||
let mut tx = self.pool.begin().await?;
|
||||
|
||||
sqlx::query(
|
||||
"UPDATE relay
|
||||
SET tenant = ?, subdomain = ?, plan = ?, status = ?, sync_error = ?, synced = 0,
|
||||
SET tenant = ?, schema = ?, subdomain = ?, plan = ?, status = ?, sync_error = ?,
|
||||
info_name = ?, info_icon = ?, info_description = ?,
|
||||
policy_public_join = ?, policy_strip_signatures = ?,
|
||||
groups_enabled = ?, management_enabled = ?, blossom_enabled = ?,
|
||||
@@ -108,6 +159,7 @@ pub async fn update_relay(relay: &Relay) -> Result<()> {
|
||||
WHERE id = ?",
|
||||
)
|
||||
.bind(&relay.tenant)
|
||||
.bind(&relay.schema)
|
||||
.bind(&relay.subdomain)
|
||||
.bind(&relay.plan)
|
||||
.bind(&relay.status)
|
||||
@@ -123,349 +175,212 @@ pub async fn update_relay(relay: &Relay) -> Result<()> {
|
||||
.bind(relay.livekit_enabled)
|
||||
.bind(relay.push_enabled)
|
||||
.bind(&relay.id)
|
||||
.execute(&mut **tx)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
insert_activity_tx(tx, "update_relay", "relay", &relay.id, Some(&relay.plan)).await
|
||||
})
|
||||
.await?;
|
||||
publish(activity);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn deactivate_relay(relay: &Relay) -> Result<()> {
|
||||
set_relay_status(&relay.id, RELAY_STATUS_INACTIVE, "deactivate_relay").await
|
||||
}
|
||||
let activity = Self::insert_activity(&mut tx, "update_relay", "relay", &relay.id).await?;
|
||||
|
||||
#[allow(dead_code)] // wired up by the delinquency flow (not yet implemented)
|
||||
pub async fn mark_relay_delinquent(relay: &Relay) -> Result<()> {
|
||||
set_relay_status(&relay.id, RELAY_STATUS_DELINQUENT, "mark_relay_delinquent").await
|
||||
}
|
||||
tx.commit().await?;
|
||||
self.emit(activity);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn activate_relay(relay: &Relay) -> Result<()> {
|
||||
set_relay_status(&relay.id, RELAY_STATUS_ACTIVE, "activate_relay").await
|
||||
}
|
||||
pub async fn deactivate_relay(&self, relay: &Relay) -> Result<()> {
|
||||
self.set_relay_status(&relay.id, RELAY_STATUS_INACTIVE, "deactivate_relay")
|
||||
.await
|
||||
}
|
||||
|
||||
async fn set_relay_status(relay_id: &str, status: &str, activity_type: &str) -> Result<()> {
|
||||
let activity = with_tx(async |tx| {
|
||||
sqlx::query("UPDATE relay SET status = ?, synced = 0 WHERE id = ?")
|
||||
pub async fn mark_relay_delinquent(&self, relay: &Relay) -> Result<()> {
|
||||
self.set_relay_status(&relay.id, RELAY_STATUS_DELINQUENT, "deactivate_relay")
|
||||
.await
|
||||
}
|
||||
|
||||
async fn set_relay_status(
|
||||
&self,
|
||||
relay_id: &str,
|
||||
status: &str,
|
||||
activity_type: &str,
|
||||
) -> Result<()> {
|
||||
let mut tx = self.pool.begin().await?;
|
||||
|
||||
sqlx::query("UPDATE relay SET status = ? WHERE id = ?")
|
||||
.bind(status)
|
||||
.bind(relay_id)
|
||||
.execute(&mut **tx)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
insert_activity_tx(tx, activity_type, "relay", relay_id, None).await
|
||||
})
|
||||
.await?;
|
||||
publish(activity);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn fail_relay_sync(relay: &Relay, sync_error: String) -> Result<()> {
|
||||
let activity = with_tx(async |tx| {
|
||||
sqlx::query("UPDATE relay SET synced = 0, sync_error = ? WHERE id = ?")
|
||||
let activity = Self::insert_activity(&mut tx, activity_type, "relay", relay_id).await?;
|
||||
|
||||
tx.commit().await?;
|
||||
self.emit(activity);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn activate_relay(&self, relay: &Relay) -> Result<()> {
|
||||
self.set_relay_status(&relay.id, RELAY_STATUS_ACTIVE, "activate_relay")
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn fail_relay_sync(&self, relay: &Relay, sync_error: String) -> Result<()> {
|
||||
let mut tx = self.pool.begin().await?;
|
||||
|
||||
sqlx::query("UPDATE relay SET sync_error = ? WHERE id = ?")
|
||||
.bind(&sync_error)
|
||||
.bind(&relay.id)
|
||||
.execute(&mut **tx)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
insert_activity_tx(tx, "fail_relay_sync", "relay", &relay.id, None).await
|
||||
})
|
||||
.await?;
|
||||
publish(activity);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn complete_relay_sync(relay_id: &str) -> Result<()> {
|
||||
let activity = with_tx(async |tx| {
|
||||
let activity =
|
||||
Self::insert_activity(&mut tx, "fail_relay_sync", "relay", &relay.id).await?;
|
||||
|
||||
tx.commit().await?;
|
||||
self.emit(activity);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn complete_relay_sync(&self, relay_id: &str) -> Result<()> {
|
||||
let mut tx = self.pool.begin().await?;
|
||||
|
||||
sqlx::query("UPDATE relay SET synced = 1, sync_error = '' WHERE id = ?")
|
||||
.bind(relay_id)
|
||||
.execute(&mut **tx)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
insert_activity_tx(tx, "complete_relay_sync", "relay", relay_id, None).await
|
||||
})
|
||||
.await?;
|
||||
publish(activity);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// --- Invoice items (the outstanding-charge ledger) ---
|
||||
|
||||
pub async fn insert_invoice_item_for_activity(invoice_item: &InvoiceItem, activity_id: &str) -> Result<()> {
|
||||
let now = chrono::Utc::now().timestamp();
|
||||
|
||||
with_tx(async |tx| {
|
||||
insert_invoice_item_tx(tx, invoice_item).await?;
|
||||
mark_activity_billed_tx(tx, activity_id, now).await?;
|
||||
let activity =
|
||||
Self::insert_activity(&mut tx, "complete_relay_sync", "relay", relay_id).await?;
|
||||
|
||||
tx.commit().await?;
|
||||
self.emit(activity);
|
||||
Ok(())
|
||||
})
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
/// Mark an activity billed without a line item — for activities that produce no
|
||||
/// charge (e.g. free-plan changes), so a recovery pass doesn't re-scan them.
|
||||
pub async fn mark_activity_billed(activity_id: &str) -> Result<()> {
|
||||
let now = chrono::Utc::now().timestamp();
|
||||
|
||||
with_tx(async |tx| mark_activity_billed_tx(tx, activity_id, now).await).await
|
||||
}
|
||||
|
||||
/// Insert renewal line items, skipping any relay already covered for the item's
|
||||
/// `period_start`. The per-relay existence check and insert are a single
|
||||
/// statement, so neither a re-tick nor a relay's own creation/activation charge
|
||||
/// (which also stamps `period_start`) can bill the same relay-period twice.
|
||||
pub async fn renew_tenant(
|
||||
tenant_pubkey: &str,
|
||||
period_start: i64,
|
||||
items: &[InvoiceItem],
|
||||
) -> Result<()> {
|
||||
with_tx(async |tx| {
|
||||
// In-tx guard: bail if this tenant has already been renewed for this
|
||||
// period (or later). This is the correctness backstop — it keeps renewal
|
||||
// idempotent under a crash mid-renewal or a poll racing the eager
|
||||
// endpoint, since the item inserts and the `renewed_at` write commit
|
||||
// together.
|
||||
let renewed_at = sqlx::query_scalar::<_, Option<i64>>(
|
||||
"SELECT renewed_at FROM tenant WHERE pubkey = ?",
|
||||
)
|
||||
.bind(tenant_pubkey)
|
||||
.fetch_one(&mut **tx)
|
||||
.await?;
|
||||
|
||||
if renewed_at.is_some_and(|at| at >= period_start) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
for item in items {
|
||||
insert_invoice_item_tx(tx, item).await?;
|
||||
}
|
||||
|
||||
sqlx::query("UPDATE tenant SET renewed_at = ? WHERE pubkey = ?")
|
||||
.bind(period_start)
|
||||
.bind(tenant_pubkey)
|
||||
.execute(&mut **tx)
|
||||
pub async fn delete_relay_subscription_item(&self, relay_id: &str) -> Result<()> {
|
||||
sqlx::query("UPDATE relay SET stripe_subscription_item_id = NULL WHERE id = ?")
|
||||
.bind(relay_id)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
})
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
// --- Invoices ---
|
||||
|
||||
/// Claim all of a tenant's outstanding items onto a new invoice — but only if
|
||||
/// they sum to a positive amount. A non-positive balance (net credit or nothing
|
||||
/// owed) leaves the items outstanding so the credit carries to the next positive
|
||||
/// invoice. The sum, insert, and claim run in one transaction. Returns the
|
||||
/// invoice, or `None` when there's nothing to bill.
|
||||
pub async fn claim_outstanding_into_invoice(
|
||||
invoice_id: &str,
|
||||
tenant_pubkey: &str,
|
||||
period_start: i64,
|
||||
period_end: i64,
|
||||
) -> Result<Option<Invoice>> {
|
||||
with_tx(async |tx| {
|
||||
let total = sqlx::query_scalar::<_, i64>(
|
||||
"SELECT COALESCE(SUM(amount), 0) FROM invoice_item
|
||||
WHERE tenant_pubkey = ? AND invoice_id IS NULL",
|
||||
)
|
||||
.bind(tenant_pubkey)
|
||||
.fetch_one(&mut **tx)
|
||||
.await?;
|
||||
|
||||
if total <= 0 {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let invoice =
|
||||
insert_invoice_tx(tx, invoice_id, tenant_pubkey, period_start, period_end).await?;
|
||||
|
||||
sqlx::query(
|
||||
"UPDATE invoice_item SET invoice_id = ?
|
||||
WHERE tenant_pubkey = ? AND invoice_id IS NULL",
|
||||
)
|
||||
.bind(invoice_id)
|
||||
.bind(tenant_pubkey)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
|
||||
Ok(Some(invoice))
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn mark_invoice_paid(invoice_id: &str, method: &str) -> Result<()> {
|
||||
let updated_at = chrono::Utc::now().timestamp();
|
||||
|
||||
let activity = with_tx(async |tx| {
|
||||
sqlx::query("UPDATE invoice SET status = 'paid', method = ?, updated_at = ? WHERE id = ?")
|
||||
.bind(method)
|
||||
.bind(updated_at)
|
||||
.bind(invoice_id)
|
||||
.execute(&mut **tx)
|
||||
pub async fn set_relay_subscription_item(
|
||||
&self,
|
||||
relay_id: &str,
|
||||
stripe_subscription_item_id: &str,
|
||||
) -> Result<()> {
|
||||
sqlx::query("UPDATE relay SET stripe_subscription_item_id = ? WHERE id = ?")
|
||||
.bind(stripe_subscription_item_id)
|
||||
.bind(relay_id)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
insert_activity_tx(tx, "invoice_paid", "invoice", invoice_id, None).await
|
||||
})
|
||||
.await?;
|
||||
publish(activity);
|
||||
Ok(())
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn set_tenant_subscription(
|
||||
&self,
|
||||
pubkey: &str,
|
||||
stripe_subscription_id: &str,
|
||||
) -> Result<()> {
|
||||
sqlx::query("UPDATE tenant SET stripe_subscription_id = ? WHERE pubkey = ?")
|
||||
.bind(stripe_subscription_id)
|
||||
.bind(pubkey)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn clear_tenant_subscription(&self, pubkey: &str) -> Result<()> {
|
||||
sqlx::query("UPDATE tenant SET stripe_subscription_id = NULL WHERE pubkey = ?")
|
||||
.bind(pubkey)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn set_tenant_nwc_error(&self, pubkey: &str, error: &str) -> Result<()> {
|
||||
sqlx::query("UPDATE tenant SET nwc_error = ? WHERE pubkey = ?")
|
||||
.bind(error)
|
||||
.bind(pubkey)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn clear_tenant_nwc_error(&self, pubkey: &str) -> Result<()> {
|
||||
sqlx::query("UPDATE tenant SET nwc_error = NULL WHERE pubkey = ?")
|
||||
.bind(pubkey)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn set_tenant_past_due(&self, pubkey: &str) -> Result<()> {
|
||||
let now = chrono::Utc::now().timestamp();
|
||||
sqlx::query("UPDATE tenant SET past_due_at = ? WHERE pubkey = ?")
|
||||
.bind(now)
|
||||
.bind(pubkey)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn clear_tenant_past_due(&self, pubkey: &str) -> Result<()> {
|
||||
sqlx::query("UPDATE tenant SET past_due_at = NULL WHERE pubkey = ?")
|
||||
.bind(pubkey)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
// --- Bolt11 records ---
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use sqlx::SqlitePool;
|
||||
use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions};
|
||||
use std::str::FromStr;
|
||||
|
||||
pub async fn insert_bolt11(
|
||||
invoice_id: &str,
|
||||
lnbc: &str,
|
||||
msats: i64,
|
||||
expires_at: i64,
|
||||
) -> Result<Option<Bolt11>> {
|
||||
let id = uuid::Uuid::new_v4().to_string();
|
||||
let created_at = chrono::Utc::now().timestamp();
|
||||
async fn test_pool() -> SqlitePool {
|
||||
let connect_options = SqliteConnectOptions::from_str("sqlite::memory:")
|
||||
.expect("valid sqlite memory url")
|
||||
.create_if_missing(true);
|
||||
|
||||
Ok(sqlx::query_as::<_, Bolt11>(
|
||||
"INSERT INTO bolt11 (id, invoice_id, lnbc, msats, created_at, expires_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?) RETURNING *",
|
||||
)
|
||||
.bind(id)
|
||||
.bind(invoice_id)
|
||||
.bind(lnbc)
|
||||
.bind(msats)
|
||||
.bind(created_at)
|
||||
.bind(expires_at)
|
||||
.fetch_optional(pool())
|
||||
.await?)
|
||||
}
|
||||
|
||||
pub async fn mark_bolt11_settled(bolt11_id: &str) -> Result<()> {
|
||||
let settled_at = chrono::Utc::now().timestamp();
|
||||
|
||||
sqlx::query("UPDATE bolt11 SET settled_at = ? WHERE id = ?")
|
||||
.bind(settled_at)
|
||||
.bind(bolt11_id)
|
||||
.execute(pool())
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// --- Intents ---
|
||||
|
||||
/// Record the Stripe PaymentIntent that paid an invoice. Keyed by the Stripe
|
||||
/// PaymentIntent id, so it's idempotent: a retried (idempotent) charge returns
|
||||
/// the same id and the re-insert is a no-op rather than a primary-key conflict.
|
||||
pub async fn insert_intent(intent_id: &str, invoice_id: &str) -> Result<()> {
|
||||
let created_at = chrono::Utc::now().timestamp();
|
||||
|
||||
sqlx::query(
|
||||
"INSERT INTO intent (id, invoice_id, created_at)
|
||||
VALUES (?, ?, ?) ON CONFLICT(id) DO NOTHING",
|
||||
)
|
||||
.bind(intent_id)
|
||||
.bind(invoice_id)
|
||||
.bind(created_at)
|
||||
.execute(pool())
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// --- Internal utils that take an explicit transaction ---
|
||||
|
||||
async fn insert_activity_tx(
|
||||
tx: &mut Transaction<'_, Sqlite>,
|
||||
activity_type: &str,
|
||||
resource_type: &str,
|
||||
resource_id: &str,
|
||||
plan_id: Option<&str>,
|
||||
) -> Result<Activity> {
|
||||
let tenant = match resource_type {
|
||||
"tenant" => resource_id.to_string(),
|
||||
"relay" => {
|
||||
sqlx::query_scalar::<_, String>("SELECT tenant FROM relay WHERE id = ?")
|
||||
.bind(resource_id)
|
||||
.fetch_one(&mut **tx)
|
||||
.await?
|
||||
}
|
||||
_ => anyhow::bail!("unknown resource_type: {resource_type}"),
|
||||
};
|
||||
|
||||
let id = uuid::Uuid::new_v4().to_string();
|
||||
let created_at = chrono::Utc::now().timestamp();
|
||||
|
||||
sqlx::query(
|
||||
"INSERT INTO activity (id, tenant, created_at, activity_type, resource_type, resource_id, plan_id)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||
)
|
||||
.bind(&id)
|
||||
.bind(&tenant)
|
||||
.bind(created_at)
|
||||
.bind(activity_type)
|
||||
.bind(resource_type)
|
||||
.bind(resource_id)
|
||||
.bind(plan_id)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
|
||||
Ok(Activity {
|
||||
id,
|
||||
tenant,
|
||||
created_at,
|
||||
activity_type: activity_type.to_string(),
|
||||
resource_type: resource_type.to_string(),
|
||||
resource_id: resource_id.to_string(),
|
||||
billed_at: None,
|
||||
plan_id: plan_id.map(str::to_string),
|
||||
})
|
||||
}
|
||||
|
||||
async fn insert_invoice_tx(
|
||||
tx: &mut Transaction<'_, Sqlite>,
|
||||
invoice_id: &str,
|
||||
tenant_pubkey: &str,
|
||||
period_start: i64,
|
||||
period_end: i64,
|
||||
) -> Result<Invoice> {
|
||||
let now = chrono::Utc::now().timestamp();
|
||||
|
||||
Ok(sqlx::query_as::<_, Invoice>(
|
||||
"INSERT INTO invoice (id, tenant_pubkey, status, period_start, period_end, created_at, updated_at)
|
||||
VALUES (?, ?, 'open', ?, ?, ?, ?) RETURNING *",
|
||||
)
|
||||
.bind(invoice_id)
|
||||
.bind(tenant_pubkey)
|
||||
.bind(period_start)
|
||||
.bind(period_end)
|
||||
.bind(now)
|
||||
.bind(now)
|
||||
.fetch_one(&mut **tx)
|
||||
.await?)
|
||||
}
|
||||
|
||||
async fn insert_invoice_item_tx(tx: &mut Transaction<'_, Sqlite>, item: &InvoiceItem) -> Result<()> {
|
||||
sqlx::query(
|
||||
"INSERT INTO invoice_item
|
||||
(id, invoice_id, activity_id, tenant_pubkey, relay_id, plan, amount, description, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
)
|
||||
.bind(&item.id)
|
||||
.bind(&item.invoice_id)
|
||||
.bind(&item.activity_id)
|
||||
.bind(&item.tenant_pubkey)
|
||||
.bind(&item.relay_id)
|
||||
.bind(&item.plan)
|
||||
.bind(item.amount)
|
||||
.bind(&item.description)
|
||||
.bind(item.created_at)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn mark_activity_billed_tx(
|
||||
tx: &mut Transaction<'_, Sqlite>,
|
||||
activity_id: &str,
|
||||
billed_at: i64,
|
||||
) -> Result<()> {
|
||||
sqlx::query("UPDATE activity SET billed_at = ? WHERE id = ?")
|
||||
.bind(billed_at)
|
||||
.bind(activity_id)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
Ok(())
|
||||
let pool = SqlitePoolOptions::new()
|
||||
.max_connections(1)
|
||||
.connect_with(connect_options)
|
||||
.await
|
||||
.expect("connect sqlite memory db");
|
||||
|
||||
sqlx::migrate!("./migrations")
|
||||
.run(&pool)
|
||||
.await
|
||||
.expect("run migrations");
|
||||
|
||||
pool
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn create_tenant_rejects_empty_stripe_customer_id() {
|
||||
let pool = test_pool().await;
|
||||
let command = Command::new(pool);
|
||||
|
||||
let tenant = Tenant {
|
||||
pubkey: "tenant_pubkey".to_string(),
|
||||
nwc_url: String::new(),
|
||||
nwc_error: None,
|
||||
created_at: 0,
|
||||
stripe_customer_id: " ".to_string(),
|
||||
stripe_subscription_id: None,
|
||||
past_due_at: None,
|
||||
};
|
||||
|
||||
let err = command
|
||||
.create_tenant(&tenant)
|
||||
.await
|
||||
.expect_err("empty customer id must be rejected");
|
||||
|
||||
assert!(
|
||||
err.to_string().contains("stripe_customer_id is required"),
|
||||
"unexpected error: {err}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,108 +0,0 @@
|
||||
use std::path::Path;
|
||||
use std::str::FromStr;
|
||||
use std::sync::OnceLock;
|
||||
|
||||
use anyhow::Result;
|
||||
use sqlx::{
|
||||
Sqlite, SqlitePool, Transaction,
|
||||
sqlite::{SqliteConnectOptions, SqlitePoolOptions},
|
||||
};
|
||||
use tokio::sync::broadcast;
|
||||
|
||||
use crate::env;
|
||||
use crate::models::Activity;
|
||||
|
||||
/// Process-wide connection pool. Set once at startup via [`init`]; read
|
||||
/// everywhere else via [`pool`], so command/query stay free functions instead of
|
||||
/// threading a handle through every service.
|
||||
static POOL: OnceLock<SqlitePool> = OnceLock::new();
|
||||
|
||||
/// Process-wide activity broadcast. Mutations record an [`Activity`] and call
|
||||
/// [`publish`] after their transaction commits; reactors (billing, infra)
|
||||
/// [`subscribe`] to react to durable changes.
|
||||
static NOTIFY: OnceLock<broadcast::Sender<Activity>> = OnceLock::new();
|
||||
|
||||
/// Create the connection pool from `env`, run migrations, and store it as the
|
||||
/// process-wide global. Panics if called more than once.
|
||||
pub async fn init() -> Result<()> {
|
||||
let pool = create_pool(&env::get().database_url).await?;
|
||||
POOL.set(pool).expect("pool already initialized");
|
||||
|
||||
let (notify, _) = broadcast::channel(64);
|
||||
NOTIFY.set(notify).expect("notify already initialized");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The global pool. Panics if [`init`] hasn't run yet.
|
||||
pub fn pool() -> &'static SqlitePool {
|
||||
POOL.get().expect("pool not initialized")
|
||||
}
|
||||
|
||||
/// Subscribe to the activity stream. Panics if [`init`] hasn't run yet.
|
||||
pub fn subscribe() -> broadcast::Receiver<Activity> {
|
||||
NOTIFY.get().expect("notify not initialized").subscribe()
|
||||
}
|
||||
|
||||
/// Broadcast an activity to subscribers. Called after the writing transaction
|
||||
/// commits, so reactors only ever observe durable rows. A send with no current
|
||||
/// subscribers is intentionally ignored.
|
||||
pub fn publish(activity: Activity) {
|
||||
if let Some(notify) = NOTIFY.get() {
|
||||
let _ = notify.send(activity);
|
||||
}
|
||||
}
|
||||
|
||||
/// Run `f` inside a transaction, commit on success, and roll back (on drop) if
|
||||
/// it returns an error. Returns whatever `f` produces. Callers compose the
|
||||
/// transaction-scoped `command`/`query` functions inside `f` to make a
|
||||
/// multi-step write atomic.
|
||||
pub async fn with_tx<F, T>(f: F) -> Result<T>
|
||||
where
|
||||
F: AsyncFnOnce(&mut Transaction<'_, Sqlite>) -> Result<T>,
|
||||
{
|
||||
let mut tx = pool().begin().await?;
|
||||
let value = f(&mut tx).await?;
|
||||
tx.commit().await?;
|
||||
Ok(value)
|
||||
}
|
||||
|
||||
async fn create_pool(database_url: &str) -> Result<SqlitePool> {
|
||||
let database_url = normalize_sqlite_url(database_url);
|
||||
|
||||
if let Some(path) = database_url.strip_prefix("sqlite://")
|
||||
&& !path.is_empty()
|
||||
&& path != ":memory:"
|
||||
&& let Some(parent) = Path::new(path).parent()
|
||||
&& !parent.as_os_str().is_empty()
|
||||
{
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
|
||||
let connect_options = SqliteConnectOptions::from_str(&database_url)?.create_if_missing(true);
|
||||
|
||||
let pool = SqlitePoolOptions::new()
|
||||
.max_connections(5)
|
||||
.connect_with(connect_options)
|
||||
.await?;
|
||||
|
||||
sqlx::query("PRAGMA journal_mode = WAL;")
|
||||
.execute(&pool)
|
||||
.await?;
|
||||
|
||||
sqlx::migrate!("./migrations").run(&pool).await?;
|
||||
|
||||
Ok(pool)
|
||||
}
|
||||
|
||||
fn normalize_sqlite_url(url: &str) -> String {
|
||||
let Some(path) = url.strip_prefix("sqlite://") else {
|
||||
return url.to_string();
|
||||
};
|
||||
|
||||
if path.is_empty() || path == ":memory:" || Path::new(path).is_absolute() {
|
||||
return url.to_string();
|
||||
}
|
||||
|
||||
format!("sqlite://{}/{}", env!("CARGO_MANIFEST_DIR"), path)
|
||||
}
|
||||
@@ -1,140 +0,0 @@
|
||||
use std::sync::OnceLock;
|
||||
|
||||
use anyhow::{Result, anyhow};
|
||||
use nostr_sdk::prelude::*;
|
||||
|
||||
/// Process-wide configuration, loaded once from the environment at startup via
|
||||
/// [`init`] and read everywhere else via [`get`].
|
||||
static ENV: OnceLock<Env> = OnceLock::new();
|
||||
|
||||
/// Load configuration from the environment and store it as the global. Panics
|
||||
/// if a required variable is missing or if called more than once.
|
||||
pub fn init() {
|
||||
ENV.set(Env::load())
|
||||
.unwrap_or_else(|_| panic!("env already initialized"));
|
||||
}
|
||||
|
||||
/// The global configuration. Panics if [`init`] hasn't run yet.
|
||||
pub fn get() -> &'static Env {
|
||||
ENV.get().expect("env not initialized")
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Env {
|
||||
pub server_host: String,
|
||||
pub server_port: u16,
|
||||
pub server_admin_pubkeys: Vec<String>,
|
||||
pub server_allow_origins: Vec<String>,
|
||||
pub app_url: String,
|
||||
pub database_url: String,
|
||||
pub robot_name: String,
|
||||
pub robot_wallet: String,
|
||||
pub robot_picture: String,
|
||||
pub robot_description: String,
|
||||
pub robot_outbox_relays: Vec<String>,
|
||||
pub robot_indexer_relays: Vec<String>,
|
||||
pub robot_messaging_relays: Vec<String>,
|
||||
pub blossom_s3_region: String,
|
||||
pub blossom_s3_bucket: String,
|
||||
pub blossom_s3_endpoint: String,
|
||||
pub blossom_s3_access_key: String,
|
||||
pub blossom_s3_secret_key: String,
|
||||
pub zooid_api_url: String,
|
||||
pub relay_domain: String,
|
||||
pub livekit_url: String,
|
||||
pub livekit_api_key: String,
|
||||
pub livekit_api_secret: String,
|
||||
pub stripe_secret_key: String,
|
||||
/// Parsed from `robot_secret`; used for nostr signing and nip44 encryption.
|
||||
pub keys: Keys,
|
||||
}
|
||||
|
||||
impl Env {
|
||||
fn load() -> Self {
|
||||
let keys = Keys::parse(&require_str("ROBOT_SECRET"))
|
||||
.expect("ROBOT_SECRET is not a valid nostr secret key");
|
||||
|
||||
Self {
|
||||
server_host: require_str("SERVER_HOST"),
|
||||
server_port: require_u16("SERVER_PORT"),
|
||||
server_admin_pubkeys: require_csv("SERVER_ADMIN_PUBKEYS"),
|
||||
server_allow_origins: require_csv("SERVER_ALLOW_ORIGINS"),
|
||||
app_url: require_str("APP_URL").trim_end_matches('/').to_string(),
|
||||
database_url: require_str("DATABASE_URL"),
|
||||
robot_name: require_str("ROBOT_NAME"),
|
||||
robot_wallet: require_str("ROBOT_WALLET"),
|
||||
robot_picture: require_str("ROBOT_PICTURE"),
|
||||
robot_description: require_str("ROBOT_DESCRIPTION"),
|
||||
robot_outbox_relays: require_csv("ROBOT_OUTBOX_RELAYS"),
|
||||
robot_indexer_relays: require_csv("ROBOT_INDEXER_RELAYS"),
|
||||
robot_messaging_relays: require_csv("ROBOT_MESSAGING_RELAYS"),
|
||||
blossom_s3_region: require_str("BLOSSOM_S3_REGION"),
|
||||
blossom_s3_bucket: require_str("BLOSSOM_S3_BUCKET"),
|
||||
blossom_s3_endpoint: require_str("BLOSSOM_S3_ENDPOINT"),
|
||||
blossom_s3_access_key: require_str("BLOSSOM_S3_ACCESS_KEY"),
|
||||
blossom_s3_secret_key: require_str("BLOSSOM_S3_SECRET_KEY"),
|
||||
zooid_api_url: require_str("ZOOID_API_URL"),
|
||||
relay_domain: require_str("RELAY_DOMAIN"),
|
||||
livekit_url: require_str("LIVEKIT_URL"),
|
||||
livekit_api_key: require_str("LIVEKIT_API_KEY"),
|
||||
livekit_api_secret: require_str("LIVEKIT_API_SECRET"),
|
||||
stripe_secret_key: require_str("STRIPE_SECRET_KEY"),
|
||||
keys,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn encrypt(&self, plaintext: &str) -> Result<String> {
|
||||
nip44::encrypt(
|
||||
self.keys.secret_key(),
|
||||
&self.keys.public_key(),
|
||||
plaintext,
|
||||
nip44::Version::V2,
|
||||
)
|
||||
.map_err(|e| anyhow!("encryption failed: {e}"))
|
||||
}
|
||||
|
||||
pub fn decrypt(&self, ciphertext: &str) -> Result<String> {
|
||||
nip44::decrypt(self.keys.secret_key(), &self.keys.public_key(), ciphertext)
|
||||
.map_err(|e| anyhow!("decryption failed: {e}"))
|
||||
}
|
||||
|
||||
pub async fn make_auth(&self, url: &str, method: HttpMethod) -> Result<String> {
|
||||
let server_url = Url::parse(url)?;
|
||||
let auth = HttpData::new(server_url, method)
|
||||
.to_authorization(&self.keys)
|
||||
.await?;
|
||||
Ok(auth)
|
||||
}
|
||||
}
|
||||
|
||||
fn require_str(key: &str) -> String {
|
||||
let v = std::env::var(key)
|
||||
.unwrap_or_else(|_| panic!("{key} is required"))
|
||||
.trim()
|
||||
.to_string();
|
||||
if v.is_empty() {
|
||||
panic!("{key} is required")
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
fn require_u16(key: &str) -> u16 {
|
||||
require_str(key)
|
||||
.parse()
|
||||
.unwrap_or_else(|_| panic!("{key} is invalid"))
|
||||
}
|
||||
|
||||
fn require_csv(key: &str) -> Vec<String> {
|
||||
let v: Vec<String> = std::env::var(key)
|
||||
.unwrap_or_default()
|
||||
.split(',')
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect();
|
||||
|
||||
if v.is_empty() {
|
||||
panic!("{key} is required");
|
||||
}
|
||||
|
||||
v
|
||||
}
|
||||
+142
-236
@@ -1,31 +1,44 @@
|
||||
use anyhow::Result;
|
||||
use nostr_sdk::prelude::*;
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::command;
|
||||
use crate::db;
|
||||
use crate::env;
|
||||
use crate::models::{Activity, RELAY_STATUS_DELINQUENT, RELAY_STATUS_INACTIVE, Relay};
|
||||
use crate::query;
|
||||
|
||||
const RELAY_SYNC_RETRY_BASE_DELAY_SECS: u64 = 30;
|
||||
const RELAY_SYNC_RETRY_MAX_DELAY_SECS: u64 = 15 * 60;
|
||||
const RELAY_SYNC_RETRY_MAX_ATTEMPTS: usize = 6;
|
||||
use crate::command::Command;
|
||||
use crate::models::{Activity, Relay, RELAY_STATUS_DELINQUENT, RELAY_STATUS_INACTIVE};
|
||||
use crate::query::Query;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Infra;
|
||||
pub struct Infra {
|
||||
api_url: String,
|
||||
relay_domain: String,
|
||||
livekit_url: String,
|
||||
livekit_api_key: String,
|
||||
livekit_api_secret: String,
|
||||
api_secret: String,
|
||||
query: Query,
|
||||
command: Command,
|
||||
}
|
||||
|
||||
impl Infra {
|
||||
pub fn new() -> Self {
|
||||
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();
|
||||
Self {
|
||||
api_url,
|
||||
relay_domain,
|
||||
livekit_url,
|
||||
livekit_api_key,
|
||||
livekit_api_secret,
|
||||
api_secret,
|
||||
query,
|
||||
command,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn start(self) {
|
||||
let mut rx = db::subscribe();
|
||||
|
||||
if let Err(error) = self.reconcile_relay_state("startup").await {
|
||||
tracing::error!(error = %error, "failed to reconcile relay state on startup");
|
||||
}
|
||||
let mut rx = self.command.notify.subscribe();
|
||||
|
||||
loop {
|
||||
match rx.recv().await {
|
||||
@@ -36,10 +49,6 @@ impl Infra {
|
||||
}
|
||||
Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => {
|
||||
tracing::warn!(missed = n, "infra lagged");
|
||||
|
||||
if let Err(error) = self.reconcile_relay_state("lagged").await {
|
||||
tracing::error!(error = %error, "failed to reconcile relay state after lag");
|
||||
}
|
||||
}
|
||||
Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
|
||||
}
|
||||
@@ -47,251 +56,148 @@ impl Infra {
|
||||
}
|
||||
|
||||
async fn handle_activity(&self, activity: &Activity) -> Result<()> {
|
||||
let needs_sync = matches!(
|
||||
activity.activity_type.as_str(),
|
||||
"create_relay" | "update_relay" | "activate_relay" | "deactivate_relay" | "fail_relay_sync"
|
||||
);
|
||||
let needs_sync = should_sync_relay_activity(activity.activity_type.as_str());
|
||||
|
||||
if activity.resource_type != "relay" || !needs_sync {
|
||||
return Ok(());
|
||||
}
|
||||
if needs_sync {
|
||||
let Some(relay) = self.query.get_relay(&activity.resource_id).await? else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
if activity.activity_type == "fail_relay_sync" {
|
||||
self.schedule_relay_sync_retry(&activity.resource_id, "activity").await?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let Some(relay) = query::get_relay(&activity.resource_id).await? else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
self.sync_relay(&relay).await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn reconcile_relay_state(&self, source: &str) -> Result<()> {
|
||||
let relays = query::list_relays_pending_sync().await?;
|
||||
|
||||
if relays.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
tracing::info!(source, relay_count = relays.len(), "reconciling pending relay state");
|
||||
|
||||
for relay in relays {
|
||||
if relay.sync_error.trim().is_empty() {
|
||||
self.sync_relay(&relay).await;
|
||||
} else {
|
||||
self.schedule_relay_sync_retry(&relay.id, source).await?;
|
||||
}
|
||||
let is_new = relay.synced == 0;
|
||||
self.sync_and_report(&relay, is_new).await;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn schedule_relay_sync_retry(&self, relay_id: &str, source: &str) -> Result<()> {
|
||||
fn get_retry_delay(consecutive_failures: usize) -> Option<Duration> {
|
||||
let retry_attempt = consecutive_failures.max(1);
|
||||
if retry_attempt > RELAY_SYNC_RETRY_MAX_ATTEMPTS {
|
||||
return None;
|
||||
}
|
||||
|
||||
let exponent = (retry_attempt - 1).min(31);
|
||||
let multiplier = 1u64 << exponent;
|
||||
let delay_secs = RELAY_SYNC_RETRY_BASE_DELAY_SECS
|
||||
.saturating_mul(multiplier)
|
||||
.min(RELAY_SYNC_RETRY_MAX_DELAY_SECS);
|
||||
|
||||
Some(Duration::from_secs(delay_secs))
|
||||
}
|
||||
|
||||
let activities = query::list_activity_for_resource(relay_id).await?;
|
||||
let consecutive_failures = activities
|
||||
.iter()
|
||||
.take_while(|activity| activity.activity_type == "fail_relay_sync")
|
||||
.count();
|
||||
|
||||
let Some(delay) = get_retry_delay(consecutive_failures) else {
|
||||
tracing::warn!(
|
||||
relay = relay_id,
|
||||
consecutive_failures,
|
||||
max_attempts = RELAY_SYNC_RETRY_MAX_ATTEMPTS,
|
||||
"relay sync retries exhausted; awaiting manual intervention"
|
||||
);
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
tracing::info!(
|
||||
relay = relay_id,
|
||||
source,
|
||||
consecutive_failures,
|
||||
delay_secs = delay.as_secs(),
|
||||
"scheduled relay sync retry"
|
||||
);
|
||||
|
||||
let relay_id = relay_id.to_string();
|
||||
let infra = self.clone();
|
||||
|
||||
tokio::spawn(async move {
|
||||
tokio::time::sleep(delay).await;
|
||||
|
||||
match query::get_relay(&relay_id).await {
|
||||
Ok(Some(relay)) => infra.sync_relay(&relay).await,
|
||||
Ok(None) => {}
|
||||
Err(e) => {
|
||||
tracing::error!(relay = %relay_id, error = %e, "relay sync retry task failed");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn sync_relay(&self, relay: &Relay) {
|
||||
match self.try_sync_relay(relay).await {
|
||||
async fn sync_and_report(&self, relay: &Relay, is_new: bool) {
|
||||
match self.sync_relay(relay, is_new).await {
|
||||
Ok(()) => {
|
||||
tracing::info!(relay = %relay.id, "relay sync succeeded");
|
||||
if let Err(e) = command::complete_relay_sync(&relay.id).await {
|
||||
if let Err(e) = self.command.complete_relay_sync(&relay.id).await {
|
||||
tracing::error!(relay = %relay.id, error = %e, "failed to mark sync complete");
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(relay = %relay.id, error = %e, "relay sync failed");
|
||||
if let Err(e2) = command::fail_relay_sync(relay, e.to_string()).await {
|
||||
if let Err(e2) = self.command.fail_relay_sync(relay, e.to_string()).await {
|
||||
tracing::error!(relay = %relay.id, error = %e2, "failed to record sync failure");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn try_sync_relay(&self, relay: &Relay) -> Result<()> {
|
||||
// A relay is "new" (POST with a freshly generated secret) only if it has
|
||||
// never completed a sync. `synced == 1` short-circuits the activity lookup;
|
||||
// otherwise check the activity history so that a re-sync after an update
|
||||
// (which resets `synced` to 0) PATCHes instead of clobbering the secret.
|
||||
let is_new = relay.synced != 1
|
||||
&& query::get_latest_activity_for_resource_and_type(&relay.id, "complete_relay_sync")
|
||||
.await?
|
||||
.is_none();
|
||||
|
||||
let mut body = serde_json::json!({
|
||||
"host": format!("{}.{}", relay.subdomain, env::get().relay_domain),
|
||||
"schema": relay.id,
|
||||
"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": if relay.blossom_enabled == 1 {
|
||||
serde_json::json!({
|
||||
"enabled": true,
|
||||
"adapter": "s3",
|
||||
"s3": {
|
||||
"endpoint": env::get().blossom_s3_endpoint,
|
||||
"region": env::get().blossom_s3_region,
|
||||
"bucket": env::get().blossom_s3_bucket,
|
||||
"access_key": env::get().blossom_s3_access_key,
|
||||
"secret_key": env::get().blossom_s3_secret_key,
|
||||
"key_prefix": relay.id,
|
||||
},
|
||||
})
|
||||
} else {
|
||||
serde_json::json!({ "enabled": false })
|
||||
},
|
||||
"livekit": if relay.livekit_enabled == 1 {
|
||||
serde_json::json!({
|
||||
"enabled": true,
|
||||
"server_url": env::get().livekit_url,
|
||||
"api_key": env::get().livekit_api_key,
|
||||
"api_secret": env::get().livekit_api_secret,
|
||||
})
|
||||
} else {
|
||||
serde_json::json!({ "enabled": false })
|
||||
},
|
||||
"push": { "enabled": relay.push_enabled == 1 },
|
||||
"roles": {
|
||||
"admin": { "can_manage": true, "can_invite": true },
|
||||
"member": { "can_invite": true },
|
||||
},
|
||||
});
|
||||
|
||||
// Only provide a secret if the relay is new. This allows us to not store the relay secrets on our side.
|
||||
if is_new && let Some(obj) = body.as_object_mut() {
|
||||
obj.insert(
|
||||
"secret".to_string(),
|
||||
serde_json::Value::String(Keys::generate().secret_key().to_secret_hex()),
|
||||
);
|
||||
}
|
||||
|
||||
let method = if is_new { HttpMethod::POST } else { HttpMethod::PATCH };
|
||||
self.request(method, &format!("relay/{}", relay.id), Some(&body))
|
||||
async fn nip98_auth(&self, url: &str, method: HttpMethod) -> Result<String> {
|
||||
let keys = Keys::parse(&self.api_secret)?;
|
||||
let server_url = Url::parse(url)?;
|
||||
let auth = HttpData::new(server_url, method)
|
||||
.to_authorization(&keys)
|
||||
.await?;
|
||||
Ok(())
|
||||
Ok(auth)
|
||||
}
|
||||
|
||||
pub async fn list_relay_members(&self, relay_id: &str) -> Result<Vec<String>> {
|
||||
#[derive(serde::Deserialize)]
|
||||
struct MembersResponse {
|
||||
members: Vec<String>,
|
||||
}
|
||||
async fn sync_relay(&self, relay: &Relay, is_new: bool) -> Result<()> {
|
||||
let client = reqwest::Client::new();
|
||||
let base = self.api_url.trim_end_matches('/');
|
||||
|
||||
let response = self
|
||||
.request(HttpMethod::GET, &format!("relay/{relay_id}/members"), None)
|
||||
.await?;
|
||||
let parsed: MembersResponse = response.json().await?;
|
||||
Ok(parsed.members)
|
||||
}
|
||||
|
||||
// Internal utilities
|
||||
|
||||
/// Sends an authenticated request to the zooid API at `path` (relative to
|
||||
/// `env.zooid_api_url`). Returns the response on 2xx; bails with the body
|
||||
/// text otherwise.
|
||||
async fn request(
|
||||
&self,
|
||||
method: HttpMethod,
|
||||
path: &str,
|
||||
body: Option<&serde_json::Value>,
|
||||
) -> Result<reqwest::Response> {
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(5))
|
||||
.build()?;
|
||||
let base = env::get().zooid_api_url.trim_end_matches('/');
|
||||
let path = path.trim_start_matches('/');
|
||||
let url = format!("{base}/{path}");
|
||||
let auth = env::get().make_auth(&url, method).await?;
|
||||
|
||||
let reqwest_method = match method {
|
||||
HttpMethod::GET => reqwest::Method::GET,
|
||||
HttpMethod::POST => reqwest::Method::POST,
|
||||
HttpMethod::PUT => reqwest::Method::PUT,
|
||||
HttpMethod::PATCH => reqwest::Method::PATCH,
|
||||
let host = if self.relay_domain.is_empty() {
|
||||
relay.subdomain.clone()
|
||||
} else {
|
||||
format!("{}.{}", relay.subdomain, self.relay_domain)
|
||||
};
|
||||
|
||||
let mut req = client
|
||||
.request(reqwest_method, &url)
|
||||
.header("Authorization", auth);
|
||||
if let Some(body) = body {
|
||||
req = req.json(body);
|
||||
}
|
||||
let livekit = if relay.livekit_enabled == 1 {
|
||||
serde_json::json!({
|
||||
"enabled": true,
|
||||
"server_url": self.livekit_url,
|
||||
"api_key": self.livekit_api_key,
|
||||
"api_secret": self.livekit_api_secret,
|
||||
})
|
||||
} else {
|
||||
serde_json::json!({ "enabled": false })
|
||||
};
|
||||
|
||||
let response = req.send().await?;
|
||||
let body = relay_sync_body(
|
||||
relay,
|
||||
host,
|
||||
livekit,
|
||||
is_new.then(|| Keys::generate().secret_key().to_secret_hex()),
|
||||
);
|
||||
|
||||
let url = format!("{}/relay/{}", base, relay.id);
|
||||
let auth = self.nip98_auth(&url, zooid_sync_http_method(is_new)).await?;
|
||||
|
||||
let request = if is_new {
|
||||
client.post(&url)
|
||||
} else {
|
||||
client.patch(&url)
|
||||
};
|
||||
|
||||
let response = request
|
||||
.header("Authorization", auth)
|
||||
.json(&body)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
let status = response.status();
|
||||
let text = response.text().await.unwrap_or_default();
|
||||
anyhow::bail!("zooid {method} {path} returned {status}: {text}");
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
anyhow::bail!("zooid sync returned {status}: {body}")
|
||||
}
|
||||
Ok(response)
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn zooid_sync_http_method(is_new: bool) -> HttpMethod {
|
||||
if is_new {
|
||||
HttpMethod::POST
|
||||
} else {
|
||||
HttpMethod::PATCH
|
||||
}
|
||||
}
|
||||
|
||||
fn relay_sync_body(
|
||||
relay: &Relay,
|
||||
host: String,
|
||||
livekit: serde_json::Value,
|
||||
secret: Option<String>,
|
||||
) -> serde_json::Value {
|
||||
let mut body = serde_json::json!({
|
||||
"host": host,
|
||||
"schema": relay.schema,
|
||||
"inactive": relay.status == RELAY_STATUS_INACTIVE
|
||||
|| relay.status == RELAY_STATUS_DELINQUENT,
|
||||
"info": {
|
||||
"name": relay.info_name,
|
||||
"icon": relay.info_icon,
|
||||
"description": relay.info_description,
|
||||
"pubkey": relay.tenant,
|
||||
},
|
||||
"policy": {
|
||||
"public_join": relay.policy_public_join == 1,
|
||||
"strip_signatures": relay.policy_strip_signatures == 1,
|
||||
},
|
||||
"groups": { "enabled": relay.groups_enabled == 1 },
|
||||
"management": { "enabled": relay.management_enabled == 1 },
|
||||
"blossom": { "enabled": relay.blossom_enabled == 1 },
|
||||
"livekit": livekit,
|
||||
"push": { "enabled": relay.push_enabled == 1 },
|
||||
"roles": {
|
||||
"admin": { "can_manage": true, "can_invite": true },
|
||||
"member": { "can_invite": true },
|
||||
},
|
||||
});
|
||||
|
||||
if let (Some(secret), Some(body_obj)) = (secret, body.as_object_mut()) {
|
||||
body_obj.insert("secret".to_string(), serde_json::Value::String(secret));
|
||||
}
|
||||
|
||||
body
|
||||
}
|
||||
|
||||
fn should_sync_relay_activity(activity_type: &str) -> bool {
|
||||
matches!(
|
||||
activity_type,
|
||||
"create_relay" | "update_relay" | "activate_relay" | "deactivate_relay"
|
||||
)
|
||||
}
|
||||
|
||||
+1
-7
@@ -1,14 +1,8 @@
|
||||
pub mod api;
|
||||
pub mod billing;
|
||||
pub mod bitcoin;
|
||||
pub mod command;
|
||||
pub mod env;
|
||||
pub mod infra;
|
||||
pub mod models;
|
||||
pub mod db;
|
||||
pub mod pool;
|
||||
pub mod query;
|
||||
pub mod robot;
|
||||
pub mod routes;
|
||||
pub mod stripe;
|
||||
pub mod wallet;
|
||||
pub mod web;
|
||||
|
||||
+32
-33
@@ -1,28 +1,23 @@
|
||||
mod api;
|
||||
mod billing;
|
||||
mod bitcoin;
|
||||
mod command;
|
||||
mod env;
|
||||
mod infra;
|
||||
mod models;
|
||||
mod db;
|
||||
mod pool;
|
||||
mod query;
|
||||
mod robot;
|
||||
mod routes;
|
||||
mod stripe;
|
||||
mod wallet;
|
||||
mod web;
|
||||
|
||||
use anyhow::Result;
|
||||
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
|
||||
|
||||
use tower_http::cors::{AllowOrigin, CorsLayer, Any};
|
||||
use tower_http::cors::{AllowOrigin, CorsLayer};
|
||||
|
||||
use crate::api::Api;
|
||||
use crate::billing::Billing;
|
||||
use crate::command::Command;
|
||||
use crate::infra::Infra;
|
||||
use crate::query::Query;
|
||||
use crate::robot::Robot;
|
||||
use crate::stripe::Stripe;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
@@ -33,25 +28,35 @@ async fn main() -> Result<()> {
|
||||
.with(tracing_subscriber::fmt::layer())
|
||||
.init();
|
||||
|
||||
env::init();
|
||||
|
||||
db::init().await?;
|
||||
|
||||
let pool = pool::create_pool().await?;
|
||||
let robot = Robot::new().await?;
|
||||
let stripe = Stripe::new();
|
||||
let infra = Infra::new();
|
||||
let billing = Billing::new(robot.clone());
|
||||
let api = Api::new(billing.clone(), stripe, robot, infra.clone());
|
||||
let query = Query::new(pool.clone());
|
||||
let command = Command::new(pool);
|
||||
let billing = Billing::new(query.clone(), command.clone(), robot.clone());
|
||||
let infra = Infra::new(query.clone(), command.clone());
|
||||
let api = Api::new(query, command, billing.clone());
|
||||
|
||||
let parsed = env::get()
|
||||
.server_allow_origins
|
||||
.iter()
|
||||
.filter_map(|o| o.parse::<axum::http::HeaderValue>().ok())
|
||||
.collect::<Vec<_>>();
|
||||
let cors = CorsLayer::new()
|
||||
.allow_origin(AllowOrigin::list(parsed))
|
||||
.allow_methods(Any)
|
||||
.allow_headers(Any);
|
||||
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);
|
||||
let origins: Vec<String> = std::env::var("ALLOW_ORIGINS")
|
||||
.unwrap_or_default()
|
||||
.split(',')
|
||||
.map(|v| v.trim().to_string())
|
||||
.filter(|v| !v.is_empty())
|
||||
.collect();
|
||||
|
||||
let cors = if origins.is_empty() {
|
||||
CorsLayer::permissive()
|
||||
} else {
|
||||
let parsed = origins
|
||||
.iter()
|
||||
.filter_map(|o| o.parse::<axum::http::HeaderValue>().ok())
|
||||
.collect::<Vec<_>>();
|
||||
CorsLayer::new().allow_origin(AllowOrigin::list(parsed))
|
||||
};
|
||||
|
||||
let app = api.router().layer(cors);
|
||||
|
||||
@@ -63,13 +68,7 @@ async fn main() -> Result<()> {
|
||||
billing.start().await;
|
||||
});
|
||||
|
||||
let listener =
|
||||
tokio::net::TcpListener::bind(format!(
|
||||
"{}:{}",
|
||||
env::get().server_host,
|
||||
env::get().server_port
|
||||
))
|
||||
.await?;
|
||||
let listener = tokio::net::TcpListener::bind(format!("{host}:{port}")).await?;
|
||||
axum::serve(listener, app).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
+6
-78
@@ -12,10 +12,6 @@ pub struct Activity {
|
||||
pub activity_type: String,
|
||||
pub resource_type: String,
|
||||
pub resource_id: String,
|
||||
pub billed_at: Option<i64>,
|
||||
/// The relay's plan at the time of a `create_relay`/`update_relay` activity;
|
||||
/// `None` for all other activity types.
|
||||
pub plan_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@@ -26,27 +22,28 @@ pub struct Plan {
|
||||
pub members: Option<i64>,
|
||||
pub blossom: bool,
|
||||
pub livekit: bool,
|
||||
pub stripe_price_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Clone, Serialize, Deserialize, sqlx::FromRow)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
|
||||
pub struct Tenant {
|
||||
pub pubkey: String,
|
||||
pub nwc_url: String,
|
||||
pub nwc_error: Option<String>,
|
||||
pub created_at: i64,
|
||||
pub billing_anchor: Option<i64>,
|
||||
pub stripe_customer_id: String,
|
||||
/// `period_start` of the most recent period this tenant was renewed for, or
|
||||
/// `None` if never renewed. The per-period renewal idempotency marker.
|
||||
pub renewed_at: Option<i64>,
|
||||
pub stripe_subscription_id: Option<String>,
|
||||
pub past_due_at: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
|
||||
pub struct Relay {
|
||||
pub id: String,
|
||||
pub tenant: String,
|
||||
pub schema: String,
|
||||
pub subdomain: String,
|
||||
pub plan: String,
|
||||
pub stripe_subscription_item_id: Option<String>,
|
||||
pub status: String,
|
||||
pub sync_error: String,
|
||||
pub info_name: String,
|
||||
@@ -61,72 +58,3 @@ pub struct Relay {
|
||||
pub push_enabled: i64,
|
||||
pub synced: i64,
|
||||
}
|
||||
|
||||
impl Default for Relay {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
id: String::new(),
|
||||
tenant: String::new(),
|
||||
subdomain: String::new(),
|
||||
plan: String::new(),
|
||||
status: RELAY_STATUS_ACTIVE.to_string(),
|
||||
sync_error: String::new(),
|
||||
info_name: String::new(),
|
||||
info_icon: String::new(),
|
||||
info_description: String::new(),
|
||||
policy_public_join: 0,
|
||||
policy_strip_signatures: 0,
|
||||
groups_enabled: 1,
|
||||
management_enabled: 1,
|
||||
blossom_enabled: 0,
|
||||
livekit_enabled: 0,
|
||||
push_enabled: 1,
|
||||
synced: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
|
||||
pub struct Invoice {
|
||||
pub id: String,
|
||||
pub tenant_pubkey: String,
|
||||
pub status: String,
|
||||
pub period_start: i64,
|
||||
pub period_end: i64,
|
||||
pub created_at: i64,
|
||||
pub updated_at: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
|
||||
pub struct InvoiceItem {
|
||||
pub id: String,
|
||||
/// `None` while outstanding; set once the item is claimed onto an invoice.
|
||||
pub invoice_id: Option<String>,
|
||||
/// `None` for renewal items, which have no source activity.
|
||||
pub activity_id: Option<String>,
|
||||
pub tenant_pubkey: String,
|
||||
pub relay_id: String,
|
||||
pub plan: String,
|
||||
pub amount: i64,
|
||||
pub description: String,
|
||||
pub created_at: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
|
||||
pub struct Bolt11 {
|
||||
pub id: String,
|
||||
pub invoice_id: String,
|
||||
pub lnbc: String,
|
||||
pub msats: i64,
|
||||
pub created_at: i64,
|
||||
pub expires_at: i64,
|
||||
pub settled_at: Option<i64>,
|
||||
}
|
||||
|
||||
#[allow(dead_code)] // backs the `intent` table for the (not yet implemented) Stripe intent flow
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
|
||||
pub struct Intent {
|
||||
pub id: String,
|
||||
pub invoice_id: String,
|
||||
pub created_at: i64,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
use std::path::Path;
|
||||
use std::str::FromStr;
|
||||
|
||||
use anyhow::Result;
|
||||
use sqlx::{
|
||||
SqlitePool,
|
||||
sqlite::{SqliteConnectOptions, SqlitePoolOptions},
|
||||
};
|
||||
|
||||
pub async fn create_pool() -> Result<SqlitePool> {
|
||||
let raw_database_url = std::env::var("DATABASE_URL")
|
||||
.unwrap_or_else(|_| format!("sqlite://{}/data/caravel.db", env!("CARGO_MANIFEST_DIR")));
|
||||
let database_url = normalize_sqlite_url(&raw_database_url);
|
||||
|
||||
if let Some(path) = database_url.strip_prefix("sqlite://")
|
||||
&& !path.is_empty()
|
||||
&& path != ":memory:"
|
||||
&& let Some(parent) = Path::new(path).parent()
|
||||
&& !parent.as_os_str().is_empty()
|
||||
{
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
|
||||
let connect_options = SqliteConnectOptions::from_str(&database_url)?.create_if_missing(true);
|
||||
|
||||
let pool = SqlitePoolOptions::new()
|
||||
.max_connections(5)
|
||||
.connect_with(connect_options)
|
||||
.await?;
|
||||
|
||||
sqlx::query("PRAGMA journal_mode = WAL;")
|
||||
.execute(&pool)
|
||||
.await?;
|
||||
|
||||
sqlx::migrate!("./migrations").run(&pool).await?;
|
||||
|
||||
Ok(pool)
|
||||
}
|
||||
|
||||
fn normalize_sqlite_url(url: &str) -> String {
|
||||
let Some(path) = url.strip_prefix("sqlite://") else {
|
||||
return url.to_string();
|
||||
};
|
||||
|
||||
if path.is_empty() || path == ":memory:" || Path::new(path).is_absolute() {
|
||||
return url.to_string();
|
||||
}
|
||||
|
||||
format!("sqlite://{}/{}", env!("CARGO_MANIFEST_DIR"), path)
|
||||
}
|
||||
+154
-216
@@ -1,232 +1,170 @@
|
||||
use anyhow::Result;
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
use crate::models::{Activity, Bolt11, Invoice, InvoiceItem, Plan, Relay, Tenant};
|
||||
use crate::db::pool;
|
||||
use crate::models::{Activity, Plan, Relay, Tenant};
|
||||
|
||||
fn select_tenant(tail: &str) -> String {
|
||||
format!("SELECT * FROM tenant {tail}")
|
||||
#[derive(Clone)]
|
||||
pub struct Query {
|
||||
pool: SqlitePool,
|
||||
}
|
||||
|
||||
fn select_relay(tail: &str) -> String {
|
||||
format!("SELECT * FROM relay {tail}")
|
||||
}
|
||||
impl Query {
|
||||
pub fn new(pool: SqlitePool) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
|
||||
fn select_activity(tail: &str) -> String {
|
||||
format!("SELECT * FROM activity {tail}")
|
||||
}
|
||||
pub async fn list_tenants(&self) -> Result<Vec<Tenant>> {
|
||||
let rows = sqlx::query_as::<_, Tenant>(
|
||||
"SELECT pubkey, nwc_url, nwc_error, created_at, stripe_customer_id, stripe_subscription_id, past_due_at
|
||||
FROM tenant
|
||||
ORDER BY pubkey",
|
||||
)
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
// Plans
|
||||
|
||||
pub fn list_plans() -> Vec<Plan> {
|
||||
vec![
|
||||
Plan {
|
||||
id: "free".to_string(),
|
||||
name: "Free".to_string(),
|
||||
amount: 0,
|
||||
members: Some(10),
|
||||
blossom: false,
|
||||
livekit: false,
|
||||
},
|
||||
Plan {
|
||||
id: "basic".to_string(),
|
||||
name: "Basic".to_string(),
|
||||
amount: 500,
|
||||
members: Some(100),
|
||||
blossom: true,
|
||||
livekit: true,
|
||||
},
|
||||
Plan {
|
||||
id: "growth".to_string(),
|
||||
name: "Growth".to_string(),
|
||||
amount: 2500,
|
||||
members: None,
|
||||
blossom: true,
|
||||
livekit: true,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
pub fn get_plan(plan_id: &str) -> Option<Plan> {
|
||||
list_plans().into_iter().find(|p| p.id == plan_id)
|
||||
}
|
||||
|
||||
// Tenants
|
||||
|
||||
pub async fn list_tenants() -> Result<Vec<Tenant>> {
|
||||
Ok(sqlx::query_as::<_, Tenant>(&select_tenant(""))
|
||||
.fetch_all(pool())
|
||||
.await?)
|
||||
}
|
||||
|
||||
pub async fn get_tenant(pubkey: &str) -> Result<Option<Tenant>> {
|
||||
Ok(sqlx::query_as::<_, Tenant>(&select_tenant("WHERE pubkey = ?"))
|
||||
pub async fn get_tenant(&self, pubkey: &str) -> Result<Option<Tenant>> {
|
||||
let row = sqlx::query_as::<_, Tenant>(
|
||||
"SELECT pubkey, nwc_url, nwc_error, created_at, stripe_customer_id, stripe_subscription_id, past_due_at
|
||||
FROM tenant
|
||||
WHERE pubkey = ?",
|
||||
)
|
||||
.bind(pubkey)
|
||||
.fetch_optional(pool())
|
||||
.await?)
|
||||
}
|
||||
.fetch_optional(&self.pool)
|
||||
.await?;
|
||||
Ok(row)
|
||||
}
|
||||
|
||||
// Relays
|
||||
pub fn list_plans() -> Vec<Plan> {
|
||||
vec![
|
||||
Plan {
|
||||
id: "free".to_string(),
|
||||
name: "Free".to_string(),
|
||||
amount: 0,
|
||||
members: Some(10),
|
||||
blossom: false,
|
||||
livekit: false,
|
||||
stripe_price_id: None,
|
||||
},
|
||||
Plan {
|
||||
id: "basic".to_string(),
|
||||
name: "Basic".to_string(),
|
||||
amount: 500,
|
||||
members: Some(100),
|
||||
blossom: true,
|
||||
livekit: true,
|
||||
stripe_price_id: Some(std::env::var("STRIPE_PRICE_BASIC").unwrap_or_default()),
|
||||
},
|
||||
Plan {
|
||||
id: "growth".to_string(),
|
||||
name: "Growth".to_string(),
|
||||
amount: 2500,
|
||||
members: None,
|
||||
blossom: true,
|
||||
livekit: true,
|
||||
stripe_price_id: Some(std::env::var("STRIPE_PRICE_GROWTH").unwrap_or_default()),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
pub async fn list_relays() -> Result<Vec<Relay>> {
|
||||
Ok(sqlx::query_as::<_, Relay>(&select_relay(""))
|
||||
.fetch_all(pool())
|
||||
.await?)
|
||||
}
|
||||
pub fn get_plan(plan_id: &str) -> Option<Plan> {
|
||||
Self::list_plans().into_iter().find(|p| p.id == plan_id)
|
||||
}
|
||||
|
||||
pub async fn list_relays_pending_sync() -> Result<Vec<Relay>> {
|
||||
Ok(
|
||||
sqlx::query_as::<_, Relay>(&select_relay("WHERE synced = 0 OR TRIM(sync_error) != ''"))
|
||||
.fetch_all(pool())
|
||||
.await?,
|
||||
)
|
||||
}
|
||||
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_for_tenant(tenant_id: &str) -> Result<Vec<Relay>> {
|
||||
Ok(sqlx::query_as::<_, Relay>(&select_relay("WHERE tenant = ?"))
|
||||
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,
|
||||
status, sync_error,
|
||||
info_name, info_icon, info_description,
|
||||
policy_public_join, policy_strip_signatures,
|
||||
groups_enabled, management_enabled, blossom_enabled,
|
||||
livekit_enabled, push_enabled, synced
|
||||
FROM relay
|
||||
ORDER BY id",
|
||||
)
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
pub async fn list_relays_for_tenant(&self, tenant_id: &str) -> Result<Vec<Relay>> {
|
||||
let rows = sqlx::query_as::<_, Relay>(
|
||||
"SELECT id, tenant, schema, subdomain, plan, stripe_subscription_item_id,
|
||||
status, sync_error,
|
||||
info_name, info_icon, info_description,
|
||||
policy_public_join, policy_strip_signatures,
|
||||
groups_enabled, management_enabled, blossom_enabled,
|
||||
livekit_enabled, push_enabled, synced
|
||||
FROM relay
|
||||
WHERE tenant = ?
|
||||
ORDER BY id",
|
||||
)
|
||||
.bind(tenant_id)
|
||||
.fetch_all(pool())
|
||||
.await?)
|
||||
}
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
pub async fn get_relay(id: &str) -> Result<Option<Relay>> {
|
||||
Ok(sqlx::query_as::<_, Relay>(&select_relay("WHERE id = ?"))
|
||||
pub async fn get_relay(&self, id: &str) -> Result<Option<Relay>> {
|
||||
let row = sqlx::query_as::<_, Relay>(
|
||||
"SELECT id, tenant, schema, subdomain, plan, stripe_subscription_item_id,
|
||||
status, sync_error,
|
||||
info_name, info_icon, info_description,
|
||||
policy_public_join, policy_strip_signatures,
|
||||
groups_enabled, management_enabled, blossom_enabled,
|
||||
livekit_enabled, push_enabled, synced
|
||||
FROM relay
|
||||
WHERE id = ?",
|
||||
)
|
||||
.bind(id)
|
||||
.fetch_optional(pool())
|
||||
.await?)
|
||||
}
|
||||
|
||||
// Invoices
|
||||
|
||||
pub async fn get_invoice(invoice_id: &str) -> Result<Option<Invoice>> {
|
||||
Ok(sqlx::query_as::<_, Invoice>("SELECT * FROM invoice WHERE id = ?")
|
||||
.bind(invoice_id)
|
||||
.fetch_optional(pool())
|
||||
.await?)
|
||||
}
|
||||
|
||||
pub async fn list_invoices(tenant_pubkey: &str) -> Result<Vec<Invoice>> {
|
||||
Ok(sqlx::query_as::<_, Invoice>(
|
||||
"SELECT * FROM invoice WHERE tenant_pubkey = ? ORDER BY created_at DESC",
|
||||
)
|
||||
.bind(tenant_pubkey)
|
||||
.fetch_all(pool())
|
||||
.await?)
|
||||
}
|
||||
|
||||
pub async fn get_latest_invoice(tenant_pubkey: &str) -> Result<Option<Invoice>> {
|
||||
Ok(sqlx::query_as::<_, Invoice>(
|
||||
"SELECT * FROM invoice WHERE tenant_pubkey = ? ORDER BY created_at DESC LIMIT 1",
|
||||
)
|
||||
.bind(tenant_pubkey)
|
||||
.fetch_optional(pool())
|
||||
.await?)
|
||||
}
|
||||
|
||||
pub async fn get_invoice_items_for_invoice(invoice_id: &str) -> Result<Vec<InvoiceItem>> {
|
||||
Ok(
|
||||
sqlx::query_as::<_, InvoiceItem>("SELECT * FROM invoice_item WHERE invoice_id = ?")
|
||||
.bind(invoice_id)
|
||||
.fetch_all(pool())
|
||||
.await?,
|
||||
)
|
||||
}
|
||||
|
||||
/// The relay's plan immediately before `before`, read from the activity log
|
||||
/// (the most recent `create_relay`/`update_relay` with `created_at < before`).
|
||||
/// Billing uses this as the `old` side of a plan-change delta.
|
||||
pub async fn get_relay_plan_before(relay_id: &str, before: i64) -> Result<Option<String>> {
|
||||
Ok(sqlx::query_scalar::<_, String>(
|
||||
"SELECT plan_id FROM activity
|
||||
WHERE resource_id = ?
|
||||
AND created_at < ?
|
||||
AND activity_type IN ('create_relay', 'update_relay')
|
||||
AND plan_id IS NOT NULL
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 1",
|
||||
)
|
||||
.bind(relay_id)
|
||||
.bind(before)
|
||||
.fetch_optional(pool())
|
||||
.await?)
|
||||
}
|
||||
|
||||
pub async fn get_bolt11(bolt11_id: &str) -> Result<Option<Bolt11>> {
|
||||
Ok(sqlx::query_as::<_, Bolt11>("SELECT * FROM bolt11 WHERE id = ?")
|
||||
.bind(bolt11_id)
|
||||
.fetch_optional(pool())
|
||||
.await?)
|
||||
}
|
||||
|
||||
pub async fn get_bolt11_for_invoice(invoice_id: &str) -> Result<Option<Bolt11>> {
|
||||
Ok(sqlx::query_as::<_, Bolt11>(
|
||||
"SELECT * FROM bolt11 WHERE invoice_id = ? ORDER BY created_at DESC LIMIT 1",
|
||||
)
|
||||
.bind(invoice_id)
|
||||
.fetch_optional(pool())
|
||||
.await?)
|
||||
}
|
||||
|
||||
// Activity
|
||||
|
||||
/// Billable activity for a tenant not yet folded into an invoice. The
|
||||
/// activity-type filter and the `billed_at IS NULL` guard live here so the
|
||||
/// caller reconciles off a precise marker rather than a timestamp watermark.
|
||||
/// Ordered oldest-first so line items and proration apply in event order.
|
||||
pub async fn list_billable_activity_for_tenant(tenant_pubkey: &str) -> Result<Vec<Activity>> {
|
||||
Ok(sqlx::query_as::<_, Activity>(&select_activity(
|
||||
"WHERE tenant = ?
|
||||
AND billed_at IS NULL
|
||||
AND activity_type IN (
|
||||
'create_relay', 'update_relay', 'activate_relay', 'deactivate_relay'
|
||||
)
|
||||
ORDER BY created_at ASC",
|
||||
))
|
||||
.bind(tenant_pubkey)
|
||||
.fetch_all(pool())
|
||||
.await?)
|
||||
}
|
||||
|
||||
/// A tenant's relay status/plan activity strictly before `before`, oldest-first
|
||||
/// — folded by billing to reconstruct each relay's state as of a period boundary.
|
||||
/// Strict `<` so a relay created exactly at the boundary isn't counted active
|
||||
/// there (its own creation charge covers that period).
|
||||
pub async fn list_relay_activity_before(
|
||||
tenant_pubkey: &str,
|
||||
before: i64,
|
||||
) -> Result<Vec<Activity>> {
|
||||
Ok(sqlx::query_as::<_, Activity>(&select_activity(
|
||||
"WHERE tenant = ?
|
||||
AND resource_type = 'relay'
|
||||
AND activity_type IN (
|
||||
'create_relay', 'update_relay', 'activate_relay', 'deactivate_relay'
|
||||
)
|
||||
AND created_at < ?
|
||||
ORDER BY created_at ASC",
|
||||
))
|
||||
.bind(tenant_pubkey)
|
||||
.bind(before)
|
||||
.fetch_all(pool())
|
||||
.await?)
|
||||
}
|
||||
|
||||
pub async fn list_activity_for_resource(resource_id: &str) -> Result<Vec<Activity>> {
|
||||
Ok(sqlx::query_as::<_, Activity>(&select_activity(
|
||||
"WHERE resource_id = ? ORDER BY created_at DESC",
|
||||
))
|
||||
.bind(resource_id)
|
||||
.fetch_all(pool())
|
||||
.await?)
|
||||
}
|
||||
|
||||
pub async fn get_latest_activity_for_resource_and_type(
|
||||
resource_id: &str,
|
||||
activity_type: &str,
|
||||
) -> Result<Option<Activity>> {
|
||||
Ok(sqlx::query_as::<_, Activity>(&select_activity(
|
||||
"WHERE resource_id = ? AND activity_type = ? ORDER BY created_at DESC LIMIT 1",
|
||||
))
|
||||
.bind(resource_id)
|
||||
.bind(activity_type)
|
||||
.fetch_optional(pool())
|
||||
.await?)
|
||||
.fetch_optional(&self.pool)
|
||||
.await?;
|
||||
Ok(row)
|
||||
}
|
||||
|
||||
pub async fn get_tenant_by_stripe_customer_id(
|
||||
&self,
|
||||
stripe_customer_id: &str,
|
||||
) -> Result<Option<Tenant>> {
|
||||
let row = sqlx::query_as::<_, Tenant>(
|
||||
"SELECT pubkey, nwc_url, nwc_error, created_at, stripe_customer_id, stripe_subscription_id, past_due_at
|
||||
FROM tenant
|
||||
WHERE stripe_customer_id = ?",
|
||||
)
|
||||
.bind(stripe_customer_id)
|
||||
.fetch_optional(&self.pool)
|
||||
.await?;
|
||||
Ok(row)
|
||||
}
|
||||
|
||||
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'",
|
||||
)
|
||||
.bind(tenant_id)
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
|
||||
Ok(plans.into_iter().any(|plan| Self::is_paid_plan(&plan)))
|
||||
}
|
||||
|
||||
pub async fn list_activity_for_relay(&self, relay_id: &str) -> Result<Vec<Activity>> {
|
||||
let rows = sqlx::query_as::<_, Activity>(
|
||||
"SELECT id, tenant, created_at, activity_type, resource_type, resource_id
|
||||
FROM activity
|
||||
WHERE resource_type = 'relay' AND resource_id = ?
|
||||
ORDER BY created_at DESC, id DESC",
|
||||
)
|
||||
.bind(relay_id)
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
Ok(rows)
|
||||
}
|
||||
}
|
||||
|
||||
+126
-48
@@ -5,10 +5,15 @@ use anyhow::{Result, anyhow};
|
||||
use nostr_sdk::prelude::*;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
use crate::env;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Robot {
|
||||
secret: String,
|
||||
name: String,
|
||||
description: String,
|
||||
picture: String,
|
||||
outbox_client: Client,
|
||||
indexer_client: Client,
|
||||
messaging_client: Client,
|
||||
outbox_cache: std::sync::Arc<Mutex<HashMap<String, CacheEntry>>>,
|
||||
dm_cache: std::sync::Arc<Mutex<HashMap<String, CacheEntry>>>,
|
||||
}
|
||||
@@ -21,59 +26,83 @@ struct CacheEntry {
|
||||
|
||||
impl Robot {
|
||||
pub async fn new() -> Result<Self> {
|
||||
let secret = std::env::var("ROBOT_SECRET").unwrap_or_default();
|
||||
if secret.trim().is_empty() {
|
||||
return Err(anyhow!("ROBOT_SECRET is required"));
|
||||
}
|
||||
|
||||
let name = std::env::var("ROBOT_NAME").unwrap_or_default();
|
||||
let description = std::env::var("ROBOT_DESCRIPTION").unwrap_or_default();
|
||||
let picture = std::env::var("ROBOT_PICTURE").unwrap_or_default();
|
||||
let outbox_relays = split_relays("ROBOT_OUTBOX_RELAYS");
|
||||
let indexer_relays = split_relays("ROBOT_INDEXER_RELAYS");
|
||||
let messaging_relays = split_relays("ROBOT_MESSAGING_RELAYS");
|
||||
|
||||
if outbox_relays.is_empty() {
|
||||
return Err(anyhow!("ROBOT_OUTBOX_RELAYS is required"));
|
||||
}
|
||||
if indexer_relays.is_empty() {
|
||||
return Err(anyhow!("ROBOT_INDEXER_RELAYS is required"));
|
||||
}
|
||||
if messaging_relays.is_empty() {
|
||||
return Err(anyhow!("ROBOT_MESSAGING_RELAYS is required"));
|
||||
}
|
||||
|
||||
let outbox_client = client_with_relays(&secret, &outbox_relays).await?;
|
||||
let indexer_client = client_with_relays(&secret, &indexer_relays).await?;
|
||||
let messaging_client = client_with_relays(&secret, &messaging_relays).await?;
|
||||
|
||||
let robot = Self {
|
||||
secret,
|
||||
name,
|
||||
description,
|
||||
picture,
|
||||
outbox_client,
|
||||
indexer_client,
|
||||
messaging_client,
|
||||
outbox_cache: std::sync::Arc::new(Mutex::new(HashMap::new())),
|
||||
dm_cache: std::sync::Arc::new(Mutex::new(HashMap::new())),
|
||||
};
|
||||
|
||||
robot.publish_identity().await?;
|
||||
robot
|
||||
.publish_identity(&outbox_relays, &messaging_relays)
|
||||
.await?;
|
||||
Ok(robot)
|
||||
}
|
||||
|
||||
async fn make_client(&self, relays: &[String]) -> Result<Client> {
|
||||
let client = Client::new(env::get().keys.clone());
|
||||
for relay in relays {
|
||||
client.add_relay(relay).await?;
|
||||
}
|
||||
client.connect().await;
|
||||
Ok(client)
|
||||
}
|
||||
|
||||
|
||||
async fn publish_identity(
|
||||
&self,
|
||||
outbox_relays: &[String],
|
||||
messaging_relays: &[String],
|
||||
) -> Result<()> {
|
||||
let mut metadata = Metadata::new();
|
||||
if !env::get().robot_name.is_empty() {
|
||||
metadata = metadata.name(&env::get().robot_name);
|
||||
if !self.name.is_empty() {
|
||||
metadata = metadata.name(&self.name);
|
||||
}
|
||||
if !env::get().robot_description.is_empty() {
|
||||
metadata = metadata.about(&env::get().robot_description);
|
||||
if !self.description.is_empty() {
|
||||
metadata = metadata.about(&self.description);
|
||||
}
|
||||
if !env::get().robot_picture.is_empty() {
|
||||
metadata = metadata.picture(Url::parse(&env::get().robot_picture)?);
|
||||
if !self.picture.is_empty() {
|
||||
metadata = metadata.picture(Url::parse(&self.picture)?);
|
||||
}
|
||||
|
||||
let outbox_client = self.make_client(&env::get().robot_outbox_relays).await?;
|
||||
let indexer_client = self.make_client(&env::get().robot_indexer_relays).await?;
|
||||
|
||||
outbox_client
|
||||
self.outbox_client
|
||||
.send_event_builder(EventBuilder::metadata(&metadata))
|
||||
.await?;
|
||||
|
||||
let outbox_tags = env::get().robot_outbox_relays
|
||||
let outbox_tags = outbox_relays
|
||||
.iter()
|
||||
.map(|r| Tag::parse(["r", r.as_str()]))
|
||||
.collect::<std::result::Result<Vec<_>, _>>()?;
|
||||
outbox_client
|
||||
self.outbox_client
|
||||
.send_event_builder(EventBuilder::new(Kind::Custom(10002), "").tags(outbox_tags))
|
||||
.await?;
|
||||
|
||||
let messaging_tags = env::get().robot_messaging_relays
|
||||
let messaging_tags = messaging_relays
|
||||
.iter()
|
||||
.map(|r| Tag::parse(["relay", r.as_str()]))
|
||||
.collect::<std::result::Result<Vec<_>, _>>()?;
|
||||
indexer_client
|
||||
self.indexer_client
|
||||
.send_event_builder(EventBuilder::new(Kind::Custom(10050), "").tags(messaging_tags))
|
||||
.await?;
|
||||
|
||||
@@ -94,8 +123,14 @@ impl Robot {
|
||||
}
|
||||
|
||||
let recipient_pubkey = PublicKey::parse(recipient)?;
|
||||
let client = self.make_client(&dm_relays).await?;
|
||||
client.send_private_msg(recipient_pubkey, message, []).await?;
|
||||
let client = self.messaging_client.clone();
|
||||
for relay in dm_relays {
|
||||
let _ = client.add_relay(relay).await;
|
||||
}
|
||||
client.connect().await;
|
||||
client
|
||||
.send_private_msg(recipient_pubkey, message, [])
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -106,8 +141,10 @@ impl Robot {
|
||||
|
||||
let pubkey = PublicKey::parse(recipient)?;
|
||||
let filter = Filter::new().author(pubkey).kind(Kind::Custom(10002));
|
||||
let client = self.make_client(&env::get().robot_indexer_relays).await?;
|
||||
let events = client.fetch_events(filter, Duration::from_secs(5)).await?;
|
||||
let events = self
|
||||
.indexer_client
|
||||
.fetch_events(filter, Duration::from_secs(5))
|
||||
.await?;
|
||||
|
||||
let mut relays = Vec::new();
|
||||
if let Some(event) = events.into_iter().max_by_key(|e| e.created_at) {
|
||||
@@ -123,22 +160,6 @@ impl Robot {
|
||||
Ok(relays)
|
||||
}
|
||||
|
||||
pub async fn fetch_nostr_name(&self, pubkey: &str) -> Option<String> {
|
||||
let pubkey = PublicKey::parse(pubkey).ok()?;
|
||||
let filter = Filter::new().author(pubkey).kind(Kind::Metadata).limit(1);
|
||||
let client = self.make_client(&env::get().robot_indexer_relays).await.ok()?;
|
||||
let events = client.fetch_events(filter, Duration::from_secs(5)).await.ok()?;
|
||||
let event = events.into_iter().max_by_key(|e| e.created_at)?;
|
||||
let content: serde_json::Value = serde_json::from_str(&event.content).ok()?;
|
||||
let name = content
|
||||
.get("display_name")
|
||||
.or_else(|| content.get("name"))
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty())?;
|
||||
Some(name)
|
||||
}
|
||||
|
||||
async fn fetch_messaging_relays_from_outbox(
|
||||
&self,
|
||||
recipient: &str,
|
||||
@@ -149,7 +170,13 @@ impl Robot {
|
||||
}
|
||||
|
||||
let pubkey = PublicKey::parse(recipient)?;
|
||||
let client = self.make_client(outbox_relays).await?;
|
||||
let keys = Keys::parse(&self.secret)?;
|
||||
let client = Client::new(keys);
|
||||
for relay in outbox_relays {
|
||||
client.add_relay(relay).await?;
|
||||
}
|
||||
client.connect().await;
|
||||
|
||||
let filter = Filter::new().author(pubkey).kind(Kind::Custom(10050));
|
||||
let events = client.fetch_events(filter, Duration::from_secs(5)).await?;
|
||||
|
||||
@@ -168,6 +195,37 @@ impl Robot {
|
||||
}
|
||||
}
|
||||
|
||||
fn split_relays(key: &str) -> Vec<String> {
|
||||
std::env::var(key)
|
||||
.unwrap_or_default()
|
||||
.split(',')
|
||||
.map(|v| normalize_relay_url(v.trim()))
|
||||
.filter(|v| !v.is_empty())
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn normalize_relay_url(url: &str) -> String {
|
||||
if url.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
|
||||
if url.starts_with("ws://") || url.starts_with("wss://") {
|
||||
url.to_string()
|
||||
} else {
|
||||
format!("wss://{url}")
|
||||
}
|
||||
}
|
||||
|
||||
async fn client_with_relays(secret: &str, relays: &[String]) -> Result<Client> {
|
||||
let keys = Keys::parse(secret)?;
|
||||
let client = Client::new(keys);
|
||||
for relay in relays {
|
||||
client.add_relay(relay).await?;
|
||||
}
|
||||
client.connect().await;
|
||||
Ok(client)
|
||||
}
|
||||
|
||||
async fn get_cached(
|
||||
cache: &std::sync::Arc<Mutex<HashMap<String, CacheEntry>>>,
|
||||
key: &str,
|
||||
@@ -196,3 +254,23 @@ async fn set_cached(
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
impl Robot {
|
||||
pub fn test_stub() -> Self {
|
||||
let keys = Keys::generate();
|
||||
let client = Client::new(keys);
|
||||
|
||||
Self {
|
||||
secret: String::new(),
|
||||
name: String::new(),
|
||||
description: String::new(),
|
||||
picture: String::new(),
|
||||
outbox_client: client.clone(),
|
||||
indexer_client: client.clone(),
|
||||
messaging_client: client,
|
||||
outbox_cache: std::sync::Arc::new(Mutex::new(HashMap::new())),
|
||||
dm_cache: std::sync::Arc::new(Mutex::new(HashMap::new())),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::extract::State;
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::api::{Api, AuthedPubkey};
|
||||
use crate::web::{ApiResult, ok};
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct IdentityResponse {
|
||||
pubkey: String,
|
||||
is_admin: bool,
|
||||
}
|
||||
|
||||
pub async fn get_identity(
|
||||
State(api): State<Arc<Api>>,
|
||||
AuthedPubkey(pubkey): AuthedPubkey,
|
||||
) -> ApiResult {
|
||||
let is_admin = api.is_admin(&pubkey);
|
||||
ok(IdentityResponse { pubkey, is_admin })
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::extract::{Path, State};
|
||||
|
||||
use crate::api::{Api, AuthedPubkey};
|
||||
use crate::query;
|
||||
use crate::web::{ApiResult, internal, not_found, ok};
|
||||
|
||||
/// The tenant's most recent invoice, after first materializing any outstanding
|
||||
/// line items into a fresh one — so the frontend can collect payment right after
|
||||
/// a change (e.g. creating a relay). Payment isn't attempted here; the caller
|
||||
/// drives it via the bolt11/Stripe endpoints. `null` when the tenant has no
|
||||
/// invoices and nothing is outstanding.
|
||||
pub async fn get_tenant_latest_invoice(
|
||||
State(api): State<Arc<Api>>,
|
||||
AuthedPubkey(auth): AuthedPubkey,
|
||||
Path(pubkey): Path<String>,
|
||||
) -> ApiResult {
|
||||
api.require_admin_or_tenant(&auth, &pubkey)?;
|
||||
let tenant = api.get_tenant_or_404(&pubkey).await?;
|
||||
|
||||
// Roll any outstanding charges (and due renewals) into an invoice, then
|
||||
// return the latest.
|
||||
api.billing
|
||||
.generate_invoice(&tenant)
|
||||
.await
|
||||
.map_err(internal)?;
|
||||
|
||||
let invoice = query::get_latest_invoice(&pubkey).await.map_err(internal)?;
|
||||
|
||||
ok(invoice)
|
||||
}
|
||||
|
||||
pub async fn get_invoice(
|
||||
State(api): State<Arc<Api>>,
|
||||
AuthedPubkey(auth): AuthedPubkey,
|
||||
Path(id): Path<String>,
|
||||
) -> ApiResult {
|
||||
let invoice = query::get_invoice(&id)
|
||||
.await
|
||||
.map_err(internal)?
|
||||
.ok_or_else(|| not_found("invoice not found"))?;
|
||||
|
||||
api.require_admin_or_tenant(&auth, &invoice.tenant_pubkey)?;
|
||||
|
||||
ok(invoice)
|
||||
}
|
||||
|
||||
pub async fn get_invoice_bolt11(
|
||||
State(api): State<Arc<Api>>,
|
||||
AuthedPubkey(auth): AuthedPubkey,
|
||||
Path(invoice_id): Path<String>,
|
||||
) -> ApiResult {
|
||||
let invoice = query::get_invoice(&invoice_id)
|
||||
.await
|
||||
.map_err(internal)?
|
||||
.ok_or_else(|| not_found("invoice not found"))?;
|
||||
|
||||
api.require_admin_or_tenant(&auth, &invoice.tenant_pubkey)?;
|
||||
|
||||
let bolt11 = api
|
||||
.billing
|
||||
.ensure_and_reconcile_bolt11(&invoice_id)
|
||||
.await
|
||||
.map_err(internal)?;
|
||||
|
||||
ok(serde_json::json!(bolt11))
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
pub mod identity;
|
||||
pub mod invoices;
|
||||
pub mod plans;
|
||||
pub mod relays;
|
||||
pub mod tenants;
|
||||
@@ -1,18 +0,0 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::extract::{Path, State};
|
||||
|
||||
use crate::api::Api;
|
||||
use crate::query;
|
||||
use crate::web::{ApiResult, not_found, ok};
|
||||
|
||||
pub async fn list_plans(State(_api): State<Arc<Api>>) -> ApiResult {
|
||||
ok(query::list_plans())
|
||||
}
|
||||
|
||||
pub async fn get_plan(State(_api): State<Arc<Api>>, Path(id): Path<String>) -> ApiResult {
|
||||
match query::get_plan(&id) {
|
||||
Some(plan) => ok(plan),
|
||||
None => Err(not_found("plan not found")),
|
||||
}
|
||||
}
|
||||
@@ -1,311 +0,0 @@
|
||||
use std::sync::{Arc, LazyLock};
|
||||
|
||||
use anyhow::Result;
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Path, State},
|
||||
};
|
||||
use regex::Regex;
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::api::{Api, AuthedPubkey};
|
||||
use crate::{command, query};
|
||||
use crate::models::{
|
||||
RELAY_STATUS_ACTIVE, RELAY_STATUS_DELINQUENT, RELAY_STATUS_INACTIVE, Relay,
|
||||
};
|
||||
use crate::web::{
|
||||
ApiError, ApiResult, bad_request, created, internal, map_unique_error, ok,
|
||||
parse_bool_default, unprocessable,
|
||||
};
|
||||
|
||||
pub async fn list_relays(
|
||||
State(api): State<Arc<Api>>,
|
||||
AuthedPubkey(auth): AuthedPubkey,
|
||||
) -> ApiResult {
|
||||
api.require_admin(&auth)?;
|
||||
|
||||
let relays = query::list_relays().await.map_err(internal)?;
|
||||
ok(relays)
|
||||
}
|
||||
|
||||
pub async fn get_relay(
|
||||
State(api): State<Arc<Api>>,
|
||||
AuthedPubkey(auth): AuthedPubkey,
|
||||
Path(id): Path<String>,
|
||||
) -> ApiResult {
|
||||
let relay = api.get_relay_or_404(&id).await?;
|
||||
api.require_admin_or_tenant(&auth, &relay.tenant)?;
|
||||
ok(relay)
|
||||
}
|
||||
|
||||
pub async fn list_relay_activity(
|
||||
State(api): State<Arc<Api>>,
|
||||
AuthedPubkey(auth): AuthedPubkey,
|
||||
Path(id): Path<String>,
|
||||
) -> ApiResult {
|
||||
let relay = api.get_relay_or_404(&id).await?;
|
||||
api.require_admin_or_tenant(&auth, &relay.tenant)?;
|
||||
|
||||
let activity = query::list_activity_for_resource(&id)
|
||||
.await
|
||||
.map_err(internal)?;
|
||||
ok(serde_json::json!({ "activity": activity }))
|
||||
}
|
||||
|
||||
pub async fn list_relay_members(
|
||||
State(api): State<Arc<Api>>,
|
||||
AuthedPubkey(auth): AuthedPubkey,
|
||||
Path(id): Path<String>,
|
||||
) -> ApiResult {
|
||||
let relay = api.get_relay_or_404(&id).await?;
|
||||
api.require_admin_or_tenant(&auth, &relay.tenant)?;
|
||||
|
||||
let members = fetch_relay_members(&api, &relay).await.map_err(internal)?;
|
||||
ok(serde_json::json!({ "members": members }))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct CreateRelayRequest {
|
||||
pub tenant: String,
|
||||
pub subdomain: String,
|
||||
pub plan: String,
|
||||
pub info_name: String,
|
||||
pub info_icon: String,
|
||||
pub info_description: String,
|
||||
pub policy_public_join: i64,
|
||||
pub policy_strip_signatures: i64,
|
||||
pub groups_enabled: i64,
|
||||
pub management_enabled: i64,
|
||||
pub blossom_enabled: i64,
|
||||
pub livekit_enabled: i64,
|
||||
pub push_enabled: i64,
|
||||
}
|
||||
|
||||
pub async fn create_relay(
|
||||
State(api): State<Arc<Api>>,
|
||||
AuthedPubkey(auth): AuthedPubkey,
|
||||
Json(payload): Json<CreateRelayRequest>,
|
||||
) -> ApiResult {
|
||||
api.require_admin_or_tenant(&auth, &payload.tenant)?;
|
||||
|
||||
let relay_id = format!(
|
||||
"{}_{}",
|
||||
payload.subdomain.replace('-', "_"),
|
||||
&uuid::Uuid::new_v4().simple().to_string()[..8]
|
||||
);
|
||||
|
||||
let relay = Relay {
|
||||
id: relay_id.clone(),
|
||||
tenant: payload.tenant,
|
||||
subdomain: payload.subdomain,
|
||||
plan: payload.plan,
|
||||
info_name: payload.info_name,
|
||||
info_icon: payload.info_icon,
|
||||
info_description: payload.info_description,
|
||||
policy_public_join: payload.policy_public_join,
|
||||
policy_strip_signatures: payload.policy_strip_signatures,
|
||||
groups_enabled: payload.groups_enabled,
|
||||
management_enabled: payload.management_enabled,
|
||||
blossom_enabled: payload.blossom_enabled,
|
||||
livekit_enabled: payload.livekit_enabled,
|
||||
push_enabled: payload.push_enabled,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let relay = prepare_relay(relay)?;
|
||||
|
||||
command::create_relay(&relay)
|
||||
.await
|
||||
.map_err(map_relay_write_error)?;
|
||||
|
||||
created(relay)
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct UpdateRelayRequest {
|
||||
pub subdomain: Option<String>,
|
||||
pub plan: Option<String>,
|
||||
pub info_name: Option<String>,
|
||||
pub info_icon: Option<String>,
|
||||
pub info_description: Option<String>,
|
||||
pub policy_public_join: Option<i64>,
|
||||
pub policy_strip_signatures: Option<i64>,
|
||||
pub groups_enabled: Option<i64>,
|
||||
pub management_enabled: Option<i64>,
|
||||
pub blossom_enabled: Option<i64>,
|
||||
pub livekit_enabled: Option<i64>,
|
||||
pub push_enabled: Option<i64>,
|
||||
}
|
||||
|
||||
pub async fn update_relay(
|
||||
State(api): State<Arc<Api>>,
|
||||
AuthedPubkey(auth): AuthedPubkey,
|
||||
Path(id): Path<String>,
|
||||
Json(payload): Json<UpdateRelayRequest>,
|
||||
) -> ApiResult {
|
||||
let mut relay = api.get_relay_or_404(&id).await?;
|
||||
|
||||
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() {
|
||||
relay.plan = v;
|
||||
}
|
||||
if let Some(v) = payload.info_name {
|
||||
relay.info_name = v;
|
||||
}
|
||||
if let Some(v) = payload.info_icon {
|
||||
relay.info_icon = v;
|
||||
}
|
||||
if let Some(v) = payload.info_description {
|
||||
relay.info_description = v;
|
||||
}
|
||||
if let Some(v) = payload.policy_public_join {
|
||||
relay.policy_public_join = v;
|
||||
}
|
||||
if let Some(v) = payload.policy_strip_signatures {
|
||||
relay.policy_strip_signatures = v;
|
||||
}
|
||||
if let Some(v) = payload.groups_enabled {
|
||||
relay.groups_enabled = v;
|
||||
}
|
||||
if let Some(v) = payload.management_enabled {
|
||||
relay.management_enabled = v;
|
||||
}
|
||||
if let Some(v) = payload.blossom_enabled {
|
||||
relay.blossom_enabled = v;
|
||||
}
|
||||
if let Some(v) = payload.livekit_enabled {
|
||||
relay.livekit_enabled = v;
|
||||
}
|
||||
if let Some(v) = payload.push_enabled {
|
||||
relay.push_enabled = v;
|
||||
}
|
||||
|
||||
let relay = prepare_relay(relay)?;
|
||||
|
||||
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 = fetch_relay_members(&api, &relay)
|
||||
.await
|
||||
.map_err(internal)?
|
||||
.len() as i64;
|
||||
|
||||
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 Err(unprocessable("member-limit-exceeded", &message));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
command::update_relay(&relay)
|
||||
.await
|
||||
.map_err(map_relay_write_error)?;
|
||||
|
||||
ok(relay)
|
||||
}
|
||||
|
||||
pub async fn deactivate_relay(
|
||||
State(api): State<Arc<Api>>,
|
||||
AuthedPubkey(auth): AuthedPubkey,
|
||||
Path(id): Path<String>,
|
||||
) -> ApiResult {
|
||||
let relay = api.get_relay_or_404(&id).await?;
|
||||
api.require_admin_or_tenant(&auth, &relay.tenant)?;
|
||||
|
||||
if relay.status == RELAY_STATUS_DELINQUENT {
|
||||
return Err(bad_request("relay-is-delinquent", "relay is delinquent"));
|
||||
}
|
||||
|
||||
if relay.status == RELAY_STATUS_INACTIVE {
|
||||
return Err(bad_request("relay-is-inactive", "relay is already inactive"));
|
||||
}
|
||||
|
||||
command::deactivate_relay(&relay)
|
||||
.await
|
||||
.map_err(internal)?;
|
||||
|
||||
ok(())
|
||||
}
|
||||
|
||||
pub async fn reactivate_relay(
|
||||
State(api): State<Arc<Api>>,
|
||||
AuthedPubkey(auth): AuthedPubkey,
|
||||
Path(id): Path<String>,
|
||||
) -> ApiResult {
|
||||
let relay = api.get_relay_or_404(&id).await?;
|
||||
api.require_admin_or_tenant(&auth, &relay.tenant)?;
|
||||
|
||||
if relay.status == RELAY_STATUS_DELINQUENT {
|
||||
return Err(bad_request("relay-is-delinquent", "relay is delinquent"));
|
||||
}
|
||||
|
||||
if relay.status == RELAY_STATUS_ACTIVE {
|
||||
return Err(bad_request("relay-is-active", "relay is already active"));
|
||||
}
|
||||
|
||||
command::activate_relay(&relay).await.map_err(internal)?;
|
||||
|
||||
ok(())
|
||||
}
|
||||
|
||||
// --- helpers ----------------------------------------------------------------
|
||||
|
||||
async fn fetch_relay_members(api: &Api, relay: &Relay) -> Result<Vec<String>> {
|
||||
if relay.synced == 0 {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
api.infra.list_relay_members(&relay.id).await
|
||||
}
|
||||
|
||||
const RESERVED_SUBDOMAINS: [&str; 3] = ["api", "admin", "internal"];
|
||||
|
||||
static SUBDOMAIN_RE: LazyLock<Regex> =
|
||||
LazyLock::new(|| Regex::new(r"^[a-z0-9][a-z0-9-]{1,61}[a-z0-9]$").unwrap());
|
||||
|
||||
fn prepare_relay(mut relay: Relay) -> Result<Relay, ApiError> {
|
||||
if !SUBDOMAIN_RE.is_match(&relay.subdomain)
|
||||
|| RESERVED_SUBDOMAINS.contains(&relay.subdomain.as_str()) {
|
||||
return Err(unprocessable("invalid-subdomain", "subdomain is invalid"));
|
||||
}
|
||||
|
||||
let plan = query::get_plan(&relay.plan)
|
||||
.ok_or_else(|| unprocessable("invalid-plan", "plan not found"))?;
|
||||
|
||||
if (!plan.blossom && relay.blossom_enabled == 1) || (!plan.livekit && relay.livekit_enabled == 1) {
|
||||
return Err(unprocessable("premium-feature", "feature requires a paid plan"));
|
||||
}
|
||||
|
||||
relay.policy_public_join = parse_bool_default(relay.policy_public_join, 0);
|
||||
relay.policy_strip_signatures = parse_bool_default(relay.policy_strip_signatures, 0);
|
||||
relay.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, 0);
|
||||
relay.livekit_enabled = parse_bool_default(relay.livekit_enabled, 0);
|
||||
relay.push_enabled = parse_bool_default(relay.push_enabled, 1);
|
||||
|
||||
Ok(relay)
|
||||
}
|
||||
|
||||
fn map_relay_write_error(e: anyhow::Error) -> ApiError {
|
||||
if matches!(map_unique_error(&e), Some("subdomain-exists")) {
|
||||
unprocessable("subdomain-exists", "subdomain already exists")
|
||||
} else {
|
||||
internal(e)
|
||||
}
|
||||
}
|
||||
@@ -1,178 +0,0 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Path, Query, State},
|
||||
};
|
||||
use chrono::Utc;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::api::{Api, AuthedPubkey};
|
||||
use crate::models::Tenant;
|
||||
use crate::web::{ApiResult, internal, map_unique_error, ok};
|
||||
use crate::{command, env, query};
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct TenantResponse {
|
||||
pub pubkey: String,
|
||||
pub nwc_is_set: bool,
|
||||
pub nwc_error: Option<String>,
|
||||
pub created_at: i64,
|
||||
pub billing_anchor: Option<i64>,
|
||||
pub stripe_customer_id: String,
|
||||
}
|
||||
|
||||
impl From<Tenant> for TenantResponse {
|
||||
fn from(t: Tenant) -> Self {
|
||||
TenantResponse {
|
||||
nwc_is_set: !t.nwc_url.is_empty(),
|
||||
pubkey: t.pubkey,
|
||||
nwc_error: t.nwc_error,
|
||||
created_at: t.created_at,
|
||||
billing_anchor: t.billing_anchor,
|
||||
stripe_customer_id: t.stripe_customer_id,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn list_tenants(
|
||||
State(api): State<Arc<Api>>,
|
||||
AuthedPubkey(auth): AuthedPubkey,
|
||||
) -> ApiResult {
|
||||
api.require_admin(&auth)?;
|
||||
|
||||
let tenants = query::list_tenants().await.map_err(internal)?;
|
||||
ok(tenants
|
||||
.into_iter()
|
||||
.map(TenantResponse::from)
|
||||
.collect::<Vec<_>>())
|
||||
}
|
||||
|
||||
pub async fn create_tenant(
|
||||
State(api): State<Arc<Api>>,
|
||||
AuthedPubkey(pubkey): AuthedPubkey,
|
||||
) -> ApiResult {
|
||||
if let Some(t) = query::get_tenant(&pubkey).await.map_err(internal)? {
|
||||
return ok(TenantResponse::from(t));
|
||||
}
|
||||
|
||||
let display_name = api
|
||||
.robot
|
||||
.fetch_nostr_name(&pubkey)
|
||||
.await
|
||||
.unwrap_or(pubkey.chars().take(8).collect());
|
||||
|
||||
let stripe_customer_id = api
|
||||
.stripe
|
||||
.create_customer(&pubkey, &display_name)
|
||||
.await
|
||||
.map_err(internal)?;
|
||||
|
||||
let tenant = Tenant {
|
||||
pubkey: pubkey.clone(),
|
||||
created_at: Utc::now().timestamp(),
|
||||
stripe_customer_id,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
match command::create_tenant(&tenant).await {
|
||||
Ok(()) => ok(TenantResponse::from(tenant)),
|
||||
Err(e) if matches!(map_unique_error(&e), Some("pubkey-exists")) => {
|
||||
match query::get_tenant(&pubkey).await {
|
||||
Ok(Some(t)) => ok(TenantResponse::from(t)),
|
||||
Ok(None) => Err(internal("tenant row missing after unique-constraint race")),
|
||||
Err(e) => Err(internal(e)),
|
||||
}
|
||||
}
|
||||
Err(e) => Err(internal(e)),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_tenant(
|
||||
State(api): State<Arc<Api>>,
|
||||
AuthedPubkey(auth): AuthedPubkey,
|
||||
Path(pubkey): Path<String>,
|
||||
) -> ApiResult {
|
||||
api.require_admin_or_tenant(&auth, &pubkey)?;
|
||||
let tenant = api.get_tenant_or_404(&pubkey).await?;
|
||||
ok(TenantResponse::from(tenant))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct UpdateTenantRequest {
|
||||
pub nwc_url: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn update_tenant(
|
||||
State(api): State<Arc<Api>>,
|
||||
AuthedPubkey(auth): AuthedPubkey,
|
||||
Path(pubkey): Path<String>,
|
||||
Json(payload): Json<UpdateTenantRequest>,
|
||||
) -> ApiResult {
|
||||
api.require_admin_or_tenant(&auth, &pubkey)?;
|
||||
let mut tenant = api.get_tenant_or_404(&pubkey).await?;
|
||||
|
||||
// Encrypt tenant's nwc_url at rest
|
||||
if let Some(nwc_url) = payload.nwc_url {
|
||||
if nwc_url.is_empty() {
|
||||
tenant.nwc_url = String::new();
|
||||
} else {
|
||||
tenant.nwc_url = env::get().encrypt(&nwc_url).map_err(internal)?;
|
||||
}
|
||||
}
|
||||
|
||||
command::update_tenant(&tenant).await.map_err(internal)?;
|
||||
ok(TenantResponse::from(tenant))
|
||||
}
|
||||
|
||||
pub async fn list_tenant_relays(
|
||||
State(api): State<Arc<Api>>,
|
||||
AuthedPubkey(auth): AuthedPubkey,
|
||||
Path(pubkey): Path<String>,
|
||||
) -> ApiResult {
|
||||
api.require_admin_or_tenant(&auth, &pubkey)?;
|
||||
|
||||
let relays = query::list_relays_for_tenant(&pubkey)
|
||||
.await
|
||||
.map_err(internal)?;
|
||||
ok(relays)
|
||||
}
|
||||
|
||||
|
||||
pub async fn list_tenant_invoices(
|
||||
State(api): State<Arc<Api>>,
|
||||
AuthedPubkey(auth): AuthedPubkey,
|
||||
Path(pubkey): Path<String>,
|
||||
) -> ApiResult {
|
||||
api.require_admin_or_tenant(&auth, &pubkey)?;
|
||||
|
||||
let invoices = query::list_invoices(&pubkey)
|
||||
.await
|
||||
.map_err(internal)?;
|
||||
|
||||
ok(invoices)
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct StripeSessionParams {
|
||||
return_url: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn create_stripe_session(
|
||||
State(api): State<Arc<Api>>,
|
||||
AuthedPubkey(auth): AuthedPubkey,
|
||||
Path(pubkey): Path<String>,
|
||||
Query(params): Query<StripeSessionParams>,
|
||||
) -> ApiResult {
|
||||
api.require_tenant(&auth, &pubkey)?;
|
||||
|
||||
let tenant = api.get_tenant_or_404(&pubkey).await?;
|
||||
|
||||
let url = api
|
||||
.stripe
|
||||
.create_portal_session(&tenant.stripe_customer_id, params.return_url.as_deref())
|
||||
.await
|
||||
.map_err(internal)?;
|
||||
|
||||
ok(serde_json::json!({ "url": url }))
|
||||
}
|
||||
@@ -1,217 +0,0 @@
|
||||
//! A thin async wrapper around the subset of the Stripe REST API this service uses.
|
||||
//!
|
||||
//! Nothing here knows about relays, tenants, or our database — it just speaks HTTP
|
||||
//! to Stripe and hands back `serde_json::Value` (or small typed results). The
|
||||
//! domain logic lives in [`crate::billing`].
|
||||
|
||||
use anyhow::{Result, anyhow};
|
||||
use hmac::{Hmac, Mac};
|
||||
use sha2::Sha256;
|
||||
|
||||
use crate::env;
|
||||
|
||||
const STRIPE_API: &str = "https://api.stripe.com/v1";
|
||||
|
||||
// Stripe struct and impl
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Stripe {
|
||||
http: reqwest::Client,
|
||||
}
|
||||
|
||||
impl Stripe {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
http: reqwest::Client::new(),
|
||||
}
|
||||
}
|
||||
|
||||
// --- Request helpers ---
|
||||
|
||||
fn get(&self, path: &str) -> reqwest::RequestBuilder {
|
||||
self.http
|
||||
.get(format!("{STRIPE_API}{path}"))
|
||||
.bearer_auth(&env::get().stripe_secret_key)
|
||||
}
|
||||
|
||||
fn post(&self, path: &str) -> reqwest::RequestBuilder {
|
||||
self.http
|
||||
.post(format!("{STRIPE_API}{path}"))
|
||||
.bearer_auth(&env::get().stripe_secret_key)
|
||||
}
|
||||
|
||||
fn idempotency_key(&self, parts: &[&str]) -> String {
|
||||
let mut mac = Hmac::<Sha256>::new_from_slice(env::get().stripe_secret_key.as_bytes())
|
||||
.expect("HMAC accepts any key length");
|
||||
for (i, part) in parts.iter().enumerate() {
|
||||
if i > 0 {
|
||||
mac.update(b":");
|
||||
}
|
||||
mac.update(part.as_bytes());
|
||||
}
|
||||
hex::encode(mac.finalize().into_bytes())
|
||||
}
|
||||
|
||||
// --- Customers ---
|
||||
|
||||
pub async fn create_customer(&self, tenant_pubkey: &str, name: &str) -> Result<String> {
|
||||
let body = self
|
||||
.post("/customers")
|
||||
.header(
|
||||
"Idempotency-Key",
|
||||
self.idempotency_key(&["create_customer", tenant_pubkey]),
|
||||
)
|
||||
.form(&[("name", name), ("metadata[tenant_pubkey]", tenant_pubkey)])
|
||||
.send_json()
|
||||
.await?;
|
||||
let customer_id = body["id"]
|
||||
.as_str()
|
||||
.ok_or_else(|| anyhow!("missing customer id"))?;
|
||||
Ok(customer_id.to_string())
|
||||
}
|
||||
|
||||
// --- Payment methods ---
|
||||
|
||||
/// Return the id of the customer's first saved payment method, or `None` if
|
||||
/// they have none. The returned `pm_…` id can be charged off-session via
|
||||
/// [`Self::create_payment_intent`]. We don't track a Stripe "default" payment
|
||||
/// method, so the first one Stripe lists is the one we'll charge.
|
||||
pub async fn get_saved_payment_method(&self, customer_id: &str) -> Result<Option<String>> {
|
||||
let body = self
|
||||
.get("/payment_methods")
|
||||
.query(&[("customer", customer_id), ("type", "card")])
|
||||
.send_json()
|
||||
.await?;
|
||||
Ok(body["data"]
|
||||
.as_array()
|
||||
.and_then(|methods| methods.first())
|
||||
.and_then(|method| method["id"].as_str())
|
||||
.map(str::to_string))
|
||||
}
|
||||
|
||||
// --- Intents ---
|
||||
|
||||
/// Create and immediately confirm an off-session PaymentIntent charging a
|
||||
/// saved payment method. `amount` is in the currency's minor units (cents for
|
||||
/// `usd`). Returns the PaymentIntent id on success.
|
||||
///
|
||||
/// A decline or an issuer authentication demand (`authentication_required`,
|
||||
/// which we can't satisfy off-session) comes back from Stripe as an HTTP
|
||||
/// error, so the caller naturally falls through to another payment method.
|
||||
/// The charge is made idempotent on `invoice_id`, so a retried collection
|
||||
/// reuses the same charge instead of billing the payment method twice.
|
||||
pub async fn create_payment_intent(
|
||||
&self,
|
||||
customer_id: &str,
|
||||
payment_method_id: &str,
|
||||
invoice_id: &str,
|
||||
amount: i64,
|
||||
currency: &str,
|
||||
) -> Result<String> {
|
||||
let amount = amount.to_string();
|
||||
let body = self
|
||||
.post("/payment_intents")
|
||||
.header(
|
||||
"Idempotency-Key",
|
||||
self.idempotency_key(&["payment_intent", invoice_id]),
|
||||
)
|
||||
.form(&[
|
||||
("amount", amount.as_str()),
|
||||
("currency", currency),
|
||||
("customer", customer_id),
|
||||
("payment_method", payment_method_id),
|
||||
("off_session", "true"),
|
||||
("confirm", "true"),
|
||||
])
|
||||
.send_json()
|
||||
.await?;
|
||||
|
||||
// A successful off-session charge settles synchronously. Anything
|
||||
// else (e.g. `requires_action`) can't be completed without the customer,
|
||||
// so treat it as a failure and let the caller fall back.
|
||||
let status = body["status"].as_str().unwrap_or_default();
|
||||
if status != "succeeded" {
|
||||
return Err(anyhow!("payment intent not succeeded (status: {status})"));
|
||||
}
|
||||
|
||||
body["id"]
|
||||
.as_str()
|
||||
.map(str::to_string)
|
||||
.ok_or_else(|| anyhow!("missing payment intent id"))
|
||||
}
|
||||
|
||||
// --- Portal ---
|
||||
|
||||
pub async fn create_portal_session(
|
||||
&self,
|
||||
customer_id: &str,
|
||||
return_url: Option<&str>,
|
||||
) -> Result<String> {
|
||||
let mut params = vec![("customer", customer_id.to_string())];
|
||||
if let Some(url) = return_url {
|
||||
params.push(("return_url", url.to_string()));
|
||||
}
|
||||
let body = self
|
||||
.post("/billing_portal/sessions")
|
||||
.form(¶ms)
|
||||
.send_json()
|
||||
.await?;
|
||||
body["url"]
|
||||
.as_str()
|
||||
.map(str::to_string)
|
||||
.ok_or_else(|| anyhow!("missing portal session url"))
|
||||
}
|
||||
}
|
||||
|
||||
// Stripe request util
|
||||
|
||||
trait StripeRequest {
|
||||
async fn send_ok(self) -> Result<reqwest::Response>;
|
||||
async fn send_json(self) -> Result<serde_json::Value>;
|
||||
}
|
||||
|
||||
impl StripeRequest for reqwest::RequestBuilder {
|
||||
async fn send_ok(self) -> Result<reqwest::Response> {
|
||||
error_for_status(self.send().await?).await
|
||||
}
|
||||
|
||||
async fn send_json(self) -> Result<serde_json::Value> {
|
||||
Ok(self.send_ok().await?.json().await?)
|
||||
}
|
||||
}
|
||||
|
||||
/// Give callers an actionable message instead of a bare "400 Bad Request"
|
||||
async fn error_for_status(resp: reqwest::Response) -> Result<reqwest::Response> {
|
||||
let status = resp.status();
|
||||
if !status.is_client_error() && !status.is_server_error() {
|
||||
return Ok(resp);
|
||||
}
|
||||
|
||||
let url = resp.url().clone();
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
let detail = serde_json::from_str::<serde_json::Value>(&body)
|
||||
.ok()
|
||||
.and_then(|json| {
|
||||
let error = &json["error"];
|
||||
let message = error["message"].as_str()?.to_string();
|
||||
let mut detail = message;
|
||||
if let Some(code) = error["type"].as_str().or_else(|| error["code"].as_str()) {
|
||||
detail.push_str(&format!(" [{code}]"));
|
||||
}
|
||||
if let Some(param) = error["param"].as_str() {
|
||||
detail.push_str(&format!(" (param: {param})"));
|
||||
}
|
||||
Some(detail)
|
||||
})
|
||||
.unwrap_or_else(|| {
|
||||
if body.trim().is_empty() {
|
||||
"<empty response body>".to_string()
|
||||
} else {
|
||||
body
|
||||
}
|
||||
});
|
||||
|
||||
Err(anyhow!(
|
||||
"Stripe API request to {url} failed with status {status}: {detail}"
|
||||
))
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
use anyhow::{Result, anyhow};
|
||||
use nwc::prelude::{
|
||||
LookupInvoiceRequest, MakeInvoiceRequest, NWC, NostrWalletConnectURI, PayInvoiceRequest,
|
||||
TransactionState,
|
||||
};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Wallet {
|
||||
url: NostrWalletConnectURI,
|
||||
}
|
||||
|
||||
impl Wallet {
|
||||
pub fn from_url(url: &str) -> Result<Self> {
|
||||
Ok(Self { url: url.parse::<NostrWalletConnectURI>()? })
|
||||
}
|
||||
|
||||
pub async fn make_invoice(
|
||||
&self,
|
||||
amount_msats: u64,
|
||||
description: &str,
|
||||
expiry_secs: u64,
|
||||
) -> Result<String> {
|
||||
let nwc = NWC::new(self.url.clone());
|
||||
let result = nwc
|
||||
.make_invoice(MakeInvoiceRequest {
|
||||
amount: amount_msats,
|
||||
description: Some(description.to_string()),
|
||||
description_hash: None,
|
||||
expiry: Some(expiry_secs),
|
||||
})
|
||||
.await;
|
||||
nwc.shutdown().await;
|
||||
Ok(result
|
||||
.map_err(|e| anyhow!("failed to create invoice: {e}"))?
|
||||
.invoice)
|
||||
}
|
||||
|
||||
pub async fn pay_invoice(&self, bolt11: String) -> Result<()> {
|
||||
let nwc = NWC::new(self.url.clone());
|
||||
let result = nwc.pay_invoice(PayInvoiceRequest::new(bolt11)).await;
|
||||
nwc.shutdown().await;
|
||||
result.map(|_| ()).map_err(|e| anyhow!("{e}"))
|
||||
}
|
||||
|
||||
pub async fn is_settled(&self, bolt11: &str) -> Result<bool> {
|
||||
let nwc = NWC::new(self.url.clone());
|
||||
let result = nwc
|
||||
.lookup_invoice(LookupInvoiceRequest {
|
||||
payment_hash: None,
|
||||
invoice: Some(bolt11.to_string()),
|
||||
})
|
||||
.await;
|
||||
nwc.shutdown().await;
|
||||
let response = result.map_err(|e| anyhow!("failed to lookup invoice: {e}"))?;
|
||||
Ok(response.state == Some(TransactionState::Settled) || response.settled_at.is_some())
|
||||
}
|
||||
}
|
||||
@@ -1,125 +0,0 @@
|
||||
//! General-purpose HTTP helpers shared across route handlers.
|
||||
//!
|
||||
//! Success builders (`res`, `ok`, `created`) return [`ApiResult`] so they
|
||||
//! can sit at the end of a handler without an `Ok(..)` wrap. Error builders
|
||||
//! (`err`, `not_found`, `forbidden`, …) return [`ApiError`] so they compose
|
||||
//! with `.map_err(...)` and with explicit `Err(...)` returns.
|
||||
|
||||
use std::fmt::Display;
|
||||
|
||||
use axum::{
|
||||
Json,
|
||||
http::StatusCode,
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use serde::Serialize;
|
||||
|
||||
pub struct ApiError(pub Box<Response>);
|
||||
|
||||
impl IntoResponse for ApiError {
|
||||
fn into_response(self) -> Response {
|
||||
*self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Response> for ApiError {
|
||||
fn from(r: Response) -> Self {
|
||||
Self(Box::new(r))
|
||||
}
|
||||
}
|
||||
|
||||
pub type ApiResult = Result<Response, ApiError>;
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct DataResponse<T: Serialize> {
|
||||
pub data: T,
|
||||
pub code: &'static str,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct ErrorResponse {
|
||||
pub error: String,
|
||||
pub code: String,
|
||||
}
|
||||
|
||||
// --- success builders (return ApiResult) ------------------------------------
|
||||
|
||||
pub fn res<T: Serialize>(status: StatusCode, data: T) -> ApiResult {
|
||||
Ok((status, Json(DataResponse { data, code: "ok" })).into_response())
|
||||
}
|
||||
|
||||
pub fn ok<T: Serialize>(data: T) -> ApiResult {
|
||||
res(StatusCode::OK, data)
|
||||
}
|
||||
|
||||
pub fn created<T: Serialize>(data: T) -> ApiResult {
|
||||
res(StatusCode::CREATED, data)
|
||||
}
|
||||
|
||||
// --- error builders (return ApiError) ---------------------------------------
|
||||
|
||||
pub fn err(status: StatusCode, code: &str, message: &str) -> ApiError {
|
||||
(
|
||||
status,
|
||||
Json(ErrorResponse {
|
||||
error: message.to_string(),
|
||||
code: code.to_string(),
|
||||
}),
|
||||
)
|
||||
.into_response()
|
||||
.into()
|
||||
}
|
||||
|
||||
pub fn unauthorized(reason: impl Display) -> ApiError {
|
||||
err(StatusCode::UNAUTHORIZED, "unauthorized", &reason.to_string())
|
||||
}
|
||||
|
||||
pub fn forbidden(message: &str) -> ApiError {
|
||||
err(StatusCode::FORBIDDEN, "forbidden", message)
|
||||
}
|
||||
|
||||
pub fn not_found(message: &str) -> ApiError {
|
||||
err(StatusCode::NOT_FOUND, "not-found", message)
|
||||
}
|
||||
|
||||
pub fn bad_request(code: &str, message: &str) -> ApiError {
|
||||
err(StatusCode::BAD_REQUEST, code, message)
|
||||
}
|
||||
|
||||
pub fn unprocessable(code: &str, message: &str) -> ApiError {
|
||||
err(StatusCode::UNPROCESSABLE_ENTITY, code, message)
|
||||
}
|
||||
|
||||
pub fn internal(reason: impl Display) -> ApiError {
|
||||
err(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"internal",
|
||||
&reason.to_string(),
|
||||
)
|
||||
}
|
||||
|
||||
// --- misc utilities ---------------------------------------------------------
|
||||
|
||||
pub fn parse_bool_default(value: i64, default: i64) -> i64 {
|
||||
if value == 0 || value == 1 {
|
||||
value
|
||||
} else {
|
||||
default
|
||||
}
|
||||
}
|
||||
|
||||
/// Recognize sqlite UNIQUE constraint violations on known columns so the
|
||||
/// caller can translate them into 422 responses instead of opaque 500s.
|
||||
pub 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 {
|
||||
return None;
|
||||
};
|
||||
if db_err.message().contains("pubkey") {
|
||||
return Some("pubkey-exists");
|
||||
}
|
||||
if db_err.message().contains("subdomain") {
|
||||
return Some("subdomain-exists");
|
||||
}
|
||||
None
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
use axum::{Json, Router, routing::get};
|
||||
use backend::billing::{fetch_btc_spot_price_from_base, fiat_minor_to_msats_from_quote};
|
||||
|
||||
#[tokio::test]
|
||||
async fn quote_endpoint_can_be_stubbed_deterministically() {
|
||||
async fn spot() -> Json<serde_json::Value> {
|
||||
Json(serde_json::json!({ "data": { "amount": "50000.00" } }))
|
||||
}
|
||||
|
||||
let app = Router::new().route("/v2/prices/BTC-USD/spot", get(spot));
|
||||
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
|
||||
.await
|
||||
.expect("bind test server");
|
||||
let addr = listener.local_addr().expect("get local addr");
|
||||
tokio::spawn(async move {
|
||||
axum::serve(listener, app).await.expect("serve quote stub");
|
||||
});
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
let base = format!("http://{addr}/v2/prices");
|
||||
let btc_price = fetch_btc_spot_price_from_base(&client, &base, "USD")
|
||||
.await
|
||||
.expect("fetch stubbed quote");
|
||||
|
||||
assert_eq!(btc_price, 50_000.0);
|
||||
|
||||
let msats =
|
||||
fiat_minor_to_msats_from_quote(100, "USD", btc_price).expect("convert quoted fiat amount");
|
||||
assert_eq!(msats, 2_000_000);
|
||||
}
|
||||
@@ -1,16 +1,16 @@
|
||||
import { createEffect, createMemo, createSignal, For, Show } from "solid-js"
|
||||
import { createEffect, createSignal, Show } from "solid-js"
|
||||
import QRCode from "qrcode"
|
||||
import Modal from "@/components/Modal"
|
||||
import PaymentSetup from "@/components/PaymentSetup"
|
||||
import { getInvoice, getInvoiceBolt11, type Invoice } from "@/lib/api"
|
||||
import { useTenantRelays } from "@/lib/hooks"
|
||||
import { plans } from "@/lib/state"
|
||||
import { getInvoice, getInvoiceBolt11 } from "@/lib/api"
|
||||
|
||||
type PayStatus = "idle" | "loading" | "success" | "error"
|
||||
type Bolt11Status = "idle" | "loading" | "ready" | "error"
|
||||
|
||||
type PaymentInvoice = Pick<Invoice, "id" | "amount_due"> &
|
||||
Partial<Pick<Invoice, "period_start" | "period_end">>
|
||||
type PaymentInvoice = {
|
||||
id: string
|
||||
amount_due: number
|
||||
}
|
||||
|
||||
type PaymentDialogProps = {
|
||||
invoice: PaymentInvoice
|
||||
@@ -27,14 +27,6 @@ export default function PaymentDialog(props: PaymentDialogProps) {
|
||||
const [payError, setPayError] = createSignal("")
|
||||
const [showPaymentSetup, setShowPaymentSetup] = createSignal(false)
|
||||
const [setupSaved, setSetupSaved] = createSignal(false)
|
||||
const [relays] = useTenantRelays()
|
||||
|
||||
const billedRelays = createMemo(() => {
|
||||
const planById = new Map(plans().map((p) => [p.id, p]))
|
||||
return (relays() ?? [])
|
||||
.map((relay) => ({ relay, plan: planById.get(relay.plan) }))
|
||||
.filter((entry) => entry.plan?.amount > 0)
|
||||
})
|
||||
|
||||
async function loadBolt11() {
|
||||
if (!props.invoice.id) return
|
||||
@@ -92,14 +84,6 @@ export default function PaymentDialog(props: PaymentDialogProps) {
|
||||
|
||||
const amountLabel = () => `$${(props.invoice.amount_due / 100).toFixed(2)}`
|
||||
|
||||
const periodLabel = () => {
|
||||
const { period_start, period_end } = props.invoice
|
||||
if (!period_start || !period_end) return ""
|
||||
const start = new Date(period_start * 1000).toLocaleDateString()
|
||||
const end = new Date(period_end * 1000).toLocaleDateString()
|
||||
return `${start} – ${end}`
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal
|
||||
@@ -114,9 +98,6 @@ export default function PaymentDialog(props: PaymentDialogProps) {
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold text-gray-900">Pay Invoice</h2>
|
||||
<p class="text-2xl font-bold text-gray-900 mt-1">{amountLabel()}</p>
|
||||
<Show when={periodLabel()}>
|
||||
<p class="text-xs text-gray-500 mt-0.5">Billing period {periodLabel()}</p>
|
||||
</Show>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
@@ -136,28 +117,7 @@ export default function PaymentDialog(props: PaymentDialogProps) {
|
||||
<Show
|
||||
when={payStatus() === "success"}
|
||||
fallback={
|
||||
<div class="w-full space-y-4">
|
||||
{/* What's being paid for */}
|
||||
<Show when={billedRelays().length > 0}>
|
||||
<div class="rounded-lg border border-gray-200 bg-gray-50 p-3">
|
||||
<p class="text-xs font-medium text-gray-500 uppercase tracking-wide mb-2">Relays on this invoice</p>
|
||||
<ul class="space-y-1.5">
|
||||
<For each={billedRelays()}>
|
||||
{({ relay, plan }) => (
|
||||
<li class="flex items-center justify-between gap-3 text-sm">
|
||||
<span class="truncate text-gray-900">{relay.info_name || relay.subdomain}</span>
|
||||
<span class="flex-shrink-0 text-xs text-gray-500">
|
||||
{plan?.name ?? relay.plan}
|
||||
<Show when={plan}> · ${(plan!.amount / 100).toFixed(2)}/mo</Show>
|
||||
</span>
|
||||
</li>
|
||||
)}
|
||||
</For>
|
||||
</ul>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<p class="text-xs font-medium text-gray-500 uppercase tracking-wide text-center">Pay with Lightning</p>
|
||||
<div class="w-full space-y-3">
|
||||
<Show when={bolt11Status() === "idle" || bolt11Status() === "loading"}>
|
||||
<div class="flex items-center justify-center py-12 text-sm text-gray-400">Generating invoice...</div>
|
||||
</Show>
|
||||
@@ -197,19 +157,16 @@ 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>
|
||||
|
||||
{/* Card / automatic payment alternative */}
|
||||
<div class="border-t border-gray-100 pt-3 text-center">
|
||||
<p class="text-xs text-gray-500 mb-1">Prefer to pay with a card?</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPaymentSetup(true)}
|
||||
class="text-sm font-medium text-blue-600 hover:text-blue-700"
|
||||
>
|
||||
Pay with card or set up automatic payments
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
|
||||
@@ -40,7 +40,7 @@ export default function PaymentSetup(props: PaymentSetupProps) {
|
||||
setRedirecting(true)
|
||||
setError("")
|
||||
try {
|
||||
const { url } = await createPortalSession(account()!.pubkey, window.location.href)
|
||||
const { url } = await createPortalSession(account()!.pubkey)
|
||||
window.location.href = url
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Failed to open billing portal")
|
||||
|
||||
@@ -35,6 +35,7 @@ export type Plan = {
|
||||
id: string
|
||||
name: string
|
||||
amount: number
|
||||
stripe_price_id: string | null
|
||||
members: number | null
|
||||
blossom: boolean
|
||||
livekit: boolean
|
||||
@@ -45,10 +46,12 @@ export type PlanId = string
|
||||
export type Relay = {
|
||||
id: string
|
||||
tenant: string
|
||||
schema: string
|
||||
subdomain: string
|
||||
plan: PlanId
|
||||
status: string
|
||||
sync_error: string
|
||||
stripe_subscription_item_id: string | null
|
||||
synced: number
|
||||
info_name: string
|
||||
info_icon: string
|
||||
@@ -95,7 +98,7 @@ export type UpdateRelayInput = {
|
||||
|
||||
export type Tenant = {
|
||||
pubkey: string
|
||||
nwc_is_set: boolean
|
||||
nwc_url: string
|
||||
created_at: number
|
||||
stripe_customer_id: string
|
||||
stripe_subscription_id: string | null
|
||||
@@ -105,10 +108,10 @@ export type Tenant = {
|
||||
|
||||
export type Invoice = {
|
||||
id: string
|
||||
customer: string
|
||||
status: string
|
||||
amount_due: number
|
||||
currency: string
|
||||
hosted_invoice_url: string
|
||||
period_start: number
|
||||
period_end: number
|
||||
}
|
||||
@@ -238,10 +241,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`)
|
||||
}
|
||||
@@ -250,9 +249,8 @@ export function reactivateRelay(id: string) {
|
||||
return callApi<undefined, void>("POST", `/relays/${id}/reactivate`)
|
||||
}
|
||||
|
||||
export function createPortalSession(pubkey: string, returnUrl?: string) {
|
||||
const query = returnUrl ? `?return_url=${encodeURIComponent(returnUrl)}` : ""
|
||||
return callApi<undefined, { url: string }>("GET", `/tenants/${pubkey}/stripe/session${query}`)
|
||||
export function createPortalSession(pubkey: string) {
|
||||
return callApi<undefined, { url: string }>("GET", `/tenants/${pubkey}/stripe/session`)
|
||||
}
|
||||
|
||||
export function getInvoiceBolt11(invoiceId: string) {
|
||||
|
||||
@@ -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,
|
||||
@@ -135,7 +136,7 @@ export const reactivateRelayById = (id: string) => reactivateRelay(id)
|
||||
|
||||
export async function tenantNeedsPaymentSetup(): Promise<boolean> {
|
||||
const tenant = await getTenant(account()!.pubkey)
|
||||
return !tenant.nwc_is_set && !tenant.stripe_subscription_id
|
||||
return !tenant.nwc_url && !tenant.stripe_subscription_id
|
||||
}
|
||||
|
||||
export async function getLatestOpenInvoice(): Promise<Invoice | null> {
|
||||
@@ -146,4 +147,14 @@ export async function getLatestOpenInvoice(): Promise<Invoice | null> {
|
||||
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 }
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createSignal } from "solid-js"
|
||||
import { updateRelayById, deactivateRelayById, reactivateRelayById, getLatestOpenInvoice, tenantNeedsPaymentSetup, type Relay } from "@/lib/hooks"
|
||||
import { updateRelayById, deactivateRelayById, reactivateRelayById, getLatestOpenInvoice, type Relay } from "@/lib/hooks"
|
||||
import { setToastMessage } from "@/components/Toast"
|
||||
import type { Invoice, PlanId } from "@/lib/api"
|
||||
|
||||
@@ -31,7 +31,6 @@ export default function useRelayToggles(
|
||||
) {
|
||||
const [busy, setBusy] = createSignal(false)
|
||||
const [pendingInvoice, setPendingInvoice] = createSignal<Invoice | undefined>()
|
||||
const [pendingPaymentSetup, setPendingPaymentSetup] = createSignal(false)
|
||||
|
||||
async function updateRelay(next: Relay, previous: Relay) {
|
||||
mutate(next)
|
||||
@@ -102,12 +101,8 @@ export default function useRelayToggles(
|
||||
}
|
||||
|
||||
if (plan !== "free") {
|
||||
const needsSetup = await tenantNeedsPaymentSetup()
|
||||
if (needsSetup) {
|
||||
const invoice = await getLatestOpenInvoice()
|
||||
if (invoice) setPendingInvoice(invoice)
|
||||
setPendingPaymentSetup(true)
|
||||
}
|
||||
const invoice = await getLatestOpenInvoice()
|
||||
if (invoice) setPendingInvoice(invoice)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -121,5 +116,5 @@ export default function useRelayToggles(
|
||||
onToggleLivekitSupport: () => toggle("livekit_enabled", relay()?.plan !== "free"),
|
||||
}
|
||||
|
||||
return { busy, handleDeactivate, handleReactivate, handleUpdatePlan, pendingInvoice, clearPendingInvoice: () => setPendingInvoice(undefined), pendingPaymentSetup, clearPendingPaymentSetup: () => setPendingPaymentSetup(false), toggles }
|
||||
return { busy, handleDeactivate, handleReactivate, handleUpdatePlan, pendingInvoice, clearPendingInvoice: () => setPendingInvoice(undefined), toggles }
|
||||
}
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import { createEffect, createMemo, createResource, createSignal, For, Show } from "solid-js"
|
||||
import { useSearchParams } from "@solidjs/router"
|
||||
import PageContainer from "@/components/PageContainer"
|
||||
import LoadingState from "@/components/LoadingState"
|
||||
import PaymentDialog from "@/components/PaymentDialog"
|
||||
import useMinLoading from "@/components/useMinLoading"
|
||||
import { updateActiveTenant, useTenant } from "@/lib/hooks"
|
||||
import { createPortalSession, getInvoice, listTenantInvoices, type Invoice } from "@/lib/api"
|
||||
import { createPortalSession, listTenantInvoices, type Invoice } from "@/lib/api"
|
||||
import { account } from "@/lib/state"
|
||||
|
||||
export default function Account() {
|
||||
@@ -18,22 +17,15 @@ export default function Account() {
|
||||
const [portalLoading, setPortalLoading] = createSignal(false)
|
||||
const invoicesLoading = useMinLoading(() => invoices.loading)
|
||||
|
||||
// Deep link: /account?invoice=<id> (e.g. from the billing DM) fetches that
|
||||
// invoice and opens the payment dialog. The fetched invoice takes precedence
|
||||
// over a row the user clicked in the list.
|
||||
const [searchParams, setSearchParams] = useSearchParams()
|
||||
const [deepLinkedInvoice] = createResource(
|
||||
() => searchParams.invoice as string | undefined,
|
||||
(id) => getInvoice(id),
|
||||
)
|
||||
createEffect(() => {
|
||||
if (deepLinkedInvoice.error) setError("Couldn't load that invoice. It may no longer be available.")
|
||||
const hasBillingChanges = createMemo(() => {
|
||||
const current = tenant()?.nwc_url?.trim() ?? ""
|
||||
const next = nwcUrl().trim()
|
||||
return current !== next
|
||||
})
|
||||
const activeInvoice = () => selectedInvoice() ?? deepLinkedInvoice()
|
||||
|
||||
// The backend never returns the stored nwc_url (it's private), so the input is
|
||||
// write-only: we can only act on a newly entered URL, not prefill the saved one.
|
||||
const hasBillingChanges = createMemo(() => nwcUrl().trim().length > 0)
|
||||
createEffect(() => {
|
||||
setNwcUrl(tenant()?.nwc_url ?? "")
|
||||
})
|
||||
|
||||
async function saveBilling() {
|
||||
setError("")
|
||||
@@ -41,7 +33,6 @@ export default function Account() {
|
||||
try {
|
||||
const next = nwcUrl().trim()
|
||||
await updateActiveTenant({ nwc_url: next })
|
||||
setNwcUrl("")
|
||||
await refetchTenant()
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Failed to update billing")
|
||||
@@ -52,15 +43,13 @@ export default function Account() {
|
||||
|
||||
function handleInvoiceDialogClose() {
|
||||
setSelectedInvoice(undefined)
|
||||
// Clearing the query param drops the deep-linked invoice and closes the dialog.
|
||||
if (searchParams.invoice) setSearchParams({ invoice: undefined })
|
||||
void refetchInvoices()
|
||||
}
|
||||
|
||||
async function openPortal() {
|
||||
setPortalLoading(true)
|
||||
try {
|
||||
const { url } = await createPortalSession(account()!.pubkey, window.location.href)
|
||||
const { url } = await createPortalSession(account()!.pubkey)
|
||||
window.location.href = url
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Failed to open billing portal")
|
||||
@@ -122,9 +111,6 @@ export default function Account() {
|
||||
<p class="text-sm text-gray-600 mb-4">
|
||||
Enable automatic payments by providing your Nostr Wallet Connect URL.
|
||||
</p>
|
||||
<Show when={tenant()?.nwc_is_set}>
|
||||
<p class="text-sm text-green-700 mb-4">A wallet is connected. Enter a new URL to replace it.</p>
|
||||
</Show>
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
@@ -202,7 +188,7 @@ export default function Account() {
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<Show when={activeInvoice()}>
|
||||
<Show when={selectedInvoice()}>
|
||||
{(invoice) => (
|
||||
<PaymentDialog
|
||||
invoice={invoice()}
|
||||
|
||||
@@ -92,7 +92,7 @@ export default function Home() {
|
||||
|
||||
<h1 class="text-5xl sm:text-6xl font-extrabold tracking-tight text-gray-900 mb-6 leading-tight">
|
||||
Your community,<br />
|
||||
<span class="text-blue-600">your server.</span>
|
||||
<span class="text-blue-600">your relay.</span>
|
||||
</h1>
|
||||
|
||||
<p class="text-xl text-gray-500 mb-10 max-w-2xl mx-auto leading-relaxed">
|
||||
|
||||
@@ -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,5 +1,5 @@
|
||||
import { useParams } from "@solidjs/router"
|
||||
import { createEffect, createMemo, createResource, createSignal, Show } from "solid-js"
|
||||
import { createMemo, createResource, createSignal, Show } from "solid-js"
|
||||
import BackLink from "@/components/BackLink"
|
||||
import PageContainer from "@/components/PageContainer"
|
||||
import PaymentDialog from "@/components/PaymentDialog"
|
||||
@@ -8,8 +8,7 @@ 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 { getLatestOpenInvoice, getRelayMembers, useRelay, useRelayActivity, useTenant } from "@/lib/hooks"
|
||||
import useRelayToggles from "@/lib/useRelayToggles"
|
||||
import { plans } from "@/lib/state"
|
||||
|
||||
@@ -17,31 +16,20 @@ 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, pendingPaymentSetup, clearPendingPaymentSetup, toggles } = useRelayToggles(relayId, relay, { refetch, mutate })
|
||||
const { busy, handleDeactivate, handleReactivate, handleUpdatePlan, pendingInvoice, clearPendingInvoice, toggles } = useRelayToggles(relayId, relay, { refetch, mutate })
|
||||
|
||||
const [tenant, { refetch: refetchTenant }] = useTenant()
|
||||
const [paymentSetupOpen, setPaymentSetupOpen] = createSignal(false)
|
||||
const [invoiceDialogOpen, setInvoiceDialogOpen] = createSignal(false)
|
||||
const [paymentBannerDismissed, setPaymentBannerDismissed] = createSignal(false)
|
||||
|
||||
createEffect(() => {
|
||||
if (pendingPaymentSetup() && !pendingInvoice()) {
|
||||
setPaymentSetupOpen(true)
|
||||
clearPendingPaymentSetup()
|
||||
}
|
||||
})
|
||||
|
||||
const isPaidRelay = createMemo(() => {
|
||||
const r = relay()
|
||||
if (!r) return false
|
||||
@@ -59,7 +47,7 @@ export default function RelayDetail() {
|
||||
if (!isPaidRelay()) return false
|
||||
const t = tenant()
|
||||
if (!t) return false
|
||||
return !t.nwc_is_set
|
||||
return !t.nwc_url
|
||||
})
|
||||
|
||||
return (
|
||||
@@ -109,7 +97,7 @@ export default function RelayDetail() {
|
||||
</Show>
|
||||
<RelayDetailCard
|
||||
relay={r()}
|
||||
currentMembers={members()?.length}
|
||||
currentMembers={members.length}
|
||||
editHref={`/relays/${params.id}/edit`}
|
||||
onDeactivate={handleDeactivate}
|
||||
onReactivate={handleReactivate}
|
||||
|
||||
@@ -3,15 +3,13 @@ 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, tenantNeedsPaymentSetup } from "@/lib/hooks"
|
||||
import { createRelayForActiveTenant, getLatestOpenInvoice } from "@/lib/hooks"
|
||||
import type { Invoice } from "@/lib/api"
|
||||
|
||||
export default function RelayNew() {
|
||||
const navigate = useNavigate()
|
||||
const [pendingInvoice, setPendingInvoice] = createSignal<Invoice | undefined>()
|
||||
const [paymentSetupOpen, setPaymentSetupOpen] = createSignal(false)
|
||||
let createdRelayId = ""
|
||||
|
||||
async function handleSubmit(values: RelayFormValues) {
|
||||
@@ -19,14 +17,9 @@ export default function RelayNew() {
|
||||
createdRelayId = relay.id
|
||||
|
||||
if (values.plan !== "free") {
|
||||
const needsSetup = await tenantNeedsPaymentSetup()
|
||||
if (needsSetup) {
|
||||
const invoice = await getLatestOpenInvoice()
|
||||
if (invoice) {
|
||||
setPendingInvoice(invoice)
|
||||
return
|
||||
}
|
||||
setPaymentSetupOpen(true)
|
||||
const invoice = await getLatestOpenInvoice()
|
||||
if (invoice) {
|
||||
setPendingInvoice(invoice)
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -34,13 +27,8 @@ export default function RelayNew() {
|
||||
navigate(`/relays/${relay.id}`)
|
||||
}
|
||||
|
||||
function handleInvoiceClose() {
|
||||
function handleDialogClose() {
|
||||
setPendingInvoice(undefined)
|
||||
setPaymentSetupOpen(true)
|
||||
}
|
||||
|
||||
function handleSetupClose() {
|
||||
setPaymentSetupOpen(false)
|
||||
navigate(`/relays/${createdRelayId}`)
|
||||
}
|
||||
|
||||
@@ -59,14 +47,10 @@ export default function RelayNew() {
|
||||
<PaymentDialog
|
||||
invoice={inv()}
|
||||
open={true}
|
||||
onClose={handleInvoiceClose}
|
||||
onClose={handleDialogClose}
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
<PaymentSetup
|
||||
open={paymentSetupOpen()}
|
||||
onClose={handleSetupClose}
|
||||
/>
|
||||
</PageContainer>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -5,9 +5,6 @@ dev:
|
||||
cd frontend && bun dev &
|
||||
wait
|
||||
|
||||
dev-backend:
|
||||
cd backend && onchange src -ik -- bash -c 'RUST_LOG=backend=info cargo run'
|
||||
|
||||
dev-frontend:
|
||||
cd frontend && bun run dev
|
||||
|
||||
@@ -30,7 +27,7 @@ build-backend:
|
||||
cd backend && cargo build
|
||||
|
||||
build-frontend:
|
||||
cd frontend && bun i && bun run build
|
||||
cd frontend && bun run build
|
||||
|
||||
fmt: fmt-backend
|
||||
|
||||
|
||||
Reference in New Issue
Block a user