forked from coracle/caravel
Compare commits
33 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b4af2f3866 | |||
| f8a0860045 | |||
| 97b1bd9a02 | |||
| e7c0e6fdbe | |||
| a998c9b833 | |||
| bf9a768b88 | |||
| f67ef5bca2 | |||
| c02d834fe0 | |||
| e6cbfb361e | |||
| 6d267ed339 | |||
| a654096f25 | |||
| b49d62f1dd | |||
| 2d5eb0ca84 | |||
| dde4b981b2 | |||
| 7134915665 | |||
| cfa52d739f | |||
| 6abe62b569 | |||
| cd7b84439e | |||
| 1c3e0d619a | |||
| 5590b14074 | |||
| 26f05e8b8f | |||
| 066c91a4d1 | |||
| 3ed021214a | |||
| c0aff5f7cf | |||
| c9c1dd2c4c | |||
| 679a56edc3 | |||
| e7efd9d08b | |||
| 0151762362 | |||
| a79c43e17e | |||
| dbe25c372f | |||
| 80a86452d0 | |||
| b1e3747ddb | |||
| 29f657635c |
@@ -0,0 +1,59 @@
|
|||||||
|
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,4 +1,5 @@
|
|||||||
ref
|
ref
|
||||||
|
todo.md
|
||||||
node_modules
|
node_modules
|
||||||
target
|
target
|
||||||
data
|
data
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ docker run -it \
|
|||||||
-v ./config:/app/config \
|
-v ./config:/app/config \
|
||||||
-v ./media:/app/media \
|
-v ./media:/app/media \
|
||||||
-v ./data:/app/data \
|
-v ./data:/app/data \
|
||||||
ghcr.io/coracle-social/zooid
|
gitea.coracle.social/coracle/zooid
|
||||||
```
|
```
|
||||||
|
|
||||||
### 2. Configure the backend
|
### 2. Configure the backend
|
||||||
|
|||||||
+17
-11
@@ -1,10 +1,8 @@
|
|||||||
# Server
|
# Server
|
||||||
HOST=127.0.0.1
|
SERVER_HOST=127.0.0.1
|
||||||
PORT=2892
|
SERVER_PORT=2892
|
||||||
ALLOW_ORIGINS= # Optional comma-separated allowed CORS origins; empty = permissive
|
SERVER_ALLOW_ORIGINS= # Optional comma-separated allowed CORS origins; empty = permissive
|
||||||
|
SERVER_ADMIN_PUBKEYS= # Comma-separated hex pubkeys with admin access
|
||||||
# Auth
|
|
||||||
ADMINS= # Comma-separated hex pubkeys with admin access
|
|
||||||
|
|
||||||
# Database
|
# Database
|
||||||
DATABASE_URL=sqlite://data/caravel.db
|
DATABASE_URL=sqlite://data/caravel.db
|
||||||
@@ -14,19 +12,27 @@ ROBOT_SECRET= # Nostr private key (hex)
|
|||||||
ROBOT_NAME=
|
ROBOT_NAME=
|
||||||
ROBOT_DESCRIPTION=
|
ROBOT_DESCRIPTION=
|
||||||
ROBOT_PICTURE=
|
ROBOT_PICTURE=
|
||||||
ROBOT_OUTBOX_RELAYS=relay.damus.io,relay.primal.net,nos.lol
|
ROBOT_WALLET= # Nostr Wallet Connect URL for generating Lightning invoices
|
||||||
ROBOT_INDEXER_RELAYS=purplepag.es,relay.damus.io,indexer.coracle.social
|
ROBOT_OUTBOX_RELAYS=wss://relay.damus.io,wss://relay.primal.net,wss://nos.lol
|
||||||
ROBOT_MESSAGING_RELAYS=auth.nostr1.com,relay.keychat.io,relay.ditto.pub
|
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
|
||||||
|
|
||||||
# Zooid
|
# Zooid
|
||||||
ZOOID_API_URL=http://127.0.0.1:3334
|
ZOOID_API_URL=http://127.0.0.1:3334
|
||||||
ZOOID_API_SECRET=
|
|
||||||
RELAY_DOMAIN=spaces.coracle.social
|
RELAY_DOMAIN=spaces.coracle.social
|
||||||
LIVEKIT_URL=
|
LIVEKIT_URL=
|
||||||
LIVEKIT_API_KEY=
|
LIVEKIT_API_KEY=
|
||||||
LIVEKIT_API_SECRET=
|
LIVEKIT_API_SECRET=
|
||||||
|
|
||||||
|
# Blossom S3 (optional; when region, bucket, access key, and secret are all set, relays with blossom enabled sync with adapter s3 and key_prefix = relay schema)
|
||||||
|
BLOSSOM_S3_ENDPOINT=
|
||||||
|
BLOSSOM_S3_REGION=
|
||||||
|
BLOSSOM_S3_BUCKET=
|
||||||
|
BLOSSOM_S3_ACCESS_KEY=
|
||||||
|
BLOSSOM_S3_SECRET_KEY=
|
||||||
|
|
||||||
# Billing
|
# Billing
|
||||||
NWC_URL= # Nostr Wallet Connect URL for generating Lightning invoices
|
|
||||||
STRIPE_SECRET_KEY= # Required Stripe API secret key (sk_...)
|
STRIPE_SECRET_KEY= # Required Stripe API secret key (sk_...)
|
||||||
STRIPE_WEBHOOK_SECRET=whsec_test_00000000000000000000000000 # Webhook signing secret (use real value in production)
|
STRIPE_WEBHOOK_SECRET=whsec_test_00000000000000000000000000 # Webhook signing secret (use real value in production)
|
||||||
|
STRIPE_PRICE_BASIC= # Stripe price ID (price_...) for the Basic plan; required for paid plans
|
||||||
|
STRIPE_PRICE_GROWTH= # Stripe price ID (price_...) for the Growth plan; required for paid plans
|
||||||
|
|||||||
Generated
+13
@@ -210,6 +210,7 @@ dependencies = [
|
|||||||
"nostr-sdk",
|
"nostr-sdk",
|
||||||
"nwc",
|
"nwc",
|
||||||
"rand 0.8.5",
|
"rand 0.8.5",
|
||||||
|
"regex",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
@@ -1894,6 +1895,18 @@ dependencies = [
|
|||||||
"bitflags",
|
"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]]
|
[[package]]
|
||||||
name = "regex-automata"
|
name = "regex-automata"
|
||||||
version = "0.4.14"
|
version = "0.4.14"
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ hmac = "0.12"
|
|||||||
sha2 = "0.10"
|
sha2 = "0.10"
|
||||||
dotenvy = "0.15.7"
|
dotenvy = "0.15.7"
|
||||||
base64 = "0.22"
|
base64 = "0.22"
|
||||||
|
regex = "1"
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tower = { version = "0.5", features = ["util"] }
|
tower = { version = "0.5", features = ["util"] }
|
||||||
|
|||||||
+71
-34
@@ -8,7 +8,7 @@ Rust backend for Caravel. It manages tenants, relays, invoices, and background w
|
|||||||
- Axum (HTTP API)
|
- Axum (HTTP API)
|
||||||
- SQLx + SQLite
|
- SQLx + SQLite
|
||||||
- Tokio (async runtime + workers)
|
- Tokio (async runtime + workers)
|
||||||
- Nostr SDK (NIP-98 auth, NIP-17 DMs, NIP-47 wallet connect)
|
- Nostr SDK (NIP-98 auth, NIP-17 DMs, NIP-47 wallet connect, NIP-44 encryption at rest)
|
||||||
|
|
||||||
## Layout
|
## Layout
|
||||||
|
|
||||||
@@ -16,45 +16,82 @@ Rust backend for Caravel. It manages tenants, relays, invoices, and background w
|
|||||||
backend/
|
backend/
|
||||||
migrations/
|
migrations/
|
||||||
0001_init.sql
|
0001_init.sql
|
||||||
|
spec/ # Module-by-module design notes
|
||||||
src/
|
src/
|
||||||
api.rs # Axum routes + NIP-98 auth checks
|
main.rs # App bootstrap: load Env, build services, serve + spawn workers
|
||||||
billing.rs # Invoice generation + collection worker
|
env.rs # Configuration from the environment (+ NIP-44 encryption, NIP-98 signing)
|
||||||
infra.rs # Zooid sync worker
|
api.rs # Shared Api state, router, NIP-98 auth + authorization helpers
|
||||||
main.rs # App bootstrap
|
web.rs # HTTP response envelope + helpers
|
||||||
models.rs # DB models
|
routes/ # HTTP route handlers (identity, plans, tenants, relays, invoices, stripe)
|
||||||
repo.rs # Data access layer
|
models.rs # Domain models + sqlite rows
|
||||||
robot.rs # Nostr robot identity + DM sending
|
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
|
||||||
```
|
```
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
Environment variables:
|
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.
|
||||||
|
|
||||||
| Variable | Description | Default |
|
**Server**
|
||||||
| ------------------------ | ----------------------------------------------------------------------- | ------------------------------------ |
|
|
||||||
| `DATABASE_URL` | SQLite URL. Relative paths are resolved under `backend/`. | `sqlite://<backend>/data/caravel.db` |
|
|
||||||
| `HOST` | API bind host (also used for NIP-98 `u` host check) | `127.0.0.1` |
|
|
||||||
| `PORT` | API bind port | `2892` |
|
|
||||||
| `ADMINS` | Comma-separated admin pubkeys (hex) | _optional_ |
|
|
||||||
| `ALLOW_ORIGINS` | Comma-separated CORS origins. If empty, CORS is permissive. | _optional_ |
|
|
||||||
| `ZOOID_API_URL` | Zooid API base URL used by infra worker | _required for infra sync_ |
|
|
||||||
| `ZOOID_API_SECRET` | Nostr secret key used for authentication of requests to the zooid API | _required_ |
|
|
||||||
| `RELAY_DOMAIN` | Base domain appended to relay subdomains | empty |
|
|
||||||
| `LIVEKIT_URL` | LiveKit URL sent to zooid when relay livekit is enabled | _optional_ |
|
|
||||||
| `LIVEKIT_API_KEY` | LiveKit API key sent to zooid | _optional_ |
|
|
||||||
| `LIVEKIT_API_SECRET` | LiveKit API secret sent to zooid | _optional_ |
|
|
||||||
| `NWC_URL` | Platform NWC URL used to generate BOLT11 invoices | _required for invoice generation_ |
|
|
||||||
| `STRIPE_SECRET_KEY` | Stripe API secret key used for billing API operations | _required_ |
|
|
||||||
| `STRIPE_WEBHOOK_SECRET` | Stripe webhook signing secret used to verify `Stripe-Signature` headers | _required_ |
|
|
||||||
| `ROBOT_SECRET` | Robot Nostr secret key | _required_ |
|
|
||||||
| `ROBOT_NAME` | Robot display name (kind `0`) | _optional_ |
|
|
||||||
| `ROBOT_DESCRIPTION` | Robot description (kind `0`) | _optional_ |
|
|
||||||
| `ROBOT_PICTURE` | Robot picture URL (kind `0`) | _optional_ |
|
|
||||||
| `ROBOT_OUTBOX_RELAYS` | Comma-separated relays published as kind `10002` | _required_ |
|
|
||||||
| `ROBOT_INDEXER_RELAYS` | Comma-separated relays used for recipient relay discovery | _required_ |
|
|
||||||
| `ROBOT_MESSAGING_RELAYS` | Comma-separated relays published as kind `10050` | _required_ |
|
|
||||||
|
|
||||||
Relay list env vars are comma-separated and trimmed. If a relay has no `ws://` or `wss://` scheme, `wss://` is prepended.
|
| 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 schema) 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.
|
||||||
|
|
||||||
## Schema and Architecture
|
## Schema and Architecture
|
||||||
|
|
||||||
@@ -94,7 +131,7 @@ Public exceptions:
|
|||||||
Caravel intentionally uses a session-style variant of NIP-98 for client-to-backend API auth.
|
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.
|
- Frontend signs one kind `27235` event with `u = VITE_API_URL` and caches that header for about 10 minutes.
|
||||||
- Backend verifies event kind, signature, and that `u` contains configured `HOST`.
|
- Backend verifies event kind, signature, and that `u` contains configured `SERVER_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.
|
- 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.
|
- Goal: reduce repeated wallet signing prompts and avoid cookie-based sessions.
|
||||||
- Tradeoff: this is weaker request-intent binding than strict NIP-98 semantics.
|
- Tradeoff: this is weaker request-intent binding than strict NIP-98 semantics.
|
||||||
|
|||||||
@@ -23,7 +23,6 @@ CREATE TABLE IF NOT EXISTS relay (
|
|||||||
schema TEXT NOT NULL,
|
schema TEXT NOT NULL,
|
||||||
subdomain TEXT NOT NULL UNIQUE,
|
subdomain TEXT NOT NULL UNIQUE,
|
||||||
plan TEXT NOT NULL,
|
plan TEXT NOT NULL,
|
||||||
stripe_subscription_item_id TEXT,
|
|
||||||
status TEXT NOT NULL,
|
status TEXT NOT NULL,
|
||||||
synced INTEGER NOT NULL DEFAULT 0,
|
synced INTEGER NOT NULL DEFAULT 0,
|
||||||
sync_error TEXT NOT NULL DEFAULT '',
|
sync_error TEXT NOT NULL DEFAULT '',
|
||||||
@@ -39,3 +38,30 @@ CREATE TABLE IF NOT EXISTS relay (
|
|||||||
push_enabled INTEGER NOT NULL DEFAULT 1,
|
push_enabled INTEGER NOT NULL DEFAULT 1,
|
||||||
FOREIGN KEY (tenant) REFERENCES tenant(pubkey)
|
FOREIGN KEY (tenant) REFERENCES tenant(pubkey)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS lightning_invoice (
|
||||||
|
stripe_invoice_id TEXT PRIMARY KEY,
|
||||||
|
tenant_pubkey TEXT NOT NULL,
|
||||||
|
bolt11 TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL CHECK (status IN ('pending', 'paid')),
|
||||||
|
paid_method TEXT CHECK (paid_method IN ('nwc', 'manual')),
|
||||||
|
expires_at INTEGER NOT NULL,
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
updated_at INTEGER NOT NULL,
|
||||||
|
FOREIGN KEY (tenant_pubkey) REFERENCES tenant(pubkey)
|
||||||
|
);
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_lightning_invoice_tenant_pubkey
|
||||||
|
ON lightning_invoice (tenant_pubkey);
|
||||||
|
|||||||
@@ -1,11 +0,0 @@
|
|||||||
CREATE INDEX IF NOT EXISTS idx_tenant_stripe_customer_id
|
|
||||||
ON tenant (stripe_customer_id);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_relay_tenant_id
|
|
||||||
ON relay (tenant, id);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_relay_tenant_status_plan
|
|
||||||
ON relay (tenant, status, plan);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_activity_resource_type_resource_id_created_at_id
|
|
||||||
ON activity (resource_type, resource_id, created_at DESC, id DESC);
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
CREATE TABLE IF NOT EXISTS invoice_nwc_payment (
|
|
||||||
invoice_id TEXT PRIMARY KEY,
|
|
||||||
tenant_pubkey TEXT NOT NULL,
|
|
||||||
state TEXT NOT NULL CHECK (state IN ('pending', 'paid')),
|
|
||||||
created_at INTEGER NOT NULL,
|
|
||||||
updated_at INTEGER NOT NULL,
|
|
||||||
FOREIGN KEY (tenant_pubkey) REFERENCES tenant(pubkey)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_invoice_nwc_payment_tenant_pubkey
|
|
||||||
ON invoice_nwc_payment (tenant_pubkey);
|
|
||||||
+220
-194
@@ -1,233 +1,259 @@
|
|||||||
# `pub struct Api`
|
# `pub struct Api`
|
||||||
|
|
||||||
Api manages the HTTP interface for the application
|
Api owns the HTTP interface: the shared application state, the router, NIP-98 authentication, and the authorization helpers. The route handlers themselves live in `crate::routes` (`routes/identity.rs`, `plans.rs`, `tenants.rs`, `relays.rs`, `invoices.rs`, `stripe.rs`) and use the response helpers in `spec/web.md`.
|
||||||
|
|
||||||
Members:
|
Members:
|
||||||
|
|
||||||
- `host: String` - the hostname of the service for checking NIP 98 auth, from `HOST`
|
- `env: Env` - configuration (see `spec/env.md`); supplies the NIP-98 host check, admin pubkeys, encryption, etc.
|
||||||
- `admins: Vec<String>` - a list of admin pubkeys from `ADMINS`
|
|
||||||
- `query: Query`
|
- `query: Query`
|
||||||
- `command: Command`
|
- `command: Command`
|
||||||
- `billing: Billing`
|
- `billing: Billing`
|
||||||
|
- `stripe: Stripe`
|
||||||
|
- `robot: Robot`
|
||||||
- `infra: Infra`
|
- `infra: Infra`
|
||||||
|
|
||||||
Notes:
|
Notes:
|
||||||
|
|
||||||
- Authentication is done using NIP 98 comparing `u` to `self.host`, not the incoming request
|
- Authentication is done using NIP-98, comparing the event's `u` tag to `env.server_host`, not the incoming request URL.
|
||||||
- Each route is responsible for authorization using `self.require_admin` or `self.require_admin_or_tenant`
|
- The shared `Api` is wrapped in an `Arc` and handed to every handler as `State<Arc<Api>>`.
|
||||||
- Successful API responses should be of the form `{data, code: "ok"}` with an appropriate http status code.
|
- A handler that requires an authenticated caller takes an `AuthedPubkey` extractor; handlers that omit it are anonymous.
|
||||||
- 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.
|
- Each handler is responsible for authorization using `require_admin` or `require_admin_or_tenant`.
|
||||||
|
- Successful responses are `{ data, code: "ok" }`; error responses are `{ error, code }`, both with an appropriate HTTP status (see `spec/web.md`).
|
||||||
|
|
||||||
## `pub fn new() -> Self`
|
## `pub fn new(query, command, billing, stripe, robot, infra, env: &Env) -> Self`
|
||||||
|
|
||||||
- Reads environment and populates members
|
- Stores the services and a clone of `env`
|
||||||
|
|
||||||
## `pub fn router(&self) -> Result<()>`
|
## `pub fn router(self) -> Router`
|
||||||
|
|
||||||
- Returns an `axum::Router`
|
- Wraps `self` in an `Arc` and returns an `axum::Router` with the routes below as state-bearing routes
|
||||||
|
|
||||||
--- Plan routes
|
## `pub fn is_admin(&self, pubkey: &str) -> bool`
|
||||||
|
|
||||||
## `async fn list_plans(...) -> Response`
|
- Whether `pubkey` is in `env.server_admin_pubkeys`
|
||||||
|
|
||||||
- Serves `GET /plans`
|
## `pub fn require_admin(&self, authorized_pubkey: &str) -> Result<(), ApiError>`
|
||||||
- No authentication required
|
|
||||||
- Return `data` is a list of plan structs from `Query::list_plans`
|
|
||||||
|
|
||||||
## `async fn get_plan(...) -> Response`
|
- `Ok` if `authorized_pubkey` is an admin, otherwise a `403`
|
||||||
|
|
||||||
- Serves `GET /plans/:id`
|
## `pub fn require_admin_or_tenant(&self, authorized_pubkey: &str, tenant_pubkey: &str) -> Result<(), ApiError>`
|
||||||
- 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
|
- `Ok` if `authorized_pubkey` is an admin or equals `tenant_pubkey`, otherwise a `403`
|
||||||
|
|
||||||
## `async fn get_identity(...) -> Response`
|
## `pub async fn get_tenant_or_404(&self, pubkey: &str) -> Result<Tenant, ApiError>`
|
||||||
|
|
||||||
- Serves `GET /identity`
|
- Looks up a tenant, returning `404` `not-found` if missing and `500` on a query error
|
||||||
- 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
|
## `pub async fn get_relay_or_404(&self, id: &str) -> Result<Relay, ApiError>`
|
||||||
|
|
||||||
## `async fn list_tenants(...) -> Response`
|
- Looks up a relay, returning `404` `not-found` if missing and `500` on a query error
|
||||||
|
|
||||||
- Serves `GET /tenants`
|
# Authentication
|
||||||
- Authorizes admin only
|
|
||||||
- Return `data` is a list of tenant structs from `query.list_tenants`
|
|
||||||
|
|
||||||
## `async fn create_tenant(...) -> Response`
|
## `pub struct AuthedPubkey(pub String)`
|
||||||
|
|
||||||
- Serves `POST /tenants`
|
An axum extractor (`FromRequestParts`) that authenticates a request via NIP-98 and yields the signer's pubkey. Adding it to a handler signature is what enforces "must be authenticated"; on failure the request is rejected with a `401`.
|
||||||
- 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`
|
## `fn extract_auth_pubkey(&self, headers: &HeaderMap) -> Result<String, ApiError>` / `fn decode_nip98_pubkey(&self, headers) -> Result<String>`
|
||||||
|
|
||||||
- Serves `GET /tenants/:pubkey`
|
- Parses the `Authorization` header, which must use the `Nostr ` scheme followed by a base64-encoded NIP-98 event
|
||||||
- Authorizes admin or matching tenant
|
- Decodes and parses the event, requires kind `27235` (`HttpAuth`), and verifies its signature
|
||||||
- Return `data` is a single tenant struct from `query.get_tenant`
|
- Requires the event's `u` tag to contain `env.server_host` (skipped when `server_host` is empty)
|
||||||
|
- Intentionally does **not** enforce exact request URL/method/query matching, and does **not** validate the `payload` tag/hash, `created_at` freshness window, or a replay nonce/cache
|
||||||
## `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 list_relay_members(...) -> Response`
|
|
||||||
|
|
||||||
- Serves `GET /relays/:id/members`
|
|
||||||
- Authorizes admin or relay owner
|
|
||||||
- For unsynced relays, returns an empty member list without calling zooid
|
|
||||||
- For synced relays, proxies the member list from zooid via `infra`
|
|
||||||
- Return `data` is `{ members }`
|
|
||||||
|
|
||||||
## `async fn create_relay(...) -> Response`
|
|
||||||
|
|
||||||
- Serves `POST /relays`
|
|
||||||
- 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`
|
|
||||||
- If the requested plan changes to a plan with a finite member limit and the current member count exceeds that limit, return a `422` with `code=member-limit-exceeded`
|
|
||||||
- Updates the given relay using `command.update_relay`
|
|
||||||
- If relay is a duplicate by subdomain, return a `422` with `code=subdomain-exists`
|
|
||||||
- Return `data` is a single relay struct.
|
|
||||||
|
|
||||||
## `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
|
- This is a deliberate session-style tradeoff to reduce repeated signer prompts in the client
|
||||||
- Returns pubkey if header all checks pass
|
- Returns the signer pubkey (hex) when all checks pass; any failure surfaces as a `401`
|
||||||
|
|
||||||
Refer to https://github.com/nostr-protocol/nips/blob/master/98.md for details. Use `nostr_sdk` functionality where possible.
|
Refer to https://github.com/nostr-protocol/nips/blob/master/98.md for details. Uses `nostr_sdk` functionality where possible.
|
||||||
|
|
||||||
## `require_admin(&self, authorized_pubkey: &str)`
|
# Routes
|
||||||
|
|
||||||
- Checks whether `authorized_pubkey` is in `self.admins`. If not, returns an forbidden error
|
Handlers take `State<Arc<Api>>`, an optional `AuthedPubkey`, then path/query/body extractors, and return `ApiResult`.
|
||||||
|
|
||||||
## `require_admin_or_tenant(&self, authorized_pubkey: &str, tenant_pubkey: &str)`
|
--- Identity
|
||||||
|
|
||||||
- Checks whether `authorized_pubkey` is an admin or matches `tenant_pubkey`
|
## `get_identity` — `GET /identity`
|
||||||
|
|
||||||
## `prepare_relay(&self, relay: Relay) -> anyhow::Result<Relay>`
|
- Authenticated (any signer)
|
||||||
|
- Side-effect-free: returns `{ pubkey, is_admin }`
|
||||||
|
- Clients must call `POST /tenants` before any tenant-scoped write
|
||||||
|
|
||||||
- Validate `subdomain`
|
--- Plans
|
||||||
- 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`
|
## `list_plans` — `GET /plans`
|
||||||
- If selected `plan` does not include `livekit` and `livekit` is enabled, return `premium-feature`
|
|
||||||
- Populate `schema` if not already set
|
- No authentication required
|
||||||
- Populate missing fields using reasonable defaults
|
- `data` is the list of plans from `query.list_plans`
|
||||||
|
|
||||||
|
## `get_plan` — `GET /plans/:id`
|
||||||
|
|
||||||
|
- No authentication required
|
||||||
|
- `data` is the plan matching `id`; `404` `not-found` if it doesn't exist
|
||||||
|
|
||||||
|
--- Tenants
|
||||||
|
|
||||||
|
## `list_tenants` — `GET /tenants`
|
||||||
|
|
||||||
|
- Admin only
|
||||||
|
- `data` is a list of `TenantResponse` (exposes `nwc_is_set: bool` instead of `nwc_url`)
|
||||||
|
|
||||||
|
## `create_tenant` — `POST /tenants`
|
||||||
|
|
||||||
|
- Authenticated (any signer); the target pubkey is the auth pubkey, no request body
|
||||||
|
- Idempotent: if a tenant already exists for the auth pubkey, return it without calling Stripe or writing to the DB
|
||||||
|
- Otherwise resolve a display name via `robot.fetch_nostr_name` (falling back to the first 8 chars of the pubkey), create a Stripe customer via `stripe.create_customer`, and create the tenant. No subscription is created yet — that happens when the first paid relay is added.
|
||||||
|
- On a unique-constraint race (`pubkey-exists`), re-fetch and return the existing tenant
|
||||||
|
- Always returns `200`; `data` is a `TenantResponse`
|
||||||
|
|
||||||
|
## `get_tenant` — `GET /tenants/:pubkey`
|
||||||
|
|
||||||
|
- Admin or matching tenant
|
||||||
|
- `data` is a `TenantResponse`
|
||||||
|
|
||||||
|
## `update_tenant` — `PUT /tenants/:pubkey`
|
||||||
|
|
||||||
|
- Admin or matching tenant
|
||||||
|
- Accepts an optional `nwc_url`: an empty string clears it, otherwise it is encrypted at rest via `env.encrypt`
|
||||||
|
- Updates the tenant via `command.update_tenant`
|
||||||
|
- `data` is the updated `TenantResponse`
|
||||||
|
|
||||||
|
## `list_tenant_relays` — `GET /tenants/:pubkey/relays`
|
||||||
|
|
||||||
|
- Admin or matching tenant
|
||||||
|
- `data` is the tenant's relays from `query.list_relays_for_tenant`
|
||||||
|
|
||||||
|
--- Relays
|
||||||
|
|
||||||
|
## `list_relays` — `GET /relays`
|
||||||
|
|
||||||
|
- Admin only
|
||||||
|
- `data` is all relays from `query.list_relays`
|
||||||
|
|
||||||
|
## `get_relay` — `GET /relays/:id`
|
||||||
|
|
||||||
|
- `404` `not-found` if the relay doesn't exist; then admin or relay owner
|
||||||
|
- `data` is the relay
|
||||||
|
|
||||||
|
## `list_relay_members` — `GET /relays/:id/members`
|
||||||
|
|
||||||
|
- Admin or relay owner
|
||||||
|
- For unsynced relays (`synced = 0`), returns an empty member list without calling zooid
|
||||||
|
- For synced relays, proxies the member list from zooid via `infra.list_relay_members`
|
||||||
|
- `data` is `{ members }`
|
||||||
|
|
||||||
|
## `create_relay` — `POST /relays`
|
||||||
|
|
||||||
|
- Admin or the `tenant` pubkey in the request body
|
||||||
|
- Generates the relay `id`/`schema`, validates and normalizes the relay via `prepare_relay`, and creates it via `command.create_relay`
|
||||||
|
- Duplicate subdomain → `422` `subdomain-exists`
|
||||||
|
- `data` is the relay; HTTP `201`
|
||||||
|
|
||||||
|
## `update_relay` — `PUT /relays/:id`
|
||||||
|
|
||||||
|
- `404` if missing; then admin or relay owner
|
||||||
|
- Applies the provided optional fields, then validates/normalizes via `prepare_relay`
|
||||||
|
- If the plan changes to one with a finite member limit and the current member count exceeds it, return `422` `member-limit-exceeded`
|
||||||
|
- Updates via `command.update_relay`; duplicate subdomain → `422` `subdomain-exists`
|
||||||
|
- `data` is the relay
|
||||||
|
|
||||||
|
## `list_relay_activity` — `GET /relays/:id/activity`
|
||||||
|
|
||||||
|
- `404` if missing; then admin or relay owner
|
||||||
|
- `data` is `{ activity }` from `query.list_activity_for_resource`
|
||||||
|
|
||||||
|
## `deactivate_relay` — `POST /relays/:id/deactivate`
|
||||||
|
|
||||||
|
- `404` if missing; then admin or relay owner
|
||||||
|
- If status is `delinquent`, return `400` `relay-is-delinquent`; if already `inactive`, return `400` `relay-is-inactive`
|
||||||
|
- Otherwise `command.deactivate_relay`; `data` is empty
|
||||||
|
|
||||||
|
## `reactivate_relay` — `POST /relays/:id/reactivate`
|
||||||
|
|
||||||
|
- `404` if missing; then admin or relay owner
|
||||||
|
- If status is `delinquent`, return `400` `relay-is-delinquent` (a delinquent relay must be resolved through payment, not reactivated by the user); if already `active`, return `400` `relay-is-active`
|
||||||
|
- Otherwise `command.activate_relay`; `data` is empty
|
||||||
|
|
||||||
|
--- Invoices
|
||||||
|
|
||||||
|
## `list_tenant_invoices` — `GET /tenants/:pubkey/invoices`
|
||||||
|
|
||||||
|
- Admin or matching tenant
|
||||||
|
- Looks up the tenant, then lists invoices from Stripe by `stripe_customer_id`
|
||||||
|
- `data` is a list of `StripeInvoice` objects: `{ id, customer, status, amount_due, currency }`
|
||||||
|
|
||||||
|
## `get_invoice` — `GET /invoices/:id`
|
||||||
|
|
||||||
|
- Fetches the invoice from Stripe (`404` `not-found` if it doesn't exist)
|
||||||
|
- Looks up the tenant by the invoice's `customer` (`404` if none), then authorizes admin or matching tenant
|
||||||
|
- Runs `billing.reconcile_invoice` (marks it paid if its bolt11 already settled out of band)
|
||||||
|
- `data` is the (possibly refreshed) `StripeInvoice`
|
||||||
|
|
||||||
|
## `get_lightning_invoice` — `GET /invoices/:id/bolt11`
|
||||||
|
|
||||||
|
- Fetches the invoice from Stripe (`404` if it doesn't exist) and the tenant by `customer` (`404` if none), then authorizes admin or matching tenant
|
||||||
|
- Runs `billing.reconcile_invoice`, then `billing.ensure_lightning_invoice` to get or (re)issue the bolt11 for the invoice's `amount_due`/`currency`
|
||||||
|
- `data` is the `LightningInvoice` (including its `bolt11`)
|
||||||
|
|
||||||
|
--- Stripe portal
|
||||||
|
|
||||||
|
## `create_stripe_session` — `GET /tenants/:pubkey/stripe/session`
|
||||||
|
|
||||||
|
- Admin or matching tenant; accepts an optional `return_url` query parameter
|
||||||
|
- Looks up the tenant and creates a Stripe Customer Portal session for its `stripe_customer_id`
|
||||||
|
- `data` is `{ url }` — the portal session URL
|
||||||
|
|
||||||
|
--- Stripe webhook
|
||||||
|
|
||||||
|
## `stripe_webhook` — `POST /stripe/webhook`
|
||||||
|
|
||||||
|
- No NIP-98 authentication — verified via the `Stripe-Signature` header over the raw body
|
||||||
|
- Reads the raw body and signature, verifies/parses the event via `stripe.get_webhook_event`, and dispatches to the handlers below
|
||||||
|
- Returns `200` on success, `400` (`webhook-error`) on verification/parse failure
|
||||||
|
|
||||||
|
# Webhook event handlers
|
||||||
|
|
||||||
|
Implemented in `routes/stripe.rs`. They translate verified Stripe events into domain actions, looking the tenant up by `stripe_customer_id` and ignoring events whose customer doesn't map to a tenant. Unknown event types are ignored.
|
||||||
|
|
||||||
|
## `invoice.created`
|
||||||
|
|
||||||
|
Attempts to pay a new subscription invoice (Stripe is pay-in-advance, so this fires immediately when a paid relay is added or a plan is upgraded). Skips `amount_due` of 0. Ensures a `LightningInvoice` exists, then in priority order:
|
||||||
|
|
||||||
|
1. **NWC auto-pay**: if the tenant has a `nwc_url`, run `billing.pay_invoice_nwc`. On success, done. On failure, record the error via `command.set_tenant_nwc_error`, log it, summarize it for the eventual DM, and fall through.
|
||||||
|
2. **Card on file**: if `stripe.has_payment_method`, do nothing — Stripe charges automatically for this attempt.
|
||||||
|
3. **Manual payment**: send a DM via `robot.send_dm` telling the tenant payment is due (including the summarized NWC error, if any), with a link to the app for manual Lightning payment.
|
||||||
|
|
||||||
|
## `invoice.paid`
|
||||||
|
|
||||||
|
- If the tenant has `past_due_at` set, clear it (`command.clear_tenant_past_due`) and reactivate each `delinquent` relay on a paid plan via `command.activate_relay`
|
||||||
|
|
||||||
|
## `invoice.payment_failed`
|
||||||
|
|
||||||
|
- If the tenant doesn't already have `past_due_at` set, set it (`command.set_tenant_past_due`) and DM the tenant that payment failed and their relays may be deactivated if unresolved
|
||||||
|
|
||||||
|
## `invoice.overdue`
|
||||||
|
|
||||||
|
- Mark every `active` relay on a paid plan `delinquent` (`command.mark_relay_delinquent`) and DM the tenant that their paid relays were deactivated for non-payment
|
||||||
|
|
||||||
|
## `customer.subscription.updated`
|
||||||
|
|
||||||
|
- If the subscription status is `canceled` or `unpaid`, clear `stripe_subscription_id` (`command.clear_tenant_subscription`) and mark every `active` paid relay `delinquent`
|
||||||
|
|
||||||
|
## `customer.subscription.deleted`
|
||||||
|
|
||||||
|
- Clear `stripe_subscription_id` (`command.clear_tenant_subscription`)
|
||||||
|
|
||||||
|
## `payment_method.attached`
|
||||||
|
|
||||||
|
- Retry Stripe collection (`stripe.pay_invoice`) for every `open` invoice with `amount_due > 0`, so invoices that were due before the card was added are charged immediately
|
||||||
|
|
||||||
|
# Helpers
|
||||||
|
|
||||||
|
## `prepare_relay(api: &Api, relay: Relay) -> Result<Relay, ApiError>`
|
||||||
|
|
||||||
|
- Validates `subdomain` against the allowed pattern and a reserved list (`api`, `admin`, `internal`) → `422` `invalid-subdomain`
|
||||||
|
- Validates that `plan` matches a known plan → `422` `invalid-plan`
|
||||||
|
- If the relay enables `blossom`/`livekit` but the selected plan doesn't include it → `422` `premium-feature`
|
||||||
|
- Normalizes the boolean relay flags to sane defaults
|
||||||
|
|
||||||
|
# `TenantResponse`
|
||||||
|
|
||||||
|
The tenant shape returned by tenant endpoints. Same as `Tenant` but replaces `nwc_url` with `nwc_is_set: bool` (true when a `nwc_url` is stored) and never exposes the stored URL: `{ pubkey, nwc_is_set, nwc_error, created_at, stripe_customer_id, stripe_subscription_id, past_due_at }`.
|
||||||
|
|||||||
+55
-103
@@ -1,144 +1,96 @@
|
|||||||
# `pub struct Billing`
|
# `pub struct Billing`
|
||||||
|
|
||||||
Billing encapsulates logic related to synchronizing state with Stripe, processing payments via NWC, and managing subscription lifecycle.
|
Billing encapsulates the domain logic for synchronizing a tenant's Stripe subscription with their relays and for collecting Stripe invoices over Lightning (NWC auto-pay and manual payment).
|
||||||
|
|
||||||
|
It owns the domain logic only: Stripe REST calls go through `Stripe` (see `spec/stripe.md`), NWC wallet operations through `Wallet` (see `spec/wallet.md`), and fiat → msats conversion through `bitcoin` (see `spec/bitcoin.md`). The Stripe webhook dispatch that calls into `Billing` lives in `spec/api.md`.
|
||||||
|
|
||||||
Members:
|
Members:
|
||||||
|
|
||||||
- `nwc_url: String` - a nostr wallet connect URL used to **create** bolt11 invoices (i.e. receive payments), from `NWC_URL`
|
- `stripe: Stripe` - Stripe REST wrapper (see `spec/stripe.md`)
|
||||||
- `stripe_secret_key: String` - Stripe API key used for billing API operations, from `STRIPE_SECRET_KEY`
|
- `wallet: Wallet` - the system NWC wallet, used to issue and look up bolt11 invoices (see `spec/wallet.md`)
|
||||||
- `stripe_webhook_secret: String` - secret for verifying Stripe webhook signatures, from `STRIPE_WEBHOOK_SECRET`
|
|
||||||
- `query: Query`
|
- `query: Query`
|
||||||
- `command: Command`
|
- `command: Command`
|
||||||
- `robot: Robot`
|
- `env: Env` - used to decrypt a tenant's stored `nwc_url`
|
||||||
|
|
||||||
## `pub fn new(query: Query, command: Command, robot: Robot) -> Self`
|
## `pub fn new(query: Query, command: Command, env: &Env) -> Self`
|
||||||
|
|
||||||
- Reads environment and populates members
|
- Builds `stripe` via `Stripe::new(env)` and `wallet` via `Wallet::from_url(&env.robot_wallet)`
|
||||||
- Panics if `STRIPE_SECRET_KEY` is missing/empty
|
- Panics if `ROBOT_WALLET` is not a valid NWC URL
|
||||||
- Panics if `STRIPE_WEBHOOK_SECRET` is missing/empty
|
|
||||||
|
|
||||||
## `pub fn start(&self)`
|
## `pub async fn start(self)`
|
||||||
|
|
||||||
- Subscribes to `command.notify.subscribe()`
|
- 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`.
|
- Runs a full reconcile (`reconcile_subscriptions("startup")`) before entering the loop
|
||||||
|
- Loops on `rx.recv()`, calling `handle_activity` for each `Activity`
|
||||||
|
- On `Lagged`, runs a full reconcile (`reconcile_subscriptions("lagged")`); on `Closed`, exits
|
||||||
|
|
||||||
## `pub fn sync_relay_subscription(&self, activity: &Activity)`
|
## `async fn reconcile_subscriptions(&self, source: &str)`
|
||||||
|
|
||||||
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.
|
- Calls `reconcile_subscription` for every tenant, logging (but not aborting on) per-tenant errors
|
||||||
|
|
||||||
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.
|
## `async fn handle_activity(&self, activity: &Activity)`
|
||||||
|
|
||||||
- Fetch the relay and tenant associated with the `activity`
|
- On `create_relay`, `update_relay`, `activate_relay`, `deactivate_relay`, `fail_relay_sync`, or `complete_relay_sync`: resolve the tenant named by the activity (skip if it no longer exists) and call `reconcile_subscription`
|
||||||
- **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<()>`
|
## `async fn reconcile_subscription(&self, tenant: &Tenant)`
|
||||||
|
|
||||||
- Verify the webhook signature using `self.stripe_webhook_secret`
|
Reconciles a tenant's single Stripe subscription with the set of relays that should be billed. Only paid (non-free) relays interact with Stripe; free-only tenants have no subscription. Idempotent.
|
||||||
- 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>`
|
Stripe forbids two subscription items on the same subscription from sharing a price, so billing is modeled as **one subscription item per plan (price), with `quantity` equal to the number of the tenant's `active` relays on that plan**. The relay → item mapping is fully derivable from `relay.plan → plan.stripe_price_id` and the live subscription's items, so nothing is persisted on the relay.
|
||||||
|
|
||||||
- Fetches invoices from Stripe API for the given customer
|
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 the webhook handler attempts payment (see `spec/api.md`).
|
||||||
- Returns the `data` array from the Stripe response
|
|
||||||
|
|
||||||
## `pub async fn stripe_get_invoice(&self, invoice_id: &str) -> Result<Value>`
|
- Build the desired state via `get_quantity_by_price_id`.
|
||||||
|
- **No relays to bill** (desired state empty): `ensure_subscription_is_inactive` and return.
|
||||||
|
- Otherwise `ensure_subscription_is_active` to resolve/create the subscription, then `ensure_subscription_items` to sync its items.
|
||||||
|
|
||||||
- Fetches a single invoice from Stripe API by ID
|
## `async fn get_quantity_by_price_id(&self, tenant: &Tenant) -> Result<BTreeMap<String, i64>>`
|
||||||
- Returns the full Stripe invoice object
|
|
||||||
|
|
||||||
## `pub async fn create_bolt11(&self, amount_due_cents: i64) -> Result<String>`
|
- For each `active` relay whose plan has a `stripe_price_id`, count relays per price. Returns the price → quantity map.
|
||||||
|
|
||||||
- Creates a bolt11 Lightning invoice for the given amount using the system NWC wallet (`self.nwc_url`)
|
## `async fn get_subscription(&self, tenant: &Tenant) -> Result<Option<StripeSubscription>>`
|
||||||
- Returns the bolt11 invoice string
|
|
||||||
|
|
||||||
## `pub async fn stripe_create_portal_session(&self, customer_id: &str) -> Result<String>`
|
- If the tenant has a `stripe_subscription_id`, fetch it from Stripe (`None` if Stripe 404s)
|
||||||
|
- If the fetched subscription's status is `canceled`/`incomplete_expired`, call `command.clear_tenant_subscription` and return `None`
|
||||||
|
|
||||||
- Creates a Stripe Customer Portal session for the given customer
|
## `async fn ensure_subscription_is_active(&self, tenant, quantity_by_price_id) -> Result<StripeSubscription>`
|
||||||
- Returns the portal session URL
|
|
||||||
|
|
||||||
## `pub async fn pay_outstanding_nwc_invoices(&self, tenant: &Tenant) -> Result<()>`
|
- Returns the existing subscription if there is one
|
||||||
|
- Otherwise creates a Stripe subscription with the desired items (Stripe rejects an itemless subscription) and saves the id via `command.set_tenant_subscription`
|
||||||
|
|
||||||
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.
|
## `async fn ensure_subscription_is_inactive(&self, tenant: &Tenant)`
|
||||||
|
|
||||||
- If `tenant.nwc_url` is empty, return early.
|
- If the tenant still has a live subscription, cancel it via Stripe and call `command.clear_tenant_subscription`
|
||||||
- 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<()>`
|
## `async fn ensure_subscription_items(&self, subscription, quantity_by_price_id)`
|
||||||
|
|
||||||
Attempts Stripe-side collection for open invoices when the tenant has a card on file.
|
- For each desired `(price, quantity)`: update the matching item's quantity if it differs, otherwise create the item
|
||||||
|
- Delete any existing item whose price no longer appears in the desired state
|
||||||
|
|
||||||
- If tenant has no card payment method, return early.
|
## `pub async fn ensure_lightning_invoice(&self, stripe_invoice_id: &str, tenant_pubkey: &str, amount_due: i64, currency: &str) -> Result<LightningInvoice>`
|
||||||
- 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)`
|
- Returns the existing `lightning_invoice` row if it is already `paid`, or still `pending` and not expired
|
||||||
|
- Otherwise converts `amount_due` to msats via `bitcoin::fiat_to_msats`, issues a fresh bolt11 (1 hour expiry) on the system wallet, and upserts it via `command.insert_lightning_invoice` (re-reading the stored row if the upsert was a no-op because the invoice was already paid)
|
||||||
|
|
||||||
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:
|
## `pub async fn pay_invoice_nwc(&self, tenant: &Tenant, invoice: &LightningInvoice) -> Result<()>`
|
||||||
|
|
||||||
1. **NWC auto-pay**: If the tenant has a `nwc_url`:
|
Pays a Lightning invoice from the tenant's own wallet.
|
||||||
- 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.
|
- Decrypt the tenant's `nwc_url` (`env.decrypt`) and build a tenant `Wallet`
|
||||||
|
- Pay `invoice.bolt11` from the tenant wallet
|
||||||
|
- On success, `settle_invoice(..., "nwc")`
|
||||||
|
- On a pay error, the payment may still have landed before the response was lost: check `wallet.is_settled(invoice.bolt11)` on the system wallet and `settle_invoice(..., "nwc")` if it settled; otherwise return the pay error
|
||||||
|
|
||||||
## `fn handle_invoice_paid(&self, invoice: &Invoice)`
|
## `pub async fn reconcile_invoice(&self, invoice: &StripeInvoice) -> Result<StripeInvoice>`
|
||||||
|
|
||||||
- Look up tenant by `stripe_customer_id`
|
Catches an out-of-band Lightning payment we never recorded (e.g. the user paid but the frontend failed to notify us). Meant to run before presenting a payable invoice so we never hand back one that's already been paid.
|
||||||
- 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)`
|
- If the invoice is not `open`, or has no `lightning_invoice` row, return it unchanged
|
||||||
|
- If its bolt11 has settled on the system wallet, `settle_invoice(..., "manual")` and return the re-fetched (now paid) Stripe invoice; fall back to the original snapshot if Stripe momentarily 404s
|
||||||
|
- On any settlement-lookup failure, log and return the invoice unchanged
|
||||||
|
|
||||||
- Look up tenant by `stripe_customer_id`
|
## `async fn settle_invoice(&self, stripe_invoice_id: &str, tenant_pubkey: &str, method: &str)`
|
||||||
- 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)`
|
- `command.mark_lightning_invoice_paid(stripe_invoice_id, method)` (first-writer-wins)
|
||||||
|
- `stripe.pay_invoice_out_of_band(stripe_invoice_id)` (idempotent)
|
||||||
- Look up tenant by `stripe_customer_id`
|
- `command.clear_tenant_nwc_error(tenant_pubkey)`
|
||||||
- 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,11 @@
|
|||||||
|
# `bitcoin` — fiat ↔ Bitcoin conversion
|
||||||
|
|
||||||
|
Free async helpers for pricing fiat amounts in Lightning units against a live BTC spot price. The NWC wallet lives in `spec/wallet.md`; billing orchestration lives in `spec/billing.md`.
|
||||||
|
|
||||||
|
## `pub async fn fiat_to_msats(amount_fiat_minor: i64, currency: &str) -> Result<u64>`
|
||||||
|
|
||||||
|
Converts a Stripe-style minor-unit fiat amount to millisatoshis using the live BTC spot price for `currency` and Stripe's per-currency decimal exponent (most currencies 2; `JPY`/`KRW`/… 0; `BHD`/`KWD`/… 3).
|
||||||
|
|
||||||
|
## `pub async fn get_bitcoin_price(currency: &str) -> Result<f64>`
|
||||||
|
|
||||||
|
Returns the current BTC spot price in `currency`, fetched from Coinbase's public spot-price endpoint.
|
||||||
+50
-50
@@ -9,101 +9,101 @@ Members:
|
|||||||
|
|
||||||
Notes:
|
Notes:
|
||||||
|
|
||||||
- All public write methods should be atomic
|
- Write methods that mutate tenants/relays are atomic and accompanied by an activity log entry of `(tenant, activity_type, resource_type, resource_id)`, run inside a single transaction via the `with_activity` helper.
|
||||||
- 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 (resolving `tenant` from the resource — directly for `tenant` resources, by looking up `relay.tenant` for `relay` resources — and using `chrono::Utc::now()` for `created_at`).
|
||||||
- `insert_activity` builds and returns the `Activity` struct (using `chrono::Utc::now()` for `created_at`)
|
- After each successful commit, the `Activity` is sent on the broadcast channel.
|
||||||
- After each successful commit, sends the `Activity` on the broadcast channel
|
- The subscription/error/past-due setters and the lightning-invoice writes below intentionally do **not** log activity and write directly to the pool.
|
||||||
|
|
||||||
## `pub fn new(&self, pool: SqlitePool) -> Self`
|
## `pub fn new(pool: SqlitePool) -> Self`
|
||||||
|
|
||||||
- Assigns pool to self
|
- Stores the pool and creates the broadcast channel
|
||||||
- Creates the broadcast channel
|
|
||||||
|
|
||||||
## `pub fn create_tenant(&self, tenant: &Tenant) -> Result<()>`
|
## `pub async fn create_tenant(&self, tenant: &Tenant) -> Result<()>`
|
||||||
|
|
||||||
- Creates tenant, may throw sqlite uniqueness error on pubkey
|
- Creates tenant (writes `pubkey`, `nwc_url`, `created_at`, `stripe_customer_id`), may throw sqlite uniqueness error on pubkey
|
||||||
- Logs activity as `(create_tenant, tenant_id)`
|
- Logs activity as `(create_tenant, tenant, pubkey)`
|
||||||
|
|
||||||
## `pub fn update_tenant(&self, tenant: &Tenant) -> Result<()>`
|
## `pub async fn update_tenant(&self, tenant: &Tenant) -> Result<()>`
|
||||||
|
|
||||||
- Updates tenant
|
- Updates the tenant's `nwc_url`
|
||||||
- Logs activity as `(update_tenant, tenant_id)`
|
- Logs activity as `(update_tenant, tenant, pubkey)`
|
||||||
|
|
||||||
## `pub fn create_relay(&self, relay: &Relay) -> Result<()>`
|
## `pub async fn create_relay(&self, relay: &Relay) -> Result<()>`
|
||||||
|
|
||||||
- Creates relay, may throw sqlite uniqueness error on subdomain
|
- Creates relay with status `active` and `synced = 0`, may throw sqlite uniqueness error on subdomain
|
||||||
- Sets relay status to `active`
|
- Logs activity as `(create_relay, relay, id)`
|
||||||
- Logs activity as `(create_relay, relay_id)`
|
|
||||||
|
|
||||||
## `pub fn update_relay(&self, relay: &Relay) -> Result<()>`
|
## `pub async fn update_relay(&self, relay: &Relay) -> Result<()>`
|
||||||
|
|
||||||
- Updates relay, may throw sqlite uniqueness error on subdomain
|
- Updates relay (all mutable fields), resets `synced = 0` so it re-syncs to zooid; may throw sqlite uniqueness error on subdomain
|
||||||
- Logs activity as `(update_relay, relay_id)`
|
- Logs activity as `(update_relay, relay, id)`
|
||||||
|
|
||||||
## `pub fn deactivate_relay(&self, relay: &Relay) -> Result<()>`
|
## `pub async fn deactivate_relay(&self, relay: &Relay) -> Result<()>`
|
||||||
|
|
||||||
- Sets relay status to `inactive`
|
- Sets relay status to `inactive` and `synced = 0`
|
||||||
- Logs activity as `(deactivate_relay, relay_id)`
|
- Logs activity as `(deactivate_relay, relay, id)`
|
||||||
- Used for user/admin-initiated deactivation only
|
- Used for user/admin-initiated deactivation only
|
||||||
|
|
||||||
## `pub fn mark_relay_delinquent(&self, relay: &Relay) -> Result<()>`
|
## `pub async fn mark_relay_delinquent(&self, relay: &Relay) -> Result<()>`
|
||||||
|
|
||||||
- Sets relay status to `delinquent`
|
- Sets relay status to `delinquent` and `synced = 0`
|
||||||
- Logs activity as `(deactivate_relay, relay_id)`
|
- Logs activity as `(mark_relay_delinquent, relay, id)`
|
||||||
- Used exclusively by the billing system when a relay's subscription becomes past due
|
- 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
|
- `delinquent` relays are automatically reactivated via `activate_relay` when payment is received
|
||||||
|
|
||||||
## `pub fn activate_relay(&self, relay: &Relay) -> Result<()>`
|
## `pub async fn activate_relay(&self, relay: &Relay) -> Result<()>`
|
||||||
|
|
||||||
- Sets relay status to `active`
|
- Sets relay status to `active` and `synced = 0`
|
||||||
- Logs activity as `(activate_relay, relay_id)`
|
- Logs activity as `(activate_relay, relay, id)`
|
||||||
|
|
||||||
## `pub fn fail_relay_sync(&self, relay: &Relay, sync_error: &str) -> Result<()>`
|
## `pub async fn fail_relay_sync(&self, relay: &Relay, sync_error: String) -> Result<()>`
|
||||||
|
|
||||||
- Sets `sync_error` on the relay
|
- Sets `synced = 0` and `sync_error` on the relay
|
||||||
- Logs activity as `(fail_relay_sync, relay_id)`
|
- Logs activity as `(fail_relay_sync, relay, id)`
|
||||||
|
|
||||||
## `pub fn complete_relay_sync(&self, relay_id: &str) -> Result<()>`
|
## `pub async fn complete_relay_sync(&self, relay_id: &str) -> Result<()>`
|
||||||
|
|
||||||
- Sets `synced = 1`, clears `sync_error`
|
- Sets `synced = 1`, clears `sync_error`
|
||||||
- Logs activity as `(complete_relay_sync, relay_id)`
|
- Logs activity as `(complete_relay_sync, relay, id)`
|
||||||
|
|
||||||
## `pub fn delete_relay_subscription_item(&self, relay_id: &str) -> Result<()>`
|
## `pub async fn set_tenant_subscription(&self, pubkey: &str, stripe_subscription_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
|
- Sets `stripe_subscription_id` on the tenant
|
||||||
- Does not log activity
|
- Does not log activity
|
||||||
|
|
||||||
## `pub fn clear_tenant_subscription(&self, pubkey: &str) -> Result<()>`
|
## `pub async fn clear_tenant_subscription(&self, pubkey: &str) -> Result<()>`
|
||||||
|
|
||||||
- Sets `stripe_subscription_id = null` on the tenant
|
- Sets `stripe_subscription_id = null` on the tenant
|
||||||
- Does not log activity
|
- Does not log activity
|
||||||
|
|
||||||
## `pub fn set_tenant_nwc_error(&self, pubkey: &str, error: &str) -> Result<()>`
|
## `pub async fn set_tenant_nwc_error(&self, pubkey: &str, error: &str) -> Result<()>`
|
||||||
|
|
||||||
- Sets `nwc_error` on the tenant
|
- Sets `nwc_error` on the tenant
|
||||||
- Does not log activity
|
- Does not log activity
|
||||||
|
|
||||||
## `pub fn clear_tenant_nwc_error(&self, pubkey: &str) -> Result<()>`
|
## `pub async fn clear_tenant_nwc_error(&self, pubkey: &str) -> Result<()>`
|
||||||
|
|
||||||
- Sets `nwc_error = null` on the tenant
|
- Sets `nwc_error = null` on the tenant
|
||||||
- Does not log activity
|
- Does not log activity
|
||||||
|
|
||||||
## `pub fn set_tenant_past_due(&self, pubkey: &str) -> Result<()>`
|
## `pub async fn set_tenant_past_due(&self, pubkey: &str) -> Result<()>`
|
||||||
|
|
||||||
- Sets `past_due_at` to the current timestamp
|
- Sets `past_due_at` to the current timestamp
|
||||||
- Does not log activity
|
- Does not log activity
|
||||||
|
|
||||||
## `pub fn clear_tenant_past_due(&self, pubkey: &str) -> Result<()>`
|
## `pub async fn clear_tenant_past_due(&self, pubkey: &str) -> Result<()>`
|
||||||
|
|
||||||
- Sets `past_due_at = null` on the tenant
|
- Sets `past_due_at = null` on the tenant
|
||||||
- Does not log activity
|
- Does not log activity
|
||||||
|
|
||||||
|
## `pub async fn insert_lightning_invoice(&self, stripe_invoice_id: &str, tenant_pubkey: &str, bolt11: &str, expires_at: i64) -> Result<Option<LightningInvoice>>`
|
||||||
|
|
||||||
|
- Upserts the `pending` bolt11 row for a Stripe invoice, returning the resulting row
|
||||||
|
- On conflict the stored `bolt11`/`expires_at` are replaced (this is how an expired invoice is regenerated), **except** once the invoice is `paid`: the `status = 'pending'` guard makes the update a no-op and `None` is returned so the caller can fall back to reading the settled row
|
||||||
|
- Does not log activity
|
||||||
|
|
||||||
|
## `pub async fn mark_lightning_invoice_paid(&self, stripe_invoice_id: &str, method: &str) -> Result<()>`
|
||||||
|
|
||||||
|
- Marks a `pending` invoice `paid`, recording `paid_method = method` (`nwc` or `manual`)
|
||||||
|
- The `status = 'pending'` guard makes this idempotent and first-writer-wins: a later reconcile won't clobber the method recorded by whoever settled it first
|
||||||
|
- Does not log activity
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
# `pub struct Env`
|
||||||
|
|
||||||
|
Env is the application's configuration, loaded once at startup and cloned into every service that needs it (`Api`, `Query`, `Command`, `Billing`, `Infra`, `Robot`, `Stripe`). It is the single source of truth for environment-derived settings, and it also owns the robot nostr key used for signing, NIP-44 encryption, and NIP-98 auth.
|
||||||
|
|
||||||
|
Members (all populated from environment variables):
|
||||||
|
|
||||||
|
- `server_host: String` - from `SERVER_HOST`; also used for the NIP-98 `u` host check
|
||||||
|
- `server_port: u16` - from `SERVER_PORT`
|
||||||
|
- `server_admin_pubkeys: Vec<String>` - admin pubkeys from `SERVER_ADMIN_PUBKEYS`
|
||||||
|
- `server_allow_origins: Vec<String>` - CORS origins from `SERVER_ALLOW_ORIGINS`
|
||||||
|
- `database_url: String` - from `DATABASE_URL`
|
||||||
|
- `robot_name: String` - from `ROBOT_NAME`
|
||||||
|
- `robot_wallet: String` - the system NWC URL from `ROBOT_WALLET`, used to issue/look up bolt11 invoices
|
||||||
|
- `robot_picture: String` - from `ROBOT_PICTURE`
|
||||||
|
- `robot_description: String` - from `ROBOT_DESCRIPTION`
|
||||||
|
- `robot_outbox_relays: Vec<String>` - from `ROBOT_OUTBOX_RELAYS`
|
||||||
|
- `robot_indexer_relays: Vec<String>` - from `ROBOT_INDEXER_RELAYS`
|
||||||
|
- `robot_messaging_relays: Vec<String>` - from `ROBOT_MESSAGING_RELAYS`
|
||||||
|
- `blossom_s3_region` / `blossom_s3_bucket` / `blossom_s3_endpoint` / `blossom_s3_access_key` / `blossom_s3_secret_key: String` - from the matching `BLOSSOM_S3_*` vars
|
||||||
|
- `zooid_api_url: String` - from `ZOOID_API_URL`
|
||||||
|
- `relay_domain: String` - from `RELAY_DOMAIN`
|
||||||
|
- `livekit_url` / `livekit_api_key` / `livekit_api_secret: String` - from the matching `LIVEKIT_*` vars
|
||||||
|
- `stripe_secret_key: String` - from `STRIPE_SECRET_KEY`
|
||||||
|
- `stripe_webhook_secret: String` - from `STRIPE_WEBHOOK_SECRET`
|
||||||
|
- `stripe_price_basic: String` - Stripe price id for the Basic plan, from `STRIPE_PRICE_BASIC`
|
||||||
|
- `stripe_price_growth: String` - Stripe price id for the Growth plan, from `STRIPE_PRICE_GROWTH`
|
||||||
|
- `keys: Keys` - parsed from `ROBOT_SECRET`; used for nostr signing, NIP-44 encryption, and NIP-98 auth
|
||||||
|
|
||||||
|
## `pub fn load() -> Self`
|
||||||
|
|
||||||
|
- Reads every variable above and panics if any is missing or malformed.
|
||||||
|
- String vars must be present and non-blank (trimmed).
|
||||||
|
- The port must parse as a `u16`.
|
||||||
|
- CSV vars are split on commas, trimmed, and empties dropped; each must contain at least one entry.
|
||||||
|
- `keys` is parsed from `ROBOT_SECRET` and panics if it is not a valid nostr secret key.
|
||||||
|
|
||||||
|
## `pub fn encrypt(&self, plaintext: &str) -> Result<String>`
|
||||||
|
|
||||||
|
- NIP-44 (v2) encrypts `plaintext` to the robot's own key. Used to encrypt a tenant's `nwc_url` at rest.
|
||||||
|
|
||||||
|
## `pub fn decrypt(&self, ciphertext: &str) -> Result<String>`
|
||||||
|
|
||||||
|
- NIP-44 decrypts a value previously produced by `encrypt`.
|
||||||
|
|
||||||
|
## `pub async fn make_auth(&self, url: &str, method: HttpMethod) -> Result<String>`
|
||||||
|
|
||||||
|
- Builds a NIP-98 `Authorization` header value for an outgoing request to `url` with `method`, signed with `keys`. Used by `Infra` to authenticate requests to zooid.
|
||||||
+39
-18
@@ -1,38 +1,59 @@
|
|||||||
# `pub struct Infra`
|
# `pub struct Infra`
|
||||||
|
|
||||||
Infra is a service which listens for activity and synchronizes relay updates to a remote zooid instance via `api_url`.
|
Infra is a background worker that listens for activity and synchronizes relay configuration to a remote zooid instance.
|
||||||
|
|
||||||
Members:
|
Members:
|
||||||
|
|
||||||
- `api_url: String` - the URL of the zooid instance to be managed, from `ZOOID_API_URL`
|
- `env: Env` - configuration; supplies `zooid_api_url`, `relay_domain`, the `BLOSSOM_S3_*` and `LIVEKIT_*` settings, and the robot key used to sign requests
|
||||||
- `query: Query`
|
- `query: Query`
|
||||||
- `command: Command`
|
- `command: Command`
|
||||||
|
|
||||||
## `pub fn new(query: Query, command: Command) -> Self`
|
## `pub fn new(query: Query, command: Command, env: &Env) -> Self`
|
||||||
|
|
||||||
- Reads environment and populates members
|
- Stores `query`, `command`, and a clone of `env`
|
||||||
|
|
||||||
## `pub async fn start(self)`
|
## `pub async fn start(self)`
|
||||||
|
|
||||||
- Subscribes to `command.notify`
|
- Subscribes to `command.notify`
|
||||||
- On startup, schedules delayed sync retries for relays whose `sync_error` is non-empty.
|
- Runs `reconcile_relay_state("startup")` before entering the loop
|
||||||
- Loops on `rx.recv()`, calling `handle_activity` for each received `Activity`.
|
- Loops on `rx.recv()`, calling `handle_activity` for each `Activity`
|
||||||
|
- On `Lagged`, runs `reconcile_relay_state("lagged")`; on `Closed`, exits
|
||||||
|
|
||||||
## `async fn handle_activity(&self, activity: &Activity)`
|
## `async fn handle_activity(&self, activity: &Activity)`
|
||||||
|
|
||||||
- For `create_relay`, `update_relay`, `activate_relay`, or `deactivate_relay` activity, calls `sync_and_report` immediately.
|
- Ignores anything that isn't a `relay` resource with activity type `create_relay`, `update_relay`, `activate_relay`, `deactivate_relay`, or `fail_relay_sync`
|
||||||
- For `fail_relay_sync`, schedules a delayed retry using exponential backoff based on consecutive failures for the relay.
|
- For `fail_relay_sync`, schedules a delayed retry via `schedule_relay_sync_retry`
|
||||||
- Retry scheduling stops after the configured max attempts to avoid infinite retry loops.
|
- Otherwise resolves the relay (skip if gone) and calls `sync_relay`
|
||||||
- Other activity types are ignored (e.g. `complete_relay_sync`).
|
|
||||||
|
|
||||||
## `async fn sync_and_report(&self, relay: &Relay, is_new: bool)`
|
## `async fn reconcile_relay_state(&self, source: &str)`
|
||||||
|
|
||||||
- Calls `sync_relay` and on success calls `command.complete_relay_sync`.
|
- Lists relays still pending sync (`query.list_relays_pending_sync`)
|
||||||
- On failure calls `command.fail_relay_sync`.
|
- For each: `sync_relay` immediately if its `sync_error` is empty, otherwise `schedule_relay_sync_retry`
|
||||||
|
|
||||||
## `async fn sync_relay(&self, relay: &Relay, is_new: bool)`
|
## `async fn schedule_relay_sync_retry(&self, relay_id: &str, source: &str)`
|
||||||
|
|
||||||
- If `is_new`, sends `POST /relay/:id` to create the relay in zooid.
|
- Counts the relay's consecutive trailing `fail_relay_sync` activities to derive the attempt number
|
||||||
- Otherwise, sends `PATCH /relay/:id` to update it.
|
- Computes an exponential backoff (base 30s, doubling, capped at 15 minutes); gives up after `RELAY_SYNC_RETRY_MAX_ATTEMPTS` (6) to avoid infinite retry loops
|
||||||
- Includes `secret` only for relay creation (`POST`) so updates do not rotate relay identity.
|
- Spawns a task that sleeps for the delay, then re-reads the relay and `sync_relay`s it (no-op if the relay is gone)
|
||||||
- Passes relay configuration in the body including host, schema, inactive flag, info, policy, groups, management, blossom, livekit, push, and roles.
|
|
||||||
|
## `async fn sync_relay(&self, relay: &Relay)`
|
||||||
|
|
||||||
|
- Calls `try_sync_relay`; on success `command.complete_relay_sync`, on failure `command.fail_relay_sync` with the error
|
||||||
|
|
||||||
|
## `async fn try_sync_relay(&self, relay: &Relay)`
|
||||||
|
|
||||||
|
- A relay is "new" only if it has never completed a sync (`synced != 1` and no `complete_relay_sync` activity exists). New relays are created with `POST /relay/:id`; existing relays are updated with `PATCH /relay/:id`.
|
||||||
|
- A freshly generated `secret` is included only for creation (`POST`), so updates don't rotate relay identity and we never store the secret.
|
||||||
|
- The body carries relay configuration: `host` (= `subdomain.relay_domain`), `schema`, `inactive` (true when status is `inactive` or `delinquent`), `info` (name/icon/description/pubkey), `policy`, `groups`, `management`, `blossom`, `livekit`, `push`, and hard-coded `roles`.
|
||||||
|
- When `blossom_enabled`, the blossom section uses `adapter: "s3"` with the `BLOSSOM_S3_*` settings and `s3.key_prefix` set to the relay's `schema`; otherwise it sends `{ "enabled": false }`.
|
||||||
|
- When `livekit_enabled`, the livekit section carries the `LIVEKIT_*` settings; otherwise `{ "enabled": false }`.
|
||||||
|
|
||||||
|
## `pub async fn list_relay_members(&self, relay_id: &str) -> Result<Vec<String>>`
|
||||||
|
|
||||||
|
- `GET /relay/:id/members` from zooid; returns the `members` array
|
||||||
|
|
||||||
|
## `async fn request(&self, method, path, body)`
|
||||||
|
|
||||||
|
- Sends an authenticated request to the zooid API at `path` (relative to `env.zooid_api_url`), with a 5s timeout
|
||||||
|
- Authenticates each request with a NIP-98 header via `env.make_auth`
|
||||||
|
- Returns the response on 2xx; bails with the status and body text otherwise
|
||||||
|
|||||||
+15
-7
@@ -1,9 +1,17 @@
|
|||||||
# `async fn main() -> Result<()>`
|
# `async fn main() -> Result<()>`
|
||||||
|
|
||||||
- Configures logging
|
- Loads `.env` (via `dotenvy`) and configures tracing/logging from the default env filter
|
||||||
- Calls `create_pool` to get a `SqlitePool`, then creates `Query`, `Command`, `Robot`, `Billing`, `Api`, and `Infra`
|
- Calls `Env::load()` to read and validate all configuration
|
||||||
- Get an axum router from `api.router`
|
- Calls `create_pool(&env.database_url)` to get a `SqlitePool`
|
||||||
- Adds CORS middleware based on `origins`
|
- Constructs the services, passing `&env` where needed:
|
||||||
- Calls `axum::serve` with a listener
|
- `Robot::new(&env).await` (publishes the robot's nostr identity)
|
||||||
- Spawns `infra.start`
|
- `Stripe::new(&env)`
|
||||||
- Spawns `billing.start`
|
- `Query::new(pool.clone(), &env)`
|
||||||
|
- `Command::new(pool)`
|
||||||
|
- `Billing::new(query.clone(), command.clone(), &env)`
|
||||||
|
- `Infra::new(query.clone(), command.clone(), &env)`
|
||||||
|
- `Api::new(query, command, billing.clone(), stripe, robot, infra.clone(), &env)`
|
||||||
|
- Builds a CORS layer restricted to the parsed `env.server_allow_origins`
|
||||||
|
- Gets the axum router from `api.router()` and applies the CORS layer
|
||||||
|
- Spawns `infra.start()` and `billing.start()` as background tasks
|
||||||
|
- Binds a `TcpListener` on `env.server_host:env.server_port` and calls `axum::serve`
|
||||||
|
|||||||
+17
-5
@@ -24,6 +24,7 @@ Activity is an audit log of all actions performed by a user or a worker process.
|
|||||||
- `update_relay`
|
- `update_relay`
|
||||||
- `activate_relay`
|
- `activate_relay`
|
||||||
- `deactivate_relay`
|
- `deactivate_relay`
|
||||||
|
- `mark_relay_delinquent`
|
||||||
- `fail_relay_sync`
|
- `fail_relay_sync`
|
||||||
- `complete_relay_sync`
|
- `complete_relay_sync`
|
||||||
- `resource_type` is a string identifying the resource type being modified.
|
- `resource_type` is a string identifying the resource type being modified.
|
||||||
@@ -35,7 +36,7 @@ A plan represents a rate charged for relays at a given feature/usage limit. Plan
|
|||||||
|
|
||||||
- `id` - the plan slug
|
- `id` - the plan slug
|
||||||
- `name` - the plan name
|
- `name` - the plan name
|
||||||
- `amount` - the plan monthly cost in USD
|
- `amount` - the plan's monthly cost in USD minor units (cents); e.g. `500` for $5/mo
|
||||||
- `members` - the max number of members a relay can have before needing to upgrade. If empty, membership is not limited.
|
- `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
|
- `blossom` - whether blossom media hosting is available on this plan
|
||||||
- `livekit` - whether livekit audio/video calls are available on this plan
|
- `livekit` - whether livekit audio/video calls are available on this plan
|
||||||
@@ -52,7 +53,7 @@ There are three plans available:
|
|||||||
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.
|
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
|
- `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_url` (private) a nostr wallet connect URL used for **paying** invoices generated by the system on the tenant's behalf; stored encrypted at rest using NIP-44 with the robot key (`ROBOT_SECRET`, via `Env::encrypt`); never serialized to API responses — tenant API endpoints expose `nwc_is_set: bool` instead
|
||||||
- `nwc_error` (private) a string indicating the most recent NWC payment error, if any. Cleared on successful NWC payment.
|
- `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
|
- `created_at` unix timestamp identifying tenant creation time
|
||||||
- `stripe_customer_id` a string identifying the associated stripe customer
|
- `stripe_customer_id` a string identifying the associated stripe customer
|
||||||
@@ -63,12 +64,11 @@ Tenants are customers of the service, identified by a nostr `pubkey`. Public met
|
|||||||
|
|
||||||
A relay is a nostr relay owned by a `tenant` and hosted by the attached zooid instance. Relay subdomains MUST be unique.
|
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
|
- `id` - calculated based on `subdomain` (with `-` replaced by `_`) + `_` + 8 random hex chars
|
||||||
- `tenant` - the tenant's pubkey
|
- `tenant` - the tenant's pubkey
|
||||||
- `schema` - the relay's db schema (read_only, calculated based on `subdomain` + `id`)
|
- `schema` - the relay's db schema (read only, same as `id`)
|
||||||
- `subdomain` - the relay's subdomain
|
- `subdomain` - the relay's subdomain
|
||||||
- `plan` - the relay's plan
|
- `plan` - the relay's plan
|
||||||
- `stripe_subscription_item_id` (nullable) - the Stripe subscription item id. Only set for relays on paid plans.
|
|
||||||
- `status` - one of `active|inactive|delinquent`. Only `active` relays count toward billing. `delinquent` is set by the billing system when a relay's subscription becomes past due; `inactive` is set when a user or admin manually deactivates a relay.
|
- `status` - 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.
|
- `synced` - whether the relay has been successfully synced to zooid at least once.
|
||||||
- `sync_error` - a string indicating any errors encountered when synchronizing.
|
- `sync_error` - a string indicating any errors encountered when synchronizing.
|
||||||
@@ -90,3 +90,15 @@ Some attributes persisted to zooid via API have special handling:
|
|||||||
- The value of `inactive` is calculated based on `status`
|
- The value of `inactive` is calculated based on `status`
|
||||||
- The relay's `livekit_*` configuration is inferred based on environment variables and `livekit_enabled`.
|
- The relay's `livekit_*` configuration is inferred based on environment variables and `livekit_enabled`.
|
||||||
- The relay's `roles` are hard-coded for now.
|
- The relay's `roles` are hard-coded for now.
|
||||||
|
|
||||||
|
# LightningInvoice
|
||||||
|
|
||||||
|
Tracks the bolt11 invoice issued on the system wallet to collect a single Stripe invoice over Lightning (either NWC auto-pay or a manual payment from the app). One row per Stripe invoice.
|
||||||
|
|
||||||
|
- `stripe_invoice_id` - the Stripe invoice this bolt11 collects (primary key)
|
||||||
|
- `tenant_pubkey` - the owning tenant
|
||||||
|
- `bolt11` - the bolt11 invoice string issued on the system wallet
|
||||||
|
- `status` - one of `pending|paid`
|
||||||
|
- `paid_method` (nullable) - how it settled, one of `nwc|manual`; set when `status` becomes `paid`
|
||||||
|
- `expires_at` - unix timestamp after which the `pending` bolt11 is considered expired and may be regenerated
|
||||||
|
- `created_at` / `updated_at` - unix timestamps
|
||||||
|
|||||||
@@ -1,14 +1,15 @@
|
|||||||
# `pub async fn create_pool() -> Result<SqlitePool>`
|
# `pub async fn create_pool(database_url: &str) -> Result<SqlitePool>`
|
||||||
|
|
||||||
Creates and returns a sqlite connection pool.
|
Creates and returns a sqlite connection pool.
|
||||||
|
|
||||||
Notes:
|
Notes:
|
||||||
|
|
||||||
- Database table names are singular: `activity`, `tenant`, `relay`
|
- Database table names are singular: `activity`, `tenant`, `relay`, `lightning_invoice`
|
||||||
|
|
||||||
Steps:
|
Steps:
|
||||||
|
|
||||||
- Reads `DATABASE_URL` from environment
|
- Normalizes `database_url`: a relative `sqlite://` path is resolved under the crate manifest directory (`CARGO_MANIFEST_DIR`); absolute paths and `:memory:` are left as-is
|
||||||
- Ensures that any directories referred to in `DATABASE_URL` exist
|
- Ensures any directory referred to in the (normalized) URL exists
|
||||||
- Initializes the sqlx pool
|
- Opens the pool with `create_if_missing` enabled
|
||||||
|
- Enables WAL journaling (`PRAGMA journal_mode = WAL`)
|
||||||
- Runs migrations found in the `migrations` directory
|
- Runs migrations found in the `migrations` directory
|
||||||
|
|||||||
+40
-20
@@ -5,45 +5,65 @@ Query reads from the database.
|
|||||||
Members:
|
Members:
|
||||||
|
|
||||||
- `pool: SqlitePool` - a sqlite connection pool
|
- `pool: SqlitePool` - a sqlite connection pool
|
||||||
|
- `env: Env` - configuration; used to fill in plan `stripe_price_id`s from `STRIPE_PRICE_*`
|
||||||
|
|
||||||
## `pub fn new(&self, pool: SqlitePool) -> Self`
|
## `pub fn new(pool: SqlitePool, env: &Env) -> Self`
|
||||||
|
|
||||||
- Assigns pool to self
|
- Stores the pool and a clone of `env`
|
||||||
|
|
||||||
## `pub fn list_tenants(&self) -> Result<Vec<Tenant>>`
|
## `pub fn list_plans(&self) -> Vec<Plan>`
|
||||||
|
|
||||||
|
- Returns the hardcoded relay plans used by the system (`free`, `basic`, `growth`)
|
||||||
|
- The `basic`/`growth` `stripe_price_id`s come from `env` (`stripe_price_basic` / `stripe_price_growth`); `free` has none
|
||||||
|
- This is the source of truth for plan metadata exposed via API
|
||||||
|
|
||||||
|
## `pub fn get_plan(&self, plan_id: &str) -> Option<Plan>`
|
||||||
|
|
||||||
|
- Returns the plan matching `plan_id`, if any
|
||||||
|
|
||||||
|
## `pub fn is_paid_plan(&self, plan_id: &str) -> bool`
|
||||||
|
|
||||||
|
- Returns whether `plan_id` is a known plan with `amount > 0`
|
||||||
|
|
||||||
|
## `pub async fn list_tenants(&self) -> Result<Vec<Tenant>>`
|
||||||
|
|
||||||
- Returns all tenants
|
- Returns all tenants
|
||||||
|
|
||||||
## `pub fn get_tenant(&self, pubkey: &str) -> Result<Tenant>`
|
## `pub async fn get_tenant(&self, pubkey: &str) -> Result<Option<Tenant>>`
|
||||||
|
|
||||||
- Returns matching tenant
|
- Returns the matching tenant, or `None` if not found
|
||||||
|
|
||||||
## `pub fn list_plans() -> Vec<Plan>`
|
## `pub async fn get_tenant_by_stripe_customer_id(&self, stripe_customer_id: &str) -> Result<Option<Tenant>>`
|
||||||
|
|
||||||
- Returns the hardcoded relay plans used by the system (`free`, `basic`, `growth`)
|
- Returns the tenant matching the given `stripe_customer_id`, or `None`
|
||||||
- This is the source of truth for plan metadata exposed via API
|
|
||||||
|
|
||||||
## `pub fn list_relays(&self) -> Result<Vec<Relay>>`
|
## `pub async fn list_relays(&self) -> Result<Vec<Relay>>`
|
||||||
|
|
||||||
- Returns all relays
|
- Returns all relays
|
||||||
|
|
||||||
## `pub fn list_relays_for_tenant(&self, tenant_id: &str) -> Result<Vec<Relay>>`
|
## `pub async fn list_relays_pending_sync(&self) -> Result<Vec<Relay>>`
|
||||||
|
|
||||||
|
- Returns all relays where `synced = 0` or `sync_error` is non-empty
|
||||||
|
- Used by `Infra` to reconcile relays that still need to be pushed to zooid
|
||||||
|
|
||||||
|
## `pub async fn list_relays_for_tenant(&self, tenant_id: &str) -> Result<Vec<Relay>>`
|
||||||
|
|
||||||
- Returns all relays belonging to the given tenant
|
- Returns all relays belonging to the given tenant
|
||||||
|
|
||||||
## `pub fn get_relay(&self, id: &str) -> Result<Relay>`
|
## `pub async fn get_relay(&self, id: &str) -> Result<Option<Relay>>`
|
||||||
|
|
||||||
- Returns matching relay
|
- Returns the matching relay, or `None` if not found
|
||||||
|
|
||||||
## `pub fn get_tenant_by_stripe_customer_id(&self, stripe_customer_id: &str) -> Result<Tenant>`
|
## `pub async fn get_lightning_invoice(&self, stripe_invoice_id: &str) -> Result<Option<LightningInvoice>>`
|
||||||
|
|
||||||
- Returns the tenant matching the given `stripe_customer_id`
|
- Returns the `lightning_invoice` row for the given Stripe invoice, or `None`
|
||||||
|
|
||||||
## `pub fn has_active_paid_relays(&self, tenant_id: &str) -> Result<bool>`
|
## `pub async fn list_activity_for_resource(&self, resource_id: &str) -> Result<Vec<Activity>>`
|
||||||
|
|
||||||
- Returns true if the tenant has any relays where `status = 'active'` and `plan != 'free'`
|
- Returns all activity where `resource_id = resource_id`
|
||||||
|
|
||||||
## `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
|
- Ordered newest-first
|
||||||
|
|
||||||
|
## `pub async fn get_latest_activity_for_resource_and_type(&self, resource_id: &str, activity_type: &str) -> Result<Option<Activity>>`
|
||||||
|
|
||||||
|
- Returns the most recent activity for `resource_id` with the given `activity_type`, or `None`
|
||||||
|
- Used by `Infra` to decide whether a relay has ever completed a sync
|
||||||
|
|||||||
+22
-15
@@ -1,25 +1,32 @@
|
|||||||
# `pub struct Robot`
|
# `pub struct Robot`
|
||||||
|
|
||||||
Robot is a nostr identity which acts on behalf of the application.
|
Robot is the nostr identity that acts on behalf of the application — it publishes the app's profile/relay lists and sends DMs to tenants. It signs with the robot key in `env.keys` and builds nostr clients on demand from the relay lists in `env`.
|
||||||
|
|
||||||
Members:
|
Members:
|
||||||
|
|
||||||
- `secret: String` - a nostr secret key, from `ROBOT_SECRET`
|
- `env: Env` - configuration; supplies the robot key and the outbox/indexer/messaging relay lists and profile metadata
|
||||||
- `name: String` - the name of the bot, from `ROBOT_NAME`
|
- `outbox_cache` / `dm_cache` - per-recipient caches (5 minute TTL) of discovered outbox and messaging relays
|
||||||
- `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`
|
## `pub async fn new(env: &Env) -> Result<Self>`
|
||||||
|
|
||||||
- Reads environment and populates members. Relay urls should be split and normalized.
|
- Stores a clone of `env` and initializes the caches
|
||||||
- Publishes a `kind 0` nostr profile, a `kind 10002` relay list, and `kind 10050` relay selections
|
- Calls `publish_identity`, which publishes a `kind 0` profile and a `kind 10002` relay list (the `ROBOT_OUTBOX_RELAYS`, as `r` tags) to the outbox relays, and a `kind 10050` DM relay list (the `ROBOT_MESSAGING_RELAYS`, as `relay` tags) via the indexer relays
|
||||||
|
|
||||||
## `pub async fn send_dm(&self, recipient: &str, message: &str) -> Result<()>`
|
## `pub async fn send_dm(&self, recipient: &str, message: &str) -> Result<()>`
|
||||||
|
|
||||||
- Fetches recipient's outbox relays from `indexer_relays` (cached)
|
- Resolves the recipient's outbox relays (`fetch_outbox_relays`), then their messaging relays from those outbox relays (`fetch_messaging_relays_from_outbox`)
|
||||||
- Fetches recipient's messaging relays from their outbox relays (cached)
|
- Sends a NIP-17 private message to the recipient via their messaging relays
|
||||||
- Sends DM to recipient via their messaging relays
|
- Errors if no outbox or messaging relays are found
|
||||||
- If no outbox/messaging relays are found, return an error
|
|
||||||
|
## `pub async fn fetch_nostr_name(&self, pubkey: &str) -> Option<String>`
|
||||||
|
|
||||||
|
- Fetches the recipient's `kind 0` metadata from the indexer relays and returns its `display_name` (falling back to `name`), trimmed and non-empty
|
||||||
|
- Returns `None` on any failure — used to derive a Stripe customer display name
|
||||||
|
|
||||||
|
## `async fn fetch_outbox_relays(&self, recipient: &str) -> Result<Vec<String>>`
|
||||||
|
|
||||||
|
- Returns the `r` tags from the recipient's latest `kind 10002` event, fetched from the indexer relays; cached for 5 minutes
|
||||||
|
|
||||||
|
## `async fn fetch_messaging_relays_from_outbox(&self, recipient: &str, outbox_relays: &[String]) -> Result<Vec<String>>`
|
||||||
|
|
||||||
|
- Returns the `relay` tags from the recipient's latest `kind 10050` event, fetched from their outbox relays; cached for 5 minutes
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
# `pub struct Stripe`
|
||||||
|
|
||||||
|
A thin async wrapper around the subset of the Stripe REST API this service uses. It knows nothing about relays, tenants, or the database — it just speaks HTTP to Stripe and returns small typed results. The domain logic that drives it lives in `spec/billing.md`, and the webhook dispatch lives in `spec/api.md`.
|
||||||
|
|
||||||
|
Members:
|
||||||
|
|
||||||
|
- `env: Env` - configuration; supplies the Stripe secret key (bearer token + idempotency HMAC key) and the webhook signing secret
|
||||||
|
- `http: reqwest::Client`
|
||||||
|
|
||||||
|
All requests authenticate with `env.stripe_secret_key` via `Authorization: Bearer`. Write requests that must not be retried twice (customer/subscription/item creation, invoice payment) send a deterministic `Idempotency-Key` derived by HMAC-ing the operation name and its arguments with the secret key. Reconcile-to-desired-state writes (e.g. setting an item quantity, deleting/canceling) intentionally omit the idempotency key, since re-applying the same target is a no-op.
|
||||||
|
|
||||||
|
On any 4xx/5xx response, the wrapper reads the body and folds Stripe's JSON error payload (`error.message` / `error.type` / `error.code` / `error.param`) into the returned error so callers get an actionable message instead of a bare status line.
|
||||||
|
|
||||||
|
## `pub fn new(env: &Env) -> Self`
|
||||||
|
|
||||||
|
Constructs the client with a fresh `reqwest::Client`, holding a clone of `env`. This is what `Billing::new` and `main` call.
|
||||||
|
|
||||||
|
## `pub async fn create_customer(&self, tenant_pubkey: &str, name: &str) -> Result<String>`
|
||||||
|
|
||||||
|
- `POST /v1/customers` with `name` and `metadata[tenant_pubkey]`
|
||||||
|
- Idempotent on `tenant_pubkey`
|
||||||
|
- Returns the new customer id
|
||||||
|
|
||||||
|
## `pub async fn get_subscription(&self, subscription_id: &str) -> Result<Option<StripeSubscription>>`
|
||||||
|
|
||||||
|
- `GET /v1/subscriptions/:id`
|
||||||
|
- Returns `None` if Stripe responds `404` (so callers can recover from a stale subscription id), otherwise the parsed `StripeSubscription`
|
||||||
|
|
||||||
|
## `pub async fn create_subscription(&self, customer_id: &str, items: &BTreeMap<String, i64>) -> Result<StripeSubscription>`
|
||||||
|
|
||||||
|
- `POST /v1/subscriptions` with `collection_method: charge_automatically` and one `items[n][price]` / `items[n][quantity]` pair per entry
|
||||||
|
- Idempotent on the customer and the `(price, quantity)` set
|
||||||
|
- Returns the created `StripeSubscription` (including its items)
|
||||||
|
|
||||||
|
## `pub async fn create_subscription_item(&self, subscription_id: &str, price_id: &str, quantity: i64) -> Result<()>`
|
||||||
|
|
||||||
|
- `POST /v1/subscription_items`
|
||||||
|
- Idempotent on `(subscription_id, price_id)`
|
||||||
|
|
||||||
|
## `pub async fn set_subscription_item_quantity(&self, item_id: &str, quantity: i64) -> Result<()>`
|
||||||
|
|
||||||
|
- `POST /v1/subscription_items/:id` with `quantity`
|
||||||
|
- No idempotency key (reconcile-to-target write)
|
||||||
|
|
||||||
|
## `pub async fn delete_subscription_item(&self, item_id: &str) -> Result<()>`
|
||||||
|
|
||||||
|
- `DELETE /v1/subscription_items/:id`
|
||||||
|
|
||||||
|
## `pub async fn cancel_subscription(&self, subscription_id: &str) -> Result<()>`
|
||||||
|
|
||||||
|
- `DELETE /v1/subscriptions/:id`
|
||||||
|
|
||||||
|
## `pub async fn list_invoices(&self, customer_id: &str) -> Result<Vec<StripeInvoice>>`
|
||||||
|
|
||||||
|
- `GET /v1/invoices?customer=…`
|
||||||
|
- Returns the parsed `data` array
|
||||||
|
|
||||||
|
## `pub async fn get_invoice(&self, invoice_id: &str) -> Result<Option<StripeInvoice>>`
|
||||||
|
|
||||||
|
- `GET /v1/invoices/:id`
|
||||||
|
- Returns `None` if Stripe responds `404`, otherwise the parsed `StripeInvoice`
|
||||||
|
|
||||||
|
## `pub async fn pay_invoice(&self, invoice_id: &str) -> Result<()>`
|
||||||
|
|
||||||
|
- `POST /v1/invoices/:id/pay` (retries collection using the customer's default payment method)
|
||||||
|
- Idempotent on `invoice_id`
|
||||||
|
|
||||||
|
## `pub async fn pay_invoice_out_of_band(&self, invoice_id: &str) -> Result<()>`
|
||||||
|
|
||||||
|
- `POST /v1/invoices/:id/pay` with `paid_out_of_band: true` — used when payment was collected over Lightning rather than through Stripe
|
||||||
|
- Idempotent on `invoice_id` (under a distinct key from `pay_invoice`)
|
||||||
|
|
||||||
|
## `pub async fn has_payment_method(&self, customer_id: &str) -> Result<bool>`
|
||||||
|
|
||||||
|
- `GET /v1/payment_methods?customer=…&type=card`
|
||||||
|
- Returns whether the customer has at least one card on file
|
||||||
|
|
||||||
|
## `pub async fn create_portal_session(&self, customer_id: &str, return_url: Option<&str>) -> Result<String>`
|
||||||
|
|
||||||
|
- `POST /v1/billing_portal/sessions` with `customer` and optional `return_url`
|
||||||
|
- Returns the Customer Portal session URL
|
||||||
|
|
||||||
|
## `pub fn get_webhook_event(&self, payload: &str, signature: &str) -> Result<StripeWebhookEvent>`
|
||||||
|
|
||||||
|
Verifies the `Stripe-Signature` header against `env.stripe_webhook_secret` and parses the body.
|
||||||
|
|
||||||
|
- Parse `t=` (timestamp) and `v1=` (signature) from the header
|
||||||
|
- Compute `HMAC-SHA256(secret, "{t}.{payload}")`, hex-encode it, and compare to `v1=`; error on mismatch
|
||||||
|
- Error if the timestamp is more than 300 seconds from now
|
||||||
|
- Returns the deserialized `StripeWebhookEvent`
|
||||||
|
|
||||||
|
# Typed results
|
||||||
|
|
||||||
|
- `StripeWebhookEvent { event_type: String, data: StripeWebhookEventData }`, `StripeWebhookEventData { object: serde_json::Value }` — the verified, parsed webhook event (`event_type` deserializes from the JSON `type` field)
|
||||||
|
- `StripeSubscription { id, status, items: Vec<StripeSubscriptionItem> }` (`items` flattened from Stripe's `{ data: [...] }` list)
|
||||||
|
- `StripeSubscriptionItem { id, price: StripePrice, quantity }` (`quantity` defaults to 1 when absent)
|
||||||
|
- `StripePrice { id }`
|
||||||
|
- `StripeInvoice { id, customer, status, amount_due, currency }` (the subset of invoice fields the API surfaces; `Serialize` + `Clone`)
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# `pub struct Wallet`
|
||||||
|
|
||||||
|
A handle to a single Nostr Wallet Connect (NWC) wallet. `Billing` holds one as its system wallet (receives — issues and looks up invoices); tenant wallets (pay invoices) are constructed ad-hoc from the decrypted `tenant.nwc_url` at the call site. Each operation opens a fresh NWC connection and shuts it down afterwards.
|
||||||
|
|
||||||
|
Member:
|
||||||
|
|
||||||
|
- `url: NostrWalletConnectURI` — the parsed `nostr+walletconnect://…` URI
|
||||||
|
|
||||||
|
## `pub fn from_url(url: &str) -> Result<Self>`
|
||||||
|
|
||||||
|
Parses an `nostr+walletconnect://` URI.
|
||||||
|
|
||||||
|
## `pub async fn make_invoice(&self, amount_msats: u64, description: &str, expiry_secs: u64) -> Result<String>`
|
||||||
|
|
||||||
|
Issues a bolt11 invoice for `amount_msats` with the given `description` and expiry, and returns the bolt11 string.
|
||||||
|
|
||||||
|
## `pub async fn pay_invoice(&self, bolt11: String) -> Result<()>`
|
||||||
|
|
||||||
|
Pays a bolt11 invoice.
|
||||||
|
|
||||||
|
## `pub async fn is_settled(&self, bolt11: &str) -> Result<bool>`
|
||||||
|
|
||||||
|
Returns whether a bolt11 invoice (previously issued by this wallet) has settled.
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
# `web` — HTTP response helpers
|
||||||
|
|
||||||
|
General-purpose helpers shared across the route handlers in `spec/api.md` (implemented under `src/routes/`). They standardize the success/error envelope and a couple of small utilities.
|
||||||
|
|
||||||
|
Successful responses are `{ data, code: "ok" }` with an appropriate HTTP status. Error responses are `{ error, code }` with an appropriate HTTP status, where `code` is a short machine-readable string (e.g. `subdomain-exists`) and `error` is a human-readable message.
|
||||||
|
|
||||||
|
## `pub struct ApiError(pub Box<Response>)`
|
||||||
|
|
||||||
|
A boxed `axum` `Response` that any handler can return as its error type. Implements `IntoResponse` and `From<Response>`, so the error builders below compose with `?`, `.map_err(...)`, and explicit `Err(...)`.
|
||||||
|
|
||||||
|
## `pub type ApiResult = Result<Response, ApiError>`
|
||||||
|
|
||||||
|
The return type of every route handler. Success builders return `ApiResult` so they sit at the end of a handler without an `Ok(..)` wrap; error builders return `ApiError`.
|
||||||
|
|
||||||
|
## Response bodies
|
||||||
|
|
||||||
|
- `DataResponse<T> { data: T, code: "ok" }` - the success envelope
|
||||||
|
- `ErrorResponse { error: String, code: String }` - the error envelope
|
||||||
|
|
||||||
|
## Success builders (return `ApiResult`)
|
||||||
|
|
||||||
|
- `res<T>(status, data)` - `{ data, code: "ok" }` with `status`
|
||||||
|
- `ok<T>(data)` - `res(200, data)`
|
||||||
|
- `created<T>(data)` - `res(201, data)`
|
||||||
|
|
||||||
|
## Error builders (return `ApiError`)
|
||||||
|
|
||||||
|
- `err(status, code, message)` - the base `{ error, code }` builder
|
||||||
|
- `unauthorized(reason)` - `401`, `code = "unauthorized"`
|
||||||
|
- `forbidden(message)` - `403`, `code = "forbidden"`
|
||||||
|
- `not_found(message)` - `404`, `code = "not-found"`
|
||||||
|
- `bad_request(code, message)` - `400` with the given `code`
|
||||||
|
- `unprocessable(code, message)` - `422` with the given `code`
|
||||||
|
- `internal(reason)` - `500`, `code = "internal"`
|
||||||
|
|
||||||
|
## Utilities
|
||||||
|
|
||||||
|
- `parse_bool_default(value: i64, default: i64) -> i64` - returns `value` if it is `0` or `1`, otherwise `default`. Used to normalize boolean-ish relay flags.
|
||||||
|
- `map_unique_error(err: &anyhow::Error) -> Option<&'static str>` - recognizes sqlite UNIQUE constraint violations so callers can translate them into `422`s instead of `500`s. Returns `pubkey-exists` or `subdomain-exists` when the violated column message matches, else `None`.
|
||||||
+143
-1040
File diff suppressed because it is too large
Load Diff
+211
-1478
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,48 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
+222
-291
@@ -3,7 +3,8 @@ use sqlx::{Sqlite, SqlitePool, Transaction};
|
|||||||
use tokio::sync::broadcast;
|
use tokio::sync::broadcast;
|
||||||
|
|
||||||
use crate::models::{
|
use crate::models::{
|
||||||
Activity, RELAY_STATUS_ACTIVE, RELAY_STATUS_DELINQUENT, RELAY_STATUS_INACTIVE, Relay, Tenant,
|
Activity, LightningInvoice, RELAY_STATUS_ACTIVE, RELAY_STATUS_DELINQUENT,
|
||||||
|
RELAY_STATUS_INACTIVE, Relay, Tenant,
|
||||||
};
|
};
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
@@ -18,6 +19,8 @@ impl Command {
|
|||||||
Self { pool, notify }
|
Self { pool, notify }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Activity
|
||||||
|
|
||||||
async fn insert_activity(
|
async fn insert_activity(
|
||||||
tx: &mut Transaction<'_, Sqlite>,
|
tx: &mut Transaction<'_, Sqlite>,
|
||||||
activity_type: &str,
|
activity_type: &str,
|
||||||
@@ -61,218 +64,55 @@ impl Command {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn emit(&self, activity: Activity) {
|
/// Run `f` inside a transaction, record an activity row, commit, and broadcast.
|
||||||
|
async fn with_activity<F>(
|
||||||
|
&self,
|
||||||
|
activity_type: &str,
|
||||||
|
resource_type: &str,
|
||||||
|
resource_id: &str,
|
||||||
|
f: F,
|
||||||
|
) -> Result<()>
|
||||||
|
where
|
||||||
|
F: AsyncFnOnce(&mut Transaction<'_, Sqlite>) -> Result<()>,
|
||||||
|
{
|
||||||
|
let mut tx = self.pool.begin().await?;
|
||||||
|
f(&mut tx).await?;
|
||||||
|
let activity =
|
||||||
|
Self::insert_activity(&mut tx, activity_type, resource_type, resource_id).await?;
|
||||||
|
tx.commit().await?;
|
||||||
let _ = self.notify.send(activity);
|
let _ = self.notify.send(activity);
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Tenants
|
||||||
|
|
||||||
pub async fn create_tenant(&self, tenant: &Tenant) -> Result<()> {
|
pub async fn create_tenant(&self, tenant: &Tenant) -> Result<()> {
|
||||||
if tenant.stripe_customer_id.trim().is_empty() {
|
self.with_activity("create_tenant", "tenant", &tenant.pubkey, async |tx| {
|
||||||
anyhow::bail!("stripe_customer_id is required");
|
sqlx::query(
|
||||||
}
|
"INSERT INTO tenant (pubkey, nwc_url, created_at, stripe_customer_id)
|
||||||
|
VALUES (?, ?, ?, ?)",
|
||||||
let mut tx = self.pool.begin().await?;
|
)
|
||||||
|
.bind(&tenant.pubkey)
|
||||||
sqlx::query(
|
.bind(&tenant.nwc_url)
|
||||||
"INSERT INTO tenant (pubkey, nwc_url, created_at, stripe_customer_id)
|
.bind(tenant.created_at)
|
||||||
VALUES (?, ?, ?, ?)",
|
.bind(&tenant.stripe_customer_id)
|
||||||
)
|
.execute(&mut **tx)
|
||||||
.bind(&tenant.pubkey)
|
.await?;
|
||||||
.bind(&tenant.nwc_url)
|
Ok(())
|
||||||
.bind(tenant.created_at)
|
})
|
||||||
.bind(&tenant.stripe_customer_id)
|
.await
|
||||||
.execute(&mut *tx)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
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<()> {
|
pub async fn update_tenant(&self, tenant: &Tenant) -> Result<()> {
|
||||||
let mut tx = self.pool.begin().await?;
|
self.with_activity("update_tenant", "tenant", &tenant.pubkey, async |tx| {
|
||||||
|
sqlx::query("UPDATE tenant SET nwc_url = ? WHERE pubkey = ?")
|
||||||
sqlx::query("UPDATE tenant SET nwc_url = ? WHERE pubkey = ?")
|
.bind(&tenant.nwc_url)
|
||||||
.bind(&tenant.nwc_url)
|
.bind(&tenant.pubkey)
|
||||||
.bind(&tenant.pubkey)
|
.execute(&mut **tx)
|
||||||
.execute(&mut *tx)
|
.await?;
|
||||||
.await?;
|
Ok(())
|
||||||
|
})
|
||||||
let activity =
|
.await
|
||||||
Self::insert_activity(&mut tx, "update_tenant", "tenant", &tenant.pubkey).await?;
|
|
||||||
|
|
||||||
tx.commit().await?;
|
|
||||||
self.emit(activity);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn create_relay(&self, relay: &Relay) -> Result<()> {
|
|
||||||
let mut tx = self.pool.begin().await?;
|
|
||||||
|
|
||||||
sqlx::query(
|
|
||||||
"INSERT INTO relay (
|
|
||||||
id, tenant, schema, subdomain, plan, status, synced, 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, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
|
||||||
)
|
|
||||||
.bind(&relay.id)
|
|
||||||
.bind(&relay.tenant)
|
|
||||||
.bind(&relay.schema)
|
|
||||||
.bind(&relay.subdomain)
|
|
||||||
.bind(&relay.plan)
|
|
||||||
.bind(&relay.sync_error)
|
|
||||||
.bind(&relay.info_name)
|
|
||||||
.bind(&relay.info_icon)
|
|
||||||
.bind(&relay.info_description)
|
|
||||||
.bind(relay.policy_public_join)
|
|
||||||
.bind(relay.policy_strip_signatures)
|
|
||||||
.bind(relay.groups_enabled)
|
|
||||||
.bind(relay.management_enabled)
|
|
||||||
.bind(relay.blossom_enabled)
|
|
||||||
.bind(relay.livekit_enabled)
|
|
||||||
.bind(relay.push_enabled)
|
|
||||||
.execute(&mut *tx)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
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 = ?, schema = ?, subdomain = ?, plan = ?, status = ?, sync_error = ?, synced = 0,
|
|
||||||
info_name = ?, info_icon = ?, info_description = ?,
|
|
||||||
policy_public_join = ?, policy_strip_signatures = ?,
|
|
||||||
groups_enabled = ?, management_enabled = ?, blossom_enabled = ?,
|
|
||||||
livekit_enabled = ?, push_enabled = ?
|
|
||||||
WHERE id = ?",
|
|
||||||
)
|
|
||||||
.bind(&relay.tenant)
|
|
||||||
.bind(&relay.schema)
|
|
||||||
.bind(&relay.subdomain)
|
|
||||||
.bind(&relay.plan)
|
|
||||||
.bind(&relay.status)
|
|
||||||
.bind(&relay.sync_error)
|
|
||||||
.bind(&relay.info_name)
|
|
||||||
.bind(&relay.info_icon)
|
|
||||||
.bind(&relay.info_description)
|
|
||||||
.bind(relay.policy_public_join)
|
|
||||||
.bind(relay.policy_strip_signatures)
|
|
||||||
.bind(relay.groups_enabled)
|
|
||||||
.bind(relay.management_enabled)
|
|
||||||
.bind(relay.blossom_enabled)
|
|
||||||
.bind(relay.livekit_enabled)
|
|
||||||
.bind(relay.push_enabled)
|
|
||||||
.bind(&relay.id)
|
|
||||||
.execute(&mut *tx)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
let activity = Self::insert_activity(&mut tx, "update_relay", "relay", &relay.id).await?;
|
|
||||||
|
|
||||||
tx.commit().await?;
|
|
||||||
self.emit(activity);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn deactivate_relay(&self, relay: &Relay) -> Result<()> {
|
|
||||||
self.set_relay_status(&relay.id, RELAY_STATUS_INACTIVE, "deactivate_relay")
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn mark_relay_delinquent(&self, relay: &Relay) -> Result<()> {
|
|
||||||
self.set_relay_status(&relay.id, RELAY_STATUS_DELINQUENT, "deactivate_relay")
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn set_relay_status(
|
|
||||||
&self,
|
|
||||||
relay_id: &str,
|
|
||||||
status: &str,
|
|
||||||
activity_type: &str,
|
|
||||||
) -> Result<()> {
|
|
||||||
let mut tx = self.pool.begin().await?;
|
|
||||||
|
|
||||||
sqlx::query("UPDATE relay SET status = ?, synced = 0 WHERE id = ?")
|
|
||||||
.bind(status)
|
|
||||||
.bind(relay_id)
|
|
||||||
.execute(&mut *tx)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
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 synced = 0, sync_error = ? WHERE id = ?")
|
|
||||||
.bind(&sync_error)
|
|
||||||
.bind(&relay.id)
|
|
||||||
.execute(&mut *tx)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
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)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
let activity =
|
|
||||||
Self::insert_activity(&mut tx, "complete_relay_sync", "relay", relay_id).await?;
|
|
||||||
|
|
||||||
tx.commit().await?;
|
|
||||||
self.emit(activity);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
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(())
|
|
||||||
}
|
|
||||||
|
|
||||||
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?;
|
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn set_tenant_subscription(
|
pub async fn set_tenant_subscription(
|
||||||
@@ -313,46 +153,6 @@ impl Command {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn insert_pending_invoice_nwc_payment(
|
|
||||||
&self,
|
|
||||||
invoice_id: &str,
|
|
||||||
tenant_pubkey: &str,
|
|
||||||
) -> Result<bool> {
|
|
||||||
let now = chrono::Utc::now().timestamp();
|
|
||||||
let result = sqlx::query(
|
|
||||||
"INSERT INTO invoice_nwc_payment (invoice_id, tenant_pubkey, state, created_at, updated_at)
|
|
||||||
VALUES (?, ?, 'pending', ?, ?)
|
|
||||||
ON CONFLICT(invoice_id) DO NOTHING",
|
|
||||||
)
|
|
||||||
.bind(invoice_id)
|
|
||||||
.bind(tenant_pubkey)
|
|
||||||
.bind(now)
|
|
||||||
.bind(now)
|
|
||||||
.execute(&self.pool)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
Ok(result.rows_affected() > 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn mark_invoice_nwc_payment_paid(&self, invoice_id: &str) -> Result<()> {
|
|
||||||
let now = chrono::Utc::now().timestamp();
|
|
||||||
let result = sqlx::query(
|
|
||||||
"UPDATE invoice_nwc_payment
|
|
||||||
SET state = 'paid', updated_at = ?
|
|
||||||
WHERE invoice_id = ?",
|
|
||||||
)
|
|
||||||
.bind(now)
|
|
||||||
.bind(invoice_id)
|
|
||||||
.execute(&self.pool)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
if result.rows_affected() == 0 {
|
|
||||||
anyhow::bail!("invoice_nwc_payment row missing for invoice_id: {invoice_id}");
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn set_tenant_past_due(&self, pubkey: &str) -> Result<()> {
|
pub async fn set_tenant_past_due(&self, pubkey: &str) -> Result<()> {
|
||||||
let now = chrono::Utc::now().timestamp();
|
let now = chrono::Utc::now().timestamp();
|
||||||
sqlx::query("UPDATE tenant SET past_due_at = ? WHERE pubkey = ?")
|
sqlx::query("UPDATE tenant SET past_due_at = ? WHERE pubkey = ?")
|
||||||
@@ -370,57 +170,188 @@ impl Command {
|
|||||||
.await?;
|
.await?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
// Relays
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
use sqlx::SqlitePool;
|
|
||||||
use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions};
|
|
||||||
use std::str::FromStr;
|
|
||||||
|
|
||||||
async fn test_pool() -> SqlitePool {
|
pub async fn create_relay(&self, relay: &Relay) -> Result<()> {
|
||||||
let connect_options = SqliteConnectOptions::from_str("sqlite::memory:")
|
self.with_activity("create_relay", "relay", &relay.id, async |tx| {
|
||||||
.expect("valid sqlite memory url")
|
sqlx::query(
|
||||||
.create_if_missing(true);
|
"INSERT INTO relay (
|
||||||
|
id, tenant, schema, subdomain, plan, status, synced, sync_error,
|
||||||
let pool = SqlitePoolOptions::new()
|
info_name, info_icon, info_description,
|
||||||
.max_connections(1)
|
policy_public_join, policy_strip_signatures,
|
||||||
.connect_with(connect_options)
|
groups_enabled, management_enabled, blossom_enabled,
|
||||||
.await
|
livekit_enabled, push_enabled
|
||||||
.expect("connect sqlite memory db");
|
) VALUES (?, ?, ?, ?, ?, 'active', 0, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||||
|
)
|
||||||
sqlx::migrate!("./migrations")
|
.bind(&relay.id)
|
||||||
.run(&pool)
|
.bind(&relay.tenant)
|
||||||
.await
|
.bind(&relay.schema)
|
||||||
.expect("run migrations");
|
.bind(&relay.subdomain)
|
||||||
|
.bind(&relay.plan)
|
||||||
pool
|
.bind(&relay.sync_error)
|
||||||
|
.bind(&relay.info_name)
|
||||||
|
.bind(&relay.info_icon)
|
||||||
|
.bind(&relay.info_description)
|
||||||
|
.bind(relay.policy_public_join)
|
||||||
|
.bind(relay.policy_strip_signatures)
|
||||||
|
.bind(relay.groups_enabled)
|
||||||
|
.bind(relay.management_enabled)
|
||||||
|
.bind(relay.blossom_enabled)
|
||||||
|
.bind(relay.livekit_enabled)
|
||||||
|
.bind(relay.push_enabled)
|
||||||
|
.execute(&mut **tx)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
pub async fn update_relay(&self, relay: &Relay) -> Result<()> {
|
||||||
async fn create_tenant_rejects_empty_stripe_customer_id() {
|
self.with_activity("update_relay", "relay", &relay.id, async |tx| {
|
||||||
let pool = test_pool().await;
|
sqlx::query(
|
||||||
let command = Command::new(pool);
|
"UPDATE relay
|
||||||
|
SET tenant = ?, schema = ?, subdomain = ?, plan = ?, status = ?, sync_error = ?, synced = 0,
|
||||||
|
info_name = ?, info_icon = ?, info_description = ?,
|
||||||
|
policy_public_join = ?, policy_strip_signatures = ?,
|
||||||
|
groups_enabled = ?, management_enabled = ?, blossom_enabled = ?,
|
||||||
|
livekit_enabled = ?, push_enabled = ?
|
||||||
|
WHERE id = ?",
|
||||||
|
)
|
||||||
|
.bind(&relay.tenant)
|
||||||
|
.bind(&relay.schema)
|
||||||
|
.bind(&relay.subdomain)
|
||||||
|
.bind(&relay.plan)
|
||||||
|
.bind(&relay.status)
|
||||||
|
.bind(&relay.sync_error)
|
||||||
|
.bind(&relay.info_name)
|
||||||
|
.bind(&relay.info_icon)
|
||||||
|
.bind(&relay.info_description)
|
||||||
|
.bind(relay.policy_public_join)
|
||||||
|
.bind(relay.policy_strip_signatures)
|
||||||
|
.bind(relay.groups_enabled)
|
||||||
|
.bind(relay.management_enabled)
|
||||||
|
.bind(relay.blossom_enabled)
|
||||||
|
.bind(relay.livekit_enabled)
|
||||||
|
.bind(relay.push_enabled)
|
||||||
|
.bind(&relay.id)
|
||||||
|
.execute(&mut **tx)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
let tenant = Tenant {
|
pub async fn deactivate_relay(&self, relay: &Relay) -> Result<()> {
|
||||||
pubkey: "tenant_pubkey".to_string(),
|
self.set_relay_status(&relay.id, RELAY_STATUS_INACTIVE, "deactivate_relay")
|
||||||
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
|
.await
|
||||||
.expect_err("empty customer id must be rejected");
|
}
|
||||||
|
|
||||||
assert!(
|
pub async fn mark_relay_delinquent(&self, relay: &Relay) -> Result<()> {
|
||||||
err.to_string().contains("stripe_customer_id is required"),
|
self.set_relay_status(&relay.id, RELAY_STATUS_DELINQUENT, "mark_relay_delinquent")
|
||||||
"unexpected error: {err}"
|
.await
|
||||||
);
|
}
|
||||||
|
|
||||||
|
pub async fn activate_relay(&self, relay: &Relay) -> Result<()> {
|
||||||
|
self.set_relay_status(&relay.id, RELAY_STATUS_ACTIVE, "activate_relay")
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn set_relay_status(
|
||||||
|
&self,
|
||||||
|
relay_id: &str,
|
||||||
|
status: &str,
|
||||||
|
activity_type: &str,
|
||||||
|
) -> Result<()> {
|
||||||
|
self.with_activity(activity_type, "relay", relay_id, async |tx| {
|
||||||
|
sqlx::query("UPDATE relay SET status = ?, synced = 0 WHERE id = ?")
|
||||||
|
.bind(status)
|
||||||
|
.bind(relay_id)
|
||||||
|
.execute(&mut **tx)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn fail_relay_sync(&self, relay: &Relay, sync_error: String) -> Result<()> {
|
||||||
|
self.with_activity("fail_relay_sync", "relay", &relay.id, async |tx| {
|
||||||
|
sqlx::query("UPDATE relay SET synced = 0, sync_error = ? WHERE id = ?")
|
||||||
|
.bind(&sync_error)
|
||||||
|
.bind(&relay.id)
|
||||||
|
.execute(&mut **tx)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn complete_relay_sync(&self, relay_id: &str) -> Result<()> {
|
||||||
|
self.with_activity("complete_relay_sync", "relay", relay_id, async |tx| {
|
||||||
|
sqlx::query("UPDATE relay SET synced = 1, sync_error = '' WHERE id = ?")
|
||||||
|
.bind(relay_id)
|
||||||
|
.execute(&mut **tx)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
// Invoices
|
||||||
|
|
||||||
|
/// Upsert the pending bolt11 for an invoice, returning the resulting row. On
|
||||||
|
/// conflict the stored bolt11/expiry are replaced — this is how an expired
|
||||||
|
/// invoice is regenerated — except once the invoice is paid, when the
|
||||||
|
/// `status = 'pending'` guard makes the update a no-op and `None` is
|
||||||
|
/// returned so the caller can fall back to reading the settled row.
|
||||||
|
pub async fn insert_lightning_invoice(
|
||||||
|
&self,
|
||||||
|
stripe_invoice_id: &str,
|
||||||
|
tenant_pubkey: &str,
|
||||||
|
bolt11: &str,
|
||||||
|
expires_at: i64,
|
||||||
|
) -> Result<Option<LightningInvoice>> {
|
||||||
|
let now = chrono::Utc::now().timestamp();
|
||||||
|
let row = sqlx::query_as::<_, LightningInvoice>(
|
||||||
|
"INSERT INTO lightning_invoice
|
||||||
|
(stripe_invoice_id, tenant_pubkey, bolt11, status, expires_at, created_at, updated_at)
|
||||||
|
VALUES (?, ?, ?, 'pending', ?, ?, ?)
|
||||||
|
ON CONFLICT(stripe_invoice_id) DO UPDATE SET
|
||||||
|
bolt11 = excluded.bolt11,
|
||||||
|
expires_at = excluded.expires_at,
|
||||||
|
updated_at = excluded.updated_at
|
||||||
|
WHERE status = 'pending'
|
||||||
|
RETURNING *",
|
||||||
|
)
|
||||||
|
.bind(stripe_invoice_id)
|
||||||
|
.bind(tenant_pubkey)
|
||||||
|
.bind(bolt11)
|
||||||
|
.bind(expires_at)
|
||||||
|
.bind(now)
|
||||||
|
.bind(now)
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(row)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mark a pending invoice paid, recording which method settled it. The
|
||||||
|
/// `status = 'pending'` guard makes this idempotent and first-writer-wins:
|
||||||
|
/// a later reconcile won't clobber the method recorded by whoever settled
|
||||||
|
/// it first.
|
||||||
|
pub async fn mark_lightning_invoice_paid(&self, stripe_invoice_id: &str, method: &str) -> Result<()> {
|
||||||
|
let now = chrono::Utc::now().timestamp();
|
||||||
|
sqlx::query(
|
||||||
|
"UPDATE lightning_invoice
|
||||||
|
SET status = 'paid', paid_method = ?, updated_at = ?
|
||||||
|
WHERE stripe_invoice_id = ? AND status = 'pending'",
|
||||||
|
)
|
||||||
|
.bind(method)
|
||||||
|
.bind(now)
|
||||||
|
.bind(stripe_invoice_id)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,126 @@
|
|||||||
|
use anyhow::{Result, anyhow};
|
||||||
|
use nostr_sdk::prelude::*;
|
||||||
|
|
||||||
|
#[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 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,
|
||||||
|
pub stripe_webhook_secret: String,
|
||||||
|
pub stripe_price_basic: String,
|
||||||
|
pub stripe_price_growth: String,
|
||||||
|
/// Parsed from `robot_secret`; used for nostr signing and nip44 encryption.
|
||||||
|
pub keys: Keys,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Env {
|
||||||
|
pub 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"),
|
||||||
|
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"),
|
||||||
|
stripe_webhook_secret: require_str("STRIPE_WEBHOOK_SECRET"),
|
||||||
|
stripe_price_basic: require_str("STRIPE_PRICE_BASIC"),
|
||||||
|
stripe_price_growth: require_str("STRIPE_PRICE_GROWTH"),
|
||||||
|
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
|
||||||
|
}
|
||||||
+163
-244
@@ -3,6 +3,7 @@ use nostr_sdk::prelude::*;
|
|||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use crate::command::Command;
|
use crate::command::Command;
|
||||||
|
use crate::env::Env;
|
||||||
use crate::models::{Activity, RELAY_STATUS_DELINQUENT, RELAY_STATUS_INACTIVE, Relay};
|
use crate::models::{Activity, RELAY_STATUS_DELINQUENT, RELAY_STATUS_INACTIVE, Relay};
|
||||||
use crate::query::Query;
|
use crate::query::Query;
|
||||||
|
|
||||||
@@ -12,42 +13,18 @@ const RELAY_SYNC_RETRY_MAX_ATTEMPTS: usize = 6;
|
|||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct Infra {
|
pub struct Infra {
|
||||||
api_url: String,
|
env: Env,
|
||||||
relay_domain: String,
|
|
||||||
livekit_url: String,
|
|
||||||
livekit_api_key: String,
|
|
||||||
livekit_api_secret: String,
|
|
||||||
api_secret: String,
|
|
||||||
query: Query,
|
query: Query,
|
||||||
command: Command,
|
command: Command,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Infra {
|
impl Infra {
|
||||||
pub fn new(query: Query, command: Command) -> Result<Self> {
|
pub fn new(query: Query, command: Command, env: &Env) -> Self {
|
||||||
let api_url = std::env::var("ZOOID_API_URL").unwrap_or_default();
|
Self {
|
||||||
let relay_domain = std::env::var("RELAY_DOMAIN").unwrap_or_default();
|
env: env.clone(),
|
||||||
let livekit_url = std::env::var("LIVEKIT_URL").unwrap_or_default();
|
|
||||||
let livekit_api_key = std::env::var("LIVEKIT_API_KEY").unwrap_or_default();
|
|
||||||
let livekit_api_secret = std::env::var("LIVEKIT_API_SECRET").unwrap_or_default();
|
|
||||||
let api_secret = std::env::var("ZOOID_API_SECRET").unwrap_or_default();
|
|
||||||
|
|
||||||
if api_url.trim().is_empty() {
|
|
||||||
anyhow::bail!("missing ZOOID_API_URL");
|
|
||||||
}
|
|
||||||
if api_secret.trim().is_empty() {
|
|
||||||
anyhow::bail!("missing ZOOID_API_SECRET");
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(Self {
|
|
||||||
api_url,
|
|
||||||
relay_domain,
|
|
||||||
livekit_url,
|
|
||||||
livekit_api_key,
|
|
||||||
livekit_api_secret,
|
|
||||||
api_secret,
|
|
||||||
query,
|
query,
|
||||||
command,
|
command,
|
||||||
})
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn start(self) {
|
pub async fn start(self) {
|
||||||
@@ -77,15 +54,17 @@ impl Infra {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn handle_activity(&self, activity: &Activity) -> Result<()> {
|
async fn handle_activity(&self, activity: &Activity) -> Result<()> {
|
||||||
let needs_sync = should_sync_relay_activity(activity.activity_type.as_str());
|
let needs_sync = matches!(
|
||||||
|
activity.activity_type.as_str(),
|
||||||
|
"create_relay" | "update_relay" | "activate_relay" | "deactivate_relay" | "fail_relay_sync"
|
||||||
|
);
|
||||||
|
|
||||||
if !needs_sync || activity.resource_type != "relay" {
|
if activity.resource_type != "relay" || !needs_sync {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
if activity.activity_type == "fail_relay_sync" {
|
if activity.activity_type == "fail_relay_sync" {
|
||||||
self.schedule_relay_sync_retry(&activity.resource_id, "activity")
|
self.schedule_relay_sync_retry(&activity.resource_id, "activity").await?;
|
||||||
.await?;
|
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -93,9 +72,7 @@ impl Infra {
|
|||||||
return Ok(());
|
return Ok(());
|
||||||
};
|
};
|
||||||
|
|
||||||
let is_new = self.relay_sync_is_new(&relay).await?;
|
self.sync_relay(&relay).await;
|
||||||
self.sync_and_report(&relay, is_new).await;
|
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -110,8 +87,7 @@ impl Infra {
|
|||||||
|
|
||||||
for relay in relays {
|
for relay in relays {
|
||||||
if relay.sync_error.trim().is_empty() {
|
if relay.sync_error.trim().is_empty() {
|
||||||
let is_new = self.relay_sync_is_new(&relay).await?;
|
self.sync_relay(&relay).await;
|
||||||
self.sync_and_report(&relay, is_new).await;
|
|
||||||
} else {
|
} else {
|
||||||
self.schedule_relay_sync_retry(&relay.id, source).await?;
|
self.schedule_relay_sync_retry(&relay.id, source).await?;
|
||||||
}
|
}
|
||||||
@@ -121,10 +97,28 @@ impl Infra {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn schedule_relay_sync_retry(&self, relay_id: &str, source: &str) -> Result<()> {
|
async fn schedule_relay_sync_retry(&self, relay_id: &str, source: &str) -> Result<()> {
|
||||||
let activities = self.query.list_activity_for_relay(relay_id).await?;
|
fn get_retry_delay(consecutive_failures: usize) -> Option<Duration> {
|
||||||
let consecutive_failures = consecutive_sync_failures(&activities);
|
let retry_attempt = consecutive_failures.max(1);
|
||||||
|
if retry_attempt > RELAY_SYNC_RETRY_MAX_ATTEMPTS {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
let Some(delay) = relay_sync_retry_delay(consecutive_failures) else {
|
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 = self.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!(
|
tracing::warn!(
|
||||||
relay = relay_id,
|
relay = relay_id,
|
||||||
consecutive_failures,
|
consecutive_failures,
|
||||||
@@ -148,40 +142,20 @@ impl Infra {
|
|||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
tokio::time::sleep(delay).await;
|
tokio::time::sleep(delay).await;
|
||||||
|
|
||||||
if let Err(e) = infra.retry_relay_sync(&relay_id).await {
|
match infra.query.get_relay(&relay_id).await {
|
||||||
tracing::error!(relay = %relay_id, error = %e, "relay sync retry task failed");
|
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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn retry_relay_sync(&self, relay_id: &str) -> Result<()> {
|
async fn sync_relay(&self, relay: &Relay) {
|
||||||
let Some(relay) = self.query.get_relay(relay_id).await? else {
|
match self.try_sync_relay(relay).await {
|
||||||
return Ok(());
|
|
||||||
};
|
|
||||||
|
|
||||||
if relay.sync_error.trim().is_empty() {
|
|
||||||
tracing::debug!(relay = %relay.id, "skip relay sync retry; relay has no sync_error");
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
let is_new = self.relay_sync_is_new(&relay).await?;
|
|
||||||
self.sync_and_report(&relay, is_new).await;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn relay_sync_is_new(&self, relay: &Relay) -> Result<bool> {
|
|
||||||
if relay.synced == 1 {
|
|
||||||
return Ok(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
let has_completed_sync = self.query.relay_has_completed_sync(&relay.id).await?;
|
|
||||||
Ok(!has_completed_sync)
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn sync_and_report(&self, relay: &Relay, is_new: bool) {
|
|
||||||
match self.sync_relay(relay, is_new).await {
|
|
||||||
Ok(()) => {
|
Ok(()) => {
|
||||||
tracing::info!(relay = %relay.id, "relay sync succeeded");
|
tracing::info!(relay = %relay.id, "relay sync succeeded");
|
||||||
if let Err(e) = self.command.complete_relay_sync(&relay.id).await {
|
if let Err(e) = self.command.complete_relay_sync(&relay.id).await {
|
||||||
@@ -197,191 +171,136 @@ impl Infra {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn nip98_auth(&self, url: &str, method: HttpMethod) -> Result<String> {
|
async fn try_sync_relay(&self, relay: &Relay) -> Result<()> {
|
||||||
let keys = Keys::parse(&self.api_secret)?;
|
// A relay is "new" (POST with a freshly generated secret) only if it has
|
||||||
let server_url = Url::parse(url)?;
|
// never completed a sync. `synced == 1` short-circuits the activity lookup;
|
||||||
let auth = HttpData::new(server_url, method)
|
// otherwise check the activity history so that a re-sync after an update
|
||||||
.to_authorization(&keys)
|
// (which resets `synced` to 0) PATCHes instead of clobbering the secret.
|
||||||
|
let is_new = relay.synced != 1
|
||||||
|
&& self
|
||||||
|
.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, self.env.relay_domain),
|
||||||
|
"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": if relay.blossom_enabled == 1 {
|
||||||
|
serde_json::json!({
|
||||||
|
"enabled": true,
|
||||||
|
"adapter": "s3",
|
||||||
|
"s3": {
|
||||||
|
"endpoint": self.env.blossom_s3_endpoint,
|
||||||
|
"region": self.env.blossom_s3_region,
|
||||||
|
"bucket": self.env.blossom_s3_bucket,
|
||||||
|
"access_key": self.env.blossom_s3_access_key,
|
||||||
|
"secret_key": self.env.blossom_s3_secret_key,
|
||||||
|
"key_prefix": relay.schema,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
serde_json::json!({ "enabled": false })
|
||||||
|
},
|
||||||
|
"livekit": if relay.livekit_enabled == 1 {
|
||||||
|
serde_json::json!({
|
||||||
|
"enabled": true,
|
||||||
|
"server_url": self.env.livekit_url,
|
||||||
|
"api_key": self.env.livekit_api_key,
|
||||||
|
"api_secret": self.env.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))
|
||||||
.await?;
|
.await?;
|
||||||
Ok(auth)
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn list_relay_members(&self, relay_id: &str) -> Result<Vec<String>> {
|
pub async fn list_relay_members(&self, relay_id: &str) -> Result<Vec<String>> {
|
||||||
let client = reqwest::Client::new();
|
#[derive(serde::Deserialize)]
|
||||||
let base = self.api_url.trim_end_matches('/');
|
struct MembersResponse {
|
||||||
let url = format!("{base}/relay/{relay_id}/members");
|
members: Vec<String>,
|
||||||
let auth = self.nip98_auth(&url, HttpMethod::GET).await?;
|
}
|
||||||
|
|
||||||
let response = client
|
let response = self
|
||||||
.get(&url)
|
.request(HttpMethod::GET, &format!("relay/{relay_id}/members"), None)
|
||||||
.header("Authorization", auth)
|
|
||||||
.send()
|
|
||||||
.await?;
|
.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 = self.env.zooid_api_url.trim_end_matches('/');
|
||||||
|
let path = path.trim_start_matches('/');
|
||||||
|
let url = format!("{base}/{path}");
|
||||||
|
let auth = self.env.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 mut req = client
|
||||||
|
.request(reqwest_method, &url)
|
||||||
|
.header("Authorization", auth);
|
||||||
|
if let Some(body) = body {
|
||||||
|
req = req.json(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
let response = req.send().await?;
|
||||||
|
|
||||||
if !response.status().is_success() {
|
if !response.status().is_success() {
|
||||||
let status = response.status();
|
let status = response.status();
|
||||||
let body = response.text().await.unwrap_or_default();
|
let text = response.text().await.unwrap_or_default();
|
||||||
anyhow::bail!("zooid members returned {status}: {body}");
|
anyhow::bail!("zooid {method} {path} returned {status}: {text}");
|
||||||
}
|
}
|
||||||
|
Ok(response)
|
||||||
let body = response.text().await?;
|
|
||||||
parse_relay_members_response(&body)
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn sync_relay(&self, relay: &Relay, is_new: bool) -> Result<()> {
|
|
||||||
let client = reqwest::Client::new();
|
|
||||||
let base = self.api_url.trim_end_matches('/');
|
|
||||||
|
|
||||||
let host = if self.relay_domain.is_empty() {
|
|
||||||
relay.subdomain.clone()
|
|
||||||
} else {
|
|
||||||
format!("{}.{}", relay.subdomain, self.relay_domain)
|
|
||||||
};
|
|
||||||
|
|
||||||
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 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 body = response.text().await.unwrap_or_default();
|
|
||||||
anyhow::bail!("zooid sync returned {status}: {body}")
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn zooid_sync_http_method(is_new: bool) -> HttpMethod {
|
|
||||||
if is_new {
|
|
||||||
HttpMethod::POST
|
|
||||||
} else {
|
|
||||||
HttpMethod::PATCH
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn parse_relay_members_response(body: &str) -> Result<Vec<String>> {
|
|
||||||
let value: serde_json::Value = serde_json::from_str(body)?;
|
|
||||||
|
|
||||||
if let Some(members) = members_from_value(&value) {
|
|
||||||
return Ok(members);
|
|
||||||
}
|
|
||||||
if let Some(members) = value.get("members").and_then(members_from_value) {
|
|
||||||
return Ok(members);
|
|
||||||
}
|
|
||||||
if let Some(members) = value
|
|
||||||
.get("data")
|
|
||||||
.and_then(|data| data.get("members"))
|
|
||||||
.and_then(members_from_value)
|
|
||||||
{
|
|
||||||
return Ok(members);
|
|
||||||
}
|
|
||||||
|
|
||||||
anyhow::bail!("zooid members response missing members array")
|
|
||||||
}
|
|
||||||
|
|
||||||
fn members_from_value(value: &serde_json::Value) -> Option<Vec<String>> {
|
|
||||||
let values = value.as_array()?;
|
|
||||||
values
|
|
||||||
.iter()
|
|
||||||
.map(|value| value.as_str().map(ToString::to_string))
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn relay_sync_body(
|
|
||||||
relay: &Relay,
|
|
||||||
host: String,
|
|
||||||
livekit: serde_json::Value,
|
|
||||||
secret: Option<String>,
|
|
||||||
) -> serde_json::Value {
|
|
||||||
let mut body = serde_json::json!({
|
|
||||||
"host": host,
|
|
||||||
"schema": relay.schema,
|
|
||||||
"inactive": relay.status == RELAY_STATUS_INACTIVE
|
|
||||||
|| relay.status == RELAY_STATUS_DELINQUENT,
|
|
||||||
"info": {
|
|
||||||
"name": relay.info_name,
|
|
||||||
"icon": relay.info_icon,
|
|
||||||
"description": relay.info_description,
|
|
||||||
"pubkey": relay.tenant,
|
|
||||||
},
|
|
||||||
"policy": {
|
|
||||||
"public_join": relay.policy_public_join == 1,
|
|
||||||
"strip_signatures": relay.policy_strip_signatures == 1,
|
|
||||||
},
|
|
||||||
"groups": { "enabled": relay.groups_enabled == 1 },
|
|
||||||
"management": { "enabled": relay.management_enabled == 1 },
|
|
||||||
"blossom": { "enabled": relay.blossom_enabled == 1 },
|
|
||||||
"livekit": livekit,
|
|
||||||
"push": { "enabled": relay.push_enabled == 1 },
|
|
||||||
"roles": {
|
|
||||||
"admin": { "can_manage": true, "can_invite": true },
|
|
||||||
"member": { "can_invite": true },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if let (Some(secret), Some(body_obj)) = (secret, body.as_object_mut()) {
|
|
||||||
body_obj.insert("secret".to_string(), serde_json::Value::String(secret));
|
|
||||||
}
|
|
||||||
|
|
||||||
body
|
|
||||||
}
|
|
||||||
|
|
||||||
fn should_sync_relay_activity(activity_type: &str) -> bool {
|
|
||||||
matches!(
|
|
||||||
activity_type,
|
|
||||||
"create_relay" | "update_relay" | "activate_relay" | "deactivate_relay" | "fail_relay_sync"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn consecutive_sync_failures(activities: &[Activity]) -> usize {
|
|
||||||
activities
|
|
||||||
.iter()
|
|
||||||
.take_while(|activity| activity.activity_type == "fail_relay_sync")
|
|
||||||
.count()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn relay_sync_retry_delay(consecutive_failures: usize) -> Option<Duration> {
|
|
||||||
let retry_attempt = consecutive_failures.max(1);
|
|
||||||
if retry_attempt > RELAY_SYNC_RETRY_MAX_ATTEMPTS {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
|
|
||||||
let exponent = (retry_attempt - 1).min(31);
|
|
||||||
let multiplier = 1u64 << exponent;
|
|
||||||
let delay_secs = RELAY_SYNC_RETRY_BASE_DELAY_SECS
|
|
||||||
.saturating_mul(multiplier)
|
|
||||||
.min(RELAY_SYNC_RETRY_MAX_DELAY_SECS);
|
|
||||||
|
|
||||||
Some(Duration::from_secs(delay_secs))
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,8 +1,14 @@
|
|||||||
pub mod api;
|
pub mod api;
|
||||||
pub mod billing;
|
pub mod billing;
|
||||||
|
pub mod bitcoin;
|
||||||
pub mod command;
|
pub mod command;
|
||||||
|
pub mod env;
|
||||||
pub mod infra;
|
pub mod infra;
|
||||||
pub mod models;
|
pub mod models;
|
||||||
pub mod pool;
|
pub mod pool;
|
||||||
pub mod query;
|
pub mod query;
|
||||||
pub mod robot;
|
pub mod robot;
|
||||||
|
pub mod routes;
|
||||||
|
pub mod stripe;
|
||||||
|
pub mod wallet;
|
||||||
|
pub mod web;
|
||||||
|
|||||||
+25
-28
@@ -1,11 +1,17 @@
|
|||||||
mod api;
|
mod api;
|
||||||
mod billing;
|
mod billing;
|
||||||
|
mod bitcoin;
|
||||||
mod command;
|
mod command;
|
||||||
|
mod env;
|
||||||
mod infra;
|
mod infra;
|
||||||
mod models;
|
mod models;
|
||||||
mod pool;
|
mod pool;
|
||||||
mod query;
|
mod query;
|
||||||
mod robot;
|
mod robot;
|
||||||
|
mod routes;
|
||||||
|
mod stripe;
|
||||||
|
mod wallet;
|
||||||
|
mod web;
|
||||||
|
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
|
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
|
||||||
@@ -15,9 +21,11 @@ use tower_http::cors::{AllowOrigin, CorsLayer};
|
|||||||
use crate::api::Api;
|
use crate::api::Api;
|
||||||
use crate::billing::Billing;
|
use crate::billing::Billing;
|
||||||
use crate::command::Command;
|
use crate::command::Command;
|
||||||
|
use crate::env::Env;
|
||||||
use crate::infra::Infra;
|
use crate::infra::Infra;
|
||||||
use crate::query::Query;
|
use crate::query::Query;
|
||||||
use crate::robot::Robot;
|
use crate::robot::Robot;
|
||||||
|
use crate::stripe::Stripe;
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() -> Result<()> {
|
async fn main() -> Result<()> {
|
||||||
@@ -28,35 +36,23 @@ async fn main() -> Result<()> {
|
|||||||
.with(tracing_subscriber::fmt::layer())
|
.with(tracing_subscriber::fmt::layer())
|
||||||
.init();
|
.init();
|
||||||
|
|
||||||
let pool = pool::create_pool().await?;
|
let env = Env::load();
|
||||||
let robot = Robot::new().await?;
|
|
||||||
let query = Query::new(pool.clone());
|
let pool = pool::create_pool(&env.database_url).await?;
|
||||||
|
let robot = Robot::new(&env).await?;
|
||||||
|
let stripe = Stripe::new(&env);
|
||||||
|
let query = Query::new(pool.clone(), &env);
|
||||||
let command = Command::new(pool);
|
let command = Command::new(pool);
|
||||||
let billing = Billing::new(query.clone(), command.clone(), robot.clone());
|
let billing = Billing::new(query.clone(), command.clone(), &env);
|
||||||
let infra = Infra::new(query.clone(), command.clone())?;
|
let infra = Infra::new(query.clone(), command.clone(), &env);
|
||||||
let api = Api::new(query, command, billing.clone(), infra.clone());
|
let api = Api::new(query, command, billing.clone(), stripe, robot, infra.clone(), &env);
|
||||||
|
|
||||||
let host = std::env::var("HOST").unwrap_or_else(|_| "127.0.0.1".to_string());
|
let parsed = env
|
||||||
let port: u16 = std::env::var("PORT")
|
.server_allow_origins
|
||||||
.ok()
|
.iter()
|
||||||
.and_then(|v| v.parse().ok())
|
.filter_map(|o| o.parse::<axum::http::HeaderValue>().ok())
|
||||||
.unwrap_or(2892);
|
.collect::<Vec<_>>();
|
||||||
let origins: Vec<String> = std::env::var("ALLOW_ORIGINS")
|
let cors = CorsLayer::new().allow_origin(AllowOrigin::list(parsed));
|
||||||
.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);
|
let app = api.router().layer(cors);
|
||||||
|
|
||||||
@@ -68,7 +64,8 @@ async fn main() -> Result<()> {
|
|||||||
billing.start().await;
|
billing.start().await;
|
||||||
});
|
});
|
||||||
|
|
||||||
let listener = tokio::net::TcpListener::bind(format!("{host}:{port}")).await?;
|
let listener =
|
||||||
|
tokio::net::TcpListener::bind(format!("{}:{}", env.server_host, env.server_port)).await?;
|
||||||
axum::serve(listener, app).await?;
|
axum::serve(listener, app).await?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
+38
-2
@@ -25,7 +25,7 @@ pub struct Plan {
|
|||||||
pub stripe_price_id: Option<String>,
|
pub stripe_price_id: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
|
#[derive(Debug, Default, Clone, Serialize, Deserialize, sqlx::FromRow)]
|
||||||
pub struct Tenant {
|
pub struct Tenant {
|
||||||
pub pubkey: String,
|
pub pubkey: String,
|
||||||
pub nwc_url: String,
|
pub nwc_url: String,
|
||||||
@@ -36,6 +36,18 @@ pub struct Tenant {
|
|||||||
pub past_due_at: Option<i64>,
|
pub past_due_at: Option<i64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
|
||||||
|
pub struct LightningInvoice {
|
||||||
|
pub stripe_invoice_id: String,
|
||||||
|
pub tenant_pubkey: String,
|
||||||
|
pub bolt11: String,
|
||||||
|
pub status: String,
|
||||||
|
pub paid_method: Option<String>,
|
||||||
|
pub expires_at: i64,
|
||||||
|
pub created_at: i64,
|
||||||
|
pub updated_at: i64,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
|
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
|
||||||
pub struct Relay {
|
pub struct Relay {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
@@ -43,7 +55,6 @@ pub struct Relay {
|
|||||||
pub schema: String,
|
pub schema: String,
|
||||||
pub subdomain: String,
|
pub subdomain: String,
|
||||||
pub plan: String,
|
pub plan: String,
|
||||||
pub stripe_subscription_item_id: Option<String>,
|
|
||||||
pub status: String,
|
pub status: String,
|
||||||
pub sync_error: String,
|
pub sync_error: String,
|
||||||
pub info_name: String,
|
pub info_name: String,
|
||||||
@@ -58,3 +69,28 @@ pub struct Relay {
|
|||||||
pub push_enabled: i64,
|
pub push_enabled: i64,
|
||||||
pub synced: i64,
|
pub synced: i64,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl Default for Relay {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
id: String::new(),
|
||||||
|
tenant: String::new(),
|
||||||
|
schema: 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+2
-4
@@ -7,10 +7,8 @@ use sqlx::{
|
|||||||
sqlite::{SqliteConnectOptions, SqlitePoolOptions},
|
sqlite::{SqliteConnectOptions, SqlitePoolOptions},
|
||||||
};
|
};
|
||||||
|
|
||||||
pub async fn create_pool() -> Result<SqlitePool> {
|
pub async fn create_pool(database_url: &str) -> Result<SqlitePool> {
|
||||||
let raw_database_url = std::env::var("DATABASE_URL")
|
let database_url = normalize_sqlite_url(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://")
|
if let Some(path) = database_url.strip_prefix("sqlite://")
|
||||||
&& !path.is_empty()
|
&& !path.is_empty()
|
||||||
|
|||||||
+104
-141
@@ -1,42 +1,38 @@
|
|||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use sqlx::SqlitePool;
|
use sqlx::SqlitePool;
|
||||||
|
|
||||||
use crate::models::{Activity, Plan, Relay, Tenant};
|
use crate::env::Env;
|
||||||
|
use crate::models::{Activity, LightningInvoice, Plan, Relay, Tenant};
|
||||||
|
|
||||||
|
fn select_tenant(tail: &str) -> String {
|
||||||
|
format!("SELECT * FROM tenant {tail}")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn select_relay(tail: &str) -> String {
|
||||||
|
format!("SELECT * FROM relay {tail}")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn select_activity(tail: &str) -> String {
|
||||||
|
format!("SELECT * FROM activity {tail}")
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct Query {
|
pub struct Query {
|
||||||
pool: SqlitePool,
|
pool: SqlitePool,
|
||||||
|
env: Env,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Query {
|
impl Query {
|
||||||
pub fn new(pool: SqlitePool) -> Self {
|
pub fn new(pool: SqlitePool, env: &Env) -> Self {
|
||||||
Self { pool }
|
Self {
|
||||||
|
pool,
|
||||||
|
env: env.clone(),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn list_tenants(&self) -> Result<Vec<Tenant>> {
|
// Plans
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn get_tenant(&self, pubkey: &str) -> Result<Option<Tenant>> {
|
pub fn list_plans(&self) -> Vec<Plan> {
|
||||||
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(&self.pool)
|
|
||||||
.await?;
|
|
||||||
Ok(row)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn list_plans() -> Vec<Plan> {
|
|
||||||
vec![
|
vec![
|
||||||
Plan {
|
Plan {
|
||||||
id: "free".to_string(),
|
id: "free".to_string(),
|
||||||
@@ -54,7 +50,7 @@ impl Query {
|
|||||||
members: Some(100),
|
members: Some(100),
|
||||||
blossom: true,
|
blossom: true,
|
||||||
livekit: true,
|
livekit: true,
|
||||||
stripe_price_id: Some(std::env::var("STRIPE_PRICE_BASIC").unwrap_or_default()),
|
stripe_price_id: Some(self.env.stripe_price_basic.clone()),
|
||||||
},
|
},
|
||||||
Plan {
|
Plan {
|
||||||
id: "growth".to_string(),
|
id: "growth".to_string(),
|
||||||
@@ -63,86 +59,33 @@ impl Query {
|
|||||||
members: None,
|
members: None,
|
||||||
blossom: true,
|
blossom: true,
|
||||||
livekit: true,
|
livekit: true,
|
||||||
stripe_price_id: Some(std::env::var("STRIPE_PRICE_GROWTH").unwrap_or_default()),
|
stripe_price_id: Some(self.env.stripe_price_growth.clone()),
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn get_plan(plan_id: &str) -> Option<Plan> {
|
pub fn get_plan(&self, plan_id: &str) -> Option<Plan> {
|
||||||
Self::list_plans().into_iter().find(|p| p.id == plan_id)
|
self.list_plans().into_iter().find(|p| p.id == plan_id)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn is_paid_plan(plan_id: &str) -> bool {
|
pub fn is_paid_plan(&self, plan_id: &str) -> bool {
|
||||||
Self::get_plan(plan_id)
|
self.get_plan(plan_id).is_some_and(|p| p.amount > 0)
|
||||||
.map(|p| p.id != "free")
|
|
||||||
.unwrap_or(false)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn list_relays(&self) -> Result<Vec<Relay>> {
|
// Tenants
|
||||||
let rows = sqlx::query_as::<_, Relay>(
|
|
||||||
"SELECT id, tenant, schema, subdomain, plan, stripe_subscription_item_id,
|
pub async fn list_tenants(&self) -> Result<Vec<Tenant>> {
|
||||||
status, sync_error,
|
let rows = sqlx::query_as::<_, Tenant>(&select_tenant(""))
|
||||||
info_name, info_icon, info_description,
|
.fetch_all(&self.pool)
|
||||||
policy_public_join, policy_strip_signatures,
|
.await?;
|
||||||
groups_enabled, management_enabled, blossom_enabled,
|
|
||||||
livekit_enabled, push_enabled, synced
|
|
||||||
FROM relay
|
|
||||||
ORDER BY id",
|
|
||||||
)
|
|
||||||
.fetch_all(&self.pool)
|
|
||||||
.await?;
|
|
||||||
Ok(rows)
|
Ok(rows)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn list_relays_pending_sync(&self) -> Result<Vec<Relay>> {
|
pub async fn get_tenant(&self, pubkey: &str) -> Result<Option<Tenant>> {
|
||||||
let rows = sqlx::query_as::<_, Relay>(
|
let row = sqlx::query_as::<_, Tenant>(&select_tenant("WHERE pubkey = ?"))
|
||||||
"SELECT id, tenant, schema, subdomain, plan, stripe_subscription_item_id,
|
.bind(pubkey)
|
||||||
status, sync_error,
|
.fetch_optional(&self.pool)
|
||||||
info_name, info_icon, info_description,
|
.await?;
|
||||||
policy_public_join, policy_strip_signatures,
|
|
||||||
groups_enabled, management_enabled, blossom_enabled,
|
|
||||||
livekit_enabled, push_enabled, synced
|
|
||||||
FROM relay
|
|
||||||
WHERE synced = 0 OR TRIM(sync_error) != ''
|
|
||||||
ORDER BY id",
|
|
||||||
)
|
|
||||||
.fetch_all(&self.pool)
|
|
||||||
.await?;
|
|
||||||
Ok(rows)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn list_relays_for_tenant(&self, tenant_id: &str) -> Result<Vec<Relay>> {
|
|
||||||
let rows = sqlx::query_as::<_, Relay>(
|
|
||||||
"SELECT id, tenant, schema, subdomain, plan, stripe_subscription_item_id,
|
|
||||||
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(&self.pool)
|
|
||||||
.await?;
|
|
||||||
Ok(rows)
|
|
||||||
}
|
|
||||||
|
|
||||||
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(&self.pool)
|
|
||||||
.await?;
|
|
||||||
Ok(row)
|
Ok(row)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -150,64 +93,84 @@ impl Query {
|
|||||||
&self,
|
&self,
|
||||||
stripe_customer_id: &str,
|
stripe_customer_id: &str,
|
||||||
) -> Result<Option<Tenant>> {
|
) -> Result<Option<Tenant>> {
|
||||||
let row = sqlx::query_as::<_, Tenant>(
|
let row = sqlx::query_as::<_, Tenant>(&select_tenant("WHERE stripe_customer_id = ?"))
|
||||||
"SELECT pubkey, nwc_url, nwc_error, created_at, stripe_customer_id, stripe_subscription_id, past_due_at
|
.bind(stripe_customer_id)
|
||||||
FROM tenant
|
.fetch_optional(&self.pool)
|
||||||
WHERE stripe_customer_id = ?",
|
.await?;
|
||||||
)
|
|
||||||
.bind(stripe_customer_id)
|
|
||||||
.fetch_optional(&self.pool)
|
|
||||||
.await?;
|
|
||||||
Ok(row)
|
Ok(row)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn get_invoice_nwc_payment_state(&self, invoice_id: &str) -> Result<Option<String>> {
|
// Relays
|
||||||
let state = sqlx::query_scalar::<_, String>(
|
|
||||||
"SELECT state FROM invoice_nwc_payment WHERE invoice_id = ?",
|
pub async fn list_relays(&self) -> Result<Vec<Relay>> {
|
||||||
)
|
let rows = sqlx::query_as::<_, Relay>(&select_relay(""))
|
||||||
.bind(invoice_id)
|
.fetch_all(&self.pool)
|
||||||
.fetch_optional(&self.pool)
|
.await?;
|
||||||
.await?;
|
Ok(rows)
|
||||||
Ok(state)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn has_active_paid_relays(&self, tenant_id: &str) -> Result<bool> {
|
pub async fn list_relays_pending_sync(&self) -> Result<Vec<Relay>> {
|
||||||
let plans = sqlx::query_scalar::<_, String>(
|
let rows = sqlx::query_as::<_, Relay>(&select_relay(
|
||||||
"SELECT plan FROM relay WHERE tenant = ? AND status = 'active'",
|
"WHERE synced = 0 OR TRIM(sync_error) != ''",
|
||||||
)
|
))
|
||||||
.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)
|
.fetch_all(&self.pool)
|
||||||
.await?;
|
.await?;
|
||||||
Ok(rows)
|
Ok(rows)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn relay_has_completed_sync(&self, relay_id: &str) -> Result<bool> {
|
pub async fn list_relays_for_tenant(&self, tenant_id: &str) -> Result<Vec<Relay>> {
|
||||||
let found = sqlx::query_scalar::<_, i64>(
|
let rows = sqlx::query_as::<_, Relay>(&select_relay("WHERE tenant = ?"))
|
||||||
"SELECT 1
|
.bind(tenant_id)
|
||||||
FROM activity
|
.fetch_all(&self.pool)
|
||||||
WHERE resource_type = 'relay'
|
.await?;
|
||||||
AND resource_id = ?
|
Ok(rows)
|
||||||
AND activity_type = 'complete_relay_sync'
|
}
|
||||||
LIMIT 1",
|
|
||||||
|
pub async fn get_relay(&self, id: &str) -> Result<Option<Relay>> {
|
||||||
|
let row = sqlx::query_as::<_, Relay>(&select_relay("WHERE id = ?"))
|
||||||
|
.bind(id)
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(row)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Invoices
|
||||||
|
|
||||||
|
pub async fn get_lightning_invoice(
|
||||||
|
&self,
|
||||||
|
stripe_invoice_id: &str,
|
||||||
|
) -> Result<Option<LightningInvoice>> {
|
||||||
|
let row = sqlx::query_as::<_, LightningInvoice>(
|
||||||
|
"SELECT * FROM lightning_invoice WHERE stripe_invoice_id = ?",
|
||||||
)
|
)
|
||||||
.bind(relay_id)
|
.bind(stripe_invoice_id)
|
||||||
.fetch_optional(&self.pool)
|
.fetch_optional(&self.pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
Ok(row)
|
||||||
|
}
|
||||||
|
|
||||||
Ok(found.is_some())
|
// Activity
|
||||||
|
|
||||||
|
pub async fn list_activity_for_resource(&self, resource_id: &str) -> Result<Vec<Activity>> {
|
||||||
|
let rows = sqlx::query_as::<_, Activity>(&select_activity("WHERE resource_id = ? ORDER BY created_at DESC"))
|
||||||
|
.bind(resource_id)
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_latest_activity_for_resource_and_type(
|
||||||
|
&self,
|
||||||
|
resource_id: &str,
|
||||||
|
activity_type: &str,
|
||||||
|
) -> Result<Option<Activity>> {
|
||||||
|
let row = 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(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(row)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+51
-127
@@ -5,15 +5,11 @@ use anyhow::{Result, anyhow};
|
|||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
use tokio::sync::Mutex;
|
use tokio::sync::Mutex;
|
||||||
|
|
||||||
|
use crate::env::Env;
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct Robot {
|
pub struct Robot {
|
||||||
secret: String,
|
env: Env,
|
||||||
name: String,
|
|
||||||
description: String,
|
|
||||||
picture: String,
|
|
||||||
outbox_client: Client,
|
|
||||||
indexer_client: Client,
|
|
||||||
messaging_client: Client,
|
|
||||||
outbox_cache: std::sync::Arc<Mutex<HashMap<String, CacheEntry>>>,
|
outbox_cache: std::sync::Arc<Mutex<HashMap<String, CacheEntry>>>,
|
||||||
dm_cache: std::sync::Arc<Mutex<HashMap<String, CacheEntry>>>,
|
dm_cache: std::sync::Arc<Mutex<HashMap<String, CacheEntry>>>,
|
||||||
}
|
}
|
||||||
@@ -25,84 +21,61 @@ struct CacheEntry {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl Robot {
|
impl Robot {
|
||||||
pub async fn new() -> Result<Self> {
|
pub async fn new(env: &Env) -> 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 {
|
let robot = Self {
|
||||||
secret,
|
env: env.clone(),
|
||||||
name,
|
|
||||||
description,
|
|
||||||
picture,
|
|
||||||
outbox_client,
|
|
||||||
indexer_client,
|
|
||||||
messaging_client,
|
|
||||||
outbox_cache: std::sync::Arc::new(Mutex::new(HashMap::new())),
|
outbox_cache: std::sync::Arc::new(Mutex::new(HashMap::new())),
|
||||||
dm_cache: std::sync::Arc::new(Mutex::new(HashMap::new())),
|
dm_cache: std::sync::Arc::new(Mutex::new(HashMap::new())),
|
||||||
};
|
};
|
||||||
|
|
||||||
robot
|
robot.publish_identity().await?;
|
||||||
.publish_identity(&outbox_relays, &messaging_relays)
|
|
||||||
.await?;
|
|
||||||
Ok(robot)
|
Ok(robot)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn make_client(&self, relays: &[String]) -> Result<Client> {
|
||||||
|
let client = Client::new(self.env.keys.clone());
|
||||||
|
for relay in relays {
|
||||||
|
client.add_relay(relay).await?;
|
||||||
|
}
|
||||||
|
client.connect().await;
|
||||||
|
Ok(client)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
async fn publish_identity(
|
async fn publish_identity(
|
||||||
&self,
|
&self,
|
||||||
outbox_relays: &[String],
|
|
||||||
messaging_relays: &[String],
|
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let mut metadata = Metadata::new();
|
let mut metadata = Metadata::new();
|
||||||
if !self.name.is_empty() {
|
if !self.env.robot_name.is_empty() {
|
||||||
metadata = metadata.name(&self.name);
|
metadata = metadata.name(&self.env.robot_name);
|
||||||
}
|
}
|
||||||
if !self.description.is_empty() {
|
if !self.env.robot_description.is_empty() {
|
||||||
metadata = metadata.about(&self.description);
|
metadata = metadata.about(&self.env.robot_description);
|
||||||
}
|
}
|
||||||
if !self.picture.is_empty() {
|
if !self.env.robot_picture.is_empty() {
|
||||||
metadata = metadata.picture(Url::parse(&self.picture)?);
|
metadata = metadata.picture(Url::parse(&self.env.robot_picture)?);
|
||||||
}
|
}
|
||||||
|
|
||||||
self.outbox_client
|
let outbox_client = self.make_client(&self.env.robot_outbox_relays).await?;
|
||||||
|
let indexer_client = self.make_client(&self.env.robot_indexer_relays).await?;
|
||||||
|
|
||||||
|
outbox_client
|
||||||
.send_event_builder(EventBuilder::metadata(&metadata))
|
.send_event_builder(EventBuilder::metadata(&metadata))
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
let outbox_tags = outbox_relays
|
let outbox_tags = self.env.robot_outbox_relays
|
||||||
.iter()
|
.iter()
|
||||||
.map(|r| Tag::parse(["r", r.as_str()]))
|
.map(|r| Tag::parse(["r", r.as_str()]))
|
||||||
.collect::<std::result::Result<Vec<_>, _>>()?;
|
.collect::<std::result::Result<Vec<_>, _>>()?;
|
||||||
self.outbox_client
|
outbox_client
|
||||||
.send_event_builder(EventBuilder::new(Kind::Custom(10002), "").tags(outbox_tags))
|
.send_event_builder(EventBuilder::new(Kind::Custom(10002), "").tags(outbox_tags))
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
let messaging_tags = messaging_relays
|
let messaging_tags = self.env.robot_messaging_relays
|
||||||
.iter()
|
.iter()
|
||||||
.map(|r| Tag::parse(["relay", r.as_str()]))
|
.map(|r| Tag::parse(["relay", r.as_str()]))
|
||||||
.collect::<std::result::Result<Vec<_>, _>>()?;
|
.collect::<std::result::Result<Vec<_>, _>>()?;
|
||||||
self.indexer_client
|
indexer_client
|
||||||
.send_event_builder(EventBuilder::new(Kind::Custom(10050), "").tags(messaging_tags))
|
.send_event_builder(EventBuilder::new(Kind::Custom(10050), "").tags(messaging_tags))
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
@@ -123,14 +96,8 @@ impl Robot {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let recipient_pubkey = PublicKey::parse(recipient)?;
|
let recipient_pubkey = PublicKey::parse(recipient)?;
|
||||||
let client = self.messaging_client.clone();
|
let client = self.make_client(&dm_relays).await?;
|
||||||
for relay in dm_relays {
|
client.send_private_msg(recipient_pubkey, message, []).await?;
|
||||||
let _ = client.add_relay(relay).await;
|
|
||||||
}
|
|
||||||
client.connect().await;
|
|
||||||
client
|
|
||||||
.send_private_msg(recipient_pubkey, message, [])
|
|
||||||
.await?;
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -141,10 +108,8 @@ impl Robot {
|
|||||||
|
|
||||||
let pubkey = PublicKey::parse(recipient)?;
|
let pubkey = PublicKey::parse(recipient)?;
|
||||||
let filter = Filter::new().author(pubkey).kind(Kind::Custom(10002));
|
let filter = Filter::new().author(pubkey).kind(Kind::Custom(10002));
|
||||||
let events = self
|
let client = self.make_client(&self.env.robot_indexer_relays).await?;
|
||||||
.indexer_client
|
let events = client.fetch_events(filter, Duration::from_secs(5)).await?;
|
||||||
.fetch_events(filter, Duration::from_secs(5))
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
let mut relays = Vec::new();
|
let mut relays = Vec::new();
|
||||||
if let Some(event) = events.into_iter().max_by_key(|e| e.created_at) {
|
if let Some(event) = events.into_iter().max_by_key(|e| e.created_at) {
|
||||||
@@ -160,6 +125,22 @@ impl Robot {
|
|||||||
Ok(relays)
|
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(&self.env.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(
|
async fn fetch_messaging_relays_from_outbox(
|
||||||
&self,
|
&self,
|
||||||
recipient: &str,
|
recipient: &str,
|
||||||
@@ -170,13 +151,7 @@ impl Robot {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let pubkey = PublicKey::parse(recipient)?;
|
let pubkey = PublicKey::parse(recipient)?;
|
||||||
let keys = Keys::parse(&self.secret)?;
|
let client = self.make_client(outbox_relays).await?;
|
||||||
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 filter = Filter::new().author(pubkey).kind(Kind::Custom(10050));
|
||||||
let events = client.fetch_events(filter, Duration::from_secs(5)).await?;
|
let events = client.fetch_events(filter, Duration::from_secs(5)).await?;
|
||||||
|
|
||||||
@@ -195,37 +170,6 @@ 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(
|
async fn get_cached(
|
||||||
cache: &std::sync::Arc<Mutex<HashMap<String, CacheEntry>>>,
|
cache: &std::sync::Arc<Mutex<HashMap<String, CacheEntry>>>,
|
||||||
key: &str,
|
key: &str,
|
||||||
@@ -254,23 +198,3 @@ 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())),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
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 })
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use axum::extract::{Path, State};
|
||||||
|
|
||||||
|
use crate::api::{Api, AuthedPubkey};
|
||||||
|
use crate::web::{ApiResult, internal, not_found, ok};
|
||||||
|
|
||||||
|
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 tenant = api.get_tenant_or_404(&pubkey).await?;
|
||||||
|
|
||||||
|
let invoices = api
|
||||||
|
.stripe
|
||||||
|
.list_invoices(&tenant.stripe_customer_id)
|
||||||
|
.await
|
||||||
|
.map_err(internal)?;
|
||||||
|
ok(invoices)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_invoice(
|
||||||
|
State(api): State<Arc<Api>>,
|
||||||
|
AuthedPubkey(auth): AuthedPubkey,
|
||||||
|
Path(id): Path<String>,
|
||||||
|
) -> ApiResult {
|
||||||
|
let Some(invoice) = api.stripe.get_invoice(&id).await.map_err(internal)? else {
|
||||||
|
return Err(not_found("invoice not found"));
|
||||||
|
};
|
||||||
|
|
||||||
|
let Some(tenant) = api
|
||||||
|
.query
|
||||||
|
.get_tenant_by_stripe_customer_id(&invoice.customer)
|
||||||
|
.await
|
||||||
|
.map_err(internal)?
|
||||||
|
else {
|
||||||
|
return Err(not_found("invoice not found"));
|
||||||
|
};
|
||||||
|
|
||||||
|
api.require_admin_or_tenant(&auth, &tenant.pubkey)?;
|
||||||
|
|
||||||
|
let invoice = api
|
||||||
|
.billing
|
||||||
|
.reconcile_invoice(&invoice)
|
||||||
|
.await
|
||||||
|
.map_err(internal)?;
|
||||||
|
|
||||||
|
ok(invoice)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_lightning_invoice(
|
||||||
|
State(api): State<Arc<Api>>,
|
||||||
|
AuthedPubkey(auth): AuthedPubkey,
|
||||||
|
Path(id): Path<String>,
|
||||||
|
) -> ApiResult {
|
||||||
|
let Some(invoice) = api.stripe.get_invoice(&id).await.map_err(internal)? else {
|
||||||
|
return Err(not_found("invoice not found"));
|
||||||
|
};
|
||||||
|
|
||||||
|
let Some(tenant) = api
|
||||||
|
.query
|
||||||
|
.get_tenant_by_stripe_customer_id(&invoice.customer)
|
||||||
|
.await
|
||||||
|
.map_err(internal)?
|
||||||
|
else {
|
||||||
|
return Err(not_found("invoice not found"));
|
||||||
|
};
|
||||||
|
|
||||||
|
api.require_admin_or_tenant(&auth, &tenant.pubkey)?;
|
||||||
|
|
||||||
|
let invoice = api
|
||||||
|
.billing
|
||||||
|
.reconcile_invoice(&invoice)
|
||||||
|
.await
|
||||||
|
.map_err(internal)?;
|
||||||
|
|
||||||
|
let lightning_invoice = api
|
||||||
|
.billing
|
||||||
|
.ensure_lightning_invoice(&invoice.id, &tenant.pubkey, invoice.amount_due, &invoice.currency)
|
||||||
|
.await
|
||||||
|
.map_err(internal)?;
|
||||||
|
|
||||||
|
ok(serde_json::json!(lightning_invoice))
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
pub mod identity;
|
||||||
|
pub mod invoices;
|
||||||
|
pub mod plans;
|
||||||
|
pub mod relays;
|
||||||
|
pub mod stripe;
|
||||||
|
pub mod tenants;
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use axum::extract::{Path, State};
|
||||||
|
|
||||||
|
use crate::api::Api;
|
||||||
|
use crate::web::{ApiResult, not_found, ok};
|
||||||
|
|
||||||
|
pub async fn list_plans(State(api): State<Arc<Api>>) -> ApiResult {
|
||||||
|
ok(api.query.list_plans())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_plan(State(api): State<Arc<Api>>, Path(id): Path<String>) -> ApiResult {
|
||||||
|
match api.query.get_plan(&id) {
|
||||||
|
Some(plan) => ok(plan),
|
||||||
|
None => Err(not_found("plan not found")),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,315 @@
|
|||||||
|
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::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,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[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,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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 list_relays(
|
||||||
|
State(api): State<Arc<Api>>,
|
||||||
|
AuthedPubkey(auth): AuthedPubkey,
|
||||||
|
) -> ApiResult {
|
||||||
|
api.require_admin(&auth)?;
|
||||||
|
|
||||||
|
let relays = api.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 = api
|
||||||
|
.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 }))
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
schema: relay_id.clone(),
|
||||||
|
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(&api, relay)?;
|
||||||
|
|
||||||
|
api.command
|
||||||
|
.create_relay(&relay)
|
||||||
|
.await
|
||||||
|
.map_err(map_relay_write_error)?;
|
||||||
|
created(relay)
|
||||||
|
}
|
||||||
|
|
||||||
|
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(&api, relay)?;
|
||||||
|
|
||||||
|
let plan_changed = requested_plan
|
||||||
|
.as_deref()
|
||||||
|
.is_some_and(|requested| requested != current_plan);
|
||||||
|
|
||||||
|
if plan_changed {
|
||||||
|
let selected_plan = api
|
||||||
|
.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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
api.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"));
|
||||||
|
}
|
||||||
|
|
||||||
|
api.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"));
|
||||||
|
}
|
||||||
|
|
||||||
|
api.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(api: &Api, 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 = api
|
||||||
|
.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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,350 @@
|
|||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use anyhow::Result;
|
||||||
|
use axum::{
|
||||||
|
body::Bytes,
|
||||||
|
extract::{Path, Query as QueryParams, State},
|
||||||
|
http::HeaderMap,
|
||||||
|
};
|
||||||
|
use serde::Deserialize;
|
||||||
|
|
||||||
|
use crate::api::{Api, AuthedPubkey};
|
||||||
|
use crate::models::{RELAY_STATUS_ACTIVE, RELAY_STATUS_DELINQUENT};
|
||||||
|
use crate::web::{ApiResult, bad_request, internal, ok};
|
||||||
|
|
||||||
|
const MANUAL_LIGHTNING_PAYMENT_DM: &str = "Payment is due for your relay subscription. Please visit the application to complete a manual Lightning payment.";
|
||||||
|
const NWC_ERROR_DM_PREFIX: &str = "NWC auto-payment failed:";
|
||||||
|
const NWC_ERROR_DM_MAX_CHARS: usize = 240;
|
||||||
|
|
||||||
|
#[derive(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>,
|
||||||
|
QueryParams(params): QueryParams<StripeSessionParams>,
|
||||||
|
) -> ApiResult {
|
||||||
|
api.require_admin_or_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 }))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stripe webhook endpoint. Authenticated via `Stripe-Signature` verification
|
||||||
|
/// on the raw body, not via NIP-98, so it does not use `AuthedPubkey`.
|
||||||
|
pub async fn stripe_webhook(
|
||||||
|
State(api): State<Arc<Api>>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
body: Bytes,
|
||||||
|
) -> ApiResult {
|
||||||
|
let signature = headers
|
||||||
|
.get("Stripe-Signature")
|
||||||
|
.and_then(|v| v.to_str().ok())
|
||||||
|
.unwrap_or("");
|
||||||
|
|
||||||
|
let payload = std::str::from_utf8(&body)
|
||||||
|
.map_err(|_| bad_request("bad-request", "invalid payload"))?;
|
||||||
|
|
||||||
|
handle_webhook(&api, payload, signature)
|
||||||
|
.await
|
||||||
|
.map_err(|e| bad_request("webhook-error", &e.to_string()))?;
|
||||||
|
ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Webhook event handlers ---
|
||||||
|
//
|
||||||
|
// These translate verified Stripe events into domain actions. The Stripe HTTP
|
||||||
|
// calls and Lightning/NWC payment orchestration they invoke live in
|
||||||
|
// [`crate::stripe`] and [`crate::billing`] respectively.
|
||||||
|
|
||||||
|
async fn handle_webhook(api: &Api, payload: &str, signature: &str) -> Result<()> {
|
||||||
|
let event = api.stripe.get_webhook_event(payload, signature)?;
|
||||||
|
let obj = &event.data.object;
|
||||||
|
|
||||||
|
match event.event_type.as_str() {
|
||||||
|
"invoice.created" => {
|
||||||
|
let customer = obj["customer"].as_str().unwrap_or_default();
|
||||||
|
let amount_due = obj["amount_due"].as_i64().unwrap_or(0);
|
||||||
|
let currency = obj["currency"].as_str().unwrap_or("usd");
|
||||||
|
let stripe_invoice_id = obj["id"].as_str().unwrap_or_default();
|
||||||
|
handle_invoice_created(api, customer, amount_due, currency, stripe_invoice_id).await?;
|
||||||
|
}
|
||||||
|
"invoice.paid" => {
|
||||||
|
let customer = obj["customer"].as_str().unwrap_or_default();
|
||||||
|
handle_invoice_paid(api, customer).await?;
|
||||||
|
}
|
||||||
|
"invoice.payment_failed" => {
|
||||||
|
let customer = obj["customer"].as_str().unwrap_or_default();
|
||||||
|
handle_invoice_payment_failed(api, customer).await?;
|
||||||
|
}
|
||||||
|
"invoice.overdue" => {
|
||||||
|
let customer = obj["customer"].as_str().unwrap_or_default();
|
||||||
|
handle_invoice_overdue(api, customer).await?;
|
||||||
|
}
|
||||||
|
"customer.subscription.updated" => {
|
||||||
|
let customer = obj["customer"].as_str().unwrap_or_default();
|
||||||
|
let status = obj["status"].as_str().unwrap_or_default();
|
||||||
|
handle_subscription_updated(api, customer, status).await?;
|
||||||
|
}
|
||||||
|
"customer.subscription.deleted" => {
|
||||||
|
let customer = obj["customer"].as_str().unwrap_or_default();
|
||||||
|
handle_subscription_deleted(api, customer).await?;
|
||||||
|
}
|
||||||
|
"payment_method.attached" => {
|
||||||
|
let customer = obj["customer"].as_str().unwrap_or_default();
|
||||||
|
handle_payment_method_attached(api, customer).await?;
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle_invoice_created(
|
||||||
|
api: &Api,
|
||||||
|
stripe_customer_id: &str,
|
||||||
|
amount_due: i64,
|
||||||
|
currency: &str,
|
||||||
|
stripe_invoice_id: &str,
|
||||||
|
) -> Result<()> {
|
||||||
|
if amount_due == 0 {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(tenant) = api
|
||||||
|
.query
|
||||||
|
.get_tenant_by_stripe_customer_id(stripe_customer_id)
|
||||||
|
.await?
|
||||||
|
else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
|
||||||
|
let invoice = api
|
||||||
|
.billing
|
||||||
|
.ensure_lightning_invoice(stripe_invoice_id, &tenant.pubkey, amount_due, currency)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let mut nwc_error_for_dm: Option<String> = None;
|
||||||
|
|
||||||
|
// 1. NWC auto-pay: if the tenant has a nwc_url
|
||||||
|
if !tenant.nwc_url.is_empty() {
|
||||||
|
match api.billing.pay_invoice_nwc(&tenant, &invoice).await {
|
||||||
|
Ok(()) => return Ok(()),
|
||||||
|
Err(e) => {
|
||||||
|
let error_msg = format!("{e}");
|
||||||
|
api.command
|
||||||
|
.set_tenant_nwc_error(&tenant.pubkey, &error_msg)
|
||||||
|
.await?;
|
||||||
|
tracing::warn!(
|
||||||
|
error = %e,
|
||||||
|
tenant_pubkey = %tenant.pubkey,
|
||||||
|
stripe_customer_id,
|
||||||
|
stripe_invoice_id,
|
||||||
|
"nwc auto-payment failed for invoice.created"
|
||||||
|
);
|
||||||
|
nwc_error_for_dm = summarize_nwc_error_for_dm(&error_msg);
|
||||||
|
// Fall through to card / manual payment
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Card on file: if the tenant has a payment method, Stripe charges automatically
|
||||||
|
if api
|
||||||
|
.stripe
|
||||||
|
.has_payment_method(&tenant.stripe_customer_id)
|
||||||
|
.await?
|
||||||
|
{
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Manual payment: send a DM
|
||||||
|
let dm_message = manual_lightning_payment_dm(nwc_error_for_dm.as_deref());
|
||||||
|
api.robot.send_dm(&tenant.pubkey, &dm_message).await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle_invoice_paid(api: &Api, stripe_customer_id: &str) -> Result<()> {
|
||||||
|
let Some(tenant) = api
|
||||||
|
.query
|
||||||
|
.get_tenant_by_stripe_customer_id(stripe_customer_id)
|
||||||
|
.await?
|
||||||
|
else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
|
||||||
|
if tenant.past_due_at.is_some() {
|
||||||
|
api.command.clear_tenant_past_due(&tenant.pubkey).await?;
|
||||||
|
|
||||||
|
let relays = api.query.list_relays_for_tenant(&tenant.pubkey).await?;
|
||||||
|
for relay in relays {
|
||||||
|
if relay.status == RELAY_STATUS_DELINQUENT && api.query.is_paid_plan(&relay.plan) {
|
||||||
|
api.command.activate_relay(&relay).await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle_invoice_payment_failed(api: &Api, stripe_customer_id: &str) -> Result<()> {
|
||||||
|
let Some(tenant) = api
|
||||||
|
.query
|
||||||
|
.get_tenant_by_stripe_customer_id(stripe_customer_id)
|
||||||
|
.await?
|
||||||
|
else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
|
||||||
|
if tenant.past_due_at.is_none() {
|
||||||
|
api.command.set_tenant_past_due(&tenant.pubkey).await?;
|
||||||
|
api.robot
|
||||||
|
.send_dm(
|
||||||
|
&tenant.pubkey,
|
||||||
|
"Your payment has failed. Your relays may be deactivated if not resolved within a week.",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle_subscription_updated(
|
||||||
|
api: &Api,
|
||||||
|
stripe_customer_id: &str,
|
||||||
|
status: &str,
|
||||||
|
) -> Result<()> {
|
||||||
|
if status != "canceled" && status != "unpaid" {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(tenant) = api
|
||||||
|
.query
|
||||||
|
.get_tenant_by_stripe_customer_id(stripe_customer_id)
|
||||||
|
.await?
|
||||||
|
else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
|
||||||
|
api.command
|
||||||
|
.clear_tenant_subscription(&tenant.pubkey)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let relays = api.query.list_relays_for_tenant(&tenant.pubkey).await?;
|
||||||
|
for relay in relays {
|
||||||
|
if relay.status == RELAY_STATUS_ACTIVE && api.query.is_paid_plan(&relay.plan) {
|
||||||
|
api.command.mark_relay_delinquent(&relay).await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle_subscription_deleted(api: &Api, stripe_customer_id: &str) -> Result<()> {
|
||||||
|
let Some(tenant) = api
|
||||||
|
.query
|
||||||
|
.get_tenant_by_stripe_customer_id(stripe_customer_id)
|
||||||
|
.await?
|
||||||
|
else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
|
||||||
|
api.command
|
||||||
|
.clear_tenant_subscription(&tenant.pubkey)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle_invoice_overdue(api: &Api, stripe_customer_id: &str) -> Result<()> {
|
||||||
|
let Some(tenant) = api
|
||||||
|
.query
|
||||||
|
.get_tenant_by_stripe_customer_id(stripe_customer_id)
|
||||||
|
.await?
|
||||||
|
else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
|
||||||
|
let relays = api.query.list_relays_for_tenant(&tenant.pubkey).await?;
|
||||||
|
for relay in relays {
|
||||||
|
if relay.status == RELAY_STATUS_ACTIVE && api.query.is_paid_plan(&relay.plan) {
|
||||||
|
api.command.mark_relay_delinquent(&relay).await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
api.robot
|
||||||
|
.send_dm(
|
||||||
|
&tenant.pubkey,
|
||||||
|
"Your paid relays have been deactivated due to non-payment.",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle_payment_method_attached(api: &Api, stripe_customer_id: &str) -> Result<()> {
|
||||||
|
if stripe_customer_id.is_empty() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(tenant) = api
|
||||||
|
.query
|
||||||
|
.get_tenant_by_stripe_customer_id(stripe_customer_id)
|
||||||
|
.await?
|
||||||
|
else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
|
||||||
|
let invoices = api
|
||||||
|
.stripe
|
||||||
|
.list_invoices(&tenant.stripe_customer_id)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
for invoice in &invoices {
|
||||||
|
if invoice.status != "open" || invoice.amount_due == 0 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if let Err(error) = api.stripe.pay_invoice(&invoice.id).await {
|
||||||
|
tracing::error!(
|
||||||
|
error = %error,
|
||||||
|
stripe_invoice_id = %invoice.id,
|
||||||
|
"failed to retry card payment for outstanding invoice"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn summarize_nwc_error_for_dm(error: &str) -> Option<String> {
|
||||||
|
let normalized = error.split_whitespace().collect::<Vec<_>>().join(" ");
|
||||||
|
if normalized.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
if normalized.chars().count() <= NWC_ERROR_DM_MAX_CHARS {
|
||||||
|
return Some(normalized);
|
||||||
|
}
|
||||||
|
|
||||||
|
let prefix_len = NWC_ERROR_DM_MAX_CHARS.saturating_sub(3);
|
||||||
|
let mut truncated = normalized.chars().take(prefix_len).collect::<String>();
|
||||||
|
truncated.push_str("...");
|
||||||
|
Some(truncated)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn manual_lightning_payment_dm(nwc_error: Option<&str>) -> String {
|
||||||
|
match nwc_error {
|
||||||
|
Some(error) if !error.is_empty() => {
|
||||||
|
format!("{MANUAL_LIGHTNING_PAYMENT_DM}\n\n{NWC_ERROR_DM_PREFIX} {error}")
|
||||||
|
}
|
||||||
|
_ => MANUAL_LIGHTNING_PAYMENT_DM.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use axum::{
|
||||||
|
Json,
|
||||||
|
extract::{Path, 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};
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct TenantResponse {
|
||||||
|
pub pubkey: String,
|
||||||
|
pub nwc_is_set: bool,
|
||||||
|
pub nwc_error: Option<String>,
|
||||||
|
pub created_at: i64,
|
||||||
|
pub stripe_customer_id: String,
|
||||||
|
pub stripe_subscription_id: Option<String>,
|
||||||
|
pub past_due_at: Option<i64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
stripe_customer_id: t.stripe_customer_id,
|
||||||
|
stripe_subscription_id: t.stripe_subscription_id,
|
||||||
|
past_due_at: t.past_due_at,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct UpdateTenantRequest {
|
||||||
|
pub nwc_url: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn list_tenants(
|
||||||
|
State(api): State<Arc<Api>>,
|
||||||
|
AuthedPubkey(auth): AuthedPubkey,
|
||||||
|
) -> ApiResult {
|
||||||
|
api.require_admin(&auth)?;
|
||||||
|
|
||||||
|
let tenants = api.query.list_tenants().await.map_err(internal)?;
|
||||||
|
ok(tenants
|
||||||
|
.into_iter()
|
||||||
|
.map(TenantResponse::from)
|
||||||
|
.collect::<Vec<_>>())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Creates the tenant row for the calling pubkey. Idempotent: if the tenant
|
||||||
|
/// already exists (including a unique-constraint race) we return the existing
|
||||||
|
/// row.
|
||||||
|
pub async fn create_tenant(
|
||||||
|
State(api): State<Arc<Api>>,
|
||||||
|
AuthedPubkey(pubkey): AuthedPubkey,
|
||||||
|
) -> ApiResult {
|
||||||
|
if let Some(t) = api.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 api.command.create_tenant(&tenant).await {
|
||||||
|
Ok(()) => ok(TenantResponse::from(tenant)),
|
||||||
|
Err(e) if matches!(map_unique_error(&e), Some("pubkey-exists")) => {
|
||||||
|
match api.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))
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = api.env.encrypt(&nwc_url).map_err(internal)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
api.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 = api
|
||||||
|
.query
|
||||||
|
.list_relays_for_tenant(&pubkey)
|
||||||
|
.await
|
||||||
|
.map_err(internal)?;
|
||||||
|
ok(relays)
|
||||||
|
}
|
||||||
@@ -0,0 +1,412 @@
|
|||||||
|
//! 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 std::collections::BTreeMap;
|
||||||
|
|
||||||
|
use crate::env::Env;
|
||||||
|
|
||||||
|
const STRIPE_API: &str = "https://api.stripe.com/v1";
|
||||||
|
|
||||||
|
// Webhooks
|
||||||
|
|
||||||
|
const WEBHOOK_TOLERANCE_SECS: i64 = 300;
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
pub struct StripeWebhookEvent {
|
||||||
|
#[serde(rename = "type")]
|
||||||
|
pub event_type: String,
|
||||||
|
pub data: StripeWebhookEventData,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
pub struct StripeWebhookEventData {
|
||||||
|
pub object: serde_json::Value,
|
||||||
|
}
|
||||||
|
|
||||||
|
// API return types
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
pub struct StripeSubscription {
|
||||||
|
pub id: String,
|
||||||
|
pub status: String,
|
||||||
|
#[serde(deserialize_with = "deserialize_list")]
|
||||||
|
pub items: Vec<StripeSubscriptionItem>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
pub struct StripeSubscriptionItem {
|
||||||
|
pub id: String,
|
||||||
|
pub price: StripePrice,
|
||||||
|
#[serde(default = "default_quantity")]
|
||||||
|
pub quantity: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
pub struct StripePrice {
|
||||||
|
pub id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize, serde::Serialize, Clone)]
|
||||||
|
pub struct StripeInvoice {
|
||||||
|
pub id: String,
|
||||||
|
pub customer: String,
|
||||||
|
pub status: String,
|
||||||
|
pub amount_due: i64,
|
||||||
|
pub currency: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
struct StripeList<T> {
|
||||||
|
data: Vec<T>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn deserialize_list<'de, D, T>(deserializer: D) -> Result<Vec<T>, D::Error>
|
||||||
|
where
|
||||||
|
D: serde::Deserializer<'de>,
|
||||||
|
T: serde::Deserialize<'de>,
|
||||||
|
{
|
||||||
|
Ok(<StripeList<T> as serde::Deserialize>::deserialize(deserializer)?.data)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_quantity() -> i64 {
|
||||||
|
1
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stripe struct and impl
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct Stripe {
|
||||||
|
env: Env,
|
||||||
|
http: reqwest::Client,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Stripe {
|
||||||
|
pub fn new(env: &Env) -> Self {
|
||||||
|
Self {
|
||||||
|
env: env.clone(),
|
||||||
|
http: reqwest::Client::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Request helpers ---
|
||||||
|
|
||||||
|
fn get(&self, path: &str) -> reqwest::RequestBuilder {
|
||||||
|
self.http
|
||||||
|
.get(format!("{STRIPE_API}{path}"))
|
||||||
|
.bearer_auth(&self.env.stripe_secret_key)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn post(&self, path: &str) -> reqwest::RequestBuilder {
|
||||||
|
self.http
|
||||||
|
.post(format!("{STRIPE_API}{path}"))
|
||||||
|
.bearer_auth(&self.env.stripe_secret_key)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn delete(&self, path: &str) -> reqwest::RequestBuilder {
|
||||||
|
self.http
|
||||||
|
.delete(format!("{STRIPE_API}{path}"))
|
||||||
|
.bearer_auth(&self.env.stripe_secret_key)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn idempotency_key(&self, parts: &[&str]) -> String {
|
||||||
|
let mut mac = Hmac::<Sha256>::new_from_slice(self.env.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())
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Subscriptions ---
|
||||||
|
|
||||||
|
pub async fn get_subscription(
|
||||||
|
&self,
|
||||||
|
subscription_id: &str,
|
||||||
|
) -> Result<Option<StripeSubscription>> {
|
||||||
|
let body = self
|
||||||
|
.get(&format!("/subscriptions/{subscription_id}"))
|
||||||
|
.send_optional_json()
|
||||||
|
.await?;
|
||||||
|
body.map(serde_json::from_value)
|
||||||
|
.transpose()
|
||||||
|
.map_err(Into::into)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stripe requires at least one item to create a subscription, so the desired
|
||||||
|
/// items are sent inline here; [`crate::billing`] reconciles from there.
|
||||||
|
pub async fn create_subscription(
|
||||||
|
&self,
|
||||||
|
customer_id: &str,
|
||||||
|
items: &BTreeMap<String, i64>,
|
||||||
|
) -> Result<StripeSubscription> {
|
||||||
|
let mut form: Vec<(String, String)> = vec![
|
||||||
|
("customer".to_string(), customer_id.to_string()),
|
||||||
|
(
|
||||||
|
"collection_method".to_string(),
|
||||||
|
"charge_automatically".to_string(),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
let mut key_parts: Vec<String> =
|
||||||
|
vec!["create_subscription".to_string(), customer_id.to_string()];
|
||||||
|
for (index, (price_id, quantity)) in items.iter().enumerate() {
|
||||||
|
form.push((format!("items[{index}][price]"), price_id.clone()));
|
||||||
|
form.push((format!("items[{index}][quantity]"), quantity.to_string()));
|
||||||
|
key_parts.push(format!("{price_id}={quantity}"));
|
||||||
|
}
|
||||||
|
let key_refs: Vec<&str> = key_parts.iter().map(String::as_str).collect();
|
||||||
|
|
||||||
|
Ok(self
|
||||||
|
.post("/subscriptions")
|
||||||
|
.header("Idempotency-Key", self.idempotency_key(&key_refs))
|
||||||
|
.form(&form)
|
||||||
|
.send_ok()
|
||||||
|
.await?
|
||||||
|
.json()
|
||||||
|
.await?)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn create_subscription_item(
|
||||||
|
&self,
|
||||||
|
subscription_id: &str,
|
||||||
|
price_id: &str,
|
||||||
|
quantity: i64,
|
||||||
|
) -> Result<()> {
|
||||||
|
let quantity = quantity.to_string();
|
||||||
|
self.post("/subscription_items")
|
||||||
|
.header(
|
||||||
|
"Idempotency-Key",
|
||||||
|
self.idempotency_key(&["create_subscription_item", subscription_id, price_id]),
|
||||||
|
)
|
||||||
|
.form(&[
|
||||||
|
("subscription", subscription_id),
|
||||||
|
("price", price_id),
|
||||||
|
("quantity", quantity.as_str()),
|
||||||
|
])
|
||||||
|
.send_ok()
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn set_subscription_item_quantity(&self, item_id: &str, quantity: i64) -> Result<()> {
|
||||||
|
self.post(&format!("/subscription_items/{item_id}"))
|
||||||
|
.form(&[("quantity", quantity.to_string())])
|
||||||
|
.send_ok()
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn delete_subscription_item(&self, item_id: &str) -> Result<()> {
|
||||||
|
self.delete(&format!("/subscription_items/{item_id}"))
|
||||||
|
.send_ok()
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn cancel_subscription(&self, subscription_id: &str) -> Result<()> {
|
||||||
|
self.delete(&format!("/subscriptions/{subscription_id}"))
|
||||||
|
.send_ok()
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Invoices ---
|
||||||
|
|
||||||
|
pub async fn list_invoices(&self, customer_id: &str) -> Result<Vec<StripeInvoice>> {
|
||||||
|
let list: StripeList<StripeInvoice> = self
|
||||||
|
.get("/invoices")
|
||||||
|
.query(&[("customer", customer_id)])
|
||||||
|
.send_ok()
|
||||||
|
.await?
|
||||||
|
.json()
|
||||||
|
.await?;
|
||||||
|
Ok(list.data)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_invoice(&self, invoice_id: &str) -> Result<Option<StripeInvoice>> {
|
||||||
|
let body = self
|
||||||
|
.get(&format!("/invoices/{invoice_id}"))
|
||||||
|
.send_optional_json()
|
||||||
|
.await?;
|
||||||
|
body.map(serde_json::from_value)
|
||||||
|
.transpose()
|
||||||
|
.map_err(Into::into)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn pay_invoice(&self, invoice_id: &str) -> Result<()> {
|
||||||
|
self.post(&format!("/invoices/{invoice_id}/pay"))
|
||||||
|
.header(
|
||||||
|
"Idempotency-Key",
|
||||||
|
self.idempotency_key(&["pay_invoice", invoice_id]),
|
||||||
|
)
|
||||||
|
.send_ok()
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn pay_invoice_out_of_band(&self, invoice_id: &str) -> Result<()> {
|
||||||
|
self.post(&format!("/invoices/{invoice_id}/pay"))
|
||||||
|
.header(
|
||||||
|
"Idempotency-Key",
|
||||||
|
self.idempotency_key(&["pay_invoice_oob", invoice_id]),
|
||||||
|
)
|
||||||
|
.form(&[("paid_out_of_band", "true")])
|
||||||
|
.send_ok()
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Payment methods ---
|
||||||
|
|
||||||
|
pub async fn has_payment_method(&self, customer_id: &str) -> Result<bool> {
|
||||||
|
let body = self
|
||||||
|
.get("/payment_methods")
|
||||||
|
.query(&[("customer", customer_id), ("type", "card")])
|
||||||
|
.send_json()
|
||||||
|
.await?;
|
||||||
|
Ok(body["data"].as_array().is_some_and(|a| !a.is_empty()))
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 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"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Webhooks ---
|
||||||
|
|
||||||
|
pub fn get_webhook_event(&self, payload: &str, signature: &str) -> Result<StripeWebhookEvent> {
|
||||||
|
let mut timestamp = None;
|
||||||
|
let mut sig = None;
|
||||||
|
for part in signature.split(',') {
|
||||||
|
if let Some(t) = part.strip_prefix("t=") {
|
||||||
|
timestamp = Some(t);
|
||||||
|
} else if let Some(v) = part.strip_prefix("v1=") {
|
||||||
|
sig = Some(v);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let timestamp = timestamp.ok_or_else(|| anyhow!("missing webhook timestamp"))?;
|
||||||
|
let signature = sig.ok_or_else(|| anyhow!("missing webhook signature"))?;
|
||||||
|
|
||||||
|
let signed_payload = format!("{timestamp}.{payload}");
|
||||||
|
let mut mac = Hmac::<Sha256>::new_from_slice(self.env.stripe_webhook_secret.as_bytes())
|
||||||
|
.map_err(|e| anyhow!("invalid webhook secret: {e}"))?;
|
||||||
|
mac.update(signed_payload.as_bytes());
|
||||||
|
let expected = hex::encode(mac.finalize().into_bytes());
|
||||||
|
if expected != signature {
|
||||||
|
return Err(anyhow!("webhook signature mismatch"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let ts: i64 = timestamp
|
||||||
|
.parse()
|
||||||
|
.map_err(|_| anyhow!("bad webhook timestamp"))?;
|
||||||
|
let now = chrono::Utc::now().timestamp();
|
||||||
|
if (now - ts).abs() > WEBHOOK_TOLERANCE_SECS {
|
||||||
|
return Err(anyhow!("webhook timestamp outside tolerance"));
|
||||||
|
}
|
||||||
|
Ok(serde_json::from_str(payload)?)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
trait StripeRequest {
|
||||||
|
async fn send_ok(self) -> Result<reqwest::Response>;
|
||||||
|
async fn send_json(self) -> Result<serde_json::Value>;
|
||||||
|
async fn send_optional_json(self) -> Result<Option<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?)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn send_optional_json(self) -> Result<Option<serde_json::Value>> {
|
||||||
|
let resp = self.send().await?;
|
||||||
|
if resp.status() == reqwest::StatusCode::NOT_FOUND {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
Ok(Some(error_for_status(resp).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}"
|
||||||
|
))
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
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> {
|
||||||
|
let url = url
|
||||||
|
.parse::<NostrWalletConnectURI>()
|
||||||
|
.map_err(|_| anyhow!("invalid NWC URL"))?;
|
||||||
|
Ok(Self { url })
|
||||||
|
}
|
||||||
|
|
||||||
|
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())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
//! 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
|
||||||
|
}
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
@@ -40,7 +40,7 @@ export default function PaymentSetup(props: PaymentSetupProps) {
|
|||||||
setRedirecting(true)
|
setRedirecting(true)
|
||||||
setError("")
|
setError("")
|
||||||
try {
|
try {
|
||||||
const { url } = await createPortalSession(account()!.pubkey)
|
const { url } = await createPortalSession(account()!.pubkey, window.location.href)
|
||||||
window.location.href = url
|
window.location.href = url
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(e instanceof Error ? e.message : "Failed to open billing portal")
|
setError(e instanceof Error ? e.message : "Failed to open billing portal")
|
||||||
|
|||||||
@@ -51,7 +51,6 @@ export type Relay = {
|
|||||||
plan: PlanId
|
plan: PlanId
|
||||||
status: string
|
status: string
|
||||||
sync_error: string
|
sync_error: string
|
||||||
stripe_subscription_item_id: string | null
|
|
||||||
synced: number
|
synced: number
|
||||||
info_name: string
|
info_name: string
|
||||||
info_icon: string
|
info_icon: string
|
||||||
@@ -253,8 +252,9 @@ export function reactivateRelay(id: string) {
|
|||||||
return callApi<undefined, void>("POST", `/relays/${id}/reactivate`)
|
return callApi<undefined, void>("POST", `/relays/${id}/reactivate`)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createPortalSession(pubkey: string) {
|
export function createPortalSession(pubkey: string, returnUrl?: string) {
|
||||||
return callApi<undefined, { url: string }>("GET", `/tenants/${pubkey}/stripe/session`)
|
const query = returnUrl ? `?return_url=${encodeURIComponent(returnUrl)}` : ""
|
||||||
|
return callApi<undefined, { url: string }>("GET", `/tenants/${pubkey}/stripe/session${query}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getInvoiceBolt11(invoiceId: string) {
|
export function getInvoiceBolt11(invoiceId: string) {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { createSignal } from "solid-js"
|
import { createSignal } from "solid-js"
|
||||||
import { updateRelayById, deactivateRelayById, reactivateRelayById, getLatestOpenInvoice, type Relay } from "@/lib/hooks"
|
import { updateRelayById, deactivateRelayById, reactivateRelayById, getLatestOpenInvoice, tenantNeedsPaymentSetup, type Relay } from "@/lib/hooks"
|
||||||
import { setToastMessage } from "@/components/Toast"
|
import { setToastMessage } from "@/components/Toast"
|
||||||
import type { Invoice, PlanId } from "@/lib/api"
|
import type { Invoice, PlanId } from "@/lib/api"
|
||||||
|
|
||||||
@@ -31,6 +31,7 @@ export default function useRelayToggles(
|
|||||||
) {
|
) {
|
||||||
const [busy, setBusy] = createSignal(false)
|
const [busy, setBusy] = createSignal(false)
|
||||||
const [pendingInvoice, setPendingInvoice] = createSignal<Invoice | undefined>()
|
const [pendingInvoice, setPendingInvoice] = createSignal<Invoice | undefined>()
|
||||||
|
const [pendingPaymentSetup, setPendingPaymentSetup] = createSignal(false)
|
||||||
|
|
||||||
async function updateRelay(next: Relay, previous: Relay) {
|
async function updateRelay(next: Relay, previous: Relay) {
|
||||||
mutate(next)
|
mutate(next)
|
||||||
@@ -101,8 +102,12 @@ export default function useRelayToggles(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (plan !== "free") {
|
if (plan !== "free") {
|
||||||
const invoice = await getLatestOpenInvoice()
|
const needsSetup = await tenantNeedsPaymentSetup()
|
||||||
if (invoice) setPendingInvoice(invoice)
|
if (needsSetup) {
|
||||||
|
const invoice = await getLatestOpenInvoice()
|
||||||
|
if (invoice) setPendingInvoice(invoice)
|
||||||
|
setPendingPaymentSetup(true)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -116,5 +121,5 @@ export default function useRelayToggles(
|
|||||||
onToggleLivekitSupport: () => toggle("livekit_enabled", relay()?.plan !== "free"),
|
onToggleLivekitSupport: () => toggle("livekit_enabled", relay()?.plan !== "free"),
|
||||||
}
|
}
|
||||||
|
|
||||||
return { busy, handleDeactivate, handleReactivate, handleUpdatePlan, pendingInvoice, clearPendingInvoice: () => setPendingInvoice(undefined), toggles }
|
return { busy, handleDeactivate, handleReactivate, handleUpdatePlan, pendingInvoice, clearPendingInvoice: () => setPendingInvoice(undefined), pendingPaymentSetup, clearPendingPaymentSetup: () => setPendingPaymentSetup(false), toggles }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ export default function Account() {
|
|||||||
async function openPortal() {
|
async function openPortal() {
|
||||||
setPortalLoading(true)
|
setPortalLoading(true)
|
||||||
try {
|
try {
|
||||||
const { url } = await createPortalSession(account()!.pubkey)
|
const { url } = await createPortalSession(account()!.pubkey, window.location.href)
|
||||||
window.location.href = url
|
window.location.href = url
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(e instanceof Error ? e.message : "Failed to open billing portal")
|
setError(e instanceof Error ? e.message : "Failed to open billing portal")
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useParams } from "@solidjs/router"
|
import { useParams } from "@solidjs/router"
|
||||||
import { createMemo, createResource, createSignal, Show } from "solid-js"
|
import { createEffect, createMemo, createResource, createSignal, Show } from "solid-js"
|
||||||
import BackLink from "@/components/BackLink"
|
import BackLink from "@/components/BackLink"
|
||||||
import PageContainer from "@/components/PageContainer"
|
import PageContainer from "@/components/PageContainer"
|
||||||
import PaymentDialog from "@/components/PaymentDialog"
|
import PaymentDialog from "@/components/PaymentDialog"
|
||||||
@@ -28,13 +28,20 @@ export default function RelayDetail() {
|
|||||||
})
|
})
|
||||||
const loading = useMinLoading(() => relay.loading && !relay())
|
const loading = useMinLoading(() => relay.loading && !relay())
|
||||||
const [activity] = useRelayActivity(relayId)
|
const [activity] = useRelayActivity(relayId)
|
||||||
const { busy, handleDeactivate, handleReactivate, handleUpdatePlan, pendingInvoice, clearPendingInvoice, toggles } = useRelayToggles(relayId, relay, { refetch, mutate })
|
const { busy, handleDeactivate, handleReactivate, handleUpdatePlan, pendingInvoice, clearPendingInvoice, pendingPaymentSetup, clearPendingPaymentSetup, toggles } = useRelayToggles(relayId, relay, { refetch, mutate })
|
||||||
|
|
||||||
const [tenant, { refetch: refetchTenant }] = useTenant()
|
const [tenant, { refetch: refetchTenant }] = useTenant()
|
||||||
const [paymentSetupOpen, setPaymentSetupOpen] = createSignal(false)
|
const [paymentSetupOpen, setPaymentSetupOpen] = createSignal(false)
|
||||||
const [invoiceDialogOpen, setInvoiceDialogOpen] = createSignal(false)
|
const [invoiceDialogOpen, setInvoiceDialogOpen] = createSignal(false)
|
||||||
const [paymentBannerDismissed, setPaymentBannerDismissed] = createSignal(false)
|
const [paymentBannerDismissed, setPaymentBannerDismissed] = createSignal(false)
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
if (pendingPaymentSetup() && !pendingInvoice()) {
|
||||||
|
setPaymentSetupOpen(true)
|
||||||
|
clearPendingPaymentSetup()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
const isPaidRelay = createMemo(() => {
|
const isPaidRelay = createMemo(() => {
|
||||||
const r = relay()
|
const r = relay()
|
||||||
if (!r) return false
|
if (!r) return false
|
||||||
|
|||||||
@@ -3,13 +3,15 @@ import { useNavigate } from "@solidjs/router"
|
|||||||
import BackLink from "@/components/BackLink"
|
import BackLink from "@/components/BackLink"
|
||||||
import PageContainer from "@/components/PageContainer"
|
import PageContainer from "@/components/PageContainer"
|
||||||
import PaymentDialog from "@/components/PaymentDialog"
|
import PaymentDialog from "@/components/PaymentDialog"
|
||||||
|
import PaymentSetup from "@/components/PaymentSetup"
|
||||||
import RelayForm, { type RelayFormValues } from "@/components/RelayForm"
|
import RelayForm, { type RelayFormValues } from "@/components/RelayForm"
|
||||||
import { createRelayForActiveTenant, getLatestOpenInvoice } from "@/lib/hooks"
|
import { createRelayForActiveTenant, getLatestOpenInvoice, tenantNeedsPaymentSetup } from "@/lib/hooks"
|
||||||
import type { Invoice } from "@/lib/api"
|
import type { Invoice } from "@/lib/api"
|
||||||
|
|
||||||
export default function RelayNew() {
|
export default function RelayNew() {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const [pendingInvoice, setPendingInvoice] = createSignal<Invoice | undefined>()
|
const [pendingInvoice, setPendingInvoice] = createSignal<Invoice | undefined>()
|
||||||
|
const [paymentSetupOpen, setPaymentSetupOpen] = createSignal(false)
|
||||||
let createdRelayId = ""
|
let createdRelayId = ""
|
||||||
|
|
||||||
async function handleSubmit(values: RelayFormValues) {
|
async function handleSubmit(values: RelayFormValues) {
|
||||||
@@ -17,9 +19,14 @@ export default function RelayNew() {
|
|||||||
createdRelayId = relay.id
|
createdRelayId = relay.id
|
||||||
|
|
||||||
if (values.plan !== "free") {
|
if (values.plan !== "free") {
|
||||||
const invoice = await getLatestOpenInvoice()
|
const needsSetup = await tenantNeedsPaymentSetup()
|
||||||
if (invoice) {
|
if (needsSetup) {
|
||||||
setPendingInvoice(invoice)
|
const invoice = await getLatestOpenInvoice()
|
||||||
|
if (invoice) {
|
||||||
|
setPendingInvoice(invoice)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setPaymentSetupOpen(true)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -27,8 +34,13 @@ export default function RelayNew() {
|
|||||||
navigate(`/relays/${relay.id}`)
|
navigate(`/relays/${relay.id}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleDialogClose() {
|
function handleInvoiceClose() {
|
||||||
setPendingInvoice(undefined)
|
setPendingInvoice(undefined)
|
||||||
|
setPaymentSetupOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSetupClose() {
|
||||||
|
setPaymentSetupOpen(false)
|
||||||
navigate(`/relays/${createdRelayId}`)
|
navigate(`/relays/${createdRelayId}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -47,10 +59,14 @@ export default function RelayNew() {
|
|||||||
<PaymentDialog
|
<PaymentDialog
|
||||||
invoice={inv()}
|
invoice={inv()}
|
||||||
open={true}
|
open={true}
|
||||||
onClose={handleDialogClose}
|
onClose={handleInvoiceClose}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Show>
|
</Show>
|
||||||
|
<PaymentSetup
|
||||||
|
open={paymentSetupOpen()}
|
||||||
|
onClose={handleSetupClose}
|
||||||
|
/>
|
||||||
</PageContainer>
|
</PageContainer>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,9 @@ dev:
|
|||||||
cd frontend && bun dev &
|
cd frontend && bun dev &
|
||||||
wait
|
wait
|
||||||
|
|
||||||
|
dev-backend:
|
||||||
|
cd backend && onchange src -ik -- bash -c 'RUST_LOG=backend=info cargo run'
|
||||||
|
|
||||||
dev-frontend:
|
dev-frontend:
|
||||||
cd frontend && bun run dev
|
cd frontend && bun run dev
|
||||||
|
|
||||||
@@ -27,7 +30,7 @@ build-backend:
|
|||||||
cd backend && cargo build
|
cd backend && cargo build
|
||||||
|
|
||||||
build-frontend:
|
build-frontend:
|
||||||
cd frontend && bun run build
|
cd frontend && bun i && bun run build
|
||||||
|
|
||||||
fmt: fmt-backend
|
fmt: fmt-backend
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user